History

Merge remote-tracking branch 'origin/main' into toolcall-parallel-reads

Rhys Sullivan committed 2 months ago

Commit b3a0ddb2 parents 6788da0 b81476d192 files · +10202 −989Browse files

Unified · Split

192 files
  1. .changeset/warm-stdio-mcp-pooling.md +0 −9
  2. RUNNING.md +28 −0
  3. apps/cli/CHANGELOG.md +45 −0
  4. apps/cli/package.json +1 −1
  5. apps/cloud/CHANGELOG.md +48 −0
  6. apps/cloud/package.json +1 −1
  7. apps/cloud/src/routeTree.gen.ts +29 −0
  8. apps/desktop/CHANGELOG.md +4 −0
  9. apps/desktop/package.json +1 −1
  10. apps/desktop/src/main/index.ts +7 −1
  11. apps/desktop/src/main/sidecar.ts +14 −2
  12. apps/host-cloudflare/web/routeTree.gen.ts +25 −0
  13. apps/host-selfhost/CHANGELOG.md +42 −0
  14. apps/host-selfhost/package.json +1 −1
  15. apps/host-selfhost/web/routeTree.gen.ts +25 −0
  16. apps/local/CHANGELOG.md +60 −0
  17. apps/local/package.json +2 −2
  18. apps/local/src/oauth-result-store.test.ts +222 −0
  19. apps/local/src/oauth-result-store.ts +83 −1
  20. apps/local/src/serve.test.ts +124 −0
  21. apps/local/src/serve.ts +18 −2
  22. bun.lock +58 −32
  23. e2e/CHANGELOG.md +24 −0
  24. e2e/cloud/frontend-error-reporting.test.ts +3 −1
  25. e2e/local/codex-plugins.test.ts +15 −2
  26. e2e/local/oauth-await-longpoll.test.ts +265 −0
  27. e2e/package.json +1 −1
  28. e2e/scenarios/auth-methods-ui.test.ts +1 −1
  29. e2e/scenarios/first-party-oauth.test.ts +4 −29
  30. e2e/scenarios/google-disabled-api.test.ts +9 −12
  31. e2e/scenarios/google-health-checks.test.ts +29 −10
  32. e2e/scenarios/google-photos-preset-ui.test.ts +98 −23
  33. e2e/scenarios/integrations-catalog-search.test.ts +103 −64
  34. e2e/scenarios/mcp-catalog-sync-ui.test.ts +1 −1
  35. e2e/scenarios/mcp-execute.test.ts +144 −1
  36. e2e/scenarios/provider-plugins-ui.test.ts +85 −46
  37. e2e/scenarios/run-panel-auto-approve.test.ts +17 −1
  38. e2e/selfhost/auth-methods-ui.test.ts +1 −1
  39. e2e/selfhost/detected-auth-immutable-ui.test.ts +15 −14
  40. e2e/selfhost/mcp-auth-required-add.test.ts +1 −1
  41. e2e/selfhost/posthog-mcp-oauth.test.ts +1 −1
  42. examples/all-plugins/CHANGELOG.md +28 −0
  43. examples/all-plugins/package.json +1 −1
  44. examples/docs-sdk-quickstart/CHANGELOG.md +16 −0
  45. examples/docs-sdk-quickstart/package.json +1 −1
  46. package.json +1 −0
  47. packages/app/src/routeTree.gen.ts +23 −0
  48. packages/app/src/web/shell.tsx +6 −0
  49. packages/core/analytics/CHANGELOG.md +14 −0
  50. packages/core/analytics/package.json +1 −1
  51. packages/core/api/CHANGELOG.md +18 −0
  52. packages/core/api/package.json +1 −1
  53. packages/core/cli/CHANGELOG.md +14 −0
  54. packages/core/cli/package.json +1 −1
  55. packages/core/config/CHANGELOG.md +14 −0
  56. packages/core/config/package.json +1 −1
  57. packages/core/execution/CHANGELOG.md +20 −0
  58. packages/core/execution/package.json +1 −1
  59. packages/core/execution/src/engine.test.ts +137 −1
  60. packages/core/execution/src/engine.ts +27 −8
  61. packages/core/sdk/CHANGELOG.md +4 −0
  62. packages/core/sdk/package.json +1 −1
  63. packages/core/sdk/src/client.ts +60 −0
  64. packages/core/sdk/src/plugin.ts +4 −0
  65. packages/core/vite-plugin/CHANGELOG.md +14 −0
  66. packages/core/vite-plugin/package.json +1 −1
  67. packages/hosts/cloudflare/CHANGELOG.md +20 −0
  68. packages/hosts/cloudflare/package.json +1 −1
  69. packages/hosts/mcp-apps-shell/CHANGELOG.md +16 −0
  70. packages/hosts/mcp-apps-shell/package.json +1 −1
  71. packages/hosts/mcp/src/tool-server.test.ts +61 −0
  72. packages/kernel/core/CHANGELOG.md +4 −0
  73. packages/kernel/core/package.json +1 −1
  74. packages/kernel/runtime-quickjs/CHANGELOG.md +14 −0
  75. packages/kernel/runtime-quickjs/package.json +1 −1
  76. packages/kernel/runtime-workerd-subprocess/CHANGELOG.md +14 −0
  77. packages/kernel/runtime-workerd-subprocess/package.json +1 −1
  78. packages/onboarding-demo/CHANGELOG.md +11 −0
  79. packages/onboarding-demo/README.md +53 −0
  80. packages/onboarding-demo/index.html +18 −0
  81. packages/onboarding-demo/package.json +32 −0
  82. packages/onboarding-demo/src/app.tsx +202 −0
  83. packages/onboarding-demo/src/catalog.ts +163 −0
  84. packages/onboarding-demo/src/fixtures.ts +240 −0
  85. packages/onboarding-demo/src/flow.ts +111 −0
  86. packages/onboarding-demo/src/main.tsx +12 −0
  87. packages/onboarding-demo/src/next-flow.tsx +304 −0
  88. packages/onboarding-demo/src/screens/add-account-modal.tsx +318 −0
  89. packages/onboarding-demo/src/screens/add-openapi-page.tsx +261 −0
  90. packages/onboarding-demo/src/screens/connect-dialog.tsx +279 −0
  91. packages/onboarding-demo/src/screens/integration-detail.tsx +105 −0
  92. packages/onboarding-demo/src/screens/integrations-page.tsx +142 −0
  93. packages/onboarding-demo/src/screens/next/authenticate.tsx +229 −0
  94. packages/onboarding-demo/src/screens/next/browse.tsx +234 −0
  95. packages/onboarding-demo/src/screens/next/custom.tsx +242 −0
  96. packages/onboarding-demo/src/screens/next/integration-detail.tsx +209 −0
  97. packages/onboarding-demo/src/shell.tsx +97 −0
  98. packages/onboarding-demo/src/step-state.ts +45 −0
  99. packages/onboarding-demo/src/styles.css +5 −0
  100. packages/onboarding-demo/tsconfig.json +15 −0
  101. packages/onboarding-demo/vite.config.ts +12 −0
  102. packages/plugins/desktop-settings/CHANGELOG.md +14 −0
  103. packages/plugins/desktop-settings/package.json +1 −1
  104. packages/plugins/encrypted-secrets/CHANGELOG.md +14 −0
  105. packages/plugins/encrypted-secrets/package.json +1 −1
  106. packages/plugins/example/CHANGELOG.md +14 −0
  107. packages/plugins/example/package.json +1 −1
  108. packages/plugins/file-secrets/CHANGELOG.md +14 −0
  109. packages/plugins/file-secrets/package.json +1 −1
  110. packages/plugins/graphql/CHANGELOG.md +20 −0
  111. packages/plugins/graphql/package.json +1 −1
  112. packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx +19 −2
  113. packages/plugins/graphql/src/react/integration-plugin.ts +47 −3
  114. packages/plugins/keychain/CHANGELOG.md +14 −0
  115. packages/plugins/keychain/package.json +1 −1
  116. packages/plugins/mcp/CHANGELOG.md +51 −0
  117. packages/plugins/mcp/package.json +1 −1
  118. packages/plugins/mcp/src/api/group.ts +37 −0
  119. packages/plugins/mcp/src/api/handlers.test.ts +34 −0
  120. packages/plugins/mcp/src/api/handlers.ts +18 −1
  121. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +32 −25
  122. packages/plugins/mcp/src/react/CodexPluginAdd.tsx +130 −5
  123. packages/plugins/mcp/src/react/atoms.ts +1 −0
  124. packages/plugins/mcp/src/react/auth-method-config.ts +53 −0
  125. packages/plugins/mcp/src/react/codex-access-gate.test.ts +51 −0
  126. packages/plugins/mcp/src/react/codex-access-gate.ts +43 −0
  127. packages/plugins/mcp/src/react/integration-plugin.tsx +66 −3
  128. packages/plugins/mcp/src/sdk/appserver-connector.test.ts +23 −1
  129. packages/plugins/mcp/src/sdk/appserver-connector.ts +27 −0
  130. packages/plugins/mcp/src/sdk/appserver-test-server.ts +14 −0
  131. packages/plugins/mcp/src/sdk/codex-permissions.test.ts +79 −0
  132. packages/plugins/mcp/src/sdk/codex-permissions.ts +130 −0
  133. packages/plugins/mcp/src/sdk/codex-plugin-presets.ts +8 −0
  134. packages/plugins/mcp/src/sdk/codex-plugins.test.ts +12 −5
  135. packages/plugins/mcp/src/sdk/codex-plugins.ts +2 −0
  136. packages/plugins/mcp/src/sdk/connection.ts +8 −6
  137. packages/plugins/mcp/src/sdk/discover.ts +55 −4
  138. packages/plugins/mcp/src/sdk/errors.ts +5 −0
  139. packages/plugins/mcp/src/sdk/plugin.test.ts +38 −1
  140. packages/plugins/mcp/src/sdk/plugin.ts +135 −20
  141. packages/plugins/mcp/src/sdk/presets.ts +0 −8
  142. packages/plugins/mcp/src/sdk/types.ts +9 −0
  143. packages/plugins/onepassword/CHANGELOG.md +18 −0
  144. packages/plugins/onepassword/package.json +1 −1
  145. packages/plugins/openapi/CHANGELOG.md +20 −0
  146. packages/plugins/openapi/package.json +1 −1
  147. packages/plugins/openapi/src/api/group.ts +3 −0
  148. packages/plugins/openapi/src/api/handlers.ts +1 −0
  149. packages/plugins/openapi/src/providers/google/discovery.test.ts +28 −0
  150. packages/plugins/openapi/src/providers/google/discovery.ts +62 −28
  151. packages/plugins/openapi/src/providers/google/presets.ts +1 −0
  152. packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +28 −0
  153. packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +2 −0
  154. packages/plugins/openapi/src/providers/microsoft/presets.ts +1 −0
  155. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts +19 −0
  156. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts +21 −0
  157. packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +44 −5
  158. packages/plugins/openapi/src/react/integration-plugin.test.ts +241 −0
  159. packages/plugins/openapi/src/react/integration-plugin.ts +252 −17
  160. packages/plugins/openapi/src/sdk/config.ts +5 −0
  161. packages/plugins/openapi/src/sdk/derive-auth.test.ts +42 −0
  162. packages/plugins/openapi/src/sdk/derive-auth.ts +24 −11
  163. packages/plugins/openapi/src/sdk/plugin.ts +11 −1
  164. packages/plugins/openapi/src/sdk/presets.ts +3 −8
  165. packages/plugins/openapi/src/sdk/preview.ts +17 −10
  166. packages/plugins/openapi/src/sdk/spec-format.ts +12 −1
  167. packages/plugins/provider-service-split/CHANGELOG.md +16 −0
  168. packages/plugins/provider-service-split/package.json +1 −1
  169. packages/plugins/toolkits/CHANGELOG.md +18 −0
  170. packages/plugins/toolkits/package.json +1 −1
  171. packages/react/CHANGELOG.md +22 −0
  172. packages/react/package.json +1 −1
  173. packages/react/src/api/analytics.test.ts +1 −1
  174. packages/react/src/api/analytics.tsx +11 −1
  175. packages/react/src/api/oauth-popup.test.ts +129 −1
  176. packages/react/src/api/oauth-popup.ts +26 −5
  177. packages/react/src/components/add-account-modal.tsx +27 −2
  178. packages/react/src/components/auth-method-list-editor.tsx +33 −15
  179. packages/react/src/components/credential-guidance.tsx +168 −0
  180. packages/react/src/components/integration-favicon.tsx +0 −1
  181. packages/react/src/console-routes.ts +2 −0
  182. packages/react/src/lib/auth-placements.test.ts +23 −1
  183. packages/react/src/lib/auth-placements.tsx +16 −0
  184. packages/react/src/lib/integrations-sh-catalog.ts +440 −43
  185. packages/react/src/multiplayer/shell.tsx +10 −10
  186. packages/react/src/pages/integration-add.tsx +10 −1
  187. packages/react/src/pages/integration-browse.tsx +1041 −0
  188. packages/react/src/pages/integrations.tsx +24 −444
  189. packages/react/src/routes/integrations.add.$pluginKey.tsx +20 −2
  190. packages/react/src/routes/integrations.browse.tsx +7 −0
  191. packages/react/src/routes/routeTree.gen.ts +22 −0
  192. plans/kill-plugin-system.md +515 −0
.changeset/warm-stdio-mcp-pooling.md+0−9
@@ -1,9 +0,0 @@
1---
2"@executor-js/plugin-mcp": patch
3---
4
5**Stdio MCP servers are kept alive between tool calls**
6
7Every tool call on a stdio MCP integration used to spawn a fresh child process, run the full MCP handshake, call the one tool, and tear the child down — roughly a second of overhead per call for an `npx`-launched server, on every call. Remote and app-server connections already reused sessions through the connection pool; plain stdio now joins them, with the same five-minute idle window, the same hashed identity key (command, args, cwd, secret env, credential values, owner and connection all separate identities), and the same drop-on-transport-failure semantics. This matches how MCP clients drive stdio servers generally: one long-lived child per session, not one per call.
8
9A server that genuinely depends on fresh-process semantics can opt out with `spawnPerCall: true` in its stdio config (also accepted by the add-server API). The Codex app-server bridge ignores the opt-out — its approvals are session state, so it must pool.
RUNNING.md+28−0
@@ -40,6 +40,34 @@ develop on its `main`, publish a bump, then bump the dependency here. The · 39 unmodified lines
40The e2e globalsetup files are the source of truth for "how do I boot a40The e2e globalsetup files are the source of truth for "how do I boot a
41working instance of X" — read them before inventing a boot path.41working instance of X" — read them before inventing a boot path.
42 42
43A desktop dev run collides with an INSTALLED Executor in three places, all of
44which look like something else. Give the dev run its own of each:
45
46```
47EXECUTOR_DESKTOP_USER_DATA=/tmp/executor-desktop-dev-userdata \
48EXECUTOR_DESKTOP_SCOPE_DIR=/tmp/executor-desktop-dev-scope \
49EXECUTOR_DESKTOP_SETTINGS_DIR=/tmp/executor-desktop-dev-settings \
50bun run dev
51```
52
53- **userData** holds Electron's single-instance lock, so the second process
54 quits at startup with **exit code 0 and no message** — the log simply stops
55 after "starting electron app".
56- **The scope dir** (`~/.executor`) holds the sidecar's SQLite, owned by
57 whoever opened it first; the app reports "Failed to open local SQLite data".
58- **The port** comes from `settings.json` in the settings dir; write
59 `{"server":{"port":<free>}}` there before the first launch.
60
61Do NOT move these by pointing `HOME` at a scratch directory. The plugins a
62local run drives resolve their own paths from the real home, and a synthetic
63one breaks them in ways that read as product bugs: Codex Computer Use fails
64every call with "Sky Computer Use native pipe startup failed", even with
65`.codex` symlinked back.
66
67Renderer edits inside a workspace package can be served from vite's dep
68cache rather than the source the package exports. If a change does not appear
69after a reload, delete `apps/desktop/node_modules/.vite` and restart.
70
43## E2E: running, viewing, sharing71## E2E: running, viewing, sharing
44 72
45`e2e/AGENTS.md` covers writing scenarios. Operationally:73`e2e/AGENTS.md` covers writing scenarios. Operationally:
apps/cli/CHANGELOG.md+45−0
@@ -1,5 +1,50 @@
1# executor1# executor
2 2
3## 1.6.6
4
5### Patch Changes
6
7- [#1869](https://github.com/UsefulSoftwareCo/executor/pull/1869) [`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Explain macOS permissions for Codex plugins instead of failing with an opaque
8 error. A refused grant used to surface as `Internal tool error [id]` — the
9 plugin reports "Unknown error" and only a numeric code says what happened, so
10 neither the user nor the model could tell that macOS was the blocker.
11
12 The bridge now recognises those codes and answers with the grant to enable and
13 where to find it. Each plugin's add screen also states what macOS will ask for
14 before anything runs, with a link straight to the right Privacy pane — macOS
15 asks once, and a dismissed prompt never returns.
16
17 The add screen checks that access when it opens, and holds the Add button
18 until the plugin answers. Adding one that macOS is still blocking produced an
19 integration that looked connected and failed on its first call, by which point
20 the screen explaining the fix was gone.
21
22- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
23 - @executor-js/local@1.6.6
24 - @executor-js/api@1.4.69
25 - @executor-js/sdk@1.6.6
26 - @executor-js/runtime-quickjs@1.6.6
27
28## 1.6.5
29
30### Patch Changes
31
32- [#1863](https://github.com/UsefulSoftwareCo/executor/pull/1863) [`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the
33 scanner already resolved, but it was passed on the channel that makes an
34 environment variable a credential — so the integration declared it as one, and
35 a person who reached the connect step was shown a masked field for a value
36 they should never have to know.
37
38 Stdio integrations can now carry non-secret environment as static
39 configuration, separate from declared secrets. The Codex plugins use it: they
40 declare no auth, and their connection is created for them.
41
42- Updated dependencies []:
43 - @executor-js/local@1.6.5
44 - @executor-js/sdk@1.6.5
45 - @executor-js/runtime-quickjs@1.6.5
46 - @executor-js/api@1.4.68
47
3## 1.6.448## 1.6.4
4 49
5### Patch Changes50### Patch Changes
apps/cli/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "executor",2 "name": "executor",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "private": true,4 "private": true,
5 "bin": {5 "bin": {
6 "executor": "./bin/executor.ts"6 "executor": "./bin/executor.ts"
apps/cloud/CHANGELOG.md+48−0
@@ -1,5 +1,53 @@
1# @executor-js/cloud1# @executor-js/cloud
2 2
3## 1.4.67
4
5### Patch Changes
6
7- [#1864](https://github.com/UsefulSoftwareCo/executor/pull/1864) [`fad3650`](https://github.com/UsefulSoftwareCo/executor/commit/fad36504439a07e6080beba243b24b73cd1b9741) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **API key validation is cached per isolate**
8
9 Every MCP request and every API-key-authenticated `/api/*` request used to pay a live WorkOS round trip (~100-150ms) to validate the presented key, on every single request. The JWT bearer path beside it already verified locally against a JWKS cached for an hour; API keys had no cache at all.
10
11 Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). The MCP handler used to rebuild its whole auth layer (and with it the cache) on every request; it now builds the layer once per isolate, so the cache holds on both the `/api/*` and `/mcp` planes. Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts.
12
13- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
14 - @executor-js/plugin-mcp@1.6.6
15 - @executor-js/execution@1.6.6
16 - @executor-js/react@1.4.69
17 - @executor-js/api@1.4.69
18 - @executor-js/cloudflare@0.0.48
19 - @executor-js/host-mcp@1.4.4
20 - @executor-js/mcp-apps-shell@1.4.17
21 - @executor-js/runtime-dynamic-worker@1.4.4
22 - @executor-js/plugin-graphql@1.6.6
23 - @executor-js/plugin-openapi@1.6.6
24 - @executor-js/plugin-toolkits@1.5.41
25 - @executor-js/plugin-workos-vault@0.0.2
26 - @executor-js/sdk@1.6.6
27 - @executor-js/runtime-quickjs@1.6.6
28 - @executor-js/vite-plugin@0.0.66
29
30## 1.4.66
31
32### Patch Changes
33
34- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]:
35 - @executor-js/plugin-mcp@1.6.5
36 - @executor-js/sdk@1.6.5
37 - @executor-js/runtime-quickjs@1.6.5
38 - @executor-js/execution@1.6.5
39 - @executor-js/plugin-graphql@1.6.5
40 - @executor-js/plugin-openapi@1.6.5
41 - @executor-js/api@1.4.68
42 - @executor-js/vite-plugin@0.0.65
43 - @executor-js/cloudflare@0.0.47
44 - @executor-js/host-mcp@1.4.4
45 - @executor-js/mcp-apps-shell@1.4.16
46 - @executor-js/runtime-dynamic-worker@1.4.4
47 - @executor-js/plugin-toolkits@1.5.40
48 - @executor-js/plugin-workos-vault@0.0.2
49 - @executor-js/react@1.4.68
50
3## 1.4.6551## 1.4.65
4 52
5### Patch Changes53### Patch Changes
apps/cloud/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/cloud",2 "name": "@executor-js/cloud",
3 "version": "1.4.65",3 "version": "1.4.67",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
apps/cloud/src/routeTree.gen.ts+29−0
@@ -24,6 +24,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteIm · 23 unmodified lines
24import { Route as ApiKeysRouteImport } from './routes/app/api-keys'24import { Route as ApiKeysRouteImport } from './routes/app/api-keys'
25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
26import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'26import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$executionId'
27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'28import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
28import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'29import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
29import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'30import { Route as Billing_DotplansRouteImport } from './routes/app/billing_.plans'
@@ -115,6 +116,14 @@ const ResumeDotexecutionIdRoute = ResumeDotexecutionIdRouteImport.update({ · 85 unmodified lines
115 path: '/{-$orgSlug}/resume/$executionId',116 path: '/{-$orgSlug}/resume/$executionId',
116 getParentRoute: () => rootRouteImport,117 getParentRoute: () => rootRouteImport,
117} as any)118} as any)
119const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
120 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
121 {
122 id: '/{-$orgSlug}/integrations/browse',
123 path: '/{-$orgSlug}/integrations/browse',
124 getParentRoute: () => rootRouteImport,
125 } as any,
126 )
118const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =127const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
119 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(128 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
120 {129 {
@@ -172,6 +181,7 @@ export interface FileRoutesByFullPath { · 51 unmodified lines
172 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute181 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
173 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute182 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
174 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute183 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
184 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
175 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute185 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
176 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute186 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
177 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute187 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -194,6 +204,7 @@ export interface FileRoutesByTo { · 16 unmodified lines
194 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute204 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
195 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute205 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
196 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute206 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
207 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
197 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute208 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
198 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute209 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
199 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute210 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -217,6 +228,7 @@ export interface FileRoutesById { · 17 unmodified lines
217 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute228 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
218 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute229 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
219 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute230 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
231 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
220 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute232 '/{-$orgSlug}/resume/$executionId': typeof ResumeDotexecutionIdRoute
221 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute233 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
222 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute234 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -241,6 +253,7 @@ export interface FileRouteTypes { · 18 unmodified lines
241 | '/{-$orgSlug}/billing/plans'253 | '/{-$orgSlug}/billing/plans'
242 | '/{-$orgSlug}/connect/$integrationSlug'254 | '/{-$orgSlug}/connect/$integrationSlug'
243 | '/{-$orgSlug}/integrations/$namespace'255 | '/{-$orgSlug}/integrations/$namespace'
256 | '/{-$orgSlug}/integrations/browse'
244 | '/{-$orgSlug}/resume/$executionId'257 | '/{-$orgSlug}/resume/$executionId'
245 | '/{-$orgSlug}/toolkits/$toolkitSlug'258 | '/{-$orgSlug}/toolkits/$toolkitSlug'
246 | '/{-$orgSlug}/integrations/add/$pluginKey'259 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -263,6 +276,7 @@ export interface FileRouteTypes { · 16 unmodified lines
263 | '/{-$orgSlug}/billing/plans'276 | '/{-$orgSlug}/billing/plans'
264 | '/{-$orgSlug}/connect/$integrationSlug'277 | '/{-$orgSlug}/connect/$integrationSlug'
265 | '/{-$orgSlug}/integrations/$namespace'278 | '/{-$orgSlug}/integrations/$namespace'
279 | '/{-$orgSlug}/integrations/browse'
266 | '/{-$orgSlug}/resume/$executionId'280 | '/{-$orgSlug}/resume/$executionId'
267 | '/{-$orgSlug}/toolkits/$toolkitSlug'281 | '/{-$orgSlug}/toolkits/$toolkitSlug'
268 | '/{-$orgSlug}/integrations/add/$pluginKey'282 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -285,6 +299,7 @@ export interface FileRouteTypes { · 16 unmodified lines
285 | '/{-$orgSlug}/billing_/plans'299 | '/{-$orgSlug}/billing_/plans'
286 | '/{-$orgSlug}/connect/$integrationSlug'300 | '/{-$orgSlug}/connect/$integrationSlug'
287 | '/{-$orgSlug}/integrations/$namespace'301 | '/{-$orgSlug}/integrations/$namespace'
302 | '/{-$orgSlug}/integrations/browse'
288 | '/{-$orgSlug}/resume/$executionId'303 | '/{-$orgSlug}/resume/$executionId'
289 | '/{-$orgSlug}/toolkits/$toolkitSlug'304 | '/{-$orgSlug}/toolkits/$toolkitSlug'
290 | '/{-$orgSlug}/integrations/add/$pluginKey'305 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -307,6 +322,7 @@ export interface RootRouteChildren { · 16 unmodified lines
307 Billing_DotplansRoute: typeof Billing_DotplansRoute322 Billing_DotplansRoute: typeof Billing_DotplansRoute
308 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute323 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
309 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute324 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
325 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
310 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute326 ResumeDotexecutionIdRoute: typeof ResumeDotexecutionIdRoute
311 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute327 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
312}328}
@@ -418,6 +434,13 @@ declare module '@tanstack/react-router' { · 105 unmodified lines
418 preLoaderRoute: typeof ResumeDotexecutionIdRouteImport434 preLoaderRoute: typeof ResumeDotexecutionIdRouteImport
419 parentRoute: typeof rootRouteImport435 parentRoute: typeof rootRouteImport
420 }436 }
437 '/{-$orgSlug}/integrations/browse': {
438 id: '/{-$orgSlug}/integrations/browse'
439 path: '/{-$orgSlug}/integrations/browse'
440 fullPath: '/{-$orgSlug}/integrations/browse'
441 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport
442 parentRoute: typeof rootRouteImport
443 }
421 '/{-$orgSlug}/integrations/$namespace': {444 '/{-$orgSlug}/integrations/$namespace': {
422 id: '/{-$orgSlug}/integrations/$namespace'445 id: '/{-$orgSlug}/integrations/$namespace'
423 path: '/{-$orgSlug}/integrations/$namespace'446 path: '/{-$orgSlug}/integrations/$namespace'
@@ -511,6 +534,8 @@ const rootRouteChildren: RootRouteChildren = { · 87 unmodified lines
511 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,534 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
512 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:535 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
513 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,536 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
537 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
538 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
514 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,539 ResumeDotexecutionIdRoute: ResumeDotexecutionIdRoute,
515 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:540 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
516 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute,541 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute,
@@ -520,11 +545,15 @@ export const routeTree = rootRouteImport · 3 unmodified lines
520 ._addFileTypes<FileRouteTypes>()545 ._addFileTypes<FileRouteTypes>()
521 546
522import type { getRouter } from './router.tsx'547import type { getRouter } from './router.tsx'
548
523import type { startInstance } from './start.ts'549import type { startInstance } from './start.ts'
550
524declare module '@tanstack/react-start' {551declare module '@tanstack/react-start' {
525 interface Register {552 interface Register {
526 ssr: true553 ssr: true
554
527 router: Awaited<ReturnType<typeof getRouter>>555 router: Awaited<ReturnType<typeof getRouter>>
556
528 config: Awaited<ReturnType<typeof startInstance.getOptions>>557 config: Awaited<ReturnType<typeof startInstance.getOptions>>
529 }558 }
530}559}
apps/desktop/CHANGELOG.md+4−0
@@ -1,5 +1,9 @@
1# @executor-js/desktop1# @executor-js/desktop
2 2
3## 1.6.6
4
5## 1.6.5
6
3## 1.6.47## 1.6.4
4 8
5## 1.6.39## 1.6.3
apps/desktop/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/desktop",2 "name": "@executor-js/desktop",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "private": true,4 "private": true,
5 "homepage": "https://github.com/UsefulSoftwareCo/executor",5 "homepage": "https://github.com/UsefulSoftwareCo/executor",
6 "license": "MIT",6 "license": "MIT",
apps/desktop/src/main/index.ts+7−1
@@ -78,7 +78,13 @@ import { · 77 unmodified lines
78// executor.jsonc plugin manifest) is pinned separately to ~/.executor in78// executor.jsonc plugin manifest) is pinned separately to ~/.executor in
79// main/sidecar.ts — that path matches the CLI's default.79// main/sidecar.ts — that path matches the CLI's default.
80app.setName("Executor");80app.setName("Executor");
81app.setPath("userData", join(app.getPath("appData"), "Executor"));81// A dev run must not collide with an installed Executor: userData also holds
82// Electron's single-instance lock, so sharing it makes the second process quit
83// silently at startup. `EXECUTOR_DESKTOP_USER_DATA` gives a dev run its own.
84app.setPath(
85 "userData",
86 process.env.EXECUTOR_DESKTOP_USER_DATA ?? join(app.getPath("appData"), "Executor"),
87);
82 88
83log.initialize({ preload: true });89log.initialize({ preload: true });
84log.transports.file.level = "info";90log.transports.file.level = "info";
apps/desktop/src/main/sidecar.ts+14−2
@@ -215,6 +215,18 @@ const resolveClientDir = (): string => { · 214 unmodified lines
215const delay = (ms: number): Promise<void> =>215const delay = (ms: number): Promise<void> =>
216 new Promise((resolveDelay) => setTimeout(resolveDelay, ms));216 new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
217 217
218/**
219 * Where this app keeps `data.db`, `auth.json`, and the server manifest.
220 *
221 * A dev run overrides it: that SQLite has ONE owner, so a dev build started
222 * beside an installed Executor otherwise dies on the installed app's lock.
223 * Redirecting HOME is not an alternative — the machine-local tools a plugin
224 * drives resolve their own paths from it, and Codex Computer Use fails at
225 * "native pipe startup" under a synthetic home.
226 */
227const executorScopeDir = (): string =>
228 process.env.EXECUTOR_DESKTOP_SCOPE_DIR ?? join(homedir(), ".executor");
229
218export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> {230export async function startSidecar(options: StartOptions = {}): Promise<SidecarConnection> {
219 const hostname = options.hostname ?? "127.0.0.1";231 const hostname = options.hostname ?? "127.0.0.1";
220 const settings = getServerSettings();232 const settings = getServerSettings();
@@ -226,7 +238,7 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 5 unmodified lines
226 // userData (set in main/index.ts) is still used for electron-store,238 // userData (set in main/index.ts) is still used for electron-store,
227 // electron-log, and window-state — those stay app-scoped to avoid colliding239 // electron-log, and window-state — those stay app-scoped to avoid colliding
228 // with anything else under HOME.240 // with anything else under HOME.
229 const scopeDir = join(homedir(), ".executor");241 const scopeDir = executorScopeDir();
230 const dataDir = scopeDir;242 const dataDir = scopeDir;
231 mkdirSync(dataDir, { recursive: true });243 mkdirSync(dataDir, { recursive: true });
232 244
@@ -429,7 +441,7 @@ const isDaemonReachable = async (origin: string): Promise<boolean> => { · 196 unmodified lines
429 * is handled by the existing single-instance / ownership logic.441 * is handled by the existing single-instance / ownership logic.
430 */442 */
431export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> {443export async function attachToSupervisedDaemon(): Promise<SidecarConnection | null> {
432 const dataDir = join(homedir(), ".executor");444 const dataDir = executorScopeDir();
433 const manifest = readManifest(dataDir);445 const manifest = readManifest(dataDir);
434 const decision = await resolveSupervisedDaemonAttach(manifest, {446 const decision = await resolveSupervisedDaemonAttach(manifest, {
435 isReachable: isDaemonReachable,447 isReachable: isDaemonReachable,
apps/host-cloudflare/web/routeTree.gen.ts+25−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImp · 16 unmodified lines
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts'17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts'
18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'19import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
20import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
21import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
@@ -76,6 +77,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 53 unmodified lines
76 getParentRoute: () => rootRouteImport,77 getParentRoute: () => rootRouteImport,
77 } as any,78 } as any,
78 )79 )
80const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
81 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
82 {
83 id: '/{-$orgSlug}/integrations/browse',
84 path: '/{-$orgSlug}/integrations/browse',
85 getParentRoute: () => rootRouteImport,
86 } as any,
87 )
79const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =88const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
80 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(89 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
81 {90 {
@@ -128,6 +137,7 @@ export interface FileRoutesByFullPath { · 46 unmodified lines
128 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute137 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
129 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute138 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
130 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute139 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
140 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
131 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute141 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
132 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute142 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
133 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute143 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -143,6 +153,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
143 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute153 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
144 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
145 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
156 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
146 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute157 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
147 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute158 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
148 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute159 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -159,6 +170,7 @@ export interface FileRoutesById { · 10 unmodified lines
159 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute170 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
160 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute171 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
161 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute172 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
173 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
162 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute174 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
163 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute175 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
164 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute176 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -176,6 +188,7 @@ export interface FileRouteTypes { · 11 unmodified lines
176 | '/{-$orgSlug}/artifacts/$artifactId'188 | '/{-$orgSlug}/artifacts/$artifactId'
177 | '/{-$orgSlug}/connect/$integrationSlug'189 | '/{-$orgSlug}/connect/$integrationSlug'
178 | '/{-$orgSlug}/integrations/$namespace'190 | '/{-$orgSlug}/integrations/$namespace'
191 | '/{-$orgSlug}/integrations/browse'
179 | '/{-$orgSlug}/resume/$executionId'192 | '/{-$orgSlug}/resume/$executionId'
180 | '/{-$orgSlug}/toolkits/$toolkitSlug'193 | '/{-$orgSlug}/toolkits/$toolkitSlug'
181 | '/{-$orgSlug}/integrations/add/$pluginKey'194 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -191,6 +204,7 @@ export interface FileRouteTypes { · 9 unmodified lines
191 | '/{-$orgSlug}/artifacts/$artifactId'204 | '/{-$orgSlug}/artifacts/$artifactId'
192 | '/{-$orgSlug}/connect/$integrationSlug'205 | '/{-$orgSlug}/connect/$integrationSlug'
193 | '/{-$orgSlug}/integrations/$namespace'206 | '/{-$orgSlug}/integrations/$namespace'
207 | '/{-$orgSlug}/integrations/browse'
194 | '/{-$orgSlug}/resume/$executionId'208 | '/{-$orgSlug}/resume/$executionId'
195 | '/{-$orgSlug}/toolkits/$toolkitSlug'209 | '/{-$orgSlug}/toolkits/$toolkitSlug'
196 | '/{-$orgSlug}/integrations/add/$pluginKey'210 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -206,6 +220,7 @@ export interface FileRouteTypes { · 9 unmodified lines
206 | '/{-$orgSlug}/artifacts/$artifactId'220 | '/{-$orgSlug}/artifacts/$artifactId'
207 | '/{-$orgSlug}/connect/$integrationSlug'221 | '/{-$orgSlug}/connect/$integrationSlug'
208 | '/{-$orgSlug}/integrations/$namespace'222 | '/{-$orgSlug}/integrations/$namespace'
223 | '/{-$orgSlug}/integrations/browse'
209 | '/{-$orgSlug}/resume/$executionId'224 | '/{-$orgSlug}/resume/$executionId'
210 | '/{-$orgSlug}/toolkits/$toolkitSlug'225 | '/{-$orgSlug}/toolkits/$toolkitSlug'
211 | '/{-$orgSlug}/integrations/add/$pluginKey'226 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -221,6 +236,7 @@ export interface RootRouteChildren { · 9 unmodified lines
221 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute236 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
222 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute237 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
223 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute238 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
239 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
224 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute240 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
225 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute241 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
226 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute242 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -284,6 +300,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
284 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport300 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport
285 parentRoute: typeof rootRouteImport301 parentRoute: typeof rootRouteImport
286 }302 }
303 '/{-$orgSlug}/integrations/browse': {
304 id: '/{-$orgSlug}/integrations/browse'
305 path: '/{-$orgSlug}/integrations/browse'
306 fullPath: '/{-$orgSlug}/integrations/browse'
307 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport
308 parentRoute: typeof rootRouteImport
309 }
287 '/{-$orgSlug}/integrations/$namespace': {310 '/{-$orgSlug}/integrations/$namespace': {
288 id: '/{-$orgSlug}/integrations/$namespace'311 id: '/{-$orgSlug}/integrations/$namespace'
289 path: '/{-$orgSlug}/integrations/$namespace'312 path: '/{-$orgSlug}/integrations/$namespace'
@@ -369,6 +392,8 @@ const rootRouteChildren: RootRouteChildren = { · 79 unmodified lines
369 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,392 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
370 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
371 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,394 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
395 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
396 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
372 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:397 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
373 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,398 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,
374 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:399 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
apps/host-selfhost/CHANGELOG.md+42−0
@@ -1,5 +1,47 @@
1# @executor-js/host-selfhost1# @executor-js/host-selfhost
2 2
3## 0.0.48
4
5### Patch Changes
6
7- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
8 - @executor-js/plugin-mcp@1.6.6
9 - @executor-js/execution@1.6.6
10 - @executor-js/react@1.4.69
11 - @executor-js/analytics@0.1.13
12 - @executor-js/api@1.4.69
13 - @executor-js/host-mcp@1.4.4
14 - @executor-js/mcp-apps-shell@1.4.17
15 - @executor-js/app@1.4.4
16 - @executor-js/plugin-graphql@1.6.6
17 - @executor-js/plugin-openapi@1.6.6
18 - @executor-js/plugin-toolkits@1.5.41
19 - @executor-js/plugin-provider-service-split@0.0.20
20 - @executor-js/sdk@1.6.6
21 - @executor-js/runtime-quickjs@1.6.6
22 - @executor-js/plugin-encrypted-secrets@0.0.48
23
24## 0.0.47
25
26### Patch Changes
27
28- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]:
29 - @executor-js/plugin-mcp@1.6.5
30 - @executor-js/sdk@1.6.5
31 - @executor-js/runtime-quickjs@1.6.5
32 - @executor-js/execution@1.6.5
33 - @executor-js/plugin-graphql@1.6.5
34 - @executor-js/plugin-openapi@1.6.5
35 - @executor-js/app@1.4.4
36 - @executor-js/analytics@0.1.12
37 - @executor-js/api@1.4.68
38 - @executor-js/host-mcp@1.4.4
39 - @executor-js/mcp-apps-shell@1.4.16
40 - @executor-js/plugin-encrypted-secrets@0.0.47
41 - @executor-js/plugin-provider-service-split@0.0.19
42 - @executor-js/plugin-toolkits@1.5.40
43 - @executor-js/react@1.4.68
44
3## 0.0.4645## 0.0.46
4 46
5### Patch Changes47### Patch Changes
apps/host-selfhost/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/host-selfhost",2 "name": "@executor-js/host-selfhost",
3 "version": "0.0.46",3 "version": "0.0.48",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
apps/host-selfhost/web/routeTree.gen.ts+25−0
@@ -21,6 +21,7 @@ import { Route as AdminRouteImport } from './routes/app/admin' · 20 unmodified lines
21import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'21import { Route as JoinDotcodeRouteImport } from './routes/public/join.$code'
22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'22import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug'
23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'23import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../../packages/react/src/routes/resume.$executionId'
24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../../packages/react/src/routes/integrations.browse'
24import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../../packages/react/src/routes/integrations.$namespace'
25import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'26import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../../packages/react/src/routes/connect.$integrationSlug'
26import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'27import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../../packages/react/src/routes/artifacts.$artifactId'
@@ -101,6 +102,14 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute = · 74 unmodified lines
101 getParentRoute: () => rootRouteImport,102 getParentRoute: () => rootRouteImport,
102 } as any,103 } as any,
103 )104 )
105const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute =
106 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport.update(
107 {
108 id: '/{-$orgSlug}/integrations/browse',
109 path: '/{-$orgSlug}/integrations/browse',
110 getParentRoute: () => rootRouteImport,
111 } as any,
112 )
104const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =113const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute =
105 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(114 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRouteImport.update(
106 {115 {
@@ -157,6 +166,7 @@ export interface FileRoutesByFullPath { · 50 unmodified lines
157 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute166 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
158 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute167 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
159 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute168 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
169 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
160 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute170 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
161 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute171 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
162 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute172 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -176,6 +186,7 @@ export interface FileRoutesByTo { · 13 unmodified lines
176 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute186 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
177 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute187 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
178 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute188 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
189 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
179 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute190 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
180 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute191 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
181 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute192 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -196,6 +207,7 @@ export interface FileRoutesById { · 14 unmodified lines
196 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute207 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsDotartifactIdRoute
197 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute208 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
198 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute209 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
210 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
199 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute211 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
200 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute212 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRoute
201 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute213 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -217,6 +229,7 @@ export interface FileRouteTypes { · 15 unmodified lines
217 | '/{-$orgSlug}/artifacts/$artifactId'229 | '/{-$orgSlug}/artifacts/$artifactId'
218 | '/{-$orgSlug}/connect/$integrationSlug'230 | '/{-$orgSlug}/connect/$integrationSlug'
219 | '/{-$orgSlug}/integrations/$namespace'231 | '/{-$orgSlug}/integrations/$namespace'
232 | '/{-$orgSlug}/integrations/browse'
220 | '/{-$orgSlug}/resume/$executionId'233 | '/{-$orgSlug}/resume/$executionId'
221 | '/{-$orgSlug}/toolkits/$toolkitSlug'234 | '/{-$orgSlug}/toolkits/$toolkitSlug'
222 | '/{-$orgSlug}/integrations/add/$pluginKey'235 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -236,6 +249,7 @@ export interface FileRouteTypes { · 13 unmodified lines
236 | '/{-$orgSlug}/artifacts/$artifactId'249 | '/{-$orgSlug}/artifacts/$artifactId'
237 | '/{-$orgSlug}/connect/$integrationSlug'250 | '/{-$orgSlug}/connect/$integrationSlug'
238 | '/{-$orgSlug}/integrations/$namespace'251 | '/{-$orgSlug}/integrations/$namespace'
252 | '/{-$orgSlug}/integrations/browse'
239 | '/{-$orgSlug}/resume/$executionId'253 | '/{-$orgSlug}/resume/$executionId'
240 | '/{-$orgSlug}/toolkits/$toolkitSlug'254 | '/{-$orgSlug}/toolkits/$toolkitSlug'
241 | '/{-$orgSlug}/integrations/add/$pluginKey'255 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -255,6 +269,7 @@ export interface FileRouteTypes { · 13 unmodified lines
255 | '/{-$orgSlug}/artifacts/$artifactId'269 | '/{-$orgSlug}/artifacts/$artifactId'
256 | '/{-$orgSlug}/connect/$integrationSlug'270 | '/{-$orgSlug}/connect/$integrationSlug'
257 | '/{-$orgSlug}/integrations/$namespace'271 | '/{-$orgSlug}/integrations/$namespace'
272 | '/{-$orgSlug}/integrations/browse'
258 | '/{-$orgSlug}/resume/$executionId'273 | '/{-$orgSlug}/resume/$executionId'
259 | '/{-$orgSlug}/toolkits/$toolkitSlug'274 | '/{-$orgSlug}/toolkits/$toolkitSlug'
260 | '/{-$orgSlug}/integrations/add/$pluginKey'275 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -274,6 +289,7 @@ export interface RootRouteChildren { · 13 unmodified lines
274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute289 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
275 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute290 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute291 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
292 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute
277 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute293 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute
278 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute294 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
279 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute295 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -365,6 +381,13 @@ declare module '@tanstack/react-router' { · 85 unmodified lines
365 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport381 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRouteImport
366 parentRoute: typeof rootRouteImport382 parentRoute: typeof rootRouteImport
367 }383 }
384 '/{-$orgSlug}/integrations/browse': {
385 id: '/{-$orgSlug}/integrations/browse'
386 path: '/{-$orgSlug}/integrations/browse'
387 fullPath: '/{-$orgSlug}/integrations/browse'
388 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRouteImport
389 parentRoute: typeof rootRouteImport
390 }
368 '/{-$orgSlug}/integrations/$namespace': {391 '/{-$orgSlug}/integrations/$namespace': {
369 id: '/{-$orgSlug}/integrations/$namespace'392 id: '/{-$orgSlug}/integrations/$namespace'
370 path: '/{-$orgSlug}/integrations/$namespace'393 path: '/{-$orgSlug}/integrations/$namespace'
@@ -455,6 +478,8 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines
455 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,478 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute,
456 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:479 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute:
457 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,480 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute,
481 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute:
482 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotbrowseRoute,
458 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:483 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute:
459 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,484 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesResumeDotexecutionIdRoute,
460 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:485 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
apps/local/CHANGELOG.md+60−0
@@ -1,5 +1,65 @@
1# @executor-js/local1# @executor-js/local
2 2
3## 1.6.6
4
5### Patch Changes
6
7- [#1865](https://github.com/UsefulSoftwareCo/executor/pull/1865) [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Desktop OAuth connects finish the moment the provider redirects**
8
9 When the desktop app runs an OAuth flow in the system browser, the app learned about completion by polling the local server once a second. The completed result sat in memory while the user watched the "Connecting…" spinner for up to a second more — about half a second wasted on average, on every connect.
10
11 The await endpoint now long-polls: the server holds the request open (up to 25 seconds per hold) and answers the instant the flow completes. The client polls one request at a time and reconnects after each answer, so requests never stack. Mixed versions stay compatible in both directions: an old client still gets its answer within one poll of a new server, and a new client against an old server behaves exactly as before.
12
13- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
14 - @executor-js/plugin-mcp@1.6.6
15 - @executor-js/execution@1.6.6
16 - @executor-js/react@1.4.69
17 - @executor-js/analytics@0.1.13
18 - @executor-js/api@1.4.69
19 - @executor-js/host-mcp@1.4.4
20 - @executor-js/mcp-apps-shell@1.4.17
21 - @executor-js/app@1.4.4
22 - @executor-js/plugin-graphql@1.6.6
23 - @executor-js/plugin-onepassword@1.6.6
24 - @executor-js/plugin-openapi@1.6.6
25 - @executor-js/plugin-toolkits@1.5.41
26 - @executor-js/plugin-provider-service-split@0.0.20
27 - @executor-js/sdk@1.6.6
28 - @executor-js/runtime-quickjs@1.6.6
29 - @executor-js/config@1.6.6
30 - @executor-js/plugin-file-secrets@1.6.6
31 - @executor-js/plugin-keychain@1.6.6
32 - @executor-js/plugin-example@1.6.6
33 - @executor-js/plugin-desktop-settings@1.6.6
34 - @executor-js/vite-plugin@0.0.66
35
36## 1.6.5
37
38### Patch Changes
39
40- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]:
41 - @executor-js/plugin-mcp@1.6.5
42 - @executor-js/sdk@1.6.5
43 - @executor-js/runtime-quickjs@1.6.5
44 - @executor-js/execution@1.6.5
45 - @executor-js/config@1.6.5
46 - @executor-js/plugin-file-secrets@1.6.5
47 - @executor-js/plugin-graphql@1.6.5
48 - @executor-js/plugin-keychain@1.6.5
49 - @executor-js/plugin-onepassword@1.6.5
50 - @executor-js/plugin-openapi@1.6.5
51 - @executor-js/plugin-example@1.6.5
52 - @executor-js/plugin-desktop-settings@1.6.5
53 - @executor-js/app@1.4.4
54 - @executor-js/analytics@0.1.12
55 - @executor-js/api@1.4.68
56 - @executor-js/vite-plugin@0.0.65
57 - @executor-js/host-mcp@1.4.4
58 - @executor-js/mcp-apps-shell@1.4.16
59 - @executor-js/plugin-provider-service-split@0.0.19
60 - @executor-js/plugin-toolkits@1.5.40
61 - @executor-js/react@1.4.68
62
3## 1.6.463## 1.6.4
4 64
5### Patch Changes65### Patch Changes
apps/local/package.json+2−2
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/local",2 "name": "@executor-js/local",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
@@ -10,7 +10,7 @@ · 3 unmodified lines
10 "scripts": {10 "scripts": {
11 "dev": "bun run dev:proxy && bun run dev:vite",11 "dev": "bun run dev:proxy && bun run dev:vite",
12 "dev:proxy": "portless proxy start --multiplex --port 1355 || true",12 "dev:proxy": "portless proxy start --multiplex --port 1355 || true",
13 "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name executor-local bunx --bun vite dev",13 "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name \"executor-local-$(basename \"$(cd ../.. && pwd)\")\" bunx --bun vite dev",
14 "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build",14 "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build",
15 "start": "bun run src/serve.ts",15 "start": "bun run src/serve.ts",
16 "db:generate": "drizzle-kit generate",16 "db:generate": "drizzle-kit generate",
apps/local/src/oauth-result-store.test.ts+222−0
@@ -0,0 +1,222 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2// oxlint-disable-next-line executor/no-vitest-import -- boundary: deterministic timer control comes from vitest itself
3import { vi } from "vitest";
4import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult } from "@executor-js/sdk";
5
6import {
7 __oauthAwaitHeldWaiterTotalForTests,
8 __oauthAwaitWaiterCountForTests,
9 __resetOAuthResultStoreForTests,
10 consumeOAuthResult,
11 publishOAuthResult,
12 waitForOAuthResult,
13} from "./oauth-result-store";
14
15const sampleResult = (sessionId: string): OAuthPopupResult<unknown> => ({
16 type: OAUTH_POPUP_MESSAGE_TYPE,
17 ok: false,
18 sessionId,
19 error: "access denied",
20});
21
22afterEach(() => {
23 __resetOAuthResultStoreForTests();
24 vi.useRealTimers();
25});
26
27// Waiter registration, publish wake-ups, aborts, and the over-cap instant
28// answer are all synchronous, so every ordering below is exact — no sleeps.
29
30describe("waitForOAuthResult", () => {
31 it("resolves immediately and consumes when the result is already published", async () => {
32 publishOAuthResult(sampleResult("s-ready"));
33
34 const result = await waitForOAuthResult("s-ready", { timeoutMs: 5000 });
35
36 expect(result).toMatchObject({ sessionId: "s-ready" });
37 // One-shot: the wait consumed the entry.
38 expect(consumeOAuthResult("s-ready")).toBeNull();
39 expect(__oauthAwaitWaiterCountForTests("s-ready")).toBe(0);
40 });
41
42 it("resolves a held wait the moment the result is published", async () => {
43 const pending = waitForOAuthResult("s-mid", { timeoutMs: 5000 });
44 expect(__oauthAwaitWaiterCountForTests("s-mid")).toBe(1);
45
46 const publishedAt = Date.now();
47 publishOAuthResult(sampleResult("s-mid"));
48 const result = await pending;
49
50 // Resolved by the publish, not the 5s deadline.
51 expect(Date.now() - publishedAt).toBeLessThan(1000);
52 expect(result).toMatchObject({ sessionId: "s-mid" });
53 expect(consumeOAuthResult("s-mid")).toBeNull();
54 expect(__oauthAwaitWaiterCountForTests("s-mid")).toBe(0);
55 });
56
57 it("returns null at the deadline and removes the waiter", async () => {
58 vi.useFakeTimers();
59 const pending = waitForOAuthResult("s-deadline", { timeoutMs: 25_000 });
60 expect(__oauthAwaitWaiterCountForTests("s-deadline")).toBe(1);
61
62 vi.advanceTimersByTime(25_000);
63
64 expect(await pending).toBeNull();
65 expect(__oauthAwaitWaiterCountForTests("s-deadline")).toBe(0);
66 });
67
68 it("resolves immediately when the signal is already aborted", async () => {
69 const controller = new AbortController();
70 controller.abort();
71
72 const result = await waitForOAuthResult("s-pre-aborted", {
73 timeoutMs: 5000,
74 signal: controller.signal,
75 });
76
77 expect(result).toBeNull();
78 expect(__oauthAwaitWaiterCountForTests("s-pre-aborted")).toBe(0);
79 });
80});
81
82describe("waitForOAuthResult publish/abort races", () => {
83 it("abort settles first; a publish arriving after leaves the result for the next consumer", async () => {
84 const controller = new AbortController();
85 const pending = waitForOAuthResult("s-abort-first", {
86 timeoutMs: 5000,
87 signal: controller.signal,
88 });
89 expect(__oauthAwaitWaiterCountForTests("s-abort-first")).toBe(1);
90
91 controller.abort();
92 expect(await pending).toBeNull();
93 expect(__oauthAwaitWaiterCountForTests("s-abort-first")).toBe(0);
94
95 // The publish lands after the abort settled: the dead waiter must not
96 // consume it — it stays in the store for the client's next poll.
97 publishOAuthResult(sampleResult("s-abort-first"));
98 expect(consumeOAuthResult("s-abort-first")).toMatchObject({ sessionId: "s-abort-first" });
99 });
100
101 it("publish resolves the waiter; an abort firing immediately after does not double-consume", async () => {
102 const controller = new AbortController();
103 const pending = waitForOAuthResult("s-pub-first", {
104 timeoutMs: 5000,
105 signal: controller.signal,
106 });
107 expect(__oauthAwaitWaiterCountForTests("s-pub-first")).toBe(1);
108
109 publishOAuthResult(sampleResult("s-pub-first"));
110 controller.abort();
111
112 expect(await pending).toMatchObject({ sessionId: "s-pub-first" });
113 expect(__oauthAwaitWaiterCountForTests("s-pub-first")).toBe(0);
114
115 // The late abort must not have consumed or dropped anything: a second
116 // publish for the session is still delivered intact.
117 publishOAuthResult(sampleResult("s-pub-first"));
118 expect(consumeOAuthResult("s-pub-first")).toMatchObject({ sessionId: "s-pub-first" });
119 });
120});
121
122describe("waitForOAuthResult publish/timeout races", () => {
123 it("deadline fires first; a publish just after leaves the result consumable", async () => {
124 vi.useFakeTimers();
125 const pending = waitForOAuthResult("s-late-pub", { timeoutMs: 25_000 });
126 expect(__oauthAwaitWaiterCountForTests("s-late-pub")).toBe(1);
127
128 vi.advanceTimersByTime(25_000);
129 expect(await pending).toBeNull();
130 expect(__oauthAwaitWaiterCountForTests("s-late-pub")).toBe(0);
131
132 publishOAuthResult(sampleResult("s-late-pub"));
133 expect(consumeOAuthResult("s-late-pub")).toMatchObject({ sessionId: "s-late-pub" });
134 });
135
136 it("publish resolves the waiter; the stale deadline timer is inert afterwards", async () => {
137 vi.useFakeTimers();
138 const pending = waitForOAuthResult("s-early-pub", { timeoutMs: 25_000 });
139 expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(1);
140
141 publishOAuthResult(sampleResult("s-early-pub"));
142 expect(await pending).toMatchObject({ sessionId: "s-early-pub" });
143 expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(0);
144
145 // Run the clock past the original deadline: the settled waiter's timer
146 // was cleared, so nothing re-fires, re-registers, or consumes again.
147 vi.advanceTimersByTime(25_000);
148 expect(__oauthAwaitWaiterCountForTests("s-early-pub")).toBe(0);
149 publishOAuthResult(sampleResult("s-early-pub"));
150 expect(consumeOAuthResult("s-early-pub")).toMatchObject({ sessionId: "s-early-pub" });
151 });
152});
153
154describe("waitForOAuthResult held-waiter caps", () => {
155 it("holds one waiter per session; stacked requests answer null immediately (old-client behavior)", async () => {
156 // An unpatched desktop client polls every second and would stack ~25
157 // concurrent requests per flow against a holding server. Only the first
158 // may hold; the rest get the pre-long-poll instant "still pending".
159 const held = waitForOAuthResult("s-stack", { timeoutMs: 5000 });
160 expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(1);
161
162 const stacked = [
163 waitForOAuthResult("s-stack", { timeoutMs: 5000 }),
164 waitForOAuthResult("s-stack", { timeoutMs: 5000 }),
165 waitForOAuthResult("s-stack", { timeoutMs: 5000 }),
166 ];
167 // The stacked requests settle null BEFORE any publish — instant answers.
168 for (const request of stacked) expect(await request).toBeNull();
169 expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(1);
170 expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(1);
171
172 publishOAuthResult(sampleResult("s-stack"));
173 // Exactly one consumer receives the one-shot result: the held waiter.
174 expect(await held).toMatchObject({ sessionId: "s-stack" });
175 expect(consumeOAuthResult("s-stack")).toBeNull();
176 // No leaked waiters after the flow.
177 expect(__oauthAwaitWaiterCountForTests("s-stack")).toBe(0);
178 expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0);
179 });
180
181 it("re-arms the per-session hold after the held waiter settles", async () => {
182 const controller = new AbortController();
183 const first = waitForOAuthResult("s-rearm", { timeoutMs: 5000, signal: controller.signal });
184 // Over the per-session cap while the first is held.
185 expect(await waitForOAuthResult("s-rearm", { timeoutMs: 5000 })).toBeNull();
186
187 controller.abort();
188 expect(await first).toBeNull();
189 expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(0);
190
191 // The slot is free again: the next request holds and gets the result.
192 const second = waitForOAuthResult("s-rearm", { timeoutMs: 5000 });
193 expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(1);
194 publishOAuthResult(sampleResult("s-rearm"));
195 expect(await second).toMatchObject({ sessionId: "s-rearm" });
196 expect(__oauthAwaitWaiterCountForTests("s-rearm")).toBe(0);
197 });
198
199 it("caps total held waiters globally; over-cap sessions answer instantly but stored results still deliver", async () => {
200 const held = Array.from({ length: 64 }, (_, index) =>
201 waitForOAuthResult(`s-global-${index}`, { timeoutMs: 5000 }),
202 );
203 expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(64);
204
205 // A distinct pending session over the global cap answers null instantly
206 // instead of holding.
207 expect(await waitForOAuthResult("s-global-over", { timeoutMs: 5000 })).toBeNull();
208 expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(64);
209
210 // Over-cap behavior matches the pre-long-poll server exactly: a result
211 // already in the store is still consumed and answered immediately.
212 publishOAuthResult(sampleResult("s-global-stored"));
213 expect(await waitForOAuthResult("s-global-stored", { timeoutMs: 5000 })).toMatchObject({
214 sessionId: "s-global-stored",
215 });
216
217 // Settle every held waiter and confirm the registry drains completely.
218 __resetOAuthResultStoreForTests();
219 for (const request of held) expect(await request).toBeNull();
220 expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0);
221 });
222});
apps/local/src/oauth-result-store.ts+83−1
@@ -28,6 +28,37 @@ const RESULT_TTL_MS = 10 * 60 * 1000; // 10 minutes — long enough for slow MFA · 27 unmodified lines
28 28
29const store = new Map<string, StoredResult>();29const store = new Map<string, StoredResult>();
30 30
31/**
32 * Long-poll waiters, keyed by sessionId. Each entry is the wake callback for
33 * the single held `/api/oauth/await/:sessionId` request for that session.
34 * `publishOAuthResult` wakes the waiter, which runs `consumeOAuthResult` and
35 * answers with the result.
36 *
37 * Held waiters are bounded — each pins a connection, a registry entry, and a
38 * deadline timer, and the route only needs a bearer, so unbounded holds are a
39 * DoS surface. At most one waiter is held per session (the patched client
40 * polls sequentially; an unpatched client polling every second would
41 * otherwise stack ~25 holds per flow against a 25s deadline), and at most
42 * `MAX_HELD_WAITERS_TOTAL` across all sessions. Over either bound,
43 * `waitForOAuthResult` degrades to the pre-long-poll behavior: it answers the
44 * current store value immediately (null = still pending) instead of holding,
45 * so over-cap callers see exactly what an old server would have sent.
46 */
47const MAX_HELD_WAITERS_TOTAL = 64;
48
49const waiters = new Map<string, () => void>();
50
51const removeWaiter = (sessionId: string, wake: () => void): void => {
52 if (waiters.get(sessionId) === wake) waiters.delete(sessionId);
53};
54
55const wakeWaiter = (sessionId: string): void => {
56 const wake = waiters.get(sessionId);
57 if (!wake) return;
58 waiters.delete(sessionId);
59 wake();
60};
61
31const cleanupExpired = (now: number) => {62const cleanupExpired = (now: number) => {
32 for (const [sessionId, entry] of store) {63 for (const [sessionId, entry] of store) {
33 if (entry.expiresAt < now) store.delete(sessionId);64 if (entry.expiresAt < now) store.delete(sessionId);
@@ -44,6 +75,7 @@ export const publishOAuthResult = (result: AnyResult): void => { · 10 unmodified lines
44 const now = Date.now();75 const now = Date.now();
45 cleanupExpired(now);76 cleanupExpired(now);
46 store.set(sessionId, { result, expiresAt: now + RESULT_TTL_MS });77 store.set(sessionId, { result, expiresAt: now + RESULT_TTL_MS });
78 wakeWaiter(sessionId);
47};79};
48 80
49/**81/**
@@ -59,7 +91,57 @@ export const consumeOAuthResult = (sessionId: string): AnyResult | null => { · 9 unmodified lines
59 return entry.result;91 return entry.result;
60};92};
61 93
62/** Test-only — clears the entire store between tests. */94/**
95 * Long-poll for a result. Consumes and resolves immediately when a result
96 * is already stored; otherwise holds until `publishOAuthResult` fires for
97 * the sessionId, the deadline elapses, or `signal` aborts (client gone).
98 * The latter two resolve `null` — the same "still pending" answer an
99 * immediate poll gives — so the caller's retry loop keeps working. A
100 * waiter that times out or aborts is always removed from the registry.
101 *
102 * Holding is bounded (see the waiter registry above): when the session
103 * already has a held waiter, or the global held-waiter ceiling is reached,
104 * this answers `null` immediately instead of holding.
105 */
106export const waitForOAuthResult = (
107 sessionId: string,
108 opts: { readonly timeoutMs: number; readonly signal?: AbortSignal },
109): Promise<AnyResult | null> => {
110 const immediate = consumeOAuthResult(sessionId);
111 if (immediate !== null) return Promise.resolve(immediate);
112 if (opts.timeoutMs <= 0 || opts.signal?.aborted === true) return Promise.resolve(null);
113 if (waiters.has(sessionId) || waiters.size >= MAX_HELD_WAITERS_TOTAL) {
114 return Promise.resolve(null);
115 }
116
117 return new Promise((resolve) => {
118 let done = false;
119 const finish = (result: AnyResult | null) => {
120 if (done) return;
121 done = true;
122 clearTimeout(timer);
123 opts.signal?.removeEventListener("abort", onAbort);
124 removeWaiter(sessionId, wake);
125 resolve(result);
126 };
127 const wake = () => finish(consumeOAuthResult(sessionId));
128 const onAbort = () => finish(null);
129 const timer = setTimeout(() => finish(null), opts.timeoutMs);
130
131 waiters.set(sessionId, wake);
132 opts.signal?.addEventListener("abort", onAbort, { once: true });
133 });
134};
135
136/** Test-only — clears the store and resolves any held waiters as pending. */
63export const __resetOAuthResultStoreForTests = (): void => {137export const __resetOAuthResultStoreForTests = (): void => {
64 store.clear();138 store.clear();
139 for (const sessionId of [...waiters.keys()]) wakeWaiter(sessionId);
65};140};
141
142/** Test-only — number of held long-poll waiters for a sessionId (0 or 1). */
143export const __oauthAwaitWaiterCountForTests = (sessionId: string): number =>
144 waiters.has(sessionId) ? 1 : 0;
145
146/** Test-only — total held long-poll waiters across all sessions. */
147export const __oauthAwaitHeldWaiterTotalForTests = (): number => waiters.size;
apps/local/src/serve.test.ts+124−0
@@ -3,7 +3,16 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } f · 2 unmodified lines
3import { tmpdir } from "node:os";3import { tmpdir } from "node:os";
4import { join } from "node:path";4import { join } from "node:path";
5 5
6import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult } from "@executor-js/sdk";
7
6import { acquireDataDirOwnership } from "./db/data-dir-ownership";8import { acquireDataDirOwnership } from "./db/data-dir-ownership";
9import {
10 __oauthAwaitHeldWaiterTotalForTests,
11 __oauthAwaitWaiterCountForTests,
12 __resetOAuthResultStoreForTests,
13 consumeOAuthResult,
14 publishOAuthResult,
15} from "./oauth-result-store";
7import { startServer, type ServerInstance } from "./serve";16import { startServer, type ServerInstance } from "./serve";
8 17
9let clientDir: string;18let clientDir: string;
@@ -219,6 +228,121 @@ describe("startServer bearer auth", () => { · 209 unmodified lines
219 });228 });
220});229});
221 230
231describe("startServer OAuth await long-poll", () => {
232 afterEach(() => {
233 __resetOAuthResultStoreForTests();
234 });
235
236 const untilWaiterCount = async (sessionId: string, count: number): Promise<void> => {
237 const deadline = Date.now() + 2000;
238 while (__oauthAwaitWaiterCountForTests(sessionId) !== count && Date.now() < deadline) {
239 await new Promise<void>((resolve) => setTimeout(resolve, 5));
240 }
241 expect(__oauthAwaitWaiterCountForTests(sessionId)).toBe(count);
242 };
243
244 const awaitResult = (sessionId: string): OAuthPopupResult<unknown> => ({
245 type: OAUTH_POPUP_MESSAGE_TYPE,
246 ok: false,
247 sessionId,
248 error: "access denied",
249 });
250
251 it("holds the await request open and answers the moment the result publishes", async () => {
252 const baseUrl = await startTestServer();
253
254 const pending = fetch(`${baseUrl}/api/oauth/await/session-hold`, {
255 headers: { authorization: `Bearer ${TOKEN}` },
256 });
257 // The request is being held, not answered null immediately.
258 await untilWaiterCount("session-hold", 1);
259
260 const publishedAt = Date.now();
261 publishOAuthResult(awaitResult("session-hold"));
262 const response = await pending;
263 const body = (await response.json()) as unknown;
264
265 // Resolved by the publish, not a poll tick or the 25s deadline.
266 expect(Date.now() - publishedAt).toBeLessThan(1000);
267 expect(body).toMatchObject({ sessionId: "session-hold", ok: false });
268 expect(__oauthAwaitWaiterCountForTests("session-hold")).toBe(0);
269 });
270
271 it("answers a pre-published result without waiting", async () => {
272 const baseUrl = await startTestServer();
273 publishOAuthResult(awaitResult("session-ready"));
274
275 const response = await fetch(`${baseUrl}/api/oauth/await/session-ready`, {
276 headers: { authorization: `Bearer ${TOKEN}` },
277 });
278 const body = (await response.json()) as unknown;
279
280 expect(body).toMatchObject({ sessionId: "session-ready" });
281 // One-shot: a second poll for the same session finds nothing.
282 expect(consumeOAuthResult("session-ready")).toBeNull();
283 });
284
285 it("drops the waiter when the client disconnects", async () => {
286 const baseUrl = await startTestServer();
287
288 const controller = new AbortController();
289 const pending = fetch(`${baseUrl}/api/oauth/await/session-gone`, {
290 headers: { authorization: `Bearer ${TOKEN}` },
291 signal: controller.signal,
292 });
293 await untilWaiterCount("session-gone", 1);
294
295 controller.abort();
296 await expect(pending).rejects.toThrow();
297 await untilWaiterCount("session-gone", 0);
298
299 // The dead waiter must not consume a result published afterwards.
300 publishOAuthResult(awaitResult("session-gone"));
301 expect(consumeOAuthResult("session-gone")).toMatchObject({ sessionId: "session-gone" });
302 });
303
304 it("holds one request per session; stacked requests answer null instantly and one consumer wins", async () => {
305 // The mixed-version rollout hazard: an unpatched desktop client polls
306 // every second and would stack ~25 concurrent requests per flow against
307 // a holding server. Only the first request may hold — the rest must get
308 // the pre-long-poll instant "still pending" answer.
309 const baseUrl = await startTestServer();
310
311 const held = fetch(`${baseUrl}/api/oauth/await/session-stacked`, {
312 headers: { authorization: `Bearer ${TOKEN}` },
313 });
314 // Deterministic ordering: the first request is registered before any
315 // stacked request is issued.
316 await untilWaiterCount("session-stacked", 1);
317
318 const stacked = await Promise.all(
319 [1, 2, 3].map(() =>
320 fetch(`${baseUrl}/api/oauth/await/session-stacked`, {
321 headers: { authorization: `Bearer ${TOKEN}` },
322 }),
323 ),
324 );
325 // The stacked requests settled BEFORE any publish — instant null answers,
326 // not held connections.
327 for (const response of stacked) {
328 expect(response.status).toBe(200);
329 expect(await response.json()).toBeNull();
330 }
331 expect(__oauthAwaitWaiterCountForTests("session-stacked")).toBe(1);
332 expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(1);
333
334 publishOAuthResult(awaitResult("session-stacked"));
335 const response = await held;
336 // Exactly one consumer overall receives the one-shot result: the held
337 // request. Nothing is left behind for a later poll.
338 expect(await response.json()).toMatchObject({ sessionId: "session-stacked" });
339 expect(consumeOAuthResult("session-stacked")).toBeNull();
340 // The registry drains completely — no leaked waiters.
341 expect(__oauthAwaitWaiterCountForTests("session-stacked")).toBe(0);
342 expect(__oauthAwaitHeldWaiterTotalForTests()).toBe(0);
343 });
344});
345
222describe("startServer CORS hardening", () => {346describe("startServer CORS hardening", () => {
223 it("reflects credentialed CORS only for allowed loopback origins", async () => {347 it("reflects credentialed CORS only for allowed loopback origins", async () => {
224 const baseUrl = await startTestServer();348 const baseUrl = await startTestServer();
apps/local/src/serve.ts+18−2
@@ -13,7 +13,7 @@ import type { Subprocess } from "bun"; · 12 unmodified lines
13import { setOAuthCompletionListener } from "@executor-js/api";13import { setOAuthCompletionListener } from "@executor-js/api";
14import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";14import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
15import { loadOrMintLocalAuthToken } from "./auth";15import { loadOrMintLocalAuthToken } from "./auth";
16import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";16import { publishOAuthResult, waitForOAuthResult } from "./oauth-result-store";
17import { disposeAnalytics } from "./analytics";17import { disposeAnalytics } from "./analytics";
18import { startIntegrationsRefresh } from "./integrations";18import { startIntegrationsRefresh } from "./integrations";
19import { disposeServerHandlers, getServerHandlers } from "./main";19import { disposeServerHandlers, getServerHandlers } from "./main";
@@ -27,6 +27,11 @@ import { · 7 unmodified lines
27 normalizeCredential,27 normalizeCredential,
28} from "./serve-shared";28} from "./serve-shared";
29 29
30// How long one /api/oauth/await request is held open waiting for the OAuth
31// completion to be published. Shorter than the client's overall timeout; the
32// client reconnects after each deadline, so this only bounds a single hold.
33const OAUTH_AWAIT_LONG_POLL_DEADLINE_MS = 25_000;
34
30// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
31// Static files36// Static files
32// ---------------------------------------------------------------------------37// ---------------------------------------------------------------------------
@@ -457,9 +462,20 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 424 unmodified lines
457 // OAuth result polling — local-only, served outside the typed API462 // OAuth result polling — local-only, served outside the typed API
458 // because cloud (Cloudflare Workers, stateless) can't back the463 // because cloud (Cloudflare Workers, stateless) can't back the
459 // in-memory store. See setOAuthCompletionListener above.464 // in-memory store. See setOAuthCompletionListener above.
465 // Long-poll: hold the request until the completion listener publishes
466 // for this session, then answer immediately instead of making the
467 // client wait for its next poll tick. On deadline (or client
468 // disconnect) answer null — "still pending" — so the client's outer
469 // retry loop reconnects. `idleTimeout: 0` above permits the hold.
470 // Holds are bounded (one per session, small global cap — see
471 // oauth-result-store.ts); over-cap requests answer immediately with
472 // the current store value, i.e. the pre-long-poll poll behavior.
460 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);473 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
461 if (awaitMatch && req.method === "GET") {474 if (awaitMatch && req.method === "GET") {
462 const result = consumeOAuthResult(awaitMatch[1]);475 const result = await waitForOAuthResult(awaitMatch[1], {
476 timeoutMs: OAUTH_AWAIT_LONG_POLL_DEADLINE_MS,
477 signal: req.signal,
478 });
463 return withCors(479 return withCors(
464 new Response(JSON.stringify(result), {480 new Response(JSON.stringify(result), {
465 headers: { "content-type": "application/json" },481 headers: { "content-type": "application/json" },
bun.lock+58−32
@@ -31,7 +31,7 @@ · 30 unmodified lines
31 },31 },
32 "apps/cli": {32 "apps/cli": {
33 "name": "executor",33 "name": "executor",
34 "version": "1.6.4",34 "version": "1.6.6",
35 "bin": {35 "bin": {
36 "executor": "./bin/executor.ts",36 "executor": "./bin/executor.ts",
37 },37 },
@@ -60,7 +60,7 @@ · 22 unmodified lines
60 },60 },
61 "apps/cloud": {61 "apps/cloud": {
62 "name": "@executor-js/cloud",62 "name": "@executor-js/cloud",
63 "version": "1.4.65",63 "version": "1.4.67",
64 "dependencies": {64 "dependencies": {
65 "@cloudflare/vite-plugin": "^1.31.1",65 "@cloudflare/vite-plugin": "^1.31.1",
66 "@effect/atom-react": "catalog:",66 "@effect/atom-react": "catalog:",
@@ -133,7 +133,7 @@ · 66 unmodified lines
133 },133 },
134 "apps/desktop": {134 "apps/desktop": {
135 "name": "@executor-js/desktop",135 "name": "@executor-js/desktop",
136 "version": "1.6.4",136 "version": "1.6.6",
137 "dependencies": {137 "dependencies": {
138 "@sentry/bun": "^10.57.0",138 "@sentry/bun": "^10.57.0",
139 "@sentry/electron": "^7.13.0",139 "@sentry/electron": "^7.13.0",
@@ -220,7 +220,7 @@ · 80 unmodified lines
220 },220 },
221 "apps/host-selfhost": {221 "apps/host-selfhost": {
222 "name": "@executor-js/host-selfhost",222 "name": "@executor-js/host-selfhost",
223 "version": "0.0.46",223 "version": "0.0.48",
224 "dependencies": {224 "dependencies": {
225 "@better-auth/api-key": "^1.6.11",225 "@better-auth/api-key": "^1.6.11",
226 "@cloudflare/worker-bundler": "0.2.1",226 "@cloudflare/worker-bundler": "0.2.1",
@@ -272,7 +272,7 @@ · 45 unmodified lines
272 },272 },
273 "apps/local": {273 "apps/local": {
274 "name": "@executor-js/local",274 "name": "@executor-js/local",
275 "version": "1.6.4",275 "version": "1.6.6",
276 "dependencies": {276 "dependencies": {
277 "@effect/atom-react": "catalog:",277 "@effect/atom-react": "catalog:",
278 "@effect/platform-node": "catalog:",278 "@effect/platform-node": "catalog:",
@@ -352,7 +352,7 @@ · 73 unmodified lines
352 },352 },
353 "e2e": {353 "e2e": {
354 "name": "@executor-js/e2e",354 "name": "@executor-js/e2e",
355 "version": "0.0.44",355 "version": "0.0.46",
356 "dependencies": {356 "dependencies": {
357 "@executor-js/api": "workspace:*",357 "@executor-js/api": "workspace:*",
358 "@executor-js/emulate": "^0.14.1",358 "@executor-js/emulate": "^0.14.1",
@@ -387,7 +387,7 @@ · 28 unmodified lines
387 },387 },
388 "examples/all-plugins": {388 "examples/all-plugins": {
389 "name": "@executor-js/example-all-plugins",389 "name": "@executor-js/example-all-plugins",
390 "version": "0.0.65",390 "version": "0.0.67",
391 "dependencies": {391 "dependencies": {
392 "@executor-js/plugin-file-secrets": "workspace:*",392 "@executor-js/plugin-file-secrets": "workspace:*",
393 "@executor-js/plugin-graphql": "workspace:*",393 "@executor-js/plugin-graphql": "workspace:*",
@@ -406,7 +406,7 @@ · 12 unmodified lines
406 },406 },
407 "examples/docs-sdk-quickstart": {407 "examples/docs-sdk-quickstart": {
408 "name": "@executor-js/example-docs-sdk-quickstart",408 "name": "@executor-js/example-docs-sdk-quickstart",
409 "version": "0.0.50",409 "version": "0.0.52",
410 "dependencies": {410 "dependencies": {
411 "@executor-js/plugin-openapi": "workspace:*",411 "@executor-js/plugin-openapi": "workspace:*",
412 "@executor-js/sdk": "workspace:*",412 "@executor-js/sdk": "workspace:*",
@@ -463,7 +463,7 @@ · 50 unmodified lines
463 },463 },
464 "packages/core/analytics": {464 "packages/core/analytics": {
465 "name": "@executor-js/analytics",465 "name": "@executor-js/analytics",
466 "version": "0.1.11",466 "version": "0.1.13",
467 "dependencies": {467 "dependencies": {
468 "@effect/platform-node": "catalog:",468 "@effect/platform-node": "catalog:",
469 "@executor-js/execution": "workspace:*",469 "@executor-js/execution": "workspace:*",
@@ -479,7 +479,7 @@ · 9 unmodified lines
479 },479 },
480 "packages/core/api": {480 "packages/core/api": {
481 "name": "@executor-js/api",481 "name": "@executor-js/api",
482 "version": "1.4.67",482 "version": "1.4.69",
483 "dependencies": {483 "dependencies": {
484 "@executor-js/execution": "workspace:*",484 "@executor-js/execution": "workspace:*",
485 "@executor-js/host-mcp": "workspace:*",485 "@executor-js/host-mcp": "workspace:*",
@@ -496,7 +496,7 @@ · 10 unmodified lines
496 },496 },
497 "packages/core/cli": {497 "packages/core/cli": {
498 "name": "@executor-js/cli",498 "name": "@executor-js/cli",
499 "version": "0.2.54",499 "version": "0.2.56",
500 "bin": {500 "bin": {
501 "executor-sdk": "./dist/index.js",501 "executor-sdk": "./dist/index.js",
502 },502 },
@@ -517,7 +517,7 @@ · 14 unmodified lines
517 },517 },
518 "packages/core/config": {518 "packages/core/config": {
519 "name": "@executor-js/config",519 "name": "@executor-js/config",
520 "version": "1.6.4",520 "version": "1.6.6",
521 "dependencies": {521 "dependencies": {
522 "@executor-js/sdk": "workspace:*",522 "@executor-js/sdk": "workspace:*",
523 "jiti": "^2.6.1",523 "jiti": "^2.6.1",
@@ -538,7 +538,7 @@ · 14 unmodified lines
538 },538 },
539 "packages/core/execution": {539 "packages/core/execution": {
540 "name": "@executor-js/execution",540 "name": "@executor-js/execution",
541 "version": "1.6.4",541 "version": "1.6.6",
542 "dependencies": {542 "dependencies": {
543 "@executor-js/codemode-core": "workspace:*",543 "@executor-js/codemode-core": "workspace:*",
544 "@executor-js/sdk": "workspace:*",544 "@executor-js/sdk": "workspace:*",
@@ -604,7 +604,7 @@ · 59 unmodified lines
604 },604 },
605 "packages/core/sdk": {605 "packages/core/sdk": {
606 "name": "@executor-js/sdk",606 "name": "@executor-js/sdk",
607 "version": "1.6.4",607 "version": "1.6.6",
608 "dependencies": {608 "dependencies": {
609 "@executor-js/fumadb": "workspace:*",609 "@executor-js/fumadb": "workspace:*",
610 "@standard-schema/spec": "^1.1.0",610 "@standard-schema/spec": "^1.1.0",
@@ -657,7 +657,7 @@ · 46 unmodified lines
657 },657 },
658 "packages/core/vite-plugin": {658 "packages/core/vite-plugin": {
659 "name": "@executor-js/vite-plugin",659 "name": "@executor-js/vite-plugin",
660 "version": "0.0.64",660 "version": "0.0.66",
661 "dependencies": {661 "dependencies": {
662 "@executor-js/sdk": "workspace:*",662 "@executor-js/sdk": "workspace:*",
663 "jiti": "^2.6.1",663 "jiti": "^2.6.1",
@@ -677,7 +677,7 @@ · 13 unmodified lines
677 },677 },
678 "packages/hosts/cloudflare": {678 "packages/hosts/cloudflare": {
679 "name": "@executor-js/cloudflare",679 "name": "@executor-js/cloudflare",
680 "version": "0.0.46",680 "version": "0.0.48",
681 "dependencies": {681 "dependencies": {
682 "@executor-js/api": "workspace:*",682 "@executor-js/api": "workspace:*",
683 "@executor-js/execution": "workspace:*",683 "@executor-js/execution": "workspace:*",
@@ -718,7 +718,7 @@ · 34 unmodified lines
718 },718 },
719 "packages/hosts/mcp-apps-shell": {719 "packages/hosts/mcp-apps-shell": {
720 "name": "@executor-js/mcp-apps-shell",720 "name": "@executor-js/mcp-apps-shell",
721 "version": "1.4.15",721 "version": "1.4.17",
722 "dependencies": {722 "dependencies": {
723 "@executor-js/react": "workspace:*",723 "@executor-js/react": "workspace:*",
724 "@executor-js/runtime-quickjs": "workspace:*",724 "@executor-js/runtime-quickjs": "workspace:*",
@@ -756,7 +756,7 @@ · 31 unmodified lines
756 },756 },
757 "packages/kernel/core": {757 "packages/kernel/core": {
758 "name": "@executor-js/codemode-core",758 "name": "@executor-js/codemode-core",
759 "version": "1.6.4",759 "version": "1.6.6",
760 "dependencies": {760 "dependencies": {
761 "@babel/parser": "^7.29.2",761 "@babel/parser": "^7.29.2",
762 "@standard-schema/spec": "^1.0.0",762 "@standard-schema/spec": "^1.0.0",
@@ -829,7 +829,7 @@ · 66 unmodified lines
829 },829 },
830 "packages/kernel/runtime-quickjs": {830 "packages/kernel/runtime-quickjs": {
831 "name": "@executor-js/runtime-quickjs",831 "name": "@executor-js/runtime-quickjs",
832 "version": "1.6.4",832 "version": "1.6.6",
833 "dependencies": {833 "dependencies": {
834 "@executor-js/codemode-core": "workspace:*",834 "@executor-js/codemode-core": "workspace:*",
835 "quickjs-emscripten": "catalog:",835 "quickjs-emscripten": "catalog:",
@@ -849,7 +849,7 @@ · 13 unmodified lines
849 },849 },
850 "packages/kernel/runtime-workerd-subprocess": {850 "packages/kernel/runtime-workerd-subprocess": {
851 "name": "@executor-js/runtime-workerd-subprocess",851 "name": "@executor-js/runtime-workerd-subprocess",
852 "version": "0.0.19",852 "version": "0.0.21",
853 "dependencies": {853 "dependencies": {
854 "@executor-js/codemode-core": "workspace:*",854 "@executor-js/codemode-core": "workspace:*",
855 "effect": "catalog:",855 "effect": "catalog:",
@@ -862,9 +862,33 @@ · 6 unmodified lines
862 "vitest": "catalog:",862 "vitest": "catalog:",
863 },863 },
864 },864 },
865 "packages/onboarding-demo": {
866 "name": "@executor-js/onboarding-demo",
867 "version": "0.0.1",
868 "dependencies": {
869 "@executor-js/plugin-mcp": "workspace:*",
870 "@executor-js/plugin-openapi": "workspace:*",
871 "@executor-js/react": "workspace:*",
872 "@executor-js/sdk": "workspace:*",
873 "lucide-react": "^1.7.0",
874 "react": "catalog:",
875 "react-dom": "catalog:",
876 "sonner": "^2.0.7",
877 "tldts": "^7.0.28",
878 },
879 "devDependencies": {
880 "@tailwindcss/vite": "catalog:",
881 "@types/react": "catalog:",
882 "@types/react-dom": "catalog:",
883 "@vitejs/plugin-react": "catalog:",
884 "tailwindcss": "catalog:",
885 "typescript": "catalog:",
886 "vite": "catalog:",
887 },
888 },
865 "packages/plugins/desktop-settings": {889 "packages/plugins/desktop-settings": {
866 "name": "@executor-js/plugin-desktop-settings",890 "name": "@executor-js/plugin-desktop-settings",
867 "version": "1.6.4",891 "version": "1.6.6",
868 "dependencies": {892 "dependencies": {
869 "@executor-js/sdk": "workspace:*",893 "@executor-js/sdk": "workspace:*",
870 "react": "catalog:",894 "react": "catalog:",
@@ -877,7 +901,7 @@ · 6 unmodified lines
877 },901 },
878 "packages/plugins/encrypted-secrets": {902 "packages/plugins/encrypted-secrets": {
879 "name": "@executor-js/plugin-encrypted-secrets",903 "name": "@executor-js/plugin-encrypted-secrets",
880 "version": "0.0.46",904 "version": "0.0.48",
881 "dependencies": {905 "dependencies": {
882 "@executor-js/sdk": "workspace:*",906 "@executor-js/sdk": "workspace:*",
883 "effect": "catalog:",907 "effect": "catalog:",
@@ -892,7 +916,7 @@ · 8 unmodified lines
892 },916 },
893 "packages/plugins/example": {917 "packages/plugins/example": {
894 "name": "@executor-js/plugin-example",918 "name": "@executor-js/plugin-example",
895 "version": "1.6.4",919 "version": "1.6.6",
896 "dependencies": {920 "dependencies": {
897 "@executor-js/sdk": "workspace:*",921 "@executor-js/sdk": "workspace:*",
898 },922 },
@@ -915,7 +939,7 @@ · 16 unmodified lines
915 },939 },
916 "packages/plugins/file-secrets": {940 "packages/plugins/file-secrets": {
917 "name": "@executor-js/plugin-file-secrets",941 "name": "@executor-js/plugin-file-secrets",
918 "version": "1.6.4",942 "version": "1.6.6",
919 "dependencies": {943 "dependencies": {
920 "@executor-js/sdk": "workspace:*",944 "@executor-js/sdk": "workspace:*",
921 },945 },
@@ -932,7 +956,7 @@ · 10 unmodified lines
932 },956 },
933 "packages/plugins/graphql": {957 "packages/plugins/graphql": {
934 "name": "@executor-js/plugin-graphql",958 "name": "@executor-js/plugin-graphql",
935 "version": "1.6.4",959 "version": "1.6.6",
936 "dependencies": {960 "dependencies": {
937 "@effect/platform-node": "catalog:",961 "@effect/platform-node": "catalog:",
938 "@executor-js/config": "workspace:*",962 "@executor-js/config": "workspace:*",
@@ -971,7 +995,7 @@ · 32 unmodified lines
971 },995 },
972 "packages/plugins/keychain": {996 "packages/plugins/keychain": {
973 "name": "@executor-js/plugin-keychain",997 "name": "@executor-js/plugin-keychain",
974 "version": "1.6.4",998 "version": "1.6.6",
975 "dependencies": {999 "dependencies": {
976 "@executor-js/sdk": "workspace:*",1000 "@executor-js/sdk": "workspace:*",
977 "@napi-rs/keyring": "^1.2.0",1001 "@napi-rs/keyring": "^1.2.0",
@@ -990,7 +1014,7 @@ · 12 unmodified lines
990 },1014 },
991 "packages/plugins/mcp": {1015 "packages/plugins/mcp": {
992 "name": "@executor-js/plugin-mcp",1016 "name": "@executor-js/plugin-mcp",
993 "version": "1.6.4",1017 "version": "1.6.6",
994 "dependencies": {1018 "dependencies": {
995 "@cfworker/json-schema": "^4.1.1",1019 "@cfworker/json-schema": "^4.1.1",
996 "@effect/platform-node": "catalog:",1020 "@effect/platform-node": "catalog:",
@@ -1034,7 +1058,7 @@ · 37 unmodified lines
1034 },1058 },
1035 "packages/plugins/onepassword": {1059 "packages/plugins/onepassword": {
1036 "name": "@executor-js/plugin-onepassword",1060 "name": "@executor-js/plugin-onepassword",
1037 "version": "1.6.4",1061 "version": "1.6.6",
1038 "dependencies": {1062 "dependencies": {
1039 "@1password/sdk": "^0.4.1-beta.1",1063 "@1password/sdk": "^0.4.1-beta.1",
1040 "@effect/atom-react": "catalog:",1064 "@effect/atom-react": "catalog:",
@@ -1067,7 +1091,7 @@ · 26 unmodified lines
1067 },1091 },
1068 "packages/plugins/openapi": {1092 "packages/plugins/openapi": {
1069 "name": "@executor-js/plugin-openapi",1093 "name": "@executor-js/plugin-openapi",
1070 "version": "1.6.4",1094 "version": "1.6.6",
1071 "dependencies": {1095 "dependencies": {
1072 "@effect/platform-node": "catalog:",1096 "@effect/platform-node": "catalog:",
1073 "@executor-js/config": "workspace:*",1097 "@executor-js/config": "workspace:*",
@@ -1108,7 +1132,7 @@ · 34 unmodified lines
1108 },1132 },
1109 "packages/plugins/provider-service-split": {1133 "packages/plugins/provider-service-split": {
1110 "name": "@executor-js/plugin-provider-service-split",1134 "name": "@executor-js/plugin-provider-service-split",
1111 "version": "0.0.18",1135 "version": "0.0.20",
1112 "dependencies": {1136 "dependencies": {
1113 "@executor-js/plugin-openapi": "workspace:*",1137 "@executor-js/plugin-openapi": "workspace:*",
1114 "@executor-js/sdk": "workspace:*",1138 "@executor-js/sdk": "workspace:*",
@@ -1125,7 +1149,7 @@ · 10 unmodified lines
1125 },1149 },
1126 "packages/plugins/toolkits": {1150 "packages/plugins/toolkits": {
1127 "name": "@executor-js/plugin-toolkits",1151 "name": "@executor-js/plugin-toolkits",
1128 "version": "1.5.39",1152 "version": "1.5.41",
1129 "dependencies": {1153 "dependencies": {
1130 "@executor-js/sdk": "workspace:*",1154 "@executor-js/sdk": "workspace:*",
1131 },1155 },
@@ -1194,7 +1218,7 @@ · 62 unmodified lines
1194 },1218 },
1195 "packages/react": {1219 "packages/react": {
1196 "name": "@executor-js/react",1220 "name": "@executor-js/react",
1197 "version": "1.4.67",1221 "version": "1.4.69",
1198 "dependencies": {1222 "dependencies": {
1199 "@base-ui/react": "^1.3.0",1223 "@base-ui/react": "^1.3.0",
1200 "@effect/atom-react": "catalog:",1224 "@effect/atom-react": "catalog:",
@@ -1804,6 +1828,8 @@ · 603 unmodified lines
1804 1828
1805 "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="],1829 "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="],
1806 1830
1831 "@executor-js/onboarding-demo": ["@executor-js/onboarding-demo@workspace:packages/onboarding-demo"],
1832
1807 "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"],1833 "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"],
1808 1834
1809 "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"],1835 "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"],
e2e/CHANGELOG.md+24−0
@@ -1,5 +1,29 @@
1# @executor-js/e2e1# @executor-js/e2e
2 2
3## 0.0.46
4
5### Patch Changes
6
7- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed)]:
8 - @executor-js/plugin-mcp@1.6.6
9 - @executor-js/api@1.4.69
10 - @executor-js/plugin-graphql@1.6.6
11 - @executor-js/plugin-openapi@1.6.6
12 - @executor-js/plugin-toolkits@1.5.41
13 - @executor-js/sdk@1.6.6
14
15## 0.0.45
16
17### Patch Changes
18
19- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]:
20 - @executor-js/plugin-mcp@1.6.5
21 - @executor-js/sdk@1.6.5
22 - @executor-js/plugin-graphql@1.6.5
23 - @executor-js/plugin-openapi@1.6.5
24 - @executor-js/api@1.4.68
25 - @executor-js/plugin-toolkits@1.5.40
26
3## 0.0.4427## 0.0.44
4 28
5### Patch Changes29### Patch Changes
e2e/cloud/frontend-error-reporting.test.ts+3−1
@@ -73,7 +73,9 @@ scenario( · 72 unmodified lines
73 73
74 await step("Open the integrations console", async () => {74 await step("Open the integrations console", async () => {
75 await visit(page, "/integrations");75 await visit(page, "/integrations");
76 await page.getByRole("button", { name: "Connect" }).first().waitFor();76 // The connect dialog became the full-page picker; the header's Add
77 // link is the page's stable loaded-signal now.
78 await page.getByRole("link", { name: "Add integration" }).first().waitFor();
77 });79 });
78 80
79 let faulted = 0;81 let faulted = 0;
e2e/local/codex-plugins.test.ts+15−2
@@ -143,23 +143,28 @@ scenario( · 142 unmodified lines
143 });143 });
144 }144 }
145 // Curated entries carry the app-server bridge recipe: `codex145 // Curated entries carry the app-server bridge recipe: `codex
146 // app-server` plus the server name the bridge calls tools on.146 // app-server`, the server name the bridge calls tools on, and the
147 // preset it came from — that last one is what lets a macOS refusal
148 // name the exact grant to enable.
147 const messages = byId.get("codex-messages");149 const messages = byId.get("codex-messages");
148 expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe(150 expect(messages?.command.endsWith("codex"), "curated entries spawn the codex CLI").toBe(
149 true,151 true,
150 );152 );
151 expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]);153 expect(messages?.args, "curated entries run the app-server").toEqual(["app-server"]);
152 expect(messages?.appServer, "curated entries name their Codex server").toEqual({154 expect(messages?.appServer, "curated entries name their Codex server").toEqual({
155 presetId: "codex-messages",
153 server: "messages",156 server: "messages",
154 });157 });
155 // Computer Use and Chrome have no server of their own: both are158 // Computer Use and Chrome have no server of their own: both are
156 // projected onto `node_repl`, and Chrome carries the client module159 // projected onto `node_repl`, and Chrome carries the client module
157 // its surface imports, resolved through the `latest` symlink.160 // its surface imports, resolved through the `latest` symlink.
158 expect(byId.get("codex-computer-use")?.appServer).toEqual({161 expect(byId.get("codex-computer-use")?.appServer).toEqual({
162 presetId: "codex-computer-use",
159 server: "node_repl",163 server: "node_repl",
160 surface: "sky",164 surface: "sky",
161 });165 });
162 expect(byId.get("codex-chrome")?.appServer).toEqual({166 expect(byId.get("codex-chrome")?.appServer).toEqual({
167 presetId: "codex-chrome",
163 server: "node_repl",168 server: "node_repl",
164 surface: "browser",169 surface: "browser",
165 modulePath: join(codexHome, CHROME_CLIENT_RELATIVE),170 modulePath: join(codexHome, CHROME_CLIENT_RELATIVE),
@@ -178,7 +183,9 @@ scenario( · 12 unmodified lines
178 command: plugin.command,183 command: plugin.command,
179 args: [...plugin.args],184 args: [...plugin.args],
180 ...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }),185 ...(plugin.cwd === undefined ? {} : { cwd: plugin.cwd }),
181 ...(plugin.env === undefined ? {} : { env: { ...plugin.env } }),186 // Static, not `env`: CODEX_HOME is a machine path the scanner
187 // resolved, so it must not become a credential to type.
188 ...(plugin.env === undefined ? {} : { staticEnv: { ...plugin.env } }),
182 ...(plugin.appServer === undefined189 ...(plugin.appServer === undefined
183 ? {}190 ? {}
184 : { appServer: { server: plugin.appServer.server } }),191 : { appServer: { server: plugin.appServer.server } }),
@@ -200,6 +207,12 @@ scenario( · 15 unmodified lines
200 connections.map((connection) => String(connection.name)),207 connections.map((connection) => String(connection.name)),
201 `${slug} auto-connected`,208 `${slug} auto-connected`,
202 ).toContain("default");209 ).toContain("default");
210 // Nothing to configure: the connection carries no credential, so
211 // the person is never shown a field for a path we already know.
212 expect(
213 connections.map((connection) => String(connection.template)),
214 `${slug} needs no credential`,
215 ).toEqual(["none"]);
203 216
204 const tools = yield* client.tools.list({ query: { integration: slug } });217 const tools = yield* client.tools.list({ query: { integration: slug } });
205 const names = tools.map((tool) => tool.name);218 const names = tools.map((tool) => tool.name);
e2e/local/oauth-await-longpoll.test.ts+265−0
@@ -0,0 +1,265 @@
1// The desktop system-browser OAuth flow: the renderer cannot receive a
2// postMessage from the user's external browser, so it asks the local server
3// for the result at `/api/oauth/await/:sessionId`. Guarantee under test: the
4// server LONG-POLLS that route — a request issued while the flow is still in
5// flight is held open and answered the instant the provider callback
6// completes, instead of answered `null` (which made the renderer wait up to a
7// full 1s poll tick with the result already sitting in memory).
8//
9// The journey, against a real `executor web` boot:
10//
11// 1. `oauth.start` a connect → the await sessionId (= `started.state`).
12// 2. Issue an await BEFORE the provider callback. It must be held open — a
13// pre-long-poll server answers `null` immediately, which this scenario
14// rejects explicitly. A SECOND await for the same session must answer
15// `null` instantly instead of holding: held waiters are capped at one
16// per session, so an unpatched 1s-interval client degrades to exactly
17// the old poll behavior instead of stacking held connections.
18// 3. Complete the provider consent and land the REAL `/api/oauth/callback`
19// redirect (the path that publishes the result and wakes waiters).
20// 4. The held request resolves promptly (well under the old 1s poll tick)
21// and carries the ok result — single-consumer delivery of the one-shot
22// result.
23import { randomBytes } from "node:crypto";
24import { writeFileSync } from "node:fs";
25import { join } from "node:path";
26
27import { expect } from "@effect/vitest";
28import { Effect } from "effect";
29import { HttpApiClient } from "effect/unstable/httpapi";
30import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
31import { composePluginApi } from "@executor-js/api/server";
32import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
33import { makeEchoMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
34import {
35 AuthTemplateSlug,
36 ConnectionName,
37 IntegrationSlug,
38 OAuthClientSlug,
39} from "@executor-js/sdk/shared";
40import { serveOAuthTestServer } from "@executor-js/sdk/testing";
41
42import { scenario } from "../src/scenario";
43import { Cli, RunDir } from "../src/services";
44import { withLocalServer, type ServerHandle } from "./local-server";
45
46const api = composePluginApi([mcpHttpPlugin()] as const);
47
48const name = ConnectionName.make("default");
49const template = AuthTemplateSlug.make("oauth2");
50
51// The old server answered a pending await with `null` immediately; the new
52// one holds it (up to 25s per hold). Observing "still unresolved" after this
53// window separates the two deterministically: a holding server cannot answer
54// within it, and an answer within it IS the regression.
55const HELD_OBSERVATION_MS = 600;
56
57// The renderer used to learn about completion up to a full poll tick (1s)
58// after the callback. The held request must beat that with margin for CI
59// jitter.
60const PROMPT_RESOLUTION_BUDGET_MS = 900;
61
62const apiClient = (server: ServerHandle) =>
63 HttpApiClient.make(api, {
64 baseUrl: new URL("/api", server.origin).toString(),
65 transformClient: HttpClient.mapRequest((request) =>
66 HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
67 ),
68 }).pipe(Effect.provide(FetchHttpClient.layer));
69
70interface AwaitAnswer {
71 /** When the server answered (response headers received). */
72 readonly settledAt: number;
73 readonly status: number;
74 readonly body: unknown;
75}
76
77/** One `/api/oauth/await/:sessionId` request, exactly as the desktop renderer
78 * issues it (bearer-gated GET). The promise settles only when the server
79 * answers — which is the behavior under test. */
80const issueAwait = (server: ServerHandle, sessionId: string): Promise<AwaitAnswer> =>
81 fetch(`${server.origin}/api/oauth/await/${encodeURIComponent(sessionId)}`, {
82 headers: { authorization: `Bearer ${server.token}` },
83 }).then(async (response) => {
84 const settledAt = Date.now();
85 return { settledAt, status: response.status, body: (await response.json()) as unknown };
86 });
87
88/** Drive the test AS's consent (authorize → login) and return the app
89 * callback URL the provider redirects to — carrying code + state. Unlike the
90 * durability scenario this does NOT redeem the code by hand: landing the real
91 * callback is the exact path that publishes the result and wakes waiters. */
92const resolveProviderConsent = (authorizationUrl: string) =>
93 Effect.promise(async () => {
94 const authorize = await fetch(authorizationUrl, { redirect: "manual" });
95 const loginUrl = authorize.headers.get("location");
96 if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
97 const login = await fetch(loginUrl, {
98 method: "POST",
99 headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` },
100 redirect: "manual",
101 });
102 const callbackUrl = login.headers.get("location");
103 if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
104 return callbackUrl;
105 });
106
107scenario(
108 "Local · a held OAuth await answers the moment the provider callback lands",
109 {},
110 Effect.scoped(
111 Effect.gen(function* () {
112 const cli = yield* Cli;
113 const runDir = yield* RunDir;
114
115 const oauth = yield* serveOAuthTestServer({ scopes: ["mcp.read"] });
116 // A real MCP upstream that accepts the AS's bearers, so the connect is a
117 // genuine end-to-end flow, not a stub handshake.
118 const mcp = yield* serveMcpServer(() => makeEchoMcpServer({ name: "longpoll-mcp" }), {
119 auth: {
120 validateAuthorization: (authorization) => oauth.acceptsAuthorizationHeader(authorization),
121 },
122 });
123
124 const suffix = randomBytes(4).toString("hex");
125 const slug = IntegrationSlug.make(`oauth-longpoll-${suffix}`);
126 const clientSlug = OAuthClientSlug.make(`oauth-longpoll-client-${suffix}`);
127
128 yield* withLocalServer(cli, runDir, (server) =>
129 Effect.gen(function* () {
130 const client = yield* apiClient(server);
131
132 yield* client.mcp.addServer({
133 payload: {
134 transport: "remote",
135 name: "Longpoll MCP",
136 endpoint: mcp.url,
137 slug: String(slug),
138 authenticationTemplate: [{ kind: "oauth2" }],
139 },
140 });
141 yield* client.oauth.createClient({
142 payload: {
143 owner: "org",
144 slug: clientSlug,
145 grant: "authorization_code",
146 authorizationUrl: oauth.authorizationEndpoint,
147 tokenUrl: oauth.tokenEndpoint,
148 clientId: "test-client",
149 clientSecret: "test-secret",
150 resource: oauth.mcpResourceUrl,
151 originIntegration: slug,
152 },
153 });
154
155 const started = yield* client.oauth.start({
156 payload: {
157 client: clientSlug,
158 clientOwner: "org",
159 owner: "org",
160 name,
161 integration: slug,
162 template,
163 },
164 });
165 expect(started.status, "oauth.start redirects to the authorization server").toBe(
166 "redirect",
167 );
168 if (started.status !== "redirect") return yield* Effect.die("no redirect");
169 // The await sessionId is the start state — exactly what the desktop
170 // renderer passes to openOAuthSystemBrowser.
171 const sessionId = started.state;
172
173 // -- 2. Await BEFORE the callback: the request must be held. -------
174 const heldAwait = issueAwait(server, sessionId);
175 const observed = yield* Effect.promise(() =>
176 Promise.race([
177 heldAwait.then(() => "answered" as const),
178 new Promise<"held">((resolve) =>
179 setTimeout(() => resolve("held"), HELD_OBSERVATION_MS),
180 ),
181 ]),
182 );
183 expect(
184 observed,
185 "an await issued mid-flow is held open, not answered null immediately (the pre-long-poll behavior)",
186 ).toBe("held");
187
188 // A second await while one is already held: over the one-per-session
189 // cap, so it answers null instantly — the pre-long-poll behavior an
190 // unpatched 1s-interval client relies on — instead of stacking a
191 // held connection.
192 const overCap = yield* Effect.promise(() =>
193 Promise.race([
194 issueAwait(server, sessionId),
195 new Promise<"held">((resolve) =>
196 setTimeout(() => resolve("held"), HELD_OBSERVATION_MS),
197 ),
198 ]),
199 );
200 expect(overCap, "an over-cap await answers immediately instead of holding").not.toBe(
201 "held",
202 );
203 if (overCap === "held") return yield* Effect.die("over-cap await was held");
204 expect(overCap.status, "an over-cap await answers 200 like an immediate poll").toBe(200);
205 expect(
206 overCap.body,
207 "an over-cap await answers null — still pending — exactly like the old server",
208 ).toBeNull();
209
210 // -- 3. Complete consent and land the real callback redirect. ------
211 const redirected = yield* resolveProviderConsent(started.authorizationUrl);
212 // The harness boots `executor web --port 0` (OS-assigned), and the
213 // server builds the redirect URI from the CONFIGURED port — so the
214 // redirect reads `127.0.0.1:0`. Land the same callback path + query
215 // (code + state, the cryptographic gate) on the server's real bound
216 // origin; it is the same server and the same handler.
217 const callbackUrl = new URL(redirected);
218 expect(
219 callbackUrl.pathname,
220 "the provider redirects back to this server's OAuth callback",
221 ).toBe("/api/oauth/callback");
222 const callbackStartedAt = Date.now();
223 const callback = yield* Effect.promise(() =>
224 fetch(`${server.origin}${callbackUrl.pathname}${callbackUrl.search}`),
225 );
226 const callbackHtml = yield* Effect.promise(() => callback.text());
227 const callbackCompletedAt = Date.now();
228 expect(callback.status, "the OAuth callback succeeds").toBe(200);
229 expect(callbackHtml, "the callback page reports the connect succeeded").toContain(
230 "Connected",
231 );
232
233 // -- 4. The held request answers promptly with the one-shot result.
234 const answer = yield* Effect.promise(() => heldAwait);
235 expect(answer.status, "a held await answers 200 like an immediate poll").toBe(200);
236 expect(
237 answer.body,
238 "the held await receives the one-shot result the callback published",
239 ).toMatchObject({ ok: true, sessionId });
240
241 const sinceCallback = answer.settledAt - callbackCompletedAt;
242 // Console output is swallowed by the runner, so park the measured
243 // timings in the run dir as reviewable evidence.
244 writeFileSync(
245 join(runDir, "await-timing.json"),
246 JSON.stringify(
247 {
248 heldObservationMs: HELD_OBSERVATION_MS,
249 callbackHandlingMs: callbackCompletedAt - callbackStartedAt,
250 awaitsSettledAfterCallbackMs: sinceCallback,
251 budgetMs: PROMPT_RESOLUTION_BUDGET_MS,
252 },
253 null,
254 2,
255 ),
256 );
257 expect(
258 sinceCallback,
259 "held awaits resolve promptly after the callback — well under the old 1s poll tick",
260 ).toBeLessThan(PROMPT_RESOLUTION_BUDGET_MS);
261 }),
262 );
263 }),
264 ),
265);
e2e/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/e2e",2 "name": "@executor-js/e2e",
3 "version": "0.0.44",3 "version": "0.0.46",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
e2e/scenarios/auth-methods-ui.test.ts+1−1
@@ -46,7 +46,7 @@ scenario( · 45 unmodified lines
46 });46 });
47 47
48 await step("The probe seeded the detected method", async () => {48 await step("The probe seeded the detected method", async () => {
49 await page.getByText("Method 1 · Detected").waitFor();49 await page.getByText("No authentication · Detected").waitFor();
50 });50 });
51 51
52 await step("Declare an API key method alongside it", async () => {52 await step("Declare an API key method alongside it", async () => {
e2e/scenarios/first-party-oauth.test.ts+4−29
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
26 26
27import { scenario } from "../src/scenario";27import { scenario } from "../src/scenario";
28import { Api, Browser, Target } from "../src/services";28import { Api, Browser, Target } from "../src/services";
29import { clickToReveal, visit } from "../src/surfaces/browser";29import { visit } from "../src/surfaces/browser";
30 30
31const api = composePluginApi([openApiHttpPlugin()] as const);31const api = composePluginApi([openApiHttpPlugin()] as const);
32 32
@@ -181,35 +181,10 @@ scenario( · 148 unmodified lines
181 const client = yield* makeApiClient(api, identity);181 const client = yield* makeApiClient(api, identity);
182 182
183 yield* browser.session(identity, async ({ page, step }) => {183 yield* browser.session(identity, async ({ page, step }) => {
184 await step("Open the Google integration catalog", async () => {184 await step("Open the integrations registry browser", async () => {
185 await visit(page, "/integrations");185 await visit(page, "/integrations/browse");
186 const dialog = page.getByRole("dialog", { name: "Connect an integration" });186 await page.getByPlaceholder(/Search integrations, or paste a URL/).waitFor();
187 await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog);
188 });187 });
189
190 const services = [
191 "Google Calendar",
192 "Google Meet",
193 "Gmail",
194 "Google Sheets",
195 "Google Drive",
196 "Google Docs",
197 "Google Slides",
198 "Google Forms",
199 "Google Tasks",
200 "Google People",
201 "Google Photos Library",
202 "Google Photos Picker",
203 "Google Search Console",
204 ] as const;
205 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
206 const search = dialog.getByPlaceholder(/Search or paste a URL/);
207 for (const service of services) {
208 await step(`${service} is available to connect`, async () => {
209 await search.fill(service);
210 await dialog.getByRole("link", { name: new RegExp(`^${service}\\b`) }).waitFor();
211 });
212 }
213 });188 });
214 189
215 // The Executor-owned Google app is withheld from every listing: it is no190 // The Executor-owned Google app is withheld from every listing: it is no
e2e/scenarios/google-disabled-api.test.ts+9−12
@@ -117,24 +117,21 @@ const createGoogleEmulator = Effect.gen(function* () { · 116 unmodified lines
117const addGooglePresetFromCatalog = (117const addGooglePresetFromCatalog = (
118 browser: BrowserSurface,118 browser: BrowserSurface,
119 identity: Identity,119 identity: Identity,
120 presetId: string,
120 presetName: string,121 presetName: string,
121 slug: string,122 slug: string,
122) =>123) =>
123 browser.session(identity, async ({ page, step }) => {124 browser.session(identity, async ({ page, step }) => {
124 await step(`Open ${presetName} from the connect catalog`, async () => {125 await step(`Open the ${presetName} preset's add flow`, async () => {
125 await page.goto("/integrations", { waitUntil: "networkidle" });126 // The connect dialog became the full-page picker, and registry-listed
126 await page127 // provider presets no longer render cards there — the preset deep link
127 .getByRole("button", { name: /Connect/ })128 // is the stable route to a preset-configured add flow.
128 .first()129 await page.goto(`/integrations/add/openapi?preset=${presetId}`, {
129 .click();130 waitUntil: "networkidle",
130 const dialog = page.getByRole("dialog", { name: "Connect an integration" });131 });
131 await dialog.waitFor();
132 await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
133 await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
134 });132 });
135 133
136 await step(`Add the ${presetName} integration`, async () => {134 await step(`Add the ${presetName} integration`, async () => {
137 await page.waitForURL(/\/integrations\/add\/openapi/);
138 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();135 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
139 const button = page.getByRole("button", { name: "Add integration" });136 const button = page.getByRole("button", { name: "Add integration" });
140 await button.waitFor({ timeout: 120_000 });137 await button.waitFor({ timeout: 120_000 });
@@ -218,7 +215,7 @@ scenario( · 77 unmodified lines
218 215
219 yield* Effect.ensuring(216 yield* Effect.ensuring(
220 Effect.gen(function* () {217 Effect.gen(function* () {
221 yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug));218 yield* addGooglePresetFromCatalog(browser, identity, "google-gmail", "Gmail", String(slug));
222 219
223 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });220 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
224 expect(stored?.operation, "the Gmail preset declares its labels probe").toBe(221 expect(stored?.operation, "the Gmail preset declares its labels probe").toBe(
e2e/scenarios/google-health-checks.test.ts+29−10
@@ -17,13 +17,15 @@ import { createEmulatorInstance } from "../src/emulator-instance"; · 16 unmodified lines
17import { scenario } from "../src/scenario";17import { scenario } from "../src/scenario";
18import { Api, Browser, Target } from "../src/services";18import { Api, Browser, Target } from "../src/services";
19import type { Identity, Target as TargetShape } from "../src/target";19import type { Identity, Target as TargetShape } from "../src/target";
20import { type BrowserSurface, clickToReveal, visit } from "../src/surfaces/browser";20import { type BrowserSurface, visit } from "../src/surfaces/browser";
21 21
22const api = composePluginApi([openApiHttpPlugin()] as const);22const api = composePluginApi([openApiHttpPlugin()] as const);
23type Client = HttpApiClient.ForApi<typeof api>;23type Client = HttpApiClient.ForApi<typeof api>;
24const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");24const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
25const CONNECTION = ConnectionName.make("main");25const CONNECTION = ConnectionName.make("main");
26const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com";26const GOOGLE_EMULATOR_ACCOUNT_EMAIL = "testuser@gmail.com";
27const googleDiscoveryUrl = (service: string, version: string) =>
28 `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
27 29
28const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;30const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
29 31
@@ -79,19 +81,18 @@ const createGoogleEmulator = Effect.gen(function* () { · 49 unmodified lines
79 return { client, baseUrl };81 return { client, baseUrl };
80});82});
81 83
82const addGooglePresetFromCatalog = (84const addGooglePresetFromDirectUrl = (
83 browser: BrowserSurface,85 browser: BrowserSurface,
84 identity: Identity,86 identity: Identity,
85 presetName: string,87 presetName: string,
88 presetId: string,
89 presetUrl: string,
86 slug: string,90 slug: string,
87) =>91) =>
88 browser.session(identity, async ({ page, step }) => {92 browser.session(identity, async ({ page, step }) => {
89 await step(`Open ${presetName} from the connect catalog`, async () => {93 await step(`Open the ${presetName} preset add flow directly`, async () => {
90 await visit(page, "/integrations");94 const search = new URLSearchParams({ preset: presetId, url: presetUrl });
91 const dialog = page.getByRole("dialog", { name: "Connect an integration" });95 await visit(page, `/integrations/add/openapi?${search}`);
92 await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog);
93 await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
94 await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
95 });96 });
96 97
97 await step(`Add the ${presetName} integration`, async () => {98 await step(`Add the ${presetName} integration`, async () => {
@@ -201,6 +202,8 @@ scenario( · 103 unmodified lines
201 const rows = [202 const rows = [
202 {203 {
203 presetName: "Google Calendar",204 presetName: "Google Calendar",
205 presetId: "google-calendar",
206 presetUrl: googleDiscoveryUrl("calendar", "v3"),
204 slug: IntegrationSlug.make("google_calendar"),207 slug: IntegrationSlug.make("google_calendar"),
205 oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")),208 oauthClient: OAuthClientSlug.make(unique("google_calendar_oauth")),
206 expectedHealthOperation: "calendar.calendarList.list",209 expectedHealthOperation: "calendar.calendarList.list",
@@ -209,6 +212,8 @@ scenario( · 2 unmodified lines
209 },212 },
210 {213 {
211 presetName: "Gmail",214 presetName: "Gmail",
215 presetId: "google-gmail",
216 presetUrl: googleDiscoveryUrl("gmail", "v1"),
212 slug: IntegrationSlug.make("google_gmail"),217 slug: IntegrationSlug.make("google_gmail"),
213 oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")),218 oauthClient: OAuthClientSlug.make(unique("google_gmail_oauth")),
214 expectedHealthOperation: "gmail.users.labels.list",219 expectedHealthOperation: "gmail.users.labels.list",
@@ -220,7 +225,14 @@ scenario( · 5 unmodified lines
220 yield* Effect.ensuring(225 yield* Effect.ensuring(
221 Effect.gen(function* () {226 Effect.gen(function* () {
222 for (const row of rows) {227 for (const row of rows) {
223 yield* addGooglePresetFromCatalog(browser, identity, row.presetName, String(row.slug));228 yield* addGooglePresetFromDirectUrl(
229 browser,
230 identity,
231 row.presetName,
232 row.presetId,
233 row.presetUrl,
234 String(row.slug),
235 );
224 236
225 const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } });237 const stored = yield* client.integrations.healthCheckGet({ params: { slug: row.slug } });
226 expect(stored?.operation, `${row.presetName} stored health check`).toBe(238 expect(stored?.operation, `${row.presetName} stored health check`).toBe(
@@ -324,7 +336,14 @@ scenario( · 97 unmodified lines
324 336
325 yield* Effect.ensuring(337 yield* Effect.ensuring(
326 Effect.gen(function* () {338 Effect.gen(function* () {
327 yield* addGooglePresetFromCatalog(browser, identity, "Google Sheets", String(slug));339 yield* addGooglePresetFromDirectUrl(
340 browser,
341 identity,
342 "Google Sheets",
343 "google-sheets",
344 googleDiscoveryUrl("sheets", "v4"),
345 String(slug),
346 );
328 347
329 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });348 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
330 expect(stored, "Google Sheets catalog preset declares no health check").toBeNull();349 expect(stored, "Google Sheets catalog preset declares no health check").toBeNull();
e2e/scenarios/google-photos-preset-ui.test.ts+98−23
@@ -3,10 +3,15 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { scenario } from "../src/scenario";4import { scenario } from "../src/scenario";
5import { Browser, Target } from "../src/services";5import { Browser, Target } from "../src/services";
6import { clickToReveal, visit } from "../src/surfaces/browser";6import { visit } from "../src/surfaces/browser";
7 7
8const librarySpecUrl = "https://integrations.sh/specs/google/google-photos-library.json";
9const pickerSpecUrl = "https://integrations.sh/specs/google/google-photos-picker.json";
10
11// Photos Library and Photos Picker are independent registry products. The
12// picker must preserve that distinction all the way into the selected add flow.
8scenario(13scenario(
9 "Google Photos: separated catalog presets open a Photos service add flow",14 "Google Photos products are separate registry cards with separate add flows",
10 {},15 {},
11 Effect.gen(function* () {16 Effect.gen(function* () {
12 const target = yield* Target;17 const target = yield* Target;
@@ -14,30 +19,100 @@ scenario( · 1 unmodified line
14 const identity = yield* target.newIdentity();19 const identity = yield* target.newIdentity();
15 20
16 yield* browser.session(identity, async ({ page, step }) => {21 yield* browser.session(identity, async ({ page, step }) => {
17 await step(22 await step("Stub the two Google Photos registry products", async () => {
18 "Find the separated Google Photos presets from the integrations picker",23 await page.route("https://integrations.sh/api/search*", (route) =>
19 async () => {24 route.fulfill({
20 await visit(page, "/integrations");25 contentType: "application/json",
21 const dialog = page.getByRole("dialog", { name: "Connect an integration" });26 headers: { "access-control-allow-origin": "*" },
22 await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);27 json: {
23 await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos");28 results: [
24 await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor();29 {
25 await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor();30 domain: "photos.google.com",
26 },31 name: "Google Photos Library",
27 );32 description: "Create and manage app-created photos.",
33 kinds: ["openapi"],
34 url: "https://integrations.sh/photos.google.com/",
35 },
36 {
37 domain: "photos.google.com",
38 name: "Google Photos Picker",
39 description: "Let people select photos for an app.",
40 kinds: ["openapi"],
41 url: "https://integrations.sh/photos.google.com/",
42 },
43 ],
44 },
45 }),
46 );
47 await page.route("https://integrations.sh/api/photos.google.com/surface", (route) =>
48 route.fulfill({
49 contentType: "application/json",
50 headers: { "access-control-allow-origin": "*" },
51 json: {
52 version: 3,
53 domain: "photos.google.com",
54 surfaces: [
55 { type: "http", slug: "google-photos-library", spec: librarySpecUrl },
56 { type: "http", slug: "google-photos-picker", spec: pickerSpecUrl },
57 ],
58 },
59 }),
60 );
61 });
62
63 await step("Search for Photos and see two separate product cards", async () => {
64 await visit(page, "/integrations/browse");
65 await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("photos");
66 const cards = page.getByTestId("catalog-photos.google.com-openapi");
67 await cards.filter({ hasText: "Google Photos Library API" }).waitFor();
68 await cards.filter({ hasText: "Google Photos Picker API" }).waitFor();
69 });
28 70
29 await step("Open the Google Photos Library add flow", async () => {71 await step("Add Photos Library in place; the Picker card is untouched", async () => {
30 const dialog = page.getByRole("dialog", { name: "Connect an integration" });72 await page
31 await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).click();73 .getByTestId("catalog-photos.google.com-openapi")
32 await page.waitForURL(/\/integrations\/add\/openapi/);74 .filter({ hasText: "Google Photos Library API" })
33 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();75 .getByRole("button", { name: "Add Google Photos Library API" })
76 .click();
77 const libraryCard = page
78 .getByTestId("catalog-photos.google.com-openapi")
79 .filter({ hasText: "Google Photos Library API" });
80 await libraryCard
81 .getByRole("link", { name: "View Google Photos Library API" })
82 .waitFor({ timeout: 120_000 });
83 expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/);
84 // Product identity survived: the LIBRARY slug became the namespace…
85 const href = await libraryCard
86 .getByRole("link", { name: "View Google Photos Library API" })
87 .getAttribute("href");
88 expect(href).toContain("/integrations/google_photos_library");
89 // …and the Picker card, same domain and kind, still offers Add.
90 await page
91 .getByTestId("catalog-photos.google.com-openapi")
92 .filter({ hasText: "Google Photos Picker API" })
93 .getByRole("button", { name: "Add Google Photos Picker API" })
94 .waitFor();
34 });95 });
35 96
36 await step("The Library link carries the focused OpenAPI preset", async () => {97 await step("Adding the SECOND same-kind card keeps its own identity", async () => {
37 const url = new URL(page.url());98 // The regression this guards: selection re-derived the surface by
38 expect(url.searchParams.get("preset")).toBe("google-photos-library");99 // KIND, so the second product of a kind inherited the first's slug,
39 expect(url.searchParams.get("url")).toContain("photoslibrary");100 // auth, and overrides.
40 await expect.poll(() => page.locator("textarea").inputValue()).toContain("photoslibrary");101 await page
102 .getByTestId("catalog-photos.google.com-openapi")
103 .filter({ hasText: "Google Photos Picker API" })
104 .getByRole("button", { name: "Add Google Photos Picker API" })
105 .click();
106 const pickerCard = page
107 .getByTestId("catalog-photos.google.com-openapi")
108 .filter({ hasText: "Google Photos Picker API" });
109 await pickerCard
110 .getByRole("link", { name: "View Google Photos Picker API" })
111 .waitFor({ timeout: 120_000 });
112 const href = await pickerCard
113 .getByRole("link", { name: "View Google Photos Picker API" })
114 .getAttribute("href");
115 expect(href).toContain("/integrations/google_photos_picker");
41 });116 });
42 });117 });
43 }),118 }),
e2e/scenarios/integrations-catalog-search.test.ts+103−64
@@ -1,77 +1,116 @@
1import { randomBytes } from "node:crypto";
2
1import { expect } from "@effect/vitest";3import { expect } from "@effect/vitest";
2import { Effect } from "effect";4import { Effect } from "effect";
5import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing";
3 6
4import { scenario } from "../src/scenario";7import { scenario } from "../src/scenario";
5import { Browser, Target } from "../src/services";8import { Browser, Target } from "../src/services";
6import { clickToReveal, visit } from "../src/surfaces/browser";9import { visit } from "../src/surfaces/browser";
7 10
8// The connect dialog's long-tail search goes to the public integrations.sh11// The picker's registry search goes to the public integrations.sh service from
9// registry from the browser. CI must not depend on the live service, so both12// the browser. CI must not depend on the live service, so the search endpoint
10// registry endpoints are fulfilled at the network layer here — including the13// is fulfilled at the network layer here — including the CORS header a real
11// CORS header a real cross-origin browser call needs.14// cross-origin browser call needs. The MCP endpoints themselves are NOT
15// stubbed: the quick add probes and registers server-side, so the working row
16// points at a local fixture server and the broken row at a reserved-TLD host.
12scenario(17scenario(
13 "Connect dialog: integrations.sh catalog search resolves into a prefilled add flow",18 "Add page: a registry row adds in place, and falls back to the config screen",
14 {},19 {},
15 Effect.gen(function* () {20 Effect.scoped(
16 const target = yield* Target;21 Effect.gen(function* () {
17 const browser = yield* Browser;22 const target = yield* Target;
18 const identity = yield* target.newIdentity();23 const browser = yield* Browser;
24 // Unique per run — the derived namespace must not collide on targets
25 // whose identities share one tenant (selfhost admin).
26 const suffix = randomBytes(3).toString("hex");
27 const server = yield* serveMcpServer(() =>
28 makeGreetingMcpServer({ name: `catalog-quick-${suffix}` }),
29 );
30 const identity = yield* target.newIdentity();
19 31
20 yield* browser.session(identity, async ({ page, step }) => {32 yield* browser.session(identity, async ({ page, step }) => {
21 await step("Stub the integrations.sh registry endpoints", async () => {33 await step("Stub the integrations.sh search endpoint", async () => {
22 await page.route("https://integrations.sh/api/search*", (route) =>34 await page.route("https://integrations.sh/api/search*", (route) =>
23 route.fulfill({35 route.fulfill({
24 contentType: "application/json",36 contentType: "application/json",
25 headers: { "access-control-allow-origin": "*" },37 headers: { "access-control-allow-origin": "*" },
26 json: {38 json: {
27 results: [39 results: [
28 {40 {
29 domain: "todoist.com",41 domain: "todoist.com",
30 name: "todoist.com",42 name: "todoist.com",
31 description: "Tasks, projects, and collaboration.",43 description: "Tasks, projects, and collaboration.",
32 kinds: ["mcp", "cli"],44 kinds: ["mcp", "cli"],
33 url: "https://integrations.sh/todoist.com/",45 surfaces: [
34 },46 { kind: "mcp", slug: `todoist-${suffix}`, url: server.endpoint },
35 ],47 { kind: "cli", slug: "todoist-cli" },
36 },48 ],
37 }),49 },
38 );50 {
39 await page.route("https://integrations.sh/api/todoist.com/surface", (route) =>51 domain: "deadserver.com",
40 route.fulfill({52 name: "deadserver.com",
41 contentType: "application/json",53 description: "A vendor whose MCP endpoint is gone.",
42 headers: { "access-control-allow-origin": "*" },54 kinds: ["mcp"],
43 json: {55 surfaces: [
44 version: 3,56 { kind: "mcp", slug: "deadserver", url: "https://mcp.notareal.invalid/mcp" },
45 domain: "todoist.com",57 ],
46 surfaces: [58 },
47 { type: "mcp", url: "https://ai.todoist.net/mcp", slug: "todoist" },59 ],
48 { type: "cli", slug: "todoist-cli" },60 },
49 ],61 }),
50 },62 );
51 }),63 });
52 );
53 });
54 64
55 await step("Searching surfaces the catalog row under the presets", async () => {65 await step("Searching surfaces the connectable rows, not the CLI", async () => {
56 await visit(page, "/integrations");66 await visit(page, "/integrations/browse");
57 const dialog = page.getByRole("dialog", { name: "Connect an integration" });67 await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist");
58 await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog);68 await page.getByTestId(`catalog-todoist-${suffix}`).getByText("Todoist MCP").waitFor();
59 await dialog.getByPlaceholder(/Search or paste a URL/).fill("todoist");69 await page.getByTestId("catalog-deadserver").waitFor();
60 // The CLI-only surface is not offered; the connectable kind is.70 expect(await page.getByTestId("catalog-todoist-cli").count()).toBe(0);
61 await dialog71 });
62 .getByRole("button", { name: /todoist\.com/ })72
63 .getByText("MCP")73 await step("Add registers in place; the card flips to View", async () => {
64 .waitFor();74 await page
65 });75 .getByTestId(`catalog-todoist-${suffix}`)
76 .getByRole("button", { name: "Add Todoist MCP" })
77 .click();
78 // Generous timeout: the quick add probes the fixture server and
79 // registers, all behind one click, and the dev server can queue
80 // under CI load.
81 await page
82 .getByTestId(`catalog-todoist-${suffix}`)
83 .getByRole("link", { name: "View Todoist MCP" })
84 .waitFor({ timeout: 90_000 });
85 // The whole point: the user never left the picker.
86 expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/);
87 });
88
89 await step("View jumps to the integration's hub", async () => {
90 await page
91 .getByTestId(`catalog-todoist-${suffix}`)
92 .getByRole("link", { name: "View Todoist MCP" })
93 .click();
94 await page.waitForURL(new RegExp(`/integrations/todoist_${suffix}`));
95 await page.goBack();
96 await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("todoist");
97 await page.getByTestId("catalog-deadserver").waitFor();
98 });
66 99
67 await step("Picking the row lands on the MCP add flow, prefilled", async () => {100 await step("An unreachable endpoint falls back to the config screen", async () => {
68 const dialog = page.getByRole("dialog", { name: "Connect an integration" });101 await page
69 await dialog.getByRole("button", { name: /todoist\.com/ }).click();102 .getByTestId("catalog-deadserver")
70 await page.waitForURL(/\/integrations\/add\/mcp/);103 .getByRole("button", { name: "Add Deadserver MCP" })
71 const url = new URL(page.url());104 .click();
72 expect(url.searchParams.get("url")).toBe("https://ai.todoist.net/mcp");105 // Quick add cannot register it, so the click lands on the add flow
73 expect(url.searchParams.get("namespace")).toBe("todoist");106 // prefilled with the same facts — where the probe failure renders
107 // with retry UX.
108 await page.waitForURL(/\/integrations\/add\/mcp/, { timeout: 90_000 });
109 const url = new URL(page.url());
110 expect(url.searchParams.get("url")).toBe("https://mcp.notareal.invalid/mcp");
111 expect(url.searchParams.get("namespace")).toBe("deadserver");
112 });
74 });113 });
75 });114 }),
76 }),115 ),
77);116);
e2e/scenarios/mcp-catalog-sync-ui.test.ts+1−1
@@ -40,7 +40,7 @@ scenario( · 39 unmodified lines
40 // The URL auto-probes (debounced); the method list appears once the40 // The URL auto-probes (debounced); the method list appears once the
41 // probe lands — an open server seeds the detected no-auth method.41 // probe lands — an open server seeds the detected no-auth method.
42 await page.getByText("How does this server authenticate?").waitFor();42 await page.getByText("How does this server authenticate?").waitFor();
43 await page.getByText("Method 1 · Detected").waitFor();43 await page.getByText("No authentication · Detected").waitFor();
44 });44 });
45 45
46 await step("Add the integration", async () => {46 await step("Add the integration", async () => {
e2e/scenarios/mcp-execute.test.ts+144−1
@@ -1,11 +1,23 @@
1// Cross-target: the MCP surface — connect with fully headless OAuth (DCR →1// Cross-target: the MCP surface — connect with fully headless OAuth (DCR →
2// consent → code → token) and run code in the sandbox, exactly as an MCP2// consent → code → token) and run code in the sandbox, exactly as an MCP
3// client (Claude, Cursor, …) would.3// client (Claude, Cursor, …) would.
4import { randomUUID } from "node:crypto";
5
4import { expect } from "@effect/vitest";6import { expect } from "@effect/vitest";
5import { Effect } from "effect";7import { Effect } from "effect";
8import { composePluginApi } from "@executor-js/api/server";
6 9
7import { scenario } from "../src/scenario";10import { scenario } from "../src/scenario";
8import { Mcp, Target } from "../src/services";11import { Api, Mcp, Target } from "../src/services";
12
13const coreApi = composePluginApi([] as const);
14
15/** The raw MCP tool result shape the fidelity scenarios assert against. */
16const rawResultOf = (result: { readonly raw: unknown }) =>
17 result.raw as {
18 content?: ReadonlyArray<{ type: string; text?: string }>;
19 structuredContent?: Record<string, unknown>;
20 };
9 21
10scenario(22scenario(
11 "MCP · OAuth connect, then execute code in the sandbox",23 "MCP · OAuth connect, then execute code in the sandbox",
@@ -24,6 +36,137 @@ scenario( · 12 unmodified lines
24 }),36 }),
25);37);
26 38
39// The exact value a sandbox script returns, non-ASCII included. The server
40// renders it into the text channel (pretty-printed JSON) and mirrors it into
41// `structuredContent`; both must reach the client byte-identical — a client
42// diffing retries or hashing results must never see the payload drift.
43const structuredPayload = {
44 greeting: "héllo — こんにちは ✅",
45 emoji: "🚀",
46 values: [1, 2, 3],
47 nested: { ok: true, label: "Zoë" },
48};
49
50scenario(
51 "MCP · a structured return value reaches the client byte-identical in text and structuredContent",
52 {},
53 Effect.gen(function* () {
54 const target = yield* Target;
55 const mcp = yield* Mcp;
56 const identity = yield* target.newIdentity();
57 const session = mcp.session(identity);
58 yield* session.listTools();
59
60 const result = yield* session.call("execute", {
61 code: `return ${JSON.stringify(structuredPayload)};`,
62 });
63 expect(result.ok, "the sandbox run completes without error").toBe(true);
64
65 const expectedText = JSON.stringify(structuredPayload, null, 2);
66 const raw = rawResultOf(result);
67 expect(raw.content?.length, "the result arrives as a single text block").toBe(1);
68 expect(raw.content?.[0]?.text, "the text channel carries the exact rendered value").toBe(
69 expectedText,
70 );
71 expect(result.text, "the joined text content matches byte-for-byte").toBe(expectedText);
72 expect(raw.structuredContent, "structuredContent mirrors the exact returned value").toEqual({
73 status: "completed",
74 result: structuredPayload,
75 logs: [],
76 });
77 }),
78);
79
80/** Sandbox code that runs ONE approval-gated call (the `policies.create` core
81 * tool gates itself via its `requiresApproval` annotation — same hermetic
82 * device as policy-tool-approval.test.ts) and then returns a deterministic
83 * payload. The pattern is unique-per-run and matches no real tool, so the
84 * created `block` rule is inert even if leaked. */
85const gatedThenReturnCode = (pattern: string, payload: unknown) => `
86await tools.executor.coreTools.policies.create({
87 owner: "user",
88 pattern: ${JSON.stringify(pattern)},
89 action: "block",
90});
91return ${JSON.stringify(payload)};
92`;
93
94scenario(
95 "MCP · a duplicate resume replays the identical settled result without re-running the tool",
96 {},
97 Effect.gen(function* () {
98 const target = yield* Target;
99 const apiSurface = yield* Api;
100 const mcp = yield* Mcp;
101 const identity = yield* target.newIdentity();
102 const client = yield* apiSurface.client(coreApi, identity);
103 const pattern = `mcp-resume-replay-${randomUUID().slice(0, 8)}.*`;
104 const replayPayload = { note: "resumed — résultat 完了 ✅", pattern };
105
106 const cleanup = client.policies.list().pipe(
107 Effect.flatMap((list) =>
108 Effect.forEach(
109 list.filter((p) => p.pattern === pattern),
110 (p) =>
111 client.policies
112 .remove({ params: { policyId: p.id }, payload: { owner: "user" } })
113 .pipe(Effect.ignore),
114 ),
115 ),
116 Effect.ignore,
117 );
118
119 yield* Effect.gen(function* () {
120 const session = mcp.session(identity);
121 yield* session.listTools();
122
123 const paused = yield* session.call("execute", {
124 code: gatedThenReturnCode(pattern, replayPayload),
125 });
126 expect(paused.text, "the gated call pauses for approval").toContain("Execution paused");
127 const match = /\bexecutionId:\s*(\S+)/.exec(paused.text);
128 expect(match, "the pause carries an executionId to resume").not.toBeNull();
129
130 // MCP clients retry `resume` when a response is lost in transit, so the
131 // duplicate uses the exact same arguments as the first delivery.
132 const resumeArgs = {
133 executionId: match![1]!,
134 action: "accept",
135 content: JSON.stringify({}),
136 };
137
138 const resumed = yield* session.call("resume", resumeArgs);
139 expect(resumed.ok, "the approved execution completes without error").toBe(true);
140 const expectedText = JSON.stringify(replayPayload, null, 2);
141 expect(resumed.text, "the resumed result carries the exact returned value").toBe(
142 expectedText,
143 );
144 expect(
145 rawResultOf(resumed).structuredContent,
146 "the resumed structuredContent mirrors the exact returned value",
147 ).toEqual({ status: "completed", result: replayPayload, logs: [] });
148
149 const replayed = yield* session.call("resume", resumeArgs);
150 expect(replayed.ok, "the duplicate resume succeeds instead of erroring").toBe(true);
151 expect(replayed.text, "the replayed text is byte-identical to the first delivery").toBe(
152 resumed.text,
153 );
154 expect(
155 rawResultOf(replayed).structuredContent,
156 "the replayed structuredContent is identical to the first delivery",
157 ).toEqual(rawResultOf(resumed).structuredContent);
158
159 // The replay served the recorded outcome — the gated tool did not run a
160 // second time.
161 const afterReplay = yield* client.policies.list();
162 expect(
163 afterReplay.filter((p) => p.pattern === pattern).length,
164 "the gated tool ran exactly once despite the duplicate resume",
165 ).toBe(1);
166 }).pipe(Effect.ensuring(cleanup));
167 }),
168);
169
27scenario(170scenario(
28 "MCP · a syntax error returns a descriptive message, not an opaque internal error",171 "MCP · a syntax error returns a descriptive message, not an opaque internal error",
29 {},172 {},
e2e/scenarios/provider-plugins-ui.test.ts+85−46
@@ -3,10 +3,16 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { scenario } from "../src/scenario";4import { scenario } from "../src/scenario";
5import { Browser, Target } from "../src/services";5import { Browser, Target } from "../src/services";
6import { clickToReveal, visit } from "../src/surfaces/browser";6import { visit } from "../src/surfaces/browser";
7 7
8const gmailSpecUrl = "https://integrations.sh/specs/google/google-gmail.json";
9const outlookSpecUrl = "https://integrations.sh/specs/microsoft-graph/mail.json";
10
11// Provider products are ordinary integrations.sh results now. Stub both the
12// search response and the per-domain surface documents so this scenario proves
13// the full registry-card-to-add-flow journey without depending on the service.
8scenario(14scenario(
9 "Provider catalog · Google and Microsoft services are OpenAPI presets",15 "Google and Microsoft products are registry cards that resolve into prefilled add flows",
10 {},16 {},
11 Effect.gen(function* () {17 Effect.gen(function* () {
12 const target = yield* Target;18 const target = yield* Target;
@@ -14,58 +20,91 @@ scenario( · 1 unmodified line
14 const identity = yield* target.newIdentity();20 const identity = yield* target.newIdentity();
15 21
16 yield* browser.session(identity, async ({ page, step }) => {22 yield* browser.session(identity, async ({ page, step }) => {
17 await step("Open the integrations picker from the sidebar", async () => {23 await step("Stub the Google and Microsoft registry products", async () => {
18 await visit(page, "/policies");24 await page.route("https://integrations.sh/api/search*", (route) =>
19 await clickToReveal(25 route.fulfill({
20 page26 contentType: "application/json",
21 .getByRole("navigation")27 headers: { "access-control-allow-origin": "*" },
22 .getByRole("button", { name: "Browse integrations", exact: true }),28 json: {
23 page.getByRole("dialog", { name: "Connect an integration" }),29 results: [
30 {
31 domain: "gmail.com",
32 name: "Gmail",
33 description: "Email from Google.",
34 kinds: ["openapi"],
35 url: "https://integrations.sh/gmail.com/",
36 },
37 {
38 domain: "graph.microsoft.com",
39 name: "Outlook Mail",
40 description: "Email from Microsoft.",
41 kinds: ["openapi"],
42 url: "https://integrations.sh/graph.microsoft.com/",
43 },
44 ],
45 },
46 }),
47 );
48 await page.route("https://integrations.sh/api/gmail.com/surface", (route) =>
49 route.fulfill({
50 contentType: "application/json",
51 headers: { "access-control-allow-origin": "*" },
52 json: {
53 version: 3,
54 domain: "gmail.com",
55 surfaces: [{ type: "http", slug: "google-gmail", spec: gmailSpecUrl }],
56 },
57 }),
58 );
59 await page.route("https://integrations.sh/api/graph.microsoft.com/surface", (route) =>
60 route.fulfill({
61 contentType: "application/json",
62 headers: { "access-control-allow-origin": "*" },
63 json: {
64 version: 3,
65 domain: "graph.microsoft.com",
66 surfaces: [{ type: "http", slug: "outlook-mail", spec: outlookSpecUrl }],
67 },
68 }),
24 );69 );
25 });70 });
26 71
27 await step("The picker exposes OpenAPI plus provider service presets", async () => {72 await step("Search for Gmail and see its registry card and domain", async () => {
28 const dialog = page.getByRole("dialog", {73 await visit(page, "/integrations/browse");
29 name: "Connect an integration",74 const search = page.getByPlaceholder(/Search integrations, or paste a URL/);
30 });
31 const search = dialog.getByPlaceholder(/Search or paste a URL/);
32 await dialog.getByRole("link", { name: "OpenAPI", exact: true }).waitFor();
33
34 await search.fill("gmail");75 await search.fill("gmail");
35 await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor();76 const card = page.getByTestId("catalog-gmail.com-openapi");
36 77 await card.waitFor();
37 await search.fill("onedrive");78 await card.getByText("gmail.com").waitFor();
38 await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor();
39 });79 });
40 80
41 await step("OpenAPI add remains generic", async () => {81 await step("Add Gmail in place: the card flips to View", async () => {
42 await page.goto("/integrations/add/openapi", {82 await page
43 waitUntil: "domcontentloaded",83 .getByTestId("catalog-gmail.com-openapi")
44 });84 .getByRole("button", { name: "Add Gmail API" })
45 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();85 .click();
46 await page.getByText("OpenAPI Spec").waitFor();86 // The quick add fetches the hosted spec server-side and registers —
47 expect(await page.getByText("Customize your Google connection").count()).toBe(0);87 // no navigation. Generous timeout: a real spec fetch + parse rides
48 expect(await page.getByText("Customize Microsoft Graph").count()).toBe(0);88 // behind the click.
89 await page
90 .getByTestId("catalog-gmail.com-openapi")
91 .getByRole("link", { name: "View Gmail API" })
92 .waitFor({ timeout: 120_000 });
93 expect(new URL(page.url()).pathname).toMatch(/\/integrations\/browse$/);
94 // The registry surface slug became the namespace.
95 const href = await page
96 .getByTestId("catalog-gmail.com-openapi")
97 .getByRole("link", { name: "View Gmail API" })
98 .getAttribute("href");
99 expect(href).toContain("/integrations/google_gmail");
49 });100 });
50 101
51 await step("A Google service preset opens the OpenAPI add flow", async () => {102 await step("Search for Outlook and see its separate registry card and domain", async () => {
52 await page.goto(103 await visit(page, "/integrations/browse");
53 "/integrations/add/openapi?preset=google-gmail&url=https%3A%2F%2Fwww.googleapis.com%2Fdiscovery%2Fv1%2Fapis%2Fgmail%2Fv1%2Frest",104 await page.getByPlaceholder(/Search integrations, or paste a URL/).fill("outlook");
54 { waitUntil: "domcontentloaded" },105 const card = page.getByTestId("catalog-graph.microsoft.com-openapi");
55 );106 await card.waitFor();
56 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();107 await card.getByText("graph.microsoft.com").waitFor();
57 await expect.poll(() => page.locator("textarea").inputValue()).toContain("gmail");
58 });
59
60 await step("A Microsoft service preset opens the OpenAPI add flow", async () => {
61 await page.goto(
62 "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fgithub.com%2FUsefulSoftwareCo%2Fexecutor%2Freleases%2Fdownload%2Fgraph-slices%2Ffiles.yaml",
63 { waitUntil: "domcontentloaded" },
64 );
65 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
66 await expect
67 .poll(() => page.locator("textarea").inputValue())
68 .toContain("graph-slices/files.yaml");
69 });108 });
70 });109 });
71 }),110 }),
e2e/scenarios/run-panel-auto-approve.test.ts+17−1
@@ -26,14 +26,24 @@ import { Api, Target } from "../src/services"; · 25 unmodified lines
26 26
27const coreApi = composePluginApi([] as const);27const coreApi = composePluginApi([] as const);
28 28
29/** The deterministic value the gated script returns once approved. Asserting
30 * the completed response against it byte-for-byte (non-ASCII included) pins
31 * output fidelity on the autoApprove path — the panel must render exactly
32 * what the script returned. */
33const approvedPayload = {
34 note: "auto-approved — résultat 完了 ✅",
35 values: [1, 2, 3],
36};
37
29/** Sandbox code that creates a policy through the approval-gated core tool. The38/** Sandbox code that creates a policy through the approval-gated core tool. The
30 * pattern is unique-per-run and matches no real tool, so the rule is inert. */39 * pattern is unique-per-run and matches no real tool, so the rule is inert. */
31const createPolicyCode = (pattern: string) => `40const createPolicyCode = (pattern: string) => `
32return await tools.executor.coreTools.policies.create({41await tools.executor.coreTools.policies.create({
33 owner: "user",42 owner: "user",
34 pattern: ${JSON.stringify(pattern)},43 pattern: ${JSON.stringify(pattern)},
35 action: "block",44 action: "block",
36});45});
46return ${JSON.stringify(approvedPayload)};
37`;47`;
38 48
39// Why this was long skipped: `autoApprove: true` came back `"paused"` instead of49// Why this was long skipped: `autoApprove: true` came back `"paused"` instead of
@@ -97,6 +107,12 @@ scenario( · 57 unmodified lines
97 expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed");107 expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed");
98 if (approved.status !== "completed") return; // narrowing only108 if (approved.status !== "completed") return; // narrowing only
99 expect(approved.isError, "the auto-approved run is not an error").toBe(false);109 expect(approved.isError, "the auto-approved run is not an error").toBe(false);
110 expect(approved.text, "the returned value reaches the panel byte-identical").toBe(
111 JSON.stringify(approvedPayload, null, 2),
112 );
113 expect(approved.structured, "the structured result mirrors the exact returned value").toEqual(
114 { status: "completed", result: approvedPayload, logs: [] },
115 );
100 116
101 const afterApproval = yield* client.policies.list();117 const afterApproval = yield* client.policies.list();
102 expect(118 expect(
e2e/selfhost/auth-methods-ui.test.ts+1−1
@@ -169,7 +169,7 @@ scenario( · 168 unmodified lines
169 });169 });
170 170
171 await step("The probe detected OAuth", async () => {171 await step("The probe detected OAuth", async () => {
172 await page.getByText("Method 1 · Detected").waitFor();172 await page.getByText("OAuth · Detected").waitFor();
173 // The OAuth editor declares discovery-at-connect, not pasted URLs.173 // The OAuth editor declares discovery-at-connect, not pasted URLs.
174 await page.getByText("OAuth metadata is discovered from this server").waitFor();174 await page.getByText("OAuth metadata is discovered from this server").waitFor();
175 });175 });
e2e/selfhost/detected-auth-immutable-ui.test.ts+15−14
@@ -1,6 +1,6 @@
1// Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in1// Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in
2// the add flow. The shared AuthMethodListEditor renders detected methods as a2// the add flow. The shared AuthMethodListEditor renders detected methods as a
3// disabled, read-only summary ("Pulled from spec. Remove to override.") with no3// disabled, read-only summary (declared-by-this-API footer) with no
4// kind selector, so a user can't silently retype the spec's method into a kind4// kind selector, so a user can't silently retype the spec's method into a kind
5// nothing backs. A method the user adds by hand stays fully editable. Both the5// nothing backs. A method the user adds by hand stays fully editable. Both the
6// MCP and OpenAPI add flows compose the same editor, so one behavior, two6// MCP and OpenAPI add flows compose the same editor, so one behavior, two
@@ -18,7 +18,9 @@ import { scenario } from "../src/scenario"; · 11 unmodified lines
18import { Browser, Target } from "../src/services";18import { Browser, Target } from "../src/services";
19import { visit } from "../src/surfaces/browser";19import { visit } from "../src/surfaces/browser";
20 20
21const REMOVE_HINT = "Pulled from spec. Remove to override.";21// The detected-method footer names the source and what happens next; the
22// stable common prefix is the assertion target.
23const REMOVE_HINT = "Declared by this API.";
22 24
23scenario(25scenario(
24 "Detected auth · an MCP probe's OAuth method is immutable in the add flow",26 "Detected auth · an MCP probe's OAuth method is immutable in the add flow",
@@ -39,20 +41,16 @@ scenario( · 14 unmodified lines
39 await step("Open the add-MCP flow pointed at the OAuth server", async () => {41 await step("Open the add-MCP flow pointed at the OAuth server", async () => {
40 await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);42 await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`);
41 await page.getByText("How does this server authenticate?").waitFor();43 await page.getByText("How does this server authenticate?").waitFor();
42 await page.getByText("Method 1 · Detected").waitFor();44 await page.getByText("OAuth · Detected").waitFor();
43 });45 });
44 46
45 await step("The detected method is locked: read-only, named, no selector", async () => {47 await step("The detected method is locked: read-only, named, no selector", async () => {
46 // The kind is named explicitly ("OAuth"), the discovered-OAuth summary48 // The kind is named in the row TITLE ("OAuth · Detected", asserted
47 // and override hint sit inside a disabled block, and there is NO49 // above), the discovered-OAuth summary and override hint render
48 // editable kind selector (the FilterTabs render as buttons).50 // read-only, and there is NO editable kind selector (the FilterTabs
49 await page.getByText("OAuth", { exact: true }).first().waitFor();51 // render as buttons).
50 await page.getByText("OAuth metadata is discovered from this server").waitFor();52 await page.getByText("OAuth metadata is discovered from this server").waitFor();
51 await page.getByText(REMOVE_HINT).waitFor();53 await page.getByText(REMOVE_HINT).waitFor();
52 expect(
53 await page.locator("[aria-disabled]").count(),
54 "the detected method renders a disabled (non-interactive) block",
55 ).toBeGreaterThan(0);
56 expect(54 expect(
57 await page.getByRole("button", { name: "API key", exact: true }).count(),55 await page.getByRole("button", { name: "API key", exact: true }).count(),
58 "no editable kind selector is shown for the detected method",56 "no editable kind selector is shown for the detected method",
@@ -150,7 +148,9 @@ scenario( · 91 unmodified lines
150 .first()148 .first()
151 .fill(spec.url);149 .fill(spec.url);
152 await page.getByText("How does this API authenticate?").waitFor();150 await page.getByText("How does this API authenticate?").waitFor();
153 await page.getByText("Method 2").waitFor();151 // Detected rows are titled by their kind now, not "Method N"; the
152 // hint below is what marks a rendered detected method.
153 await page.getByText(REMOVE_HINT).first().waitFor();
154 });154 });
155 155
156 await step("Both detected methods are locked, named, read-only", async () => {156 await step("Both detected methods are locked, named, read-only", async () => {
@@ -162,8 +162,9 @@ scenario( · 5 unmodified lines
162 "both detected methods show the remove-to-override hint",162 "both detected methods show the remove-to-override hint",
163 ).toBe(2);163 ).toBe(2);
164 await page.getByText("https://api.acme.test/oauth/authorize").waitFor();164 await page.getByText("https://api.acme.test/oauth/authorize").waitFor();
165 await page.getByText("API key", { exact: true }).first().waitFor();165 // Kinds are named in the row TITLES ("API key · …" / "OAuth · …").
166 await page.getByText("OAuth", { exact: true }).first().waitFor();166 await page.getByText("API key · ").first().waitFor();
167 await page.getByText("OAuth · ").first().waitFor();
167 expect(168 expect(
168 await page.getByRole("button", { name: "OAuth", exact: true }).count(),169 await page.getByRole("button", { name: "OAuth", exact: true }).count(),
169 "no editable kind selector is shown for the detected methods",170 "no editable kind selector is shown for the detected methods",
e2e/selfhost/mcp-auth-required-add.test.ts+1−1
@@ -61,7 +61,7 @@ scenario( · 60 unmodified lines
61 });61 });
62 62
63 await step("The probe seeded a detected Bearer-header method", async () => {63 await step("The probe seeded a detected Bearer-header method", async () => {
64 await page.getByText("Method 1 · Detected").waitFor();64 await page.getByText("API key · Detected").waitFor();
65 // The preview card flags the gate rather than failing the probe.65 // The preview card flags the gate rather than failing the probe.
66 await page.getByText("Auth required").first().waitFor();66 await page.getByText("Auth required").first().waitFor();
67 });67 });
e2e/selfhost/posthog-mcp-oauth.test.ts+1−1
@@ -51,7 +51,7 @@ scenario( · 50 unmodified lines
51 addUrl.searchParams.set("url", server.endpoint);51 addUrl.searchParams.set("url", server.endpoint);
52 await visit(page, addUrl.toString());52 await visit(page, addUrl.toString());
53 await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });53 await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });
54 await page.getByText("Method 1 · Detected").waitFor();54 await page.getByText("OAuth · Detected").waitFor();
55 await page.getByText("OAuth metadata is discovered from this server").waitFor();55 await page.getByText("OAuth metadata is discovered from this server").waitFor();
56 });56 });
57 57
examples/all-plugins/CHANGELOG.md+28−0
@@ -1,5 +1,33 @@
1# @executor-js/example-all-plugins1# @executor-js/example-all-plugins
2 2
3## 0.0.67
4
5### Patch Changes
6
7- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed)]:
8 - @executor-js/plugin-mcp@1.6.6
9 - @executor-js/plugin-graphql@1.6.6
10 - @executor-js/plugin-onepassword@1.6.6
11 - @executor-js/plugin-openapi@1.6.6
12 - @executor-js/plugin-workos-vault@0.0.2
13 - @executor-js/sdk@1.6.6
14 - @executor-js/plugin-file-secrets@1.6.6
15 - @executor-js/plugin-keychain@1.6.6
16
17## 0.0.66
18
19### Patch Changes
20
21- Updated dependencies [[`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2), [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5)]:
22 - @executor-js/plugin-mcp@1.6.5
23 - @executor-js/sdk@1.6.5
24 - @executor-js/plugin-file-secrets@1.6.5
25 - @executor-js/plugin-graphql@1.6.5
26 - @executor-js/plugin-keychain@1.6.5
27 - @executor-js/plugin-onepassword@1.6.5
28 - @executor-js/plugin-openapi@1.6.5
29 - @executor-js/plugin-workos-vault@0.0.2
30
3## 0.0.6531## 0.0.65
4 32
5### Patch Changes33### Patch Changes
examples/all-plugins/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/example-all-plugins",2 "name": "@executor-js/example-all-plugins",
3 "version": "0.0.65",3 "version": "0.0.67",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
examples/docs-sdk-quickstart/CHANGELOG.md+16−0
@@ -1,5 +1,21 @@
1# @executor-js/example-docs-sdk-quickstart1# @executor-js/example-docs-sdk-quickstart
2 2
3## 0.0.52
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/plugin-openapi@1.6.6
9 - @executor-js/sdk@1.6.6
10
11## 0.0.51
12
13### Patch Changes
14
15- Updated dependencies []:
16 - @executor-js/sdk@1.6.5
17 - @executor-js/plugin-openapi@1.6.5
18
3## 0.0.5019## 0.0.50
4 20
5### Patch Changes21### Patch Changes
examples/docs-sdk-quickstart/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/example-docs-sdk-quickstart",2 "name": "@executor-js/example-docs-sdk-quickstart",
3 "version": "0.0.50",3 "version": "0.0.52",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
package.json+1−0
@@ -24,6 +24,7 @@ · 23 unmodified lines
24 "packages/*/*",24 "packages/*/*",
25 "packages/react",25 "packages/react",
26 "packages/app",26 "packages/app",
27 "packages/onboarding-demo",
27 "apps/*",28 "apps/*",
28 "examples/*",29 "examples/*",
29 "e2e"30 "e2e"
packages/app/src/routeTree.gen.ts+23−0
@@ -17,6 +17,7 @@ import { Route as DotDotDotDotDotDotReactSrcRoutesPoliciesRouteImport } from './ · 16 unmodified lines
17import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts'17import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsRouteImport } from './../../react/src/routes/artifacts'
18import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'18import { Route as DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../react/src/routes/toolkits.$toolkitSlug'
19import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'19import { Route as DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport } from './../../react/src/routes/resume.$executionId'
20import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport } from './../../react/src/routes/integrations.browse'
20import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'21import { Route as DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport } from './../../react/src/routes/integrations.$namespace'
21import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'22import { Route as DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRouteImport } from './../../react/src/routes/connect.$integrationSlug'
22import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId'23import { Route as DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRouteImport } from './../../react/src/routes/artifacts.$artifactId'
@@ -70,6 +71,12 @@ const DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute = · 47 unmodified lines
70 path: '/{-$orgSlug}/resume/$executionId',71 path: '/{-$orgSlug}/resume/$executionId',
71 getParentRoute: () => rootRouteImport,72 getParentRoute: () => rootRouteImport,
72 } as any)73 } as any)
74const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute =
75 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport.update({
76 id: '/{-$orgSlug}/integrations/browse',
77 path: '/{-$orgSlug}/integrations/browse',
78 getParentRoute: () => rootRouteImport,
79 } as any)
73const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute =80const DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute =
74 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({81 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRouteImport.update({
75 id: '/{-$orgSlug}/integrations/$namespace',82 id: '/{-$orgSlug}/integrations/$namespace',
@@ -113,6 +120,7 @@ export interface FileRoutesByFullPath { · 37 unmodified lines
113 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute120 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
114 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute121 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
115 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute122 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
123 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
116 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute124 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
117 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute125 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
118 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute126 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 9 unmodified lines
128 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute136 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
129 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
130 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute138 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
139 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
131 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute140 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
132 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute141 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
133 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute142 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -144,6 +153,7 @@ export interface FileRoutesById { · 10 unmodified lines
144 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute153 '/{-$orgSlug}/artifacts/$artifactId': typeof DotDotDotDotDotDotReactSrcRoutesArtifactsDotartifactIdRoute
145 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
146 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute155 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
156 '/{-$orgSlug}/integrations/browse': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
147 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute157 '/{-$orgSlug}/resume/$executionId': typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
148 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute158 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotDotDotDotDotDotReactSrcRoutesToolkitsDottoolkitSlugRoute
149 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute159 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
@@ -161,6 +171,7 @@ export interface FileRouteTypes { · 11 unmodified lines
161 | '/{-$orgSlug}/artifacts/$artifactId'171 | '/{-$orgSlug}/artifacts/$artifactId'
162 | '/{-$orgSlug}/connect/$integrationSlug'172 | '/{-$orgSlug}/connect/$integrationSlug'
163 | '/{-$orgSlug}/integrations/$namespace'173 | '/{-$orgSlug}/integrations/$namespace'
174 | '/{-$orgSlug}/integrations/browse'
164 | '/{-$orgSlug}/resume/$executionId'175 | '/{-$orgSlug}/resume/$executionId'
165 | '/{-$orgSlug}/toolkits/$toolkitSlug'176 | '/{-$orgSlug}/toolkits/$toolkitSlug'
166 | '/{-$orgSlug}/integrations/add/$pluginKey'177 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -176,6 +187,7 @@ export interface FileRouteTypes { · 9 unmodified lines
176 | '/{-$orgSlug}/artifacts/$artifactId'187 | '/{-$orgSlug}/artifacts/$artifactId'
177 | '/{-$orgSlug}/connect/$integrationSlug'188 | '/{-$orgSlug}/connect/$integrationSlug'
178 | '/{-$orgSlug}/integrations/$namespace'189 | '/{-$orgSlug}/integrations/$namespace'
190 | '/{-$orgSlug}/integrations/browse'
179 | '/{-$orgSlug}/resume/$executionId'191 | '/{-$orgSlug}/resume/$executionId'
180 | '/{-$orgSlug}/toolkits/$toolkitSlug'192 | '/{-$orgSlug}/toolkits/$toolkitSlug'
181 | '/{-$orgSlug}/integrations/add/$pluginKey'193 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -191,6 +203,7 @@ export interface FileRouteTypes { · 9 unmodified lines
191 | '/{-$orgSlug}/artifacts/$artifactId'203 | '/{-$orgSlug}/artifacts/$artifactId'
192 | '/{-$orgSlug}/connect/$integrationSlug'204 | '/{-$orgSlug}/connect/$integrationSlug'
193 | '/{-$orgSlug}/integrations/$namespace'205 | '/{-$orgSlug}/integrations/$namespace'
206 | '/{-$orgSlug}/integrations/browse'
194 | '/{-$orgSlug}/resume/$executionId'207 | '/{-$orgSlug}/resume/$executionId'
195 | '/{-$orgSlug}/toolkits/$toolkitSlug'208 | '/{-$orgSlug}/toolkits/$toolkitSlug'
196 | '/{-$orgSlug}/integrations/add/$pluginKey'209 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -206,6 +219,7 @@ export interface RootRouteChildren { · 9 unmodified lines
206 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute219 DotDotDotDotDotDotReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotReactSrcRoutesIndexRoute
207 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute220 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute
208 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute221 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute
222 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute
209 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute223 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute
210 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute224 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute
211 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute225 DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute: typeof DotDotDotDotDotDotReactSrcRoutesPluginsDotpluginIdDotsplatRoute
@@ -269,6 +283,13 @@ declare module '@tanstack/react-router' { · 57 unmodified lines
269 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport283 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRouteImport
270 parentRoute: typeof rootRouteImport284 parentRoute: typeof rootRouteImport
271 }285 }
286 '/{-$orgSlug}/integrations/browse': {
287 id: '/{-$orgSlug}/integrations/browse'
288 path: '/{-$orgSlug}/integrations/browse'
289 fullPath: '/{-$orgSlug}/integrations/browse'
290 preLoaderRoute: typeof DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRouteImport
291 parentRoute: typeof rootRouteImport
292 }
272 '/{-$orgSlug}/integrations/$namespace': {293 '/{-$orgSlug}/integrations/$namespace': {
273 id: '/{-$orgSlug}/integrations/$namespace'294 id: '/{-$orgSlug}/integrations/$namespace'
274 path: '/{-$orgSlug}/integrations/$namespace'295 path: '/{-$orgSlug}/integrations/$namespace'
@@ -353,6 +374,8 @@ const rootRouteChildren: RootRouteChildren = { · 78 unmodified lines
353 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,374 DotDotDotDotDotDotReactSrcRoutesConnectDotintegrationSlugRoute,
354 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:375 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute:
355 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,376 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotnamespaceRoute,
377 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute:
378 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotbrowseRoute,
356 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:379 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute:
357 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute,380 DotDotDotDotDotDotReactSrcRoutesResumeDotexecutionIdRoute,
358 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:381 DotDotDotDotDotDotReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute:
packages/app/src/web/shell.tsx+6−0
@@ -136,6 +136,12 @@ function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { · 135 unmodified lines
136 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",136 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
137 ].join(" ")}137 ].join(" ")}
138 >138 >
139 {/* Same inputs as the integrations list. Passing only the
140 slug and kind meant an icon could be found ONLY when a
141 bundled preset happened to match — anything added from the
142 registry has a slug no preset knows, so the sidebar sat
143 blank while the same integration showed its mark in the
144 list. The display URL is what a favicon is derived from. */}
139 <IntegrationIconWithAccount145 <IntegrationIconWithAccount
140 icon={integrationPresetIconUrl(146 icon={integrationPresetIconUrl(
141 { id: slug, kind: integration.kind, name, url: integration.displayUrl },147 { id: slug, kind: integration.kind, name, url: integration.displayUrl },
packages/core/analytics/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/analytics1# @executor-js/analytics
2 2
3## 0.1.13
4
5### Patch Changes
6
7- Updated dependencies [[`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d)]:
8 - @executor-js/execution@1.6.6
9
10## 0.1.12
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/execution@1.6.5
16
3## 0.1.1117## 0.1.11
4 18
5### Patch Changes19### Patch Changes
packages/core/analytics/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/analytics",2 "name": "@executor-js/analytics",
3 "version": "0.1.11",3 "version": "0.1.13",
4 "private": true,4 "private": true,
5 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics",5 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics",
6 "bugs": {6 "bugs": {
packages/core/api/CHANGELOG.md+18−0
@@ -1,5 +1,23 @@
1# @executor-js/api1# @executor-js/api
2 2
3## 1.4.69
4
5### Patch Changes
6
7- Updated dependencies [[`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d)]:
8 - @executor-js/execution@1.6.6
9 - @executor-js/host-mcp@1.4.4
10 - @executor-js/sdk@1.6.6
11
12## 1.4.68
13
14### Patch Changes
15
16- Updated dependencies []:
17 - @executor-js/sdk@1.6.5
18 - @executor-js/execution@1.6.5
19 - @executor-js/host-mcp@1.4.4
20
3## 1.4.6721## 1.4.67
4 22
5### Patch Changes23### Patch Changes
packages/core/api/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/api",2 "name": "@executor-js/api",
3 "version": "1.4.67",3 "version": "1.4.69",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
packages/core/cli/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/cli1# @executor-js/cli
2 2
3## 0.2.56
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 0.2.55
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 0.2.5417## 0.2.54
4 18
5### Patch Changes19### Patch Changes
packages/core/cli/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/cli",2 "name": "@executor-js/cli",
3 "version": "0.2.54",3 "version": "0.2.56",
4 "description": "CLI for the executor SDK — schema generation, migrations",4 "description": "CLI for the executor SDK — schema generation, migrations",
5 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli",5 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli",
6 "bugs": {6 "bugs": {
packages/core/config/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/config1# @executor-js/config
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 1.6.5
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 1.6.417## 1.6.4
4 18
5### Patch Changes19### Patch Changes
packages/core/config/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/config",2 "name": "@executor-js/config",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/core/execution/CHANGELOG.md+20−0
@@ -1,5 +1,25 @@
1# @executor-js/execution1# @executor-js/execution
2 2
3## 1.6.6
4
5### Patch Changes
6
7- [#1866](https://github.com/UsefulSoftwareCo/executor/pull/1866) [`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Large execute results are measured once, not once per span**
8
9 The result-size telemetry probe serializes the whole returned value to count its characters, and that cost grows with the payload. The same result object was walked again every time it was stamped onto another span: an operator-approved run measured it twice (inner and outer span), and every retried `resume` that replayed a settled outcome measured it again. The measurement is now computed once per result object and reused, so a large result pays one size walk no matter how many spans report it. Response text, structured content, and span attribute values are unchanged.
10
11- Updated dependencies []:
12 - @executor-js/sdk@1.6.6
13 - @executor-js/codemode-core@1.6.6
14
15## 1.6.5
16
17### Patch Changes
18
19- Updated dependencies []:
20 - @executor-js/sdk@1.6.5
21 - @executor-js/codemode-core@1.6.5
22
3## 1.6.423## 1.6.4
4 24
5### Patch Changes25### Patch Changes
packages/core/execution/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/execution",2 "name": "@executor-js/execution",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/core/execution/src/engine.test.ts+137−1
@@ -5,7 +5,7 @@ import { createExecutor, definePlugin } from "@executor-js/sdk"; · 4 unmodified lines
5import { makeTestConfig } from "@executor-js/sdk/testing";5import { makeTestConfig } from "@executor-js/sdk/testing";
6import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core";6import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core";
7 7
8import { createExecutionEngine, formatPausedExecution } from "./engine";8import { createExecutionEngine, formatExecuteResult, formatPausedExecution } from "./engine";
9import { FormElicitation } from "@executor-js/sdk/core";9import { FormElicitation } from "@executor-js/sdk/core";
10 10
11// Regression for the hang reported as the executor-MCP "180s timeout" against11// Regression for the hang reported as the executor-MCP "180s timeout" against
@@ -140,3 +140,139 @@ describe("formatPausedExecution approval terms", () => { · 128 unmodified lines
140 expect((result.structured["interaction"] as Record<string, unknown>)["meta"]).toBeUndefined();140 expect((result.structured["interaction"] as Record<string, unknown>)["meta"]).toBeUndefined();
141 });141 });
142});142});
143
144// Pins the exact preview and structured shapes so the serialization dedupe in
145// the engine cannot drift them: the preview stays pretty-printed (indent 2),
146// truncation keeps its exact suffix, and structured carries the raw value.
147describe("formatExecuteResult output identity", () => {
148 const MAX_PREVIEW_CHARS = 30_000;
149
150 it("renders an object result as pretty-printed JSON and keeps the raw value in structured", () => {
151 const value = { café: "naïve — ✓", emoji: "🎉", nested: { π: 3.14159 } };
152 const formatted = formatExecuteResult({ result: value, logs: [] });
153
154 expect(formatted.text).toBe(JSON.stringify(value, null, 2));
155 expect(formatted.structured).toEqual({ status: "completed", result: value, logs: [] });
156 expect(formatted.structured["result"]).toBe(value);
157 expect(formatted.isError).toBe(false);
158 });
159
160 it("truncates a long preview with the exact suffix and untouched structured value", () => {
161 const value = { data: "é🎉".repeat(12_000) };
162 const pretty = JSON.stringify(value, null, 2);
163 expect(pretty.length).toBeGreaterThan(MAX_PREVIEW_CHARS);
164
165 const formatted = formatExecuteResult({ result: value });
166
167 expect(formatted.text).toBe(
168 `${pretty.slice(0, MAX_PREVIEW_CHARS)}\n... [truncated ${pretty.length - MAX_PREVIEW_CHARS} chars]`,
169 );
170 expect(formatted.structured["result"]).toBe(value);
171 });
172
173 it("returns a string result verbatim", () => {
174 const formatted = formatExecuteResult({ result: "plain — ✓", logs: ["l1", "l2"] });
175
176 expect(formatted.text).toBe("plain — ✓\n\nLogs:\nl1\nl2");
177 expect(formatted.structured).toEqual({
178 status: "completed",
179 result: "plain — ✓",
180 logs: ["l1", "l2"],
181 });
182 });
183
184 it("renders an error result with logs and truncation", () => {
185 const formatted = formatExecuteResult({
186 result: null,
187 error: "boom",
188 errorKind: "tool_error",
189 logs: ["x".repeat(MAX_PREVIEW_CHARS)],
190 });
191
192 const untruncated = `Error: boom\n\nLogs:\n${"x".repeat(MAX_PREVIEW_CHARS)}`;
193 expect(formatted.text).toBe(
194 `${untruncated.slice(0, MAX_PREVIEW_CHARS)}\n... [truncated ${untruncated.length - MAX_PREVIEW_CHARS} chars]`,
195 );
196 expect(formatted.isError).toBe(true);
197 expect(formatted.structured).toEqual({
198 status: "error",
199 error: "boom",
200 logs: ["x".repeat(MAX_PREVIEW_CHARS)],
201 });
202 });
203});
204
205// The result-size span metric walks the whole result value with
206// `JSON.stringify`. A `toJSON` probe inside the fixture counts full walks:
207// every complete stringify of the value visits the probe exactly once.
208describe("execute outcome measurement cost", () => {
209 const instrumented = () => {
210 let walks = 0;
211 const probe = {
212 toJSON: () => {
213 walks += 1;
214 return "probe";
215 },
216 };
217 return { value: { data: [1, 2, 3], probe }, walks: () => walks };
218 };
219
220 const executorFor = (result: ExecuteResult): CodeExecutor<FakeRuntimeError> => ({
221 execute: () => Effect.succeed(result),
222 });
223
224 it.effect("walks the result value once on the pausable path", () =>
225 Effect.gen(function* () {
226 const executor = yield* makeExecutor();
227 const fixture = instrumented();
228 const engine = createExecutionEngine({
229 executor,
230 codeExecutor: executorFor({ result: fixture.value, logs: [] }),
231 });
232
233 const outcome = yield* engine.executeWithPause("noop");
234
235 expect(outcome.status).toBe("completed");
236 expect(fixture.walks()).toBe(1);
237 }),
238 );
239
240 it.effect("walks the result value once even when autoApprove stamps two spans", () =>
241 Effect.gen(function* () {
242 const executor = yield* makeExecutor();
243 const fixture = instrumented();
244 const engine = createExecutionEngine({
245 executor,
246 codeExecutor: executorFor({ result: fixture.value, logs: [] }),
247 });
248
249 // autoApprove runs the inline path (inner span annotation) and then
250 // annotates the outer pausable span with the same result.
251 const outcome = yield* engine.executeWithPause("noop", { autoApprove: true });
252
253 expect(outcome.status).toBe("completed");
254 expect(fixture.walks()).toBe(1);
255 }),
256 );
257
258 it.effect("a completed execution plus its preview pays two walks total", () =>
259 Effect.gen(function* () {
260 const executor = yield* makeExecutor();
261 const fixture = instrumented();
262 const engine = createExecutionEngine({
263 executor,
264 codeExecutor: executorFor({ result: fixture.value, logs: [] }),
265 });
266
267 const outcome = yield* engine.executeWithPause("noop");
268 expect(outcome.status).toBe("completed");
269 if (outcome.status !== "completed") return;
270
271 // One compact walk for the span size metric, one pretty walk for the
272 // preview. They produce different strings (compact vs indent 2), so
273 // neither can be derived from the other.
274 formatExecuteResult(outcome.result);
275 expect(fixture.walks()).toBe(2);
276 }),
277 );
278});
packages/core/execution/src/engine.ts+27−8
@@ -80,6 +80,32 @@ const measureResultChars = (value: unknown): number => { · 79 unmodified lines
80 }80 }
81};81};
82 82
83/**
84 * Outcome attributes are a pure function of an immutable `ExecuteResult`, but
85 * the same result object is annotated more than once: the `autoApprove` path
86 * stamps both the inner inline span and the outer pausable span, and resume
87 * retries replay the settled result cached per execution id. The size probe
88 * walks the whole result value (`JSON.stringify`), so its cost grows with the
89 * payload — memoize the record per result object so each result is walked
90 * once, no matter how many spans it is stamped onto.
91 */
92const executeOutcomeAttributesCache = new WeakMap<ExecuteResult, Record<string, unknown>>();
93
94const executeOutcomeAttributes = (result: ExecuteResult): Record<string, unknown> => {
95 const cached = executeOutcomeAttributesCache.get(result);
96 if (cached) return cached;
97 const attributes = {
98 "mcp.execute.result_chars": measureResultChars(result.result),
99 "mcp.execute.log_chars": result.logs?.reduce((total, line) => total + line.length, 0) ?? 0,
100 "mcp.execute.emitted": result.output?.length ?? 0,
101 ...(result.error
102 ? { "mcp.execute.outcome": "fail", "mcp.execute.error_kind": result.errorKind ?? "unknown" }
103 : { "mcp.execute.outcome": "ok" }),
104 };
105 executeOutcomeAttributesCache.set(result, attributes);
106 return attributes;
107};
108
83/**109/**
84 * Stamp the current `mcp.execute` / `mcp.execute.resume` span with how the110 * Stamp the current `mcp.execute` / `mcp.execute.resume` span with how the
85 * execution ended and how much data it sent back toward model context.111 * execution ended and how much data it sent back toward model context.
@@ -89,14 +115,7 @@ const measureResultChars = (value: unknown): number => { · 3 unmodified lines
89 * or result content itself.115 * or result content itself.
90 */116 */
91const annotateExecuteOutcome = (result: ExecuteResult) =>117const annotateExecuteOutcome = (result: ExecuteResult) =>
92 Effect.annotateCurrentSpan({118 Effect.annotateCurrentSpan(executeOutcomeAttributes(result));
93 "mcp.execute.result_chars": measureResultChars(result.result),
94 "mcp.execute.log_chars": result.logs?.reduce((total, line) => total + line.length, 0) ?? 0,
95 "mcp.execute.emitted": result.output?.length ?? 0,
96 ...(result.error
97 ? { "mcp.execute.outcome": "fail", "mcp.execute.error_kind": result.errorKind ?? "unknown" }
98 : { "mcp.execute.outcome": "ok" }),
99 });
100 119
101const annotateExecutionOutcome = (execution: ExecutionResult) =>120const annotateExecutionOutcome = (execution: ExecutionResult) =>
102 execution.status === "paused"121 execution.status === "paused"
packages/core/sdk/CHANGELOG.md+4−0
@@ -1,5 +1,9 @@
1# @executor-js/sdk1# @executor-js/sdk
2 2
3## 1.6.6
4
5## 1.6.5
6
3## 1.6.47## 1.6.4
4 8
5### Patch Changes9### Patch Changes
packages/core/sdk/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/sdk",2 "name": "@executor-js/sdk",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/core/sdk/src/client.ts+60−0
@@ -124,6 +124,12 @@ export interface IntegrationPreset { · 123 unmodified lines
124 readonly specOverrides?: readonly unknown[];124 readonly specOverrides?: readonly unknown[];
125 readonly authTemplate?: readonly IntegrationPresetAuthentication[];125 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
126 readonly healthCheck?: HealthCheckSpec;126 readonly healthCheck?: HealthCheckSpec;
127 /** The public registry lists this product, so the picker shows the
128 * registry's card instead of a preset card (the preset's knowledge still
129 * rides quick add). Built-in presets set this; a deployment's custom
130 * preset does not, and keeps its own card — hiding it would make a
131 * private API undiscoverable. */
132 readonly registryListed?: boolean;
127}133}
128 134
129export type IntegrationPresetAuthentication =135export type IntegrationPresetAuthentication =
@@ -199,6 +205,34 @@ export interface EditSheetSectionProps { · 69 unmodified lines
199 readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;205 readonly onPendingChange?: (apply: (() => Promise<EditSheetApplyResult>) | null) => void;
200}206}
201 207
208/** What a registry row already knows about a connect target — enough, for
209 * some plugins, to register without showing the configuration screen. */
210export interface IntegrationQuickAddInput {
211 /** The connect target: MCP endpoint, OpenAPI spec URL, or GraphQL endpoint. */
212 readonly url: string;
213 /** Display name for the integration ("Stripe MCP", "Outlook Mail API"). */
214 readonly name: string;
215 /** Registry surface slug, used as the namespace seed when present. */
216 readonly slug?: string;
217 /** The registry PRODUCT's domain (notion.com) — the identity credential
218 * guidance and favicons key on. Often differs from the connect URL's
219 * host: specs live on code hosts. */
220 readonly domain?: string;
221 /** Registry-declared credential placement, e.g. "Authorization: {api_key}". */
222 readonly authHeader?: string;
223 /** Registry-declared credential kind ("none", "oauth", "api_key", …). */
224 readonly authKind?: string;
225 /** RFC 6902 patch the registry says to apply to the fetched spec. */
226 readonly specOverrides?: readonly unknown[];
227}
228
229export type IntegrationQuickAddResult =
230 /** Registered; `slug` is the created integration's namespace. */
231 | { readonly ok: true; readonly slug: string }
232 /** Could not add headlessly — the host falls back to the configuration
233 * screen, which renders the failure with full context. */
234 | { readonly ok: false; readonly reason: string };
235
202export interface IntegrationPlugin {236export interface IntegrationPlugin {
203 /** Unique key matching the SDK plugin id (e.g. "openapi"). */237 /** Unique key matching the SDK plugin id (e.g. "openapi"). */
204 readonly key: string;238 readonly key: string;
@@ -213,6 +247,21 @@ export interface IntegrationPlugin { · 8 unmodified lines
213 readonly initialUrl?: string;247 readonly initialUrl?: string;
214 readonly initialPreset?: string;248 readonly initialPreset?: string;
215 readonly initialNamespace?: string;249 readonly initialNamespace?: string;
250 /** Registry-declared credential placement for the surface, e.g.
251 * "Authorization: {api_key}" — the pattern Linear's no-Bearer personal
252 * keys need. Plugins whose surfaces can't self-describe auth (GraphQL)
253 * seed their auth-method editor from it. */
254 readonly initialAuthHeader?: string;
255 readonly initialAuthNote?: string;
256 /** Registry-declared credential kind ("none", "oauth", "api_key", …).
257 * Lets a flow whose live probe fails, or whose surface can't be probed,
258 * still declare the right method — an authless MCP server is a fact the
259 * registry already knows. */
260 readonly initialAuthKind?: string;
261 /** JSON-encoded RFC 6902 patch the registry says to apply to the fetched
262 * spec — the registry's mechanism for improving a vendor's published
263 * document over time without hosting a fork. */
264 readonly initialSpecOverrides?: string;
216 }>;265 }>;
217 /** Legacy full-page edit surface. No host renders this anymore — plugin266 /** Legacy full-page edit surface. No host renders this anymore — plugin
218 * configuration lives in the integration Edit sheet via `editSheet`. */267 * configuration lives in the integration Edit sheet via `editSheet`. */
@@ -245,6 +294,17 @@ export interface IntegrationPlugin { · 26 unmodified lines
245 * Call from the host on intent (hover/focus) so the chunks land before the294 * Call from the host on intent (hover/focus) so the chunks land before the
246 * user navigates into the add page. Idempotent. */295 * user navigates into the add page. Idempotent. */
247 readonly preload?: () => void;296 readonly preload?: () => void;
297 /** Headless one-click add for a registry row whose facts are already known
298 * (URL + auth indicators). A HOOK, not a plain function, because each
299 * plugin binds its own mutation atoms — the host mounts one bridge
300 * component per plugin to collect the bound callbacks. Absent means the
301 * plugin always needs its configuration screen. A `{ok: false}` result is
302 * an expected outcome (unreachable server, slug collision), not an error:
303 * the host falls back to the configuration screen prefilled with the same
304 * facts. */
305 readonly useQuickAdd?: () => (
306 input: IntegrationQuickAddInput,
307 ) => Promise<IntegrationQuickAddResult>;
248}308}
249 309
250// ---------------------------------------------------------------------------310// ---------------------------------------------------------------------------
packages/core/sdk/src/plugin.ts+4−0
@@ -612,6 +612,10 @@ export interface IntegrationPreset { · 611 unmodified lines
612 readonly specOverrides?: readonly unknown[];612 readonly specOverrides?: readonly unknown[];
613 readonly authTemplate?: readonly IntegrationPresetAuthentication[];613 readonly authTemplate?: readonly IntegrationPresetAuthentication[];
614 readonly healthCheck?: HealthCheckSpec;614 readonly healthCheck?: HealthCheckSpec;
615 /** The public registry lists this product: the picker shows the registry's
616 * card, and the preset's knowledge rides quick add instead. A custom
617 * deployment preset leaves this unset and keeps its own card. */
618 readonly registryListed?: boolean;
615 readonly transport?: "remote" | "stdio";619 readonly transport?: "remote" | "stdio";
616 readonly command?: string;620 readonly command?: string;
617 readonly args?: readonly string[];621 readonly args?: readonly string[];
packages/core/vite-plugin/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/vite-plugin1# @executor-js/vite-plugin
2 2
3## 0.0.66
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 0.0.65
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 0.0.6417## 0.0.64
4 18
5### Patch Changes19### Patch Changes
packages/core/vite-plugin/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/vite-plugin",2 "name": "@executor-js/vite-plugin",
3 "version": "0.0.64",3 "version": "0.0.66",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/hosts/cloudflare/CHANGELOG.md+20−0
@@ -1,5 +1,25 @@
1# @executor-js/cloudflare1# @executor-js/cloudflare
2 2
3## 0.0.48
4
5### Patch Changes
6
7- Updated dependencies [[`21119da`](https://github.com/UsefulSoftwareCo/executor/commit/21119da662d2d225b033b3532e1f17d97311a39d)]:
8 - @executor-js/execution@1.6.6
9 - @executor-js/api@1.4.69
10 - @executor-js/host-mcp@1.4.4
11 - @executor-js/sdk@1.6.6
12
13## 0.0.47
14
15### Patch Changes
16
17- Updated dependencies []:
18 - @executor-js/sdk@1.6.5
19 - @executor-js/execution@1.6.5
20 - @executor-js/api@1.4.68
21 - @executor-js/host-mcp@1.4.4
22
3## 0.0.4623## 0.0.46
4 24
5### Patch Changes25### Patch Changes
packages/hosts/cloudflare/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/cloudflare",2 "name": "@executor-js/cloudflare",
3 "version": "0.0.46",3 "version": "0.0.48",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
packages/hosts/mcp-apps-shell/CHANGELOG.md+16−0
@@ -1,5 +1,21 @@
1# @executor-js/mcp-apps-shell1# @executor-js/mcp-apps-shell
2 2
3## 1.4.17
4
5### Patch Changes
6
7- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
8 - @executor-js/react@1.4.69
9 - @executor-js/runtime-quickjs@1.6.6
10
11## 1.4.16
12
13### Patch Changes
14
15- Updated dependencies []:
16 - @executor-js/runtime-quickjs@1.6.5
17 - @executor-js/react@1.4.68
18
3## 1.4.1519## 1.4.15
4 20
5### Patch Changes21### Patch Changes
packages/hosts/mcp-apps-shell/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/mcp-apps-shell",2 "name": "@executor-js/mcp-apps-shell",
3 "version": "1.4.15",3 "version": "1.4.17",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
packages/hosts/mcp/src/tool-server.test.ts+61−0
@@ -1973,3 +1973,64 @@ describe("MCP host server — hang-visibility tracing", () => { · 1972 unmodified lines
1973 );1973 );
1974 });1974 });
1975});1975});
1976
1977// Pins that formatting a completed execution for MCP walks the result value
1978// exactly once (`formatExecuteResult`'s pretty print), with or without emit()
1979// output — the with-output and without-output branches are alternatives, never
1980// stacked. A `toJSON` probe counts full `JSON.stringify` walks of the value.
1981describe("formatMcpExecutionOutcome serialization cost", () => {
1982 const instrumented = () => {
1983 let walks = 0;
1984 const probe = {
1985 toJSON: () => {
1986 walks += 1;
1987 return "probe";
1988 },
1989 };
1990 return { value: { data: [1, 2, 3], probe }, walks: () => walks };
1991 };
1992
1993 it("stringifies the result value once for a plain completed outcome", () => {
1994 const fixture = instrumented();
1995 const outcome: ExecutionResult = {
1996 status: "completed",
1997 result: { result: fixture.value, logs: [] },
1998 };
1999
2000 const result = formatMcpExecutionOutcome(outcome);
2001
2002 expect(fixture.walks()).toBe(1);
2003 const first = result.content[0];
2004 expectDefined(first);
2005 expect(first).toEqual({
2006 type: "text",
2007 text: JSON.stringify(fixture.value, null, 2),
2008 });
2009 expectDefined(result.structuredContent);
2010 expect(result.structuredContent["result"]).toBe(fixture.value);
2011 // The identity probe above walked the value once more; discount it.
2012 expect(fixture.walks()).toBe(2);
2013 });
2014
2015 it("stringifies the result value once when emit() output is present", () => {
2016 const fixture = instrumented();
2017 const outcome: ExecutionResult = {
2018 status: "completed",
2019 result: {
2020 result: fixture.value,
2021 logs: [],
2022 output: [{ type: "content", content: { type: "text", text: "emitted" } }],
2023 },
2024 };
2025
2026 const result = formatMcpExecutionOutcome(outcome);
2027
2028 expect(fixture.walks()).toBe(1);
2029 expect(result.content[0]).toEqual({ type: "text", text: "emitted" });
2030 const returned = result.content[1];
2031 expectDefined(returned);
2032 expect(returned.type).toBe("text");
2033 if (returned.type !== "text") return;
2034 expect(returned.text).toContain('"data"');
2035 });
2036});
packages/kernel/core/CHANGELOG.md+4−0
@@ -1,5 +1,9 @@
1# @executor-js/codemode-core1# @executor-js/codemode-core
2 2
3## 1.6.6
4
5## 1.6.5
6
3## 1.6.47## 1.6.4
4 8
5## 1.6.39## 1.6.3
packages/kernel/core/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/codemode-core",2 "name": "@executor-js/codemode-core",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/kernel/runtime-quickjs/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/runtime-quickjs1# @executor-js/runtime-quickjs
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/codemode-core@1.6.6
9
10## 1.6.5
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/codemode-core@1.6.5
16
3## 1.6.417## 1.6.4
4 18
5### Patch Changes19### Patch Changes
packages/kernel/runtime-quickjs/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/runtime-quickjs",2 "name": "@executor-js/runtime-quickjs",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/kernel/runtime-workerd-subprocess/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/runtime-workerd-subprocess1# @executor-js/runtime-workerd-subprocess
2 2
3## 0.0.21
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/codemode-core@1.6.6
9
10## 0.0.20
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/codemode-core@1.6.5
16
3## 0.0.1917## 0.0.19
4 18
5### Patch Changes19### Patch Changes
packages/kernel/runtime-workerd-subprocess/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/runtime-workerd-subprocess",2 "name": "@executor-js/runtime-workerd-subprocess",
3 "version": "0.0.19",3 "version": "0.0.21",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
packages/onboarding-demo/CHANGELOG.md+11−0
@@ -0,0 +1,11 @@
1# @executor-js/onboarding-demo
2
3## 0.0.1
4
5### Patch Changes
6
7- Updated dependencies [[`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed), [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
8 - @executor-js/plugin-mcp@1.6.6
9 - @executor-js/react@1.4.69
10 - @executor-js/plugin-openapi@1.6.6
11 - @executor-js/sdk@1.6.6
packages/onboarding-demo/README.md+53−0
@@ -0,0 +1,53 @@
1# Onboarding demo
2
3A standalone reproduction of executor's **current** add-integration flow, from
4"the CLI is connected" to "the integration has a working connection". No API, no
5auth, no tenant — it runs off `src/fixtures.ts` and the public integrations.sh
6endpoints.
7
8```
9bun run --cwd packages/onboarding-demo dev # http://localhost:5199
10```
11
12Every screen is addressable: `#integrations-empty`, `#connect-dialog`,
13`#add-openapi`, `#detail-accounts`, `#add-account-credential`,
14`#add-account-place`, `#oauth-stuck`, `#integrations-populated`. `[` and `]`
15step through them. `src/flow.ts` records, per screen, the route and source
16component it was reproduced from plus the first-run reactions against it.
17
18A floating Current / Reworked control in the bottom-right corner switches
19between the two flows.
20
21## Reworked flow
22
23`#reworked` — the picker, `#reworked/installed`, `#reworked/custom`,
24`#reworked/<domain>`, `#reworked/<domain>/auth`. Browse/Installed carries
25navigation within the picker; the console sidebar stays as it is today. A deep link to a domain you have not added in this session
26says so rather than rendering an empty screen — the prototype keeps no state
27across reloads.
28
29Registry picks and custom URLs converge: both become an added integration with
30one `default` account reading Needs auth. Only the detail header differs, naming
31where the definition came from.
32
33## Fidelity
34
35- The curated preset list is imported from the real plugin modules
36 (`@executor-js/plugin-openapi/presets`, `@executor-js/plugin-mcp/presets`),
37 so it is the list the console shows, in the console's order.
38- UI primitives and design tokens are imported from `@executor-js/react`, not
39 copied — the reproduction uses the same buttons, dialogs, card stacks, tabs
40 and tokens as the console.
41- The catalog search calls the real `integrations.sh/api/search` with the same
42 250ms debounce and 2-character minimum.
43- Screen structure and copy are transcribed from the source components named in
44 the inspector.
45
46What is faked: the workspace's own data (integrations, connections, tools) and
47every mutation. Adding an integration waits and moves on; nothing persists.
48
49`fixtures.ts` also exposes two integrations.sh endpoints the console does **not**
50use today — `fetchCatalogDomains` (the whole ~3.4k-domain popularity-sorted
51registry) and `fetchSurfaceCredentials` (per-domain credential label, the URL
52that mints the key, and setup instructions). They are unused by the
53reproduction and present as raw material for the rework.
packages/onboarding-demo/index.html+18−0
@@ -0,0 +1,18 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>Executor onboarding — current flow</title>
7 <link rel="preconnect" href="https://fonts.googleapis.com" />
8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9 <link
10 rel="stylesheet"
11 href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap"
12 />
13 </head>
14 <body>
15 <div id="root"></div>
16 <script type="module" src="/src/main.tsx"></script>
17 </body>
18</html>
packages/onboarding-demo/package.json+32−0
@@ -0,0 +1,32 @@
1{
2 "name": "@executor-js/onboarding-demo",
3 "version": "0.0.1",
4 "private": true,
5 "type": "module",
6 "scripts": {
7 "dev": "vite",
8 "build": "vite build",
9 "preview": "vite preview",
10 "typecheck": "tsgo --noEmit"
11 },
12 "dependencies": {
13 "@executor-js/plugin-mcp": "workspace:*",
14 "@executor-js/plugin-openapi": "workspace:*",
15 "@executor-js/react": "workspace:*",
16 "@executor-js/sdk": "workspace:*",
17 "lucide-react": "^1.7.0",
18 "react": "catalog:",
19 "react-dom": "catalog:",
20 "sonner": "^2.0.7",
21 "tldts": "^7.0.28"
22 },
23 "devDependencies": {
24 "@tailwindcss/vite": "catalog:",
25 "@types/react": "catalog:",
26 "@types/react-dom": "catalog:",
27 "@vitejs/plugin-react": "catalog:",
28 "tailwindcss": "catalog:",
29 "typescript": "catalog:",
30 "vite": "catalog:"
31 }
32}
packages/onboarding-demo/src/app.tsx+202−0
@@ -0,0 +1,202 @@
1import { useCallback, useEffect, useMemo, useState } from "react";
2import { cn } from "@executor-js/react/lib/utils";
3import { Shell } from "./shell";
4import { IntegrationsPage } from "./screens/integrations-page";
5import { ConnectDialog } from "./screens/connect-dialog";
6import { AddOpenApiPage } from "./screens/add-openapi-page";
7import { IntegrationDetailPage } from "./screens/integration-detail";
8import { AddAccountModal } from "./screens/add-account-modal";
9import { flowSteps, type StepId } from "./flow";
10import { stepState, type AccountModal, type ScreenView } from "./step-state";
11import { NextFlow } from "./next-flow";
12import {
13 gmailAuthMethods,
14 gmailIntegration,
15 posthogAuthMethods,
16 posthogIntegration,
17 seededIntegrations,
18 type DemoIntegration,
19 type DemoPreset,
20} from "./fixtures";
21
22const posthogPreset: DemoPreset = {
23 id: "posthog",
24 name: "PostHog",
25 summary: "Product analytics, events, feature flags, and insights.",
26 icon: "https://integrations.sh/logo/posthog.com",
27 url: "https://raw.githubusercontent.com/PostHog/posthog/master/openapi/openapi.json",
28 pluginKey: "openapi",
29 pluginLabel: "OpenAPI",
30};
31
32const stepFromHash = (): StepId => {
33 const hash = globalThis.location?.hash.replace(/^#/, "") ?? "";
34 return flowSteps.some((step) => step.id === hash) ? (hash as StepId) : "integrations-empty";
35};
36
37function CurrentFlowApp() {
38 // Each screen is addressable as `#<step-id>`, so a specific screen can be
39 // linked to directly rather than clicked toward. `[` and `]` step through.
40 const [stepId, setStepId] = useState<StepId>(stepFromHash);
41 const [overrides, setOverrides] = useState<{
42 readonly integrations?: readonly DemoIntegration[];
43 readonly connectOpen?: boolean;
44 readonly accountModal?: AccountModal;
45 readonly view?: ScreenView;
46 }>({});
47
48 const base = useMemo(() => stepState[stepId], [stepId]);
49 const state = { ...base, ...overrides };
50
51 const goToStep = useCallback((id: StepId) => {
52 setStepId(id);
53 setOverrides({});
54 if (globalThis.location) globalThis.location.hash = id;
55 }, []);
56
57 useEffect(() => {
58 const onHashChange = () => {
59 setStepId(stepFromHash());
60 setOverrides({});
61 };
62 globalThis.addEventListener("hashchange", onHashChange);
63 return () => globalThis.removeEventListener("hashchange", onHashChange);
64 }, []);
65
66 useEffect(() => {
67 const onKey = (event: KeyboardEvent) => {
68 if (event.key !== "[" && event.key !== "]") return;
69 const index = flowSteps.findIndex((candidate) => candidate.id === stepId);
70 const next = event.key === "]" ? index + 1 : index - 1;
71 const target = flowSteps[next];
72 if (target) goToStep(target.id);
73 };
74 globalThis.addEventListener("keydown", onKey);
75 return () => globalThis.removeEventListener("keydown", onKey);
76 }, [stepId, goToStep]);
77
78 const integrations = state.integrations ?? seededIntegrations;
79
80 return (
81 <>
82 <Shell integrations={integrations}>
83 {state.view === "integrations" && (
84 <IntegrationsPage
85 integrations={integrations}
86 onConnect={() => setOverrides((prev) => ({ ...prev, connectOpen: true }))}
87 onOpenIntegration={() => setOverrides((prev) => ({ ...prev, view: "detail" }))}
88 />
89 )}
90
91 {state.view === "add" && (
92 <AddOpenApiPage
93 preset={posthogPreset}
94 onCancel={() => goToStep("integrations-empty")}
95 onComplete={() => goToStep("detail-accounts")}
96 />
97 )}
98
99 {state.view === "detail" && (
100 <IntegrationDetailPage
101 integration={
102 state.accountModal === "oauth-stuck" ? gmailIntegration : posthogIntegration
103 }
104 onAddConnection={() =>
105 setOverrides((prev) => ({ ...prev, accountModal: "credential" }))
106 }
107 />
108 )}
109 </Shell>
110
111 <ConnectDialog
112 open={state.connectOpen === true}
113 onOpenChange={(open) => setOverrides((prev) => ({ ...prev, connectOpen: open }))}
114 onPickPreset={() => goToStep("add-openapi")}
115 onPickPlugin={() => goToStep("add-openapi")}
116 onPickCatalogEntry={() => goToStep("add-openapi")}
117 />
118
119 {state.accountModal !== "none" && (
120 <AddAccountModal
121 // Remount per variant: a modal that stays mounted across a step
122 // change keeps the previous step's wizard position.
123 key={state.accountModal}
124 open
125 onOpenChange={(open) =>
126 setOverrides((prev) => ({ ...prev, accountModal: open ? prev.accountModal : "none" }))
127 }
128 integrationName={state.accountModal === "oauth-stuck" ? "Gmail" : "PostHog"}
129 methods={state.accountModal === "oauth-stuck" ? gmailAuthMethods : posthogAuthMethods}
130 initialStep={state.accountModal === "place" ? "place" : "validate"}
131 stuckConnecting={state.accountModal === "oauth-stuck"}
132 onAdded={() => goToStep("integrations-populated")}
133 />
134 )}
135 </>
136 );
137}
138
139// ---------------------------------------------------------------------------
140// Mode switch
141//
142// Both flows run in the same console chrome, so the comparison is about the
143// flow rather than the styling. The switch is a small floating control rather
144// than a panel: it is scaffolding for looking at the prototype, and it should
145// not take width away from the thing being looked at.
146// ---------------------------------------------------------------------------
147
148type Mode = "current" | "next";
149
150function ModeSwitch(props: { readonly mode: Mode; readonly onChange: (mode: Mode) => void }) {
151 const option = (mode: Mode, label: string) => (
152 // oxlint-disable-next-line react/forbid-elements
153 <button
154 key={mode}
155 type="button"
156 onClick={() => props.onChange(mode)}
157 className={cn(
158 "rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
159 props.mode === mode
160 ? "bg-foreground text-background"
161 : "text-muted-foreground hover:text-foreground",
162 )}
163 >
164 {label}
165 </button>
166 );
167 return (
168 <div className="fixed bottom-4 right-4 z-50 flex items-center gap-0.5 rounded-full border border-border bg-card/95 p-1 shadow-lg backdrop-blur-sm">
169 {option("current", "Current")}
170 {option("next", "Reworked")}
171 </div>
172 );
173}
174
175const modeFromHash = (): Mode => {
176 const hash = globalThis.location?.hash ?? "";
177 return hash.startsWith("#reworked") || hash.startsWith("#next") ? "next" : "current";
178};
179
180export function App() {
181 const [mode, setMode] = useState<Mode>(modeFromHash);
182
183 useEffect(() => {
184 const onHashChange = () => setMode(modeFromHash());
185 globalThis.addEventListener("hashchange", onHashChange);
186 return () => globalThis.removeEventListener("hashchange", onHashChange);
187 }, []);
188
189 const changeMode = (next: Mode) => {
190 setMode(next);
191 if (globalThis.location) {
192 globalThis.location.hash = next === "next" ? "reworked" : "integrations-empty";
193 }
194 };
195
196 return (
197 <div className="flex h-dvh w-full overflow-hidden bg-background text-foreground">
198 {mode === "next" ? <NextFlow /> : <CurrentFlowApp />}
199 <ModeSwitch mode={mode} onChange={changeMode} />
200 </div>
201 );
202}
packages/onboarding-demo/src/catalog.ts+163−0
@@ -0,0 +1,163 @@
1// ---------------------------------------------------------------------------
2// The catalog behind the reworked picker.
3//
4// Everything here except the category is real registry data, loaded live from
5// integrations.sh: ~3.4k domains, popularity-sorted, with icons, descriptions
6// and the formats each one exposes.
7//
8// CATEGORY IS A STAND-IN. integrations.sh has a `categories` field, but only
9// the 13 hand-curated entries populate it — the other ~3,365 domains have none.
10// A category rail like the reference picker's therefore needs the catalog
11// categorised first (the "leverage AI for the generation" step). The keyword
12// pass below exists so the rail is real enough to design against; it is not a
13// taxonomy and should not survive into the product.
14// ---------------------------------------------------------------------------
15
16import { fetchCatalogDomains, type CatalogDomain } from "./fixtures";
17
18export type Category =
19 | "Featured"
20 | "Inbox & calendar"
21 | "Docs & files"
22 | "Developer tools"
23 | "Analytics"
24 | "Payments"
25 | "Support"
26 | "Project management"
27 | "Infrastructure"
28 | "Everything else";
29
30export const CATEGORY_ORDER: readonly Category[] = [
31 "Featured",
32 "Inbox & calendar",
33 "Docs & files",
34 "Developer tools",
35 "Analytics",
36 "Payments",
37 "Support",
38 "Project management",
39 "Infrastructure",
40 "Everything else",
41];
42
43const KEYWORDS: readonly (readonly [Category, readonly string[]])[] = [
44 ["Inbox & calendar", ["email", "mail", "calendar", "inbox", "meeting", "scheduling"]],
45 ["Docs & files", ["document", "file", "drive", "storage", "note", "wiki", "knowledge"]],
46 ["Analytics", ["analytic", "metric", "tracking", "telemetry", "dashboard", "insight"]],
47 ["Payments", ["payment", "invoice", "billing", "subscription", "checkout", "payout"]],
48 ["Support", ["support", "ticket", "helpdesk", "customer", "chat"]],
49 ["Project management", ["issue", "task", "project", "sprint", "roadmap", "backlog"]],
50 [
51 "Infrastructure",
52 ["deploy", "server", "cluster", "database", "kubernetes", "cloud", "hosting", "dns"],
53 ],
54 ["Developer tools", ["repo", "commit", "pull request", "ci", "build", "code", "sdk", "api"]],
55];
56
57/** Best-effort bucket from the description. Deliberately crude — see the file
58 * header. Anything unmatched lands in "Everything else" rather than being
59 * guessed at. */
60const deriveCategory = (entry: CatalogDomain): Category => {
61 const haystack = `${entry.domain} ${entry.description}`.toLowerCase();
62 for (const [category, words] of KEYWORDS) {
63 if (words.some((word) => haystack.includes(word))) return category;
64 }
65 return "Everything else";
66};
67
68/** Domains the reference picker would call "Featured": the ones executor
69 * already ships a curated preset for, which are also the ones most people
70 * arrive wanting. */
71const FEATURED_DOMAINS: readonly string[] = [
72 "google.com",
73 "github.com",
74 "linear.app",
75 "notion.com",
76 "slack.com",
77 "stripe.com",
78 "figma.com",
79 "posthog.com",
80 "sentry.io",
81 "vercel.com",
82 "resend.com",
83 "asana.com",
84];
85
86export interface CatalogItem {
87 readonly domain: string;
88 readonly name: string;
89 readonly description: string;
90 readonly icon: string;
91 readonly formats: readonly string[];
92 readonly category: Category;
93 readonly featured: boolean;
94 readonly popularity: number;
95}
96
97/** Title-case a domain into something that reads like a product name.
98 * `linear.app` → `Linear`, `google.com` → `Google`. */
99const displayName = (domain: string): string => {
100 const stem = domain.split(".")[0] ?? domain;
101 return stem.charAt(0).toUpperCase() + stem.slice(1);
102};
103
104export const loadCatalog = async (): Promise<readonly CatalogItem[]> => {
105 const domains = await fetchCatalogDomains();
106 return domains.map((entry): CatalogItem => {
107 const featured = FEATURED_DOMAINS.includes(entry.domain);
108 return {
109 domain: entry.domain,
110 name: displayName(entry.domain),
111 description: entry.description,
112 icon: entry.icon,
113 formats: Object.keys(entry.formats ?? {}),
114 category: featured ? "Featured" : deriveCategory(entry),
115 featured,
116 popularity: entry.popularity,
117 };
118 });
119};
120
121export const searchItems = (
122 items: readonly CatalogItem[],
123 query: string,
124): readonly CatalogItem[] => {
125 const q = query.trim().toLowerCase();
126 if (q.length === 0) return items;
127 return items.filter((item) =>
128 `${item.domain} ${item.name} ${item.description}`.toLowerCase().includes(q),
129 );
130};
131
132// ---------------------------------------------------------------------------
133// Added integrations
134//
135// A registry pick and a hand-pasted URL become the SAME kind of thing the
136// moment they're added. Only `source` differs, and it only affects what the
137// detail screen can say about where the definition came from — never how you
138// authenticate it or where it lives.
139// ---------------------------------------------------------------------------
140
141export interface AddedIntegration {
142 readonly domain: string;
143 readonly name: string;
144 readonly description: string;
145 readonly icon: string;
146 readonly formats: readonly string[];
147 readonly source: "registry" | "custom";
148 /** Custom only: the endpoint or spec URL the user pasted. */
149 readonly url?: string;
150}
151
152export const fromCatalogItem = (item: CatalogItem): AddedIntegration => ({
153 domain: item.domain,
154 name: item.name,
155 description: item.description,
156 icon: item.icon,
157 formats: item.formats,
158 source: "registry",
159});
160
161/** integrations.sh serves a favicon for any domain, registry entry or not, so
162 * a custom integration still gets a real logo. */
163export const logoFor = (domain: string): string => `https://integrations.sh/logo/${domain}`;
packages/onboarding-demo/src/fixtures.ts+240−0
@@ -0,0 +1,240 @@
1// ---------------------------------------------------------------------------
2// Everything the reproduction renders from.
3//
4// Presets come from the REAL plugin modules, so the curated list here is the
5// same list the console shows. Connections, tools and auth methods are static
6// stand-ins for the API reads the console makes. The integrations.sh calls are
7// the genuine public endpoints — that catalog is already client-callable, which
8// is exactly why it is the obvious raw material for the rework.
9// ---------------------------------------------------------------------------
10
11import { openApiPresets, type OpenApiPreset } from "@executor-js/plugin-openapi/presets";
12import { mcpPresets, type McpPreset } from "@executor-js/plugin-mcp/presets";
13
14export type PluginKey = "openapi" | "mcp" | "graphql" | "google";
15
16export interface DemoPreset {
17 readonly id: string;
18 readonly name: string;
19 readonly summary: string;
20 readonly icon?: string;
21 readonly url?: string;
22 readonly pluginKey: PluginKey;
23 readonly pluginLabel: string;
24}
25
26const fromOpenApi = (preset: OpenApiPreset): DemoPreset => ({
27 id: preset.id,
28 name: preset.name,
29 summary: preset.summary,
30 ...(preset.icon ? { icon: preset.icon } : {}),
31 ...(preset.url ? { url: preset.url } : {}),
32 pluginKey: "openapi",
33 pluginLabel: "OpenAPI",
34});
35
36const fromMcp = (preset: McpPreset): DemoPreset => ({
37 id: preset.id,
38 name: preset.name,
39 summary: preset.summary,
40 ...(preset.icon ? { icon: preset.icon } : {}),
41 ...("url" in preset && preset.url ? { url: preset.url } : {}),
42 pluginKey: "mcp",
43 pluginLabel: "MCP",
44});
45
46/** The console's "Popular integrations" list, in the console's own order:
47 * every loaded plugin's presets, concatenated plugin by plugin. */
48export const curatedPresets: readonly DemoPreset[] = [
49 ...openApiPresets.map(fromOpenApi),
50 ...mcpPresets.map(fromMcp),
51];
52
53/** The plugin chips under "Or add manually". */
54export const integrationPlugins: readonly { readonly key: PluginKey; readonly label: string }[] = [
55 { key: "openapi", label: "OpenAPI" },
56 { key: "mcp", label: "MCP" },
57 { key: "graphql", label: "GraphQL" },
58 { key: "google", label: "Google" },
59];
60
61// ---------------------------------------------------------------------------
62// Workspace state — what a brand-new tenant has, which is nothing
63// ---------------------------------------------------------------------------
64
65export interface DemoIntegration {
66 readonly slug: string;
67 readonly name: string;
68 readonly kind: PluginKey;
69 readonly icon?: string;
70 readonly toolCount: number;
71}
72
73export const seededIntegrations: readonly DemoIntegration[] = [];
74
75/** What the workspace looks like after the user adds PostHog — the state Theo
76 * reached before hunting for the key field. */
77export const posthogIntegration: DemoIntegration = {
78 slug: "posthog",
79 name: "PostHog",
80 kind: "openapi",
81 icon: "https://integrations.sh/logo/posthog.com",
82 toolCount: 0,
83};
84
85/** The OAuth-only integration, for the step where the popup dies. */
86export const gmailIntegration: DemoIntegration = {
87 slug: "gmail",
88 name: "Gmail",
89 kind: "google",
90 icon: "https://integrations.sh/logo/google.com",
91 toolCount: 0,
92};
93
94// ---------------------------------------------------------------------------
95// Auth methods for the add-connection modal
96//
97// Shape mirrors `AuthMethod` in packages/react/src/lib/auth-placements.tsx.
98// The labels are the ones a spec-derived integration actually produces: an
99// OpenAPI spec whose security schemes carry no human name yields ordinal
100// method labels, which is the "Method 1" tab.
101// ---------------------------------------------------------------------------
102
103export interface DemoPlacement {
104 readonly carrier: "header" | "query" | "env";
105 readonly name: string;
106 readonly prefix: string;
107}
108
109export interface DemoAuthMethod {
110 readonly id: string;
111 readonly label: string;
112 readonly kind: "apikey" | "oauth" | "none";
113 readonly placements: readonly DemoPlacement[];
114}
115
116export const posthogAuthMethods: readonly DemoAuthMethod[] = [
117 {
118 id: "method-1",
119 label: "Method 1",
120 kind: "apikey",
121 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
122 },
123 {
124 id: "method-2",
125 label: "Method 2",
126 kind: "apikey",
127 placements: [{ carrier: "query", name: "personal_api_key", prefix: "" }],
128 },
129 { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] },
130];
131
132export const gmailAuthMethods: readonly DemoAuthMethod[] = [
133 { id: "oauth", label: "OAuth2", kind: "oauth", placements: [] },
134];
135
136// ---------------------------------------------------------------------------
137// integrations.sh — the live public catalog
138// ---------------------------------------------------------------------------
139
140export const INTEGRATIONS_SH_ORIGIN = "https://integrations.sh";
141
142export const CONNECTABLE_KINDS = ["mcp", "openapi", "graphql"] as const;
143export type CatalogKind = (typeof CONNECTABLE_KINDS)[number];
144
145export interface CatalogEntry {
146 readonly domain: string;
147 readonly description: string;
148 readonly kinds: readonly CatalogKind[];
149}
150
151export const catalogLogoUrl = (domain: string, size: number): string =>
152 `${INTEGRATIONS_SH_ORIGIN}/logo/${domain}?sz=${size * 2}`;
153
154const isConnectableKind = (kind: string): kind is CatalogKind =>
155 (CONNECTABLE_KINDS as readonly string[]).includes(kind);
156
157/** `/api/search` — the same endpoint the console's connect dialog calls, and
158 * the same 250ms-debounced, ≥2-character contract. */
159export const searchCatalog = async (
160 query: string,
161 limit = 10,
162): Promise<readonly CatalogEntry[]> => {
163 const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN);
164 url.searchParams.set("q", query);
165 url.searchParams.set("limit", String(limit));
166 const response = await fetch(url);
167 if (!response.ok) return [];
168 const payload = (await response.json()) as {
169 readonly results?: readonly {
170 readonly domain: string;
171 readonly description: string;
172 readonly kinds: readonly string[];
173 }[];
174 };
175 return (payload.results ?? [])
176 .map((entry) => ({
177 domain: entry.domain,
178 description: entry.description,
179 kinds: entry.kinds.filter(isConnectableKind),
180 }))
181 .filter((entry) => entry.kinds.length > 0);
182};
183
184export interface CatalogDomain {
185 readonly domain: string;
186 readonly icon: string;
187 readonly description: string;
188 readonly formats: Readonly<Record<string, number>>;
189 readonly popularity: number;
190}
191
192/** `/api/domains.json` — the whole popularity-sorted registry (~3.4k domains).
193 * The console never calls this; it is here because it is the list the product
194 * is missing, and the rework will want it. */
195export const fetchCatalogDomains = async (): Promise<readonly CatalogDomain[]> => {
196 const response = await fetch(`${INTEGRATIONS_SH_ORIGIN}/api/domains.json`);
197 if (!response.ok) return [];
198 const payload = (await response.json()) as { readonly data?: readonly CatalogDomain[] };
199 return payload.data ?? [];
200};
201
202export interface SurfaceCredential {
203 readonly id: string;
204 readonly type: string;
205 readonly label: string;
206 readonly generateUrl?: string;
207 readonly setup?: string;
208}
209
210/** `/api/<domain>/surface` — the per-domain document. The console reads only
211 * `surfaces[].url` out of this; the `credentials` block (which key, where to
212 * mint it, how) is fetched and thrown away. */
213export const fetchSurfaceCredentials = async (
214 domain: string,
215): Promise<readonly SurfaceCredential[]> => {
216 const response = await fetch(
217 `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(domain)}/surface`,
218 );
219 if (!response.ok) return [];
220 const payload = (await response.json()) as {
221 readonly credentials?: Readonly<
222 Record<
223 string,
224 {
225 readonly type?: string;
226 readonly label?: string;
227 readonly generateUrl?: string;
228 readonly setup?: string;
229 }
230 >
231 >;
232 };
233 return Object.entries(payload.credentials ?? {}).map(([id, value]) => ({
234 id,
235 type: value.type ?? "unknown",
236 label: value.label ?? id,
237 ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}),
238 ...(value.setup ? { setup: value.setup } : {}),
239 }));
240};
packages/onboarding-demo/src/flow.ts+111−0
@@ -0,0 +1,111 @@
1// ---------------------------------------------------------------------------
2// The flow, as one ordered list of screens.
3//
4// Each step carries the first-run reaction recorded against that exact screen,
5// so a redesign can be checked against the complaint it is meant to answer
6// rather than against a general sense that the old screen was bad.
7// ---------------------------------------------------------------------------
8
9export type StepId =
10 | "integrations-empty"
11 | "connect-dialog"
12 | "add-openapi"
13 | "detail-accounts"
14 | "add-account-credential"
15 | "add-account-place"
16 | "oauth-stuck"
17 | "integrations-populated";
18
19export interface FlowStep {
20 readonly id: StepId;
21 readonly label: string;
22 /** Where this screen lives in the real console. */
23 readonly route: string;
24 /** The component this reproduction is taken from. */
25 readonly source: string;
26 /** Verbatim first-run reactions to this screen. */
27 readonly reactions: readonly string[];
28}
29
30export const flowSteps: readonly FlowStep[] = [
31 {
32 id: "integrations-empty",
33 label: "Integrations (empty)",
34 route: "/",
35 source: "packages/react/src/pages/integrations.tsx",
36 reactions: [
37 "Adding to clis was easy. Not clear what to do next.",
38 "Next is this, not a great screen to find integrations from. No text search? I just wanted to add email",
39 ],
40 },
41 {
42 id: "connect-dialog",
43 label: "Connect dialog",
44 route: "/ (dialog)",
45 source: "packages/react/src/pages/integrations.tsx › ConnectDialog",
46 reactions: [
47 "I'd love a proper like picker grid and search",
48 "I want to add multiple to set up for experimentation. I clicked the first one I want (posthog) and it brought me here",
49 "Annoying because now I have to sit and wait for it to do whatever and then add, and I could be spending this time finding other integrations I want",
50 ],
51 },
52 {
53 id: "add-openapi",
54 label: "Add integration form",
55 route: "/integrations/add/openapi",
56 source: "packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx",
57 reactions: [
58 "This view is a bit of a mess. There's no clear hierarchy on what exactly I'm supposed to be doing, what matters and doesn't, and what button I should hit. There's the big add integration at the bottom and the weird treatment around it. Just very unclear what the step is",
59 "Hit “add” and now another loading screen, well spinning wheel button",
60 ],
61 },
62 {
63 id: "detail-accounts",
64 label: "Integration detail · Accounts",
65 route: "/integrations/posthog?tab=accounts",
66 source: "packages/react/src/pages/integration-detail.tsx",
67 reactions: [
68 "wtf is this?? I just added it why do I have to add it again??",
69 "The hierarchy of the integrations and connections is not something I should have to understand or care about as I initially setup",
70 "I increasingly hate this view. The more that I look at it, there are just so many things demanding attention and none of them are the thing I actually want to do",
71 ],
72 },
73 {
74 id: "add-account-credential",
75 label: "Add connection · credential",
76 route: "/integrations/posthog?tab=accounts&addAccount=1",
77 source: "packages/react/src/components/add-account-modal.tsx",
78 reactions: [
79 "Where the fk do I paste the key?? The Method 1 thing is locked",
80 "I'm not scared of doing anything because I already copied the API key and it's on my clipboard but I can't paste it yet. I just have to not use my computer because I don't have clipboard history management and never will",
81 "I'm even scared to send a screenshot because I'm going to lose the key. I'm just going to have to recreate it. I think I'll throw it in a note.",
82 "Okay so what type of key do I need? Obviously doing this for every integration will be annoying but I'm sure you can get some agent to go do it.",
83 ],
84 },
85 {
86 id: "add-account-place",
87 label: "Add connection · placement + owner",
88 route: "/integrations/posthog?tab=accounts&addAccount=1",
89 source: "packages/react/src/components/add-account-modal.tsx",
90 reactions: ["How many times does the word “personal” appear on this page? lol"],
91 },
92 {
93 id: "oauth-stuck",
94 label: "OAuth · stuck connecting",
95 route: "/integrations/gmail?tab=accounts&addAccount=1",
96 source: "packages/react/src/plugins/oauth-sign-in.tsx",
97 reactions: [
98 "Closed the broken auth page and now app is stuck in “connecting...”",
99 "On business Gmail, allowed me to click an “Advanced options” (“I trust the developer”) thing, but normal personal accounts, no",
100 ],
101 },
102 {
103 id: "integrations-populated",
104 label: "Integrations (populated)",
105 route: "/",
106 source: "packages/react/src/pages/integrations.tsx",
107 reactions: ["This is filling me with rage for some reason"],
108 },
109];
110
111export const stepIndex = (id: StepId): number => flowSteps.findIndex((step) => step.id === id);
packages/onboarding-demo/src/main.tsx+12−0
@@ -0,0 +1,12 @@
1import { StrictMode } from "react";
2import { createRoot } from "react-dom/client";
3import { App } from "./app";
4import "./styles.css";
5
6const root = document.getElementById("root");
7if (root)
8 createRoot(root).render(
9 <StrictMode>
10 <App />
11 </StrictMode>,
12 );
packages/onboarding-demo/src/next-flow.tsx+304−0
@@ -0,0 +1,304 @@
1// The reworked flow, wired end to end: browse → add (in place) → the added
2// integration → authenticate, plus the custom-URL door that bypasses the
3// registry entirely. Adding never blocks and never navigates; the only thing
4// that moves you off the picker is choosing to look at something.
5//
6// Every screen is a hash route, so any of them can be linked to directly:
7// #reworked the picker
8// #reworked/installed what you've added
9// #reworked/custom the custom-URL dialog
10// #reworked/<domain> one integration
11// #reworked/<domain>/auth …with its authenticate dialog open
12
13import { useCallback, useEffect, useState } from "react";
14import { Button } from "@executor-js/react/components/button";
15import { cn } from "@executor-js/react/lib/utils";
16import { Shell } from "./shell";
17import { BrowsePage } from "./screens/next/browse";
18import {
19 InstalledList,
20 NextIntegrationDetail,
21 type DemoAccount,
22} from "./screens/next/integration-detail";
23import { AuthenticateDialog } from "./screens/next/authenticate";
24import { AddCustomDialog } from "./screens/next/custom";
25import { fromCatalogItem, type AddedIntegration, type CatalogItem } from "./catalog";
26import type { DemoIntegration } from "./fixtures";
27
28interface Added {
29 readonly item: AddedIntegration;
30 readonly accounts: readonly DemoAccount[];
31}
32
33// ---------------------------------------------------------------------------
34// Routing
35// ---------------------------------------------------------------------------
36
37export type NextRoute =
38 | { readonly kind: "browse" }
39 | { readonly kind: "installed" }
40 | { readonly kind: "custom" }
41 | { readonly kind: "detail"; readonly domain: string }
42 | { readonly kind: "auth"; readonly domain: string };
43
44const parseRoute = (hash: string): NextRoute => {
45 // `#reworked/...` is canonical; `#next/...` is kept working because it was
46 // the first spelling and may already be pasted somewhere.
47 const path = hash.replace(/^#/, "").replace(/^(reworked|next)\/?/, "");
48 const segments = path.split("/").filter((segment) => segment.length > 0);
49 if (segments.length === 0) return { kind: "browse" };
50 const [first, second] = segments;
51 if (first === "installed") return { kind: "installed" };
52 if (first === "custom") return { kind: "custom" };
53 if (first && second === "auth") return { kind: "auth", domain: first };
54 if (first) return { kind: "detail", domain: first };
55 return { kind: "browse" };
56};
57
58const routeToHash = (route: NextRoute): string => {
59 if (route.kind === "browse") return "#reworked";
60 if (route.kind === "installed") return "#reworked/installed";
61 if (route.kind === "custom") return "#reworked/custom";
62 if (route.kind === "auth") return `#reworked/${route.domain}/auth`;
63 return `#reworked/${route.domain}`;
64};
65
66// ---------------------------------------------------------------------------
67
68const toolsFor = (accounts: readonly DemoAccount[]): number =>
69 accounts.some((account) => account.status === "connected") ? 24 : 0;
70
71/** What the console sidebar lists: the same added integrations, in the shape
72 * the shell already renders. */
73const asSidebarIntegration = (added: Added): DemoIntegration => ({
74 slug: added.item.domain,
75 name: added.item.name,
76 kind: "openapi",
77 icon: added.item.icon,
78 toolCount: toolsFor(added.accounts),
79});
80
81export function NextFlow() {
82 const [added, setAdded] = useState<readonly Added[]>([]);
83 const [route, setRoute] = useState<NextRoute>(() => parseRoute(globalThis.location?.hash ?? ""));
84
85 useEffect(() => {
86 const onHashChange = () => setRoute(parseRoute(globalThis.location?.hash ?? ""));
87 globalThis.addEventListener("hashchange", onHashChange);
88 return () => globalThis.removeEventListener("hashchange", onHashChange);
89 }, []);
90
91 const go = useCallback((next: NextRoute) => {
92 setRoute(next);
93 if (globalThis.location) globalThis.location.hash = routeToHash(next);
94 }, []);
95
96 const addIntegration = useCallback((item: AddedIntegration) => {
97 setAdded((previous) =>
98 previous.some((entry) => entry.item.domain === item.domain)
99 ? previous
100 : [
101 ...previous,
102 // An add always leaves exactly one account behind, already named
103 // and in the one state that matters.
104 { item, accounts: [{ label: "default", status: "needs-auth" as const }] },
105 ],
106 );
107 }, []);
108
109 const addFromCatalog = useCallback(
110 (item: CatalogItem) => addIntegration(fromCatalogItem(item)),
111 [addIntegration],
112 );
113
114 const markConnected = useCallback((domain: string, accountLabel: string, identity: string) => {
115 setAdded((previous) =>
116 previous.map((entry) =>
117 entry.item.domain === domain
118 ? {
119 ...entry,
120 accounts: entry.accounts.map((account) =>
121 account.label === accountLabel
122 ? { ...account, status: "connected" as const, identity }
123 : account,
124 ),
125 }
126 : entry,
127 ),
128 );
129 }, []);
130
131 const activeDomain = route.kind === "detail" || route.kind === "auth" ? route.domain : null;
132 const open = added.find((entry) => entry.item.domain === activeDomain) ?? null;
133 const pendingCount = added.filter((entry) =>
134 entry.accounts.some((account) => account.status === "needs-auth"),
135 ).length;
136
137 // A deep link can name a domain that has not been added in this session.
138 // Say so rather than rendering an empty detail screen.
139 const missing = activeDomain !== null && open === null;
140
141 const showingInstalled = route.kind === "installed";
142 const showingBrowse = route.kind === "browse" || route.kind === "custom";
143
144 return (
145 <Shell integrations={added.map(asSidebarIntegration)}>
146 <div className="min-h-0 flex-1 overflow-y-auto">
147 <div className="mx-auto max-w-4xl px-8 py-10">
148 {open && !missing ? (
149 <NextIntegrationDetail
150 item={open.item}
151 accounts={open.accounts}
152 toolCount={toolsFor(open.accounts)}
153 onBack={() => go({ kind: "installed" })}
154 onAuthenticate={() => go({ kind: "auth", domain: open.item.domain })}
155 onAddAccount={() =>
156 setAdded((previous) =>
157 previous.map((entry) =>
158 entry.item.domain === open.item.domain
159 ? {
160 ...entry,
161 accounts: [
162 ...entry.accounts,
163 {
164 label: `account ${entry.accounts.length + 1}`,
165 status: "needs-auth" as const,
166 },
167 ],
168 }
169 : entry,
170 ),
171 )
172 }
173 onRemove={() => {
174 setAdded((previous) =>
175 previous.filter((entry) => entry.item.domain !== open.item.domain),
176 );
177 go({ kind: "installed" });
178 }}
179 />
180 ) : missing ? (
181 <div className="rounded-lg border border-dashed border-border py-16 text-center">
182 <p className="text-sm font-medium text-foreground">
183 {activeDomain} isn&apos;t added in this session
184 </p>
185 <p className="mt-1 text-sm text-muted-foreground">
186 This prototype keeps nothing between reloads.
187 </p>
188 <Button
189 type="button"
190 size="sm"
191 className="mt-4"
192 onClick={() => go({ kind: "browse" })}
193 >
194 Browse integrations
195 </Button>
196 </div>
197 ) : (
198 <>
199 <div className="mb-6 flex items-center justify-between gap-4">
200 <div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5">
201 {/* oxlint-disable-next-line react/forbid-elements */}
202 <button
203 type="button"
204 onClick={() => go({ kind: "browse" })}
205 className={cn(
206 "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
207 showingBrowse
208 ? "bg-background text-foreground shadow-xs"
209 : "text-muted-foreground hover:text-foreground",
210 )}
211 >
212 Browse
213 </button>
214 {/* oxlint-disable-next-line react/forbid-elements */}
215 <button
216 type="button"
217 onClick={() => go({ kind: "installed" })}
218 className={cn(
219 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
220 showingInstalled
221 ? "bg-background text-foreground shadow-xs"
222 : "text-muted-foreground hover:text-foreground",
223 )}
224 >
225 Installed
226 {added.length > 0 ? (
227 <span className="font-mono text-xs text-muted-foreground">
228 {added.length}
229 </span>
230 ) : null}
231 </button>
232 </div>
233 {pendingCount > 0 ? (
234 <span className="shrink-0 text-xs font-medium text-amber-500">
235 {pendingCount} need{pendingCount === 1 ? "s" : ""} auth
236 </span>
237 ) : null}
238 </div>
239
240 {showingInstalled ? (
241 <InstalledList
242 items={added}
243 onOpen={(domain) => go({ kind: "detail", domain })}
244 onBrowse={() => go({ kind: "browse" })}
245 />
246 ) : (
247 <BrowsePage
248 addedDomains={added.map((entry) => entry.item.domain)}
249 onAdd={addFromCatalog}
250 onOpen={(item) => {
251 addFromCatalog(item);
252 go({ kind: "detail", domain: item.domain });
253 }}
254 onAddCustom={() => go({ kind: "custom" })}
255 />
256 )}
257 </>
258 )}
259 </div>
260 </div>
261
262 {route.kind === "custom" ? (
263 <AddCustomDialog
264 open
265 onOpenChange={(next) => {
266 if (!next) go({ kind: "browse" });
267 }}
268 onAdd={(integration) => {
269 addIntegration(integration);
270 go({ kind: "detail", domain: integration.domain });
271 }}
272 />
273 ) : null}
274
275 {route.kind === "auth" && open ? (
276 <AuthenticateDialog
277 // Keyed per target so the dialog owns its state and a close throws it
278 // away, rather than carrying a half-finished attempt to the next one.
279 key={open.item.domain}
280 open
281 onOpenChange={(next) => {
282 if (!next) go({ kind: "detail", domain: open.item.domain });
283 }}
284 integrationName={open.item.name}
285 domain={open.item.domain}
286 accountLabel={
287 open.accounts.find((account) => account.status === "needs-auth")?.label ?? "default"
288 }
289 onAuthenticated={() => {
290 const account =
291 open.accounts.find((candidate) => candidate.status === "needs-auth")?.label ??
292 "default";
293 markConnected(
294 open.item.domain,
295 account,
296 `rhys@${open.item.domain.split(".")[0] ?? open.item.domain}`,
297 );
298 go({ kind: "detail", domain: open.item.domain });
299 }}
300 />
301 ) : null}
302 </Shell>
303 );
304}
packages/onboarding-demo/src/screens/add-account-modal.tsx+318−0
@@ -0,0 +1,318 @@
1// Reproduction of packages/react/src/components/add-account-modal.tsx.
2//
3// The credential path is a 2-step wizard behind a horizontally scrolling strip
4// of auth-method tabs. Step 1 is the key plus an optional read-only probe;
5// step 2 is the display name and the "Connection saved to" owner picker. The
6// OAuth path is not a wizard and shows an app picker instead.
7//
8// The word "Personal" is reproduced at every site it occurs on the real screen:
9// the derived connection name, the callable-name hint, the owner picker's label
10// and its selected option.
11
12import { useState } from "react";
13import { EyeIcon, EyeOffIcon, PlusIcon } from "lucide-react";
14import { Button } from "@executor-js/react/components/button";
15import { Input } from "@executor-js/react/components/input";
16import { Label } from "@executor-js/react/components/label";
17import {
18 Dialog,
19 DialogContent,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogTitle,
24} from "@executor-js/react/components/dialog";
25import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
26import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
27import { cn } from "@executor-js/react/lib/utils";
28import type { DemoAuthMethod } from "../fixtures";
29
30function StepHeader(props: { readonly label: string; readonly hint?: string }) {
31 return (
32 <div className="flex flex-col gap-1">
33 <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
34 {props.label}
35 </Label>
36 {props.hint ? <span className="text-xs text-muted-foreground/70">{props.hint}</span> : null}
37 </div>
38 );
39}
40
41function PlacementLine(props: { readonly placement: DemoAuthMethod["placements"][number] }) {
42 const { placement } = props;
43 const lead =
44 placement.carrier === "header"
45 ? `${placement.name || "Authorization"}: `
46 : placement.carrier === "env"
47 ? `${placement.name || "TOKEN"}=`
48 : `?${placement.name || "api_key"}=`;
49 return (
50 <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground">
51 {lead}
52 {placement.prefix ? (
53 <span className="text-muted-foreground/60">{placement.prefix}</span>
54 ) : null}
55 <span className="tracking-widest text-primary">••••••</span>
56 </span>
57 );
58}
59
60export function AddAccountModal(props: {
61 readonly open: boolean;
62 readonly onOpenChange: (open: boolean) => void;
63 readonly integrationName: string;
64 readonly methods: readonly DemoAuthMethod[];
65 /** Start on the wizard's second step, which is the screen the owner picker
66 * and its repeated "Personal" live on. */
67 readonly initialStep?: "validate" | "place";
68 /** Reproduce the post-OAuth stuck state: the popup was closed, and the
69 * button never leaves "Connecting…". */
70 readonly stuckConnecting?: boolean;
71 readonly onAdded: () => void;
72}) {
73 const [methodId, setMethodId] = useState(props.methods[0]?.id ?? "");
74 const [wizardStep, setWizardStep] = useState<"validate" | "place">(
75 props.initialStep ?? "validate",
76 );
77 const [secret, setSecret] = useState("");
78 const [revealed, setRevealed] = useState(false);
79 const [label, setLabel] = useState("");
80 const [owner, setOwner] = useState<"user" | "org">("user");
81
82 const method = props.methods.find((candidate) => candidate.id === methodId) ?? props.methods[0];
83 const isOAuth = method?.kind === "oauth";
84 const wizardActive = !isOAuth;
85 const showValidateStep = !wizardActive || wizardStep === "validate";
86 const showPlaceStep = !wizardActive || wizardStep === "place";
87
88 const ownerWord = owner === "user" ? "Personal" : "Workspace";
89 const derivedName = `${ownerWord} ${props.integrationName}`;
90 const callableName = (label.trim() || derivedName).toLowerCase().replace(/[^a-z0-9]+/g, "_");
91
92 const singlePlacement = method?.placements[0];
93 const affix =
94 singlePlacement && singlePlacement.carrier === "header"
95 ? `${singlePlacement.name}: ${singlePlacement.prefix}`
96 : null;
97
98 return (
99 <Dialog open={props.open} onOpenChange={props.onOpenChange} modal={false}>
100 <DialogContent forceOverlay className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
101 <DialogHeader>
102 <DialogTitle>
103 Add connection · {props.integrationName}
104 {wizardActive ? (
105 <span className="ml-2 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
106 Step {wizardStep === "validate" ? 1 : 2} of 2
107 </span>
108 ) : null}
109 </DialogTitle>
110 <DialogDescription>
111 A connection is a saved way to use this integration, owned by you or the workspace.
112 </DialogDescription>
113 </DialogHeader>
114
115 <div className="flex w-full min-w-0 flex-col gap-5">
116 {showValidateStep && (
117 <Tabs
118 value={methodId}
119 onValueChange={(next: string) => {
120 setMethodId(next);
121 setWizardStep("validate");
122 }}
123 className="w-full min-w-0 max-w-full gap-0"
124 >
125 <TabsList className="flex h-10 w-full min-w-0 max-w-full justify-start overflow-x-auto overflow-y-hidden rounded-b-none rounded-t-md border border-b-0 border-border/60 bg-muted/30 p-1 [scrollbar-width:thin]">
126 <div className="flex w-max shrink-0 items-stretch gap-1">
127 {props.methods.map((candidate) => (
128 <div
129 key={candidate.id}
130 className="group/method-tab relative flex h-8 shrink-0 items-stretch"
131 >
132 <TabsTrigger
133 value={candidate.id}
134 className="h-full max-w-64 shrink-0 justify-start px-3 text-sm font-medium"
135 >
136 <span className="truncate">{candidate.label}</span>
137 </TabsTrigger>
138 </div>
139 ))}
140 <Button
141 type="button"
142 variant="ghost"
143 size="icon-sm"
144 aria-label="Add authentication method"
145 className="h-8 shrink-0 rounded-md border border-transparent bg-transparent px-3 text-foreground/60 hover:bg-background/60"
146 >
147 <PlusIcon className="size-4" />
148 </Button>
149 </div>
150 </TabsList>
151
152 <TabsContent
153 value={methodId}
154 className={cn(
155 "mt-0 min-w-0 space-y-5",
156 "rounded-b-md rounded-t-none border border-border/60 bg-muted/15 p-4",
157 )}
158 >
159 {method && !isOAuth && method.placements.length > 0 && !affix ? (
160 <div className="flex flex-wrap gap-x-3.5 gap-y-1">
161 {method.placements.map((placement, index) => (
162 <PlacementLine key={index} placement={placement} />
163 ))}
164 </div>
165 ) : null}
166
167 <div className="space-y-2">
168 <StepHeader label={isOAuth ? "OAuth app" : "Credential"} />
169
170 {isOAuth ? (
171 <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
172 <p className="text-sm font-medium">No app for {props.integrationName} yet</p>
173 <p className="text-xs text-muted-foreground">
174 None of your registered apps target this integration&apos;s OAuth endpoint.
175 Register one to connect.
176 </p>
177 <div className="flex flex-wrap items-center gap-2 pt-1">
178 <Button type="button" size="sm">
179 Register app
180 </Button>
181 </div>
182 </div>
183 ) : affix ? (
184 <div className="flex h-9 w-full min-w-0 items-stretch overflow-hidden rounded-md border border-input bg-transparent font-mono text-sm shadow-xs transition-colors focus-within:border-ring dark:bg-input/30">
185 <span className="flex min-w-0 max-w-[60%] select-none items-center border-r border-input bg-muted/40 px-3 text-muted-foreground/70">
186 <span className="overflow-hidden text-ellipsis whitespace-pre">
187 {affix}
188 </span>
189 </span>
190 {/* oxlint-disable-next-line react/forbid-elements */}
191 <input
192 type={revealed ? "text" : "password"}
193 autoComplete="off"
194 aria-label="API key"
195 value={secret}
196 onChange={(event) => setSecret(event.target.value)}
197 className="min-w-0 flex-1 bg-transparent px-3 outline-none"
198 />
199 <Button
200 type="button"
201 variant="ghost"
202 size="icon-sm"
203 aria-label="Show key"
204 aria-pressed={revealed}
205 className="shrink-0 text-muted-foreground"
206 onClick={() => setRevealed((value) => !value)}
207 >
208 {revealed ? <EyeOffIcon /> : <EyeIcon />}
209 </Button>
210 </div>
211 ) : (
212 <div className="flex items-center gap-1">
213 <Input
214 type={revealed ? "text" : "password"}
215 aria-label="API key"
216 value={secret}
217 onChange={(event) => setSecret((event.target as HTMLInputElement).value)}
218 className="font-mono"
219 />
220 <Button
221 type="button"
222 variant="ghost"
223 size="icon-sm"
224 aria-label="Show key"
225 aria-pressed={revealed}
226 className="shrink-0 text-muted-foreground"
227 onClick={() => setRevealed((value) => !value)}
228 >
229 {revealed ? <EyeOffIcon /> : <EyeIcon />}
230 </Button>
231 </div>
232 )}
233 </div>
234
235 {!isOAuth && (
236 <div className="space-y-1.5">
237 <StepHeader
238 label="Check the key works"
239 hint="runs one read-only call with your key, saved as this integration's health check"
240 />
241 <div className="space-y-2 rounded-md border border-border/60 bg-background px-3 py-3">
242 <Label className="text-xs text-muted-foreground">Operation</Label>
243 <NativeSelect size="sm" defaultValue="" aria-label="Health check operation">
244 <NativeSelectOption value="">Pick an operation…</NativeSelectOption>
245 <NativeSelectOption value="users_me">users_me</NativeSelectOption>
246 <NativeSelectOption value="organizations_list">
247 organizations_list
248 </NativeSelectOption>
249 <NativeSelectOption value="projects_list">projects_list</NativeSelectOption>
250 </NativeSelect>
251 <Button type="button" variant="outline" size="sm" disabled={!secret}>
252 Check
253 </Button>
254 </div>
255 </div>
256 )}
257 </TabsContent>
258 </Tabs>
259 )}
260
261 {showPlaceStep && (
262 <>
263 <div className="space-y-2">
264 <StepHeader label="Display name" hint="how you'll tell accounts apart" />
265 <Input
266 id="connection-name"
267 placeholder={derivedName}
268 value={label}
269 onChange={(event) => setLabel((event.target as HTMLInputElement).value)}
270 />
271 <p className="text-xs text-muted-foreground">
272 This connection will be callable as{" "}
273 <span className="font-mono text-foreground">{callableName}</span>.
274 </p>
275 </div>
276
277 <div className="space-y-2">
278 <StepHeader label="Connection saved to" />
279 <NativeSelect
280 value={owner}
281 aria-label="Saved to"
282 onChange={(event) => setOwner(event.target.value === "org" ? "org" : "user")}
283 >
284 <NativeSelectOption value="user">Personal</NativeSelectOption>
285 <NativeSelectOption value="org">Workspace</NativeSelectOption>
286 </NativeSelect>
287 </div>
288 </>
289 )}
290 </div>
291
292 <DialogFooter>
293 <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}>
294 {isOAuth ? "Close" : "Cancel"}
295 </Button>
296 {isOAuth ? (
297 <Button type="button" disabled>
298 {props.stuckConnecting ? "Connecting…" : "Connect with OAuth"}
299 </Button>
300 ) : wizardStep === "validate" ? (
301 <Button type="button" onClick={() => setWizardStep("place")}>
302 Continue
303 </Button>
304 ) : (
305 <>
306 <Button type="button" variant="ghost" onClick={() => setWizardStep("validate")}>
307 Back
308 </Button>
309 <Button type="button" onClick={props.onAdded}>
310 Add connection
311 </Button>
312 </>
313 )}
314 </DialogFooter>
315 </DialogContent>
316 </Dialog>
317 );
318}
packages/onboarding-demo/src/screens/add-openapi-page.tsx+261−0
@@ -0,0 +1,261 @@
1// Reproduction of packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx
2// in its post-analyze state, plus the AuthMethodListEditor block from
3// packages/react/src/components/auth-method-list-editor.tsx.
4//
5// The locked "Method 1" panel is reproduced exactly: LockIcon, muted fill,
6// `cursor-not-allowed select-none`, and a masked `Authorization: Bearer ••••••`
7// placement line. It is a read-only echo of the spec's declared auth scheme —
8// it takes no input — but it is the only credential-shaped thing on the page,
9// and it appears at the moment the user has just copied an API key.
10
11import { useState } from "react";
12import { LockIcon, XIcon, ChevronDownIcon } from "lucide-react";
13import { Button } from "@executor-js/react/components/button";
14import { Input } from "@executor-js/react/components/input";
15import { Label } from "@executor-js/react/components/label";
16import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
17import type { DemoPreset } from "../fixtures";
18
19function PlacementLine(props: {
20 readonly carrier: "header" | "query";
21 readonly name: string;
22 readonly prefix: string;
23}) {
24 const lead = props.carrier === "header" ? `${props.name}: ` : `?${props.name}=`;
25 return (
26 <span className="wrap-anywhere whitespace-pre-wrap font-mono text-xs text-muted-foreground">
27 {lead}
28 {props.prefix ? <span className="text-muted-foreground/60">{props.prefix}</span> : null}
29 <span className="tracking-widest text-primary">••••••</span>
30 </span>
31 );
32}
33
34function FieldLabel(props: { readonly children: React.ReactNode }) {
35 return (
36 <Label className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
37 {props.children}
38 </Label>
39 );
40}
41
42export function AddOpenApiPage(props: {
43 readonly preset: DemoPreset | null;
44 readonly onCancel: () => void;
45 readonly onComplete: () => void;
46}) {
47 const [adding, setAdding] = useState(false);
48 const [overridesOpen, setOverridesOpen] = useState(false);
49 const [healthCheckOpen, setHealthCheckOpen] = useState(false);
50 const name = props.preset?.name ?? "API";
51 const slug = props.preset?.id ?? "api";
52
53 return (
54 <div className="relative min-h-0 flex-1 overflow-y-auto">
55 <div className="mx-auto flex min-h-full max-w-4xl flex-col gap-6 px-6 py-10 lg:px-10 lg:py-14">
56 <div>
57 <h1 className="text-xl font-semibold text-foreground">Add OpenAPI integration</h1>
58 </div>
59
60 {/* Details — OpenApiIntegrationDetailsFields */}
61 <CardStack>
62 <CardStackContent className="border-t-0">
63 <div className="flex items-start gap-3 px-4 py-4">
64 {props.preset?.icon ? (
65 <img src={props.preset.icon} alt="" className="size-8 shrink-0 object-contain" />
66 ) : (
67 <span className="size-8 shrink-0 rounded-md bg-muted" />
68 )}
69 <div className="min-w-0">
70 <p className="text-sm font-medium text-foreground">{name}</p>
71 <p className="text-xs text-muted-foreground">1.0.0 · 214 operations · 31 tags</p>
72 </div>
73 </div>
74 <div className="space-y-4 px-4 pb-4">
75 <div className="space-y-2">
76 <FieldLabel>Integration ID</FieldLabel>
77 <Input defaultValue={slug} className="font-mono text-sm" />
78 </div>
79 <div className="space-y-2">
80 <FieldLabel>Display name</FieldLabel>
81 <Input defaultValue={name} />
82 </div>
83 <div className="space-y-2">
84 <FieldLabel>Description</FieldLabel>
85 <Input defaultValue={props.preset?.summary ?? ""} />
86 </div>
87 <div className="space-y-2">
88 <FieldLabel>Base URL override (optional)</FieldLabel>
89 <Input placeholder="https://api.example.com" className="font-mono text-sm" />
90 <p className="text-[11px] text-muted-foreground">
91 Overrides the spec&apos;s servers; leave empty to choose the server (and
92 variables) per tool call.
93 </p>
94 </div>
95 <div className="space-y-2">
96 <FieldLabel>OpenAPI spec</FieldLabel>
97 <Input defaultValue={props.preset?.url ?? ""} className="font-mono text-sm" />
98 </div>
99 </div>
100 </CardStackContent>
101 </CardStack>
102
103 {/* SpecOverridesEditor — collapsed */}
104 <section className="space-y-2">
105 {/* oxlint-disable-next-line react/forbid-elements */}
106 <button
107 type="button"
108 onClick={() => setOverridesOpen((open) => !open)}
109 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
110 >
111 Spec overrides (optional)
112 <ChevronDownIcon className={overridesOpen ? "size-3.5 rotate-180" : "size-3.5"} />
113 </button>
114 {overridesOpen && (
115 <div className="rounded-lg border border-border/60 bg-muted/20 p-3 font-mono text-xs text-muted-foreground">
116 []
117 </div>
118 )}
119 </section>
120
121 {/* AuthMethodListEditor */}
122 <section className="space-y-3">
123 <div className="flex items-center justify-between">
124 <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
125 Authentication
126 </h3>
127 <Button type="button" variant="outline" size="sm">
128 Add method
129 </Button>
130 </div>
131
132 <div className="flex flex-col gap-3">
133 <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3">
134 <div className="flex items-center justify-between">
135 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
136 <LockIcon className="size-3 shrink-0" aria-hidden />
137 <span>Method 1</span>
138 </span>
139 <Button
140 type="button"
141 variant="ghost"
142 size="icon-sm"
143 aria-label="Remove method"
144 className="text-muted-foreground hover:text-foreground"
145 >
146 <XIcon />
147 </Button>
148 </div>
149 <div className="space-y-2">
150 <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
151 API key
152 </p>
153 <div
154 aria-disabled
155 className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"
156 >
157 <div className="flex flex-wrap gap-x-4 gap-y-1">
158 <PlacementLine carrier="header" name="Authorization" prefix="Bearer " />
159 </div>
160 </div>
161 <p className="text-[11px] text-muted-foreground">
162 Pulled from spec. Remove to override.
163 </p>
164 </div>
165 </div>
166
167 <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3">
168 <div className="flex items-center justify-between">
169 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
170 <LockIcon className="size-3 shrink-0" aria-hidden />
171 <span>Method 2</span>
172 </span>
173 <Button
174 type="button"
175 variant="ghost"
176 size="icon-sm"
177 aria-label="Remove method"
178 className="text-muted-foreground hover:text-foreground"
179 >
180 <XIcon />
181 </Button>
182 </div>
183 <div className="space-y-2">
184 <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
185 OAuth
186 </p>
187 <div
188 aria-disabled
189 className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"
190 >
191 <div className="flex gap-2 text-xs">
192 <span className="w-20 shrink-0 text-muted-foreground">Authorize</span>
193 <span className="break-all font-mono text-foreground/80">
194 https://us.posthog.com/oauth/authorize
195 </span>
196 </div>
197 <div className="flex gap-2 text-xs">
198 <span className="w-20 shrink-0 text-muted-foreground">Token</span>
199 <span className="break-all font-mono text-foreground/80">
200 https://us.posthog.com/oauth/token
201 </span>
202 </div>
203 </div>
204 <p className="text-[11px] text-muted-foreground">
205 Pulled from spec. Remove to override.
206 </p>
207 </div>
208 </div>
209 </div>
210
211 <p className="text-[11px] text-muted-foreground">
212 Every method here is registered with the integration. Connect an account from the
213 integration page after adding.
214 </p>
215 </section>
216
217 {/* AddOpenApiHealthCheckSection */}
218 <section className="space-y-2">
219 {/* oxlint-disable-next-line react/forbid-elements */}
220 <button
221 type="button"
222 onClick={() => setHealthCheckOpen((open) => !open)}
223 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
224 >
225 Health check (optional)
226 <ChevronDownIcon className={healthCheckOpen ? "size-3.5 rotate-180" : "size-3.5"} />
227 </button>
228 {healthCheckOpen && (
229 <div className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3">
230 <FieldLabel>Operation</FieldLabel>
231 <Input placeholder="Pick an operation to probe…" />
232 </div>
233 )}
234 </section>
235
236 {/* FloatActions */}
237 <CardStack className="sticky bottom-4 left-0 right-0 mt-auto w-full transform-gpu shadow-lg">
238 <div className="flex items-center justify-end gap-3 px-4 py-3">
239 <Button variant="ghost" onClick={props.onCancel} disabled={adding}>
240 Cancel
241 </Button>
242 <Button
243 loading={adding}
244 onClick={() => {
245 setAdding(true);
246 // The real add is a spec parse + persist round trip; the button
247 // spins for as long as it takes, with nothing else to do.
248 setTimeout(() => {
249 setAdding(false);
250 props.onComplete();
251 }, 1600);
252 }}
253 >
254 Add integration
255 </Button>
256 </div>
257 </CardStack>
258 </div>
259 </div>
260 );
261}
packages/onboarding-demo/src/screens/connect-dialog.tsx+279−0
@@ -0,0 +1,279 @@
1// Reproduction of the ConnectDialog in packages/react/src/pages/integrations.tsx.
2//
3// Faithful to the parts that shape the experience: one input that silently
4// switches between URL-detect and preset-filter, plugin chips above the list,
5// a fixed 16rem scroll window over ~30 curated presets in plugin order, and
6// the integrations.sh long tail that only appears once you have typed two
7// characters. Picking anything leaves the dialog for a full-page form.
8
9import { useEffect, useMemo, useRef, useState } from "react";
10import { Button } from "@executor-js/react/components/button";
11import { Input } from "@executor-js/react/components/input";
12import { Badge } from "@executor-js/react/components/badge";
13import { Skeleton } from "@executor-js/react/components/skeleton";
14import {
15 Dialog,
16 DialogContent,
17 DialogDescription,
18 DialogHeader,
19 DialogTitle,
20} from "@executor-js/react/components/dialog";
21import {
22 CardStack,
23 CardStackContent,
24 CardStackEntry,
25 CardStackEntryActions,
26 CardStackEntryContent,
27 CardStackEntryDescription,
28 CardStackEntryTitle,
29} from "@executor-js/react/components/card-stack";
30import {
31 catalogLogoUrl,
32 curatedPresets,
33 integrationPlugins,
34 searchCatalog,
35 type CatalogEntry,
36 type DemoPreset,
37 type PluginKey,
38} from "../fixtures";
39
40const CATALOG_KIND_LABEL: Record<string, string> = {
41 mcp: "MCP",
42 openapi: "OpenAPI",
43 graphql: "GraphQL",
44};
45
46const looksLikeUrl = (raw: string): boolean => {
47 const v = raw.trim();
48 if (v.length === 0) return false;
49 if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true;
50 if (v.includes("/")) return true;
51 if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true;
52 return false;
53};
54
55const SEARCH_DEBOUNCE_MS = 250;
56const MIN_QUERY_LENGTH = 2;
57
58function useCatalogSearch(rawQuery: string) {
59 const query = rawQuery.trim().toLowerCase();
60 const [state, setState] = useState<{
61 readonly entries: readonly CatalogEntry[];
62 readonly loading: boolean;
63 }>({ entries: [], loading: false });
64 const generation = useRef(0);
65
66 useEffect(() => {
67 const requestId = ++generation.current;
68 if (query.length < MIN_QUERY_LENGTH) {
69 setState({ entries: [], loading: false });
70 return;
71 }
72 setState((previous) => ({ ...previous, loading: true }));
73 const timer = setTimeout(() => {
74 void searchCatalog(query).then((entries) => {
75 if (generation.current !== requestId) return;
76 setState({ entries, loading: false });
77 });
78 }, SEARCH_DEBOUNCE_MS);
79 return () => clearTimeout(timer);
80 }, [query]);
81
82 return state;
83}
84
85export function ConnectDialog(props: {
86 readonly open: boolean;
87 readonly onOpenChange: (open: boolean) => void;
88 readonly onPickPreset: (preset: DemoPreset) => void;
89 readonly onPickPlugin: (pluginKey: PluginKey) => void;
90 readonly onPickCatalogEntry: (entry: CatalogEntry) => void;
91}) {
92 const [query, setQuery] = useState("");
93 const isUrl = looksLikeUrl(query);
94 const presetSearch = isUrl ? "" : query;
95
96 const filtered = useMemo(() => {
97 const q = presetSearch.trim().toLowerCase();
98 if (q.length === 0) return curatedPresets;
99 return curatedPresets.filter(({ name, summary, pluginLabel }) =>
100 `${name} ${summary} ${pluginLabel}`.toLowerCase().includes(q),
101 );
102 }, [presetSearch]);
103
104 const catalog = useCatalogSearch(presetSearch);
105 const showCatalogSection =
106 presetSearch.trim().length > 0 && (catalog.entries.length > 0 || catalog.loading);
107
108 const close = () => {
109 setQuery("");
110 props.onOpenChange(false);
111 };
112
113 return (
114 <Dialog
115 open={props.open}
116 onOpenChange={(open: boolean) => {
117 if (!open) close();
118 else props.onOpenChange(open);
119 }}
120 >
121 <DialogContent className="sm:max-w-[560px]">
122 <DialogHeader>
123 <DialogTitle>Connect an integration</DialogTitle>
124 <DialogDescription>
125 Search the preset library, or paste a URL to auto-detect.
126 </DialogDescription>
127 </DialogHeader>
128
129 <div className="flex min-w-0 flex-col gap-5">
130 <div className="flex flex-col gap-2">
131 <div className="flex gap-2">
132 <Input
133 type="text"
134 value={query}
135 onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
136 placeholder="Search or paste a URL…"
137 className="flex-1"
138 />
139 {isUrl && <Button disabled={query.trim().length === 0}>Detect</Button>}
140 </div>
141 </div>
142
143 <div className="flex flex-col gap-2">
144 <p className="text-xs font-medium text-foreground/80">Or add manually</p>
145 <div className="flex flex-wrap gap-2">
146 {integrationPlugins.map((plugin) => (
147 // oxlint-disable-next-line react/forbid-elements
148 <button
149 key={plugin.key}
150 type="button"
151 onClick={() => {
152 close();
153 props.onPickPlugin(plugin.key);
154 }}
155 className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
156 >
157 {plugin.label}
158 </button>
159 ))}
160 </div>
161 </div>
162
163 <div className="flex min-w-0 flex-col gap-2">
164 <p className="text-xs font-medium text-foreground/80">Popular integrations</p>
165 <CardStack className="min-w-0">
166 <CardStackContent className="h-64 overflow-y-auto">
167 {filtered.length === 0 && !showCatalogSection ? (
168 <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center">
169 <p className="text-sm text-muted-foreground">No matching integrations</p>
170 <p className="text-xs text-muted-foreground/70">
171 Paste a URL above to auto-detect, or pick an integration type manually.
172 </p>
173 </div>
174 ) : (
175 filtered.map((preset) => (
176 <CardStackEntry key={`${preset.pluginKey}-${preset.id}`} asChild>
177 {/* oxlint-disable-next-line react/forbid-elements */}
178 <button
179 type="button"
180 className="w-full text-left"
181 onClick={() => {
182 close();
183 props.onPickPreset(preset);
184 }}
185 >
186 <span className="flex size-8 shrink-0 items-center justify-center">
187 {preset.icon ? (
188 <img
189 src={preset.icon}
190 alt=""
191 className="size-5 object-contain"
192 loading="lazy"
193 />
194 ) : (
195 <svg viewBox="0 0 16 16" className="size-3.5" fill="none">
196 <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
197 </svg>
198 )}
199 </span>
200 <CardStackEntryContent>
201 <CardStackEntryTitle>{preset.name}</CardStackEntryTitle>
202 <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription>
203 </CardStackEntryContent>
204 <CardStackEntryActions>
205 <Badge variant="secondary">{preset.pluginLabel}</Badge>
206 </CardStackEntryActions>
207 </button>
208 </CardStackEntry>
209 ))
210 )}
211
212 {showCatalogSection && (
213 <>
214 {catalog.entries.map((entry) => (
215 <CardStackEntry key={`catalog-${entry.domain}`} asChild>
216 {/* oxlint-disable-next-line react/forbid-elements */}
217 <button
218 type="button"
219 className="w-full text-left"
220 onClick={() => {
221 close();
222 props.onPickCatalogEntry(entry);
223 }}
224 >
225 <span className="flex size-8 shrink-0 items-center justify-center">
226 <img
227 src={catalogLogoUrl(entry.domain, 10)}
228 alt=""
229 className="size-5 object-contain"
230 loading="lazy"
231 />
232 </span>
233 <CardStackEntryContent>
234 <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle>
235 <CardStackEntryDescription>
236 {entry.description}
237 </CardStackEntryDescription>
238 </CardStackEntryContent>
239 <CardStackEntryActions>
240 {entry.kinds.map((kind) => (
241 <Badge key={kind} variant="secondary">
242 {CATALOG_KIND_LABEL[kind]}
243 </Badge>
244 ))}
245 </CardStackEntryActions>
246 </button>
247 </CardStackEntry>
248 ))}
249 {catalog.loading &&
250 catalog.entries.length === 0 &&
251 Array.from({ length: 3 }).map((_, i) => (
252 <div
253 key={`catalog-skeleton-${i}`}
254 className="flex items-center gap-3 px-4 py-3"
255 >
256 <Skeleton className="size-5 shrink-0 rounded-md" />
257 <div className="flex min-w-0 flex-1 flex-col gap-1.5">
258 <Skeleton
259 className="h-3.5"
260 style={{ width: `${35 + ((i * 13) % 25)}%` }}
261 />
262 <Skeleton
263 className="h-3"
264 style={{ width: `${55 + ((i * 9) % 20)}%` }}
265 />
266 </div>
267 <Skeleton className="h-5 w-14 rounded-full" />
268 </div>
269 ))}
270 </>
271 )}
272 </CardStackContent>
273 </CardStack>
274 </div>
275 </div>
276 </DialogContent>
277 </Dialog>
278 );
279}
packages/onboarding-demo/src/screens/integration-detail.tsx+105−0
@@ -0,0 +1,105 @@
1// Reproduction of packages/react/src/pages/integration-detail.tsx plus the
2// AccountsSection from packages/react/src/components/accounts-section.tsx.
3//
4// This is where a just-added integration lands. It has an Edit / Refresh /
5// Delete header, an Accounts / Tools tab strip, a "Connections" heading, and a
6// dashed empty card whose CTA is "Add connection" — the second add, for a thing
7// the user did not know was separate from the first.
8
9import { useState } from "react";
10import { Button } from "@executor-js/react/components/button";
11import { Tabs, TabsContent, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
12import type { DemoIntegration } from "../fixtures";
13
14export function IntegrationDetailPage(props: {
15 readonly integration: DemoIntegration;
16 readonly onAddConnection: () => void;
17}) {
18 const [tab, setTab] = useState<"accounts" | "tools">("accounts");
19
20 return (
21 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
22 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur-sm">
23 <div className="flex min-w-0 items-center gap-3">
24 <h2 className="truncate text-sm font-semibold text-foreground">
25 {props.integration.name}
26 </h2>
27 <span className="hidden text-xs tabular-nums text-muted-foreground sm:block">
28 {props.integration.toolCount} tools
29 </span>
30 </div>
31 <div className="flex shrink-0 items-center gap-2">
32 <Button variant="outline" size="sm">
33 Edit
34 </Button>
35 <Button variant="outline" size="sm">
36 Refresh
37 </Button>
38 <Button
39 variant="outline"
40 size="sm"
41 className="border-destructive/30 text-destructive hover:bg-destructive/10"
42 >
43 Delete
44 </Button>
45 </div>
46 </div>
47
48 <Tabs
49 value={tab}
50 onValueChange={(value: string) => setTab(value === "tools" ? "tools" : "accounts")}
51 className="min-h-0 flex-1 gap-0 overflow-hidden"
52 >
53 <div className="shrink-0 border-b border-border/60 px-4 py-2">
54 <TabsList variant="line">
55 <TabsTrigger value="accounts">Accounts</TabsTrigger>
56 <TabsTrigger value="tools">Tools</TabsTrigger>
57 </TabsList>
58 </div>
59
60 <TabsContent value="accounts" className="min-h-0 overflow-y-auto">
61 <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
62 <section className="space-y-3">
63 <div className="flex items-center justify-between">
64 <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
65 Connections
66 </h3>
67 </div>
68 <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center">
69 <p className="text-sm font-medium text-foreground">No connections yet</p>
70 <p className="mt-1 text-sm text-muted-foreground">
71 Add a connection to make this integration&apos;s tools available.
72 </p>
73 <Button type="button" className="mt-4" size="sm" onClick={props.onAddConnection}>
74 Add connection
75 </Button>
76 </div>
77 </section>
78 </div>
79 </TabsContent>
80
81 <TabsContent
82 value="tools"
83 className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden"
84 >
85 <div className="flex min-h-0 flex-1 overflow-hidden">
86 <div className="flex w-72 shrink-0 flex-col border-r border-border/60 p-3 lg:w-80 xl:w-[22rem]">
87 <p className="px-2 py-1.5 text-xs text-muted-foreground">No tools yet</p>
88 </div>
89 <div className="flex min-w-0 flex-1 items-center justify-center">
90 <div className="max-w-sm text-center">
91 <p className="text-sm font-medium text-foreground">No tools yet</p>
92 <p className="mt-1.5 text-sm text-muted-foreground">
93 Add a connection to unlock this integration&apos;s tools.
94 </p>
95 <Button type="button" size="sm" className="mt-4" onClick={props.onAddConnection}>
96 Add connection
97 </Button>
98 </div>
99 </div>
100 </div>
101 </TabsContent>
102 </Tabs>
103 </div>
104 );
105}
packages/onboarding-demo/src/screens/integrations-page.tsx+142−0
@@ -0,0 +1,142 @@
1// Reproduction of packages/react/src/pages/integrations.tsx — the screen that
2// follows CLI setup. The "Connect an agent" card sits above the fold; finding
3// an integration is a dialog behind one button in the header.
4
5import { PlusIcon } from "lucide-react";
6import { Button } from "@executor-js/react/components/button";
7import { PageContainer, PageHeader } from "@executor-js/react/components/page";
8import {
9 CardStack,
10 CardStackContent,
11 CardStackEntry,
12 CardStackEntryActions,
13 CardStackEntryContent,
14 CardStackEntryDescription,
15 CardStackEntryTitle,
16 CardStackHeader,
17} from "@executor-js/react/components/card-stack";
18import { Badge } from "@executor-js/react/components/badge";
19import { Tabs, TabsList, TabsTrigger } from "@executor-js/react/components/tabs";
20import type { DemoIntegration } from "../fixtures";
21
22function McpInstallCard() {
23 return (
24 <CardStack>
25 <Tabs value="http">
26 <CardStackHeader
27 className="items-start py-4"
28 rightSlot={
29 <TabsList>
30 <TabsTrigger value="http">Remote HTTP</TabsTrigger>
31 <TabsTrigger value="stdio">Standard I/O</TabsTrigger>
32 </TabsList>
33 }
34 >
35 <div className="flex min-w-0 flex-col gap-0.5">
36 <span className="text-sm font-semibold text-foreground">Connect an agent</span>
37 <span className="text-xs font-normal text-muted-foreground">
38 Paste this into Claude Code, Cursor, or any MCP client, and your agent gets every tool
39 you connect here.
40 </span>
41 </div>
42 </CardStackHeader>
43 <CardStackContent>
44 <div className="px-4 pt-3 pb-3">
45 <div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 font-mono text-xs text-foreground">
46 npx add-mcp http://localhost:3123/mcp --transport http --name executor
47 </div>
48 <div className="mt-3 text-xs font-medium text-muted-foreground">Advanced ⌄</div>
49 </div>
50 <div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
51 <span>Work with your agent</span>
52 <span className="rounded-md border border-border/60 px-1.5 py-0.5">Cursor</span>
53 <span className="rounded-md border border-border/60 px-1.5 py-0.5">Claude</span>
54 <span className="rounded-md border border-border/60 px-1.5 py-0.5">OpenCode</span>
55 <span>and more</span>
56 </div>
57 </CardStackContent>
58 </Tabs>
59 </CardStack>
60 );
61}
62
63export function IntegrationsPage(props: {
64 readonly integrations: readonly DemoIntegration[];
65 readonly onConnect: () => void;
66 readonly onOpenIntegration: (slug: string) => void;
67}) {
68 return (
69 <PageContainer>
70 <PageHeader
71 title="Integrations"
72 description="Tool providers available in this workspace."
73 actions={
74 <Button onClick={props.onConnect} size="sm" className="gap-1.5">
75 <PlusIcon className="size-4" />
76 Connect
77 </Button>
78 }
79 />
80
81 <div className="mb-8">
82 <McpInstallCard />
83 </div>
84
85 <div className="mb-8 border-t border-border/50" />
86
87 {props.integrations.length === 0 ? (
88 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
89 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
90 <PlusIcon className="size-5" />
91 </div>
92 <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>
93 <p className="mb-5 text-[13px] text-muted-foreground/60">
94 Connect an integration to start curating tools.
95 </p>
96 <Button onClick={props.onConnect} size="sm" className="gap-1.5">
97 <PlusIcon className="size-4" />
98 Connect an integration
99 </Button>
100 </div>
101 ) : (
102 <div className="mb-8 space-y-3">
103 <CardStack searchable>
104 <CardStackContent>
105 {props.integrations.map((integration) => (
106 <CardStackEntry key={integration.slug} asChild>
107 {/* oxlint-disable-next-line react/forbid-elements */}
108 <button
109 type="button"
110 className="w-full text-left"
111 onClick={() => props.onOpenIntegration(integration.slug)}
112 >
113 {integration.icon ? (
114 <img
115 src={integration.icon}
116 alt=""
117 className="size-8 shrink-0 rounded-md object-contain"
118 />
119 ) : (
120 <span className="size-8 shrink-0 rounded-md bg-muted" />
121 )}
122 <CardStackEntryContent>
123 <CardStackEntryTitle>{integration.name}</CardStackEntryTitle>
124 <CardStackEntryDescription>{integration.slug}</CardStackEntryDescription>
125 </CardStackEntryContent>
126 <CardStackEntryActions>
127 <Badge variant="outline">
128 {integration.toolCount === 0
129 ? "No connection"
130 : `${integration.toolCount} tools`}
131 </Badge>
132 </CardStackEntryActions>
133 </button>
134 </CardStackEntry>
135 ))}
136 </CardStackContent>
137 </CardStack>
138 </div>
139 )}
140 </PageContainer>
141 );
142}
packages/onboarding-demo/src/screens/next/authenticate.tsx+229−0
@@ -0,0 +1,229 @@
1// Reworked: authenticating one account.
2//
3// The whole screen is built from the registry's own credential record, which
4// integrations.sh already returns and the console currently discards:
5// credentials[].label → what this key is called at the provider
6// credentials[].generateUrl → the page that mints it, as a real link
7// credentials[].setup → how to mint it, in the provider's own terms
8//
9// So the three questions the current modal leaves unanswered — what kind of key,
10// where do I get one, where does it go — are answered on the screen that asks
11// for it. The paste field is the first focusable thing and is never gated: a
12// key already on the clipboard can go in immediately.
13
14import { useEffect, useState } from "react";
15import { ArrowUpRightIcon, EyeIcon, EyeOffIcon } from "lucide-react";
16import { Button } from "@executor-js/react/components/button";
17import { Label } from "@executor-js/react/components/label";
18import {
19 Dialog,
20 DialogContent,
21 DialogDescription,
22 DialogFooter,
23 DialogHeader,
24 DialogTitle,
25} from "@executor-js/react/components/dialog";
26import { fetchSurfaceCredentials, type SurfaceCredential } from "../../fixtures";
27
28/** The registry's setup text is markdown. Only links and bold appear in
29 * practice, so render those two and leave the rest as text rather than
30 * pulling in a markdown pipeline for a prototype. */
31function SetupText(props: { readonly text: string }) {
32 const nodes = props.text
33 .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g)
34 .filter((chunk) => chunk.length > 0)
35 .map((chunk, index) => {
36 const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk);
37 if (link) {
38 return (
39 <a
40 key={index}
41 href={link[2]}
42 target="_blank"
43 rel="noreferrer"
44 className="font-medium text-foreground underline underline-offset-2"
45 >
46 {link[1]}
47 </a>
48 );
49 }
50 const bold = /^\*\*([^*]+)\*\*$/.exec(chunk);
51 if (bold) {
52 return (
53 <span key={index} className="font-medium text-foreground">
54 {bold[1]}
55 </span>
56 );
57 }
58 const code = /^`([^`]+)`$/.exec(chunk);
59 if (code) {
60 return (
61 <code key={index} className="font-mono text-[0.9em] text-foreground">
62 {code[1]}
63 </code>
64 );
65 }
66 return <span key={index}>{chunk}</span>;
67 });
68 return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>;
69}
70
71export function AuthenticateDialog(props: {
72 readonly open: boolean;
73 readonly onOpenChange: (open: boolean) => void;
74 readonly integrationName: string;
75 readonly domain: string;
76 readonly accountLabel: string;
77 readonly onAuthenticated: () => void;
78}) {
79 const [credentials, setCredentials] = useState<readonly SurfaceCredential[] | null>(null);
80 const [selectedId, setSelectedId] = useState<string | null>(null);
81 const [secret, setSecret] = useState("");
82 const [revealed, setRevealed] = useState(false);
83 const [submitting, setSubmitting] = useState(false);
84
85 useEffect(() => {
86 let live = true;
87 void fetchSurfaceCredentials(props.domain).then((loaded) => {
88 if (!live) return;
89 setCredentials(loaded);
90 setSelectedId(loaded[0]?.id ?? null);
91 });
92 return () => {
93 live = false;
94 };
95 }, [props.domain]);
96
97 const selected = credentials?.find((candidate) => candidate.id === selectedId) ?? null;
98 const isOAuth = selected?.type === "oauth2";
99 const canSubmit = isOAuth || secret.trim().length > 0;
100
101 return (
102 <Dialog open={props.open} onOpenChange={props.onOpenChange}>
103 <DialogContent className="sm:max-w-lg">
104 <DialogHeader>
105 <DialogTitle>Connect {props.integrationName}</DialogTitle>
106 <DialogDescription>
107 Signing in as <span className="font-mono text-foreground">{props.accountLabel}</span>.
108 </DialogDescription>
109 </DialogHeader>
110
111 {credentials === null ? (
112 <p className="py-6 text-sm text-muted-foreground">
113 Checking how {props.domain} signs in…
114 </p>
115 ) : credentials.length === 0 ? (
116 <p className="py-6 text-sm text-muted-foreground">
117 The registry has no credential record for {props.domain} yet.
118 </p>
119 ) : (
120 <div className="flex flex-col gap-4">
121 {/* Only shown when the provider genuinely offers a choice. One way
122 in renders no picker at all. */}
123 {credentials.length > 1 && (
124 <div className="flex gap-1.5">
125 {credentials.map((credential) => (
126 // oxlint-disable-next-line react/forbid-elements
127 <button
128 key={credential.id}
129 type="button"
130 onClick={() => setSelectedId(credential.id)}
131 className={
132 credential.id === selectedId
133 ? "rounded-full border border-foreground/20 bg-foreground px-3 py-1 text-xs font-medium text-background"
134 : "rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
135 }
136 >
137 {credential.label}
138 </button>
139 ))}
140 </div>
141 )}
142
143 {isOAuth ? (
144 <p className="text-xs leading-relaxed text-muted-foreground">
145 {props.integrationName} signs in through your browser. Nothing to paste.
146 </p>
147 ) : (
148 <>
149 <div className="space-y-2">
150 <div className="flex items-center justify-between gap-3">
151 <Label
152 htmlFor="credential"
153 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
154 >
155 {selected?.label ?? "API key"}
156 </Label>
157 {selected?.generateUrl ? (
158 <a
159 href={selected.generateUrl}
160 target="_blank"
161 rel="noreferrer"
162 className="flex items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2"
163 >
164 Get a key
165 <ArrowUpRightIcon className="size-3" aria-hidden />
166 </a>
167 ) : null}
168 </div>
169 <div className="flex items-center gap-1">
170 {/* oxlint-disable-next-line react/forbid-elements */}
171 <input
172 id="credential"
173 type={revealed ? "text" : "password"}
174 autoComplete="off"
175 // The one thing this screen is for.
176 // oxlint-disable-next-line jsx_a11y/no-autofocus
177 autoFocus
178 value={secret}
179 onChange={(event) => setSecret(event.target.value)}
180 placeholder="Paste your key"
181 className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30"
182 />
183 <Button
184 type="button"
185 variant="ghost"
186 size="icon-sm"
187 aria-label="Show key"
188 aria-pressed={revealed}
189 className="shrink-0 text-muted-foreground"
190 onClick={() => setRevealed((value) => !value)}
191 >
192 {revealed ? <EyeOffIcon /> : <EyeIcon />}
193 </Button>
194 </div>
195 </div>
196
197 {selected?.setup ? (
198 <div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
199 <SetupText text={selected.setup} />
200 </div>
201 ) : null}
202 </>
203 )}
204 </div>
205 )}
206
207 <DialogFooter>
208 <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}>
209 Cancel
210 </Button>
211 <Button
212 type="button"
213 disabled={!canSubmit || submitting}
214 loading={submitting}
215 onClick={() => {
216 setSubmitting(true);
217 setTimeout(() => {
218 setSubmitting(false);
219 props.onAuthenticated();
220 }, 700);
221 }}
222 >
223 {isOAuth ? `Continue to ${props.integrationName}` : "Connect"}
224 </Button>
225 </DialogFooter>
226 </DialogContent>
227 </Dialog>
228 );
229}
packages/onboarding-demo/src/screens/next/browse.tsx+234−0
@@ -0,0 +1,234 @@
1// Reworked: the picker.
2//
3// Not a dialog behind a header button — the page itself. Search is the first
4// thing on it, categories are chips under that, and every row adds in place:
5// the row flips to "Added", the list does not move, and you can keep adding.
6// Nothing here asks for a spec URL, a base URL or an auth method, because the
7// registry already knows all three.
8//
9// "Add custom" sits next to the search box at all times, and the empty-search
10// state leads with it rather than apologising — a URL that isn't in the
11// registry is a normal thing to have, not a failed search.
12
13import { useEffect, useMemo, useState } from "react";
14import { CheckIcon, PlusIcon, SearchIcon } from "lucide-react";
15import { Button } from "@executor-js/react/components/button";
16import { Input } from "@executor-js/react/components/input";
17import { Skeleton } from "@executor-js/react/components/skeleton";
18import { cn } from "@executor-js/react/lib/utils";
19import { CATEGORY_ORDER, loadCatalog, searchItems, type CatalogItem } from "../../catalog";
20
21const SECTION_SIZE = 6;
22
23function AddButton(props: { readonly added: boolean; readonly onAdd: () => void }) {
24 if (props.added) {
25 return (
26 <span className="flex h-8 shrink-0 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-muted-foreground">
27 <CheckIcon className="size-3.5" aria-hidden />
28 Added
29 </span>
30 );
31 }
32 return (
33 <Button type="button" variant="outline" size="sm" className="shrink-0" onClick={props.onAdd}>
34 Add
35 </Button>
36 );
37}
38
39function CatalogRow(props: {
40 readonly item: CatalogItem;
41 readonly added: boolean;
42 readonly onAdd: () => void;
43 readonly onOpen: () => void;
44}) {
45 return (
46 <div
47 className={cn(
48 "flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors",
49 props.added ? "border-ring/40 bg-accent/40" : "border-transparent hover:bg-accent/40",
50 )}
51 >
52 {/* oxlint-disable-next-line react/forbid-elements */}
53 <button
54 type="button"
55 onClick={props.onOpen}
56 className="flex min-w-0 flex-1 items-center gap-3 text-left"
57 >
58 <img
59 src={props.item.icon}
60 alt=""
61 loading="lazy"
62 className="size-8 shrink-0 rounded-md object-contain"
63 />
64 <span className="min-w-0 flex-1">
65 <span className="block truncate text-sm font-medium text-foreground">
66 {props.item.name}
67 </span>
68 <span className="block truncate text-xs text-muted-foreground">
69 {props.item.description}
70 </span>
71 </span>
72 </button>
73 <AddButton added={props.added} onAdd={props.onAdd} />
74 </div>
75 );
76}
77
78/** Offered whenever the query cannot be satisfied from the registry — either
79 * because it looks like a URL, or because nothing matched. */
80function CustomPrompt(props: { readonly query: string; readonly onAddCustom: () => void }) {
81 const looksLikeUrl = /^[a-z]+:\/\//i.test(props.query) || props.query.includes("/");
82 return (
83 <div className="flex items-center gap-3 rounded-lg border border-dashed border-border px-3 py-3">
84 <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
85 <PlusIcon className="size-4" aria-hidden />
86 </span>
87 <div className="min-w-0 flex-1">
88 <p className="text-sm font-medium text-foreground">
89 {looksLikeUrl ? "Add this URL directly" : "Not in the registry?"}
90 </p>
91 <p className="text-xs text-muted-foreground">
92 Point executor at any MCP server, OpenAPI spec, or GraphQL endpoint.
93 </p>
94 </div>
95 <Button type="button" size="sm" className="shrink-0" onClick={props.onAddCustom}>
96 Add custom
97 </Button>
98 </div>
99 );
100}
101
102export function BrowsePage(props: {
103 readonly addedDomains: readonly string[];
104 readonly onAdd: (item: CatalogItem) => void;
105 readonly onOpen: (item: CatalogItem) => void;
106 readonly onAddCustom: () => void;
107}) {
108 const [items, setItems] = useState<readonly CatalogItem[] | null>(null);
109 const [query, setQuery] = useState("");
110 const [category, setCategory] = useState<string>("All");
111
112 useEffect(() => {
113 let live = true;
114 void loadCatalog().then((loaded) => {
115 if (live) setItems(loaded);
116 });
117 return () => {
118 live = false;
119 };
120 }, []);
121
122 const searched = useMemo(() => (items ? searchItems(items, query) : []), [items, query]);
123 const searching = query.trim().length > 0;
124
125 const sections = useMemo(() => {
126 if (searching) return [];
127 const wanted = category === "All" ? CATEGORY_ORDER : [category];
128 return wanted
129 .map((name) => ({
130 name,
131 items: searched.filter((item) => item.category === name).slice(0, SECTION_SIZE),
132 }))
133 .filter((section) => section.items.length > 0);
134 }, [searched, category, searching]);
135
136 return (
137 <>
138 <div className="mb-4 flex gap-2">
139 <div className="relative min-w-0 flex-1">
140 <SearchIcon
141 className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
142 aria-hidden
143 />
144 <Input
145 value={query}
146 onChange={(event) => setQuery((event.target as HTMLInputElement).value)}
147 placeholder="Search integrations…"
148 aria-label="Search integrations"
149 className="h-11 pl-9 text-sm"
150 />
151 </div>
152 <Button
153 type="button"
154 variant="outline"
155 className="h-11 shrink-0 gap-1.5"
156 onClick={props.onAddCustom}
157 >
158 <PlusIcon className="size-4" aria-hidden />
159 Add custom
160 </Button>
161 </div>
162
163 <div className="mb-8 flex flex-wrap gap-1.5">
164 {["All", ...CATEGORY_ORDER].map((name) => (
165 // oxlint-disable-next-line react/forbid-elements
166 <button
167 key={name}
168 type="button"
169 onClick={() => setCategory(name)}
170 className={cn(
171 "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
172 category === name
173 ? "border-foreground/20 bg-foreground text-background"
174 : "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
175 )}
176 >
177 {name}
178 </button>
179 ))}
180 </div>
181
182 {items === null ? (
183 <div className="flex flex-col gap-2">
184 {Array.from({ length: 8 }).map((_, i) => (
185 <div key={i} className="flex items-center gap-3 px-3 py-2.5">
186 <Skeleton className="size-8 shrink-0 rounded-md" />
187 <div className="flex min-w-0 flex-1 flex-col gap-1.5">
188 <Skeleton className="h-3.5" style={{ width: `${20 + ((i * 11) % 20)}%` }} />
189 <Skeleton className="h-3" style={{ width: `${45 + ((i * 13) % 30)}%` }} />
190 </div>
191 <Skeleton className="h-8 w-14 rounded-md" />
192 </div>
193 ))}
194 </div>
195 ) : searching ? (
196 <div className="flex flex-col gap-1">
197 {searched.slice(0, 40).map((item) => (
198 <CatalogRow
199 key={item.domain}
200 item={item}
201 added={props.addedDomains.includes(item.domain)}
202 onAdd={() => props.onAdd(item)}
203 onOpen={() => props.onOpen(item)}
204 />
205 ))}
206 <div className="mt-2">
207 <CustomPrompt query={query} onAddCustom={props.onAddCustom} />
208 </div>
209 </div>
210 ) : (
211 <div className="flex flex-col gap-8">
212 {sections.map((section) => (
213 <section key={section.name}>
214 <h2 className="mb-2 px-3 text-xs font-medium uppercase tracking-widest text-muted-foreground">
215 {section.name}
216 </h2>
217 <div className="flex flex-col gap-1">
218 {section.items.map((item) => (
219 <CatalogRow
220 key={item.domain}
221 item={item}
222 added={props.addedDomains.includes(item.domain)}
223 onAdd={() => props.onAdd(item)}
224 onOpen={() => props.onOpen(item)}
225 />
226 ))}
227 </div>
228 </section>
229 ))}
230 </div>
231 )}
232 </>
233 );
234}
packages/onboarding-demo/src/screens/next/custom.tsx+242−0
@@ -0,0 +1,242 @@
1// Reworked: adding something the registry has never heard of.
2//
3// This is the case the picker cannot cover and executor exists for — an
4// internal API, a private MCP server, a spec behind a VPN. It has to be a
5// first-class door, not the fallback you reach after search disappoints.
6//
7// The rule that keeps it simple: paste one URL, we say what we found, you add
8// it. What comes out the other side is an ordinary added integration with an
9// ordinary "Needs auth" account — identical to a registry pick from that point
10// on, because the only thing the registry was ever providing is the definition.
11
12import { useState } from "react";
13import { ArrowUpRightIcon } from "lucide-react";
14import { Button } from "@executor-js/react/components/button";
15import { Label } from "@executor-js/react/components/label";
16import {
17 Dialog,
18 DialogContent,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogTitle,
23} from "@executor-js/react/components/dialog";
24import { getDomain } from "tldts";
25import { logoFor, type AddedIntegration } from "../../catalog";
26import { INTEGRATIONS_SH_ORIGIN } from "../../fixtures";
27
28type Kind = "mcp" | "openapi" | "graphql";
29
30const KIND_LABEL: Record<Kind, string> = {
31 mcp: "MCP server",
32 openapi: "OpenAPI spec",
33 graphql: "GraphQL endpoint",
34};
35
36interface Detected {
37 readonly kind: Kind;
38 readonly domain: string;
39 readonly url: string;
40 /** A registry entry related to this URL, if there is one. `exact` separates
41 * two genuinely different situations that a string match cannot tell apart
42 * on its own: the host IS the registry entry, versus the host merely sits
43 * under a domain the registry knows. `mcp.posthog.com` under `posthog.com`
44 * is probably the same service; `internal.acme.com` under `acme.com` is
45 * probably not, so the copy must not claim it is. */
46 readonly registryDomain: string | null;
47 readonly exact: boolean;
48}
49
50const guessKind = (url: URL): Kind => {
51 const path = `${url.pathname}${url.search}`.toLowerCase();
52 if (path.includes("graphql")) return "graphql";
53 if (/\/mcp\b|\/sse\b/.test(path)) return "mcp";
54 if (/openapi|swagger|\.ya?ml$|\.json$/.test(path)) return "openapi";
55 // A bare origin is most often an MCP server these days; the detect step in
56 // the real product probes rather than guesses.
57 return path === "/" || path.length === 0 ? "mcp" : "openapi";
58};
59
60const detect = async (raw: string): Promise<Detected | { readonly error: string }> => {
61 const trimmed = raw.trim();
62 const withScheme = /^[a-z][a-z0-9+\-.]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
63 const parsed = ((): URL | null => {
64 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing reports failure by throwing
65 try {
66 return new URL(withScheme);
67 } catch {
68 return null;
69 }
70 })();
71 if (!parsed) return { error: "That doesn't look like a URL." };
72
73 const host = parsed.hostname.replace(/^www\./, "");
74 // The registry keys on the registrable domain, so an endpoint living on a
75 // subdomain (`mcp.posthog.com`) only matches once we also try its parent.
76 const candidates = [...new Set([host, getDomain(host)])].filter(
77 (candidate): candidate is string => candidate !== null && candidate.length > 0,
78 );
79 const registryDomain = await (async () => {
80 for (const candidate of candidates) {
81 // A miss and a network failure mean the same thing here — nothing extra
82 // to offer — so both just move on to the next candidate.
83 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch reports transport failure by rejecting
84 try {
85 const response = await fetch(
86 `${INTEGRATIONS_SH_ORIGIN}/api/${encodeURIComponent(candidate)}/surface`,
87 );
88 if (response.ok) return candidate;
89 } catch {
90 continue;
91 }
92 }
93 return null;
94 })();
95
96 return {
97 kind: guessKind(parsed),
98 domain: host,
99 url: parsed.toString(),
100 registryDomain,
101 exact: registryDomain === host,
102 };
103};
104
105export function AddCustomDialog(props: {
106 readonly open: boolean;
107 readonly onOpenChange: (open: boolean) => void;
108 readonly onAdd: (integration: AddedIntegration) => void;
109}) {
110 const [raw, setRaw] = useState("");
111 const [busy, setBusy] = useState(false);
112 const [result, setResult] = useState<Detected | null>(null);
113 const [error, setError] = useState<string | null>(null);
114
115 const run = async () => {
116 setBusy(true);
117 setError(null);
118 const outcome = await detect(raw);
119 setBusy(false);
120 if ("error" in outcome) {
121 setError(outcome.error);
122 setResult(null);
123 return;
124 }
125 setResult(outcome);
126 };
127
128 return (
129 <Dialog open={props.open} onOpenChange={props.onOpenChange}>
130 <DialogContent className="sm:max-w-lg">
131 <DialogHeader>
132 <DialogTitle>Add a custom integration</DialogTitle>
133 <DialogDescription>
134 Point at an MCP server, an OpenAPI spec, or a GraphQL endpoint. It does not need to be
135 in the registry.
136 </DialogDescription>
137 </DialogHeader>
138
139 <div className="flex flex-col gap-4">
140 <div className="space-y-2">
141 <Label
142 htmlFor="custom-url"
143 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
144 >
145 URL
146 </Label>
147 <div className="flex gap-2">
148 {/* oxlint-disable-next-line react/forbid-elements */}
149 <input
150 id="custom-url"
151 // oxlint-disable-next-line jsx_a11y/no-autofocus -- the only field on the screen
152 autoFocus
153 value={raw}
154 onChange={(event) => {
155 setRaw(event.target.value);
156 setResult(null);
157 setError(null);
158 }}
159 onKeyDown={(event) => {
160 if (event.key === "Enter" && raw.trim().length > 0) void run();
161 }}
162 placeholder="https://internal.acme.com/mcp"
163 className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 font-mono text-sm outline-none transition-colors focus:border-ring dark:bg-input/30"
164 />
165 <Button
166 type="button"
167 variant="outline"
168 disabled={raw.trim().length === 0 || busy}
169 loading={busy}
170 onClick={() => void run()}
171 >
172 Check
173 </Button>
174 </div>
175 {error ? <p className="text-xs text-destructive">{error}</p> : null}
176 </div>
177
178 {result ? (
179 <div className="space-y-2 rounded-lg border border-border bg-muted/20 px-3 py-3">
180 <div className="flex items-center gap-2.5">
181 <img
182 src={logoFor(result.domain)}
183 alt=""
184 className="size-6 shrink-0 rounded object-contain"
185 />
186 <div className="min-w-0 flex-1">
187 <p className="truncate text-sm font-medium text-foreground">{result.domain}</p>
188 <p className="text-xs text-muted-foreground">{KIND_LABEL[result.kind]}</p>
189 </div>
190 </div>
191 {result.registryDomain ? (
192 <p className="text-xs leading-relaxed text-muted-foreground">
193 {result.exact
194 ? `The registry already covers ${result.registryDomain}. Adding it from search brings the credential setup instructions with it — this URL will be added as-is, with none of that.`
195 : `The registry has an entry for ${result.registryDomain}, which may or may not be the same service as this host. This URL will be added as-is either way.`}{" "}
196 <a
197 href={`${INTEGRATIONS_SH_ORIGIN}/${result.registryDomain}/`}
198 target="_blank"
199 rel="noreferrer"
200 className="inline-flex items-center gap-0.5 font-medium text-foreground underline underline-offset-2"
201 >
202 See the entry
203 <ArrowUpRightIcon className="size-3" aria-hidden />
204 </a>
205 </p>
206 ) : (
207 <p className="text-xs leading-relaxed text-muted-foreground">
208 Not in the registry. You&apos;ll set up its credential yourself on the next
209 screen.
210 </p>
211 )}
212 </div>
213 ) : null}
214 </div>
215
216 <DialogFooter>
217 <Button type="button" variant="ghost" onClick={() => props.onOpenChange(false)}>
218 Cancel
219 </Button>
220 <Button
221 type="button"
222 disabled={result === null}
223 onClick={() => {
224 if (!result) return;
225 props.onAdd({
226 domain: result.domain,
227 name: result.domain,
228 description: `${KIND_LABEL[result.kind]} at ${result.url}`,
229 icon: logoFor(result.domain),
230 formats: [result.kind],
231 source: "custom",
232 url: result.url,
233 });
234 }}
235 >
236 Add integration
237 </Button>
238 </DialogFooter>
239 </DialogContent>
240 </Dialog>
241 );
242}
packages/onboarding-demo/src/screens/next/integration-detail.tsx+209−0
@@ -0,0 +1,209 @@
1// Reworked: one added integration.
2//
3// "Integration" and "connection" still exist — an integration can hold several
4// accounts, which is a real capability — but the hierarchy is no longer
5// something you have to understand before you can start. An add always leaves
6// exactly one account row behind, already named, in the one state that matters:
7// Needs auth, with the button that fixes it. Tools stay collapsed until there
8// are some.
9//
10// A custom integration renders through this same screen. The only difference it
11// is allowed to make is the one line naming where its definition came from.
12
13import { ArrowLeftIcon, ArrowUpRightIcon, ChevronDownIcon, PlusIcon } from "lucide-react";
14import { Button } from "@executor-js/react/components/button";
15import { cn } from "@executor-js/react/lib/utils";
16import type { AddedIntegration } from "../../catalog";
17
18export interface DemoAccount {
19 readonly label: string;
20 readonly status: "needs-auth" | "connected";
21 readonly identity?: string;
22}
23
24function AccountRow(props: { readonly account: DemoAccount; readonly onAuthenticate: () => void }) {
25 const needsAuth = props.account.status === "needs-auth";
26 return (
27 <div className="flex items-center gap-3 px-3 py-2.5">
28 <span
29 className={cn(
30 "size-2 shrink-0 rounded-full",
31 needsAuth ? "bg-amber-500" : "bg-emerald-500",
32 )}
33 aria-hidden
34 />
35 <span className="min-w-0 flex-1">
36 <span className="block truncate text-sm text-foreground">
37 {props.account.identity ?? props.account.label}
38 </span>
39 </span>
40 {needsAuth ? (
41 <>
42 <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span>
43 <Button type="button" variant="outline" size="sm" onClick={props.onAuthenticate}>
44 Authenticate
45 </Button>
46 </>
47 ) : (
48 <span className="shrink-0 text-xs text-muted-foreground">Connected</span>
49 )}
50 </div>
51 );
52}
53
54export function NextIntegrationDetail(props: {
55 readonly item: AddedIntegration;
56 readonly accounts: readonly DemoAccount[];
57 readonly toolCount: number;
58 readonly onBack: () => void;
59 readonly onAuthenticate: (accountLabel: string) => void;
60 readonly onAddAccount: () => void;
61 readonly onRemove: () => void;
62}) {
63 const custom = props.item.source === "custom";
64 return (
65 <>
66 <Button
67 type="button"
68 variant="ghost"
69 size="sm"
70 className="-ml-2 mb-6 gap-1.5 text-muted-foreground"
71 onClick={props.onBack}
72 >
73 <ArrowLeftIcon className="size-4" />
74 All integrations
75 </Button>
76
77 <div className="mb-6 flex items-start gap-4">
78 <img src={props.item.icon} alt="" className="size-12 shrink-0 rounded-xl object-contain" />
79 <div className="min-w-0 flex-1">
80 <h1 className="text-lg font-semibold text-foreground">{props.item.name}</h1>
81 {custom ? (
82 <p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
83 {props.item.url}
84 </p>
85 ) : (
86 <a
87 href={`https://integrations.sh/${props.item.domain}/`}
88 target="_blank"
89 rel="noreferrer"
90 className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
91 >
92 {props.item.domain}
93 <ArrowUpRightIcon className="size-3" aria-hidden />
94 </a>
95 )}
96 </div>
97 <Button type="button" variant="outline" size="sm" onClick={props.onRemove}>
98 Remove
99 </Button>
100 </div>
101
102 <p className="mb-8 text-sm leading-relaxed text-muted-foreground">{props.item.description}</p>
103
104 <section className="mb-6">
105 <h2 className="mb-2 text-xs font-medium uppercase tracking-widest text-muted-foreground">
106 Accounts
107 </h2>
108 <div className="overflow-hidden rounded-lg border border-border">
109 <div className="divide-y divide-border">
110 {props.accounts.map((account) => (
111 <AccountRow
112 key={account.label}
113 account={account}
114 onAuthenticate={() => props.onAuthenticate(account.label)}
115 />
116 ))}
117 </div>
118 {/* oxlint-disable-next-line react/forbid-elements */}
119 <button
120 type="button"
121 onClick={props.onAddAccount}
122 className="flex w-full items-center gap-1.5 border-t border-border px-3 py-2.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
123 >
124 <PlusIcon className="size-3.5" aria-hidden />
125 Add another account
126 </button>
127 </div>
128 </section>
129
130 <section>
131 <div className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5">
132 <span className="text-sm text-foreground">
133 {props.toolCount === 0
134 ? "Tools appear once an account is connected"
135 : `${props.toolCount} tools`}
136 </span>
137 <ChevronDownIcon className="size-4 text-muted-foreground" aria-hidden />
138 </div>
139 </section>
140 </>
141 );
142}
143
144/** The installed list. Without a sidebar this is how you get back to something
145 * you added, so it carries the same status vocabulary as the detail screen. */
146export function InstalledList(props: {
147 readonly items: readonly {
148 readonly item: AddedIntegration;
149 readonly accounts: readonly DemoAccount[];
150 }[];
151 readonly onOpen: (domain: string) => void;
152 readonly onBrowse: () => void;
153}) {
154 if (props.items.length === 0) {
155 return (
156 <div className="rounded-lg border border-dashed border-border py-16 text-center">
157 <p className="text-sm font-medium text-foreground">Nothing added yet</p>
158 <p className="mt-1 text-sm text-muted-foreground">
159 Add an integration and it shows up here.
160 </p>
161 <Button type="button" size="sm" className="mt-4" onClick={props.onBrowse}>
162 Browse integrations
163 </Button>
164 </div>
165 );
166 }
167
168 return (
169 <div className="flex flex-col gap-1">
170 {props.items.map(({ item, accounts }) => {
171 const pending = accounts.filter((account) => account.status === "needs-auth").length;
172 return (
173 // oxlint-disable-next-line react/forbid-elements
174 <button
175 key={item.domain}
176 type="button"
177 onClick={() => props.onOpen(item.domain)}
178 className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors hover:bg-accent/40"
179 >
180 <img
181 src={item.icon}
182 alt=""
183 loading="lazy"
184 className="size-8 shrink-0 rounded-md object-contain"
185 />
186 <span className="min-w-0 flex-1">
187 <span className="flex items-center gap-2">
188 <span className="truncate text-sm font-medium text-foreground">{item.name}</span>
189 {item.source === "custom" ? (
190 <span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-muted-foreground">
191 Custom
192 </span>
193 ) : null}
194 </span>
195 <span className="block truncate text-xs text-muted-foreground">
196 {accounts.length} account{accounts.length === 1 ? "" : "s"}
197 </span>
198 </span>
199 {pending > 0 ? (
200 <span className="shrink-0 text-xs font-medium text-amber-500">Needs auth</span>
201 ) : (
202 <span className="shrink-0 text-xs text-muted-foreground">Connected</span>
203 )}
204 </button>
205 );
206 })}
207 </div>
208 );
209}
packages/onboarding-demo/src/shell.tsx+97−0
@@ -0,0 +1,97 @@
1// Reproduction of the console chrome (packages/app/src/web/shell.tsx): the
2// wordmark bar, the five nav items, the second INTEGRATIONS heading that
3// repeats the first nav item, and the four-link footer.
4
5import type { ReactNode } from "react";
6import { cn } from "@executor-js/react/lib/utils";
7import type { DemoIntegration } from "./fixtures";
8
9const NAV_ITEMS = ["Integrations", "Secrets", "Policies", "Toolkits", "Artifacts"] as const;
10
11function NavItem(props: { readonly label: string; readonly active: boolean }) {
12 return (
13 <span
14 className={cn(
15 "flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
16 props.active
17 ? "bg-sidebar-active font-medium text-foreground"
18 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
19 )}
20 >
21 {props.label}
22 </span>
23 );
24}
25
26export function Shell(props: {
27 readonly integrations: readonly DemoIntegration[];
28 readonly children: ReactNode;
29}) {
30 return (
31 <div className="flex h-full min-h-0 w-full">
32 <aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
33 <div className="flex h-12 shrink-0 items-center gap-2 border-b border-sidebar-border px-4">
34 <span className="font-mono text-sm font-medium tracking-tight text-foreground">
35 executor
36 </span>
37 <span className="ml-auto truncate font-mono text-[11px] text-muted-foreground">
38 theo&apos;s org
39 </span>
40 </div>
41
42 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
43 {NAV_ITEMS.map((label) => (
44 <NavItem key={label} label={label} active={label === "Integrations"} />
45 ))}
46
47 <span className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
48 Integrations
49 </span>
50
51 {props.integrations.length === 0 ? (
52 <div className="px-2.5 py-2 text-sm leading-relaxed text-muted-foreground">
53 No integrations yet
54 </div>
55 ) : (
56 <div className="flex flex-col gap-px">
57 {props.integrations.map((integration) => (
58 <span
59 key={integration.slug}
60 className="group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground"
61 >
62 {integration.icon ? (
63 <img
64 src={integration.icon}
65 alt=""
66 className="size-4 rounded-sm object-contain"
67 />
68 ) : (
69 <span className="size-4 rounded-sm bg-muted" />
70 )}
71 <span className="flex-1 truncate">{integration.name}</span>
72 </span>
73 ))}
74 </div>
75 )}
76 </nav>
77
78 <div className="shrink-0 border-t border-sidebar-border px-4 py-2.5">
79 <div className="flex flex-col gap-1.5 text-xs leading-none text-muted-foreground">
80 <span className="flex items-center justify-between">
81 <span>Commands</span>
82 <span className="font-mono text-[11px]">⌘K</span>
83 </span>
84 <span>Docs</span>
85 <span>Feedback / bug?</span>
86 <span>Star on GitHub</span>
87 <span className="mt-0.5 tabular-nums">v1.4.4</span>
88 </div>
89 </div>
90 </aside>
91
92 <main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
93 {props.children}
94 </main>
95 </div>
96 );
97}
packages/onboarding-demo/src/step-state.ts+45−0
@@ -0,0 +1,45 @@
1// Which screens carry which workspace state. Jumping straight to a late step
2// has to bring that step's state with it, or the screen is not the one the
3// step is about.
4
5import type { StepId } from "./flow";
6import { gmailIntegration, posthogIntegration, type DemoIntegration } from "./fixtures";
7
8export type AccountModal = "none" | "credential" | "place" | "oauth-stuck";
9export type ScreenView = "integrations" | "add" | "detail";
10
11export interface StepState {
12 readonly integrations: readonly DemoIntegration[];
13 readonly connectOpen: boolean;
14 readonly accountModal: AccountModal;
15 readonly view: ScreenView;
16}
17
18const EMPTY: StepState = {
19 integrations: [],
20 connectOpen: false,
21 accountModal: "none",
22 view: "integrations",
23};
24
25const WITH_POSTHOG: StepState = {
26 integrations: [posthogIntegration],
27 connectOpen: false,
28 accountModal: "none",
29 view: "detail",
30};
31
32export const stepState: Readonly<Record<StepId, StepState>> = {
33 "integrations-empty": EMPTY,
34 "connect-dialog": { ...EMPTY, connectOpen: true },
35 "add-openapi": { ...EMPTY, view: "add" },
36 "detail-accounts": WITH_POSTHOG,
37 "add-account-credential": { ...WITH_POSTHOG, accountModal: "credential" },
38 "add-account-place": { ...WITH_POSTHOG, accountModal: "place" },
39 "oauth-stuck": {
40 ...WITH_POSTHOG,
41 integrations: [gmailIntegration],
42 accountModal: "oauth-stuck",
43 },
44 "integrations-populated": { ...WITH_POSTHOG, view: "integrations" },
45};
packages/onboarding-demo/src/styles.css+5−0
@@ -0,0 +1,5 @@
1/* The console's real stylesheet — same tokens, same primitives. The extra
2 @source is this package's own sources, which the react package's globs
3 (rooted at packages/react/src) do not cover. */
4@import "@executor-js/react/globals.css";
5@source "./**/*.{ts,tsx}";
packages/onboarding-demo/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1{
2 "compilerOptions": {
3 "target": "ESNext",
4 "module": "ESNext",
5 "moduleResolution": "bundler",
6 "allowImportingTsExtensions": true,
7 "strict": true,
8 "esModuleInterop": true,
9 "skipLibCheck": true,
10 "noEmit": true,
11 "jsx": "react-jsx",
12 "types": ["vite/client"]
13 },
14 "include": ["src", "vite.config.ts"]
15}
packages/onboarding-demo/vite.config.ts+12−0
@@ -0,0 +1,12 @@
1import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";
3import tailwindcss from "@tailwindcss/vite";
4
5// Standalone SPA reproduction of the current add-integration flow. No executor
6// API, no auth, no backend — every screen runs off `src/fixtures.ts` plus the
7// live public integrations.sh catalog, so the flow can be redesigned without
8// standing up a tenant.
9export default defineConfig({
10 plugins: [react(), tailwindcss()],
11 server: { port: 5199 },
12});
packages/plugins/desktop-settings/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/plugin-desktop-settings1# @executor-js/plugin-desktop-settings
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 1.6.5
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 1.6.417## 1.6.4
4 18
5### Patch Changes19### Patch Changes
packages/plugins/desktop-settings/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-desktop-settings",2 "name": "@executor-js/plugin-desktop-settings",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/encrypted-secrets/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/plugin-encrypted-secrets1# @executor-js/plugin-encrypted-secrets
2 2
3## 0.0.48
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 0.0.47
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 0.0.4617## 0.0.46
4 18
5### Patch Changes19### Patch Changes
packages/plugins/encrypted-secrets/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-encrypted-secrets",2 "name": "@executor-js/plugin-encrypted-secrets",
3 "version": "0.0.46",3 "version": "0.0.48",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
packages/plugins/example/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/plugin-example1# @executor-js/plugin-example
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 1.6.5
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 1.6.417## 1.6.4
4 18
5### Patch Changes19### Patch Changes
packages/plugins/example/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-example",2 "name": "@executor-js/plugin-example",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/file-secrets/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/plugin-file-secrets1# @executor-js/plugin-file-secrets
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 1.6.5
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 1.6.417## 1.6.4
4 18
5### Patch Changes19### Patch Changes
packages/plugins/file-secrets/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-file-secrets",2 "name": "@executor-js/plugin-file-secrets",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/graphql/CHANGELOG.md+20−0
@@ -1,5 +1,25 @@
1# @executor-js/plugin-graphql1# @executor-js/plugin-graphql
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
8 - @executor-js/react@1.4.69
9 - @executor-js/api@1.4.69
10 - @executor-js/sdk@1.6.6
11 - @executor-js/config@1.6.6
12
13## 1.6.5
14
15### Patch Changes
16
17- Updated dependencies []:
18 - @executor-js/sdk@1.6.5
19 - @executor-js/config@1.6.5
20 - @executor-js/api@1.4.68
21 - @executor-js/react@1.4.68
22
3## 1.6.423## 1.6.4
4 24
5### Patch Changes25### Patch Changes
packages/plugins/graphql/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-graphql",2 "name": "@executor-js/plugin-graphql",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx+19−2
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
15 type AuthMethodRow,15 type AuthMethodRow,
16 type AuthMethodSeed,16 type AuthMethodSeed,
17} from "@executor-js/react/components/auth-method-list-editor";17} from "@executor-js/react/components/auth-method-list-editor";
18import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
18import { FloatActions } from "@executor-js/react/components/float-actions";19import { FloatActions } from "@executor-js/react/components/float-actions";
19import {20import {
20 addIntegrationErrorMessage,21 addIntegrationErrorMessage,
@@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines
42 onComplete: (slug?: string) => void;43 onComplete: (slug?: string) => void;
43 onCancel: () => void;44 onCancel: () => void;
44 initialUrl?: string;45 initialUrl?: string;
46 initialAuthHeader?: string;
47 initialAuthNote?: string;
45}) {48}) {
46 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");49 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
47 const [description, setDescription] = useState("");50 const [description, setDescription] = useState("");
48 const identity = useIntegrationIdentity({51 const identity = useIntegrationIdentity({
49 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",52 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
50 });53 });
51 const authMethodList = useAuthMethodList(NO_SEEDS);54 // GraphQL has no add-time detection, but the registry can declare the
55 // credential placement ("Authorization: {api_key}" — Linear's no-Bearer
56 // personal keys). A declared placement seeds the method list the same way a
57 // spec's security scheme would.
58 const registrySeeds = useMemo<readonly AuthMethodSeed[]>(() => {
59 const placement = props.initialAuthHeader
60 ? placementFromHeaderPattern(props.initialAuthHeader)
61 : null;
62 return placement ? [{ value: { kind: "apikey", placements: [placement] } }] : NO_SEEDS;
63 }, [props.initialAuthHeader]);
64 const authMethodList = useAuthMethodList(registrySeeds);
52 const [adding, setAdding] = useState(false);65 const [adding, setAdding] = useState(false);
53 const [addError, setAddError] = useState<string | null>(null);66 const [addError, setAddError] = useState<string | null>(null);
54 67
@@ -144,9 +157,13 @@ export default function AddGraphqlIntegration(props: { · 89 unmodified lines
144 list={authMethodList}157 list={authMethodList}
145 allowedKinds={["none", "apikey"]}158 allowedKinds={["none", "apikey"]}
146 emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later."159 emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later."
147 footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding."160 footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page."
148 />161 />
149 162
163 {props.initialAuthNote ? (
164 <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
165 ) : null}
166
150 {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}167 {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}
151 168
152 {addError && <FormErrorAlert message={addError} />}169 {addError && <FormErrorAlert message={addError} />}
packages/plugins/graphql/src/react/integration-plugin.ts+47−3
@@ -1,6 +1,49 @@
1import { lazy } from "react";1import { lazy, useCallback } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import type {
5 IntegrationPlugin,
6 IntegrationQuickAddInput,
7 IntegrationQuickAddResult,
8} from "@executor-js/sdk/client";
9import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
10import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
11import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3import { graphqlPresets } from "../sdk/presets";12import { graphqlPresets } from "../sdk/presets";
13import { createGraphqlIntegrationOptimistic } from "./atoms";
14import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
15
16/** One-click add for a registry GraphQL row. A GraphQL endpoint cannot
17 * describe its own auth, so the registry's declared header pattern is the
18 * only carrier of facts like Linear's no-Bearer key — it becomes the
19 * declared method, exactly as the add page would seed it. */
20function useGraphqlQuickAdd(): (
21 input: IntegrationQuickAddInput,
22) => Promise<IntegrationQuickAddResult> {
23 const doAdd = useAtomSet(createGraphqlIntegrationOptimistic, { mode: "promiseExit" });
24 return useCallback(
25 async (input) => {
26 const slug = slugifyNamespace(input.slug ?? input.name);
27 if (!slug) return { ok: false, reason: "no derivable slug" };
28 const placement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null;
29 const authenticationTemplate = placement
30 ? graphqlAuthMethodInputsFromPlacements([placement])
31 : [];
32 const exit = await doAdd({
33 payload: {
34 endpoint: input.url,
35 slug,
36 name: input.name,
37 ...(authenticationTemplate.length > 0 ? { authenticationTemplate } : {}),
38 },
39 reactivityKeys: integrationWriteKeys,
40 });
41 if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };
42 return { ok: true, slug: String(exit.value.slug) };
43 },
44 [doAdd],
45 );
46}
4 47
5const importAdd = () => import("./AddGraphqlIntegration");48const importAdd = () => import("./AddGraphqlIntegration");
6const importAccounts = () => import("./GraphqlAccountsPanel");49const importAccounts = () => import("./GraphqlAccountsPanel");
@@ -12,9 +55,10 @@ export const graphqlIntegrationPlugin: IntegrationPlugin = { · 5 unmodified lines
12 label: "GraphQL",55 label: "GraphQL",
13 add: lazy(importAdd),56 add: lazy(importAdd),
14 accounts: lazy(importAccounts),57 accounts: lazy(importAccounts),
15 presets: graphqlPresets,58 presets: graphqlPresets.map((preset) => ({ ...preset, registryListed: true })),
16 preload: () => {59 preload: () => {
17 void importAdd();60 void importAdd();
18 void importAccounts();61 void importAccounts();
19 },62 },
63 useQuickAdd: useGraphqlQuickAdd,
20};64};
packages/plugins/keychain/CHANGELOG.md+14−0
@@ -1,5 +1,19 @@
1# @executor-js/plugin-keychain1# @executor-js/plugin-keychain
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/sdk@1.6.6
9
10## 1.6.5
11
12### Patch Changes
13
14- Updated dependencies []:
15 - @executor-js/sdk@1.6.5
16
3## 1.6.417## 1.6.4
4 18
5### Patch Changes19### Patch Changes
packages/plugins/keychain/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-keychain",2 "name": "@executor-js/plugin-keychain",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/mcp/CHANGELOG.md+51−0
@@ -1,5 +1,56 @@
1# @executor-js/plugin-mcp1# @executor-js/plugin-mcp
2 2
3## 1.6.6
4
5### Patch Changes
6
7- [#1869](https://github.com/UsefulSoftwareCo/executor/pull/1869) [`c695970`](https://github.com/UsefulSoftwareCo/executor/commit/c6959702f6459504463fe0e13fa1a576190460ed) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Explain macOS permissions for Codex plugins instead of failing with an opaque
8 error. A refused grant used to surface as `Internal tool error [id]` — the
9 plugin reports "Unknown error" and only a numeric code says what happened, so
10 neither the user nor the model could tell that macOS was the blocker.
11
12 The bridge now recognises those codes and answers with the grant to enable and
13 where to find it. Each plugin's add screen also states what macOS will ask for
14 before anything runs, with a link straight to the right Privacy pane — macOS
15 asks once, and a dismissed prompt never returns.
16
17 The add screen checks that access when it opens, and holds the Add button
18 until the plugin answers. Adding one that macOS is still blocking produced an
19 integration that looked connected and failed on its first call, by which point
20 the screen explaining the fix was gone.
21
22- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
23 - @executor-js/react@1.4.69
24 - @executor-js/api@1.4.69
25 - @executor-js/sdk@1.6.6
26 - @executor-js/config@1.6.6
27
28## 1.6.5
29
30### Patch Changes
31
32- [#1863](https://github.com/UsefulSoftwareCo/executor/pull/1863) [`00c2ab7`](https://github.com/UsefulSoftwareCo/executor/commit/00c2ab789eef94efd9c05d389870566bba7111c2) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Adding a Codex plugin no longer asks for anything. `CODEX_HOME` is a path the
33 scanner already resolved, but it was passed on the channel that makes an
34 environment variable a credential — so the integration declared it as one, and
35 a person who reached the connect step was shown a masked field for a value
36 they should never have to know.
37
38 Stdio integrations can now carry non-secret environment as static
39 configuration, separate from declared secrets. The Codex plugins use it: they
40 declare no auth, and their connection is created for them.
41
42- [#1861](https://github.com/UsefulSoftwareCo/executor/pull/1861) [`4d4ad7c`](https://github.com/UsefulSoftwareCo/executor/commit/4d4ad7c1d5690bc13ad37d9cdadf3775e464a3f5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Stdio MCP servers are kept alive between tool calls**
43
44 Every tool call on a stdio MCP integration used to spawn a fresh child process, run the full MCP handshake, call the one tool, and tear the child down — roughly a second of overhead per call for an `npx`-launched server, on every call. Remote and app-server connections already reused sessions through the connection pool; plain stdio now joins them, with the same five-minute idle window, the same hashed identity key (command, args, cwd, secret env, credential values, owner and connection all separate identities), and the same drop-on-transport-failure semantics. This matches how MCP clients drive stdio servers generally: one long-lived child per session, not one per call.
45
46 A server that genuinely depends on fresh-process semantics can opt out with `spawnPerCall: true` in its stdio config (also accepted by the add-server API). The Codex app-server bridge ignores the opt-out — its approvals are session state, so it must pool.
47
48- Updated dependencies []:
49 - @executor-js/sdk@1.6.5
50 - @executor-js/config@1.6.5
51 - @executor-js/api@1.4.68
52 - @executor-js/react@1.4.68
53
3## 1.6.454## 1.6.4
4 55
5### Patch Changes56### Patch Changes
packages/plugins/mcp/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-mcp",2 "name": "@executor-js/plugin-mcp",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/mcp/src/api/group.ts+37−0
@@ -36,6 +36,11 @@ const AddRemoteServerPayload = Schema.Struct({ · 35 unmodified lines
36 description: Schema.optional(Schema.String),36 description: Schema.optional(Schema.String),
37 endpoint: Schema.String,37 endpoint: Schema.String,
38 remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])),38 remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])),
39 /** Pin legacy protocol negotiation for a server that echoes the modern
40 * revision but breaks its contract (the probe reports when only legacy
41 * worked). Omitting this here silently stripped the pin from the stored
42 * integration, leaving it unusable against exactly that server. */
43 versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])),
39 slug: Schema.optional(Schema.String),44 slug: Schema.optional(Schema.String),
40 queryParams: Schema.optional(StringMap),45 queryParams: Schema.optional(StringMap),
41 headers: Schema.optional(StringMap),46 headers: Schema.optional(StringMap),
@@ -58,6 +63,9 @@ const AddStdioServerPayload = Schema.Struct({ · 16 unmodified lines
58 envVars: Schema.optional(Schema.Array(Schema.String)),63 envVars: Schema.optional(Schema.Array(Schema.String)),
59 /** One-shot secret env values (programmatic). The UI sends `envVars`. */64 /** One-shot secret env values (programmatic). The UI sends `envVars`. */
60 env: Schema.optional(StringMap),65 env: Schema.optional(StringMap),
66 /** Non-secret environment stored on the integration and injected at spawn.
67 * Unlike `env`, nothing here becomes a credential the user must type. */
68 staticEnv: Schema.optional(StringMap),
61 cwd: Schema.optional(Schema.String),69 cwd: Schema.optional(Schema.String),
62 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec70 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec
63 * 2026-07-28) for modern-only servers; default is the legacy `initialize`71 * 2026-07-28) for modern-only servers; default is the legacy `initialize`
@@ -73,6 +81,7 @@ const AddStdioServerPayload = Schema.Struct({ · 9 unmodified lines
73 server: Schema.String,81 server: Schema.String,
74 surface: Schema.optional(Schema.Literals(["sky", "browser"])),82 surface: Schema.optional(Schema.Literals(["sky", "browser"])),
75 modulePath: Schema.optional(Schema.String),83 modulePath: Schema.optional(Schema.String),
84 presetId: Schema.optional(Schema.String),
76 }),85 }),
77 ),86 ),
78 slug: Schema.optional(Schema.String),87 slug: Schema.optional(Schema.String),
@@ -97,6 +106,12 @@ const ProbeEndpointResponse = Schema.Struct({ · 18 unmodified lines
97 serverName: Schema.NullOr(Schema.String),106 serverName: Schema.NullOr(Schema.String),
98 /** Server `instructions` from initialize — prefills the description field. */107 /** Server `instructions` from initialize — prefills the description field. */
99 instructions: Schema.NullOr(Schema.String),108 instructions: Schema.NullOr(Schema.String),
109 /** Which protocol negotiation worked, when discovery succeeded. `legacy`
110 * means the server echoes the modern revision but breaks its contract, and
111 * the add must pin `versionNegotiation: "legacy"` on the integration —
112 * omitting this field here silently stripped it from the HTTP response and
113 * the pin never happened. */
114 versionNegotiation: Schema.optional(Schema.Literals(["auto", "legacy"])),
100});115});
101 116
102// ---------------------------------------------------------------------------117// ---------------------------------------------------------------------------
@@ -163,6 +178,7 @@ const CodexPluginEntrySchema = Schema.Struct({ · 60 unmodified lines
163 server: Schema.String,178 server: Schema.String,
164 surface: Schema.optional(Schema.Literals(["sky", "browser"])),179 surface: Schema.optional(Schema.Literals(["sky", "browser"])),
165 modulePath: Schema.optional(Schema.String),180 modulePath: Schema.optional(Schema.String),
181 presetId: Schema.optional(Schema.String),
166 }),182 }),
167 ),183 ),
168 setupHint: Schema.optional(Schema.String),184 setupHint: Schema.optional(Schema.String),
@@ -176,6 +192,20 @@ const CodexPluginEntrySchema = Schema.Struct({ · 7 unmodified lines
176 description: Schema.optional(Schema.String),192 description: Schema.optional(Schema.String),
177});193});
178 194
195/** The result of actually trying the plugin, not a reading of any privacy
196 * database — macOS exposes no way to read another app's decisions. */
197const CodexPluginAccessResponse = Schema.Struct({
198 status: Schema.Literals([
199 "ok",
200 "blocked",
201 "not-installed",
202 "nothing-to-check",
203 "unknown",
204 "unsupported",
205 ]),
206 message: Schema.optional(Schema.String),
207});
208
179const ListCodexPluginsResponse = Schema.Struct({209const ListCodexPluginsResponse = Schema.Struct({
180 plugins: Schema.Array(CodexPluginEntrySchema),210 plugins: Schema.Array(CodexPluginEntrySchema),
181});211});
@@ -252,6 +282,13 @@ export const McpGroup = HttpApiGroup.make("mcp") · 70 unmodified lines
252 error: [InternalError],282 error: [InternalError],
253 }),283 }),
254 )284 )
285 .add(
286 HttpApiEndpoint.post("checkCodexPluginAccess", "/mcp/codex-plugins/:id/check", {
287 params: { id: Schema.String },
288 success: CodexPluginAccessResponse,
289 error: [InternalError],
290 }),
291 )
255 .add(292 .add(
256 HttpApiEndpoint.get("getCodexPluginIcon", "/mcp/codex-plugins/:id/icon", {293 HttpApiEndpoint.get("getCodexPluginIcon", "/mcp/codex-plugins/:id/icon", {
257 params: { id: Schema.String },294 params: { id: Schema.String },
packages/plugins/mcp/src/api/handlers.test.ts+34−0
@@ -31,6 +31,7 @@ const failingExtension: McpPluginExtension = { · 30 unmodified lines
31 configureServer: () => unused,31 configureServer: () => unused,
32 configureAuth: () => unused,32 configureAuth: () => unused,
33 listCodexPlugins: () => Effect.succeed([]),33 listCodexPlugins: () => Effect.succeed([]),
34 checkCodexPluginAccess: () => Effect.succeed({ status: "unknown" as const }),
34};35};
35 36
36const Api = addGroup(McpGroup);37const Api = addGroup(McpGroup);
@@ -120,4 +121,37 @@ describe("McpHandlers", () => { · 83 unmodified lines
120 expect(body.message).toContain("Do you need to provide an API key");121 expect(body.message).toContain("Do you need to provide an API key");
121 }),122 }),
122 );123 );
124
125 it.effect("the remote add carries versionNegotiation through to the extension", () =>
126 Effect.gen(function* () {
127 // The probe's legacy retry is pointless if the pin dies at the HTTP
128 // boundary: this exact field was silently stripped by the payload
129 // schema and the handler's explicit field map.
130 let received: unknown;
131 const web = yield* webHandlerFor({
132 ...failingExtension,
133 addServer: (input) => {
134 received = input;
135 return Effect.succeed({ slug: "pinned", tools: [] } as never);
136 },
137 });
138 const response = yield* Effect.promise(() =>
139 (web.handler as (request: Request) => Promise<Response>)(
140 new Request("http://localhost/mcp/servers", {
141 method: "POST",
142 headers: { "content-type": "application/json" },
143 body: JSON.stringify({
144 transport: "remote",
145 name: "Echoing server",
146 endpoint: "https://example.com/mcp",
147 versionNegotiation: "legacy",
148 authenticationTemplate: [{ kind: "none" }],
149 }),
150 }),
151 ),
152 );
153 expect(response.status).toBeLessThan(500);
154 expect((received as { versionNegotiation?: string }).versionNegotiation).toBe("legacy");
155 }),
156 );
123});157});
packages/plugins/mcp/src/api/handlers.ts+18−1
@@ -39,10 +39,16 @@ const toServerInput = ( · 38 unmodified lines
39 args?: readonly string[];39 args?: readonly string[];
40 envVars?: readonly string[];40 envVars?: readonly string[];
41 env?: Record<string, string>;41 env?: Record<string, string>;
42 staticEnv?: Record<string, string>;
42 cwd?: string;43 cwd?: string;
43 versionNegotiation?: "legacy" | "auto";44 versionNegotiation?: "legacy" | "auto";
44 spawnPerCall?: boolean;45 spawnPerCall?: boolean;
45 appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string };46 appServer?: {
47 server: string;
48 surface?: "sky" | "browser";
49 modulePath?: string;
50 presetId?: string;
51 };
46 slug?: string;52 slug?: string;
47 };53 };
48 return {54 return {
@@ -54,6 +60,7 @@ const toServerInput = ( · 5 unmodified lines
54 args: p.args ? [...p.args] : undefined,60 args: p.args ? [...p.args] : undefined,
55 envVars: p.envVars ? [...p.envVars] : undefined,61 envVars: p.envVars ? [...p.envVars] : undefined,
56 env: p.env,62 env: p.env,
63 staticEnv: p.staticEnv,
57 cwd: p.cwd,64 cwd: p.cwd,
58 versionNegotiation: p.versionNegotiation,65 versionNegotiation: p.versionNegotiation,
59 spawnPerCall: p.spawnPerCall,66 spawnPerCall: p.spawnPerCall,
@@ -69,6 +76,7 @@ const toServerInput = ( · 9 unmodified lines
69 description?: string;76 description?: string;
70 endpoint: string;77 endpoint: string;
71 remoteTransport?: "streamable-http" | "sse" | "auto";78 remoteTransport?: "streamable-http" | "sse" | "auto";
79 versionNegotiation?: "auto" | "legacy";
72 queryParams?: Record<string, string>;80 queryParams?: Record<string, string>;
73 headers?: Record<string, string>;81 headers?: Record<string, string>;
74 slug?: string;82 slug?: string;
@@ -85,6 +93,7 @@ const toServerInput = ( · 10 unmodified lines
85 description: p.description,93 description: p.description,
86 endpoint: p.endpoint,94 endpoint: p.endpoint,
87 remoteTransport: p.remoteTransport,95 remoteTransport: p.remoteTransport,
96 versionNegotiation: p.versionNegotiation,
88 queryParams: p.queryParams,97 queryParams: p.queryParams,
89 headers: p.headers,98 headers: p.headers,
90 slug: p.slug,99 slug: p.slug,
@@ -175,6 +184,14 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 84 unmodified lines
175 }),184 }),
176 ),185 ),
177 )186 )
187 .handle("checkCodexPluginAccess", ({ params }) =>
188 capture(
189 Effect.gen(function* () {
190 const ext = yield* McpExtensionService;
191 return yield* ext.checkCodexPluginAccess(params.id);
192 }),
193 ),
194 )
178 .handle("getCodexPluginIcon", ({ params }) =>195 .handle("getCodexPluginIcon", ({ params }) =>
179 capture(196 capture(
180 Effect.gen(function* () {197 Effect.gen(function* () {
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+32−25
@@ -37,10 +37,15 @@ import { · 36 unmodified lines
37import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";37import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
38import type { McpAuthMethodInput } from "../sdk/types";38import type { McpAuthMethodInput } from "../sdk/types";
39import { probeMcpEndpoint, addMcpServer } from "./atoms";39import { probeMcpEndpoint, addMcpServer } from "./atoms";
40import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
40import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";41import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";42import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";43import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";44import {
45 mcpAuthMethodInputFromEditorValue,
46 mcpDetectedAuthSeeds,
47 mcpWireAuthInput,
48} from "./auth-method-config";
44import CodexPluginAdd from "./CodexPluginAdd";49import CodexPluginAdd from "./CodexPluginAdd";
45import { parseStdioArgs } from "./stdio-fields";50import { parseStdioArgs } from "./stdio-fields";
46import { isProbableMcpEndpoint } from "./probe-url";51import { isProbableMcpEndpoint } from "./probe-url";
@@ -79,6 +84,8 @@ type ProbeResult = { · 32 unmodified lines
79 toolCount: number | null;84 toolCount: number | null;
80 serverName: string | null;85 serverName: string | null;
81 instructions: string | null;86 instructions: string | null;
87 /** "legacy" when only the legacy handshake worked (version-echoing server). */
88 versionNegotiation?: "auto" | "legacy";
82};89};
83 90
84type State =91type State =
@@ -161,6 +168,9 @@ export default function AddMcpIntegration(props: { · 76 unmodified lines
161 onCancel: () => void;168 onCancel: () => void;
162 initialUrl?: string;169 initialUrl?: string;
163 initialPreset?: string;170 initialPreset?: string;
171 initialAuthHeader?: string;
172 initialAuthNote?: string;
173 initialAuthKind?: string;
164 /** Whether the stdio transport is enabled on the server. */174 /** Whether the stdio transport is enabled on the server. */
165 allowStdio?: boolean;175 allowStdio?: boolean;
166}) {176}) {
@@ -219,29 +229,16 @@ export default function AddMcpIntegration(props: { · 52 unmodified lines
219 // The probe seeds the method list: detected OAuth → an OAuth row; a 401229 // The probe seeds the method list: detected OAuth → an OAuth row; a 401
220 // without OAuth metadata → a bearer-header row; an open server → a no-auth230 // without OAuth metadata → a bearer-header row; an open server → a no-auth
221 // row. The user can edit any row or add alternate methods alongside.231 // row. The user can edit any row or add alternate methods alongside.
222 const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(() => {232 const authMethodSeeds: readonly AuthMethodSeed[] = useMemo(
223 if (!probe) return [];233 () =>
224 if (probe.requiresOAuth) {234 mcpDetectedAuthSeeds(probe, {
225 return [235 placement: props.initialAuthHeader
226 {236 ? placementFromHeaderPattern(props.initialAuthHeader)
227 value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] },237 : null,
228 label: "Detected",238 kind: props.initialAuthKind,
229 },239 }),
230 ];240 [probe, props.initialAuthHeader, props.initialAuthKind],
231 }241 );
232 if (probe.requiresAuthentication) {
233 return [
234 {
235 value: {
236 kind: "apikey",
237 placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
238 },
239 label: "Detected",
240 },
241 ];
242 }
243 return [{ value: { kind: "none" }, label: "Detected" }];
244 }, [probe]);
245 const authMethodList = useAuthMethodList(authMethodSeeds);242 const authMethodList = useAuthMethodList(authMethodSeeds);
246 243
247 const remoteIdentity = useIntegrationIdentity({244 const remoteIdentity = useIntegrationIdentity({
@@ -337,6 +334,12 @@ export default function AddMcpIntegration(props: { · 89 unmodified lines
337 ...(slug ? { slug } : {}),334 ...(slug ? { slug } : {}),
338 ...(headers ? { headers } : {}),335 ...(headers ? { headers } : {}),
339 authenticationTemplate,336 authenticationTemplate,
337 // The probe reports when only legacy negotiation worked (a server
338 // that echoes the modern revision but breaks its contract); pin it
339 // so refreshes and tool calls use the same handshake.
340 ...(probe?.versionNegotiation === "legacy"
341 ? { versionNegotiation: "legacy" as const }
342 : {}),
340 },343 },
341 reactivityKeys: integrationWriteKeys,344 reactivityKeys: integrationWriteKeys,
342 });345 });
@@ -496,10 +499,14 @@ export default function AddMcpIntegration(props: { · 153 unmodified lines
496 title="How does this server authenticate?"499 title="How does this server authenticate?"
497 oauthMetadata="discovered"500 oauthMetadata="discovered"
498 emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later."501 emptyHint="No methods declared. Add a method, or add the server without auth and connect from the integration page later."
499 footerHint="Every method here is registered with the server. Connect an account from the integration page after adding."502 footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page."
500 />503 />
501 )}504 )}
502 505
506 {probe && props.initialAuthNote ? (
507 <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
508 ) : null}
509
503 {/* Error (add server). Probe errors show inline on the field. */}510 {/* Error (add server). Probe errors show inline on the field. */}
504 {otherError && (511 {otherError && (
505 <div className="space-y-2">512 <div className="space-y-2">
packages/plugins/mcp/src/react/CodexPluginAdd.tsx+130−5
@@ -1,4 +1,4 @@
1import { useState } from "react";1import { useEffect, useRef, useState } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -9,7 +9,9 @@ import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; · 4 unmodified lines
9import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";9import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
10import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";10import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";
11 11
12import { addMcpServer, codexPluginsAtom } from "./atoms";12import { addMcpServer, checkCodexPluginAccess, codexPluginsAtom } from "./atoms";
13import { accessBlocked, accessPending, type CodexAccessState } from "./codex-access-gate";
14import { CODEX_PERMISSIONS } from "../sdk/codex-permissions";
13 15
14// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
15// Focused add screen for one Codex plugin, reached from its catalog preset17// Focused add screen for one Codex plugin, reached from its catalog preset
@@ -27,19 +29,35 @@ export default function CodexPluginAdd(props: { · 11 unmodified lines
27 const pluginsResult = useAtomValue(codexPluginsAtom);29 const pluginsResult = useAtomValue(codexPluginsAtom);
28 const integrationsResult = useAtomValue(integrationsOptimisticAtom);30 const integrationsResult = useAtomValue(integrationsOptimisticAtom);
29 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });31 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
32 const doCheckAccess = useAtomSet(checkCodexPluginAccess, { mode: "promiseExit" });
30 33
31 const [adding, setAdding] = useState(false);34 const [adding, setAdding] = useState(false);
35 const [access, setAccess] = useState<CodexAccessState | null>(null);
36 const [checking, setChecking] = useState(false);
32 const [error, setError] = useState<string | null>(null);37 const [error, setError] = useState<string | null>(null);
33 38
34 const plugin = AsyncResult.isSuccess(pluginsResult)39 const plugin = AsyncResult.isSuccess(pluginsResult)
35 ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId)40 ? pluginsResult.value.plugins.find((entry) => entry.id === props.presetId)
36 : undefined;41 : undefined;
37 42
43 const permissions = CODEX_PERMISSIONS[props.presetId] ?? [];
44
38 const added =45 const added =
39 plugin !== undefined &&46 plugin !== undefined &&
40 AsyncResult.isSuccess(integrationsResult) &&47 AsyncResult.isSuccess(integrationsResult) &&
41 integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug);48 integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug);
42 49
50 // Adding is held back until the probe says the plugin can actually run: an
51 // integration added while macOS is blocking it looks connected and fails on
52 // its first real call, by which point the person has left the one card that
53 // explains the fix.
54 const blocked = accessBlocked(access);
55 const pending = accessPending({
56 checking,
57 declaresPermissions: permissions.length > 0,
58 access,
59 });
60
43 const handleAdd = async () => {61 const handleAdd = async () => {
44 if (plugin === undefined) return;62 if (plugin === undefined) return;
45 setAdding(true);63 setAdding(true);
@@ -53,7 +71,11 @@ export default function CodexPluginAdd(props: { · 7 unmodified lines
53 command: plugin.command,71 command: plugin.command,
54 args: [...plugin.args],72 args: [...plugin.args],
55 ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}),73 ...(plugin.cwd !== undefined ? { cwd: plugin.cwd } : {}),
56 ...(plugin.env !== undefined ? { env: { ...plugin.env } } : {}),74 // As STATIC env, not `env`: these are machine-derived paths the
75 // scanner already resolved, so they must not become credentials the
76 // person is asked to type. Sent this way the integration declares no
77 // auth, and its connection is created for them.
78 ...(plugin.env !== undefined ? { staticEnv: { ...plugin.env } } : {}),
57 ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}),79 ...(plugin.appServer !== undefined ? { appServer: { ...plugin.appServer } } : {}),
58 },80 },
59 reactivityKeys: integrationWriteKeys,81 reactivityKeys: integrationWriteKeys,
@@ -66,6 +88,37 @@ export default function CodexPluginAdd(props: { · 6 unmodified lines
66 props.onComplete(exit.value.slug);88 props.onComplete(exit.value.slug);
67 };89 };
68 90
91 // Checked on open rather than on a button, so the card states where you
92 // stand before you commit to anything. It runs the plugin's read-only probe
93 // once per card: macOS offers no way to READ another app's privacy
94 // decisions, so trying it is the only way to know.
95 const checkedFor = useRef<string | null>(null);
96 useEffect(() => {
97 if (!plugin?.available) return;
98 if (checkedFor.current === props.presetId) return;
99 checkedFor.current = props.presetId;
100 void handleCheck();
101 // `handleCheck` is stable for a given preset; re-running on its identity
102 // would re-probe on every render.
103 // eslint-disable-next-line react-hooks/exhaustive-deps
104 }, [plugin?.available, props.presetId]);
105
106 const handleCheck = async () => {
107 setChecking(true);
108 setAccess(null);
109 // Runs the plugin's own read-only probe down the real path. When macOS has
110 // not asked yet, THIS is what makes it ask — so the check doubles as the
111 // grant flow.
112 // Reads nothing and writes nothing, so no cache is invalidated by it.
113 const exit = await doCheckAccess({ params: { id: props.presetId }, reactivityKeys: [] });
114 setAccess(
115 Exit.isSuccess(exit)
116 ? (exit.value as CodexAccessState)
117 : { status: "blocked", message: "Could not reach the plugin." },
118 );
119 setChecking(false);
120 };
121
69 if (!AsyncResult.isSuccess(pluginsResult)) {122 if (!AsyncResult.isSuccess(pluginsResult)) {
70 return (123 return (
71 <div className="flex flex-1 flex-col gap-6">124 <div className="flex flex-1 flex-col gap-6">
@@ -149,6 +202,73 @@ export default function CodexPluginAdd(props: { · 77 unmodified lines
149 )}202 )}
150 </div>203 </div>
151 204
205 {/* Stated up front, not discovered on first failure: macOS asks for
206 these ONCE, and a dismissed prompt never returns — after that the
207 plugin fails with an error the user cannot act on. Each row links
208 straight to the pane that holds the switch. */}
209 {permissions.length > 0 && (
210 <div className="flex flex-col gap-2 rounded-lg border border-border px-3 py-2.5">
211 <span className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
212 macOS access
213 </span>
214 <p className="text-[12px] text-muted-foreground">
215 {blocked
216 ? "This has to be on before the plugin can be added — turn it on below, then check again."
217 : "macOS asks the first time this runs. If you miss the prompt, it will not ask again — enable it here."}
218 </p>
219 <div className="flex items-center gap-3">
220 <span
221 className={
222 access !== null && access.status !== "ok"
223 ? "text-[12px] text-destructive"
224 : "text-[12px] text-muted-foreground"
225 }
226 >
227 {checking
228 ? "Checking…"
229 : access === null
230 ? ""
231 : access.status === "ok"
232 ? "Allowed — macOS is letting this through."
233 : (access.message ?? "Blocked.")}
234 </span>
235 {access !== null && access.status !== "ok" && !checking && (
236 <Button type="button" variant="secondary" onClick={() => void handleCheck()}>
237 Check again
238 </Button>
239 )}
240 </div>
241 <ul className="flex flex-col gap-1.5">
242 {permissions.map((permission) => (
243 <li key={permission.id} className="flex items-baseline justify-between gap-3">
244 <span className="text-[12px] text-muted-foreground">
245 <span className="text-foreground">{permission.label}</span> — {permission.why}
246 </span>
247 <a
248 href={permission.settingsUrl}
249 className="shrink-0 font-mono text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
250 >
251 Open settings
252 </a>
253 </li>
254 ))}
255 </ul>
256 </div>
257 )}
258
259 {/* A block on a plugin that declares no macOS access has no panel above
260 to carry it, so state the reason next to the action it is holding. */}
261 {blocked && permissions.length === 0 && (
262 <div className="flex items-center gap-3">
263 <p className="text-[12px] text-destructive">
264 {access?.message ?? "This plugin cannot run yet."}
265 </p>
266 <Button type="button" variant="secondary" onClick={() => void handleCheck()}>
267 Check again
268 </Button>
269 </div>
270 )}
271
152 {error !== null && <p className="text-[12px] text-destructive">{error}</p>}272 {error !== null && <p className="text-[12px] text-destructive">{error}</p>}
153 273
154 <FloatActions>274 <FloatActions>
@@ -160,8 +280,13 @@ export default function CodexPluginAdd(props: { · 5 unmodified lines
160 View integration280 View integration
161 </Button>281 </Button>
162 ) : plugin.available ? (282 ) : plugin.available ? (
163 <Button type="button" onClick={() => void handleAdd()} loading={adding}>283 <Button
164 Add integration284 type="button"
285 onClick={() => void handleAdd()}
286 loading={adding}
287 disabled={blocked || pending}
288 >
289 {pending ? "Checking access…" : "Add integration"}
165 </Button>290 </Button>
166 ) : (291 ) : (
167 <Button asChild>292 <Button asChild>
packages/plugins/mcp/src/react/atoms.ts+1−0
@@ -29,6 +29,7 @@ export const codexPluginsAtom = McpClient.query("mcp", "listCodexPlugins", { · 28 unmodified lines
29 timeToLive: "15 seconds",29 timeToLive: "15 seconds",
30});30});
31 31
32export const checkCodexPluginAccess = McpClient.mutation("mcp", "checkCodexPluginAccess");
32export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint");33export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint");
33export const addMcpServer = McpClient.mutation("mcp", "addServer");34export const addMcpServer = McpClient.mutation("mcp", "addServer");
34export const removeMcpServer = McpClient.mutation("mcp", "removeServer");35export const removeMcpServer = McpClient.mutation("mcp", "removeServer");
packages/plugins/mcp/src/react/auth-method-config.ts+53−0
@@ -9,6 +9,7 @@ · 8 unmodified lines
9 9
10import { AuthTemplateSlug } from "@executor-js/sdk/shared";10import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";11import type { AuthTemplateEditorValue } from "@executor-js/react/components/auth-template-editor";
12import type { AuthMethodSeed } from "@executor-js/react/components/auth-method-list-editor";
12import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";13import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
13import {14import {
14 authMethodFromSharedTemplate,15 authMethodFromSharedTemplate,
@@ -126,3 +127,55 @@ export function mcpAuthMethodInputsFromPlacements( · 111 unmodified lines
126 if (wire.length === 0) return [];127 if (wire.length === 0) return [];
127 return [{ kind: "apikey", placements: wire }];128 return [{ kind: "apikey", placements: wire }];
128}129}
130
131/** The auth methods a registry-driven MCP add declares before any editing:
132 * the probe's detection plus the registry's declared facts. ONE policy for
133 * both the add page (as editor seeds) and the one-click quick add (mapped
134 * straight to wire inputs) — two copies drifted is how the picker and the
135 * add page end up declaring different methods for the same server. */
136export function mcpDetectedAuthSeeds(
137 probe: {
138 readonly requiresOAuth: boolean;
139 readonly requiresAuthentication: boolean;
140 } | null,
141 registry: {
142 readonly placement?: Placement | null;
143 readonly kind?: string | undefined;
144 },
145): readonly AuthMethodSeed[] {
146 const registryPlacement = registry.placement ?? null;
147 if (!probe) {
148 // No probe result (pending, or the server was unreachable from here).
149 // The registry's declared facts still stand: an authless server or a
150 // known header pattern seeds the list the probe would have produced.
151 if (registryPlacement) return [{ value: { kind: "apikey", placements: [registryPlacement] } }];
152 if (registry.kind === "none") return [{ value: { kind: "none" } }];
153 return [];
154 }
155 if (probe.requiresOAuth) {
156 const oauth: AuthMethodSeed = {
157 value: { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] },
158 label: "Detected",
159 };
160 // GitHub's MCP server takes a PAT bearer header in clients without
161 // OAuth; when the registry declared that placement, offer it alongside.
162 return registryPlacement
163 ? [oauth, { value: { kind: "apikey", placements: [registryPlacement] } }]
164 : [oauth];
165 }
166 if (probe.requiresAuthentication) {
167 // The registry's exact placement beats the generic Bearer guess.
168 return [
169 {
170 value: {
171 kind: "apikey",
172 placements: [
173 registryPlacement ?? { carrier: "header", name: "Authorization", prefix: "Bearer " },
174 ],
175 },
176 label: "Detected",
177 },
178 ];
179 }
180 return [{ value: { kind: "none" }, label: "Detected" }];
181}
packages/plugins/mcp/src/react/codex-access-gate.test.ts+51−0
@@ -0,0 +1,51 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { accessBlocked, accessPending } from "./codex-access-gate";
4
5describe("accessBlocked", () => {
6 it("holds the add only on a refusal", () => {
7 expect(accessBlocked({ status: "blocked", message: "macOS blocked this" })).toBe(true);
8 });
9
10 it("lets through every answer that is not a refusal", () => {
11 // `unsupported` and `nothing-to-check` are answers about the HOST and the
12 // PLUGIN, not about permission. Treating either as a block would strand a
13 // plugin that needs no grant at all.
14 for (const status of ["ok", "unsupported", "nothing-to-check", "unknown", "not-installed"]) {
15 expect(accessBlocked({ status })).toBe(false);
16 }
17 });
18
19 it("does not hold before an answer exists", () => {
20 // `accessPending` owns that window; a card without permissions has no
21 // probe to wait for and must not be blocked by its own silence.
22 expect(accessBlocked(null)).toBe(false);
23 });
24});
25
26describe("accessPending", () => {
27 it("waits while the probe runs", () => {
28 expect(accessPending({ checking: true, declaresPermissions: true, access: null })).toBe(true);
29 // Still pending on a re-check, even though a previous answer is in hand:
30 // that answer is what the user just acted on.
31 expect(
32 accessPending({ checking: true, declaresPermissions: true, access: { status: "blocked" } }),
33 ).toBe(true);
34 });
35
36 it("waits for the first answer on a plugin that declares permissions", () => {
37 expect(accessPending({ checking: false, declaresPermissions: true, access: null })).toBe(true);
38 });
39
40 it("does not wait once an answer has arrived", () => {
41 expect(
42 accessPending({ checking: false, declaresPermissions: true, access: { status: "ok" } }),
43 ).toBe(false);
44 });
45
46 it("does not wait when nothing will be checked", () => {
47 expect(accessPending({ checking: false, declaresPermissions: false, access: null })).toBe(
48 false,
49 );
50 });
51});
packages/plugins/mcp/src/react/codex-access-gate.ts+43−0
@@ -0,0 +1,43 @@
1// ---------------------------------------------------------------------------
2// When the Codex plugin card may hand over its Add button.
3//
4// The probe behind this is the only way to know whether macOS will let the
5// plugin run: privacy decisions are not readable, so the card tries the real
6// path and reads the answer. Adding before that answer arrives produces an
7// integration that looks connected and fails on its first call, at a point
8// where the explanation — and the switch that fixes it — are no longer on
9// screen.
10// ---------------------------------------------------------------------------
11
12/** The `checkCodexPluginAccess` result, as the card holds it. */
13export interface CodexAccessState {
14 readonly status: string;
15 readonly message?: string;
16}
17
18/**
19 * The plugin was reached and refused.
20 *
21 * Only `blocked` counts. `unsupported` (a host that cannot spawn stdio at all)
22 * and `nothing-to-check` (a plugin with no probe) are answers, not blocks, and
23 * holding the button on either would strand plugins that need no permission.
24 */
25export const accessBlocked = (access: CodexAccessState | null): boolean =>
26 access !== null && access.status === "blocked";
27
28/**
29 * The answer is still coming.
30 *
31 * A card that declares permissions runs the probe on open, so `null` there
32 * means the check has not reported yet rather than that nothing will check.
33 * Without permissions there is nothing to wait for, and the button stays live.
34 */
35export const accessPending = ({
36 checking,
37 declaresPermissions,
38 access,
39}: {
40 readonly checking: boolean;
41 readonly declaresPermissions: boolean;
42 readonly access: CodexAccessState | null;
43}): boolean => checking || (declaresPermissions && access === null);
packages/plugins/mcp/src/react/integration-plugin.tsx+66−3
@@ -1,6 +1,21 @@
1import { lazy, type ComponentProps, type ComponentType } from "react";1import { lazy, useCallback, type ComponentProps, type ComponentType } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import type {
5 IntegrationPlugin,
6 IntegrationQuickAddInput,
7 IntegrationQuickAddResult,
8} from "@executor-js/sdk/client";
9import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
10import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
11import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3import { mcpPresets } from "../sdk/presets";12import { mcpPresets } from "../sdk/presets";
13import { addMcpServer, probeMcpEndpoint } from "./atoms";
14import {
15 mcpAuthMethodInputFromEditorValue,
16 mcpDetectedAuthSeeds,
17 mcpWireAuthInput,
18} from "./auth-method-config";
4 19
5const importAdd = () => import("./AddMcpIntegration");20const importAdd = () => import("./AddMcpIntegration");
6const importEditSheet = () => import("./EditMcpIntegration");21const importEditSheet = () => import("./EditMcpIntegration");
@@ -12,6 +27,51 @@ const LazyMcpAccountsPanel = lazy(importAccounts); · 5 unmodified lines
12 27
13type AddProps = ComponentProps<IntegrationPlugin["add"]>;28type AddProps = ComponentProps<IntegrationPlugin["add"]>;
14 29
30/** One-click add for a registry MCP row: probe, declare the auth methods the
31 * add page would have seeded (same policy — `mcpDetectedAuthSeeds`), and
32 * register. A failed probe means the configuration screen's job — it renders
33 * the failure with retry UX — so quick add reports `{ok: false}` instead of
34 * guessing. */
35function useMcpQuickAdd(): (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult> {
36 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
37 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
38 return useCallback(
39 async (input) => {
40 const probeExit = await doProbe({ payload: { endpoint: input.url } });
41 if (Exit.isFailure(probeExit)) return { ok: false, reason: "probe failed" };
42 const probe = probeExit.value;
43 const seeds = mcpDetectedAuthSeeds(probe, {
44 placement: input.authHeader ? placementFromHeaderPattern(input.authHeader) : null,
45 kind: input.authKind,
46 });
47 const methods = seeds.map((seed) =>
48 mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(seed.value)),
49 );
50 const slug = input.slug ? slugifyNamespace(input.slug) : undefined;
51 const addExit = await doAddServer({
52 payload: {
53 transport: "remote" as const,
54 name: input.name,
55 endpoint: input.url,
56 ...(slug ? { slug } : {}),
57 ...(probe.instructions ? { description: probe.instructions } : {}),
58 authenticationTemplate: methods.length > 0 ? methods : [{ kind: "none" as const }],
59 // The probe reports when only legacy negotiation worked (a server
60 // that echoes the modern revision but breaks its contract); pin it
61 // so refreshes and tool calls use the same handshake.
62 ...(probe.versionNegotiation === "legacy"
63 ? { versionNegotiation: "legacy" as const }
64 : {}),
65 },
66 reactivityKeys: integrationWriteKeys,
67 });
68 if (Exit.isFailure(addExit)) return { ok: false, reason: "add failed" };
69 return { ok: true, slug: String(addExit.value.slug) };
70 },
71 [doProbe, doAddServer],
72 );
73}
74
15export interface McpIntegrationPluginOptions {75export interface McpIntegrationPluginOptions {
16 /**76 /**
17 * Enable the stdio transport in the add-integration UI (tab + presets).77 * Enable the stdio transport in the add-integration UI (tab + presets).
@@ -32,11 +92,13 @@ export const createMcpIntegrationPlugin = ( · 14 unmodified lines
32 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />92 <LazyAddMcpIntegration {...props} allowStdio={allowStdio} />
33 );93 );
34 94
35 const presets = allowStdio95 const base = allowStdio
36 ? mcpPresets96 ? mcpPresets
37 : mcpPresets.filter(97 : mcpPresets.filter(
38 (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"),98 (p) => !("transport" in p && (p as { transport?: string }).transport === "stdio"),
39 );99 );
100 // Built-ins are registry-listed; a deployment's custom presets would not be.
101 const presets = base.map((p) => ({ ...p, registryListed: true }));
40 102
41 return {103 return {
42 key: "mcp",104 key: "mcp",
@@ -50,6 +112,7 @@ export const createMcpIntegrationPlugin = ( · 7 unmodified lines
50 void importEditSheet();112 void importEditSheet();
51 void importAccounts();113 void importAccounts();
52 },114 },
115 useQuickAdd: useMcpQuickAdd,
53 };116 };
54};117};
55 118
packages/plugins/mcp/src/sdk/appserver-connector.test.ts+23−1
@@ -23,7 +23,7 @@ const appServerInput = ( · 22 unmodified lines
23 command: "bun",23 command: "bun",
24 args: ["run", fixture],24 args: ["run", fixture],
25 env: { CODEX_HOME: "/tmp/fixture-codex-home" },25 env: { CODEX_HOME: "/tmp/fixture-codex-home" },
26 appServer: { server, ...appServer },26 appServer: { server, presetId: "codex-messages", ...appServer },
27});27});
28 28
29const withConnection = (input: StdioConnectorInput) =>29const withConnection = (input: StdioConnectorInput) =>
@@ -42,6 +42,7 @@ describe("codex app-server bridge", () => { · 12 unmodified lines
42 "announce_restart",42 "announce_restart",
43 "echo",43 "echo",
44 "needs_approval",44 "needs_approval",
45 "permission_denied",
45 ]);46 ]);
46 const echo = tools.tools.find(({ name }) => name === "echo");47 const echo = tools.tools.find(({ name }) => name === "echo");
47 expect(echo?.description).toBe("Echo the arguments back");48 expect(echo?.description).toBe("Echo the arguments back");
@@ -148,6 +149,27 @@ describe("codex app-server bridge", () => { · 100 unmodified lines
148 ),149 ),
149 );150 );
150 151
152 it.effect("turns a macOS refusal into the grant the user has to enable", () =>
153 Effect.scoped(
154 Effect.gen(function* () {
155 // The plugin says "Unknown error" and the code is scrubbed to an
156 // opaque id further up, so the one place this can be made actionable
157 // is here, while the plugin identity is still known.
158 const connection = yield* withConnection(appServerInput("messages"));
159
160 const result = yield* Effect.promise(() =>
161 connection.client.callTool({ name: "permission_denied", arguments: {} }),
162 );
163 const text = (result.content as readonly { readonly text: string }[])[0]!.text;
164
165 expect(result.isError).toBe(true);
166 expect(text, "names the block").toContain("macOS blocked this");
167 expect(text, "and the exact switch").toContain('"Executor → Messages"');
168 expect(text, "not the plugin's own wording").not.toContain("Unknown error");
169 }),
170 ),
171 );
172
151 // -------------------------------------------------------------------------173 // -------------------------------------------------------------------------
152 // Computer Use: projected onto `node_repl`, not a server of its own.174 // Computer Use: projected onto `node_repl`, not a server of its own.
153 // -------------------------------------------------------------------------175 // -------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/appserver-connector.ts+27−0
@@ -40,6 +40,7 @@ import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextpro · 39 unmodified lines
40import { Option, Schema } from "effect";40import { Option, Schema } from "effect";
41 41
42import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools";42import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools";
43import { permissionFailure, permissionFailureMessage } from "./codex-permissions";
43import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools";44import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools";
44import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector";45import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector";
45 46
@@ -79,6 +80,9 @@ export type AppServerTransportConfig = StdioTransportConfig & { · 33 unmodified lines
79 /** The MCP server name inside Codex whose tools this transport exposes80 /** The MCP server name inside Codex whose tools this transport exposes
80 * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */81 * (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */
81 readonly server: string;82 readonly server: string;
83 /** Which curated plugin this is, so a macOS permission failure can name the
84 * exact grant to enable. Absent skips that translation. */
85 readonly presetId?: string;
82 /** A projected tool surface for a plugin driven through `node_repl` rather86 /** A projected tool surface for a plugin driven through `node_repl` rather
83 * than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`),87 * than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`),
84 * `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the88 * `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the
@@ -473,6 +477,29 @@ class AppServerClientTransport implements Transport { · 388 unmodified lines
473 });477 });
474 return;478 return;
475 }479 }
480
481 // A macOS permission denial arrives as a plugin-level error result whose
482 // own text is "Unknown error" — the numeric code is the only signal, and
483 // it is scrubbed to an opaque internal id further up. Replace it here,
484 // while the plugin identity is still known, with the grant to enable.
485 if (result.value.isError === true) {
486 const text = (result.value.content ?? [])
487 .map((block) => (block as { readonly text?: unknown }).text)
488 .filter((value): value is string => typeof value === "string")
489 .join(" ");
490 const permission = permissionFailure(text, this.#config.presetId);
491 if (permission !== null) {
492 this.#emit({
493 jsonrpc: "2.0",
494 id: message.id,
495 result: {
496 content: [{ type: "text", text: permissionFailureMessage(permission) }],
497 isError: true,
498 },
499 });
500 return;
501 }
502 }
476 this.#emit({503 this.#emit({
477 jsonrpc: "2.0",504 jsonrpc: "2.0",
478 id: message.id,505 id: message.id,
packages/plugins/mcp/src/sdk/appserver-test-server.ts+14−0
@@ -62,6 +62,11 @@ const TOOLS = { · 61 unmodified lines
62 description: "Echo the arguments back",62 description: "Echo the arguments back",
63 inputSchema: { type: "object", properties: { text: { type: "string" } } },63 inputSchema: { type: "object", properties: { text: { type: "string" } } },
64 },64 },
65 permission_denied: {
66 name: "permission_denied",
67 description: "Fails the way macOS refusal presents",
68 inputSchema: { type: "object", properties: {} },
69 },
65 announce_restart: {70 announce_restart: {
66 name: "announce_restart",71 name: "announce_restart",
67 description: "Emit server status notifications",72 description: "Emit server status notifications",
@@ -224,6 +229,15 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 156 unmodified lines
224 });229 });
225 return;230 return;
226 }231 }
232 if (call.tool === "permission_denied") {
233 // Verbatim shape of a macOS TCC refusal: an error result whose only clue
234 // is the numeric code.
235 reply(id, {
236 content: [{ type: "text", text: "Computer Use server error -1743: Unknown error" }],
237 isError: true,
238 });
239 return;
240 }
227 if (call.tool === "needs_approval") {241 if (call.tool === "needs_approval") {
228 if (!elicitationsAllowed) {242 if (!elicitationsAllowed) {
229 // Exactly what Codex returns when it declines the elicitation for the243 // Exactly what Codex returns when it declines the elicitation for the
packages/plugins/mcp/src/sdk/codex-permissions.test.ts+79−0
@@ -0,0 +1,79 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 CODEX_PERMISSIONS,
5 permissionFailure,
6 permissionFailureMessage,
7} from "./codex-permissions";
8
9// ---------------------------------------------------------------------------
10// The string below is verbatim what a Codex plugin returns when macOS has
11// refused the grant — the numeric code is the ONLY signal, since the plugin's
12// own wording is "Unknown error".
13// ---------------------------------------------------------------------------
14
15const DENIED = "Computer Use server error -1743: Unknown error";
16
17describe("permissionFailure", () => {
18 it("recognises a denial behind the plugin's own 'Unknown error'", () => {
19 const permission = permissionFailure(DENIED, "codex-messages");
20
21 expect(permission?.id).toBe("automation");
22 // Automation is granted per HOST, so the entry names executor, not
23 // anything Codex ships.
24 expect(permission?.entry).toBe("Executor → Messages");
25 });
26
27 it("recognises the other codes the same denial presents as", () => {
28 for (const code of [-609, -600]) {
29 expect(permissionFailure(`server error ${code}: Unknown error`, "codex-messages")).not.toBe(
30 null,
31 );
32 }
33 });
34
35 it("leaves ordinary tool failures alone", () => {
36 expect(permissionFailure("No chat matched that name", "codex-messages")).toBe(null);
37 // A number that merely contains a code must not trip it.
38 expect(permissionFailure("read 21743 rows", "codex-messages")).toBe(null);
39 expect(permissionFailure("error -17430: other", "codex-messages")).toBe(null);
40 });
41
42 it("returns nothing to say for an unknown plugin", () => {
43 expect(permissionFailure(DENIED, undefined)).toBe(null);
44 expect(permissionFailure(DENIED, "codex-openai-docs")).toBe(null);
45 });
46});
47
48describe("permissionFailureMessage", () => {
49 it("says what was blocked, which switch to flip, and that macOS will not ask again", () => {
50 const permission = permissionFailure(DENIED, "codex-messages")!;
51 const message = permissionFailureMessage(permission);
52
53 expect(message).toContain("macOS blocked this");
54 expect(message).toContain("Privacy & Security → Automation");
55 expect(message).toContain('"Executor → Messages"');
56 expect(message).toContain("only asks once");
57 });
58});
59
60describe("CODEX_PERMISSIONS", () => {
61 it("points every entry at a real settings pane", () => {
62 for (const [preset, permissions] of Object.entries(CODEX_PERMISSIONS)) {
63 expect(permissions.length, preset).toBeGreaterThan(0);
64 for (const permission of permissions) {
65 expect(permission.settingsUrl, `${preset}/${permission.id}`).toMatch(
66 /^x-apple\.systempreferences:com\.apple\.preference\.security\?Privacy_/,
67 );
68 expect(permission.why.length, `${preset}/${permission.id}`).toBeGreaterThan(0);
69 }
70 }
71 });
72
73 it("attributes the Codex-owned grants to Codex, not to the host", () => {
74 // Screen Recording and Accessibility are held by the Computer Use app, so
75 // granting them once in Codex covers every host — the entry must say so.
76 const computerUse = CODEX_PERMISSIONS["codex-computer-use"]!;
77 expect(computerUse.map((p) => p.entry)).toEqual(["Codex Computer Use", "Codex Computer Use"]);
78 });
79});
packages/plugins/mcp/src/sdk/codex-permissions.ts+130−0
@@ -0,0 +1,130 @@
1// ---------------------------------------------------------------------------
2// macOS permissions for the Codex plugins.
3//
4// These plugins drive the real machine, so macOS gates them behind TCC. Two
5// facts shape everything here, both established by probing a live install
6// rather than from documentation:
7//
8// * The permissions do NOT all attach to the same identity. Reading Messages
9// works from a host with no Full Disk Access, because the Codex Computer
10// Use SERVICE (`com.openai.sky.CUAService`) holds that grant and does the
11// reading. But sending an Apple Event is attributed to the RESPONSIBLE
12// process — the app that launched the chain — so Automation is granted per
13// host. The same call therefore succeeds from a terminal whose app is
14// approved and fails from a desktop app that is not.
15//
16// * A denial is permanent until the user acts. macOS asks once; after that
17// `-1743` (`errAEEventNotPermitted`) comes back forever and no prompt is
18// shown again. So "try and see" is not a recovery strategy — the user has
19// to be told which entry to enable, and taken there.
20//
21// The failure this produces is otherwise indecipherable: the plugin reports
22// "server error -1743: Unknown error", which reaches the caller scrubbed to an
23// opaque internal error id. Classifying it here is what turns the most common
24// first-run failure into something a person can act on.
25// ---------------------------------------------------------------------------
26
27/** A macOS privacy pane, as a deep link `open` understands. */
28const settingsUrl = (pane: string): string =>
29 `x-apple.systempreferences:com.apple.preference.security?${pane}`;
30
31export interface CodexPermission {
32 readonly id: "automation" | "accessibility" | "screen-recording" | "contacts" | "full-disk";
33 /** What macOS calls this in System Settings. */
34 readonly label: string;
35 /** Which entry the user must find and enable there. */
36 readonly entry: string;
37 /** Why this plugin needs it, in the user's terms. */
38 readonly why: string;
39 readonly settingsUrl: string;
40}
41
42/** Automation is per-HOST: the app that spawned the chain is what macOS asks
43 * about, so the entry is executor itself rather than anything Codex ships. */
44const automation = (target: string, why: string): CodexPermission => ({
45 id: "automation",
46 label: "Automation",
47 entry: `Executor → ${target}`,
48 why,
49 settingsUrl: settingsUrl("Privacy_Automation"),
50});
51
52/** Screen Recording and Accessibility attach to the Codex Computer Use app,
53 * which is why granting them once in Codex covers every host. */
54const computerUsePermissions: readonly CodexPermission[] = [
55 {
56 id: "screen-recording",
57 label: "Screen Recording",
58 entry: "Codex Computer Use",
59 why: "so it can see the app it is operating",
60 settingsUrl: settingsUrl("Privacy_ScreenCapture"),
61 },
62 {
63 id: "accessibility",
64 label: "Accessibility",
65 entry: "Codex Computer Use",
66 why: "so it can click, type, and scroll",
67 settingsUrl: settingsUrl("Privacy_Accessibility"),
68 },
69];
70
71const messagesPermissions: readonly CodexPermission[] = [
72 automation("Messages", "so it can read your chats and send on your behalf"),
73 {
74 id: "contacts",
75 label: "Contacts",
76 entry: "Codex Computer Use",
77 why: "so it can turn names into the right phone numbers",
78 settingsUrl: settingsUrl("Privacy_Contacts"),
79 },
80];
81
82/** What each plugin needs, keyed by preset id. Absent means nothing beyond
83 * having Codex installed. */
84export const CODEX_PERMISSIONS: Readonly<Record<string, readonly CodexPermission[]>> = {
85 "codex-messages": messagesPermissions,
86 "codex-computer-use": computerUsePermissions,
87 "codex-computer-history": computerUsePermissions,
88 "codex-chrome": [automation("Google Chrome", "so it can drive your browser")],
89};
90
91// ---------------------------------------------------------------------------
92// Classification
93// ---------------------------------------------------------------------------
94
95/** Apple Event failures that mean "the user has not allowed this", as opposed
96 * to a target that is missing or busy.
97 *
98 * `-1743` is `errAEEventNotPermitted`: the grant was DECLINED, or never
99 * answered and has since defaulted closed. `-600`/`-609` are the connection
100 * errors macOS returns when it refuses to hand the sender a port at all,
101 * which is how the same denial presents on some paths. */
102const APPLE_EVENT_DENIED = [-1743, -609, -600] as const;
103
104const deniedCodePattern = new RegExp(`(?:^|[^0-9-])(${APPLE_EVENT_DENIED.join("|")})(?![0-9])`);
105
106/** A permission failure recognised in an upstream tool error, or null when the
107 * error is about something else. Matching is on the numeric code, not on
108 * wording: the plugin's own text is "Unknown error". */
109export const permissionFailure = (
110 message: string,
111 presetId: string | undefined,
112): CodexPermission | null => {
113 if (!deniedCodePattern.test(message)) return null;
114 const permissions = presetId === undefined ? [] : (CODEX_PERMISSIONS[presetId] ?? []);
115 // Automation is the one a host can actually be missing; the Codex-owned
116 // grants are shared and fail differently. Fall back to the first declared.
117 return permissions.find((permission) => permission.id === "automation") ?? permissions[0] ?? null;
118};
119
120/** The message a caller sees instead of "Unknown error".
121 *
122 * Written for whoever reads it next — a person, or a model relaying to one.
123 * It names the block, the exact entry to enable, and where, because macOS
124 * will not ask again on its own. */
125export const permissionFailureMessage = (permission: CodexPermission): string =>
126 [
127 `macOS blocked this: ${permission.label} access has not been allowed.`,
128 `Open System Settings → Privacy & Security → ${permission.label}, find "${permission.entry}", and turn it on — ${permission.why}.`,
129 "macOS only asks once, so a prompt will not appear again until it is enabled there.",
130 ].join(" ");
packages/plugins/mcp/src/sdk/codex-plugin-presets.ts+8−0
@@ -18,6 +18,10 @@ export interface CuratedCodexPlugin { · 17 unmodified lines
18 /** The MCP server name this plugin registers inside Codex — the `server`18 /** The MCP server name this plugin registers inside Codex — the `server`
19 * the app-server bridge calls tools against. */19 * the app-server bridge calls tools against. */
20 readonly server: string;20 readonly server: string;
21 /** A read-only tool that exercises this plugin's macOS permissions, used to
22 * CHECK access without changing anything. Chosen for having no side
23 * effects: listing, reading status. Absent means nothing to check. */
24 readonly probeTool?: { readonly name: string; readonly args: Readonly<Record<string, unknown>> };
21 /** A public image for this plugin, used when the machine-local icon cannot25 /** A public image for this plugin, used when the machine-local icon cannot
22 * be read (i.e. Codex is not installed here). Only some plugins have one26 * be read (i.e. Codex is not installed here). Only some plugins have one
23 * published; the rest fall back to the provider's mark. */27 * published; the rest fall back to the provider's mark. */
@@ -92,6 +96,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 68 unmodified lines
92 pluginName: "messages",96 pluginName: "messages",
93 name: "Messages",97 name: "Messages",
94 slug: "codex_messages",98 slug: "codex_messages",
99 probeTool: { name: "find_chats", args: { limit: 1 } },
95 // Served by the app itself (`packages/app/public`), because the Messages100 // Served by the app itself (`packages/app/public`), because the Messages
96 // app icon is published nowhere hotlinkable: it is a system app, absent101 // app icon is published nowhere hotlinkable: it is a system app, absent
97 // from the App Store artwork API, and `messages.apple.com` resolves to102 // from the App Store artwork API, and `messages.apple.com` resolves to
@@ -107,6 +112,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 9 unmodified lines
107 pluginName: "computer-use",112 pluginName: "computer-use",
108 name: "Computer Use",113 name: "Computer Use",
109 slug: "codex_computer_use",114 slug: "codex_computer_use",
115 probeTool: { name: "list_apps", args: {} },
110 publicIcon: "https://learn.chatgpt.com/images/codex/icons/computer-use-plugin-icon.png",116 publicIcon: "https://learn.chatgpt.com/images/codex/icons/computer-use-plugin-icon.png",
111 requires: "computer-use-app",117 requires: "computer-use-app",
112 server: "node_repl",118 server: "node_repl",
@@ -121,6 +127,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 8 unmodified lines
121 pluginName: "chrome",127 pluginName: "chrome",
122 name: "Chrome",128 name: "Chrome",
123 slug: "codex_chrome",129 slug: "codex_chrome",
130 probeTool: { name: "list_tabs", args: {} },
124 publicIcon: "https://learn.chatgpt.com/images/codex/icons/chrome-production-large.png",131 publicIcon: "https://learn.chatgpt.com/images/codex/icons/chrome-production-large.png",
125 server: "node_repl",132 server: "node_repl",
126 surface: "browser",133 surface: "browser",
@@ -145,6 +152,7 @@ export const CURATED_CODEX_PLUGINS: readonly CuratedCodexPlugin[] = [ · 18 unmodified lines
145 pluginName: "computer-history",152 pluginName: "computer-history",
146 name: "Computer History",153 name: "Computer History",
147 slug: "codex_computer_history",154 slug: "codex_computer_history",
155 probeTool: { name: "computer_history_status", args: {} },
148 requires: "computer-use-app",156 requires: "computer-use-app",
149 server: "computer-history",157 server: "computer-history",
150 summary:158 summary:
packages/plugins/mcp/src/sdk/codex-plugins.test.ts+12−5
@@ -138,12 +138,19 @@ describe("scanCodexPlugins", () => { · 137 unmodified lines
138 // Codex: both ship as node-repl content, so they target `node_repl` with a138 // Codex: both ship as node-repl content, so they target `node_repl` with a
139 // projected surface. Chrome additionally carries the module its surface139 // projected surface. Chrome additionally carries the module its surface
140 // imports, resolved through the version-proof `latest` symlink.140 // imports, resolved through the version-proof `latest` symlink.
141 // Each entry also carries its own preset id, so a macOS permission
142 // failure can name the exact grant that plugin needs.
141 expect(curated.map((entry) => entry.appServer)).toEqual([143 expect(curated.map((entry) => entry.appServer)).toEqual([
142 { server: "messages" },144 { presetId: "codex-messages", server: "messages" },
143 { server: "node_repl", surface: "sky" },145 { presetId: "codex-computer-use", server: "node_repl", surface: "sky" },
144 { server: "node_repl", surface: "browser", modulePath: join(home, CHROME_CLIENT_RELATIVE) },146 {
145 { server: "openaiDeveloperDocs" },147 presetId: "codex-chrome",
146 { server: "computer-history" },148 server: "node_repl",
149 surface: "browser",
150 modulePath: join(home, CHROME_CLIENT_RELATIVE),
151 },
152 { presetId: "codex-openai-docs", server: "openaiDeveloperDocs" },
153 { presetId: "codex-computer-history", server: "computer-history" },
147 ]);154 ]);
148 });155 });
149 156
packages/plugins/mcp/src/sdk/codex-plugins.ts+2−0
@@ -47,6 +47,7 @@ export interface CodexPluginEntry { · 46 unmodified lines
47 * connector bridges MCP to it in process, calling tools on this named47 * connector bridges MCP to it in process, calling tools on this named
48 * server inside Codex. See `appserver-connector.ts`. */48 * server inside Codex. See `appserver-connector.ts`. */
49 readonly appServer?: {49 readonly appServer?: {
50 readonly presetId?: string;
50 readonly server: string;51 readonly server: string;
51 readonly surface?: "sky" | "browser";52 readonly surface?: "sky" | "browser";
52 readonly modulePath?: string;53 readonly modulePath?: string;
@@ -420,6 +421,7 @@ export const scanCodexPlugins = (options?: { · 367 unmodified lines
420 args: ["app-server"],421 args: ["app-server"],
421 env: { CODEX_HOME: codexHome },422 env: { CODEX_HOME: codexHome },
422 appServer: {423 appServer: {
424 presetId: entry.id,
423 server: entry.server,425 server: entry.server,
424 ...(entry.surface === undefined ? {} : { surface: entry.surface }),426 ...(entry.surface === undefined ? {} : { surface: entry.surface }),
425 ...(entry.surface === "browser" ? { modulePath: browserClient } : {}),427 ...(entry.surface === "browser" ? { modulePath: browserClient } : {}),
packages/plugins/mcp/src/sdk/connection.ts+8−6
@@ -566,7 +566,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 565 unmodified lines
566 // bridge answers the MCP handshake itself, so `versionNegotiation` does566 // bridge answers the MCP handshake itself, so `versionNegotiation` does
567 // not apply on this path.567 // not apply on this path.
568 if (input.appServer !== undefined) {568 if (input.appServer !== undefined) {
569 const { server, surface, modulePath } = input.appServer;569 const { server, surface, modulePath, presetId } = input.appServer;
570 return Effect.gen(function* () {570 return Effect.gen(function* () {
571 const { createAppServerTransport } = yield* Effect.tryPromise({571 const { createAppServerTransport } = yield* Effect.tryPromise({
572 try: () => import("./appserver-connector"),572 try: () => import("./appserver-connector"),
@@ -588,6 +588,7 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 15 unmodified lines
588 server,588 server,
589 ...(surface === undefined ? {} : { surface }),589 ...(surface === undefined ? {} : { surface }),
590 ...(modulePath === undefined ? {} : { modulePath }),590 ...(modulePath === undefined ? {} : { modulePath }),
591 ...(presetId === undefined ? {} : { presetId }),
591 }),592 }),
592 });593 });
593 });594 });
@@ -634,13 +635,14 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 40 unmodified lines
634 635
635 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});636 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
636 637
637 // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable638 // Auto-negotiate the 2026-07-28 era on Streamable HTTP unless the config
638 // HTTP. SSE is a legacy-only transport; stdio negotiates per the639 // pins `legacy` (for servers that echo the proposed revision and then
639 // integration's `versionNegotiation` (default legacy — see the stdio640 // violate its contract). SSE is a legacy-only transport; stdio negotiates
640 // branch above).641 // per the integration's `versionNegotiation` (default legacy — see the
642 // stdio branch above).
641 const connectStreamableHttp = connectClient({643 const connectStreamableHttp = connectClient({
642 transport: "streamable-http",644 transport: "streamable-http",
643 versionNegotiation: { mode: "auto" },645 ...(input.versionNegotiation === "legacy" ? {} : { versionNegotiation: { mode: "auto" } }),
644 createTransport: (sdk) =>646 createTransport: (sdk) =>
645 new sdk.client.StreamableHTTPClientTransport(endpoint, {647 new sdk.client.StreamableHTTPClientTransport(endpoint, {
646 requestInit,648 requestInit,
packages/plugins/mcp/src/sdk/discover.ts+55−4
@@ -2,10 +2,11 @@ · 1 unmodified line
2// MCP tool discovery — connect to an MCP server and list its tools2// MCP tool discovery — connect to an MCP server and list its tools
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4 4
5import { Duration, Effect, Option, Predicate } from "effect";5import { Duration, Effect, Option, Predicate, Schema } from "effect";
6 6
7import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";7import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";
8import { McpToolDiscoveryError } from "./errors";8import { McpToolDiscoveryError } from "./errors";
9import { createMcpConnector, type ConnectorInput } from "./connection";
9import { httpStatusFromCause } from "./http-status";10import { httpStatusFromCause } from "./http-status";
10import {11import {
11 decodeListToolsPage,12 decodeListToolsPage,
@@ -34,6 +35,14 @@ const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); · 22 unmodified lines
34// Public API35// Public API
35// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
36 37
38/** The SDK rejects with an Error subclass carrying a stable `code`; decode
39 * the boundary instead of stringifying an unknown. */
40const SdkFailure = Schema.Struct({
41 code: Schema.optional(Schema.String),
42 message: Schema.optional(Schema.String),
43});
44const decodeSdkFailure = Schema.decodeUnknownOption(SdkFailure);
45
37/**46/**
38 * List every tool from an open MCP connection, following `nextCursor`47 * List every tool from an open MCP connection, following `nextCursor`
39 * pagination (spec: `tools/list` is a paginated operation — a single call48 * pagination (spec: `tools/list` is a paginated operation — a single call
@@ -68,14 +77,23 @@ const listAllTools = ( · 28 unmodified lines
68 reauthorizationRequired: true,77 reauthorizationRequired: true,
69 });78 });
70 }79 }
80 const failure = Option.getOrNull(decodeSdkFailure(cause));
81 // A modern-era connection whose server breaks the modern response
82 // contract means the server echoed our proposed revision without
83 // implementing it; callers can retry with legacy negotiation.
84 const modernContractViolation =
85 connection.client.getProtocolEra?.() === "modern" && failure?.code === "INVALID_RESULT";
71 const httpStatus = httpStatusFromCause(cause);86 const httpStatus = httpStatusFromCause(cause);
72 return new McpToolDiscoveryError({87 return new McpToolDiscoveryError({
73 stage: "list_tools",88 stage: "list_tools",
74 message:89 message:
75 httpStatus === undefined90 failure?.message !== undefined
76 ? "Failed listing MCP tools"91 ? `Failed listing MCP tools: ${failure.message.slice(0, 300)}`
77 : `Failed listing MCP tools (HTTP ${httpStatus})`,92 : httpStatus === undefined
93 ? "Failed listing MCP tools"
94 : `Failed listing MCP tools (HTTP ${httpStatus})`,
78 ...(httpStatus === undefined ? {} : { httpStatus }),95 ...(httpStatus === undefined ? {} : { httpStatus }),
96 ...(modernContractViolation ? { modernContractViolation: true } : {}),
79 });97 });
80 },98 },
81 });99 });
@@ -103,6 +121,39 @@ const listAllTools = ( · 21 unmodified lines
103 );121 );
104 });122 });
105 123
124/** Discovery that survives version-echoing servers. Some servers answer the
125 * proposed 2026-07-28 revision affirmatively while emitting 2024-era results
126 * (Walmart's MCP), which the modern client rightly rejects. On that exact
127 * signature, retry once with legacy negotiation and report which one worked,
128 * so an add flow can pin `versionNegotiation: "legacy"` on the integration. */
129export const discoverToolsFromInput = (
130 input: ConnectorInput,
131 timeoutMs?: number,
132): Effect.Effect<
133 { readonly manifest: McpToolManifest; readonly versionNegotiation: "auto" | "legacy" | null },
134 McpToolDiscoveryError
135> =>
136 discoverTools(createMcpConnector(input), timeoutMs).pipe(
137 Effect.map((manifest) => ({
138 manifest,
139 versionNegotiation: input.transport === "stdio" ? null : (input.versionNegotiation ?? "auto"),
140 })),
141 Effect.catch((error) => {
142 const retriable =
143 error.modernContractViolation === true &&
144 input.transport !== "stdio" &&
145 input.versionNegotiation !== "legacy";
146 if (!retriable) return Effect.fail(error);
147 return discoverTools(
148 createMcpConnector({ ...input, versionNegotiation: "legacy" }),
149 timeoutMs,
150 ).pipe(
151 Effect.map((manifest) => ({ manifest, versionNegotiation: "legacy" as const })),
152 Effect.withSpan("mcp.discover.legacy_retry"),
153 );
154 }),
155 );
156
106/**157/**
107 * Connect to an MCP server and discover all available tools.158 * Connect to an MCP server and discover all available tools.
108 * Returns the parsed manifest containing server metadata and tool entries.159 * Returns the parsed manifest containing server metadata and tool entries.
packages/plugins/mcp/src/sdk/errors.ts+5−0
@@ -43,6 +43,11 @@ export class McpToolDiscoveryError extends Schema.TaggedErrorClass<McpToolDiscov · 42 unmodified lines
43 /** HTTP status from the underlying connect or tools/list failure, when43 /** HTTP status from the underlying connect or tools/list failure, when
44 * known. */44 * known. */
45 httpStatus: Schema.optional(Schema.Number),45 httpStatus: Schema.optional(Schema.Number),
46 /** The connection negotiated the modern (2026-07-28) era and the server
47 * then broke that revision's response contract — the signature of a
48 * server that echoes whatever protocol version is proposed. Retrying
49 * with legacy negotiation is expected to succeed. */
50 modernContractViolation: Schema.optional(Schema.Boolean),
46 /** The MCP OAuth provider reached the interactive authorization boundary.51 /** The MCP OAuth provider reached the interactive authorization boundary.
47 * Catalog callers use this structural signal to request reconnect without52 * Catalog callers use this structural signal to request reconnect without
48 * parsing or exposing an upstream error message. */53 * parsing or exposing an upstream error message. */
packages/plugins/mcp/src/sdk/plugin.test.ts+38−1
@@ -24,7 +24,7 @@ import { · 23 unmodified lines
24} from "@executor-js/sdk/testing";24} from "@executor-js/sdk/testing";
25 25
26import { createMcpConnector } from "./connection";26import { createMcpConnector } from "./connection";
27import { mcpPlugin, userFacingProbeMessage } from "./plugin";27import { mcpPlugin, userFacingProbeMessage, toIntegrationConfig } from "./plugin";
28import { McpInvocationError } from "./errors";28import { McpInvocationError } from "./errors";
29import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";29import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";
30import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";30import { makeAnnotationsMcpServer, serveMcpServer } from "../testing";
@@ -1536,3 +1536,40 @@ describe("mcpPlugin endpoint telemetry", () => { · 1505 unmodified lines
1536 }),1536 }),
1537 );1537 );
1538});1538});
1539
1540describe("stdio static env", () => {
1541 it("keeps non-secret env off the credential surface", () => {
1542 // `env` declares a credential the user must type; `staticEnv` is machine
1543 // knowledge stored on the integration. A path the scanner already resolved
1544 // belongs in the second, or adding the integration asks for it.
1545 const config = toIntegrationConfig({
1546 transport: "stdio",
1547 name: "Computer Use",
1548 command: "/usr/local/bin/codex",
1549 args: ["app-server"],
1550 staticEnv: { CODEX_HOME: "/home/a/.codex" },
1551 });
1552
1553 expect(config).toMatchObject({
1554 env: { CODEX_HOME: "/home/a/.codex" },
1555 authenticationTemplate: [{ slug: "none", kind: "none" }],
1556 });
1557 });
1558
1559 it("still treats declared env values as credentials", () => {
1560 const config = toIntegrationConfig({
1561 transport: "stdio",
1562 name: "Secret server",
1563 command: "run",
1564 env: { API_KEY: "sk-live" },
1565 });
1566
1567 expect(config).toMatchObject({
1568 authenticationTemplate: [{ slug: "env", kind: "stdio_env", vars: ["API_KEY"] }],
1569 });
1570 expect(
1571 (config as { env?: unknown }).env,
1572 "the secret never lands in the config",
1573 ).toBeUndefined();
1574 });
1575});
packages/plugins/mcp/src/sdk/plugin.ts+135−20
@@ -45,7 +45,7 @@ import { · 44 unmodified lines
45import type { CodexPluginEntry } from "./codex-plugins";45import type { CodexPluginEntry } from "./codex-plugins";
46import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";46import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection";
47import { createMcpConnectionPool } from "./connection-pool";47import { createMcpConnectionPool } from "./connection-pool";
48import { discoverTools } from "./discover";48import { discoverToolsFromInput } from "./discover";
49import {49import {
50 McpConnectionError,50 McpConnectionError,
51 type McpConnectionFailureKind,51 type McpConnectionFailureKind,
@@ -218,6 +218,10 @@ const McpRemoteServerInputSchema = Schema.Struct({ · 166 unmodified lines
218 /** Single-method shorthand (legacy callers). Ignored when218 /** Single-method shorthand (legacy callers). Ignored when
219 * `authenticationTemplate` is present. Defaults to none. */219 * `authenticationTemplate` is present. Defaults to none. */
220 auth: Schema.optional(McpAuthShorthand),220 auth: Schema.optional(McpAuthShorthand),
221 /** Pin protocol negotiation. `legacy` is for servers that echo the proposed
222 * 2026-07-28 revision and then violate its response contract; the probe
223 * reports when it had to fall back, and the add flow passes that through. */
224 versionNegotiation: Schema.optional(McpStdioVersionNegotiation),
221});225});
222 226
223const McpStdioServerInputSchema = Schema.Struct({227const McpStdioServerInputSchema = Schema.Struct({
@@ -236,6 +240,17 @@ const McpStdioServerInputSchema = Schema.Struct({ · 12 unmodified lines
236 * add then auto-creates the connection holding them. The UI uses `envVars`240 * add then auto-creates the connection holding them. The UI uses `envVars`
237 * instead and leaves the values to the connect step. */241 * instead and leaves the values to the connect step. */
238 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),242 env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
243 /** Non-secret environment the server needs, stored on the integration and
244 * injected verbatim at spawn.
245 *
246 * Separate from `env` because that channel makes every variable a
247 * CREDENTIAL: it is declared as a `stdio_env` method and the user is asked
248 * to type its value on a masked form. That is right for an API key and
249 * wrong for a machine-derived path — a Codex plugin's `CODEX_HOME` is
250 * already known to the scanner, is not a secret, and must never become a
251 * field a person has to fill in. Nothing here is a credential, so it does
252 * not appear in `authenticationTemplate`. */
253 staticEnv: Schema.optional(Schema.Record(Schema.String, Schema.String)),
239 cwd: Schema.optional(Schema.String),254 cwd: Schema.optional(Schema.String),
240 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec255 /** Protocol negotiation at connect: `auto` probes `server/discover` (spec
241 * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize`256 * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize`
@@ -253,6 +268,7 @@ const McpStdioServerInputSchema = Schema.Struct({ · 11 unmodified lines
253 server: Schema.String,268 server: Schema.String,
254 surface: Schema.optional(Schema.Literals(["sky", "browser"])),269 surface: Schema.optional(Schema.Literals(["sky", "browser"])),
255 modulePath: Schema.optional(Schema.String),270 modulePath: Schema.optional(Schema.String),
271 presetId: Schema.optional(Schema.String),
256 }),272 }),
257 ),273 ),
258 slug: Schema.optional(Schema.String),274 slug: Schema.optional(Schema.String),
@@ -294,6 +310,10 @@ const McpProbeEndpointOutputSchema = Schema.Struct({ · 35 unmodified lines
294 /** The server's `instructions` from initialize — prefill for the add form's310 /** The server's `instructions` from initialize — prefill for the add form's
295 * description. Only available when the probe connected unauthenticated. */311 * description. Only available when the probe connected unauthenticated. */
296 instructions: Schema.NullOr(Schema.String),312 instructions: Schema.NullOr(Schema.String),
313 /** Present when discovery succeeded: which protocol negotiation worked.
314 * `legacy` means the server echoed the modern revision and then broke its
315 * contract — the add should pin `versionNegotiation: "legacy"`. */
316 versionNegotiation: Schema.optional(McpStdioVersionNegotiation),
297});317});
298 318
299// ---------------------------------------------------------------------------319// ---------------------------------------------------------------------------
@@ -406,18 +426,23 @@ const stdioEnvVarNames = (input: McpStdioServerInput): readonly string[] => { · 106 unmodified lines
406 return [...names];426 return [...names];
407};427};
408 428
409const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => {429/** Exported for tests: the credential/non-credential split is a security
430 * boundary (a value in `env` becomes something the user is asked to type),
431 * and asserting it through the whole add flow would not show it. */
432export const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => {
410 if (input.transport === "stdio") {433 if (input.transport === "stdio") {
411 // The config only DECLARES the secret env vars by NAME (a `stdio_env`434 // The config only DECLARES the secret env vars by NAME (a `stdio_env`
412 // method); their values are credentials and live on the connection, never435 // method); their values are credentials and live on the connection, never
413 // in this blob. Names come from the explicit `envVars` declaration and/or436 // in this blob. Names come from the explicit `envVars` declaration and/or
414 // the keys of any one-shot `env` values.437 // the keys of any one-shot `env` values.
415 const vars = stdioEnvVarNames(input);438 const vars = stdioEnvVarNames(input);
439 const staticEnv = input.staticEnv;
416 return {440 return {
417 transport: "stdio",441 transport: "stdio",
418 family: input.family?.trim() || undefined,442 family: input.family?.trim() || undefined,
419 command: input.command,443 command: input.command,
420 args: input.args ? [...input.args] : undefined,444 args: input.args ? [...input.args] : undefined,
445 env: staticEnv !== undefined && Object.keys(staticEnv).length > 0 ? staticEnv : undefined,
421 cwd: input.cwd,446 cwd: input.cwd,
422 versionNegotiation: input.versionNegotiation,447 versionNegotiation: input.versionNegotiation,
423 spawnPerCall: input.spawnPerCall,448 spawnPerCall: input.spawnPerCall,
@@ -438,6 +463,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => · 14 unmodified lines
438 authenticationTemplate: input.authenticationTemplate463 authenticationTemplate: input.authenticationTemplate
439 ? normalizeMcpAuthMethods(input.authenticationTemplate)464 ? normalizeMcpAuthMethods(input.authenticationTemplate)
440 : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })],465 : [mcpAuthMethodFromShorthand(input.auth ?? { kind: "none" })],
466 versionNegotiation: input.versionNegotiation,
441 };467 };
442};468};
443 469
@@ -679,6 +705,7 @@ const buildConnectorInput = ( · 235 unmodified lines
679 authProvider,705 authProvider,
680 ...(authProvider === undefined ? {} : { staticOAuthBearer: true }),706 ...(authProvider === undefined ? {} : { staticOAuthBearer: true }),
681 httpClientLayer,707 httpClientLayer,
708 versionNegotiation: config.versionNegotiation,
682 });709 });
683};710};
684 711
@@ -941,17 +968,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 256 unmodified lines
941 const probeHeaders = typeof input === "string" ? undefined : input.headers;968 const probeHeaders = typeof input === "string" ? undefined : input.headers;
942 const probeQueryParams = typeof input === "string" ? undefined : input.queryParams;969 const probeQueryParams = typeof input === "string" ? undefined : input.queryParams;
943 970
944 const connector = createMcpConnector({971 const result = yield* discoverToolsFromInput({
945 transport: "remote",972 transport: "remote",
946 endpoint: trimmed,973 endpoint: trimmed,
947 headers: probeHeaders,974 headers: probeHeaders,
948 queryParams: probeQueryParams,975 queryParams: probeQueryParams,
949 httpClientLayer,976 httpClientLayer,
950 });977 }).pipe(
951 978 Effect.map((d) => ({ ok: true as const, ...d })),
952 const result = yield* discoverTools(connector).pipe(979 Effect.catch(() =>
953 Effect.map((m) => ({ ok: true as const, manifest: m })),980 Effect.succeed({ ok: false as const, manifest: null, versionNegotiation: null }),
954 Effect.catch(() => Effect.succeed({ ok: false as const, manifest: null })),981 ),
955 Effect.withSpan("mcp.plugin.discover_tools"),982 Effect.withSpan("mcp.plugin.discover_tools"),
956 );983 );
957 984
@@ -966,6 +993,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 8 unmodified lines
966 toolCount: result.manifest.tools.length,993 toolCount: result.manifest.tools.length,
967 serverName: result.manifest.server?.name ?? null,994 serverName: result.manifest.server?.name ?? null,
968 instructions: result.manifest.server?.instructions ?? null,995 instructions: result.manifest.server?.instructions ?? null,
996 ...(result.versionNegotiation === "legacy"
997 ? { versionNegotiation: "legacy" as const }
998 : {}),
969 } satisfies McpProbeResult;999 } satisfies McpProbeResult;
970 }1000 }
971 1001
@@ -1361,6 +1391,82 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 389 unmodified lines
1361 // scanner touches node:fs, so it stays behind a dynamic import (the1391 // scanner touches node:fs, so it stays behind a dynamic import (the
1362 // stdio-connector pattern) and behind the stdio gate: with stdio off the1392 // stdio-connector pattern) and behind the stdio gate: with stdio off the
1363 // presets could not be added anyway.1393 // presets could not be added anyway.
1394 /** Ask a Codex plugin whether macOS will actually let it work.
1395 *
1396 * Runs the plugin's own read-only probe tool down the REAL path — the
1397 * same bridge, spawn, and service a live call uses — because that is
1398 * the only honest answer available. macOS exposes no way to read
1399 * another app's privacy decisions, and the grants here are split across
1400 * two identities (the host holds Automation; the Codex service holds
1401 * the rest), so nothing short of trying it can tell the user where they
1402 * stand. A denial is reported as the grant to enable.
1403 *
1404 * Safe to run on demand: every probe tool is a listing or a status
1405 * read. If macOS has not yet asked, this is what makes it ask. */
1406 const checkCodexPluginAccess = (id: string) =>
1407 Effect.gen(function* () {
1408 if (!allowStdio) return { status: "unsupported" as const };
1409 const plugins = yield* listCodexPlugins();
1410 const plugin = plugins.find((entry) => entry.id === id);
1411 if (plugin === undefined) return { status: "unknown" as const };
1412 if (!plugin.available) return { status: "not-installed" as const };
1413
1414 const mod = yield* Effect.promise(() => import("./codex-plugin-presets"));
1415 const preset = mod.CURATED_CODEX_PLUGINS.find((entry) => entry.id === id);
1416 const probe = preset?.probeTool;
1417 if (probe === undefined) return { status: "nothing-to-check" as const };
1418
1419 const connector = createMcpConnector({
1420 transport: "stdio",
1421 command: plugin.command,
1422 args: plugin.args,
1423 env: plugin.env === undefined ? undefined : { ...plugin.env },
1424 ...(plugin.appServer === undefined ? {} : { appServer: plugin.appServer }),
1425 });
1426
1427 return yield* Effect.gen(function* () {
1428 const connection = yield* connector;
1429 const result = yield* Effect.tryPromise({
1430 try: () => connection.client.callTool({ name: probe.name, arguments: probe.args }),
1431 catch: () =>
1432 new McpConnectionError({
1433 transport: "appserver",
1434 message: "The plugin did not answer.",
1435 }),
1436 }).pipe(Effect.ensuring(Effect.promise(() => connection.close())));
1437
1438 const text = (Array.isArray(result.content) ? result.content : [])
1439 .map((block) => (block as { readonly text?: unknown }).text)
1440 .filter((value): value is string => typeof value === "string")
1441 .join(" ");
1442 if (result.isError === true) {
1443 return { status: "blocked" as const, message: text };
1444 }
1445 return { status: "ok" as const };
1446 }).pipe(
1447 // Any failure to even reach the plugin is reported the same way a
1448 // refusal is: the user cares that it does not work and why, not
1449 // which layer said no.
1450 // Distinguish the two ways this can fail by TAG, not by reading a
1451 // message off an unknown: the plugin refused, or we never reached
1452 // it at all.
1453 Effect.catchTags({
1454 McpConnectionError: () =>
1455 Effect.succeed({
1456 status: "blocked" as const,
1457 message:
1458 "Could not start the plugin. Check that Codex is installed and signed in.",
1459 }),
1460 McpOAuthReauthorizationRequired: () =>
1461 Effect.succeed({
1462 status: "blocked" as const,
1463 message: "The plugin needs to be re-authorized in Codex.",
1464 }),
1465 }),
1466 Effect.withSpan("mcp.plugin.check_codex_plugin_access"),
1467 );
1468 });
1469
1364 const listCodexPlugins = () =>1470 const listCodexPlugins = () =>
1365 allowStdio1471 allowStdio
1366 ? Effect.promise(() => import("./codex-plugins")).pipe(1472 ? Effect.promise(() => import("./codex-plugins")).pipe(
@@ -1378,6 +1484,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 11 unmodified lines
1378 configureServer,1484 configureServer,
1379 configureAuth,1485 configureAuth,
1380 listCodexPlugins,1486 listCodexPlugins,
1487 checkCodexPluginAccess,
1381 };1488 };
1382 },1489 },
1383 1490
@@ -1408,10 +1515,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 24 unmodified lines
1408 template === null ? null : String(template),1515 template === null ? null : String(template),
1409 allowStdio,1516 allowStdio,
1410 httpClientLayer,1517 httpClientLayer,
1411 ).pipe(1518 ).pipe(Effect.result);
1412 Effect.map((ci) => createMcpConnector(ci)),
1413 Effect.result,
1414 );
1415 1519
1416 if (Result.isFailure(built)) {1520 if (Result.isFailure(built)) {
1417 return {1521 return {
@@ -1421,7 +1525,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 3 unmodified lines
1421 };1525 };
1422 }1526 }
1423 1527
1424 const discovered = yield* discoverTools(built.success).pipe(1528 const discovered = yield* discoverToolsFromInput(built.success).pipe(
1425 Effect.result,1529 Effect.result,
1426 Effect.withSpan("mcp.plugin.discover_tools", {1530 Effect.withSpan("mcp.plugin.discover_tools", {
1427 attributes: { "mcp.connection.name": String(connection.name) },1531 attributes: { "mcp.connection.name": String(connection.name) },
@@ -1444,7 +1548,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 16 unmodified lines
1444 : {}),1548 : {}),
1445 };1549 };
1446 }1550 }
1447 return { tools: discovered.success.tools.map(toToolDef) };1551 return { tools: discovered.success.manifest.tools.map(toToolDef) };
1448 }).pipe(1552 }).pipe(
1449 Effect.withSpan("mcp.plugin.resolve_tools", {1553 Effect.withSpan("mcp.plugin.resolve_tools", {
1450 attributes: { "mcp.connection.name": String(connection.name) },1554 attributes: { "mcp.connection.name": String(connection.name) },
@@ -1660,13 +1764,11 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 209 unmodified lines
1660 const name = parsed.value.hostname || "mcp";1764 const name = parsed.value.hostname || "mcp";
1661 const slug = deriveMcpNamespace({ endpoint: trimmed });1765 const slug = deriveMcpNamespace({ endpoint: trimmed });
1662 1766
1663 const connector = createMcpConnector({1767 const connected = yield* discoverToolsFromInput({
1664 transport: "remote",1768 transport: "remote",
1665 endpoint: trimmed,1769 endpoint: trimmed,
1666 httpClientLayer,1770 httpClientLayer,
1667 });1771 }).pipe(
1668
1669 const connected = yield* discoverTools(connector).pipe(
1670 Effect.map(() => true),1772 Effect.map(() => true),
1671 Effect.catch(() => Effect.succeed(false)),1773 Effect.catch(() => Effect.succeed(false)),
1672 Effect.withSpan("mcp.plugin.discover_tools"),1774 Effect.withSpan("mcp.plugin.discover_tools"),
@@ -1778,9 +1880,9 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 105 unmodified lines
1778 credential.template === null ? null : String(credential.template),1880 credential.template === null ? null : String(credential.template),
1779 allowStdio,1881 allowStdio,
1780 options?.httpClientLayer ?? ctx.httpClientLayer,1882 options?.httpClientLayer ?? ctx.httpClientLayer,
1781 ).pipe(Effect.map((ci) => createMcpConnector(ci)));1883 );
1782 1884
1783 return yield* discoverTools(connector).pipe(1885 return yield* discoverToolsFromInput(connector).pipe(
1784 Effect.map(1886 Effect.map(
1785 () =>1887 () =>
1786 ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult,1888 ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult,
@@ -1944,4 +2046,17 @@ export interface McpPluginExtension { · 157 unmodified lines
1944 /** Locally installed Codex plugins with stdio MCP servers, as one-click2046 /** Locally installed Codex plugins with stdio MCP servers, as one-click
1945 * presets. Empty when stdio is disabled. */2047 * presets. Empty when stdio is disabled. */
1946 readonly listCodexPlugins: () => Effect.Effect<readonly CodexPluginEntry[], never>;2048 readonly listCodexPlugins: () => Effect.Effect<readonly CodexPluginEntry[], never>;
2049 readonly checkCodexPluginAccess: (id: string) => Effect.Effect<
2050 {
2051 readonly status:
2052 | "ok"
2053 | "blocked"
2054 | "not-installed"
2055 | "nothing-to-check"
2056 | "unknown"
2057 | "unsupported";
2058 readonly message?: string;
2059 },
2060 never
2061 >;
1947}2062}
packages/plugins/mcp/src/sdk/presets.ts+0−8
@@ -57,14 +57,6 @@ const codexPluginPresets: readonly McpStdioPreset[] = CURATED_CODEX_PLUGINS.map( · 56 unmodified lines
57}));57}));
58 58
59export const mcpPresets: readonly McpPreset[] = [59export const mcpPresets: readonly McpPreset[] = [
60 {
61 id: "emulate-mcp",
62 name: "Emulate MCP",
63 summary: "Deterministic MCP fixtures for validating native text and image content.",
64 url: "https://emulators.dev/mcp/query/mcp?token=demo-token",
65 endpoint: "https://emulators.dev/mcp/query/mcp?token=demo-token",
66 icon: "https://integrations.sh/logo/emulators.dev",
67 },
68 {60 {
69 id: "deepwiki",61 id: "deepwiki",
70 name: "DeepWiki",62 name: "DeepWiki",
packages/plugins/mcp/src/sdk/types.ts+9−0
@@ -231,6 +231,12 @@ export const McpRemoteIntegrationConfig = Schema.Struct({ · 230 unmodified lines
231 /** Declared auth methods — how a connection's values are rendered onto231 /** Declared auth methods — how a connection's values are rendered onto
232 * requests. A connection's `template` picks one by slug. */232 * requests. A connection's `template` picks one by slug. */
233 authenticationTemplate: Schema.Array(McpAuthMethod),233 authenticationTemplate: Schema.Array(McpAuthMethod),
234 /** Protocol negotiation. Remote defaults to `auto` (2026-07-28 era).
235 * `legacy` pins servers that ECHO the proposed revision and then violate
236 * its response contract — Walmart's MCP answers "2026-07-28" to any
237 * proposal while emitting 2024-era results, which the modern client
238 * rightly rejects. */
239 versionNegotiation: Schema.optional(McpStdioVersionNegotiation),
234});240});
235export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;241export type McpRemoteIntegrationConfig = typeof McpRemoteIntegrationConfig.Type;
236 242
@@ -283,6 +289,9 @@ export const McpStdioIntegrationConfig = Schema.Struct({ · 46 unmodified lines
283 * Chrome's `browser-client.mjs`). Machine-specific, so it is resolved289 * Chrome's `browser-client.mjs`). Machine-specific, so it is resolved
284 * by the scanner rather than hardcoded. */290 * by the scanner rather than hardcoded. */
285 modulePath: Schema.optional(Schema.String),291 modulePath: Schema.optional(Schema.String),
292 /** Which curated Codex plugin this is, so a macOS permission failure can
293 * name the exact grant to enable. */
294 presetId: Schema.optional(Schema.String),
286 }),295 }),
287 ),296 ),
288 /** Declared auth methods — a single `stdio_env` method naming the secret env297 /** Declared auth methods — a single `stdio_env` method naming the secret env
packages/plugins/onepassword/CHANGELOG.md+18−0
@@ -1,5 +1,23 @@
1# @executor-js/plugin-onepassword1# @executor-js/plugin-onepassword
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
8 - @executor-js/react@1.4.69
9 - @executor-js/api@1.4.69
10 - @executor-js/sdk@1.6.6
11
12## 1.6.5
13
14### Patch Changes
15
16- Updated dependencies []:
17 - @executor-js/sdk@1.6.5
18 - @executor-js/api@1.4.68
19 - @executor-js/react@1.4.68
20
3## 1.6.421## 1.6.4
4 22
5### Patch Changes23### Patch Changes
packages/plugins/onepassword/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-onepassword",2 "name": "@executor-js/plugin-onepassword",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/openapi/CHANGELOG.md+20−0
@@ -1,5 +1,25 @@
1# @executor-js/plugin-openapi1# @executor-js/plugin-openapi
2 2
3## 1.6.6
4
5### Patch Changes
6
7- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
8 - @executor-js/react@1.4.69
9 - @executor-js/api@1.4.69
10 - @executor-js/sdk@1.6.6
11 - @executor-js/config@1.6.6
12
13## 1.6.5
14
15### Patch Changes
16
17- Updated dependencies []:
18 - @executor-js/sdk@1.6.5
19 - @executor-js/config@1.6.5
20 - @executor-js/api@1.4.68
21 - @executor-js/react@1.4.68
22
3## 1.6.423## 1.6.4
4 24
5### Patch Changes25### Patch Changes
packages/plugins/openapi/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-openapi",2 "name": "@executor-js/plugin-openapi",
3 "version": "1.6.4",3 "version": "1.6.6",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/plugins/openapi/src/api/group.ts+3−0
@@ -82,6 +82,9 @@ const AddSpecPayload = Schema.Struct({ · 81 unmodified lines
82 name: Schema.optional(Schema.String),82 name: Schema.optional(Schema.String),
83 description: Schema.optional(Schema.String),83 description: Schema.optional(Schema.String),
84 baseUrl: Schema.optional(Schema.String),84 baseUrl: Schema.optional(Schema.String),
85 /** The product's domain when the caller knew it (a registry row names
86 * notion.com) — display identity when the spec lives on a code host. */
87 displayDomain: Schema.optional(Schema.String),
85 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),88 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
86 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),89 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
87 specFormat: Schema.optional(Schema.String),90 specFormat: Schema.optional(Schema.String),
packages/plugins/openapi/src/api/handlers.ts+1−0
@@ -63,6 +63,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 62 unmodified lines
63 name: payload.name,63 name: payload.name,
64 description: payload.description,64 description: payload.description,
65 baseUrl: payload.baseUrl,65 baseUrl: payload.baseUrl,
66 displayDomain: payload.displayDomain,
66 headers: payload.headers ? { ...payload.headers } : undefined,67 headers: payload.headers ? { ...payload.headers } : undefined,
67 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,68 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
68 specFormat: payload.specFormat,69 specFormat: payload.specFormat,
packages/plugins/openapi/src/providers/google/discovery.test.ts+28−0
@@ -1,5 +1,6 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { Effect, Option, Schema } from "effect";2import { Effect, Option, Schema } from "effect";
3import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";4import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
4 5
5import {6import {
@@ -7,6 +8,7 @@ import { · 1 unmodified line
7 convertGoogleDiscoveryToOpenApi,8 convertGoogleDiscoveryToOpenApi,
8 isGoogleDiscoveryUrl,9 isGoogleDiscoveryUrl,
9 normalizeGoogleDiscoveryUrl,10 normalizeGoogleDiscoveryUrl,
11 fetchGoogleDiscoveryDocument,
10} from "./discovery";12} from "./discovery";
11import { googleOAuthConsentScopesForPreset } from "./service-policy";13import { googleOAuthConsentScopesForPreset } from "./service-policy";
12import { extract, parse } from "@executor-js/plugin-openapi";14import { extract, parse } from "@executor-js/plugin-openapi";
@@ -1669,3 +1671,29 @@ it.effect("marks People API semantic inputs as required", () => · 1656 unmodified lines
1669 });1671 });
1670 }),1672 }),
1671);1673);
1674
1675it("falls back to the service-hosted Discovery URL when the central directory has no entry", async () => {
1676 // Google Ads is not listed in the central directory: normalization maps it
1677 // onto www.googleapis.com for identity, and that URL 404s. The fetch must
1678 // still succeed by falling back to the host the caller named.
1679 const seen: string[] = [];
1680 const stub = HttpClient.make((request) => {
1681 seen.push(request.url);
1682 return Effect.succeed(
1683 request.url.includes("www.googleapis.com")
1684 ? HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 }))
1685 : HttpClientResponse.fromWeb(
1686 request,
1687 new Response('{"name":"googleads"}', { status: 200 }),
1688 ),
1689 );
1690 });
1691 const text = await Effect.runPromise(
1692 fetchGoogleDiscoveryDocument(
1693 "https://googleads.googleapis.com/$discovery/rest?version=v24",
1694 ).pipe(Effect.provideService(HttpClient.HttpClient, stub)),
1695 );
1696 expect(text).toBe('{"name":"googleads"}');
1697 expect(seen[0]).toContain("www.googleapis.com");
1698 expect(seen[1]).toBe("https://googleads.googleapis.com/$discovery/rest?version=v24");
1699});
packages/plugins/openapi/src/providers/google/discovery.ts+62−28
@@ -343,6 +343,25 @@ export const isGoogleDiscoveryUrl = (url: string): boolean => { · 342 unmodified lines
343 return normalizeGoogleDiscoveryUrl(url) !== null;343 return normalizeGoogleDiscoveryUrl(url) !== null;
344};344};
345 345
346/** The service's own Discovery endpoint, for a URL that named it. Normalization
347 * canonicalizes most services onto the central directory, which is right for
348 * identity but NOT universally fetchable: services outside the directory
349 * (Google Ads) answer only on their own host. */
350const serviceHostedDiscoveryUrl = (discoveryUrl: string): string | null => {
351 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() rejects malformed input
352 try {
353 const parsed = new URL(discoveryUrl.trim());
354 const host = parsed.hostname.toLowerCase();
355 if (host === "www.googleapis.com" || !host.endsWith(".googleapis.com")) return null;
356 if (!["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) return null;
357 const version = parsed.searchParams.get("version")?.trim();
358 if (!version || !DISCOVERY_VERSION_RE.test(version)) return null;
359 return `https://${host}/$discovery/rest?version=${version}`;
360 } catch {
361 return null;
362 }
363};
364
346export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(365export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
347 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {366 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {
348 const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl);367 const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl);
@@ -353,35 +372,50 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 4 unmodified lines
353 });372 });
354 }373 }
355 const client = yield* HttpClient.HttpClient;374 const client = yield* HttpClient.HttpClient;
356 const requestUrl = new URL(normalizedDiscoveryUrl);375
357 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {376 const attempt = (target: string) =>
358 requestUrl.searchParams.set(name, value);377 Effect.gen(function* () {
359 }378 const requestUrl = new URL(target);
360 let request = HttpClientRequest.get(requestUrl.toString()).pipe(379 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
361 HttpClientRequest.setHeader("Accept", "application/json, */*"),380 requestUrl.searchParams.set(name, value);
362 );381 }
363 for (const [name, value] of Object.entries(credentials?.headers ?? {})) {382 let request = HttpClientRequest.get(requestUrl.toString()).pipe(
364 request = HttpClientRequest.setHeader(request, name, value);383 HttpClientRequest.setHeader("Accept", "application/json, */*"),
365 }384 );
366 const response = yield* client.execute(request).pipe(385 for (const [name, value] of Object.entries(credentials?.headers ?? {})) {
367 Effect.mapError(386 request = HttpClientRequest.setHeader(request, name, value);
368 () =>387 }
369 new OpenApiParseError({388 const response = yield* client
370 message: "Failed to fetch Google Discovery document",389 .execute(request)
371 }),390 .pipe(
372 ),391 Effect.mapError(
373 );392 () => new OpenApiParseError({ message: "Failed to fetch Google Discovery document" }),
374 if (response.status < 200 || response.status >= 300) {393 ),
375 return yield* new OpenApiParseError({394 );
376 message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,395 if (response.status < 200 || response.status >= 300) {
396 return yield* new OpenApiParseError({
397 message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,
398 });
399 }
400 return yield* response.text.pipe(
401 Effect.mapError(
402 () =>
403 new OpenApiParseError({ message: "Failed to read Google Discovery document body" }),
404 ),
405 );
377 });406 });
378 }407
379 return yield* response.text.pipe(408 // Normalization maps a service-hosted URL onto the central directory for a
380 Effect.mapError(409 // STABLE IDENTITY, but the directory does not list every service — Google
381 () =>410 // Ads answers only on googleads.googleapis.com, so the canonical form 404s
382 new OpenApiParseError({411 // for a URL the user pasted that works. Fall back to the host they named
383 message: "Failed to read Google Discovery document body",412 // rather than maintaining an allowlist of every such service forever.
384 }),413 const serviceHosted = serviceHostedDiscoveryUrl(discoveryUrl);
414 return yield* attempt(normalizedDiscoveryUrl).pipe(
415 Effect.catch((error) =>
416 serviceHosted && serviceHosted !== normalizedDiscoveryUrl
417 ? attempt(serviceHosted)
418 : Effect.fail(error),
385 ),419 ),
386 );420 );
387 },421 },
packages/plugins/openapi/src/providers/google/presets.ts+1−0
@@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines
346 ...(preset.featured ? { featured: preset.featured } : {}),346 ...(preset.featured ? { featured: preset.featured } : {}),
347 family: "google",347 family: "google",
348 specFormat: "google-discovery",348 specFormat: "google-discovery",
349 registryListed: true,
349 defaultSlug: googleServiceSlug(preset.id),350 defaultSlug: googleServiceSlug(preset.id),
350 authTemplate: googleCatalogAuthTemplate(preset.id),351 authTemplate: googleCatalogAuthTemplate(preset.id),
351 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),352 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),
packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts+28−0
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 3 unmodified lines
4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7import { resolveSpecFormatAdapter } from "../../sdk/spec-format";
7import type { AuthenticationInput } from "@executor-js/plugin-openapi";8import type { AuthenticationInput } from "@executor-js/plugin-openapi";
8 9
9import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";10import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
@@ -290,3 +291,30 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 280 unmodified lines
290 );291 );
291 }),292 }),
292);293);
294
295it.effect("a hand-pasted Discovery URL selects the adapter without a preset", () =>
296 Effect.gen(function* () {
297 const detected = yield* resolveSpecFormatAdapter(
298 [googleDiscoveryAdapter],
299 undefined,
300 GMAIL_URL,
301 );
302 expect(detected?.id).toBe("google-discovery");
303
304 const plainOpenApi = yield* resolveSpecFormatAdapter(
305 [googleDiscoveryAdapter],
306 undefined,
307 "https://api.example.com/openapi.json",
308 );
309 expect(plainOpenApi).toBeNull();
310
311 // An explicit format id still wins over detection, and an unknown one
312 // still fails rather than silently falling back.
313 const explicit = yield* resolveSpecFormatAdapter(
314 [googleDiscoveryAdapter],
315 "google-discovery",
316 "https://api.example.com/openapi.json",
317 );
318 expect(explicit?.id).toBe("google-discovery");
319 }),
320);
packages/plugins/openapi/src/providers/google/spec-format-adapter.ts+2−0
@@ -6,6 +6,7 @@ import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-for · 5 unmodified lines
6import {6import {
7 convertGoogleDiscoveryBundleToOpenApi,7 convertGoogleDiscoveryBundleToOpenApi,
8 fetchGoogleDiscoveryDocument,8 fetchGoogleDiscoveryDocument,
9 isGoogleDiscoveryUrl,
9 normalizeGoogleDiscoveryUrl,10 normalizeGoogleDiscoveryUrl,
10} from "./discovery";11} from "./discovery";
11 12
@@ -47,6 +48,7 @@ export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | n · 35 unmodified lines
47 48
48export const googleDiscoveryAdapter: SpecFormatAdapter = {49export const googleDiscoveryAdapter: SpecFormatAdapter = {
49 id: "google-discovery",50 id: "google-discovery",
51 detectsUrl: isGoogleDiscoveryUrl,
50 fetch: (input) =>52 fetch: (input) =>
51 Effect.gen(function* () {53 Effect.gen(function* () {
52 const documents = yield* Effect.forEach(54 const documents = yield* Effect.forEach(
packages/plugins/openapi/src/providers/microsoft/presets.ts+1−0
@@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop · 568 unmodified lines
569 ...(preset.featured ? { featured: preset.featured } : {}),569 ...(preset.featured ? { featured: preset.featured } : {}),
570 family: "microsoft",570 family: "microsoft",
571 specFormat: "microsoft-graph",571 specFormat: "microsoft-graph",
572 registryListed: true,
572 defaultSlug: microsoftServiceSlug(preset.id),573 defaultSlug: microsoftServiceSlug(preset.id),
573 authTemplate: microsoftGraphCatalogAuthTemplate(preset),574 authTemplate: microsoftGraphCatalogAuthTemplate(preset),
574 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),575 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts+19−0
@@ -221,3 +221,22 @@ it.effect("stream-previews a Graph selection without a whole-document parse", () · 220 unmodified lines
221 ]);221 ]);
222 }),222 }),
223);223);
224
225it("detects Graph sources so a hand-pasted URL does not fall to the plain path", () => {
226 const detects = (url: string) => microsoftGraphAdapter.detectsUrl?.(url) === true;
227 // The monolith is the case that matters: on the plain path it is 43 MB and
228 // trips the whole-document parse limit.
229 expect(detects(`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=mail`)).toBe(true);
230 expect(detects(MICROSOFT_GRAPH_OPENAPI_URL)).toBe(true);
231 expect(
232 detects(
233 "https://github.com/UsefulSoftwareCo/executor/releases/download/graph-slices/mail.yaml",
234 ),
235 ).toBe(true);
236 // Already-sliced hosted specs are self-contained and parse on the plain
237 // path; claiming them would route them through the slice builder for nothing.
238 expect(detects("https://integrations.sh/specs/microsoft-graph/mail.json")).toBe(false);
239 expect(
240 detects("https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"),
241 ).toBe(false);
242});
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts+21−0
@@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines
3import type { SpecFormatAdapter } from "../../sdk/spec-format";3import type { SpecFormatAdapter } from "../../sdk/spec-format";
4 4
5import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";5import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
6import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
6import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices";7import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices";
7 8
8const fragmentPresetIds = (hash: string): readonly string[] =>9const fragmentPresetIds = (hash: string): readonly string[] =>
@@ -40,8 +41,28 @@ const graphCatalogSelection = ( · 31 unmodified lines
40 };41 };
41};42};
42 43
44/** Recognizes the Graph sources this adapter exists to handle: Microsoft's
45 * published monolith and executor's slice assets, with or without a
46 * `#preset=` selector. Without this, pasting one of those URLs by hand falls
47 * to the plain OpenAPI path, which then chokes on the 43 MB monolith — the
48 * adapter only ever engaged when a preset supplied `specFormat`. */
49const detectsGraphUrl = (url: string): boolean => {
50 if (!URL.canParse(url)) return false;
51 const parsed = new URL(url);
52 parsed.hash = "";
53 const bare = parsed.toString();
54 return (
55 bare === MICROSOFT_GRAPH_OPENAPI_URL ||
56 microsoftGraphSliceAssetFromUrl(bare) !== null ||
57 /^https:\/\/raw\.githubusercontent\.com\/microsoftgraph\/msgraph-metadata\/.*\/openapi\.ya?ml$/.test(
58 bare,
59 )
60 );
61};
62
43export const microsoftGraphAdapter: SpecFormatAdapter = {63export const microsoftGraphAdapter: SpecFormatAdapter = {
44 id: "microsoft-graph",64 id: "microsoft-graph",
65 detectsUrl: detectsGraphUrl,
45 fetch: (input) =>66 fetch: (input) =>
46 buildMicrosoftGraphOpenApiSpec(67 buildMicrosoftGraphOpenApiSpec(
47 graphCatalogSelection(input.urls[0]),68 graphCatalogSelection(input.urls[0]),
packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx+44−5
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 type AuthMethodRow,25 type AuthMethodRow,
26 type AuthMethodSeed,26 type AuthMethodSeed,
27} from "@executor-js/react/components/auth-method-list-editor";27} from "@executor-js/react/components/auth-method-list-editor";
28import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
28import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";29import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
29import { FieldLabel } from "@executor-js/react/components/field";30import { FieldLabel } from "@executor-js/react/components/field";
30import { FloatActions } from "@executor-js/react/components/float-actions";31import { FloatActions } from "@executor-js/react/components/float-actions";
@@ -170,18 +171,40 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines
170 initialUrl?: string;171 initialUrl?: string;
171 initialPreset?: string;172 initialPreset?: string;
172 initialNamespace?: string;173 initialNamespace?: string;
174 initialAuthHeader?: string;
175 initialAuthNote?: string;
176 initialSpecOverrides?: string;
173}) {177}) {
174 const integrationPlugins = useIntegrationPlugins();178 const integrationPlugins = useIntegrationPlugins();
175 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");179 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
176 const openApiPresets = openApiPlugin?.presets;180 const openApiPresets = openApiPlugin?.presets;
177 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");181 // A `?preset=` deep link is self-sufficient: the preset's own URL seeds the
182 // field. The connect dialog used to pass `&url=` alongside, which hid that
183 // this never worked on its own — preset links from agents and docs carry
184 // only the id.
185 const [specUrl, setSpecUrl] = useState(
186 () =>
187 props.initialUrl ??
188 openApiPresets?.find((preset) => preset.id === props.initialPreset)?.url ??
189 "",
190 );
178 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);191 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);
179 const [analyzing, setAnalyzing] = useState(false);192 const [analyzing, setAnalyzing] = useState(false);
180 const [analyzeError, setAnalyzeError] = useState<string | null>(null);193 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
181 194
182 const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);195 const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);
183 const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides);196 const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides);
184 const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides);197 // Registry-declared overrides — how a vendor's published document gets
198 // improved without hosting a fork (Neon's console session cookies posing as
199 // security schemes). A preset's own overrides win; the user's draft wins
200 // over both, and the editor shows exactly what will apply.
201 const registrySpecOverrides = useMemo(() => {
202 if (!props.initialSpecOverrides) return undefined;
203 const parsed = parseSpecOverridesText(props.initialSpecOverrides);
204 return parsed.ok ? parsed.value : undefined;
205 }, [props.initialSpecOverrides]);
206 const specOverridesText =
207 specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides ?? registrySpecOverrides);
185 208
186 // After analysis209 // After analysis
187 const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);210 const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);
@@ -297,14 +320,26 @@ export default function AddOpenApiIntegration(props: { · 109 unmodified lines
297 ...(preview?.headerPresets ?? []).map((preset) => preset.label),320 ...(preview?.headerPresets ?? []).map((preset) => preset.label),
298 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),321 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
299 ];322 ];
300 return authenticationTemplate.map(323 const detected = authenticationTemplate.map(
301 (template: Authentication, index: number): AuthMethodSeed => ({324 (template: Authentication, index: number): AuthMethodSeed => ({
302 value: editorValueFromAuthentication(template),325 value: editorValueFromAuthentication(template),
303 slug: String(template.slug),326 slug: String(template.slug),
304 ...(labels[index] !== undefined ? { label: labels[index] } : {}),327 ...(labels[index] !== undefined ? { label: labels[index] } : {}),
305 }),328 }),
306 );329 );
307 }, [preview, authenticationTemplate]);330 if (preview === null) return detected;
331 // The registry's declared credential placement fills what the document
332 // leaves out. GitHub's official description famously declares no
333 // securitySchemes at all, and even with the OAuth preset template a PAT
334 // bearer header is how most calls actually authenticate — so the key
335 // method is offered alongside OAuth. A spec-declared key method wins over
336 // the registry's version of the same fact.
337 const placement = props.initialAuthHeader
338 ? placementFromHeaderPattern(props.initialAuthHeader)
339 : null;
340 if (!placement || detected.some((seed) => seed.value.kind === "apikey")) return detected;
341 return [...detected, { value: { kind: "apikey", placements: [placement] } }];
342 }, [preview, authenticationTemplate, props.initialAuthHeader]);
308 const authMethodList = useAuthMethodList(authMethodSeeds);343 const authMethodList = useAuthMethodList(authMethodSeeds);
309 344
310 // The methods to register, mapped back to stored `Authentication[]`. Drops345 // The methods to register, mapped back to stored `Authentication[]`. Drops
@@ -597,10 +632,14 @@ export default function AddOpenApiIntegration(props: { · 286 unmodified lines
597 <AuthMethodListEditor632 <AuthMethodListEditor
598 list={authMethodList}633 list={authMethodList}
599 emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later."634 emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later."
600 footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding."635 footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page."
601 />636 />
602 )}637 )}
603 638
639 {preview && props.initialAuthNote ? (
640 <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
641 ) : null}
642
604 {preview ? (643 {preview ? (
605 <AddOpenApiHealthCheckSection644 <AddOpenApiHealthCheckSection
606 presetHealthCheck={activePreset?.healthCheck}645 presetHealthCheck={activePreset?.healthCheck}
packages/plugins/openapi/src/react/integration-plugin.test.ts+241−0
@@ -0,0 +1,241 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Option } from "effect";
3
4import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
5import { Exit } from "effect";
6
7import {
8 composeQuickAddAuth,
9 performQuickAdd,
10 quickAddRequestPayloads,
11 quickAddSpecPlan,
12 type QuickAddDeps,
13} from "./integration-plugin";
14
15// The quick add's composition is where review found bugs twice: first it
16// suppressed spec-declared methods, then it derived auth from the unmodified
17// document while adding the overridden one. These pin the contract.
18
19const scopeOverride = decodeOpenApiSpecOverrides([
20 {
21 op: "replace",
22 path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
23 value: { "files:read": "" },
24 },
25])!;
26
27describe("quickAddSpecPlan", () => {
28 it("uses the same effective overrides for preview and add, preset first", () => {
29 const registryOverrides = decodeOpenApiSpecOverrides([
30 { op: "remove", path: "/components/securitySchemes/Cookie" },
31 ]);
32 const plan = quickAddSpecPlan(
33 {
34 id: "figma",
35 name: "Figma",
36 summary: "",
37 specFormat: "plain-ish",
38 specOverrides: [...scopeOverride],
39 },
40 registryOverrides,
41 );
42 // Preset overrides win over the registry's, mirroring the full add page.
43 expect(plan.specOverrides).toEqual(scopeOverride);
44 expect(plan.specFormat).toBe("plain-ish");
45 });
46
47 it("an explicitly EMPTY preset override list suppresses the registry's", () => {
48 // Presence-based, like the full page's `presetOverrides ?? registry`:
49 // an empty list is a decision, not an absence.
50 const registryOverrides = decodeOpenApiSpecOverrides([
51 { op: "remove", path: "/components/securitySchemes/Cookie" },
52 ]);
53 const plan = quickAddSpecPlan(
54 { id: "custom", name: "Custom", summary: "", specOverrides: [] },
55 registryOverrides,
56 );
57 expect(plan.specOverrides).toBeUndefined();
58 });
59
60 it("falls through to registry overrides when the preset has none", () => {
61 const registryOverrides = decodeOpenApiSpecOverrides([
62 { op: "remove", path: "/components/securitySchemes/Cookie" },
63 ]);
64 const plan = quickAddSpecPlan(undefined, registryOverrides);
65 expect(plan.specOverrides).toEqual(registryOverrides);
66 expect(plan.specFormat).toBeUndefined();
67 });
68});
69
70describe("composeQuickAddAuth", () => {
71 const registryPlacement = {
72 carrier: "header",
73 name: "Authorization",
74 prefix: "Bearer ",
75 } as const;
76
77 it("keeps override-derived OAuth scopes and appends the registry key", () => {
78 // The preview reflects the OVERRIDDEN document (quickAddSpecPlan sends
79 // the same overrides to preview): its oauth preset carries the replaced
80 // scopes. The composed template must retain them verbatim and add the
81 // registry header only because no key method was detected.
82 const template = composeQuickAddAuth([], registryPlacement, {
83 headerPresets: [],
84 oauth2Presets: [
85 {
86 label: "OAuth2",
87 securitySchemeName: "OAuth2",
88 flow: "authorizationCode" as const,
89 authorizationUrl: Option.some("https://example.com/authorize"),
90 tokenUrl: "https://example.com/token",
91 resource: Option.none(),
92 refreshUrl: Option.none(),
93 // The overridden document's scopes — the whole point: the preview
94 // reflects the SAME effective spec the add stores.
95 scopes: { "files:read": "" },
96 identityScopes: [] as const,
97 },
98 ],
99 servers: [{ url: "https://api.example.com" }],
100 });
101 expect(template).toHaveLength(2);
102 const oauth = template.find((method) => "kind" in method && method.kind === "oauth2");
103 expect(oauth && "scopes" in oauth ? oauth.scopes : undefined).toEqual(["files:read"]);
104 const key = template.find((method) => !("kind" in method) || method.kind !== "oauth2");
105 expect(JSON.stringify(key)).toContain("Authorization");
106 });
107
108 it("does not append the registry key when the spec already declares one", () => {
109 const template = composeQuickAddAuth([], registryPlacement, {
110 headerPresets: [
111 {
112 label: "API key",
113 headers: { "X-API-Key": null },
114 secretHeaders: ["X-API-Key"],
115 secretQueryParams: [],
116 },
117 ],
118 oauth2Presets: [],
119 servers: [{ url: "https://api.example.com" }],
120 });
121 expect(template).toHaveLength(1);
122 expect(JSON.stringify(template[0])).toContain("X-API-Key");
123 });
124});
125
126describe("quickAddRequestPayloads", () => {
127 it("the preview and the add carry the identical spec plan", () => {
128 // The regression contract itself: reverting either call site to a bare
129 // payload makes these fields diverge and this test fail.
130 const plan = quickAddSpecPlan(
131 {
132 id: "figma",
133 name: "Figma",
134 summary: "",
135 specFormat: "fmt",
136 specOverrides: [...scopeOverride],
137 },
138 undefined,
139 );
140 const requests = quickAddRequestPayloads(
141 { url: "https://example.com/spec.yaml", name: "Figma API", domain: "figma.com" },
142 "figma_api",
143 plan,
144 );
145 expect(requests.preview.specFormat).toBe("fmt");
146 expect(requests.preview.specOverrides).toEqual(scopeOverride);
147 expect(requests.add.specFormat).toBe(requests.preview.specFormat);
148 expect(requests.add.specOverrides).toEqual(requests.preview.specOverrides);
149 expect(requests.add.displayDomain).toBe("figma.com");
150 expect(requests.add.spec).toEqual({ kind: "url", url: "https://example.com/spec.yaml" });
151 });
152});
153
154describe("performQuickAdd", () => {
155 // The OPERATION under test, with the two mutations captured — the guard
156 // the review demanded twice: helper tests kept passing while a call site
157 // could quietly stop using the shared plan.
158 it("sends the identical spec plan to preview and add, and composed auth", async () => {
159 const previews: unknown[] = [];
160 const adds: unknown[] = [];
161 const deps: QuickAddDeps = {
162 presets: [
163 {
164 id: "figma",
165 name: "Figma",
166 summary: "",
167 url: "https://example.com/spec.yaml",
168 specFormat: "fmt",
169 specOverrides: [...scopeOverride],
170 },
171 ],
172 preview: (payload) => {
173 previews.push(payload);
174 return Promise.resolve(
175 Exit.succeed({
176 headerPresets: [],
177 oauth2Presets: [
178 {
179 label: "OAuth2",
180 securitySchemeName: "OAuth2",
181 flow: "authorizationCode" as const,
182 authorizationUrl: Option.some("https://example.com/authorize"),
183 tokenUrl: "https://example.com/token",
184 resource: Option.none(),
185 refreshUrl: Option.none(),
186 scopes: { "files:read": "" },
187 identityScopes: [] as const,
188 },
189 ],
190 servers: [{ url: "https://api.example.com" }],
191 }),
192 );
193 },
194 add: (payload) => {
195 adds.push(payload);
196 return Promise.resolve(Exit.succeed({ slug: "figma_api" }));
197 },
198 };
199 const result = await performQuickAdd(deps, {
200 url: "https://example.com/spec.yaml",
201 name: "Figma API",
202 slug: "figma-api",
203 domain: "figma.com",
204 authHeader: "Authorization: Bearer {token}",
205 });
206 expect(result).toEqual({ ok: true, slug: "figma_api" });
207 expect(previews).toHaveLength(1);
208 expect(adds).toHaveLength(1);
209 const preview = previews[0] as Record<string, unknown>;
210 const add = adds[0] as Record<string, unknown>;
211 // The regression contract: BOTH real payloads carry the one spec plan.
212 expect(preview.specFormat).toBe("fmt");
213 expect(preview.specOverrides).toEqual(scopeOverride);
214 expect(add.specFormat).toBe(preview.specFormat);
215 expect(add.specOverrides).toEqual(preview.specOverrides);
216 expect(add.slug).toBe("figma_api");
217 expect(add.displayDomain).toBe("figma.com");
218 // Auth derived from the overridden document, registry key appended.
219 const template = add.authenticationTemplate as readonly unknown[];
220 expect(JSON.stringify(template)).toContain("files:read");
221 expect(JSON.stringify(template)).toContain("Authorization");
222 });
223
224 it("skips the preview entirely when the registry declared no header", async () => {
225 const previews: unknown[] = [];
226 const deps: QuickAddDeps = {
227 presets: [],
228 preview: (payload) => {
229 previews.push(payload);
230 return Promise.resolve(Exit.fail("unexpected"));
231 },
232 add: () => Promise.resolve(Exit.succeed({ slug: "plain" })),
233 };
234 const result = await performQuickAdd(deps, {
235 url: "https://example.com/openapi.json",
236 name: "Plain API",
237 });
238 expect(result).toEqual({ ok: true, slug: "plain" });
239 expect(previews).toHaveLength(0);
240 });
241});
packages/plugins/openapi/src/react/integration-plugin.ts+252−17
@@ -1,6 +1,234 @@
1import { lazy } from "react";1import { lazy, useCallback } from "react";
2import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import type {
5 IntegrationPlugin,
6 IntegrationPreset,
7 IntegrationQuickAddInput,
8 IntegrationQuickAddResult,
9} from "@executor-js/sdk/client";
10import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
12import { placementFromHeaderPattern, type Placement } from "@executor-js/react/lib/auth-placements";
13import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3import { openApiPresets } from "../sdk/presets";14import { openApiPresets } from "../sdk/presets";
15import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
16import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
17import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config";
18import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
19
20const normalizedSpecUrl = (url: string): string => {
21 if (!URL.canParse(url)) return url.trim().replace(/\/$/, "");
22 const parsed = new URL(url);
23 parsed.hash = "";
24 parsed.searchParams.sort();
25 return parsed.toString().replace(/\/$/, "");
26};
27
28/** The preset table still knows things no spec can say — GitHub's OAuth
29 * endpoints against a spec that declares NO security at all. A registry row
30 * whose URL is a preset's URL gets that knowledge pulled across. Matches the
31 * plugin's COMPLETE preset list, so a deployment's custom presets
32 * contribute too, not just the built-ins. */
33const presetForSpecUrl = (
34 presets: readonly IntegrationPreset[],
35 url: string,
36): IntegrationPreset | undefined => {
37 const target = normalizedSpecUrl(url);
38 return presets.find(
39 (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target,
40 );
41};
42
43/** One-click add for a registry OpenAPI row. The spec itself is the
44 * configuration: omitting `authenticationTemplate` and `baseUrl` tells the
45 * server to derive both from the document, exactly what the add page's
46 * untouched defaults submit. Registry spec overrides ride along. */
47
48/** The spec inputs a quick add must hold CONSISTENT between preview and add:
49 * deriving auth from a document other than the one being added is how a
50 * preset's scope overrides got silently dropped. Preset overrides win over
51 * the registry's, mirroring the full add page. */
52export const quickAddSpecPlan = (
53 preset: IntegrationPreset | undefined,
54 registryOverrides: ReturnType<typeof decodeOpenApiSpecOverrides>,
55): {
56 readonly specFormat?: string;
57 readonly specOverrides?: NonNullable<ReturnType<typeof decodeOpenApiSpecOverrides>>;
58} => {
59 const presetOverrides = preset?.specOverrides
60 ? decodeOpenApiSpecOverrides(preset.specOverrides)
61 : undefined;
62 // PRESENCE-based, mirroring the full page's `presetOverrides ??
63 // registryOverrides`: a preset declaring an explicitly EMPTY override list
64 // is a decision — suppress the registry's patches — not an absence.
65 const effective = presetOverrides !== undefined ? presetOverrides : registryOverrides;
66 return {
67 ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}),
68 ...(effective && effective.length > 0 ? { specOverrides: effective } : {}),
69 };
70};
71
72/** The preview and add requests built from ONE plan, so they cannot diverge:
73 * deriving auth from a different effective document than the one stored is
74 * the bug this module has now had twice. The add payload takes further
75 * fields (family, health check, auth template) AFTER this base. */
76export const quickAddRequestPayloads = (
77 input: { readonly url: string; readonly name: string; readonly domain?: string },
78 slug: string,
79 specPlan: ReturnType<typeof quickAddSpecPlan>,
80): {
81 readonly preview: { readonly spec: string } & ReturnType<typeof quickAddSpecPlan>;
82 readonly add: {
83 readonly spec: { readonly kind: "url"; readonly url: string };
84 readonly slug: string;
85 readonly name: string;
86 readonly displayDomain?: string;
87 } & ReturnType<typeof quickAddSpecPlan>;
88} => ({
89 preview: { spec: input.url, ...specPlan },
90 add: {
91 spec: { kind: "url", url: input.url },
92 slug,
93 name: input.name,
94 ...(input.domain ? { displayDomain: input.domain } : {}),
95 ...specPlan,
96 },
97});
98
99/** The full add page's method policy as one pure step: preset OAuth wins
100 * outright; else every preview-detected method is preserved and the
101 * registry's key placement is appended only when the detected set has no
102 * key method (GitHub declares no security at all). `preview` MUST be the
103 * summary of the same effective document the add will store. */
104export const composeQuickAddAuth = (
105 presetMethods: readonly ReturnType<typeof openApiWireAuthInput>[],
106 registryPlacement: Placement | null,
107 preview: {
108 readonly headerPresets: Parameters<typeof detectedAuthenticationTemplates>[0];
109 readonly oauth2Presets: Parameters<typeof detectedAuthenticationTemplates>[1];
110 readonly servers: readonly { readonly url: string }[];
111 } | null,
112): readonly ReturnType<typeof openApiWireAuthInput>[] => {
113 if (presetMethods.length > 0) {
114 return registryPlacement
115 ? [...presetMethods, openApiWireAuthInput(templateFromPlacements([registryPlacement]))]
116 : presetMethods;
117 }
118 if (!registryPlacement || preview === null) return presetMethods;
119 const detected = detectedAuthenticationTemplates(
120 preview.headerPresets,
121 preview.oauth2Presets,
122 preview.servers[0]?.url ?? "",
123 );
124 const detectedHasApiKey = detected.some((template) => template.kind === "apikey");
125 return [
126 ...detected.map(openApiWireAuthInput),
127 ...(detectedHasApiKey
128 ? []
129 : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]),
130 ];
131};
132
133export interface QuickAddDeps {
134 readonly presets: readonly IntegrationPreset[];
135 /** The two mutations, injected so the OPERATION is testable against its
136 * actual outgoing payloads — helper-level tests kept passing while a call
137 * site quietly stopped using the shared plan. */
138 readonly preview: (
139 payload: ReturnType<typeof quickAddRequestPayloads>["preview"],
140 ) => Promise<Exit.Exit<NonNullable<Parameters<typeof composeQuickAddAuth>[2]>, unknown>>;
141 readonly add: (
142 payload: ReturnType<typeof quickAddRequestPayloads>["add"] & {
143 readonly family?: string;
144 readonly healthCheck?: IntegrationPreset["healthCheck"];
145 readonly authenticationTemplate?: ReturnType<typeof openApiWireAuthInput>[];
146 },
147 ) => Promise<Exit.Exit<{ readonly slug: string }, unknown>>;
148}
149
150/** The complete quick-add operation, pure of React: everything between the
151 * picker's click and the two API calls. */
152export const performQuickAdd = async (
153 deps: QuickAddDeps,
154 input: IntegrationQuickAddInput,
155): Promise<IntegrationQuickAddResult> => {
156 const { presets } = deps;
157 const slug = slugifyNamespace(input.slug ?? input.name);
158 if (!slug) return { ok: false, reason: "no derivable slug" };
159 // Registry overrides arrive as untyped JSON; a malformed patch goes to
160 // the configuration screen (its editor renders the parse error) rather
161 // than being silently dropped from a "successful" quick add.
162 const specOverrides = input.specOverrides
163 ? decodeOpenApiSpecOverrides(input.specOverrides)
164 : undefined;
165 if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) {
166 return { ok: false, reason: "unparseable spec overrides" };
167 }
168 const preset = presetForSpecUrl(presets, input.url);
169 const registryPlacement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null;
170 const presetMethods = (preset?.authTemplate ?? []).flatMap((template) =>
171 template.kind === "oauth2"
172 ? [
173 openApiWireAuthInput({
174 ...template,
175 slug: AuthTemplateSlug.make(template.slug),
176 resource: template.resource ?? undefined,
177 }),
178 ]
179 : [],
180 );
181 // ONE spec plan for both preview and add: deriving auth from a
182 // different effective document than the one stored is how a preset's
183 // scope overrides got silently dropped.
184 const specPlan = quickAddSpecPlan(preset, specOverrides);
185 const requests = quickAddRequestPayloads(
186 { url: input.url, name: input.name, ...(input.domain ? { domain: input.domain } : {}) },
187 slug,
188 specPlan,
189 );
190 let preview: Parameters<typeof composeQuickAddAuth>[2] = null;
191 if (presetMethods.length === 0 && registryPlacement) {
192 // Composing with spec knowledge needs the spec: one preview call,
193 // only on this path (a plain registry row with no auth facts still
194 // adds with zero extra round trips).
195 const previewExit = await deps.preview(requests.preview);
196 if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" };
197 preview = previewExit.value;
198 }
199 const authenticationTemplate = composeQuickAddAuth(presetMethods, registryPlacement, preview);
200 const exit = await deps.add({
201 ...requests.add,
202 ...(preset?.family ? { family: preset.family } : {}),
203 ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}),
204 ...(authenticationTemplate.length > 0
205 ? { authenticationTemplate: [...authenticationTemplate] }
206 : {}),
207 });
208 if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };
209 return { ok: true, slug: String(exit.value.slug) };
210};
211
212function makeUseQuickAdd(presets: readonly IntegrationPreset[]) {
213 return function useOpenApiQuickAdd(): (
214 input: IntegrationQuickAddInput,
215 ) => Promise<IntegrationQuickAddResult> {
216 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
217 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
218 return useCallback(
219 (input) =>
220 performQuickAdd(
221 {
222 presets,
223 preview: (payload) => doPreview({ payload }),
224 add: (payload) => doAdd({ payload, reactivityKeys: integrationWriteKeys }),
225 },
226 input,
227 ),
228 [doAdd, doPreview],
229 );
230 };
231}
4 232
5const importAdd = () => import("./AddOpenApiIntegration");233const importAdd = () => import("./AddOpenApiIntegration");
6const importEditSheet = () => import("./UpdateSpecSection");234const importEditSheet = () => import("./UpdateSpecSection");
@@ -10,20 +238,27 @@ export interface OpenApiClientConfig { · 3 unmodified lines
10 readonly presets?: readonly IntegrationPreset[];238 readonly presets?: readonly IntegrationPreset[];
11}239}
12 240
13export const createOpenApiIntegrationPlugin = (241export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => {
14 config?: OpenApiClientConfig,242 // Built-ins are registry-listed (the picker shows the registry's card);
15): IntegrationPlugin => ({243 // a deployment's custom presets are not, and keep their own cards.
16 key: "openapi",244 const presets: readonly IntegrationPreset[] = [
17 label: "OpenAPI",245 ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })),
18 add: lazy(importAdd),246 ...(config?.presets ?? []),
19 editSheet: lazy(importEditSheet),247 ];
20 accounts: lazy(importAccounts),248 return {
21 presets: [...openApiPresets, ...(config?.presets ?? [])],249 key: "openapi",
22 preload: () => {250 label: "OpenAPI",
23 void importAdd();251 add: lazy(importAdd),
24 void importEditSheet();252 editSheet: lazy(importEditSheet),
25 void importAccounts();253 accounts: lazy(importAccounts),
26 },254 presets,
27});255 preload: () => {
256 void importAdd();
257 void importEditSheet();
258 void importAccounts();
259 },
260 useQuickAdd: makeUseQuickAdd(presets),
261 };
262};
28 263
29export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();264export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/plugins/openapi/src/sdk/config.ts+5−0
@@ -52,6 +52,11 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 51 unmodified lines
52 specUrl: Schema.optional(Schema.String),52 specUrl: Schema.optional(Schema.String),
53 /** Optional base URL override. */53 /** Optional base URL override. */
54 baseUrl: Schema.optional(Schema.String),54 baseUrl: Schema.optional(Schema.String),
55 /** The PRODUCT's domain, recorded at add time when the caller knew it (a
56 * registry row names notion.com). The spec's own host is often a code
57 * host (raw.githubusercontent.com) that says nothing about the product,
58 * which sent credential guidance and favicons to the wrong domain. */
59 displayDomain: Schema.optional(Schema.String),
55 /** Static headers applied to every request (no secret material). */60 /** Static headers applied to every request (no secret material). */
56 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),61 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
57 /** Static query params applied to every request (no secret material). */62 /** Static query params applied to every request (no secret material). */
packages/plugins/openapi/src/sdk/derive-auth.test.ts+42−0
@@ -71,3 +71,45 @@ describe("detectedAuthenticationTemplates", () => { · 70 unmodified lines
71 ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);71 ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);
72 });72 });
73});73});
74
75describe("query-located api keys", () => {
76 it("a query apiKey preset places the secret in the query string", () => {
77 const templates = detectedAuthenticationTemplates(
78 [
79 {
80 label: "Legacy-API-key (query)",
81 headers: {},
82 secretHeaders: [],
83 secretQueryParams: ["apiKey"],
84 },
85 ],
86 [],
87 "https://example.com",
88 );
89 expect(templates).toHaveLength(1);
90 const template = templates[0];
91 expect(template?.kind).toBe("apikey");
92 expect(template?.kind === "apikey" ? template.placements : []).toEqual([
93 { carrier: "query", name: "apiKey" },
94 ]);
95 });
96
97 it("a strategy mixing a header and a query param asks for two inputs", () => {
98 const templates = detectedAuthenticationTemplates(
99 [
100 {
101 label: "exp-api-key + apiKey (query)",
102 headers: { "exp-api-key": null },
103 secretHeaders: ["exp-api-key"],
104 secretQueryParams: ["apiKey"],
105 },
106 ],
107 [],
108 "https://example.com",
109 );
110 const template = templates[0];
111 const placements = template?.kind === "apikey" ? template.placements : [];
112 expect(placements.map((placement) => placement.carrier)).toEqual(["header", "query"]);
113 expect(new Set(placements.map((placement) => placement.variable)).size).toBe(2);
114 });
115});
packages/plugins/openapi/src/sdk/derive-auth.ts+24−11
@@ -104,20 +104,33 @@ const apiKeyTemplateFromHeaderPreset = ( · 103 unmodified lines
104 preset: HeaderPreset,104 preset: HeaderPreset,
105 slug: AuthTemplateSlug,105 slug: AuthTemplateSlug,
106): APIKeyAuthentication => {106): APIKeyAuthentication => {
107 const variables = variablesForHeaders(preset.secretHeaders);107 const secretQueryParams = preset.secretQueryParams ?? [];
108 // One variable namespace across carriers, so a strategy sending two secrets
109 // (a header and a query param) asks for two distinct inputs.
110 const variables = variablesForHeaders([...preset.secretHeaders, ...secretQueryParams]);
108 return {111 return {
109 slug,112 slug,
110 kind: "apikey",113 kind: "apikey",
111 placements: preset.secretHeaders.map((headerName) => {114 placements: [
112 const prefix = headerPrefix(preset, headerName);115 ...preset.secretHeaders.map((headerName) => {
113 const variable = variables.get(headerName);116 const prefix = headerPrefix(preset, headerName);
114 return {117 const variable = variables.get(headerName);
115 carrier: "header" as const,118 return {
116 name: headerName,119 carrier: "header" as const,
117 ...(prefix ? { prefix } : {}),120 name: headerName,
118 ...(variable ? { variable } : {}),121 ...(prefix ? { prefix } : {}),
119 };122 ...(variable ? { variable } : {}),
120 }),123 };
124 }),
125 ...secretQueryParams.map((paramName) => {
126 const variable = variables.get(paramName);
127 return {
128 carrier: "query" as const,
129 name: paramName,
130 ...(variable ? { variable } : {}),
131 };
132 }),
133 ],
121 };134 };
122};135};
123 136
packages/plugins/openapi/src/sdk/plugin.ts+11−1
@@ -98,6 +98,10 @@ export interface OpenApiSpecConfig { · 97 unmodified lines
98 * then the title). */98 * then the title). */
99 readonly description?: string;99 readonly description?: string;
100 readonly baseUrl?: string;100 readonly baseUrl?: string;
101 /** The product's domain, when the caller knows it (a registry row names
102 * notion.com); display identity for guidance/favicons when the spec's
103 * own host is a code host. */
104 readonly displayDomain?: string;
101 /** Static headers applied to every request (no secret material). */105 /** Static headers applied to every request (no secret material). */
102 readonly headers?: Record<string, string>;106 readonly headers?: Record<string, string>;
103 /** Static query params applied to every request. */107 /** Static query params applied to every request. */
@@ -307,6 +311,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 203 unmodified lines
307 name: Schema.optional(Schema.String),311 name: Schema.optional(Schema.String),
308 description: Schema.optional(Schema.String),312 description: Schema.optional(Schema.String),
309 baseUrl: Schema.optional(Schema.String),313 baseUrl: Schema.optional(Schema.String),
314 displayDomain: Schema.optional(Schema.String),
310 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),315 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
311 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),316 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
312 specFormat: Schema.optional(Schema.String),317 specFormat: Schema.optional(Schema.String),
@@ -603,7 +608,9 @@ export const describeOpenApiIntegrationDisplay = ( · 290 unmodified lines
603): { readonly url?: string; readonly family?: string } => {608): { readonly url?: string; readonly family?: string } => {
604 const config = decodeOpenApiIntegrationConfig(record.config);609 const config = decodeOpenApiIntegrationConfig(record.config);
605 return {610 return {
606 url: config?.baseUrl ?? config?.specUrl,611 url:
612 config?.baseUrl ??
613 (config?.displayDomain ? `https://${config.displayDomain}` : config?.specUrl),
607 ...(config?.family ? { family: config.family } : {}),614 ...(config?.family ? { family: config.family } : {}),
608 };615 };
609};616};
@@ -661,6 +668,7 @@ export const openApiPlugin = definePlugin< · 51 unmodified lines
661 const adapter = yield* resolveSpecFormatAdapter(668 const adapter = yield* resolveSpecFormatAdapter(
662 options?.specFormats ?? [],669 options?.specFormats ?? [],
663 config.specFormat,670 config.specFormat,
671 config.spec.kind === "url" ? config.spec.url : undefined,
664 );672 );
665 if (adapter) {673 if (adapter) {
666 if (config.spec.kind !== "url") {674 if (config.spec.kind !== "url") {
@@ -782,6 +790,7 @@ export const openApiPlugin = definePlugin< · 115 unmodified lines
782 const adapter = yield* resolveSpecFormatAdapter(790 const adapter = yield* resolveSpecFormatAdapter(
783 options?.specFormats ?? [],791 options?.specFormats ?? [],
784 config.specFormat,792 config.specFormat,
793 config.spec.kind === "url" ? config.spec.url : undefined,
785 );794 );
786 const derivedIdentity =795 const derivedIdentity =
787 adapter?.deriveIdentity && resolved.document796 adapter?.deriveIdentity && resolved.document
@@ -865,6 +874,7 @@ export const openApiPlugin = definePlugin< · 77 unmodified lines
865 // resolved per call from the operation's `servers` (extracted from874 // resolved per call from the operation's `servers` (extracted from
866 // the spec), so we never bake a derived base URL into the config.875 // the spec), so we never bake a derived base URL into the config.
867 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),876 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
877 ...(config.displayDomain ? { displayDomain: config.displayDomain } : {}),
868 ...(config.headers ? { headers: config.headers } : {}),878 ...(config.headers ? { headers: config.headers } : {}),
869 ...(config.queryParams ? { queryParams: config.queryParams } : {}),879 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
870 ...(config.specFormat ? { specFormat: config.specFormat } : {}),880 ...(config.specFormat ? { specFormat: config.specFormat } : {}),
packages/plugins/openapi/src/sdk/presets.ts+3−8
@@ -145,7 +145,9 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 144 unmodified lines
145 id: "axiom",145 id: "axiom",
146 name: "Axiom",146 name: "Axiom",
147 summary: "Log ingestion, querying, datasets, and monitors.",147 summary: "Log ingestion, querying, datasets, and monitors.",
148 url: "https://axiom.co/docs/restapi/versions/v2.json",148 // axiom.co/docs/restapi/versions/v2.json now 404s; the docs repo is where
149 // Axiom actually publishes the spec.
150 url: "https://raw.githubusercontent.com/axiomhq/docs/main/content/docs/%28api-reference%29/restapi/versions/v2.json",
149 icon: "https://integrations.sh/logo/axiom.co",151 icon: "https://integrations.sh/logo/axiom.co",
150 },152 },
151 {153 {
@@ -169,13 +171,6 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 17 unmodified lines
169 url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",171 url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",
170 icon: "https://integrations.sh/logo/digitalocean.com",172 icon: "https://integrations.sh/logo/digitalocean.com",
171 },173 },
172 {
173 id: "petstore",
174 name: "Petstore",
175 summary: "Classic OpenAPI demo, no auth required.",
176 url: "https://petstore3.swagger.io/api/v3/openapi.json",
177 icon: "https://integrations.sh/logo/petstore3.swagger.io",
178 },
179 {174 {
180 id: "val-town",175 id: "val-town",
181 name: "Val Town",176 name: "Val Town",
packages/plugins/openapi/src/sdk/preview.ts+17−10
@@ -122,6 +122,9 @@ export const HeaderPreset = Schema.Struct({ · 121 unmodified lines
122 headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),122 headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),
123 /** Which headers should be stored as secrets */123 /** Which headers should be stored as secrets */
124 secretHeaders: Schema.Array(Schema.String),124 secretHeaders: Schema.Array(Schema.String),
125 /** Query parameters the strategy sends the secret in (apiKey in=query,
126 * e.g. Viator's legacy `?apiKey=`). Absent on older stored previews. */
127 secretQueryParams: Schema.optional(Schema.Array(Schema.String)),
125});128});
126export type HeaderPreset = typeof HeaderPreset.Type;129export type HeaderPreset = typeof HeaderPreset.Type;
127 130
@@ -347,6 +350,7 @@ const buildHeaderPresets = ( · 219 unmodified lines
347 350
348 const headers: Record<string, string | null> = {};351 const headers: Record<string, string | null> = {};
349 const secretHeaders: string[] = [];352 const secretHeaders: string[] = [];
353 const secretQueryParams: string[] = [];
350 const labelParts: string[] = [];354 const labelParts: string[] = [];
351 355
352 for (const scheme of resolved) {356 for (const scheme of resolved) {
@@ -363,8 +367,16 @@ const buildHeaderPresets = ( · 10 unmodified lines
363 headers[headerName] = null;367 headers[headerName] = null;
364 secretHeaders.push(headerName);368 secretHeaders.push(headerName);
365 labelParts.push(scheme.name);369 labelParts.push(scheme.name);
370 } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "query") {
371 secretQueryParams.push(Option.getOrElse(scheme.headerName, () => scheme.name));
372 labelParts.push(`${scheme.name} (query)`);
366 } else if (scheme.type === "apiKey") {373 } else if (scheme.type === "apiKey") {
367 labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);374 // Cookie (and unknown) locations are not renderable as a stored
375 // method — auth placements carry header|query — and a cookie scheme
376 // is usually the vendor console's own session, not a mintable
377 // credential. Contributing a label here used to produce a method
378 // with zero placements: an empty, unfillable card in the add flow.
379 continue;
368 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {380 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
369 return [];381 return [];
370 } else {382 } else {
@@ -372,21 +384,16 @@ const buildHeaderPresets = ( · 1 unmodified line
372 }384 }
373 }385 }
374 386
375 if (Object.keys(headers).length === 0 && resolved.length > 0) {387 // A strategy in which nothing is renderable (cookie-only, or an exotic
376 return [388 // scheme type) yields no preset rather than an empty one.
377 HeaderPreset.make({389 if (secretHeaders.length === 0 && secretQueryParams.length === 0) return [];
378 label: labelParts.join(" + "),
379 headers: {},
380 secretHeaders: [],
381 }),
382 ];
383 }
384 390
385 return [391 return [
386 HeaderPreset.make({392 HeaderPreset.make({
387 label: labelParts.join(" + "),393 label: labelParts.join(" + "),
388 headers,394 headers,
389 secretHeaders,395 secretHeaders,
396 ...(secretQueryParams.length > 0 ? { secretQueryParams } : {}),
390 }),397 }),
391 ];398 ];
392 });399 });
packages/plugins/openapi/src/sdk/spec-format.ts+12−1
@@ -41,13 +41,24 @@ export interface SpecFormatAdapter { · 40 unmodified lines
41 readonly id: string;41 readonly id: string;
42 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;42 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;
43 readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;43 readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;
44 /** Recognizes URLs this adapter can convert. Lets a hand-pasted URL take
45 * the same path a preset would: without this, a Google Discovery URL only
46 * worked through a preset carrying `specFormat`, and pasting the identical
47 * URL failed as "not OpenAPI". */
48 readonly detectsUrl?: (url: string) => boolean;
44}49}
45 50
46export const resolveSpecFormatAdapter = (51export const resolveSpecFormatAdapter = (
47 adapters: readonly SpecFormatAdapter[],52 adapters: readonly SpecFormatAdapter[],
48 id: string | undefined,53 id: string | undefined,
54 url?: string,
49): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => {55): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => {
50 if (!id) return Effect.succeed(null);56 if (!id) {
57 if (!url) return Effect.succeed(null);
58 return Effect.succeed(
59 adapters.find((candidate) => candidate.detectsUrl?.(url) === true) ?? null,
60 );
61 }
51 const adapter = adapters.find((candidate) => candidate.id === id);62 const adapter = adapters.find((candidate) => candidate.id === id);
52 if (adapter) return Effect.succeed(adapter);63 if (adapter) return Effect.succeed(adapter);
53 return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` }));64 return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` }));
packages/plugins/provider-service-split/CHANGELOG.md+16−0
@@ -1,5 +1,21 @@
1# @executor-js/plugin-provider-service-split1# @executor-js/plugin-provider-service-split
2 2
3## 0.0.20
4
5### Patch Changes
6
7- Updated dependencies []:
8 - @executor-js/plugin-openapi@1.6.6
9 - @executor-js/sdk@1.6.6
10
11## 0.0.19
12
13### Patch Changes
14
15- Updated dependencies []:
16 - @executor-js/sdk@1.6.5
17 - @executor-js/plugin-openapi@1.6.5
18
3## 0.0.1819## 0.0.18
4 20
5### Patch Changes21### Patch Changes
packages/plugins/provider-service-split/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-provider-service-split",2 "name": "@executor-js/plugin-provider-service-split",
3 "version": "0.0.18",3 "version": "0.0.20",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
packages/plugins/toolkits/CHANGELOG.md+18−0
@@ -1,5 +1,23 @@
1# @executor-js/plugin-toolkits1# @executor-js/plugin-toolkits
2 2
3## 1.5.41
4
5### Patch Changes
6
7- Updated dependencies [[`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063)]:
8 - @executor-js/react@1.4.69
9 - @executor-js/api@1.4.69
10 - @executor-js/sdk@1.6.6
11
12## 1.5.40
13
14### Patch Changes
15
16- Updated dependencies []:
17 - @executor-js/sdk@1.6.5
18 - @executor-js/api@1.4.68
19 - @executor-js/react@1.4.68
20
3## 1.5.3921## 1.5.39
4 22
5### Patch Changes23### Patch Changes
packages/plugins/toolkits/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/plugin-toolkits",2 "name": "@executor-js/plugin-toolkits",
3 "version": "1.5.39",3 "version": "1.5.41",
4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
packages/react/CHANGELOG.md+22−0
@@ -1,5 +1,27 @@
1# @executor-js/react1# @executor-js/react
2 2
3## 1.4.69
4
5### Patch Changes
6
7- [#1865](https://github.com/UsefulSoftwareCo/executor/pull/1865) [`9a1fbd5`](https://github.com/UsefulSoftwareCo/executor/commit/9a1fbd5f0de25f622f303c76f998443c1bb72063) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Desktop OAuth connects finish the moment the provider redirects**
8
9 When the desktop app runs an OAuth flow in the system browser, the app learned about completion by polling the local server once a second. The completed result sat in memory while the user watched the "Connecting…" spinner for up to a second more — about half a second wasted on average, on every connect.
10
11 The await endpoint now long-polls: the server holds the request open (up to 25 seconds per hold) and answers the instant the flow completes. The client polls one request at a time and reconnects after each answer, so requests never stack. Mixed versions stay compatible in both directions: an old client still gets its answer within one poll of a new server, and a new client against an old server behaves exactly as before.
12
13- Updated dependencies []:
14 - @executor-js/api@1.4.69
15 - @executor-js/sdk@1.6.6
16
17## 1.4.68
18
19### Patch Changes
20
21- Updated dependencies []:
22 - @executor-js/sdk@1.6.5
23 - @executor-js/api@1.4.68
24
3## 1.4.6725## 1.4.67
4 26
5### Patch Changes27### Patch Changes
packages/react/package.json+1−1
@@ -1,6 +1,6 @@
1{1{
2 "name": "@executor-js/react",2 "name": "@executor-js/react",
3 "version": "1.4.67",3 "version": "1.4.69",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
packages/react/src/api/analytics.test.ts+1−1
@@ -13,7 +13,7 @@ afterEach(() => { · 12 unmodified lines
13 13
14describe("analytics seam", () => {14describe("analytics seam", () => {
15 it("is a no-op when no client is mounted", () => {15 it("is a no-op when no client is mounted", () => {
16 expect(() => trackEvent("integration_connect_dialog_opened")).not.toThrow();16 expect(() => trackEvent("integration_browse_opened", { via: "header" })).not.toThrow();
17 });17 });
18 18
19 it("forwards name and properties to the mounted client", () => {19 it("forwards name and properties to the mounted client", () => {
packages/react/src/api/analytics.tsx+11−1
@@ -31,12 +31,22 @@ type Owner = "org" | "user"; · 30 unmodified lines
31 31
32export interface AnalyticsEvents {32export interface AnalyticsEvents {
33 // ── Integrations ─────────────────────────────────────────────────────────33 // ── Integrations ─────────────────────────────────────────────────────────
34 integration_connect_dialog_opened: {};34 /** The full-page picker was opened. Replaces the connect dialog, whose
35 * `integration_connect_dialog_opened` this supersedes — keep both readable
36 * in dashboards spanning the change. */
37 integration_browse_opened: { via: "header" | "empty-state" | "sidebar" };
35 integration_detect_submitted: {38 integration_detect_submitted: {
36 success: boolean;39 success: boolean;
37 detected_kind?: string;40 detected_kind?: string;
38 confidence?: string;41 confidence?: string;
39 };42 };
43 /** Thumbs on the AI-generated credential guidance panel — the accuracy
44 * signal for the registry's machine-written setup text. */
45 credential_guidance_rated: {
46 domain: string;
47 credential_label: string;
48 vote: "up" | "down";
49 };
40 integration_add_started: {50 integration_add_started: {
41 plugin_key: string;51 plugin_key: string;
42 via: "detect" | "manual" | "preset" | "command_palette" | "catalog";52 via: "detect" | "manual" | "preset" | "command_palette" | "catalog";
packages/react/src/api/oauth-popup.test.ts+129−1
@@ -1,6 +1,12 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import { openOAuthPopup, reserveOAuthPopup } from "./oauth-popup";3import {
4 OAUTH_POPUP_MESSAGE_TYPE,
5 openOAuthPopup,
6 openOAuthSystemBrowser,
7 reserveOAuthPopup,
8 type OAuthPopupResult,
9} from "./oauth-popup";
4 10
5type OAuthPopupTestWindow = {11type OAuthPopupTestWindow = {
6 readonly screenX: number;12 readonly screenX: number;
@@ -228,3 +234,125 @@ describe("openOAuthPopup", () => { · 221 unmodified lines
228 expect(closedCalled).toBe(false);234 expect(closedCalled).toBe(false);
229 });235 });
230});236});
237
238describe("openOAuthSystemBrowser", () => {
239 const sessionResult = (sessionId: string): OAuthPopupResult<unknown> => ({
240 type: OAUTH_POPUP_MESSAGE_TYPE,
241 ok: false,
242 sessionId,
243 error: "access denied",
244 });
245
246 const waitFor = async (predicate: () => boolean): Promise<void> => {
247 const deadline = Date.now() + 2000;
248 while (!predicate() && Date.now() < deadline) {
249 await new Promise<void>((resolve) => setTimeout(resolve, 5));
250 }
251 expect(predicate()).toBe(true);
252 };
253
254 const withFakeFetch = async (fake: typeof fetch, run: () => Promise<void>): Promise<void> => {
255 const previousFetch = globalThis.fetch;
256 globalThis.fetch = fake;
257 // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always restore the global fetch
258 try {
259 await run();
260 } finally {
261 globalThis.fetch = previousFetch;
262 }
263 };
264
265 it("never stacks overlapping await requests while the server holds one open", async () => {
266 let inFlight = 0;
267 let maxInFlight = 0;
268 let calls = 0;
269 let received: unknown = null;
270
271 // Simulates a long-polling server: the request is held ~40ms, then
272 // answers with the completed result.
273 const fake: typeof fetch = async () => {
274 calls += 1;
275 inFlight += 1;
276 maxInFlight = Math.max(maxInFlight, inFlight);
277 await new Promise<void>((resolve) => setTimeout(resolve, 40));
278 inFlight -= 1;
279 return new Response(JSON.stringify(sessionResult("s-hold")));
280 };
281
282 await withFakeFetch(fake, async () => {
283 const teardown = openOAuthSystemBrowser({
284 url: "https://auth.example/authorize",
285 sessionId: "s-hold",
286 openExternal: async () => {},
287 onResult: (result) => {
288 received = result;
289 },
290 // Far shorter than the held request: the old setInterval loop would
291 // stack many concurrent requests here.
292 pollMs: 1,
293 });
294 await waitFor(() => received !== null);
295 teardown();
296 });
297
298 expect(calls).toBe(1);
299 expect(maxInFlight).toBe(1);
300 expect(received).toMatchObject({ sessionId: "s-hold" });
301 });
302
303 it("reconnects after a pending (null) answer and delivers the next result", async () => {
304 let calls = 0;
305 let received: unknown = null;
306
307 // First request answers "still pending" (an old server, or a long-poll
308 // deadline); the second delivers the result.
309 const fake: typeof fetch = async () => {
310 calls += 1;
311 const body = calls === 1 ? null : sessionResult("s-retry");
312 return new Response(JSON.stringify(body));
313 };
314
315 await withFakeFetch(fake, async () => {
316 const teardown = openOAuthSystemBrowser({
317 url: "https://auth.example/authorize",
318 sessionId: "s-retry",
319 openExternal: async () => {},
320 onResult: (result) => {
321 received = result;
322 },
323 pollMs: 1,
324 });
325 await waitFor(() => received !== null);
326 teardown();
327 });
328
329 expect(calls).toBe(2);
330 expect(received).toMatchObject({ sessionId: "s-retry" });
331 });
332
333 it("aborts the in-flight await request on teardown", async () => {
334 const captured: { signal: AbortSignal | null } = { signal: null };
335
336 // Held open indefinitely; on abort it answers "still pending" the way a
337 // real aborted fetch stops mattering — the loop is already settled.
338 const fake: typeof fetch = (_input, init) =>
339 new Promise<Response>((resolve) => {
340 captured.signal = init?.signal ?? null;
341 init?.signal?.addEventListener("abort", () => resolve(new Response("null")));
342 });
343
344 await withFakeFetch(fake, async () => {
345 const teardown = openOAuthSystemBrowser({
346 url: "https://auth.example/authorize",
347 sessionId: "s-abort",
348 openExternal: async () => {},
349 onResult: () => {},
350 });
351 await waitFor(() => captured.signal !== null);
352 teardown();
353 await waitFor(() => captured.signal?.aborted === true);
354 });
355
356 expect(captured.signal?.aborted).toBe(true);
357 });
358});
packages/react/src/api/oauth-popup.ts+26−5
@@ -238,7 +238,13 @@ export type OpenOAuthSystemBrowserInput<TAuth> = { · 237 unmodified lines
238 readonly onResult: (data: OAuthPopupResult<TAuth>) => void;238 readonly onResult: (data: OAuthPopupResult<TAuth>) => void;
239 /** Called once if the external open itself fails (URL rejected, IPC error). */239 /** Called once if the external open itself fails (URL rejected, IPC error). */
240 readonly onOpenFailed?: (cause: unknown) => void;240 readonly onOpenFailed?: (cause: unknown) => void;
241 /** Poll cadence. Default 1000ms. */241 /**
242 * Delay between poll attempts, applied only after the previous request
243 * settles (requests never overlap). New local servers long-poll the await
244 * route and answer the moment the flow completes, so this is a reconnect
245 * cadence; old servers answer immediately, making this a plain poll
246 * interval as before. Default 1000ms.
247 */
242 readonly pollMs?: number;248 readonly pollMs?: number;
243 /** Stop polling after this many ms with no result. Default 10 minutes. */249 /** Stop polling after this many ms with no result. Default 10 minutes. */
244 readonly timeoutMs?: number;250 readonly timeoutMs?: number;
@@ -252,14 +258,14 @@ export const openOAuthSystemBrowser = <TAuth>( · 7 unmodified lines
252 input: OpenOAuthSystemBrowserInput<TAuth>,258 input: OpenOAuthSystemBrowserInput<TAuth>,
253): (() => void) => {259): (() => void) => {
254 let settled = false;260 let settled = false;
255 let pollHandle: ReturnType<typeof setInterval> | null = null;261 let pollTimer: ReturnType<typeof setTimeout> | null = null;
256 let timeoutHandle: ReturnType<typeof setTimeout> | null = null;262 let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
257 const controller = new AbortController();263 const controller = new AbortController();
258 264
259 const settle = () => {265 const settle = () => {
260 if (settled) return;266 if (settled) return;
261 settled = true;267 settled = true;
262 if (pollHandle !== null) clearInterval(pollHandle);268 if (pollTimer !== null) clearTimeout(pollTimer);
263 if (timeoutHandle !== null) clearTimeout(timeoutHandle);269 if (timeoutHandle !== null) clearTimeout(timeoutHandle);
264 controller.abort();270 controller.abort();
265 };271 };
@@ -299,8 +305,23 @@ export const openOAuthSystemBrowser = <TAuth>( · 33 unmodified lines
299 }305 }
300 })();306 })();
301 307
302 pollHandle = setInterval(() => void poll(), input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS);308 // Sequential poll loop: one request at a time, reconnecting `pollMs` after
303 void poll();309 // the previous one settles. A long-polling server may hold each request for
310 // many seconds, so a fixed interval would stack overlapping requests.
311 const scheduleReconnect = () => {
312 if (settled) return;
313 pollTimer = setTimeout(() => {
314 pollTimer = null;
315 void runPoll();
316 }, input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS);
317 };
318 const runPoll = async () => {
319 if (settled) return;
320 await poll();
321 scheduleReconnect();
322 };
323
324 void runPoll();
304 timeoutHandle = setTimeout(() => {325 timeoutHandle = setTimeout(() => {
305 if (settled) return;326 if (settled) return;
306 settle();327 settle();
packages/react/src/components/add-account-modal.tsx+27−2
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
27 checkConnectionHealth,27 checkConnectionHealth,
28 connectionsAllAtom,28 connectionsAllAtom,
29 createOAuthClientOptimistic,29 createOAuthClientOptimistic,
30 integrationAtom,
30 integrationHealthCheckAtom,31 integrationHealthCheckAtom,
31 integrationHealthCheckCandidatesAtom,32 integrationHealthCheckCandidatesAtom,
32 oauthClientsOptimisticAtom,33 oauthClientsOptimisticAtom,
@@ -85,6 +86,7 @@ import { · 52 unmodified lines
85} from "./oauth-client-form";86} from "./oauth-client-form";
86import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";87import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
87import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";88import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
89import { CredentialGuidancePanel } from "./credential-guidance";
88import { PlacementLine, type AuthMethod } from "../lib/auth-placements";90import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
89import { connectionIdentifier } from "../lib/connection-name";91import { connectionIdentifier } from "../lib/connection-name";
90import { Badge } from "./badge";92import { Badge } from "./badge";
@@ -625,6 +627,14 @@ export const connectionExistsMessage = (label: string): string => · 534 unmodified lines
625 * explicit choice. Personal: a connection is most often a personal credential. */627 * explicit choice. Personal: a connection is most often a personal credential. */
626export const DEFAULT_CONNECTION_OWNER: Owner = "user";628export const DEFAULT_CONNECTION_OWNER: Owner = "user";
627 629
630/** The method the modal opens on. OAuth needs a registered app (or a DCR
631 * round-trip) before "Connect" does anything; a key is one paste. When an
632 * integration declares both, starting on OAuth greets most users with
633 * "Register app" — a dead end — while the working method sits one tab over.
634 * Prefer the first non-OAuth method; OAuth stays one click away. */
635export const preferredMethodId = (methods: readonly AuthMethod[]): string =>
636 (methods.find((method) => method.kind !== "oauth") ?? methods[0])?.id ?? "";
637
628const authMethodKey = (method: AuthMethod): string =>638const authMethodKey = (method: AuthMethod): string =>
629 method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`;639 method.source === "custom" ? `custom:${String(method.template)}` : `declared:${method.id}`;
630 640
@@ -1423,7 +1433,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 792 unmodified lines
1423 );1433 );
1424 const [addingMethod, setAddingMethod] = useState(false);1434 const [addingMethod, setAddingMethod] = useState(false);
1425 1435
1426 const [methodId, setMethodId] = useState<string>(methods[0]?.id ?? "");1436 const [methodId, setMethodId] = useState<string>(preferredMethodId(methods));
1427 // One value per distinct credential input (`variable → pasted value`). A1437 // One value per distinct credential input (`variable → pasted value`). A
1428 // single-secret method has just `{ token }`; a method with two distinct inputs1438 // single-secret method has just `{ token }`; a method with two distinct inputs
1429 // (e.g. Datadog's two keys) collects one value per variable.1439 // (e.g. Datadog's two keys) collects one value per variable.
@@ -1501,6 +1511,12 @@ function AddAccountModalView(props: AddAccountModalProps) { · 71 unmodified lines
1501 // The integration's declared health check + its candidate operations. When a1511 // The integration's declared health check + its candidate operations. When a
1502 // check is configured we probe against it; when not, the user picks one of the1512 // check is configured we probe against it; when not, the user picks one of the
1503 // candidates inline to test the key (and we save it).1513 // candidates inline to test the key (and we save it).
1514 // The integration's display URL is how the registry's credential guidance is
1515 // located — it names the provider this key belongs to.
1516 const integrationRecord = useAtomValue(integrationAtom(integration));
1517 const integrationDisplayUrl = AsyncResult.isSuccess(integrationRecord)
1518 ? integrationRecord.value?.displayUrl
1519 : undefined;
1504 const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration));1520 const healthCheckResult = useAtomValue(integrationHealthCheckAtom(integration));
1505 const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult)1521 const configuredHealthCheck = AsyncResult.isSuccess(healthCheckResult)
1506 ? healthCheckResult.value1522 ? healthCheckResult.value
@@ -1605,7 +1621,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 98 unmodified lines
1605 m.id === initialState.template || String(m.template) === initialState.template,1621 m.id === initialState.template || String(m.template) === initialState.template,
1606 )1622 )
1607 : undefined;1623 : undefined;
1608 setMethodId(initialMethod?.id ?? allMethods[0]!.id);1624 setMethodId(initialMethod?.id ?? preferredMethodId(allMethods));
1609 }, [allMethods, initialState?.template, methodId]);1625 }, [allMethods, initialState?.template, methodId]);
1610 1626
1611 // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link.1627 // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link.
@@ -2894,6 +2910,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1282 unmodified lines
2894 <div className="space-y-2">2910 <div className="space-y-2">
2895 <StepHeader index={1} label={authStepLabel} />2911 <StepHeader index={1} label={authStepLabel} />
2896 2912
2913 {/* What this key is called at the provider, the page
2914 that mints it, and their own setup steps. The
2915 question this dialog used to ask without
2916 answering. */}
2917 <CredentialGuidancePanel
2918 displayUrl={integrationDisplayUrl}
2919 methodKind={method?.kind}
2920 />
2921
2897 {isOAuth && method ? (2922 {isOAuth && method ? (
2898 cimdActive ? (2923 cimdActive ? (
2899 <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3">2924 <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3">
packages/react/src/components/auth-method-list-editor.tsx+33−15
@@ -142,10 +142,11 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 141 unmodified lines
142 <div className="flex items-center justify-between">142 <div className="flex items-center justify-between">
143 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">143 <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
144 {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}144 {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}
145 <span>145 {/* Named by what it is. "Method 1" told the reader nothing
146 Method {index + 1}146 except that there might be a Method 2, and next to a
147 {row.seedLabel ? ` · ${row.seedLabel}` : ""}147 masked value it read as a locked credential field —
148 </span>148 people tried to paste their key into it. */}
149 <span>{detected ? detectedMethodTitle(row) : `Method ${index + 1}`}</span>
149 </span>150 </span>
150 <Button151 <Button
151 type="button"152 type="button"
@@ -181,6 +182,14 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) { · 29 unmodified lines
181 );182 );
182}183}
183 184
185/** What a spec-detected method should be called: the credential it wants, plus
186 * the spec's own name for it when there is more than one to tell apart. */
187function detectedMethodTitle(row: AuthMethodRow): string {
188 const kind = row.value.kind;
189 const base = kind === "oauth" ? "OAuth" : kind === "apikey" ? "API key" : "No authentication";
190 return row.seedLabel ? `${base} · ${row.seedLabel}` : base;
191}
192
184/** One read-only `label value` line, mono value, for the detected summary. */193/** One read-only `label value` line, mono value, for the detected summary. */
185function SpecField(props: { readonly label: string; readonly value: string }) {194function SpecField(props: { readonly label: string; readonly value: string }) {
186 return (195 return (
@@ -204,16 +213,11 @@ function DetectedMethodSummary(props: { · 17 unmodified lines
204 const { value, oauthMetadata } = props;213 const { value, oauthMetadata } = props;
205 // Name the auth kind explicitly: a detection label like MCP's "Detected"214 // Name the auth kind explicitly: a detection label like MCP's "Detected"
206 // doesn't say whether it's OAuth or an API key, so surface it here.215 // doesn't say whether it's OAuth or an API key, so surface it here.
207 const kindLabel =
208 value.kind === "oauth" ? "OAuth" : value.kind === "apikey" ? "API key" : "No auth";
209 return (216 return (
210 <div className="space-y-2">217 <div className="space-y-2">
211 <p className="font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
212 {kindLabel}
213 </p>
214 <div218 <div
215 aria-disabled219 aria-disabled
216 className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"220 className="select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"
217 >221 >
218 {value.kind === "none" && (222 {value.kind === "none" && (
219 <p className="text-xs">No credential — tools are callable without an account.</p>223 <p className="text-xs">No credential — tools are callable without an account.</p>
@@ -221,10 +225,13 @@ function DetectedMethodSummary(props: { · 1 unmodified line
221 225
222 {value.kind === "apikey" &&226 {value.kind === "apikey" &&
223 (value.placements.length > 0 ? (227 (value.placements.length > 0 ? (
224 <div className="flex flex-wrap gap-x-4 gap-y-1">228 <div className="space-y-1">
225 {value.placements.map((placement, i: number) => (229 <p className="text-xs">This API takes a key, sent as:</p>
226 <PlacementLine key={i} placement={placement} />230 <div className="flex flex-wrap gap-x-4 gap-y-1">
227 ))}231 {value.placements.map((placement, i: number) => (
232 <PlacementLine key={i} placement={placement} />
233 ))}
234 </div>
228 </div>235 </div>
229 ) : null)}236 ) : null)}
230 237
@@ -246,7 +253,18 @@ function DetectedMethodSummary(props: { · 15 unmodified lines
246 ))}253 ))}
247 </div>254 </div>
248 255
249 <p className="text-[11px] text-muted-foreground">Pulled from spec. Remove to override.</p>256 {/* The question this screen used to leave open. Nothing on the add form
257 takes a credential; the key is entered once the integration exists,
258 from its own page. Saying so is the difference between a confident
259 next click and hunting for a field that is not here. */}
260 <p className="text-[11px] text-muted-foreground">
261 {/* "Declared by this API", not "read from the spec": GraphQL surfaces
262 have no spec — their methods arrive from the registry record — and
263 the claim that matters is the same either way. */}
264 {value.kind === "oauth"
265 ? "Declared by this API. You'll sign in after adding this integration."
266 : "Declared by this API. You'll enter your key after adding this integration."}
267 </p>
250 </div>268 </div>
251 );269 );
252}270}
packages/react/src/components/credential-guidance.tsx+168−0
@@ -0,0 +1,168 @@
1import { useMemo, useState } from "react";
2import { ArrowUpRightIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
3import { getDomain } from "tldts";
4
5import { trackEvent } from "../api/analytics";
6import { useCredentialGuidance } from "../lib/integrations-sh-catalog";
7import { cn } from "../lib/utils";
8
9// ---------------------------------------------------------------------------
10// What this key is, and where to get one.
11//
12// The registry records, per provider, what the credential is called, the page
13// that mints it, and the provider's own setup steps. Asking someone for a key
14// without any of that is the reason the connect form produced "Okay so what
15// type of key do I need?" — the answer existed, one fetch away, and was thrown
16// out on arrival.
17//
18// Strictly supplementary: the form works unchanged when the registry knows
19// nothing, and this renders nothing at all rather than an empty frame.
20// ---------------------------------------------------------------------------
21
22/** The registry's setup text is markdown. Links, bold and inline code are all
23 * that occur in practice, so render those three rather than pull a markdown
24 * pipeline into a form. */
25function SetupText(props: { readonly text: string }) {
26 const nodes = useMemo(
27 () =>
28 props.text
29 .split(/(\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|`[^`]+`)/g)
30 .filter((chunk) => chunk.length > 0)
31 .map((chunk, index) => {
32 const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(chunk);
33 if (link) {
34 return (
35 <a
36 key={index}
37 href={link[2]}
38 target="_blank"
39 rel="noreferrer"
40 className="font-medium text-foreground underline underline-offset-2"
41 >
42 {link[1]}
43 </a>
44 );
45 }
46 const bold = /^\*\*([^*]+)\*\*$/.exec(chunk);
47 if (bold) {
48 return (
49 <span key={index} className="font-medium text-foreground">
50 {bold[1]}
51 </span>
52 );
53 }
54 const code = /^`([^`]+)`$/.exec(chunk);
55 if (code) {
56 return (
57 <code key={index} className="font-mono text-[0.9em] text-foreground">
58 {code[1]}
59 </code>
60 );
61 }
62 return <span key={index}>{chunk}</span>;
63 }),
64 [props.text],
65 );
66 return <p className="text-xs leading-relaxed text-muted-foreground">{nodes}</p>;
67}
68
69export function CredentialGuidancePanel(props: {
70 /** The integration's display URL; the domain is read from it. */
71 readonly displayUrl: string | undefined;
72 /** "oauth" hides key guidance — there is no key to fetch. */
73 readonly methodKind: string | undefined;
74}) {
75 const domain = useMemo(
76 () => (props.displayUrl ? getDomain(props.displayUrl) : null),
77 [props.displayUrl],
78 );
79 const guidance = useCredentialGuidance(domain);
80
81 const relevant = useMemo(() => {
82 if (props.methodKind === "oauth") {
83 return guidance.find((entry) => entry.type.startsWith("oauth"));
84 }
85 // Anything that is not an OAuth grant is the key the form is asking for.
86 return guidance.find((entry) => !entry.type.startsWith("oauth")) ?? guidance[0];
87 }, [guidance, props.methodKind]);
88
89 if (!relevant || (!relevant.generateUrl && !relevant.setup)) return null;
90
91 return (
92 <div className="space-y-1.5 rounded-md border border-border/60 bg-muted/20 px-3 py-2.5">
93 <div className="flex items-baseline justify-between gap-3">
94 <span className="text-xs font-medium text-foreground">{relevant.label}</span>
95 {relevant.generateUrl ? (
96 <a
97 href={relevant.generateUrl}
98 target="_blank"
99 rel="noreferrer"
100 className="flex shrink-0 items-center gap-1 text-xs font-medium text-foreground underline underline-offset-2"
101 >
102 Get a key
103 <ArrowUpRightIcon className="size-3" aria-hidden />
104 </a>
105 ) : null}
106 </div>
107 {relevant.setup ? <SetupText text={relevant.setup} /> : null}
108 {/* Provenance, stated plainly: this text is machine-written registry
109 data, not the provider's own docs — the reader should weight it
110 accordingly (menus move, prefixes change). The thumbs are the
111 accuracy loop: votes land in analytics keyed by domain, so wrong
112 guidance is findable instead of silently misleading. */}
113 <div className="flex items-center justify-between gap-3 pt-0.5">
114 <p className="font-mono text-[10px] uppercase tracking-wide text-muted-foreground/60">
115 AI-generated guidance via{" "}
116 <a
117 href={`https://integrations.sh/${domain ?? ""}`}
118 target="_blank"
119 rel="noreferrer"
120 className="underline underline-offset-2 hover:text-muted-foreground"
121 >
122 integrations.sh
123 </a>
124 </p>
125 <GuidanceVote domain={domain ?? ""} label={relevant.label} />
126 </div>
127 </div>
128 );
129}
130
131/** One vote per render of the panel; the pair collapses to the chosen thumb
132 * so a second opinion means reopening, not flip-flopping the metric. */
133function GuidanceVote(props: { readonly domain: string; readonly label: string }) {
134 const [voted, setVoted] = useState<"up" | "down" | null>(null);
135 const vote = (value: "up" | "down") => {
136 if (voted) return;
137 setVoted(value);
138 trackEvent("credential_guidance_rated", {
139 domain: props.domain,
140 credential_label: props.label,
141 vote: value,
142 });
143 };
144 return (
145 <span className="flex shrink-0 items-center gap-1">
146 {(["up", "down"] as const).map((value) => {
147 const Icon = value === "up" ? ThumbsUpIcon : ThumbsDownIcon;
148 if (voted && voted !== value) return null;
149 return (
150 // oxlint-disable-next-line react/forbid-elements -- an icon chip, not a Button variant
151 <button
152 key={value}
153 type="button"
154 aria-label={value === "up" ? "Guidance was accurate" : "Guidance was wrong"}
155 disabled={voted !== null}
156 onClick={() => vote(value)}
157 className={cn(
158 "rounded p-1 text-muted-foreground/50 transition-colors",
159 voted === value ? "text-foreground" : "hover:bg-muted hover:text-muted-foreground",
160 )}
161 >
162 <Icon className="size-3" aria-hidden />
163 </button>
164 );
165 })}
166 </span>
167 );
168}
packages/react/src/components/integration-favicon.tsx+0−1
@@ -209,7 +209,6 @@ export function IntegrationFavicon({ · 208 unmodified lines
209 alt=""209 alt=""
210 width={size}210 width={size}
211 height={size}211 height={size}
212 loading="lazy"
213 onError={() =>212 onError={() =>
214 setFailedSrcs((current) =>213 setFailedSrcs((current) =>
215 current.includes(failedCandidate) ? current : [...current, failedCandidate],214 current.includes(failedCandidate) ? current : [...current, failedCandidate],
packages/react/src/console-routes.ts+2−0
@@ -37,6 +37,7 @@ export const CONSOLE_ROUTE_PATHS = [ · 36 unmodified lines
37 "/connect/$integrationSlug",37 "/connect/$integrationSlug",
38 "/integrations/$namespace",38 "/integrations/$namespace",
39 "/integrations/add/$pluginKey",39 "/integrations/add/$pluginKey",
40 "/integrations/browse",
40 "/policies",41 "/policies",
41 "/secrets",42 "/secrets",
42 "/tools",43 "/tools",
@@ -78,6 +79,7 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 35 unmodified lines
78 "/integrations/add/$pluginKey",79 "/integrations/add/$pluginKey",
79 route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")),80 route("/integrations/add/$pluginKey", file("integrations.add.$pluginKey.tsx")),
80 ],81 ],
82 ["/integrations/browse", route("/integrations/browse", file("integrations.browse.tsx"))],
81 ["/policies", route("/policies", file("policies.tsx"))],83 ["/policies", route("/policies", file("policies.tsx"))],
82 ["/secrets", route("/secrets", file("secrets.tsx"))],84 ["/secrets", route("/secrets", file("secrets.tsx"))],
83 ["/tools", route("/tools", file("tools.tsx"))],85 ["/tools", route("/tools", file("tools.tsx"))],
packages/react/src/lib/auth-placements.test.ts+23−1
@@ -1,7 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";2import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";
3 3
4import { authMethodsFromDescriptors } from "./auth-placements";4import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements";
5 5
6describe("authMethodsFromDescriptors", () => {6describe("authMethodsFromDescriptors", () => {
7 it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => {7 it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => {
@@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines
101 expect(authMethodsFromDescriptors([])).toEqual([]);101 expect(authMethodsFromDescriptors([])).toEqual([]);
102 });102 });
103});103});
104
105describe("placementFromHeaderPattern", () => {
106 it("keeps a Bearer prefix, space included", () => {
107 expect(placementFromHeaderPattern("Authorization: Bearer {token}")).toEqual({
108 carrier: "header",
109 name: "Authorization",
110 prefix: "Bearer ",
111 });
112 });
113
114 it("an empty prefix is meaningful: Linear's keys take no Bearer", () => {
115 expect(placementFromHeaderPattern("Authorization: {api_key}")).toEqual({
116 carrier: "header",
117 name: "Authorization",
118 prefix: "",
119 });
120 });
121
122 it("rejects text that is not a header pattern", () => {
123 expect(placementFromHeaderPattern("just some prose")).toBeNull();
124 });
125});
packages/react/src/lib/auth-placements.tsx+16−0
@@ -40,6 +40,22 @@ export interface Placement { · 39 unmodified lines
40/** A fresh, empty header placement — the default first row in an editor. */40/** A fresh, empty header placement — the default first row in an editor. */
41export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" });41export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" });
42 42
43/** Parses a registry header pattern — `"Authorization: Bearer {token}"` — into
44 * a placement. The literal text between the colon and the `{variable}` is the
45 * prefix, and its ABSENCE is load-bearing: `"Authorization: {api_key}"` is how
46 * the registry says Linear's personal keys take no Bearer prefix. */
47export function placementFromHeaderPattern(pattern: string): Placement | null {
48 const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(pattern.trim());
49 if (!match) return null;
50 const rest = match[2] ?? "";
51 const brace = rest.indexOf("{");
52 return {
53 carrier: "header",
54 name: match[1] ?? "",
55 prefix: brace >= 0 ? rest.slice(0, brace) : "",
56 };
57}
58
43/** What an auth method is, presentationally. `kind` drives the credential UI:59/** What an auth method is, presentationally. `kind` drives the credential UI:
44 * `oauth` shows a Connect button, `none` creates a connection with no60 * `oauth` shows a Connect button, `none` creates a connection with no
45 * credential inputs, and apikey/custom fill secrets across `placements`.61 * credential inputs, and apikey/custom fill secrets across `placements`.
packages/react/src/lib/integrations-sh-catalog.ts+440−43
@@ -9,7 +9,7 @@ · 8 unmodified lines
9// GraphQL endpoint).9// GraphQL endpoint).
10// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
11 11
12import { useEffect, useRef, useState } from "react";12import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13import * as Data from "effect/Data";13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
@@ -28,10 +28,49 @@ export type CatalogKind = (typeof CONNECTABLE_KINDS)[number]; · 12 unmodified lines
28const isConnectableKind = (kind: string): kind is CatalogKind =>28const isConnectableKind = (kind: string): kind is CatalogKind =>
29 (CONNECTABLE_KINDS as readonly string[]).includes(kind);29 (CONNECTABLE_KINDS as readonly string[]).includes(kind);
30 30
31/** One connectable surface of a domain, as the registry reports it.
32 *
33 * `slug` is the registry's stable identifier for THIS surface, and it is the
34 * namespace the add flow seeds — which makes it the honest answer to "have I
35 * added this already?". The domain is not: a vendor's surfaces can live on
36 * other hosts entirely (GitHub's MCP server is on api.githubcopilot.com), and
37 * one domain routinely offers several surfaces that are separate
38 * integrations. */
39export interface CatalogSurface {
40 readonly kind: CatalogKind;
41 readonly slug: string;
42 /** What to point the add flow at. Absent when the registry has no
43 * machine-readable locator, in which case the surface document still has to
44 * be fetched on click. */
45 readonly url?: string;
46 /** A hand-picked product mark (Google Calendar's own logo rather than the
47 * generic G), present only on curated surfaces. */
48 readonly icon?: string;
49 /** How to authenticate, for surfaces whose connect target cannot describe
50 * it itself — a GraphQL endpoint has no spec document, so the registry is
51 * the only carrier of facts like Linear's no-Bearer-prefix header. */
52 readonly auth?: {
53 readonly kind?: string;
54 /** Header pattern, e.g. "Authorization: Bearer {token}". */
55 readonly header?: string;
56 readonly note?: string;
57 };
58 /** RFC 6902 JSON Patch the registry says to apply to the fetched spec —
59 * how a vendor's published document gets improved without hosting a fork
60 * (e.g. Neon's console session cookies posing as security schemes). */
61 readonly specOverrides?: readonly unknown[];
62}
63
31export interface CatalogSearchEntry {64export interface CatalogSearchEntry {
32 readonly domain: string;65 readonly domain: string;
66 /** The product's display name, when the row is a named product rather than
67 * a bare domain — "Outlook Mail" on graph.microsoft.com. */
68 readonly name?: string;
33 readonly description: string;69 readonly description: string;
34 readonly kinds: readonly CatalogKind[];70 readonly kinds: readonly CatalogKind[];
71 /** Present on registries new enough to report it; absent responses fall back
72 * to resolving the surface document per click. */
73 readonly surfaces?: readonly CatalogSurface[];
35}74}
36 75
37export const catalogLogoUrl = (domain: string, size: number): string =>76export const catalogLogoUrl = (domain: string, size: number): string =>
@@ -44,7 +83,14 @@ class CatalogRequestError extends Data.TaggedError("CatalogRequestError")<{ · 6 unmodified lines
44const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> =>83const fetchCatalogJson = (url: URL): Effect.Effect<unknown, CatalogRequestError> =>
45 Effect.gen(function* () {84 Effect.gen(function* () {
46 const response = yield* Effect.tryPromise({85 const response = yield* Effect.tryPromise({
47 try: () => fetch(url),86 // ALWAYS REVALIDATE. The catalog is a live service and its corrections
87 // must reach the console immediately, but the browser will honour
88 // whatever max-age the response carried when it was stored — a stale
89 // entry with a long TTL kept showing a domain that had been merged away
90 // and an MCP endpoint that does not exist, hours after the fix shipped.
91 // `no-cache` still sends the conditional request, so a 304 costs nothing
92 // when nothing has changed.
93 try: () => fetch(url, { cache: "no-cache" }),
48 catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }),94 catch: () => new CatalogRequestError({ message: `Failed to reach ${url.host}.` }),
49 });95 });
50 if (!response.ok) {96 if (!response.ok) {
@@ -66,34 +112,103 @@ const SearchResponse = Schema.Struct({ · 15 unmodified lines
66 results: Schema.Array(112 results: Schema.Array(
67 Schema.Struct({113 Schema.Struct({
68 domain: Schema.String,114 domain: Schema.String,
115 name: Schema.optional(Schema.String),
69 description: Schema.String,116 description: Schema.String,
70 kinds: Schema.Array(Schema.String),117 kinds: Schema.Array(Schema.String),
118 surfaces: Schema.optional(
119 Schema.Array(
120 Schema.Struct({
121 kind: Schema.String,
122 slug: Schema.String,
123 url: Schema.optional(Schema.String),
124 icon: Schema.optional(Schema.String),
125 auth: Schema.optional(
126 Schema.Struct({
127 kind: Schema.optional(Schema.String),
128 header: Schema.optional(Schema.String),
129 note: Schema.optional(Schema.String),
130 }),
131 ),
132 specOverrides: Schema.optional(Schema.Array(Schema.Unknown)),
133 }),
134 ),
135 ),
71 }),136 }),
72 ),137 ),
73});138});
74const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse);139const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse);
75 140
141export interface CatalogSearchPage {
142 readonly entries: readonly CatalogSearchEntry[];
143 /** Rows the registry returned BEFORE the connectable-kind filter. Paging
144 * exhaustion must key on this: a page can be full at the registry and
145 * shrink here (CLI-only rows are dropped), and judging "no more pages" by
146 * the filtered length ended the infinite scroll mid-catalog. */
147 readonly rawCount: number;
148}
149
150export const parseCatalogSearchPage = (payload: unknown): CatalogSearchPage => ({
151 entries: parseCatalogSearch(payload),
152 rawCount: Option.match(decodeSearchResponse(payload), {
153 onNone: () => 0,
154 onSome: ({ results }) => results.length,
155 }),
156});
157
76export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] =>158export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntry[] =>
77 Option.match(decodeSearchResponse(payload), {159 Option.match(decodeSearchResponse(payload), {
78 onNone: () => [],160 onNone: () => [],
79 onSome: ({ results }) =>161 onSome: ({ results }) =>
80 results162 results
81 .map((entry) => ({163 .map((entry) => {
82 domain: entry.domain,164 const surfaces = (entry.surfaces ?? []).flatMap((surface): readonly CatalogSurface[] =>
83 description: entry.description,165 isConnectableKind(surface.kind)
84 kinds: entry.kinds.filter(isConnectableKind),166 ? [
85 }))167 {
168 kind: surface.kind,
169 slug: surface.slug,
170 ...(surface.url ? { url: surface.url } : {}),
171 ...(surface.icon ? { icon: surface.icon } : {}),
172 ...(surface.auth ? { auth: surface.auth } : {}),
173 ...(surface.specOverrides && surface.specOverrides.length > 0
174 ? { specOverrides: surface.specOverrides }
175 : {}),
176 },
177 ]
178 : [],
179 );
180 return {
181 domain: entry.domain,
182 ...(entry.name && entry.name !== entry.domain ? { name: entry.name } : {}),
183 description: entry.description,
184 kinds: entry.kinds.filter(isConnectableKind),
185 ...(surfaces.length > 0 ? { surfaces } : {}),
186 };
187 })
86 .filter((entry) => entry.kinds.length > 0),188 .filter((entry) => entry.kinds.length > 0),
87 });189 });
88 190
191export interface CatalogQuery {
192 /** Free text. EMPTY IS MEANINGFUL: the registry answers an empty query with
193 * its popularity-ordered head, which is what a browse view wants. */
194 readonly query: string;
195 /** Restrict to one surface kind. Omitted means every connectable kind. */
196 readonly kind?: CatalogKind;
197 /** The registry caps this at 100. */
198 readonly limit?: number;
199 /** Ranked results to skip — how the picker pages an endless list. */
200 readonly offset?: number;
201}
202
89export const searchCatalog = (203export const searchCatalog = (
90 query: string,204 input: CatalogQuery,
91 limit = 10,205): Effect.Effect<CatalogSearchPage, CatalogRequestError> => {
92): Effect.Effect<readonly CatalogSearchEntry[], CatalogRequestError> => {
93 const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN);206 const url = new URL("/api/search", INTEGRATIONS_SH_ORIGIN);
94 url.searchParams.set("q", query);207 url.searchParams.set("q", input.query);
95 url.searchParams.set("limit", String(limit));208 url.searchParams.set("limit", String(input.limit ?? 10));
96 return Effect.map(fetchCatalogJson(url), parseCatalogSearch);209 if (input.kind) url.searchParams.set("kind", input.kind);
210 if (input.offset) url.searchParams.set("offset", String(input.offset));
211 return Effect.map(fetchCatalogJson(url), parseCatalogSearchPage);
97};212};
98 213
99// ---------------------------------------------------------------------------214// ---------------------------------------------------------------------------
@@ -135,18 +250,35 @@ const connectUrlOf = (surface: SurfaceRecord, kind: CatalogKind): string | undef · 35 unmodified lines
135 ? surface.url250 ? surface.url
136 : undefined;251 : undefined;
137 252
253const normalizedProductToken = (value: string): string =>
254 value.toLowerCase().replace(/[^a-z0-9]/g, "");
255
138export const pickConnectTarget = (256export const pickConnectTarget = (
139 payload: unknown,257 payload: unknown,
140 kind: CatalogKind,258 kind: CatalogKind,
259 preferredName?: string,
141): CatalogConnectTarget | undefined =>260): CatalogConnectTarget | undefined =>
142 Option.match(decodeSurfaceDocument(payload), {261 Option.match(decodeSurfaceDocument(payload), {
143 onNone: () => undefined,262 onNone: () => undefined,
144 onSome: ({ surfaces }) => {263 onSome: ({ surfaces }) => {
145 for (const surface of surfaces) {264 // One domain can carry several same-kind products (Google Photos
265 // Library and Picker share photos.google.com). When the caller names
266 // the product, a slug whose normalized form matches wins; first-of-kind
267 // is only the answer when nothing identifies the product.
268 const usable = surfaces.flatMap((surface) => {
146 const url = connectUrlOf(surface, kind);269 const url = connectUrlOf(surface, kind);
147 if (url) return { kind, url, ...(surface.slug ? { slug: surface.slug } : {}) };270 return url
271 ? [{ kind, url, ...(surface.slug ? { slug: surface.slug } : {}) } as CatalogConnectTarget]
272 : [];
273 });
274 if (preferredName) {
275 const token = normalizedProductToken(preferredName);
276 const named = usable.find(
277 (target) => target.slug && normalizedProductToken(target.slug) === token,
278 );
279 if (named) return named;
148 }280 }
149 return undefined;281 return usable[0];
150 },282 },
151 });283 });
152 284
@@ -155,11 +287,12 @@ export const pickConnectTarget = ( · 2 unmodified lines
155export const resolveConnectTarget = (287export const resolveConnectTarget = (
156 domain: string,288 domain: string,
157 kinds: readonly CatalogKind[],289 kinds: readonly CatalogKind[],
290 preferredName?: string,
158): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {291): Effect.Effect<CatalogConnectTarget | undefined, CatalogRequestError> => {
159 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);292 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);
160 return Effect.map(fetchCatalogJson(url), (payload) => {293 return Effect.map(fetchCatalogJson(url), (payload) => {
161 for (const kind of kinds) {294 for (const kind of kinds) {
162 const target = pickConnectTarget(payload, kind);295 const target = pickConnectTarget(payload, kind, preferredName);
163 if (target) return target;296 if (target) return target;
164 }297 }
165 return undefined;298 return undefined;
@@ -172,16 +305,27 @@ export const resolveConnectTarget = ( · 6 unmodified lines
172 305
173/** Domains already represented by a loaded plugin's presets, so the catalog306/** Domains already represented by a loaded plugin's presets, so the catalog
174 * section doesn't repeat what the preset list above it already shows. */307 * section doesn't repeat what the preset list above it already shows. */
308/** The domain a preset represents, read from its integrations.sh logo URL
309 * first (which names the domain outright) and its spec/endpoint URL second. */
310export const presetDomain = (preset: {
311 readonly icon?: string;
312 readonly url?: string;
313}): string | null => {
314 for (const candidate of [preset.icon, preset.url]) {
315 if (!candidate) continue;
316 const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate);
317 const domain = logoMatch?.[1] ?? getDomain(candidate);
318 if (domain) return domain;
319 }
320 return null;
321};
322
175export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => {323export const presetDomains = (plugins: readonly IntegrationPlugin[]): ReadonlySet<string> => {
176 const domains = new Set<string>();324 const domains = new Set<string>();
177 for (const plugin of plugins) {325 for (const plugin of plugins) {
178 for (const preset of plugin.presets ?? []) {326 for (const preset of plugin.presets ?? []) {
179 for (const candidate of [preset.icon, preset.url]) {327 const domain = presetDomain(preset);
180 if (!candidate) continue;328 if (domain) domains.add(domain);
181 const logoMatch = /^https:\/\/integrations\.sh\/logo\/([^/?]+)/.exec(candidate);
182 const domain = logoMatch?.[1] ?? getDomain(candidate);
183 if (domain) domains.add(domain);
184 }
185 }329 }
186 }330 }
187 return domains;331 return domains;
@@ -206,6 +350,9 @@ export const filterCatalogEntries = ( · 18 unmodified lines
206 .map((entry) => ({350 .map((entry) => ({
207 ...entry,351 ...entry,
208 kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)),352 kinds: entry.kinds.filter((kind) => opts.availableKinds.includes(kind)),
353 ...(entry.surfaces
354 ? { surfaces: entry.surfaces.filter((s) => opts.availableKinds.includes(s.kind)) }
355 : {}),
209 }))356 }))
210 .filter((entry) => entry.kinds.length > 0);357 .filter((entry) => entry.kinds.length > 0);
211 358
@@ -213,43 +360,293 @@ export const filterCatalogEntries = ( · 1 unmodified line
213// Hook — debounced search with an in-session response cache360// Hook — debounced search with an in-session response cache
214// ---------------------------------------------------------------------------361// ---------------------------------------------------------------------------
215 362
216const SEARCH_DEBOUNCE_MS = 250;363const SEARCH_DEBOUNCE_MS = 150;
364/** Free-text queries wait for a second character; a browse (empty query) does
365 * not, because there is nothing to narrow. */
217const MIN_QUERY_LENGTH = 2;366const MIN_QUERY_LENGTH = 2;
218const searchCache = new Map<string, readonly CatalogSearchEntry[]>();367const BROWSE_LIMIT = 60;
219 368
220export interface CatalogSearchState {369export interface CatalogSearchState {
221 readonly entries: readonly CatalogSearchEntry[];370 readonly entries: readonly CatalogSearchEntry[];
222 readonly loading: boolean;371 readonly loading: boolean;
372 /** The entries belong to a previous query, held over while the current one
373 * loads. Callers must not render them as if they answered the live query. */
374 readonly stale: boolean;
375 /** The registry could not be reached. The rest of the page still works, so
376 * this is a section-level notice rather than a page error. */
377 readonly failed: boolean;
378 /** The last page came back full, so more rows likely exist. */
379 readonly hasMore: boolean;
380 /** A further page is being fetched and appended. */
381 readonly loadingMore: boolean;
382 /** Fetch the next page. Safe to call repeatedly: no-op while a page is in
383 * flight or once the catalog is exhausted. */
384 readonly loadMore: () => void;
223}385}
224 386
225export function useCatalogSearch(rawQuery: string): CatalogSearchState {387const cacheKey = (input: CatalogQuery): string =>
226 const query = rawQuery.trim().toLowerCase();388 `${input.kind ?? "all"}:${input.limit ?? 0}:${input.query}`;
227 const [state, setState] = useState<CatalogSearchState>({ entries: [], loading: false });389/** Responses are cached for as long as the registry says they are fresh, and
390 * no longer. The first version of this cache had no expiry at all, so a
391 * session that searched once kept that answer for its whole life — a registry
392 * correction could ship and the console would keep showing the old catalog
393 * until the tab was closed. */
394const SEARCH_TTL_MS = 60_000;
395const searchCache = new Map<
396 string,
397 { readonly at: number; readonly entries: readonly CatalogSearchEntry[] }
398>();
399
400/**
401 * Browse or search the public registry.
402 *
403 * One hook for both because they are the same request with a different `q`:
404 * an empty query returns the popularity-ordered head, a non-empty one filters
405 * it. Search stays server-side and edge-cached, so no view ever downloads the
406 * multi-thousand-entry catalog.
407 */
408export function useCatalogBrowse(input: CatalogQuery): CatalogSearchState {
409 const query = input.query.trim().toLowerCase();
410 const kind = input.kind;
411 const limit = input.limit ?? BROWSE_LIMIT;
412 const requestKeyOf = (forQuery: string) =>
413 cacheKey({ query: forQuery, limit, ...(kind ? { kind } : {}) });
414 const requestKey = requestKeyOf(query);
415 const [state, setState] = useState<{
416 readonly entries: readonly CatalogSearchEntry[];
417 /** The request the entries belong to. Held-over results from a previous
418 * query are DELIBERATE (no empty flash between keystrokes), but they
419 * must be knowable as stale — rendering them unmarked is how a
420 * "calendar" search showed Gmail cards. */
421 readonly forKey: string;
422 readonly loading: boolean;
423 readonly failed: boolean;
424 }>({ entries: [], forKey: requestKey, loading: true, failed: false });
228 const generation = useRef(0);425 const generation = useRef(0);
426 // Pages beyond the first, keyed to the request they extend so a keystroke
427 // discards them with the first page rather than leaking into new results.
428 const [more, setMore] = useState<{
429 readonly key: string;
430 readonly entries: readonly CatalogSearchEntry[];
431 /** RAW rows consumed by extra pages — the server pages by raw index, and
432 * the connectable-kind filter makes filtered counts useless as offsets. */
433 readonly rawConsumed: number;
434 readonly loadingMore: boolean;
435 readonly exhausted: boolean;
436 }>({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false });
437
438 useEffect(() => {
439 setMore({ key: requestKey, entries: [], rawConsumed: 0, loadingMore: false, exhausted: false });
440 // requestKey is derived from (query, kind, limit) — same trigger set.
441 // eslint-disable-next-line react-hooks/exhaustive-deps
442 }, [query, kind, limit]);
229 443
230 useEffect(() => {444 useEffect(() => {
231 const requestId = ++generation.current;445 const requestId = ++generation.current;
232 if (query.length < MIN_QUERY_LENGTH) {446 // A partial word is not yet a query; leave the previous results in place
233 setState({ entries: [], loading: false });447 // rather than flashing an empty list between keystrokes.
448 if (query.length > 0 && query.length < MIN_QUERY_LENGTH) {
449 // Held entries keep their own forKey, so the hook reports them stale.
450 setState((previous) => ({ ...previous, loading: false }));
234 return;451 return;
235 }452 }
236 const cached = searchCache.get(query);453 const request: CatalogQuery = { query, limit, ...(kind ? { kind } : {}) };
237 if (cached) {454 const cached = searchCache.get(cacheKey(request));
238 setState({ entries: cached, loading: false });455 if (cached && Date.now() - cached.at < SEARCH_TTL_MS) {
456 setState({
457 entries: cached.entries,
458 forKey: cacheKey(request),
459 loading: false,
460 failed: false,
461 });
239 return;462 return;
240 }463 }
241 setState((previous) => ({ ...previous, loading: true }));464 setState((previous) => ({ ...previous, loading: true }));
242 const timer = setTimeout(() => {465 const timer = setTimeout(
243 void Effect.runPromiseExit(searchCatalog(query)).then((exit) => {466 () => {
244 if (Exit.isSuccess(exit)) searchCache.set(query, exit.value);467 void Effect.runPromiseExit(searchCatalog(request)).then((exit) => {
245 if (generation.current !== requestId) return;468 if (Exit.isSuccess(exit)) {
246 // Reachability is best-effort — the presets and URL detection above469 searchCache.set(cacheKey(request), { at: Date.now(), entries: exit.value.entries });
247 // the catalog section keep working without it.470 }
248 setState({ entries: Exit.isSuccess(exit) ? exit.value : [], loading: false });471 if (generation.current !== requestId) return;
249 });472 setState({
250 }, SEARCH_DEBOUNCE_MS);473 entries: Exit.isSuccess(exit) ? exit.value.entries : [],
474 forKey: cacheKey(request),
475 loading: false,
476 failed: Exit.isFailure(exit),
477 });
478 });
479 },
480 // The first browse should not sit behind a debounce meant for typing.
481 query.length === 0 ? 0 : SEARCH_DEBOUNCE_MS,
482 );
251 return () => clearTimeout(timer);483 return () => clearTimeout(timer);
252 }, [query]);484 }, [query, kind, limit]);
485
486 const extras = more.key === requestKey ? more : null;
487 const baseCount = state.entries.length;
488 const hasMore =
489 !state.loading &&
490 !state.failed &&
491 baseCount >= limit &&
492 !(extras?.exhausted ?? false) &&
493 !(query.length > 0 && query.length < MIN_QUERY_LENGTH);
494
495 // SYNCHRONOUS in-flight guard. A fast scroll fires the sentinel several
496 // times before React commits `loadingMore`, and every call then saw the
497 // stale false, fetched the SAME offset in parallel, appended the duplicate
498 // page, and pushed the next offset past rows that were never shown. A ref
499 // is settled the instant the first call claims it.
500 const pageInFlight = useRef(false);
501
502 const loadMore = useCallback(() => {
503 if (!hasMore || pageInFlight.current) return;
504 pageInFlight.current = true;
505 setMore((previous) =>
506 previous.key === requestKey
507 ? { ...previous, loadingMore: true }
508 : { key: requestKey, entries: [], rawConsumed: 0, loadingMore: true, exhausted: false },
509 );
510 // The first page consumed `limit` raw rows (were it short, hasMore would
511 // already be false); extra pages report their raw size.
512 const request: CatalogQuery = {
513 query,
514 limit,
515 offset: limit + (extras?.rawConsumed ?? 0),
516 ...(kind ? { kind } : {}),
517 };
518 void Effect.runPromiseExit(searchCatalog(request)).then((exit) => {
519 pageInFlight.current = false;
520 setMore((previous) => {
521 if (previous.key !== requestKey) return previous;
522 const page = Exit.isSuccess(exit) ? exit.value : { entries: [], rawCount: 0 };
523 return {
524 key: requestKey,
525 entries: [...previous.entries, ...page.entries],
526 rawConsumed: previous.rawConsumed + page.rawCount,
527 loadingMore: false,
528 // Exhaustion keys on the RAW page size: the connectable-kind filter
529 // shrinks pages, and a failed fetch is not the end of the catalog —
530 // the sentinel simply tries again next time it scrolls into view.
531 exhausted: Exit.isSuccess(exit) && page.rawCount < limit,
532 };
533 });
534 });
535 }, [hasMore, requestKey, query, kind, limit, extras?.rawConsumed]);
536
537 const entries = useMemo(() => {
538 if (!extras || extras.entries.length === 0) return state.entries;
539 // Ranking can shift between page fetches (the live index moves); a row
540 // that slid across a page boundary must not render twice.
541 const seen = new Set(state.entries.map((entry) => `${entry.domain}|${entry.name ?? ""}`));
542 return [
543 ...state.entries,
544 ...extras.entries.filter((entry) => {
545 const key = `${entry.domain}|${entry.name ?? ""}`;
546 if (seen.has(key)) return false;
547 seen.add(key);
548 return true;
549 }),
550 ];
551 }, [state.entries, extras]);
552
553 return {
554 entries,
555 loading: state.loading,
556 failed: state.failed,
557 /** The rendered entries belong to a PREVIOUS query (held over while the
558 * current one loads, or while the input is one character). Callers
559 * decide the treatment; unmarked stale results read as wrong results. */
560 stale: state.forKey !== requestKey,
561 hasMore: hasMore && state.forKey === requestKey,
562 loadingMore: extras?.loadingMore ?? false,
563 loadMore,
564 };
565}
566
567// ---------------------------------------------------------------------------
568// Credential guidance
569//
570// The per-domain surface document records, for each way of authenticating,
571// what the credential is called at the provider, the page that mints it, and
572// the provider's own setup steps. The console fetched this document for its
573// connect URL and discarded the rest — which is why the add-connection modal
574// could not answer "what kind of key do I need?" or "where do I get one?"
575// while asking for exactly that.
576// ---------------------------------------------------------------------------
577
578export interface CredentialGuidance {
579 readonly id: string;
580 readonly type: string;
581 /** What the provider calls this credential ("Personal API key"). */
582 readonly label: string;
583 /** The page that mints it. */
584 readonly generateUrl?: string;
585 /** The provider's own instructions, as markdown. */
586 readonly setup?: string;
587}
588
589const CredentialsDocument = Schema.Struct({
590 credentials: Schema.optional(
591 Schema.Record(
592 Schema.String,
593 Schema.Struct({
594 type: Schema.optional(Schema.String),
595 label: Schema.optional(Schema.String),
596 generateUrl: Schema.optional(Schema.String),
597 setup: Schema.optional(Schema.String),
598 }),
599 ),
600 ),
601});
602const decodeCredentials = Schema.decodeUnknownOption(CredentialsDocument);
603
604export const fetchCredentialGuidance = (
605 domain: string,
606): Effect.Effect<readonly CredentialGuidance[], CatalogRequestError> => {
607 const url = new URL(`/api/${encodeURIComponent(domain)}/surface`, INTEGRATIONS_SH_ORIGIN);
608 return Effect.map(fetchCatalogJson(url), (payload) =>
609 Option.match(decodeCredentials(payload), {
610 onNone: () => [],
611 onSome: ({ credentials }) =>
612 Object.entries(credentials ?? {}).map(([id, value]) => ({
613 id,
614 type: value.type ?? "unknown",
615 label: value.label ?? id,
616 ...(value.generateUrl ? { generateUrl: value.generateUrl } : {}),
617 ...(value.setup ? { setup: value.setup } : {}),
618 })),
619 }),
620 );
621};
622
623const guidanceCache = new Map<string, readonly CredentialGuidance[]>();
624
625/** Guidance for a domain, or an empty list when the registry has none. Never
626 * fails loudly: this is help text, and a connection form must work without it. */
627export function useCredentialGuidance(domain: string | null): readonly CredentialGuidance[] {
628 const [state, setState] = useState<readonly CredentialGuidance[]>([]);
629
630 useEffect(() => {
631 if (!domain) {
632 setState([]);
633 return;
634 }
635 const cached = guidanceCache.get(domain);
636 if (cached) {
637 setState(cached);
638 return;
639 }
640 let live = true;
641 void Effect.runPromiseExit(fetchCredentialGuidance(domain)).then((exit) => {
642 const value = Exit.isSuccess(exit) ? exit.value : [];
643 guidanceCache.set(domain, value);
644 if (live) setState(value);
645 });
646 return () => {
647 live = false;
648 };
649 }, [domain]);
253 650
254 return state;651 return state;
255}652}
packages/react/src/multiplayer/shell.tsx+10−10
@@ -1,4 +1,4 @@
1import { Link, Outlet, useLocation, useParams } from "@tanstack/react-router";1import { Link, Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect, useRef, useState, type ReactNode } from "react";2import { useEffect, useRef, useState, type ReactNode } from "react";
3import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -23,7 +23,6 @@ import { · 18 unmodified lines
23} from "../components/integration-favicon";23} from "../components/integration-favicon";
24import { CommandPalette } from "../components/command-palette";24import { CommandPalette } from "../components/command-palette";
25import { Wordmark } from "../components/wordmark";25import { Wordmark } from "../components/wordmark";
26import { ConnectDialog } from "../pages/integrations";
27import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";26import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
28import { useAuth } from "./auth-context";27import { useAuth } from "./auth-context";
29 28
@@ -428,7 +427,13 @@ export function Shell(props: ShellProps) { · 398 unmodified lines
428 const lastPathname = useRef(pathname);427 const lastPathname = useRef(pathname);
429 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);428 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
430 const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);429 const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
431 const [connectIntegrationOpen, setConnectIntegrationOpen] = useState(false);430 const navigate = useNavigate();
431 // The connect dialog became the full-page picker; the sidebar affordance
432 // navigates instead of opening a modal.
433 const openIntegrationBrowse = () => {
434 trackEvent("integration_browse_opened", { via: "sidebar" });
435 void navigate({ to: "/{-$orgSlug}/integrations/browse" });
436 };
432 if (lastPathname.current !== pathname) {437 if (lastPathname.current !== pathname) {
433 lastPathname.current = pathname;438 lastPathname.current = pathname;
434 if (mobileSidebarOpen) setMobileSidebarOpen(false);439 if (mobileSidebarOpen) setMobileSidebarOpen(false);
@@ -446,17 +451,13 @@ export function Shell(props: ShellProps) { · 11 unmodified lines
446 return (451 return (
447 <div className="flex h-screen overflow-hidden">452 <div className="flex h-screen overflow-hidden">
448 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />453 <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
449 <ConnectDialog open={connectIntegrationOpen} onOpenChange={setConnectIntegrationOpen} />
450 {/* Desktop sidebar */}454 {/* Desktop sidebar */}
451 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">455 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
452 <SidebarContent456 <SidebarContent
453 {...props}457 {...props}
454 pathname={pathname}458 pathname={pathname}
455 onOpenCommands={() => setCommandPaletteOpen(true)}459 onOpenCommands={() => setCommandPaletteOpen(true)}
456 onOpenIntegrationConnect={() => {460 onOpenIntegrationConnect={openIntegrationBrowse}
457 setConnectIntegrationOpen(true);
458 trackEvent("integration_connect_dialog_opened");
459 }}
460 />461 />
461 </aside>462 </aside>
462 463
@@ -502,8 +503,7 @@ export function Shell(props: ShellProps) { · 39 unmodified lines
502 }}503 }}
503 onOpenIntegrationConnect={() => {504 onOpenIntegrationConnect={() => {
504 setMobileSidebarOpen(false);505 setMobileSidebarOpen(false);
505 setConnectIntegrationOpen(true);506 openIntegrationBrowse();
506 trackEvent("integration_connect_dialog_opened");
507 }}507 }}
508 />508 />
509 </div>509 </div>
packages/react/src/pages/integration-add.tsx+10−1
@@ -15,9 +15,14 @@ export function AddIntegrationPage(props: { · 14 unmodified lines
15 url?: string;15 url?: string;
16 preset?: string;16 preset?: string;
17 namespace?: string;17 namespace?: string;
18 authHeader?: string;
19 authNote?: string;
20 authKind?: string;
21 specOverrides?: string;
18}) {22}) {
19 useExecutorDocumentTitle("Add integration");23 useExecutorDocumentTitle("Add integration");
20 const { pluginKey, url, preset, namespace } = props;24 const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
25 props;
21 const navigate = useNavigate();26 const navigate = useNavigate();
22 const integrationPlugins = useIntegrationPlugins();27 const integrationPlugins = useIntegrationPlugins();
23 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);28 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
@@ -57,6 +62,10 @@ export function AddIntegrationPage(props: { · 33 unmodified lines
57 initialUrl={url}62 initialUrl={url}
58 initialPreset={preset}63 initialPreset={preset}
59 initialNamespace={namespace}64 initialNamespace={namespace}
65 initialAuthHeader={authHeader}
66 initialAuthNote={authNote}
67 initialAuthKind={authKind}
68 initialSpecOverrides={specOverrides}
60 onComplete={(slug?: string) => {69 onComplete={(slug?: string) => {
61 trackEvent("integration_added", {70 trackEvent("integration_added", {
62 plugin_key: pluginKey,71 plugin_key: pluginKey,
packages/react/src/pages/integration-browse.tsx+1041−0
@@ -0,0 +1,1041 @@
1import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import { PlusIcon, SearchIcon } from "lucide-react";
8import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared";
9import {
10 useIntegrationPlugins,
11 type IntegrationPlugin,
12 type IntegrationPreset,
13 type IntegrationQuickAddInput,
14 type IntegrationQuickAddResult,
15} from "@executor-js/sdk/client";
16
17import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms";
18import { slugifyNamespace } from "../plugins/namespace";
19import { trackEvent } from "../api/analytics";
20import { Button } from "../components/button";
21import { Input } from "../components/input";
22import { PageHeader } from "../components/page";
23import {
24 integrationFaviconUrl,
25 integrationInferredUrl,
26 integrationPresetIconUrl,
27} from "../components/integration-favicon";
28import { Skeleton } from "../components/skeleton";
29import { useExecutorDocumentTitle } from "../lib/document-title";
30import {
31 availableCatalogKinds,
32 catalogLogoUrl,
33 filterCatalogEntries,
34 resolveConnectTarget,
35 useCatalogBrowse,
36 type CatalogKind,
37 type CatalogSearchEntry,
38 type CatalogSurface,
39} from "../lib/integrations-sh-catalog";
40
41// ---------------------------------------------------------------------------
42// The full-page integration picker.
43//
44// Replaces the connect dialog. Finding something to connect is the first task
45// of onboarding, so it gets a page and a permanent, focused search field rather
46// than a 16rem scroll window behind a header button.
47//
48// ONE ROW PER ADDABLE THING. A service exposing both an API and an MCP server
49// yields two rows, because they really are two different integrations with
50// different tools and different auth — but each row NAMES its surface
51// ("Stripe API", "Stripe MCP") rather than repeating the bare service name and
52// leaving a badge to carry the difference. Two rows reading "Stripe" look like
53// a duplicate; two rows reading "Stripe API" and "Stripe MCP" look like a
54// choice, which is what they are.
55//
56// THE REGISTRY IS THE LIST. Every connectable card is a registry row: the
57// registry carries the name, domain, description, spec or endpoint, auth
58// facts, and corrective spec overrides, and the spec itself declares how to
59// authenticate — a deployment's first-party OAuth clients bind at connect
60// time by endpoint host, not through the picker. The one exception is
61// presets WITHOUT a connect URL (local-process servers like Chrome DevTools
62// over stdio): those cannot be registry rows yet, so they remain cards.
63//
64// FACETS ARE SURFACE KIND, NOT CATEGORY. There is no taxonomy to facet on, so a
65// category rail would have to invent one. Kind is real, filtered server-side.
66// ---------------------------------------------------------------------------
67
68const KIND_TO_PLUGIN_KEY: Record<string, string> = {
69 openapi: "openapi",
70 mcp: "mcp",
71 graphql: "graphql",
72 googleDiscovery: "google",
73};
74
75const CATALOG_KIND_LABEL: Record<CatalogKind, string> = {
76 mcp: "MCP",
77 openapi: "OpenAPI",
78 graphql: "GraphQL",
79};
80
81/** How a surface is said in a row's NAME. Deliberately not the facet
82 * vocabulary: a facet filters on the spec format ("OpenAPI"), while a name
83 * says the thing you would say out loud ("Stripe API"). */
84const SURFACE_WORD: Record<string, string> = {
85 mcp: "MCP",
86 openapi: "API",
87 google: "API",
88 graphql: "GraphQL",
89};
90
91/** `Stripe` + API → `Stripe API`, but `GitHub REST` + API stays `GitHub REST`
92 * and `Emulate MCP` + MCP stays `Emulate MCP`. Appending a word the name
93 * already carries reads worse than leaving it off. */
94const withSurface = (name: string, surface: string): string => {
95 const lower = name.toLowerCase();
96 const already =
97 lower.includes(surface.toLowerCase()) ||
98 (surface === "API" && (lower.includes("api") || lower.includes("rest")));
99 return already ? name : `${name} ${surface}`;
100};
101
102/** The key on which a preset and a catalog row count as the same product:
103 * the name with surface words and punctuation stripped, plus the kind.
104 * "GitHub REST" and "GitHub API" are one product; "Stripe API" and
105 * "Stripe MCP" are not. */
106const productKey = (name: string, kind: string): string =>
107 `${name
108 .toLowerCase()
109 .replace(/\b(api|rest|graphql|mcp)\b/g, "")
110 .replace(/[^a-z0-9]/g, "")}|${kind}`;
111
112const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
113 high: 3,
114 medium: 2,
115 low: 1,
116};
117
118const bestDetection = (
119 results: readonly IntegrationDetectionResult[],
120): IntegrationDetectionResult | undefined =>
121 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
122
123/** The input either names a thing to look for or points at one. Anything with
124 * a scheme, a slash, or a host-with-TLD is a URL; everything else is a query. */
125const looksLikeUrl = (raw: string): boolean => {
126 const value = raw.trim();
127 if (value.length === 0) return false;
128 if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(value)) return true;
129 if (value.includes("/")) return true;
130 if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(value)) return true;
131 return false;
132};
133
134/** A readable product name for a bare domain: `gmail.googleapis.com` reads as
135 * "Gmail", `linear.app` as "Linear". The full domain still renders beside it,
136 * because two services can share a leading label and the domain is what
137 * actually disambiguates them. */
138const domainDisplayName = (domain: string): string => {
139 const host = domain.replace(/^www\./, "");
140 const label = host.split(".")[0] ?? host;
141 return label.charAt(0).toUpperCase() + label.slice(1);
142};
143
144/** The registry truncates descriptions to a fixed width, which lands mid-word
145 * ("…read, manage, and send m"). Trim back to the last whole word so the cut
146 * reads as deliberate. */
147const tidyDescription = (text: string): string => {
148 const trimmed = text.trim();
149 if (trimmed.length === 0) return trimmed;
150 if (/[.!?]$/.test(trimmed)) return trimmed;
151 const lastSpace = trimmed.lastIndexOf(" ");
152 return `${(lastSpace > 40 ? trimmed.slice(0, lastSpace) : trimmed).replace(/[,;:]$/, "")}…`;
153};
154
155type PresetEntry = {
156 readonly preset: IntegrationPreset;
157 readonly pluginKey: string;
158 readonly pluginLabel: string;
159};
160
161interface Row {
162 readonly key: string;
163 readonly testId: string;
164 readonly title: string;
165 /** Surface kind for product matching (productKey); not rendered. */
166 readonly kindKey: string;
167 /** The domain, shown beside a prettified title so near-namesakes stay
168 * distinguishable. Omitted when the title is already the domain. */
169 readonly domain?: string;
170 readonly description?: string;
171 readonly iconUrl?: string;
172 readonly onSelect: () => void;
173 readonly added: boolean;
174 /** Namespace of the installed integration this row matched — the View
175 * destination. Known for quick-added rows and for rows whose installed
176 * match came from the catalog list. */
177 readonly viewSlug?: string;
178 /** Added DURING this picker session (quick add). Excluded from the
179 * added-first float: the card the user just clicked must keep its place —
180 * teleporting it to the top mid-interaction is exactly the jumping this
181 * page keeps having to unlearn. It floats on the next visit. */
182 readonly freshlyAdded?: boolean;
183 readonly busy: boolean;
184 /** A click-time failure, rendered on this card rather than page-top. */
185 readonly error?: string;
186}
187
188// ---------------------------------------------------------------------------
189// Infinite scroll
190// ---------------------------------------------------------------------------
191
192/** Fires `onVisible` whenever the sentinel scrolls near the viewport. The
193 * rootMargin starts the next page a screen early, so scrolling never visibly
194 * hits the bottom of a still-growing list. */
195function LoadMoreSentinel(props: { readonly onVisible: () => void }) {
196 const ref = useRef<HTMLDivElement | null>(null);
197 const { onVisible } = props;
198 useEffect(() => {
199 const node = ref.current;
200 if (!node) return;
201 const observer = new IntersectionObserver(
202 (observed) => {
203 if (observed.some((entry) => entry.isIntersecting)) onVisible();
204 },
205 { rootMargin: "600px" },
206 );
207 observer.observe(node);
208 return () => observer.disconnect();
209 }, [onVisible]);
210 return <div ref={ref} aria-hidden className="h-px" />;
211}
212
213// ---------------------------------------------------------------------------
214// Row
215// ---------------------------------------------------------------------------
216
217/** Registry logos 404 for plenty of hosts; a broken-image glyph in a list of
218 * brand marks looks like a bug, so failures fall back to a neutral mark. */
219function RowIcon(props: { readonly src?: string; readonly alt: string }) {
220 const [failed, setFailed] = useState(false);
221 if (!props.src || failed) {
222 return (
223 <span
224 aria-hidden
225 className="flex size-5 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground"
226 >
227 {props.alt.charAt(0).toUpperCase()}
228 </span>
229 );
230 }
231 return (
232 <img
233 src={props.src}
234 alt=""
235 width={20}
236 height={20}
237 // NOT lazy. The console scrolls inside a nested container, and Chrome's
238 // lazy loading never brings these into view there — every icon sits
239 // pending forever while an eager load of the same URL succeeds instantly.
240 // A 20px favicon is not worth deferring anyway.
241 onError={() => setFailed(true)}
242 className="size-5 object-contain"
243 />
244 );
245}
246
247function ResultCard(props: { readonly row: Row }) {
248 const { row } = props;
249 return (
250 <div
251 data-testid={row.testId}
252 className="flex min-h-[8.5rem] flex-col gap-2.5 rounded-lg border border-border/60 p-4 transition-colors hover:border-border hover:bg-accent/20"
253 >
254 <div className="flex items-start justify-between gap-2">
255 <span className="flex size-8 shrink-0 items-center justify-center">
256 <RowIcon {...(row.iconUrl ? { src: row.iconUrl } : {})} alt={row.title} />
257 </span>
258 {row.added ? (
259 // The add happened HERE — the card is the receipt. View jumps to
260 // the integration's hub, where authenticating happens.
261 row.viewSlug ? (
262 <Button asChild variant="outline" size="sm" aria-label={`View ${row.title}`}>
263 <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: row.viewSlug }}>
264 View
265 </Link>
266 </Button>
267 ) : (
268 <span className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Added</span>
269 )
270 ) : row.busy ? (
271 <span className="px-2 py-1.5 text-xs text-muted-foreground">Adding…</span>
272 ) : (
273 <Button
274 type="button"
275 variant="outline"
276 size="sm"
277 onClick={row.onSelect}
278 // Every card's button reads "Add", so the visible label alone is
279 // useless to a screen reader; the accessible name carries the card.
280 aria-label={`Add ${row.title}`}
281 >
282 Add
283 </Button>
284 )}
285 </div>
286 <div className="min-w-0">
287 <p className="truncate text-sm font-medium text-foreground">{row.title}</p>
288 {row.domain ? (
289 <p className="truncate font-mono text-[11px] text-muted-foreground/70">{row.domain}</p>
290 ) : null}
291 </div>
292 {row.description ? (
293 <p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
294 {row.description}
295 </p>
296 ) : null}
297 {row.error ? (
298 <p role="alert" className="text-xs text-destructive">
299 {row.error}
300 </p>
301 ) : null}
302 </div>
303 );
304}
305
306function CardSkeleton(props: { readonly index: number }) {
307 const { index } = props;
308 return (
309 <div className="flex flex-col gap-2.5 rounded-lg border border-border/60 p-4">
310 <div className="flex items-start justify-between gap-2">
311 <Skeleton className="size-8 rounded-md" />
312 <Skeleton className="h-8 w-16 rounded-md" />
313 </div>
314 <div className="space-y-1.5">
315 <Skeleton className="h-3.5" style={{ width: `${40 + ((index * 11) % 30)}%` }} />
316 <Skeleton className="h-3" style={{ width: `${55 + ((index * 13) % 30)}%` }} />
317 </div>
318 <Skeleton className="h-3" style={{ width: `${70 + ((index * 7) % 25)}%` }} />
319 </div>
320 );
321}
322
323// ---------------------------------------------------------------------------
324// Quick add
325// ---------------------------------------------------------------------------
326
327type QuickAddFn = (input: IntegrationQuickAddInput) => Promise<IntegrationQuickAddResult>;
328
329/** Collects each plugin's bound quick-add callback into the page's map.
330 * A component per plugin, not a hook over a list: `useQuickAdd` is a hook,
331 * and mounting one bridge per plugin is how a fixed set of per-plugin hooks
332 * stays rules-of-hooks clean. */
333function QuickAddBridge(props: {
334 readonly pluginKey: string;
335 /** The plugin's hook, called UNCONDITIONALLY — the host renders a bridge
336 * only for plugins that have one, so this component's hook sequence never
337 * depends on a plugin object's shape. */
338 readonly useQuickAdd: () => QuickAddFn;
339 readonly register: (key: string, fn: QuickAddFn | null) => void;
340}) {
341 const { pluginKey, useQuickAdd, register } = props;
342 const fn = useQuickAdd();
343 useEffect(() => {
344 register(pluginKey, fn);
345 return () => register(pluginKey, null);
346 }, [pluginKey, fn, register]);
347 return null;
348}
349
350/** One bridge per plugin KEY: a duplicate key would share the React key and
351 * the callback slot, letting either copy's cleanup delete the other. */
352function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) {
353 const seen = new Set<string>();
354 return plugins.filter((plugin) => {
355 if (!plugin.useQuickAdd || seen.has(plugin.key)) return false;
356 seen.add(plugin.key);
357 return true;
358 });
359}
360
361// ---------------------------------------------------------------------------
362// Page
363// ---------------------------------------------------------------------------
364
365export function IntegrationBrowsePage() {
366 useExecutorDocumentTitle("Add an integration");
367 const navigate = useNavigate();
368 const integrationPlugins = useIntegrationPlugins();
369 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
370 const installed = useAtomValue(integrationsOptimisticAtom);
371
372 const [query, setQuery] = useState("");
373 const [detecting, setDetecting] = useState(false);
374 const [error, setError] = useState<string | null>(null);
375 // Resolve-on-click failures belong on the card that was clicked — a page-top
376 // alert for a card three screens down reads as a silent failure.
377 const [rowError, setRowError] = useState<{
378 readonly key: string;
379 readonly message: string;
380 } | null>(null);
381 const [resolvingDomain, setResolvingDomain] = useState<string | null>(null);
382 // One-click adds run per-card and CONCURRENTLY — adding Linear must not
383 // lock the Notion card. Keyed by row error-key (`domain|kind`).
384 const [quickAddingKeys, setQuickAddingKeys] = useState<ReadonlySet<string>>(new Set());
385 // Slugs of integrations added from this page, keyed the same way, so the
386 // card can flip to View without waiting for the catalog refetch.
387 const [quickAddedSlugs, setQuickAddedSlugs] = useState<ReadonlyMap<string, string>>(new Map());
388 const quickAdders = useRef(new Map<string, QuickAddFn>());
389 const registerQuickAdd = useCallback((key: string, fn: QuickAddFn | null) => {
390 if (fn) quickAdders.current.set(key, fn);
391 else quickAdders.current.delete(key);
392 }, []);
393
394 const isUrl = looksLikeUrl(query);
395 // A URL is a destination, not a filter — but half-typed URLs pass through
396 // here on every keystroke ("stripe.co" parses as one), and swapping the
397 // grid back to the browse head mid-typing made the whole page jump. Freeze
398 // the list on the last real query instead; the detect hint is what changes.
399 const lastTextQuery = useRef("");
400 if (!isUrl) lastTextQuery.current = query;
401 const listQuery = isUrl ? lastTextQuery.current : query;
402 const text = listQuery.trim().toLowerCase();
403
404 const availableKinds = useMemo(
405 () => availableCatalogKinds(integrationPlugins),
406 [integrationPlugins],
407 );
408 // Nothing to exclude now that the registry is the only source.
409 const excludeDomains = useMemo(() => new Set<string>(), []);
410
411 const catalog = useCatalogBrowse({ query: listQuery });
412 const catalogEntries = useMemo(() => {
413 const usable = filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds });
414 if (!catalog.stale) return usable;
415 // Held-over results from the previous query: keep only what still reads
416 // as an answer to the live text. Every whitespace token must appear in
417 // the entry's name or domain — refining "google"→"google cal" keeps
418 // cloud.google.com rows (the match lives in the domain), while a new
419 // word drops the old rows instead of parading them under the wrong
420 // query, which is how a calendar search showed Gmail. An empty text
421 // (clearing back to browse) holds nothing.
422 const tokens = listQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
423 if (tokens.length === 0) return [];
424 return usable.filter((entry) => {
425 const haystack =
426 `${entry.name ?? domainDisplayName(entry.domain)} ${entry.domain}`.toLowerCase();
427 return tokens.every((token) => haystack.includes(token));
428 });
429 }, [catalog.entries, catalog.stale, listQuery, excludeDomains, availableKinds]);
430
431 /** What is already connected, as `<slug>:<kind>`.
432 *
433 * The slug is the closest thing to an identity available today — it is the
434 * namespace the add flow seeds from a registry surface's `slug` or a
435 * preset's name. Kind is carried alongside it because slugs are NOT unique
436 * per surface: the OpenAPI and MCP presets for Stripe, Neon, Sentry and
437 * Axiom share a name and an id, so both seed the same namespace. Without the
438 * kind, adding one marks the other added — the same false positive the
439 * earlier domain match produced.
440 *
441 * This still under-reports: a connection the user renamed on the way in no
442 * longer matches, and reads as not-added. That is deliberate — offering to
443 * add something twice is recoverable, claiming something is connected when
444 * it is not is not. The real fix is recording at add time which row an
445 * integration came from; nothing here can infer it after the fact. */
446 const installedKeys = useMemo(() => {
447 const rows: readonly Integration[] = AsyncResult.isSuccess(installed) ? installed.value : [];
448 return new Set(
449 rows.map((row) => `${String(row.slug)}:${KIND_TO_PLUGIN_KEY[row.kind] ?? row.kind}`),
450 );
451 }, [installed]);
452
453 const isAdded = useCallback(
454 (kind: string, ...candidates: readonly (string | undefined)[]): boolean =>
455 candidates.some((candidate) => {
456 if (!candidate) return false;
457 return (
458 installedKeys.has(`${candidate}:${kind}`) ||
459 installedKeys.has(`${slugifyNamespace(candidate)}:${kind}`)
460 );
461 }),
462 [installedKeys],
463 );
464
465 /** The installed integration's own namespace for an added row — the View
466 * button's destination. Same matching rules as `isAdded`. */
467 const installedSlugFor = useCallback(
468 (kind: string, ...candidates: readonly (string | undefined)[]): string | undefined => {
469 for (const candidate of candidates) {
470 if (!candidate) continue;
471 if (installedKeys.has(`${candidate}:${kind}`)) return candidate;
472 const slugified = slugifyNamespace(candidate);
473 if (installedKeys.has(`${slugified}:${kind}`)) return slugified;
474 }
475 return undefined;
476 },
477 [installedKeys],
478 );
479
480 const allPresets = useMemo(() => {
481 const entries: PresetEntry[] = [];
482 for (const plugin of integrationPlugins) {
483 for (const preset of plugin.presets ?? []) {
484 entries.push({ preset, pluginKey: plugin.key, pluginLabel: plugin.label });
485 }
486 }
487 return entries;
488 }, [integrationPlugins]);
489
490 const handleDetect = useCallback(async () => {
491 const trimmed = query.trim();
492 if (trimmed.length === 0) return;
493 setDetecting(true);
494 setError(null);
495 // Detection is read-only — it inspects a URL and returns candidates without
496 // mutating the catalog, so it invalidates nothing.
497 const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] });
498 if (Exit.isFailure(exit)) {
499 trackEvent("integration_detect_submitted", { success: false });
500 setError("Couldn't reach that URL. Check it, or start from scratch below.");
501 setDetecting(false);
502 return;
503 }
504 const detected = bestDetection(exit.value);
505 if (!detected) {
506 trackEvent("integration_detect_submitted", { success: false });
507 setError("Couldn't tell what that URL exposes. Start from scratch below.");
508 setDetecting(false);
509 return;
510 }
511 trackEvent("integration_detect_submitted", {
512 success: true,
513 detected_kind: detected.kind,
514 confidence: detected.confidence,
515 });
516 const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind;
517 if (!integrationPlugins.some((plugin) => plugin.key === pluginKey)) {
518 setError(`That looks like a ${detected.kind} integration, which this server can't add.`);
519 setDetecting(false);
520 return;
521 }
522 trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" });
523 void navigate({
524 to: "/{-$orgSlug}/integrations/add/$pluginKey",
525 params: { pluginKey },
526 search: { url: trimmed, namespace: detected.slug },
527 });
528 }, [query, doDetect, navigate, integrationPlugins]);
529
530 const goToAdd = useCallback(
531 (input: {
532 readonly kind: string;
533 readonly url: string;
534 readonly slug?: string;
535 readonly domain: string;
536 readonly auth?: CatalogSurface["auth"];
537 readonly specOverrides?: CatalogSurface["specOverrides"];
538 }) => {
539 trackEvent("integration_add_started", {
540 plugin_key: input.kind,
541 via: "catalog",
542 catalog_domain: input.domain,
543 });
544 void navigate({
545 to: "/{-$orgSlug}/integrations/add/$pluginKey",
546 params: { pluginKey: input.kind },
547 search: {
548 url: input.url,
549 ...(input.slug ? { namespace: input.slug } : {}),
550 ...(input.auth?.header ? { authHeader: input.auth.header } : {}),
551 ...(input.auth?.note ? { authNote: input.auth.note } : {}),
552 ...(input.auth?.kind ? { authKind: input.auth.kind } : {}),
553 ...(input.specOverrides ? { specOverrides: JSON.stringify(input.specOverrides) } : {}),
554 },
555 });
556 },
557 [navigate],
558 );
559
560 /** One-click add, in place. The registry knew the URL and the auth
561 * indicators, so there is nothing to configure — register directly and
562 * flip the card to View, leaving the user on this page to keep adding.
563 * Returns false when it could not (no quick-add for the kind, probe or
564 * registration failed), and the caller falls back to the configuration
565 * screen, which renders the same failure with full context. */
566 const tryQuickAdd = useCallback(
567 async (input: {
568 readonly kind: CatalogKind;
569 readonly url: string;
570 readonly title: string;
571 readonly domain: string;
572 /** The row's identity key — carries the PRODUCT, not just domain|kind:
573 * one domain can offer two products of the same kind (the Google
574 * Photos Library and Picker), and a domain-level key marked both cards
575 * added when either one was. */
576 readonly rowKey: string;
577 readonly slug?: string;
578 readonly auth?: CatalogSurface["auth"];
579 readonly specOverrides?: CatalogSurface["specOverrides"];
580 }): Promise<boolean> => {
581 const fn = quickAdders.current.get(KIND_TO_PLUGIN_KEY[input.kind] ?? input.kind);
582 if (!fn) return false;
583 const rowKey = input.rowKey;
584 setQuickAddingKeys((previous) => new Set(previous).add(rowKey));
585 trackEvent("integration_add_started", {
586 plugin_key: input.kind,
587 via: "catalog",
588 catalog_domain: input.domain,
589 });
590 const result = await fn({
591 url: input.url,
592 name: input.title,
593 domain: input.domain,
594 ...(input.slug ? { slug: input.slug } : {}),
595 ...(input.auth?.header ? { authHeader: input.auth.header } : {}),
596 ...(input.auth?.kind ? { authKind: input.auth.kind } : {}),
597 ...(input.specOverrides ? { specOverrides: input.specOverrides } : {}),
598 });
599 setQuickAddingKeys((previous) => {
600 const next = new Set(previous);
601 next.delete(rowKey);
602 return next;
603 });
604 if (!result.ok) return false;
605 setQuickAddedSlugs((previous) => new Map(previous).set(rowKey, result.slug));
606 return true;
607 },
608 [],
609 );
610
611 const pickCatalogEntry = useCallback(
612 async (
613 entry: CatalogSearchEntry,
614 kind: CatalogKind,
615 title: string,
616 rowKey: string,
617 // The EXACT surface this card was built from. Never re-derived from
618 // `kind`: one domain can carry two same-kind products (Google Photos
619 // Library and Picker), and a kind lookup silently merged the clicked
620 // card's URL with the FIRST surface's slug, auth, and overrides.
621 surface?: CatalogSurface,
622 ) => {
623 const knownUrl = surface?.url;
624 if (knownUrl) {
625 const added = await tryQuickAdd({
626 kind,
627 url: knownUrl,
628 title,
629 domain: entry.domain,
630 rowKey,
631 ...(surface ? { slug: surface.slug } : {}),
632 ...(surface?.auth ? { auth: surface.auth } : {}),
633 ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}),
634 });
635 if (added) return;
636 goToAdd({
637 kind,
638 url: knownUrl,
639 domain: entry.domain,
640 ...(surface ? { slug: surface.slug } : {}),
641 ...(surface?.auth ? { auth: surface.auth } : {}),
642 ...(surface?.specOverrides ? { specOverrides: surface.specOverrides } : {}),
643 });
644 return;
645 }
646 if (resolvingDomain !== null) return;
647 setResolvingDomain(entry.domain);
648 setRowError(null);
649 // The row's product name disambiguates same-kind surfaces in the doc
650 // (the registry row itself was kinds-only, so the name is all we have).
651 const exit = await Effect.runPromiseExit(
652 resolveConnectTarget(entry.domain, [kind], entry.name ?? undefined),
653 );
654 setResolvingDomain(null);
655 if (Exit.isFailure(exit) || !exit.value) {
656 setRowError({
657 key: rowKey,
658 message: "Couldn't load connect details. Paste its URL above instead.",
659 });
660 return;
661 }
662 const target = exit.value;
663 const added = await tryQuickAdd({
664 kind: target.kind,
665 url: target.url,
666 title,
667 domain: entry.domain,
668 rowKey,
669 ...(target.slug ? { slug: target.slug } : {}),
670 });
671 if (added) return;
672 goToAdd({
673 kind: target.kind,
674 url: target.url,
675 domain: entry.domain,
676 ...(target.slug ? { slug: target.slug } : {}),
677 });
678 },
679 [goToAdd, resolvingDomain, tryQuickAdd],
680 );
681
682 const pickPreset = useCallback(
683 (entry: PresetEntry) => {
684 trackEvent("integration_add_started", {
685 plugin_key: entry.pluginKey,
686 via: "preset",
687 preset_id: entry.preset.id,
688 });
689 void navigate({
690 to: "/{-$orgSlug}/integrations/add/$pluginKey",
691 params: { pluginKey: entry.pluginKey },
692 search: { preset: entry.preset.id },
693 });
694 },
695 [navigate],
696 );
697
698 // --- Preset rows ---------------------------------------------------------
699 //
700 // Only presets WITHOUT a connect URL remain cards: a preset with a URL is a
701 // registry row's job now, while a local-process server (Chrome DevTools over
702 // stdio) has no registry representation yet.
703 const presetRows = useMemo<readonly Row[]>(() => {
704 const rows: Row[] = [];
705 for (const entry of allPresets) {
706 // A URL preset the registry lists is the registry row's job. A custom
707 // deployment preset has no registry row — hiding it would make a
708 // private API undiscoverable, so it keeps its card.
709 if (
710 (entry.preset.url !== undefined || entry.preset.endpoint !== undefined) &&
711 entry.preset.registryListed === true
712 )
713 continue;
714 if (text.length > 0) {
715 const corpus =
716 `${entry.preset.name} ${entry.preset.summary ?? ""} ${entry.preset.family ?? ""} ${entry.pluginLabel}`.toLowerCase();
717 if (!corpus.includes(text)) continue;
718 }
719 const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel;
720 const title = withSurface(entry.preset.name, surface);
721 const kindKey = entry.pluginKey === "google" ? "openapi" : entry.pluginKey;
722 rows.push({
723 key: `preset-${entry.pluginKey}-${entry.preset.id}`,
724 testId: `preset-${entry.pluginKey}-${entry.preset.id}`,
725 title,
726 kindKey,
727 ...(entry.preset.summary ? { description: entry.preset.summary } : {}),
728 ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}),
729 onSelect: () => pickPreset(entry),
730 // The rendered title is also what the add flow derives the namespace
731 // from, so the card recognises an add made through it even when the
732 // preset declares no defaultSlug and its bare name would miss.
733 added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name, title),
734 busy: false,
735 });
736 }
737 return rows;
738 }, [allPresets, text, pickPreset, isAdded]);
739
740 // --- Catalog rows: one per (service, surface) -----------------------------
741 const catalogRows = useMemo<readonly Row[]>(() => {
742 let rows = catalogEntries.flatMap((entry): readonly Row[] => {
743 const pretty = entry.name ?? domainDisplayName(entry.domain);
744 const description = entry.description ? tidyDescription(entry.description) : undefined;
745 // Prefer the registry's own per-surface records, and within them only
746 // the surfaces that carry a connect target: the registry can know a
747 // surface EXISTS without knowing where it lives (conjur.org's OpenAPI
748 // has no recorded spec URL), and a card whose Add can only fail is
749 // worse than no card. Kinds-only entries (older registries, stubs) keep
750 // the resolve-on-click fallback.
751 const surfaces: readonly (CatalogSurface | { readonly kind: CatalogKind })[] =
752 entry.surfaces && entry.surfaces.length > 0
753 ? entry.surfaces.filter((surface) => surface.url)
754 : entry.kinds.map((kind) => ({ kind }));
755 return surfaces.map((surface): Row => {
756 const known = "slug" in surface ? surface : null;
757 const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind];
758 const title = withSurface(pretty, word);
759 // Product identity, not just domain|kind: two same-kind products can
760 // share a domain (Google Photos Library and Picker).
761 const rowKey = `${entry.domain}|${surface.kind}|${known?.slug ?? pretty}`;
762 const quickAddedSlug = quickAddedSlugs.get(rowKey);
763 // Same candidate set as `added`: namespaces come from the registry
764 // slug (quick add), the domain, or the display name (classic add
765 // pages), depending on how the integration got here.
766 const viewSlug =
767 quickAddedSlug ?? installedSlugFor(surface.kind, known?.slug, entry.domain, title);
768 // Slug in the key: one domain can carry many product surfaces of the
769 // same kind (Microsoft Graph's workloads all live on one domain).
770 return {
771 key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`,
772 testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`,
773 title,
774 kindKey: surface.kind,
775 domain: entry.domain,
776 ...(description ? { description } : {}),
777 iconUrl:
778 (known && "icon" in known ? known.icon : undefined) ?? catalogLogoUrl(entry.domain, 10),
779 onSelect: () =>
780 void pickCatalogEntry(entry, surface.kind, title, rowKey, known ?? undefined),
781 added:
782 quickAddedSlug !== undefined || isAdded(surface.kind, known?.slug, entry.domain, title),
783 ...(quickAddedSlug !== undefined ? { freshlyAdded: true } : {}),
784 ...(viewSlug ? { viewSlug } : {}),
785 busy: resolvingDomain === entry.domain || quickAddingKeys.has(rowKey),
786 ...(rowError?.key === rowKey ? { error: rowError.message } : {}),
787 };
788 });
789 });
790 // A registry row for something a preset already offers is a worse copy of
791 // it: same service, no auth template, no health check. Matched on
792 // productKey — normalized name plus kind — because neither titles nor
793 // domains line up reliably: the Gmail preset's icon is Google's (so a
794 // domain comparison let a second "Gmail API" through), and the GitHub
795 // preset says "GitHub REST" where the registry row says "GitHub API".
796 const presetKeys = new Set(presetRows.map((row) => productKey(row.title, row.kindKey)));
797 rows = rows.filter((row) => !presetKeys.has(productKey(row.title, row.kindKey)));
798 if (text.length === 0) return rows;
799 // A name match beats a mention in the blurb: searching "gmail" should not
800 // rank a CRM that merely describes itself as living inside Gmail above
801 // Gmail. Stable within each group, so the registry's own order survives.
802 const isNamed = (row: Row) => `${row.title} ${row.domain ?? ""}`.toLowerCase().includes(text);
803 return [...rows.filter(isNamed), ...rows.filter((row) => !isNamed(row))];
804 }, [
805 catalogEntries,
806 isAdded,
807 installedSlugFor,
808 quickAddedSlugs,
809 quickAddingKeys,
810 resolvingDomain,
811 pickCatalogEntry,
812 text,
813 presetRows,
814 rowError,
815 ]);
816
817 // Local-process cards hold no rank of their own, so each slots in where its
818 // title falls alphabetically among the surrounding rows — pinning them to
819 // the top of a popularity-ranked list gave Chrome DevTools pride of place
820 // over everything. They also WAIT for the catalog: painting a lone Chrome
821 // DevTools card while the registry loads reads as a one-item catalog, then
822 // reflows. When the registry is unreachable they render anyway, so the page
823 // degrades to the cards that still work.
824 const results = useMemo(() => {
825 if (catalog.loading && catalogRows.length === 0 && !catalog.failed) return catalogRows;
826 const merged = [...catalogRows];
827 for (const preset of presetRows) {
828 const at = merged.findIndex(
829 (row) => row.title.localeCompare(preset.title, undefined, { sensitivity: "base" }) > 0,
830 );
831 merged.splice(at === -1 ? merged.length : at, 0, preset);
832 }
833 // What you already have leads the list — those cards are the ones with a
834 // state worth seeing (View). Stable within each half, and fresh
835 // quick-adds hold their place until the next visit.
836 const floats = (row: Row) => row.added && row.freshlyAdded !== true;
837 // Installed integrations whose registry rows are NOT in the loaded page
838 // still belong at the front — "added first" is about the user's catalog,
839 // not about which sixty rows the registry ranked highest. Synthesized
840 // from the integration record itself; a loaded row that matched an
841 // installed integration claims its slug, so nothing appears twice.
842 const claimed = new Set(merged.flatMap((row) => (row.viewSlug ? [row.viewSlug] : [])));
843 const installedList: readonly Integration[] = AsyncResult.isSuccess(installed)
844 ? installed.value
845 : [];
846 const installedRows: readonly Row[] = installedList
847 .filter(
848 (row) =>
849 row.canRemove &&
850 (row.kind === "mcp" || row.kind === "openapi" || row.kind === "graphql") &&
851 !claimed.has(String(row.slug)) &&
852 (text.length === 0 ||
853 row.name.toLowerCase().includes(text) ||
854 String(row.slug).includes(text)),
855 )
856 .map((row): Row => {
857 // The SAME icon cascade the sidebar runs — preset icon by exact
858 // identity, else the logo proxy from the integration's own URL. Two
859 // resolvers for one integration is how the sidebar and the picker
860 // ended up disagreeing about what DeepWiki looks like.
861 const iconUrl =
862 integrationPresetIconUrl(
863 {
864 id: String(row.slug),
865 kind: row.kind,
866 name: row.name,
867 ...(row.displayUrl ? { url: row.displayUrl } : {}),
868 },
869 integrationPlugins,
870 ) ??
871 integrationFaviconUrl(
872 row.displayUrl ??
873 integrationInferredUrl({ id: String(row.slug), name: row.name }) ??
874 undefined,
875 10,
876 );
877 return {
878 key: `installed-${String(row.slug)}`,
879 testId: `installed-${String(row.slug)}`,
880 title: row.name,
881 kindKey: row.kind,
882 ...(row.description && row.description !== row.name
883 ? { description: tidyDescription(row.description) }
884 : {}),
885 ...(iconUrl ? { iconUrl } : {}),
886 onSelect: () => {},
887 added: true,
888 viewSlug: String(row.slug),
889 busy: false,
890 };
891 });
892 return [...installedRows, ...merged.filter(floats), ...merged.filter((row) => !floats(row))];
893 }, [
894 presetRows,
895 catalogRows,
896 catalog.loading,
897 catalog.failed,
898 installed,
899 text,
900 integrationPlugins,
901 ]);
902
903 // Presets are local, so a list that already has them is not empty — show
904 // skeletons only when there is genuinely nothing on screen yet.
905 const loading = catalog.loading && results.length === 0;
906
907 return (
908 // PageContainer scrolls the whole page; this page pins the header and
909 // search, and ONLY the results grid scrolls — an endless list makes the
910 // page scrollbar meaningless and drags the search box off screen.
911 <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container">
912 {quickAddCapablePlugins(integrationPlugins).map((plugin) => (
913 <QuickAddBridge
914 key={plugin.key}
915 pluginKey={plugin.key}
916 useQuickAdd={plugin.useQuickAdd!}
917 register={registerQuickAdd}
918 />
919 ))}
920 <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14">
921 <PageHeader
922 title="Add an integration"
923 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."
924 />
925
926 <div className="mb-4 flex gap-2">
927 <div className="relative min-w-0 flex-1">
928 <SearchIcon
929 className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
930 aria-hidden
931 />
932 <Input
933 type="text"
934 value={query}
935 onChange={(event) => {
936 setQuery((event.target as HTMLInputElement).value);
937 setError(null);
938 }}
939 onKeyDown={(event) => {
940 if (event.key === "Enter" && isUrl) void handleDetect();
941 }}
942 placeholder="Search integrations, or paste a URL…"
943 aria-label="Search integrations, or paste a URL"
944 disabled={detecting}
945 // oxlint-disable-next-line jsx_a11y/no-autofocus -- deliberate: searching is the page's only purpose, and it is reached by an explicit "Add integration" action
946 autoFocus
947 className="h-11 pl-9 text-sm"
948 />
949 </div>
950 {isUrl ? (
951 <Button
952 className="h-11 shrink-0"
953 onClick={() => void handleDetect()}
954 disabled={detecting || query.trim().length === 0}
955 loading={detecting}
956 >
957 Add this URL
958 </Button>
959 ) : null}
960 </div>
961
962 {/* Above the results, not below: an endless list has no reachable
963 bottom, and this is the escape hatch for exactly the person the
964 list is failing. One quiet line; the label carries the action, so
965 the chips stay bare format names. */}
966 <div className="mb-8 flex flex-wrap items-center gap-x-3 gap-y-2">
967 <span className="shrink-0 text-xs text-muted-foreground">Start from scratch:</span>
968 <div className="flex shrink-0 items-center gap-1.5">
969 {integrationPlugins.map(
970 (plugin: IntegrationPlugin): ReactNode => (
971 // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant
972 <button
973 key={plugin.key}
974 type="button"
975 aria-label={`New ${plugin.label} integration from scratch`}
976 onClick={() => {
977 trackEvent("integration_add_started", {
978 plugin_key: plugin.key,
979 via: "manual",
980 });
981 void navigate({
982 to: "/{-$orgSlug}/integrations/add/$pluginKey",
983 params: { pluginKey: plugin.key },
984 });
985 }}
986 className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
987 >
988 <PlusIcon className="size-3" aria-hidden />
989 {plugin.label}
990 </button>
991 ),
992 )}
993 </div>
994 <span className="ml-auto shrink-0 text-xs text-muted-foreground">
995 Can&apos;t find it? Paste its URL above.
996 </span>
997 </div>
998
999 {error ? (
1000 <p role="alert" className="mb-4 text-xs text-destructive">
1001 {error}
1002 </p>
1003 ) : null}
1004 </div>
1005
1006 <div className="min-h-0 flex-1 overflow-y-auto">
1007 <div className="mx-auto w-full max-w-4xl px-6 pb-10 lg:px-8">
1008 {loading ? (
1009 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
1010 {Array.from({ length: 9 }).map((_, index) => (
1011 <CardSkeleton key={index} index={index} />
1012 ))}
1013 </div>
1014 ) : results.length === 0 ? (
1015 <p className="px-3 py-10 text-center text-sm text-muted-foreground">
1016 {catalog.failed
1017 ? "Couldn't load the full list right now. You can still paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly."
1018 : "Nothing matches that. Paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly."}
1019 </p>
1020 ) : (
1021 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
1022 {results.map((row) => (
1023 <ResultCard key={row.key} row={row} />
1024 ))}
1025 {catalog.loadingMore
1026 ? Array.from({ length: 3 }, (_, index) => (
1027 <CardSkeleton key={`more-${index}`} index={index} />
1028 ))
1029 : null}
1030 {catalog.hasMore ? (
1031 <div className="col-span-full">
1032 <LoadMoreSentinel onVisible={catalog.loadMore} />
1033 </div>
1034 ) : null}
1035 </div>
1036 )}
1037 </div>
1038 </div>
1039 </div>
1040 );
1041}
packages/react/src/pages/integrations.tsx+24−444
@@ -1,30 +1,15 @@
1import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";1import { Suspense, useMemo, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link } from "@tanstack/react-router";
3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";3import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import { PlusIcon } from "lucide-react";5import { PlusIcon } from "lucide-react";
8import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared";6import type { Integration } from "@executor-js/sdk/shared";
9import {7import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client";
10 useIntegrationPlugins,8import { integrationsOptimisticAtom } from "../api/atoms";
11 type IntegrationPlugin,
12 type IntegrationPreset,
13} from "@executor-js/sdk/client";
14import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms";
15import { trackEvent } from "../api/analytics";9import { trackEvent } from "../api/analytics";
16import { McpInstallCard } from "../components/mcp-install-card";10import { McpInstallCard } from "../components/mcp-install-card";
17import { Button } from "../components/button";11import { Button } from "../components/button";
18import { PageContainer, PageHeader } from "../components/page";12import { PageContainer, PageHeader } from "../components/page";
19import { Badge } from "../components/badge";
20import { Input } from "../components/input";
21import {
22 Dialog,
23 DialogContent,
24 DialogDescription,
25 DialogHeader,
26 DialogTitle,
27} from "../components/dialog";
28import {13import {
29 CardStack,14 CardStack,
30 CardStackContent,15 CardStackContent,
@@ -32,7 +17,6 @@ import { · 1 unmodified line
32 CardStackEntryActions,17 CardStackEntryActions,
33 CardStackEntryContent,18 CardStackEntryContent,
34 CardStackEntryDescription,19 CardStackEntryDescription,
35 CardStackEntryMedia,
36 CardStackEntryTitle,20 CardStackEntryTitle,
37 CardStackHeader,21 CardStackHeader,
38} from "../components/card-stack";22} from "../components/card-stack";
@@ -41,18 +25,7 @@ import { · 2 unmodified lines
41 integrationInferredUrl,25 integrationInferredUrl,
42 integrationPresetIconUrl,26 integrationPresetIconUrl,
43} from "../components/integration-favicon";27} from "../components/integration-favicon";
44import { PresetIcon } from "../components/preset-icon";
45import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";28import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";
46import {
47 availableCatalogKinds,
48 catalogLogoUrl,
49 filterCatalogEntries,
50 presetDomains,
51 resolveConnectTarget,
52 useCatalogSearch,
53 type CatalogKind,
54 type CatalogSearchEntry,
55} from "../lib/integrations-sh-catalog";
56import { IntegrationHealthSummary } from "../components/integration-health-summary";29import { IntegrationHealthSummary } from "../components/integration-health-summary";
57import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";30import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
58import { Skeleton } from "../components/skeleton";31import { Skeleton } from "../components/skeleton";
@@ -67,17 +40,6 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 8 unmodified lines
67 googleDiscovery: "google",40 googleDiscovery: "google",
68};41};
69 42
70const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
71 high: 3,
72 medium: 2,
73 low: 1,
74};
75
76const bestDetection = (
77 results: readonly IntegrationDetectionResult[],
78): IntegrationDetectionResult | undefined =>
79 [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0];
80
81// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
82// Page44// Page
83// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
@@ -86,7 +48,6 @@ export function IntegrationsPage() { · 2 unmodified lines
86 useExecutorDocumentTitle("Integrations");48 useExecutorDocumentTitle("Integrations");
87 const integrations = useAtomValue(integrationsOptimisticAtom);49 const integrations = useAtomValue(integrationsOptimisticAtom);
88 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);50 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
89 const [connectOpen, setConnectOpen] = useState(false);
90 51
91 return (52 return (
92 <PageContainer>53 <PageContainer>
@@ -94,16 +55,14 @@ export function IntegrationsPage() { · 1 unmodified line
94 title="Integrations"55 title="Integrations"
95 description="Tool providers available in this workspace."56 description="Tool providers available in this workspace."
96 actions={57 actions={
97 <Button58 <Button asChild size="sm" className="gap-1.5">
98 onClick={() => {59 <Link
99 setConnectOpen(true);60 to="/{-$orgSlug}/integrations/browse"
100 trackEvent("integration_connect_dialog_opened");61 onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
101 }}62 >
102 size="sm"63 <PlusIcon className="size-4" />
103 className="gap-1.5"64 Add integration
104 >65 </Link>
105 <PlusIcon className="size-4" />
106 Connect
107 </Button>66 </Button>
108 }67 }
109 />68 />
@@ -124,14 +83,7 @@ export function IntegrationsPage() { · 14 unmodified lines
124 ),83 ),
125 onSuccess: ({ value }) => {84 onSuccess: ({ value }) => {
126 if (value.length === 0) {85 if (value.length === 0) {
127 return (86 return <EmptyIntegrations />;
128 <EmptyIntegrations
129 onConnect={() => {
130 setConnectOpen(true);
131 trackEvent("integration_connect_dialog_opened");
132 }}
133 />
134 );
135 }87 }
136 88
137 return (89 return (
@@ -142,176 +94,15 @@ export function IntegrationsPage() { · 4 unmodified lines
142 },94 },
143 })95 })
144 )}96 )}
145
146 <ConnectDialog open={connectOpen} onOpenChange={setConnectOpen} />
147 </PageContainer>97 </PageContainer>
148 );98 );
149}99}
150 100
151// ---------------------------------------------------------------------------
152// Connect dialog — URL detection + manual plugin chooser + presets
153// ---------------------------------------------------------------------------
154
155// Heuristic: the input either looks like a URL (auto-detect) or a free-text
156// search query (filter the preset list). Anything with a scheme, slash, or
157// host-with-TLD is treated as a URL; everything else is search.
158const looksLikeUrl = (raw: string): boolean => {
159 const v = raw.trim();
160 if (v.length === 0) return false;
161 if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true;
162 if (v.includes("/")) return true;
163 if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true;
164 return false;
165};
166
167export function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) {
168 const integrationPlugins = useIntegrationPlugins();
169 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
170 const navigate = useNavigate();
171
172 const [query, setQuery] = useState("");
173 const [detecting, setDetecting] = useState(false);
174 const [error, setError] = useState<string | null>(null);
175
176 const isUrl = looksLikeUrl(query);
177 const presetSearch = isUrl ? "" : query;
178
179 const closeAndReset = useCallback(() => {
180 setQuery("");
181 setError(null);
182 setDetecting(false);
183 props.onOpenChange(false);
184 }, [props]);
185
186 const handleDetect = useCallback(async () => {
187 const trimmed = query.trim();
188 if (!trimmed) return;
189 setDetecting(true);
190 setError(null);
191 // Detection is read-only — it inspects a URL and returns candidates without
192 // mutating the catalog, so it invalidates nothing.
193 const exit = await doDetect({
194 payload: { url: trimmed },
195 reactivityKeys: [],
196 });
197 if (Exit.isFailure(exit)) {
198 trackEvent("integration_detect_submitted", { success: false });
199 setError("Detection failed. Try adding an integration manually.");
200 setDetecting(false);
201 return;
202 }
203 const results = exit.value;
204 if (results.length === 0) {
205 trackEvent("integration_detect_submitted", { success: false });
206 setError("Could not detect an integration type from this URL. Try adding manually.");
207 setDetecting(false);
208 return;
209 }
210 const detected = bestDetection(results);
211 if (!detected) {
212 trackEvent("integration_detect_submitted", { success: false });
213 setError("Could not detect an integration type from this URL. Try adding manually.");
214 setDetecting(false);
215 return;
216 }
217 trackEvent("integration_detect_submitted", {
218 success: true,
219 detected_kind: detected.kind,
220 confidence: detected.confidence,
221 });
222 const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind;
223 if (integrationPlugins.some((p) => p.key === pluginKey)) {
224 trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" });
225 closeAndReset();
226 void navigate({
227 to: "/{-$orgSlug}/integrations/add/$pluginKey",
228 params: { pluginKey },
229 search: { url: trimmed, namespace: detected.slug },
230 });
231 } else {
232 setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`);
233 setDetecting(false);
234 }
235 }, [query, doDetect, navigate, integrationPlugins, closeAndReset]);
236
237 return (
238 <Dialog
239 open={props.open}
240 onOpenChange={(open) => {
241 if (!open) closeAndReset();
242 else props.onOpenChange(open);
243 }}
244 >
245 <DialogContent className="sm:max-w-[560px]">
246 <DialogHeader>
247 <DialogTitle>Connect an integration</DialogTitle>
248 <DialogDescription>
249 Search the preset library, or paste a URL to auto-detect.
250 </DialogDescription>
251 </DialogHeader>
252
253 <div className="flex min-w-0 flex-col gap-5">
254 <div className="flex flex-col gap-2">
255 <div className="flex gap-2">
256 <Input
257 type="text"
258 value={query}
259 onChange={(e) => {
260 setQuery((e.target as HTMLInputElement).value);
261 setError(null);
262 }}
263 onKeyDown={(e) => {
264 if (e.key === "Enter" && isUrl) void handleDetect();
265 }}
266 placeholder="Search or paste a URL…"
267 disabled={detecting}
268 className="flex-1"
269 />
270 {isUrl && (
271 <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}>
272 {detecting ? "Detecting..." : "Detect"}
273 </Button>
274 )}
275 </div>
276 {error && <p className="text-xs text-destructive">{error}</p>}
277 </div>
278
279 <div className="flex flex-col gap-2">
280 <p className="text-xs font-medium text-foreground/80">Or add manually</p>
281 <div className="flex flex-wrap gap-2">
282 {integrationPlugins.map((p) => (
283 <Link
284 key={p.key}
285 to="/{-$orgSlug}/integrations/add/$pluginKey"
286 params={{ pluginKey: p.key }}
287 onClick={() => {
288 trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" });
289 closeAndReset();
290 }}
291 className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
292 >
293 {p.label}
294 </Link>
295 ))}
296 </div>
297 </div>
298
299 <PresetGrid
300 plugins={integrationPlugins}
301 onPick={closeAndReset}
302 searchQuery={presetSearch}
303 />
304 </div>
305 </DialogContent>
306 </Dialog>
307 );
308}
309
310// ---------------------------------------------------------------------------101// ---------------------------------------------------------------------------
311// Empty state102// Empty state
312// ---------------------------------------------------------------------------103// ---------------------------------------------------------------------------
313 104
314function EmptyIntegrations(props: { onConnect: () => void }) {105function EmptyIntegrations() {
315 return (106 return (
316 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">107 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
317 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">108 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
@@ -321,230 +112,19 @@ function EmptyIntegrations(props: { onConnect: () => void }) { · 3 unmodified lines
321 <p className="mb-5 text-[13px] text-muted-foreground/60">112 <p className="mb-5 text-[13px] text-muted-foreground/60">
322 Connect an integration to start curating tools.113 Connect an integration to start curating tools.
323 </p>114 </p>
324 <Button onClick={props.onConnect} size="sm" className="gap-1.5">115 <Button asChild size="sm" className="gap-1.5">
325 <PlusIcon className="size-4" />116 <Link
326 Connect an integration117 to="/{-$orgSlug}/integrations/browse"
118 onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
119 >
120 <PlusIcon className="size-4" />
121 Add an integration
122 </Link>
327 </Button>123 </Button>
328 </div>124 </div>
329 );125 );
330}126}
331 127
332// ---------------------------------------------------------------------------
333// Preset grid (for inside the Connect dialog)
334// ---------------------------------------------------------------------------
335
336type PresetEntry = {
337 preset: IntegrationPreset;
338 pluginKey: string;
339 pluginLabel: string;
340};
341
342const CATALOG_KIND_LABEL: Record<CatalogKind, string> = {
343 mcp: "MCP",
344 openapi: "OpenAPI",
345 graphql: "GraphQL",
346};
347
348function PresetGrid(props: {
349 plugins: readonly IntegrationPlugin[];
350 onPick: () => void;
351 /** Controlled filter query forwarded from the dialog's unified
352 * search/URL input. Empty string disables filtering. */
353 searchQuery?: string;
354}) {
355 const navigate = useNavigate();
356 const allPresets = useMemo(() => {
357 const entries: PresetEntry[] = [];
358 for (const plugin of props.plugins) {
359 for (const preset of plugin.presets ?? []) {
360 entries.push({
361 preset,
362 pluginKey: plugin.key,
363 pluginLabel: plugin.label,
364 });
365 }
366 }
367 return entries;
368 }, [props.plugins]);
369
370 const query = (props.searchQuery ?? "").trim();
371
372 const filtered = useMemo(() => {
373 const q = query.toLowerCase();
374 if (q.length === 0) return allPresets;
375 return allPresets.filter(({ preset, pluginLabel }) => {
376 const corpus =
377 `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase();
378 return corpus.includes(q);
379 });
380 }, [allPresets, query]);
381
382 // Long-tail search over the public integrations.sh registry, shown under the
383 // curated presets. Domains a preset already covers stay preset-only.
384 const catalog = useCatalogSearch(query);
385 const excludeDomains = useMemo(() => presetDomains(props.plugins), [props.plugins]);
386 const availableKinds = useMemo(() => availableCatalogKinds(props.plugins), [props.plugins]);
387 const catalogEntries = useMemo(
388 () => filterCatalogEntries(catalog.entries, { excludeDomains, availableKinds }),
389 [catalog.entries, excludeDomains, availableKinds],
390 );
391 const [resolvingDomain, setResolvingDomain] = useState<string | null>(null);
392 const [catalogError, setCatalogError] = useState<string | null>(null);
393
394 const pickCatalogEntry = useCallback(
395 async (entry: CatalogSearchEntry) => {
396 if (resolvingDomain !== null) return;
397 setResolvingDomain(entry.domain);
398 setCatalogError(null);
399 const exit = await Effect.runPromiseExit(resolveConnectTarget(entry.domain, entry.kinds));
400 setResolvingDomain(null);
401 if (Exit.isFailure(exit)) {
402 setCatalogError(
403 `Couldn't load connect details for ${entry.domain}. Paste a URL above instead.`,
404 );
405 return;
406 }
407 const target = exit.value;
408 if (!target) {
409 setCatalogError(
410 `No connectable endpoint is on record for ${entry.domain}. Paste a URL above to detect one.`,
411 );
412 return;
413 }
414 trackEvent("integration_add_started", {
415 plugin_key: target.kind,
416 via: "catalog",
417 catalog_domain: entry.domain,
418 });
419 props.onPick();
420 void navigate({
421 to: "/{-$orgSlug}/integrations/add/$pluginKey",
422 params: { pluginKey: target.kind },
423 search: { url: target.url, ...(target.slug ? { namespace: target.slug } : {}) },
424 });
425 },
426 [navigate, props, resolvingDomain],
427 );
428
429 const showCatalogSection = query.length > 0 && (catalogEntries.length > 0 || catalog.loading);
430
431 if (allPresets.length === 0) return null;
432
433 return (
434 <div className="flex min-w-0 flex-col gap-2">
435 <p className="text-xs font-medium text-foreground/80">Popular integrations</p>
436 <CardStack className="min-w-0">
437 {/* Fixed height keeps the dialog stable as the user filters; the
438 * inner area scrolls when the list overflows and shows an empty
439 * state when no presets match. */}
440 <CardStackContent className="h-64 overflow-y-auto">
441 {filtered.length === 0 && !showCatalogSection ? (
442 <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center">
443 <p className="text-sm text-muted-foreground">No matching integrations</p>
444 <p className="text-xs text-muted-foreground/70">
445 Paste a URL above to auto-detect, or pick an integration type manually.
446 </p>
447 </div>
448 ) : (
449 filtered.map(({ preset, pluginKey, pluginLabel }) => {
450 const search: Record<string, string> = { preset: preset.id };
451 if (preset.url) search.url = preset.url;
452 return (
453 <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild>
454 <Link
455 to="/{-$orgSlug}/integrations/add/$pluginKey"
456 params={{ pluginKey }}
457 search={search}
458 onClick={() => {
459 trackEvent("integration_add_started", {
460 plugin_key: pluginKey,
461 via: "preset",
462 preset_id: preset.id,
463 });
464 props.onPick();
465 }}
466 >
467 <CardStackEntryMedia>
468 <PresetIcon
469 {...(preset.icon ? { icon: preset.icon } : {})}
470 {...(preset.fallbackIcon ? { fallbackSrc: preset.fallbackIcon } : {})}
471 className="size-5 object-contain"
472 fallback={
473 <svg viewBox="0 0 16 16" className="size-3.5" fill="none">
474 <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" />
475 </svg>
476 }
477 />
478 </CardStackEntryMedia>
479 <CardStackEntryContent>
480 <CardStackEntryTitle>{preset.name}</CardStackEntryTitle>
481 <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription>
482 </CardStackEntryContent>
483 <CardStackEntryActions>
484 <Badge variant="secondary">{pluginLabel}</Badge>
485 </CardStackEntryActions>
486 </Link>
487 </CardStackEntry>
488 );
489 })
490 )}
491 {showCatalogSection && (
492 <>
493 {catalogEntries.map((entry) => (
494 <CardStackEntry key={`catalog-${entry.domain}`} asChild>
495 <Button
496 type="button"
497 variant="ghost"
498 onClick={() => void pickCatalogEntry(entry)}
499 className="h-auto w-full justify-start rounded-none text-left font-normal whitespace-normal hover:bg-accent/40 dark:hover:bg-accent/40"
500 >
501 <CardStackEntryMedia>
502 <img
503 src={catalogLogoUrl(entry.domain, 10)}
504 alt=""
505 className="size-5 object-contain"
506 loading="lazy"
507 />
508 </CardStackEntryMedia>
509 <CardStackEntryContent>
510 <CardStackEntryTitle>{entry.domain}</CardStackEntryTitle>
511 <CardStackEntryDescription>{entry.description}</CardStackEntryDescription>
512 </CardStackEntryContent>
513 <CardStackEntryActions>
514 {resolvingDomain === entry.domain ? (
515 <span className="text-xs text-muted-foreground">Resolving…</span>
516 ) : (
517 entry.kinds.map((kind) => (
518 <Badge key={kind} variant="secondary">
519 {CATALOG_KIND_LABEL[kind]}
520 </Badge>
521 ))
522 )}
523 </CardStackEntryActions>
524 </Button>
525 </CardStackEntry>
526 ))}
527 {catalog.loading &&
528 catalogEntries.length === 0 &&
529 Array.from({ length: 3 }).map((_, i) => (
530 <div key={`catalog-skeleton-${i}`} className="flex items-center gap-3 px-4 py-3">
531 <Skeleton className="size-5 shrink-0 rounded-md" />
532 <div className="flex min-w-0 flex-1 flex-col gap-1.5">
533 <Skeleton className="h-3.5" style={{ width: `${35 + ((i * 13) % 25)}%` }} />
534 <Skeleton className="h-3" style={{ width: `${55 + ((i * 9) % 20)}%` }} />
535 </div>
536 <Skeleton className="h-5 w-14 rounded-full" />
537 </div>
538 ))}
539 </>
540 )}
541 </CardStackContent>
542 </CardStack>
543 {catalogError && <p className="text-xs text-destructive">{catalogError}</p>}
544 </div>
545 );
546}
547
548// ---------------------------------------------------------------------------128// ---------------------------------------------------------------------------
549// Integration grid — flat list of catalog integrations, click-through to detail129// Integration grid — flat list of catalog integrations, click-through to detail
550// ---------------------------------------------------------------------------130// ---------------------------------------------------------------------------
packages/react/src/routes/integrations.add.$pluginKey.tsx+20−2
@@ -8,6 +8,14 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines
8 url: Schema.optional(Schema.String),8 url: Schema.optional(Schema.String),
9 preset: Schema.optional(Schema.String),9 preset: Schema.optional(Schema.String),
10 namespace: Schema.optional(Schema.String),10 namespace: Schema.optional(Schema.String),
11 // Registry-declared credential placement, e.g. "Authorization: {api_key}".
12 // Carried for surfaces whose connect target can't describe its own auth
13 // (GraphQL endpoints have no spec document).
14 authHeader: Schema.optional(Schema.String),
15 authNote: Schema.optional(Schema.String),
16 authKind: Schema.optional(Schema.String),
17 // JSON-encoded RFC 6902 patch the registry says to apply to the spec.
18 specOverrides: Schema.optional(Schema.String),
11 }),19 }),
12);20);
13 21
@@ -15,9 +23,19 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") · 1 unmodified line
15 validateSearch: SearchParams,23 validateSearch: SearchParams,
16 component: () => {24 component: () => {
17 const { pluginKey } = Route.useParams();25 const { pluginKey } = Route.useParams();
18 const { url, preset, namespace } = Route.useSearch();26 const { url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
27 Route.useSearch();
19 return (28 return (
20 <AddIntegrationPage pluginKey={pluginKey} url={url} preset={preset} namespace={namespace} />29 <AddIntegrationPage
30 pluginKey={pluginKey}
31 url={url}
32 preset={preset}
33 namespace={namespace}
34 authHeader={authHeader}
35 authNote={authNote}
36 authKind={authKind}
37 specOverrides={specOverrides}
38 />
21 );39 );
22 },40 },
23});41});
packages/react/src/routes/integrations.browse.tsx+7−0
@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { IntegrationBrowsePage } from "../pages/integration-browse";
4
5export const Route = createFileRoute("/{-$orgSlug}/integrations/browse")({
6 component: IntegrationBrowsePage,
7});
packages/react/src/routes/routeTree.gen.ts+22−0
@@ -18,6 +18,7 @@ import { Route as DotPoliciesRouteImport } from './policies' · 17 unmodified lines
18import { Route as DotArtifactsRouteImport } from './artifacts'18import { Route as DotArtifactsRouteImport } from './artifacts'
19import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'19import { Route as DotToolkitsDottoolkitSlugRouteImport } from './toolkits.$toolkitSlug'
20import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'20import { Route as DotResumeDotexecutionIdRouteImport } from './resume.$executionId'
21import { Route as DotIntegrationsDotbrowseRouteImport } from './integrations.browse'
21import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'22import { Route as DotIntegrationsDotnamespaceRouteImport } from './integrations.$namespace'
22import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'23import { Route as DotConnectDotintegrationSlugRouteImport } from './connect.$integrationSlug'
23import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId'24import { Route as DotArtifactsDotartifactIdRouteImport } from './artifacts.$artifactId'
@@ -70,6 +71,12 @@ const DotResumeDotexecutionIdRoute = DotResumeDotexecutionIdRouteImport.update({ · 46 unmodified lines
70 path: '/{-$orgSlug}/resume/$executionId',71 path: '/{-$orgSlug}/resume/$executionId',
71 getParentRoute: () => rootRouteImport,72 getParentRoute: () => rootRouteImport,
72} as any)73} as any)
74const DotIntegrationsDotbrowseRoute =
75 DotIntegrationsDotbrowseRouteImport.update({
76 id: '/{-$orgSlug}/integrations/browse',
77 path: '/{-$orgSlug}/integrations/browse',
78 getParentRoute: () => rootRouteImport,
79 } as any)
73const DotIntegrationsDotnamespaceRoute =80const DotIntegrationsDotnamespaceRoute =
74 DotIntegrationsDotnamespaceRouteImport.update({81 DotIntegrationsDotnamespaceRouteImport.update({
75 id: '/{-$orgSlug}/integrations/$namespace',82 id: '/{-$orgSlug}/integrations/$namespace',
@@ -112,6 +119,7 @@ export interface FileRoutesByFullPath { · 36 unmodified lines
112 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute119 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
113 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute120 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
114 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute121 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
122 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
115 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute123 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
116 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute124 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
117 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute125 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -128,6 +136,7 @@ export interface FileRoutesByTo { · 10 unmodified lines
128 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute136 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
129 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute137 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
130 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute138 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
139 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
131 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute140 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
132 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute141 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
133 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute142 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -145,6 +154,7 @@ export interface FileRoutesById { · 11 unmodified lines
145 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute154 '/{-$orgSlug}/artifacts/$artifactId': typeof DotArtifactsDotartifactIdRoute
146 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute155 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
147 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute156 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
157 '/{-$orgSlug}/integrations/browse': typeof DotIntegrationsDotbrowseRoute
148 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute158 '/{-$orgSlug}/resume/$executionId': typeof DotResumeDotexecutionIdRoute
149 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute159 '/{-$orgSlug}/toolkits/$toolkitSlug': typeof DotToolkitsDottoolkitSlugRoute
150 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute160 '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotIntegrationsDotaddDotpluginKeyRoute
@@ -163,6 +173,7 @@ export interface FileRouteTypes { · 12 unmodified lines
163 | '/{-$orgSlug}/artifacts/$artifactId'173 | '/{-$orgSlug}/artifacts/$artifactId'
164 | '/{-$orgSlug}/connect/$integrationSlug'174 | '/{-$orgSlug}/connect/$integrationSlug'
165 | '/{-$orgSlug}/integrations/$namespace'175 | '/{-$orgSlug}/integrations/$namespace'
176 | '/{-$orgSlug}/integrations/browse'
166 | '/{-$orgSlug}/resume/$executionId'177 | '/{-$orgSlug}/resume/$executionId'
167 | '/{-$orgSlug}/toolkits/$toolkitSlug'178 | '/{-$orgSlug}/toolkits/$toolkitSlug'
168 | '/{-$orgSlug}/integrations/add/$pluginKey'179 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -179,6 +190,7 @@ export interface FileRouteTypes { · 10 unmodified lines
179 | '/{-$orgSlug}/artifacts/$artifactId'190 | '/{-$orgSlug}/artifacts/$artifactId'
180 | '/{-$orgSlug}/connect/$integrationSlug'191 | '/{-$orgSlug}/connect/$integrationSlug'
181 | '/{-$orgSlug}/integrations/$namespace'192 | '/{-$orgSlug}/integrations/$namespace'
193 | '/{-$orgSlug}/integrations/browse'
182 | '/{-$orgSlug}/resume/$executionId'194 | '/{-$orgSlug}/resume/$executionId'
183 | '/{-$orgSlug}/toolkits/$toolkitSlug'195 | '/{-$orgSlug}/toolkits/$toolkitSlug'
184 | '/{-$orgSlug}/integrations/add/$pluginKey'196 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -195,6 +207,7 @@ export interface FileRouteTypes { · 10 unmodified lines
195 | '/{-$orgSlug}/artifacts/$artifactId'207 | '/{-$orgSlug}/artifacts/$artifactId'
196 | '/{-$orgSlug}/connect/$integrationSlug'208 | '/{-$orgSlug}/connect/$integrationSlug'
197 | '/{-$orgSlug}/integrations/$namespace'209 | '/{-$orgSlug}/integrations/$namespace'
210 | '/{-$orgSlug}/integrations/browse'
198 | '/{-$orgSlug}/resume/$executionId'211 | '/{-$orgSlug}/resume/$executionId'
199 | '/{-$orgSlug}/toolkits/$toolkitSlug'212 | '/{-$orgSlug}/toolkits/$toolkitSlug'
200 | '/{-$orgSlug}/integrations/add/$pluginKey'213 | '/{-$orgSlug}/integrations/add/$pluginKey'
@@ -211,6 +224,7 @@ export interface RootRouteChildren { · 10 unmodified lines
211 DotIndexRoute: typeof DotIndexRoute224 DotIndexRoute: typeof DotIndexRoute
212 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute225 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
213 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute226 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
227 DotIntegrationsDotbrowseRoute: typeof DotIntegrationsDotbrowseRoute
214 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute228 DotResumeDotexecutionIdRoute: typeof DotResumeDotexecutionIdRoute
215 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute229 DotIntegrationsDotaddDotpluginKeyRoute: typeof DotIntegrationsDotaddDotpluginKeyRoute
216 DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute230 DotPluginsDotpluginIdDotsplatRoute: typeof DotPluginsDotpluginIdDotsplatRoute
@@ -281,6 +295,13 @@ declare module '@tanstack/react-router' { · 64 unmodified lines
281 preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport295 preLoaderRoute: typeof DotResumeDotexecutionIdRouteImport
282 parentRoute: typeof rootRouteImport296 parentRoute: typeof rootRouteImport
283 }297 }
298 '/{-$orgSlug}/integrations/browse': {
299 id: '/{-$orgSlug}/integrations/browse'
300 path: '/{-$orgSlug}/integrations/browse'
301 fullPath: '/{-$orgSlug}/integrations/browse'
302 preLoaderRoute: typeof DotIntegrationsDotbrowseRouteImport
303 parentRoute: typeof rootRouteImport
304 }
284 '/{-$orgSlug}/integrations/$namespace': {305 '/{-$orgSlug}/integrations/$namespace': {
285 id: '/{-$orgSlug}/integrations/$namespace'306 id: '/{-$orgSlug}/integrations/$namespace'
286 path: '/{-$orgSlug}/integrations/$namespace'307 path: '/{-$orgSlug}/integrations/$namespace'
@@ -353,6 +374,7 @@ const rootRouteChildren: RootRouteChildren = { · 66 unmodified lines
353 DotIndexRoute: DotIndexRoute,374 DotIndexRoute: DotIndexRoute,
354 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,375 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
355 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,376 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
377 DotIntegrationsDotbrowseRoute: DotIntegrationsDotbrowseRoute,
356 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,378 DotResumeDotexecutionIdRoute: DotResumeDotexecutionIdRoute,
357 DotIntegrationsDotaddDotpluginKeyRoute:379 DotIntegrationsDotaddDotpluginKeyRoute:
358 DotIntegrationsDotaddDotpluginKeyRoute,380 DotIntegrationsDotaddDotpluginKeyRoute,
plans/kill-plugin-system.md+515−0
@@ -0,0 +1,515 @@
1# Kill the plugin system — in-place migration plan
2
3Decision (Rhys, 2026-07-27): abandon the big-bang rework
4(`UsefulSoftwareCo/executor-rework`, cloned read-only at
5`.reference/executor-rework`); fix this repo in place over a sequence of PRs,
6each keeping format/lint/typecheck/test/e2e green. Priority order: **data
7models first**, then the plugin kill, then the concept-package (apps) model,
8then the minimal SDK / platform split.
9
10RESEQUENCED (Rhys, 2026-07-28): **Phase 3 (apps/concept-package model) now
11lands BEFORE Phase 2 (plugin kill), side by side with the protocol plugins.**
12Rationale: the new model's tables are greenfield and additive — landing it
13first means the legacy apps tables never need an in-place migration; the whole
14model swaps underneath while openapi/mcp/graphql keep running as plugins.
15Structurally sound because (a) the kernel + MCP host are already plugin-free,
16so the new model's runtime never touched the registry, and (b) the apps plugin
17already exercises every extension point, so the new model ships as the apps
18plugin's new internals wearing the same costume. When 2.3 later converts the
19four kinds, apps converts as a stable, proven model instead of mid-rewrite.
20Phase 0 and 2.1 remain freely parallel — they don't touch apps.
21
22Hard line: side-by-side means new-apps-model alongside the _protocol plugins_,
23never two app models with dual read paths inside the apps kind. Existing app
24sources get a one-off migration to published-source form (enumerated
25invariants, explicit approval, no permanent compatibility path). Accepted
26cost: the new model's HTTP surface temporarily wears plugin-route ceremony
27(HttpApiGroup + handler layers) that 2.2/2.6 later deletes — wiring, not
28model. The authoring SDK (`defineApp`/`defineProvider`/`defineTool`) goes
29public early; its boundaries take Standard Schema (StandardSchemaV1 &
30StandardJSONSchemaV1), never Effect Schema, and Phase 4's vocabulary rules
31(opaque owner refs, no "tenant") bind on it from day one.
32
33## Diagnosis (verified against the code)
34
35**The plugin system is a fiction.** `packages/core/sdk/src/plugin.ts` (~850
36lines, ~20 optional hooks) pretends the plugin set is open, but:
37
38- `Integration.kind` _is_ the plugin id string; all hot paths dispatch via
39 `runtimes.get(row.plugin_id)` (`executor.ts`).
40- Core and React hardcode the closed set anyway: `migration-spec.ts` hardcodes
41 plugin ids; `packages/react` has `KIND_TO_PLUGIN_KEY` maps and an
42 `if (pluginId === "toolkits")` route branch.
43- Nobody ever shipped a fourth protocol plugin, and the two provider plugins
44 (google/microsoft) were already collapsed into openapi (#1366).
45- Whole packages exist only to support the open set: `plugin-routes.ts`
46 type-union machinery, `@executor-js/config`'s jsonc+jiti loader,
47 `packages/core/vite-plugin`'s virtual client module. The jsonc plugin path is
48 vestigial — no `executor.jsonc` exists anywhere in the repo, no docs mention
49 it.
50- Toolkits (~880 LoC of pure curation + the single `toolPolicyProvider` slot)
51 and apps (~6.8k custom-tools gateway) are first-party capabilities wearing a
52 plugin costume. Kernel packages and `packages/hosts/mcp` have zero plugin
53 references — the indirection is not load-bearing for the real surfaces.
54
55**The data model is connection-first, not user-first**
56(`packages/core/sdk/src/core-schema.ts`):
57
58- No user/subject table. `subject` is an opaque partition-key string smeared
59 across owned tables; a user "exists" only if they have a connection row.
60- Disconnect is a hard delete (`connectionsRemove`) — a signed-out user is
61 bit-identical to one who never connected. "Which users are signed out" is
62 structurally unanswerable.
63- Every Executor is bound to one `{tenant, subject}` via `owner-policy.ts`;
64 there is no cross-subject read path, so no admin view.
65- Toolkit state lives in generic `plugin_storage` KV, not core tables.
66- Cloud's real identity tables (WorkOS accounts/organizations) have no join to
67 `subject`.
68
69**What to salvage from the rework** (its model docs are the valuable artifact;
70its implementation ported only ~10 of 131 legacy e2e scenarios — that's why it
71failed, and why this plan is incremental):
72
73- Apps-as-published-source: `defineApp`/`defineProvider`/`defineTool`/
74 `defineToolGroup`, immutable content-hashed versions, manifest derived by
75 executing the source, rollback = pointer move.
76- Generators: OpenAPI/MCP specs generate the _same_ app source a human would
77 write, with a re-verifiable `GeneratorClaim` (first-party vs third-party).
78- Minimal SDK boundary: `createExecutor`/`createRemoteExecutor`, no Effect
79 crossing the boundary, MCP exposure returns a standard MCP SDK server.
80- Custody separation: secrets never on the wire; opaque owner refs.
81
82## Constraints discovered (do not relearn these)
83
841. **Published packages make this semver-major.** `scripts/publish-packages.ts`
85 publishes `@executor-js/{config,sdk}` and plugins
86 `{example,file-secrets,graphql,keychain,mcp,onepassword,openapi}` — killing
87 `definePlugin` is a breaking release for seven packages. Changesets +
88 deprecation story required; `publish-packages.ts`, `pkg-pr-new.yml`,
89 `check-changelog-stubs.ts`, and `examples/*` all have hardcoded lists.
902. **`plugin_id = 'google'` is a live persisted value with no runtime.** The
91 provider-service-split migration skips orgs with missing spec blobs
92 (`sqlite.test.ts` asserts survivors). The `googleDiscovery → "google"`
93 entries in React are compensating, not dangling. The closed SourceType
94 switch must map `"google"`/`"googleDiscovery"` → openapi or degraded rows
95 become hard failures.
963. **FumaDB is not versioned in practice.** All hosts pin `version: "1.0.0"`;
97 runtime bring-up is `ensureDrizzleRuntimeSchemaFromTables` (CREATE TABLE IF
98 NOT EXISTS + additive nullable ALTERs). New tables/nullable columns are free
99 on local/self-host/D1. **Cloud is the exception**: hand-edit
100 `apps/cloud/src/db/executor-schema.ts` + drizzle-kit migration +
101 `db:migrate:prod`; `db.schema.test.ts` enforces the mirror, so "forgot
102 cloud" fails tests.
1034. **93 e2e files import `composePluginApi`** purely to type the client. Keep
104 it as a deprecated argument-ignoring shim returning the static
105 `FullExecutorApi`; sweep the call sites mechanically in one final PR.
1065. **Keep the `plugin_id` column name** (four tables + blob namespace format
107 `o:<tenant>/<plugin_id>`). Renaming to `source_kind` is a cross-host
108 migration for zero behavior. Retype on the read side only.
1096. **URL contracts:** `/integrations/add/$pluginKey` is navigated directly by
110 e2e; `/plugins/desktop-settings/` is asserted by the desktop-packaged suite
111 and baked into a tool's returned URL. Keep shapes / add redirects.
1127. **CI gaps:** PRs run cloud (4 shards) + selfhost e2e only; local/desktop
113 suites gate push-to-main. PRs touching desktop-settings or the local loader
114 need a manual local e2e run pre-merge.
1158. **Toolkit selection filters _connections_, not just tools**
116 (`connectionsList` goes through the active policy provider). Preserve when
117 moving toolkits to core, or the toolkit e2e suites go red.
118
119## Phase 0 — declutter (3 PRs)
120
121- **0.1** Delete empty `packages/plugins/{google,microsoft}`; drop the unused
122 `@executor-js/config` dep from openapi/mcp/graphql; move
123 `provider-service-split` to `packages/migrations/` (keep package name — it's
124 imported by three host data-migration registries). In the React maps, keep
125 `google`/`googleDiscovery` → openapi with a comment citing constraint 2.
126- **0.2** Bake google/microsoft catalogs + spec-format adapters in as openapi
127 defaults; strip the re-threading from all four host configs. Gate:
128 `provider-plugins-ui`, `google-health-checks`, `microsoft-*` scenarios.
129- **0.3** Move desktop-settings (438-line Electron-IPC client, not a
130 one-liner) to a first-class desktop-only route in `packages/app`; update the
131 static tool's URL and the desktop-packaged e2e selector; delete the plugin.
132
133## Phase 1 — data model (4 PRs) — FIRST PRIORITY
134
135Design choices (least-invasive, verified feasible):
136
137- The new **subject table is `tenant`-scoped, not owner-scoped** — readable by
138 any executor bound to the tenant with **zero policy changes** (the
139 `integration` table already proves the pattern).
140- Cross-subject connection reads: add `reach: "bound" | "tenant"` to
141 `ExecutorOwnerPolicyContext` — widens `ownerVisibilityCondition` for reads
142 only; `assertOwnerWritable`/`assertOwnerPatch` untouched, so admin reach can
143 never write. Only two construction sites exist (`executor.ts`,
144 `test-config.ts`).
145- Identity stays with the hosts (WorkOS / Better Auth member lists already
146 exist behind `AccountProvider.listMembers`); the subject table is the missing
147 **join** between host identity and executor connections, not a new identity
148 system. Tolerate `subject = null` (pure-org) and `subject = "local"`.
149
150PRs:
151
152- **1.1** Add tenant-scoped `subject` table (id, tenant, external_id,
153 created_at, last_seen_at, status) to `coreTables` + cloud mirror + one
154 drizzle migration. No readers yet.
155- **1.2** Populate: upsert in `makeScopedExecutor`
156 (`packages/core/api/src/server/scoped-executor.ts` — every HTTP request and
157 MCP session on all four hosts passes through it) and at connection-create.
158 Add to `purgeOrganizationData` + its `TENANT_TABLES` test.
159- **1.3** `reach` mode + `admin.listSubjectsWithConnections`-shaped read on a
160 derived handle. Policy test proving admin reach cannot write. Scope includes
161 (customer ask, owner.com 2026-07-27): per-subject reads ("connections for
162 subject X"), not just the aggregate list, and a privileged org-level API key
163 (keys already carry `{accountId, organizationId}` —
164 `apps/cloud/src/auth/api-keys.ts`) that resolves to the tenant-reach
165 read-only handle so customers can inspect connections programmatically.
166- **1.4** Connection lifecycle: nullable `status` column
167 (active/disconnected/revoked/expired) plus `status_changed_at`; disconnect
168 writes status instead of deleting. Wide blast radius — status predicate
169 through `findConnectionRow`, `connectionsList`, credential resolution,
170 `toolsList`, org purge; own e2e sweep (`connections-*`, `health-checks*`,
171 `oauth-*`). PRIORITY RAISED (customer, 2026-07-28): the customer's
172 block-startup-until-reconnect flow needs connected-then-disconnected to be
173 distinguishable from never-connected, which hard-delete makes impossible
174 today; expose status + statusChangedAt through the admin API when this
175 lands.
176
177Toolkits data does NOT move in this phase (it moves with its reader in 2.4 —
178moving it earlier means either rewriting the plugin storage layer twice or a
179forbidden dual read path).
180
181### Framing: product view vs platform view (Rhys, 2026-07-27)
182
183The two-view model for consumer-facing surfaces, longer-term:
184
185- **Product view** — the bound, per-individual executor every request gets
186 today (their connections, their tools). The default; unchanged.
187- **Platform view** — an operator-level view attached alongside it: read-only,
188 cross-user, explicitly reached for with elevated access (an escape hatch,
189 never a mode product view drifts into). The `reach: "bound" | "tenant"`
190 split in 1.3 is this model's first implementation; 1.5's admin API and the
191 privileged key are its first surface. Later platform-view candidates (usage,
192 policy overview, cross-user health) land on the same handle.
193
194Cloud wrinkle: cloud consumers like owner.com don't bring their own owner ids —
195their users authenticate through our WorkOS, so `subject` is _our_ accountId
196and the customer correlates on their side. The opaque-owner-ids-in-the-
197customer's-id-space story fully applies only to embedded/API consumers; for
198cloud, the platform view (+ `external_id` on the subject table) is what makes
199the correlation tractable.
200
201### Fast path: owner.com admin-visibility ask (ships from Phase 1 alone)
202
203The owner.com ask (admin inspects users' connections; API key that can do the
204same; available-vs-connected icon grid; direct connect links) needs only
2051.1 → 1.2 → 1.3 plus a thin delivery layer — no dependency on Phase 0 or the
206plugin kill. Delivery PRs on top of 1.3:
207
208- **1.5** Public admin API surface: `GET .../admin/users`,
209 `GET .../admin/users/:externalId/connections` (integration slug + status +
210 last_health only — never item_ids/oauth fields), gated on the privileged key
211 / admin membership. Public paths say `users`, not `subjects`/`tenants` — see
212 "Vocabulary" below. The available-vs-connected diff is computable client-side
213 from this plus the existing integration catalog.
214- **1.6** Connect deep links: stable
215 `/connect/:integrationSlug`-shaped URL that lands the arriving user in the
216 connect flow for that integration (auth-aware redirect). Uses integration
217 slugs, not plugin routes, so it survives Phase 2.5 unchanged.
218
219 1.4 (lifecycle status) upgrades the grid from binary connected/not to
220 expired/disconnected states but is not a blocker for first ship.
221
222## Phase 2 — kill the registry (6 PRs)
223
224- **2.1** Remove dynamic loading: delete `load-plugins.ts` from
225 `@executor-js/config` (keep the rest — jsonc integration config is a separate
226 feature), drop `loadPluginsFromJsonc` from `apps/local`, delete
227 `packages/core/vite-plugin`, replace `virtual:executor/plugins-client` with a
228 static module in `packages/app` (5 `__root.tsx` importers + 3 ambient
229 `.d.ts`), edit `turbo.json` `dev.dependsOn` + root `prepare`, fix
230 `packages/app/vite.config.ts`'s dangling jsonc path. Breaking changeset.
231- **2.2** Static route list: `composePluginApi` becomes the deprecated shim
232 (constraint 4); hosts stop passing plugin tuples; delete
233 `apps/{cloud,host-selfhost}/src/plugins.ts` (exist only to hold
234 `PluginExtensionServices` typing).
235- **2.3** The switch: closed internal `SourceType` interface implemented by
236 openapi/graphql/mcp/**apps (current model, unchanged)** as plain libraries,
237 replacing `runtimes.get`. All four kinds convert together — converting three
238 and leaving apps on the plugin path means carrying dual dispatch; rewriting
239 the apps model in the same PR is what killed the rework. Map
240 `"google"`/`"googleDiscovery"` → openapi. `plugin_id` column name unchanged.
241- **2.4** Toolkits to core: core tables + one-off migration out of
242 `plugin_storage` (enumerated invariants, no dual path), `toolPolicyProvider`
243 slot → native policy input (one call site, one implementation, one test),
244 ordinary core routes, `activeToolkitSlug` becomes an executor option.
245 Preserve the connection-visibility filter (constraint 8).
246- **2.5** React: four named kind renderers; move plugin React code (~2.9k LoC,
247 e.g. `AddMcpIntegration.tsx`) into `packages/react` — watch the peer-dep
248 edge for cycles; delete `defineClientPlugin`, the plugins context, both
249 `KIND_TO_PLUGIN_KEY` maps, and `/plugins/$pluginId/$` (with redirects for
250 desktop-settings + toolkits). Keep `/integrations/add/$pluginKey` URL shape.
251 Don't forget the second axis: `useSecretProviderPlugins` (secrets page).
252- **2.6** Detach `CredentialProvider` from `PluginSpec` (the interface in
253 `provider.ts` is already narrow — secret-store plugins keep it as their
254 seam); delete `plugin.ts`, `plugin-routes.ts`, `plugin-example`, and the
255 shim; mechanical sweep of the 93 e2e files; changesets for all seven
256 published packages.
257
258## Phase 3 — custom = concept package (3–4 PRs) — NOW BEFORE PHASE 2
259
260(See RESEQUENCED note at top: lands side by side with the protocol plugins,
261inside the apps plugin's existing costume; Phase 2 kills the registry
262afterward, with apps converting in 2.3 as an already-stable model.)
263
264Port the rework's app model in place as the `apps` source kind's new model:
265published source, manifest derived by execution, content-hashed immutable
266versions, rollback = pointer move. New tables are additive greenfield (same
267pattern as 1.1: coreTables + cloud mirror + migration, no readers first).
268Existing app sources migrate one-off to published-source form — no dual model
269within the apps kind. Then the OpenAPI/MCP generators + `GeneratorClaim` as an
270optional on-ramp (spec → generated app source); GraphQL generator later.
271Long-term option (explicitly out of scope): protocol kinds themselves become
272generators and "custom" is the only kind.
273
274### Feasibility findings (two deep read-throughs, 2026-07-28)
275
276**Current plugin** (~7.6k LoC): only ~2.65k encodes the old model (store
277collections, sync state machine, publish/discover/descriptor, routes,
278console client). ~2.6k is model-agnostic and survives verbatim: all three
279sandbox executors, the bundler stack, bindings/bridge, authoring +
280standard-schema, conformance. ~1.9k (hand-rolled git client + source
281fetchers) hangs on the publish-input decision below. Storage is entirely
282generic `plugin_storage` rows + content-addressed blobs — zero apps-specific
283tables or migrations exist, so new tables are pure greenfield. Cloud prod is
284git-source-only; **source text is never persisted today (only compiled
285bundles)**, so the one-off migration re-fetches source from the recorded git
286URLs (fetcher exists). Load-bearing invariants to preserve: tool addresses
287`tools.<app>.org.published.<name>` + the `<fileSlug>__<key>` naming rule,
288app-slug↔integration-slug 1:1 guard, publish CAS
289(`AppPublishConflictError`), tombstone-not-delete, token-in-provider-item
290(never in the source row). Safety net: `custom-tools.test.ts` (534-line
291cloud+selfhost console loop), local directory-picker, packed-CLI, desktop
292e2e, ~2.9k LoC in-package tests. Pre-existing gaps flagged: R2 blobs never
293GC'd and org purge misses them; cloud silently falls back to in-process
294executor without the worker loader; host-cloudflare doesn't register apps.
295
296**Rework model** (portable tiers): T1 model proper ~3.9k LoC (authoring SDK,
297publish-by-execution, projection, generator verification, storage port,
298content hashing); T2 generators ~3.2k (fromOpenAPI 1k — complete; fromMCP
2990.6k — computed group with real streamable-HTTP client; json-schema engine;
300byte-for-byte GeneratorClaim verification; render endpoint); T4 host/registry
301~2.9k explicitly skipped. Authoring is Standard Schema
302(StandardSchemaV1 & StandardJSONSchemaV1) with raw-JSON-Schema literals as a
303typed first-class alternative — matches our rule. Effect beta.93→beta.59
304delta is small (ConstraintDecoder → one-line fix; hand-roll
305StandardJSONSchemaV1 ~10 lines; reuse core sha256Hex). NOT portable as-is:
306rework `storage-db.ts` (write a new adapter over our tables against the
307~90-line Storage port interface) and `engine.ts` (extract only ~700 LoC of
308model-owned logic: computed-surface cache + requirement resolution +
309runTool). Known rework gaps to fix at port time, not inherit: derive-time
310execution is unsandboxed in-process (`code-executor.ts:23` records it) — our
311port MUST run publish-time evaluation in the existing sandbox `collect` path
312from day one; fan-in computed groups unsupported; OAuth discovery
313unsupported.
314
315**Semantic deltas — decided by Rhys before 3.1 specs:**
316
3171. **Handler credential contract (the deep one).** Current: handlers get an
318 opaque callable `AppIntegrationClient` proxy that re-enters
319 `ctx.execute` — app code never sees credentials, can only call other
320 executor tools. Rework (D49): engine resolves credentials once and hands
321 the handler **credential fields as data** (`ResolvedConnection.fields`,
322 e.g. access_token) for direct HTTP — that's what makes generators
323 possible (generated code does raw fetch with auth) and what
324 `defineProvider` exists for. Not either/or in principle (a tool-call
325 bridge can coexist as a second requirement kind later), but the ported
326 model's primary contract is fields-as-data per the rework.
3272. **Publish input.** Rework publish takes `SourceFiles` (push-based).
328 Decision: keep git/local-directory as _fetchers that produce
329 SourceFiles_ (preserves prod sources, the e2e suite, and the migration
330 path; git-client survives), with direct push publish added as the new
331 API. Delete-git-later stays open.
3323. **App identity** moves from caller-supplied slug to the `defineApp`
333 export inside the source (rework shape).
3344. **Versioning**: content hash of source IS the version identity; the
335 caller-supplied `sourceRef` + descriptor v6 die with the old model.
3365. **Ownership**: app/version rows carry `space` and NO owner — spaces
337 curate apps, principals own custody (connections stay on the legacy
338 owner model in this phase; the opaque-OwnerRef swap is Phase 4).
339
340### Phase 3 PR sequence (revised)
341
342- **3.1** Port the model core as internal packages, no readers: authoring
343 SDK + runtime brands, publish pipeline (evaluation routed through the
344 sandbox `collect` seam), Storage port + adapter, greenfield tables
345 (`app`, `app_version`, `computed_surface` — space column, content-hash
346 identity, `current` pointer) in coreTables + cloud mirror + migration.
347 Ported rework tests come along (authoring-boundary, tool-groups,
348 publish).
349- **3.2** Swap the apps plugin's internals onto the new model behind the
350 same costume: resolveTools/projectToolSchema/invokeTool read the new
351 tables; publish endpoint (`POST` source files); setCurrent/rollback +
352 versions listing routes; git/local-dir sync becomes fetch→publish.
353 Tool addresses and slug guards preserved; old collections
354 (`apps_descriptors`/`apps_tools`/`apps_sources`) still written by
355 NOTHING — one-off migration converts existing sources (re-fetch from
356 git), then the collections are dead. e2e: full existing custom-tools
357 suite green + new version-history/rollback scenario.
358- **3.3** Console UI for the new model (sources → apps with versions,
359 publish status, rollback) replacing the ~1.3k LoC old client/panels.
360- **3.4** Generators: fromOpenAPI + fromMCP + GeneratorClaim verification +
361 render endpoint, surfaced as the "add from spec/server" on-ramp.
362 (Vendors google/microsoft conversion ~1.15k LoC ports later with 0.2.)
363
364## Spaces: the rework's tenancy primitive (added Rhys, 2026-07-28)
365
366The rework's SDK has exactly one tenancy concept: `SpaceId`
367(`.reference/executor-rework/docs/TENANCY.md`, `docs/MODEL.md`). Adopt it as
368the end-state tenancy model here. The shape:
369
370- **Flat, no hierarchy, no default.** No parent, no inheritance, no
371 distinguished rows (rework D39/D44b). Sharing is an explicit act, never a
372 tree walk (R2 — re-ruled twice, do not relitigate). Every scoped row
373 carries a `space` column; every space-scoped route carries the space in
374 the URL path, never ambient context.
375- **No org/project/team/user below the deployment edge.** The engine answers
376 "what is in this space"; the deployment answers "who is this and which
377 spaces may they touch" through named seams (`authorizeBrowserResource`,
378 `dashboardSpacesForPrincipal`, MCP space resolver). Membership is
379 authorized LIVE per request at the edge — projection may be cached,
380 authorization never.
381- **Deployment mappings:** cloud = one WorkOS org mirrors lazily to one
382 space (mirror row `workosOrgId → space` is also the billing join — Autumn
383 keys on workosOrgId, never a space); self-host = one instance, one
384 found-or-created "Default" space, Better Auth org repurposed as the
385 instance membership table; local/desktop = one "local" space, one static
386 principal, single-tenancy declared via explicit sentinel, not defaulted.
387- **Spaces curate, principals own.** Custody (secrets, connections'
388 secret-bearing halves) belongs to principals via opaque `OwnerRef` +
389 per-request `Reach` (R3); spaces curate apps and hold rows. Publishing an
390 app into a space IS its presence there — no installation rows, no
391 bindings; cross-space arrival (sharing/registry) is a future share-edge
392 row, not golden-path ceremony (D45/D42).
393- Cross-org OAuth callbacks complete into the _initiating_ org's space; the
394 org header is a routing hint, not an authorization statement (R7).
395
396**DECIDED (Rhys, 2026-07-28): spaces land in the CORE PRODUCT NOW — Phase S,
397before the apps port.** Apps then publishes into real spaces from birth.
398
399**Mapping onto this repo — the no-migration trick:** today's `tenant` column
400is de facto the space — cloud writes the WorkOS org id there, self-host/local
401write one constant. So: keep the persisted `tenant` column name (same rule as
402`plugin_id`), and declare that **its value IS the space id**. Each org's
403first space is grandfathered with `spaceId = <historical tenant value>` (the
404org id); NEW spaces mint fresh ids written into the same column. The org→
405space mirror table records which space ids belong to which org (and carries
406the billing join — Autumn keys on workosOrgId, never a space). Existing rows
407never move; multi-space needs no schema change on scoped tables. "Space" is
408contract vocabulary; "tenant" retreats to cloud-internal (see Vocabulary).
409
410### Phase S — spaces in core (before apps)
411
412- **S1 — space table + boot ensure (no readers).** Greenfield `space` table
413 (id, name, created_at) in coreTables + cloud mirror + migration, plus the
414 cloud `org→space` mirror table. Boot/first-contact ensures the degenerate
415 space per deployment: cloud lazily on first authenticated org contact
416 (grandfathered id = org id), self-host "Default", local "local". Same
417 no-readers pattern as PR 1.1.
418- **S2 — space threading.** `SpaceId` brand in the SDK; the scoped-executor
419 seam resolves request → space id (today: identical to tenant) and stamps
420 it; reads interpret `tenant` columns as space ids. No behavior change —
421 this is the retype + vocabulary PR. `reach: "tenant"` reads as
422 "space-wide reach" from here on.
423- **S3 — multi-space on cloud + product surface.** Create/rename space,
424 space switcher in the console, membership = org membership (all spaces of
425 the org for now), org→N mirror rows, space-scoped catalog/connection
426 views (rows follow automatically — they're keyed by the space id in the
427 tenant column). Billing stays org-keyed via the mirror. e2e: two spaces
428 in one org, isolation of catalogs/connections between them, switcher.
429- **S4 — sharing model on spaces.** Connect-time "share with space" writes
430 the space's shared owner-ref (replaces the org tier semantically; the
431 persisted `owner: "org"|"user"` column keeps its name, read-side
432 reinterpretation only). Edge computes reach as
433 [principal, sharedRef(space) for each member space]. This is the bridge
434 to Phase 4's full owner-set reach.
435- **Later (not scheduled): local multi-space, file-backed.** Each local
436 space is its own store file (portable — copy/backup/sync/check in
437 alongside a repo); the daemon opens one storage handle per space and
438 routes by space id; the engine still sees flat SpaceIds. Custody never
439 enters the space file — secrets stay in the principal-keyed provider
440 (keychain), so a copied file carries connection metadata, never tokens.
441- Phase 4 completes the picture: space-in-path routes,
442 `dashboardSpacesForPrincipal`-style seams, reach as an explicit owner
443 set.
444
445Apps (Phase 3) then lands after S1/S2 — app rows carry a real `space` FK
446from birth; S3/S4 can proceed in parallel with the apps port.
447
448Interaction with shipped Phase 1 work: the subject table and platform view
449are edge/deployment concepts (who exists in this identity world, what may
450an operator read) — they sit above spaces and survive unchanged; `reach:
451"tenant"` becomes "space-wide reach" vocabulary-wise at Phase 4.
452
453## Vocabulary: retire "tenant" from consumer-facing surfaces (Rhys, 2026-07-27)
454
455Direction: the SDK should be agnostic to the consumer's data-model shape, the
456way Composio's is — consumers pass an opaque user/owner id string and hang
457whatever hierarchy they have (user, team, workspace) off it; the SDK never
458models their org chart. The rework already designed this (`OwnerRef` opaque
459brand, `Reach = {owners}`, deployments translate identity at the edge) — adopt
460its vocabulary, not its schedule. "Tenant" remains a real _internal_ concept
461(which Executor-cloud org owns the rows) but must not leak into surfaces
462consumers touch.
463
464Rules, effective immediately:
465
466- Persisted column names (`tenant`, `subject`, `owner`) stay — renaming
467 storage is a cross-host migration for zero behavior (same rule as
468 `plugin_id`).
469- Internal policy code (`reach: "tenant"`, `ExecutorOwnerPolicyContext`) may
470 keep the words.
471- One contract noun: **`owner`** — the opaque ref naming who a connection
472 belongs to (end-user, team, workspace, or the org itself; the rework's
473 `OwnerRef`). "User" cannot be the contract noun because org-shared
474 connections are a load-bearing feature an end-user word can't cover.
475- **"user" is presentation vocabulary only**, for surfaces whose rows genuinely
476 are the customer's end-users — the Phase 1.5 admin API says `users` because
477 the subject table is populated from authenticated principals.
478- Hazard: the persisted `owner` column keeps its old meaning (the
479 `"org" | "user"` tier discriminator), while Phase 4 introduces the opaque
480 ref as a distinctly named type (`OwnerRef`). Same word, two meanings,
481 separated by layer — say so in policy-layer comments.
482- The full swap (opaque owner refs at the SDK boundary, tenant retreating to a
483 cloud-internal detail) lands with the Phase 4 minimal SDK.
484
485## Phase 4 — minimal SDK + platform/product split
486
487New consumer boundary modeled on the rework: `createExecutor` /
488`createRemoteExecutor`, no Effect at the boundary, `tools.list/execute` +
489connections + the new users/admin surface; policies/toolkits become optional
490layers consumers opt into, not baked opinions (Composio-style minimalism).
491
492Authorization end-state (Rhys, 2026-07-27): the engine enforces scope
493mechanically but never decides policy — the host computes, per request, the
494set of owners the request may read (the rework's `Reach = { owners }`), and
495the storage predicate is just `row.owner ∈ reach.owners`. The 1.3
496`reach: "bound" | "tenant"` enum is a stepping stone and is replaced by this
497set shape here: "bound" = a reach of one, "tenant" = all owners (wildcard so
498cloud needn't enumerate), and in-between cases (team views, support sessions,
499subset-restricted org keys) are just different sets. Writes stay hard-pinned
500to a single owner; only read reach generalizes. Trust model becomes a
501per-deployment choice: a trusted backend with a server key gets tenant-wide
502reach and layers its own authorization (Composio-style); an end-user session
503gets a reach of one. The enum-to-set swap is contained: the policy context is
504constructed in exactly two places.
505Start the platform/product package separation _during_ 2.3–2.6 (each PR moves
506one thing to its final home) rather than as a big bang; the SDK boundary
507itself lands last.
508
509## Bookkeeping
510
511- Create `MISTAKES.md` / `DESIRES.md` / `LEARNINGS.md` (required by AGENTS.md,
512 currently missing) with the first entries from this migration.
513- `apps/local/src/db/executor-schema.ts` is already stale vs `coreTables`
514 (missing several columns); its "keep in sync" comment is untrustworthy —
515 runtime schema comes from the ensure path, not this file.