History

Fix toolkit CI lint and formatting

Rhys Sullivan committed 4 months ago

Commit 2836ce21 parent c7f81af17 files · +120 −294Browse files

Unified · Split

17 files
  1. apps/cloud/src/mcp/mount.test.ts +2 −8
  2. apps/cloud/src/mcp/mount.ts +2 −8
  3. apps/host-selfhost/src/mcp/auth.ts +1 −2
  4. apps/local/executor.config.ts +1 −2
  5. apps/local/src/executor.ts +15 −33
  6. apps/local/src/main.ts +6 −11
  7. apps/local/src/mcp.ts +26 −73
  8. apps/local/src/serve.ts +13 −38
  9. apps/local/vite.config.ts +14 −39
  10. e2e/local/toolkits-mcp.test.ts +2 −2
  11. e2e/scenarios/toolkits-mcp.test.ts +2 −2
  12. e2e/selfhost/toolkits-ui.test.ts +8 −2
  13. packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts +12 −54
  14. packages/plugins/google/src/sdk/openapi-ownership-migration.ts +2 −6
  15. packages/plugins/toolkits/CHANGELOG.md +1 −0
  16. packages/plugins/toolkits/src/page.tsx +6 −11
  17. packages/plugins/toolkits/src/server.ts +7 −3
apps/cloud/src/mcp/mount.test.ts+2−8
@@ -1,10 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2 2
3import {3import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth";
4 protectedResourceMetadataUrlFor,
5 resourceUrlFor,
6 toolkitSlugFromRequest,
7} from "./auth";
8import { classifyMcpPath, prepareMcpOrgScope } from "./mount";4import { classifyMcpPath, prepareMcpOrgScope } from "./mount";
9 5
10describe("cloud MCP toolkit route normalization", () => {6describe("cloud MCP toolkit route normalization", () => {
@@ -50,9 +46,7 @@ describe("cloud MCP toolkit route normalization", () => { · 39 unmodified lines
50 46
51 it("builds toolkit-specific resource and metadata URLs", () => {47 it("builds toolkit-specific resource and metadata URLs", () => {
52 expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy");48 expect(resourceUrlFor(null, "deploy")).toBe("https://executor.sh/mcp/toolkits/deploy");
53 expect(resourceUrlFor("acme", "deploy")).toBe(49 expect(resourceUrlFor("acme", "deploy")).toBe("https://executor.sh/acme/mcp/toolkits/deploy");
54 "https://executor.sh/acme/mcp/toolkits/deploy",
55 );
56 expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe(50 expect(protectedResourceMetadataUrlFor(null, "deploy")).toBe(
57 "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",51 "https://executor.sh/.well-known/oauth-protected-resource/mcp/toolkits/deploy",
58 );52 );
apps/cloud/src/mcp/mount.ts+2−8
@@ -78,11 +78,7 @@ const matchMcpSuffix = (segments: readonly string[]): MatchedMcpSuffix | undefin · 77 unmodified lines
78 const organizationId = orgSelectorSegment(segments[0]);78 const organizationId = orgSelectorSegment(segments[0]);
79 return organizationId ? { organizationId } : undefined;79 return organizationId ? { organizationId } : undefined;
80 }80 }
81 if (81 if (segments.length === 4 && segments[1] === "mcp" && segments[2] === "toolkits") {
82 segments.length === 4 &&
83 segments[1] === "mcp" &&
84 segments[2] === "toolkits"
85 ) {
86 const organizationId = orgSelectorSegment(segments[0]);82 const organizationId = orgSelectorSegment(segments[0]);
87 const toolkitSlug = segments[3];83 const toolkitSlug = segments[3];
88 return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined;84 return organizationId && toolkitSlug ? { organizationId, toolkitSlug } : undefined;
@@ -114,9 +110,7 @@ export const classifyMcpPath = (pathname: string): McpRoute => { · 25 unmodified lines
114 const prmPrefix = "/.well-known/oauth-protected-resource";110 const prmPrefix = "/.well-known/oauth-protected-resource";
115 if (pathname.startsWith(`${prmPrefix}/`)) {111 if (pathname.startsWith(`${prmPrefix}/`)) {
116 const matched = matchMcpSuffix(segments.slice(2));112 const matched = matchMcpSuffix(segments.slice(2));
117 return matched === undefined113 return matched === undefined ? null : { kind: "oauth-protected-resource", ...matched };
118 ? null
119 : { kind: "oauth-protected-resource", ...matched };
120 }114 }
121 115
122 // MCP transport: `/mcp` or `/<org>/mcp`.116 // MCP transport: `/mcp` or `/<org>/mcp`.
apps/host-selfhost/src/mcp/auth.ts+1−2
@@ -46,8 +46,7 @@ import { BetterAuth } from "../auth/better-auth"; · 45 unmodified lines
46// ---------------------------------------------------------------------------46// ---------------------------------------------------------------------------
47 47
48const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";48const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
49const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH =49const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`;
50 `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`;
51const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";50const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
52const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/";51const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/";
53 52
apps/local/executor.config.ts+1−2
@@ -37,8 +37,7 @@ export default defineExecutorConfig({ · 36 unmodified lines
37 onepasswordHttpPlugin(),37 onepasswordHttpPlugin(),
38 desktopSettingsPlugin({38 desktopSettingsPlugin({
39 webBaseUrl:39 webBaseUrl:
40 process.env.EXECUTOR_WEB_BASE_URL ??40 process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`,
41 `http://localhost:${process.env.PORT ?? "4788"}`,
42 }),41 }),
43 ] as const,42 ] as const,
44});43});
apps/local/src/executor.ts+15−33
@@ -49,8 +49,7 @@ const makeTenantId = (cwd: string): string => { · 48 unmodified lines
49 return `${folder}-${hash}`;49 return `${folder}-${hash}`;
50};50};
51 51
52const resolvePluginConfigPath = (scopeDir: string): string =>52const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc");
53 join(scopeDir, "executor.jsonc");
54 53
55// Plugins reach the host through two doors that compose:54// Plugins reach the host through two doors that compose:
56// - `executor.config.ts`'s static tuple55// - `executor.config.ts`'s static tuple
@@ -69,14 +68,11 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => · 12 unmodified lines
69 activeToolkitSlug: options.activeToolkitSlug,68 activeToolkitSlug: options.activeToolkitSlug,
70 });69 });
71 const dynamicPlugins =70 const dynamicPlugins =
72 (yield* Effect.promise(() =>71 (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ??
73 loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }),72 [];
74 )) ?? [];
75 73
76 const staticPackageNames = new Set(74 const staticPackageNames = new Set(
77 staticPlugins75 staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name),
78 .map((plugin) => plugin.packageName)
79 .filter((name): name is string => !!name),
80 );76 );
81 const dedupedDynamic = dynamicPlugins.filter((plugin) => {77 const dedupedDynamic = dynamicPlugins.filter((plugin) => {
82 if (plugin.packageName && staticPackageNames.has(plugin.packageName)) {78 if (plugin.packageName && staticPackageNames.has(plugin.packageName)) {
@@ -101,23 +97,18 @@ interface LocalExecutorBundle { · 18 unmodified lines
101 readonly plugins: LocalPlugins;97 readonly plugins: LocalPlugins;
102}98}
103 99
104class LocalExecutorTag extends Context.Service<100class LocalExecutorTag extends Context.Service<LocalExecutorTag, LocalExecutorBundle>()(
105 LocalExecutorTag,101 "@executor-js/local/Executor",
106 LocalExecutorBundle102) {}
107>()("@executor-js/local/Executor") {}
108 103
109export type LocalExecutor = LocalExecutorBundle["executor"];104export type LocalExecutor = LocalExecutorBundle["executor"];
110 105
111class LocalExecutorCreateError extends Data.TaggedError(106class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{
112 "LocalExecutorCreateError",
113)<{
114 readonly message: string;107 readonly message: string;
115 readonly cause: unknown;108 readonly cause: unknown;
116}> {}109}> {}
117 110
118class LocalExecutorDisposeError extends Data.TaggedError(111class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{
119 "LocalExecutorDisposeError",
120)<{
121 readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime";112 readonly operation: "createHandle" | "disposeExecutor" | "disposeRuntime";
122 readonly cause: unknown;113 readonly cause: unknown;
123}> {}114}> {}
@@ -142,13 +133,10 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) => · 18 unmodified lines
142 Effect.runPromise(133 Effect.runPromise(
143 Effect.tryPromise({134 Effect.tryPromise({
144 try: () => promise,135 try: () => promise,
145 catch: (cause) =>136 catch: (cause) => new LocalExecutorDisposeError({ operation: "createHandle", cause }),
146 new LocalExecutorDisposeError({ operation: "createHandle", cause }),
147 }).pipe(137 }).pipe(
148 Effect.catch(() =>138 Effect.catch(() =>
149 Effect.succeed<Awaited<ReturnType<typeof createExecutorHandle>> | null>(139 Effect.succeed<Awaited<ReturnType<typeof createExecutorHandle>> | null>(null),
150 null,
151 ),
152 ),140 ),
153 ),141 ),
154 );142 );
@@ -212,8 +200,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 57 unmodified lines
212 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL200 // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL
213 // overrides entirely for deployments where the UI is on a different host.201 // overrides entirely for deployments where the UI is on a different host.
214 const webBaseUrl =202 const webBaseUrl =
215 process.env.EXECUTOR_WEB_BASE_URL ??203 process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`;
216 `http://localhost:${process.env.PORT ?? "4788"}`;
217 204
218 const executor = yield* createExecutor({205 const executor = yield* createExecutor({
219 tenant: Tenant.make(tenantId),206 tenant: Tenant.make(tenantId),
@@ -247,9 +234,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { · 27 unmodified lines
247 );234 );
248};235};
249 236
250export const createExecutorHandle = async (237export const createExecutorHandle = async (options: LocalExecutorOptions = {}) => {
251 options: LocalExecutorOptions = {},
252) => {
253 const layer = createLocalExecutorLayer(options);238 const layer = createLocalExecutorLayer(options);
254 const runtime = ManagedRuntime.make(layer);239 const runtime = ManagedRuntime.make(layer);
255 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());240 const bundle = await runtime.runPromise(LocalExecutorTag.asEffect());
@@ -275,17 +260,14 @@ const loadSharedHandle = () => { · 19 unmodified lines
275 return sharedHandlePromise;260 return sharedHandlePromise;
276};261};
277 262
278export const getExecutor = () =>263export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor);
279 loadSharedHandle().then((handle) => handle.executor);
280export const getExecutorBundle = () => loadSharedHandle();264export const getExecutorBundle = () => loadSharedHandle();
281 265
282export const disposeExecutor = async (): Promise<void> => {266export const disposeExecutor = async (): Promise<void> => {
283 const currentHandlePromise = sharedHandlePromise;267 const currentHandlePromise = sharedHandlePromise;
284 sharedHandlePromise = null;268 sharedHandlePromise = null;
285 269
286 const handle = currentHandlePromise270 const handle = currentHandlePromise ? await handleOrNull(currentHandlePromise) : null;
287 ? await handleOrNull(currentHandlePromise)
288 : null;
289 if (handle) {271 if (handle) {
290 await ignorePromiseFailure("disposeExecutor", () => handle.dispose());272 await ignorePromiseFailure("disposeExecutor", () => handle.dispose());
291 }273 }
apps/local/src/main.ts+6−11
@@ -53,9 +53,7 @@ const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => { · 52 unmodified lines
53 );53 );
54};54};
55 55
56export const createServerHandlers = async (56export const createServerHandlers = async (token: string): Promise<ServerHandlers> => {
57 token: string,
58): Promise<ServerHandlers> => {
59 // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The57 // The typed `/api` web-handler comes from `ExecutorApp.make` (./app.ts). The
60 // boot bearer token is the authoritative `/api` gate (see `identity.ts`).58 // boot bearer token is the authoritative `/api` gate (see `identity.ts`).
61 const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token);59 const apiHandler: ServerHandlers["api"] = await makeLocalApiHandler(token);
@@ -90,18 +88,15 @@ export const createServerHandlers = async ( · 28 unmodified lines
90 return { api: apiHandler, mcp };88 return { api: apiHandler, mcp };
91};89};
92 90
93export class ServerHandlersService extends Context.Service<91export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()(
94 ServerHandlersService,92 "@executor-js/local/ServerHandlersService",
95 ServerHandlers93) {}
96>()("@executor-js/local/ServerHandlersService") {}
97 94
98// The handlers are built once per process and memoized. The boot token is95// The handlers are built once per process and memoized. The boot token is
99// captured on the first call (serve.ts / the vite dev middleware both pass the96// captured on the first call (serve.ts / the vite dev middleware both pass the
100// SAME token loaded from `auth.json`), so memoization on first-call is correct.97// SAME token loaded from `auth.json`), so memoization on first-call is correct.
101let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<98let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<ServerHandlersService, never> | null =
102 ServerHandlersService,99 null;
103 never
104> | null = null;
105 100
106const getServerHandlersRuntime = (101const getServerHandlersRuntime = (
107 token: string,102 token: string,
apps/local/src/mcp.ts+26−73
@@ -67,9 +67,7 @@ const formatBoundaryError = (error: unknown): unknown => { · 66 unmodified lines
67 return error;67 return error;
68};68};
69 69
70const ignoreClose = (70const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
71 close: (() => Promise<void>) | undefined,
72): Promise<void> =>
73 close71 close
74 ? Effect.runPromise(72 ? Effect.runPromise(
75 Effect.ignore(73 Effect.ignore(
@@ -81,10 +79,8 @@ const ignoreClose = ( · 5 unmodified lines
81 )79 )
82 : Promise.resolve();80 : Promise.resolve();
83 81
84const pausedRequestPattern =82const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
85 /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;83const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
86const approvalRequestPattern =
87 /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
88 84
89const json = (value: unknown, status = 200): Response =>85const json = (value: unknown, status = 200): Response =>
90 new Response(JSON.stringify(value), {86 new Response(JSON.stringify(value), {
@@ -97,15 +93,10 @@ const readResumeResponse = (request: Request): Promise<ResumeResponse | null> => · 6 unmodified lines
97 Effect.tryPromise({93 Effect.tryPromise({
98 try: () => request.json(),94 try: () => request.json(),
99 catch: () => null,95 catch: () => null,
100 }).pipe(96 }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))),
101 Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw))),
102 ),
103 );97 );
104 98
105const resumeApprovalResult = (99const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({
106 executionId: string,
107 response: ResumeResponse,
108) => ({
109 status: "completed",100 status: "completed",
110 ...formatResumeAcknowledgement(executionId, response),101 ...formatResumeAcknowledgement(executionId, response),
111 isError: false,102 isError: false,
@@ -121,23 +112,18 @@ const resourceFromRequest = (request: Request): McpResource | null => { · 9 unmodified lines
121 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };112 return { kind: "toolkit", slug: decodeURIComponent(match[1]) };
122};113};
123 114
124const engineFromConfig = (115const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null =>
125 config: ExecutorMcpServerConfig,116 "engine" in config ? config.engine : null;
126): AnyExecutionEngine | null => ("engine" in config ? config.engine : null);
127 117
128const normalizeHandlerConfig = (118const normalizeHandlerConfig = (
129 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,119 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
130): LocalMcpRequestHandlerConfig =>120): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input });
131 "defaultConfig" in input ? input : { defaultConfig: input };
132 121
133export const createMcpRequestHandler = (122export const createMcpRequestHandler = (
134 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,123 input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig,
135): McpRequestHandler => {124): McpRequestHandler => {
136 const handlerConfig = normalizeHandlerConfig(input);125 const handlerConfig = normalizeHandlerConfig(input);
137 const transports = new Map<126 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
138 string,
139 WebStandardStreamableHTTPServerTransport
140 >();
141 const servers = new Map<string, McpServer>();127 const servers = new Map<string, McpServer>();
142 const resources = new Map<string, McpResource>();128 const resources = new Map<string, McpResource>();
143 const sessionEngines = new Map<string, AnyExecutionEngine>();129 const sessionEngines = new Map<string, AnyExecutionEngine>();
@@ -151,29 +137,19 @@ export const createMcpRequestHandler = ( · 7 unmodified lines
151 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>137 ): Promise<ReturnType<typeof formatPausedExecution> | null> =>
152 (sessionEngines.get(sessionId) ?? defaultEngine)138 (sessionEngines.get(sessionId) ?? defaultEngine)
153 ? Effect.runPromise(139 ? Effect.runPromise(
154 (sessionEngines.get(sessionId) ?? defaultEngine)!140 (sessionEngines.get(sessionId) ?? defaultEngine)!.getPausedExecution(executionId).pipe(
155 .getPausedExecution(executionId)141 Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),
156 .pipe(142 Effect.orElseSucceed(() => null),
157 Effect.map((paused) =>143 ),
158 paused ? formatPausedExecution(paused) : null,
159 ),
160 Effect.orElseSucceed(() => null),
161 ),
162 )144 )
163 : Promise.resolve(null);145 : Promise.resolve(null);
164 146
165 const configForResource = async (147 const configForResource = async (resource: McpResource): Promise<LocalMcpServerConfig> => {
166 resource: McpResource,148 if (!handlerConfig.createConfigForResource) return { config: handlerConfig.defaultConfig };
167 ): Promise<LocalMcpServerConfig> => {
168 if (!handlerConfig.createConfigForResource)
169 return { config: handlerConfig.defaultConfig };
170 return handlerConfig.createConfigForResource(resource);149 return handlerConfig.createConfigForResource(resource);
171 };150 };
172 151
173 const dispose = async (152 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
174 id: string,
175 opts: { transport?: boolean; server?: boolean } = {},
176 ) => {
177 const t = transports.get(id);153 const t = transports.get(id);
178 const s = servers.get(id);154 const s = servers.get(id);
179 const close = sessionClosers.get(id);155 const close = sessionClosers.get(id);
@@ -197,15 +173,8 @@ export const createMcpRequestHandler = ( · 17 unmodified lines
197 const transport = transports.get(sessionId);173 const transport = transports.get(sessionId);
198 if (!transport) return jsonError(404, -32001, "Session not found");174 if (!transport) return jsonError(404, -32001, "Session not found");
199 const sessionResource = resources.get(sessionId);175 const sessionResource = resources.get(sessionId);
200 if (176 if (!sessionResource || mcpResourceKey(sessionResource) !== mcpResourceKey(resource)) {
201 !sessionResource ||177 return jsonError(403, -32003, "Session belongs to a different MCP resource");
202 mcpResourceKey(sessionResource) !== mcpResourceKey(resource)
203 ) {
204 return jsonError(
205 403,
206 -32003,
207 "Session belongs to a different MCP resource",
208 );
209 }178 }
210 return transport.handleRequest(request);179 return transport.handleRequest(request);
211 }180 }
@@ -221,12 +190,9 @@ export const createMcpRequestHandler = ( · 9 unmodified lines
221 transports.set(sid, transport);190 transports.set(sid, transport);
222 if (created) servers.set(sid, created);191 if (created) servers.set(sid, created);
223 resources.set(sid, resource);192 resources.set(sid, resource);
224 const engine = resourceConfig193 const engine = resourceConfig ? engineFromConfig(resourceConfig.config) : null;
225 ? engineFromConfig(resourceConfig.config)
226 : null;
227 if (engine) sessionEngines.set(sid, engine);194 if (engine) sessionEngines.set(sid, engine);
228 if (resourceConfig?.close)195 if (resourceConfig?.close) sessionClosers.set(sid, resourceConfig.close);
229 sessionClosers.set(sid, resourceConfig.close);
230 },196 },
231 onsessionclosed: (sid) => void dispose(sid, { server: true }),197 onsessionclosed: (sid) => void dispose(sid, { server: true }),
232 });198 });
@@ -249,11 +215,7 @@ export const createMcpRequestHandler = ( · 16 unmodified lines
249 ? {215 ? {
250 mode: "browser" as const,216 mode: "browser" as const,
251 approvalUrl: (executionId) =>217 approvalUrl: (executionId) =>
252 approvalUrlForRequest(218 approvalUrlForRequest(request, executionId, createdSessionId),
253 request,
254 executionId,
255 createdSessionId,
256 ),
257 }219 }
258 : { mode: elicitationMode },220 : { mode: elicitationMode },
259 }),221 }),
@@ -283,13 +245,9 @@ export const createMcpRequestHandler = ( · 23 unmodified lines
283 handlePausedRequest: async (request) => {245 handlePausedRequest: async (request) => {
284 const match = pausedRequestPattern.exec(new URL(request.url).pathname);246 const match = pausedRequestPattern.exec(new URL(request.url).pathname);
285 if (!match) return json({ error: "Not found" }, 404);247 if (!match) return json({ error: "Not found" }, 404);
286 if (request.method !== "GET")248 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
287 return json({ error: "Method not allowed" }, 405);
288 249
289 const paused = await pausedDetail(250 const paused = await pausedDetail(decodeURIComponent(match[1]), decodeURIComponent(match[2]));
290 decodeURIComponent(match[1]),
291 decodeURIComponent(match[2]),
292 );
293 if (!paused) return json({ error: "Paused execution not found" }, 404);251 if (!paused) return json({ error: "Paused execution not found" }, 404);
294 return json({ text: paused.text, structured: paused.structured });252 return json({ text: paused.text, structured: paused.structured });
295 },253 },
@@ -297,8 +255,7 @@ export const createMcpRequestHandler = ( · 1 unmodified line
297 handleApprovalRequest: async (request) => {255 handleApprovalRequest: async (request) => {
298 const match = approvalRequestPattern.exec(new URL(request.url).pathname);256 const match = approvalRequestPattern.exec(new URL(request.url).pathname);
299 if (!match) return json({ error: "Not found" }, 404);257 if (!match) return json({ error: "Not found" }, 404);
300 if (request.method !== "POST")258 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
301 return json({ error: "Method not allowed" }, 405);
302 259
303 const sessionId = decodeURIComponent(match[1]);260 const sessionId = decodeURIComponent(match[1]);
304 const executionId = decodeURIComponent(match[2]);261 const executionId = decodeURIComponent(match[2]);
@@ -316,9 +273,7 @@ export const createMcpRequestHandler = ( · 11 unmodified lines
316 273
317 close: async () => {274 close: async () => {
318 const ids = new Set([...transports.keys(), ...servers.keys()]);275 const ids = new Set([...transports.keys(), ...servers.keys()]);
319 await Promise.all(276 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
320 [...ids].map((id) => dispose(id, { transport: true, server: true })),
321 );
322 },277 },
323 };278 };
324};279};
@@ -327,9 +282,7 @@ export const createMcpRequestHandler = ( · 2 unmodified lines
327// Stdio transport282// Stdio transport
328// ---------------------------------------------------------------------------283// ---------------------------------------------------------------------------
329 284
330export const runMcpStdioServer = async (285export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
331 config: ExecutorMcpServerConfig,
332): Promise<void> => {
333 startIntegrationsRefresh();286 startIntegrationsRefresh();
334 287
335 const server = await Effect.runPromise(createExecutorMcpServer(config));288 const server = await Effect.runPromise(createExecutorMcpServer(config));
apps/local/src/serve.ts+13−38
@@ -35,10 +35,7 @@ const htmlResponse = (file: Bun.BunFile): Response => · 34 unmodified lines
35 headers: { "content-type": "text/html", "cache-control": "no-store" },35 headers: { "content-type": "text/html", "cache-control": "no-store" },
36 });36 });
37 37
38function collectStaticRoutes(38function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {
39 dir: string,
40 prefix = "",
41): Record<string, StaticHandler> {
42 const routes: Record<string, StaticHandler> = {};39 const routes: Record<string, StaticHandler> = {};
43 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets40 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets
44 try {41 try {
@@ -67,9 +64,7 @@ function collectStaticRoutes( · 22 unmodified lines
67 * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes.64 * Convert an embedded web UI map (path → bunfs path) into Bun.serve routes.
68 * The embedded map is generated by the CLI build step using `with { type: "file" }`.65 * The embedded map is generated by the CLI build step using `with { type: "file" }`.
69 */66 */
70function embeddedToStaticRoutes(67function embeddedToStaticRoutes(embedded: Record<string, string>): Record<string, StaticHandler> {
71 embedded: Record<string, string>,
72): Record<string, StaticHandler> {
73 const routes: Record<string, StaticHandler> = {};68 const routes: Record<string, StaticHandler> = {};
74 for (const [key, bunfsPath] of Object.entries(embedded)) {69 for (const [key, bunfsPath] of Object.entries(embedded)) {
75 const file = Bun.file(bunfsPath);70 const file = Bun.file(bunfsPath);
@@ -165,9 +160,7 @@ async function startViteChild(): Promise<ViteChild> { · 89 unmodified lines
165 }160 }
166 if (child.exitCode !== null) {161 if (child.exitCode !== null) {
167 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready162 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready
168 throw new Error(163 throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`);
169 `vite dev exited with code ${child.exitCode} before becoming ready`,
170 );
171 }164 }
172 await Bun.sleep(150);165 await Bun.sleep(150);
173 }166 }
@@ -256,8 +249,7 @@ const withCorsHeaders = ( · 82 unmodified lines
256 if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response;249 if (!origin || !isAllowedOrigin(origin, allowedHosts)) return response;
257 const headers = new Headers(response.headers);250 const headers = new Headers(response.headers);
258 headers.set("access-control-allow-origin", origin);251 headers.set("access-control-allow-origin", origin);
259 for (const [key, value] of Object.entries(corsHeaders))252 for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
260 headers.set(key, value);
261 headers.set(253 headers.set(
262 "access-control-allow-headers",254 "access-control-allow-headers",
263 req.headers.get("access-control-request-headers") ??255 req.headers.get("access-control-request-headers") ??
@@ -271,23 +263,17 @@ const withCorsHeaders = ( · 7 unmodified lines
271 });263 });
272};264};
273 265
274const corsPreflightResponse = (266const corsPreflightResponse = (req: Request, allowedHosts: ReadonlySet<string>): Response =>
275 req: Request,
276 allowedHosts: ReadonlySet<string>,
277): Response =>
278 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);267 withCorsHeaders(req, new Response(null, { status: 204 }), allowedHosts);
279 268
280export async function startServer(269export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
281 opts: StartServerOptions = {},
282): Promise<ServerInstance> {
283 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);270 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
284 const hostname = opts.hostname ?? "127.0.0.1";271 const hostname = opts.hostname ?? "127.0.0.1";
285 // ONE credential, always present: an explicit override or the stable token272 // ONE credential, always present: an explicit override or the stable token
286 // from auth.json (minted on first run). Auth is unconditionally on — loopback273 // from auth.json (minted on first run). Auth is unconditionally on — loopback
287 // is no longer a free pass, since Executor runs arbitrary code that any local274 // is no longer a free pass, since Executor runs arbitrary code that any local
288 // process could otherwise drive.275 // process could otherwise drive.
289 const authToken =276 const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
290 normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
291 const isAuthorized = makeIsAuthorized(authToken);277 const isAuthorized = makeIsAuthorized(authToken);
292 // CORS-only origin allowlist (no Host gate — the bearer is the boundary).278 // CORS-only origin allowlist (no Host gate — the bearer is the boundary).
293 const corsAllowedHosts = new Set<string>([279 const corsAllowedHosts = new Set<string>([
@@ -317,26 +303,20 @@ export async function startServer( · 23 unmodified lines
317 303
318 const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;304 const devMode = process.env.EXECUTOR_DEV === "1" && !opts.embeddedWebUI;
319 if (devMode) {305 if (devMode) {
320 console.log(306 console.log("[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI");
321 "[executor] EXECUTOR_DEV=1 — spawning vite dev child for live UI",
322 );
323 viteChild = await startViteChild();307 viteChild = await startViteChild();
324 // Diagnostic only — this is the internal vite port the daemon proxies to.308 // Diagnostic only — this is the internal vite port the daemon proxies to.
325 // It must NOT read as a destination: the URL to open is the `Open:` line the309 // It must NOT read as a destination: the URL to open is the `Open:` line the
326 // CLI prints (the daemon port, with ?_token). Hitting the vite port directly310 // CLI prints (the daemon port, with ?_token). Hitting the vite port directly
327 // skips that bootstrap and lands on the auth gate.311 // skips that bootstrap and lands on the auth gate.
328 console.log(312 console.log(`[executor] (internal) vite dev child at ${viteChild.url} — don't open this`);
329 `[executor] (internal) vite dev child at ${viteChild.url} — don't open this`,
330 );
331 serveIndex = () =>313 serveIndex = () =>
332 // Unused when viteChild is non-null; defined so the type checker314 // Unused when viteChild is non-null; defined so the type checker
333 // can keep `serveIndex` non-nullable.315 // can keep `serveIndex` non-nullable.
334 new Response("vite not ready", { status: 503 });316 new Response("vite not ready", { status: 503 });
335 } else if (opts.embeddedWebUI) {317 } else if (opts.embeddedWebUI) {
336 staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);318 staticRoutes = embeddedToStaticRoutes(opts.embeddedWebUI);
337 const indexFile = Bun.file(319 const indexFile = Bun.file(opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"));
338 opts.embeddedWebUI["index.html"] ?? join(clientDir, "index.html"),
339 );
340 serveIndex = () => htmlResponse(indexFile);320 serveIndex = () => htmlResponse(indexFile);
341 } else {321 } else {
342 staticRoutes = collectStaticRoutes(clientDir);322 staticRoutes = collectStaticRoutes(clientDir);
@@ -364,9 +344,7 @@ export async function startServer( · 21 unmodified lines
364 // Unauthenticated liveness probe — carries no data, used by the CLI344 // Unauthenticated liveness probe — carries no data, used by the CLI
365 // reachability check (which therefore never forwards a credential).345 // reachability check (which therefore never forwards a credential).
366 if (url.pathname === "/api/health" && req.method === "GET") {346 if (url.pathname === "/api/health" && req.method === "GET") {
367 return withCors(347 return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));
368 new Response("ok", { headers: { "content-type": "text/plain" } }),
369 );
370 }348 }
371 349
372 // OAuth provider callbacks are hit by the user's external browser and350 // OAuth provider callbacks are hit by the user's external browser and
@@ -374,8 +352,7 @@ export async function startServer( · 1 unmodified line
374 // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under352 // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under
375 // /api and /mcp requires the bearer.353 // /api and /mcp requires the bearer.
376 const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);354 const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);
377 const isMcpPath =355 const isMcpPath = url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");
378 url.pathname === "/mcp" || url.pathname.startsWith("/mcp/");
379 const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;356 const isGatedSurface = url.pathname.startsWith("/api") || isMcpPath;
380 357
381 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {358 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
@@ -457,7 +434,5 @@ export async function startServer( · 75 unmodified lines
457 434
458if (import.meta.main) {435if (import.meta.main) {
459 const server = await startServer();436 const server = await startServer();
460 console.log(437 console.log(`Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`);
461 `Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`,
462 );
463}438}
apps/local/vite.config.ts+14−39
@@ -6,10 +6,7 @@ import { defineConfig, type Plugin } from "vite"; · 5 unmodified lines
6import appPlugin from "@executor-js/app/vite";6import appPlugin from "@executor-js/app/vite";
7import { loadOrMintLocalAuthToken } from "./src/auth";7import { loadOrMintLocalAuthToken } from "./src/auth";
8import { consumeOAuthResult } from "./src/oauth-result-store";8import { consumeOAuthResult } from "./src/oauth-result-store";
9import {9import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";
10 isUnauthenticatedOAuthCallbackPath,
11 makeIsAuthorized,
12} from "./src/serve-shared";
13 10
14// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json11// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
15const rootPackage = JSON.parse(12const rootPackage = JSON.parse(
@@ -26,9 +23,7 @@ const cliPackage = JSON.parse( · 10 unmodified lines
26) as { version?: string };23) as { version?: string };
27 24
28const repositoryUrl =25const repositoryUrl =
29 typeof rootPackage.repository === "string"26 typeof rootPackage.repository === "string" ? rootPackage.repository : rootPackage.repository?.url;
30 ? rootPackage.repository
31 : rootPackage.repository?.url;
32 27
33const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version;28const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version;
34const EXECUTOR_GITHUB_URL = (29const EXECUTOR_GITHUB_URL = (
@@ -67,9 +62,7 @@ function executorApiPlugin(): Plugin { · 32 unmodified lines
67 server.httpServer?.once("listening", () => {62 server.httpServer?.once("listening", () => {
68 const address = server.httpServer?.address();63 const address = server.httpServer?.address();
69 const port =64 const port =
70 typeof address === "object" && address65 typeof address === "object" && address ? address.port : server.config.server.port;
71 ? address.port
72 : server.config.server.port;
73 server.config.logger.info(66 server.config.logger.info(
74 `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`,67 `\n Open with auth: http://127.0.0.1:${port}/?_token=${devToken}\n`,
75 );68 );
@@ -77,10 +70,7 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
77 }70 }
78 71
79 server.watcher.on("change", (path) => {72 server.watcher.on("change", (path) => {
80 if (73 if (path.includes("/apps/local/src/") || path.endsWith("/executor.config.ts")) {
81 path.includes("/apps/local/src/") ||
82 path.endsWith("/executor.config.ts")
83 ) {
84 handlers = null;74 handlers = null;
85 }75 }
86 });76 });
@@ -100,8 +90,7 @@ function executorApiPlugin(): Plugin { · 13 unmodified lines
100 // bootstrap, so the UI is unaffected; external MCP clients use the90 // bootstrap, so the UI is unaffected; external MCP clients use the
101 // daemon port.91 // daemon port.
102 const authExempt =92 const authExempt =
103 pathOnly === "/api/health" ||93 pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly);
104 isUnauthenticatedOAuthCallbackPath(pathOnly);
105 if (!authExempt) {94 if (!authExempt) {
106 const presented = req.headers.authorization;95 const presented = req.headers.authorization;
107 const authValue = Array.isArray(presented) ? presented[0] : presented;96 const authValue = Array.isArray(presented) ? presented[0] : presented;
@@ -109,9 +98,7 @@ function executorApiPlugin(): Plugin { · 1 unmodified line
109 "http://localhost/",98 "http://localhost/",
110 authValue ? { headers: { authorization: authValue } } : undefined,99 authValue ? { headers: { authorization: authValue } } : undefined,
111 );100 );
112 if (101 if (!makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)) {
113 !makeIsAuthorized(devToken ?? loadOrMintLocalAuthToken())(probe)
114 ) {
115 res.statusCode = 401;102 res.statusCode = 401;
116 res.setHeader("www-authenticate", 'Bearer realm="executor"');103 res.setHeader("www-authenticate", 'Bearer realm="executor"');
117 res.end("Unauthorized");104 res.end("Unauthorized");
@@ -123,16 +110,13 @@ function executorApiPlugin(): Plugin { · 5 unmodified lines
123 try {110 try {
124 if (!handlers) {111 if (!handlers) {
125 const { getServerHandlers } = await import("./src/main");112 const { getServerHandlers } = await import("./src/main");
126 handlers = await getServerHandlers(113 handlers = await getServerHandlers(devToken ?? loadOrMintLocalAuthToken());
127 devToken ?? loadOrMintLocalAuthToken(),
128 );
129 }114 }
130 115
131 const origin = `http://${req.headers.host ?? "localhost"}`;116 const origin = `http://${req.headers.host ?? "localhost"}`;
132 const headers = new Headers();117 const headers = new Headers();
133 for (const [key, value] of Object.entries(req.headers)) {118 for (const [key, value] of Object.entries(req.headers)) {
134 if (value)119 if (value) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
135 headers.set(key, Array.isArray(value) ? value.join(", ") : value);
136 }120 }
137 121
138 const hasBody = req.method !== "GET" && req.method !== "HEAD";122 const hasBody = req.method !== "GET" && req.method !== "HEAD";
@@ -158,21 +142,14 @@ function executorApiPlugin(): Plugin { · 19 unmodified lines
158 : handlers.mcp.handleApprovalRequest;142 : handlers.mcp.handleApprovalRequest;
159 response = await handler(webRequest(rawUrl));143 response = await handler(webRequest(rawUrl));
160 } else {144 } else {
161 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(145 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(pathOnly);
162 pathOnly,
163 );
164 if (awaitMatch && req.method === "GET") {146 if (awaitMatch && req.method === "GET") {
165 response = new Response(147 response = new Response(JSON.stringify(consumeOAuthResult(awaitMatch[1]!)), {
166 JSON.stringify(consumeOAuthResult(awaitMatch[1]!)),148 headers: { "content-type": "application/json" },
167 {149 });
168 headers: { "content-type": "application/json" },
169 },
170 );
171 } else {150 } else {
172 // Strip /api prefix for Effect handlers.151 // Strip /api prefix for Effect handlers.
173 response = await handlers.api.handler(152 response = await handlers.api.handler(webRequest(rawUrl.slice("/api".length) || "/"));
174 webRequest(rawUrl.slice("/api".length) || "/"),
175 );
176 }153 }
177 }154 }
178 155
@@ -211,9 +188,7 @@ export default defineConfig({ · 32 unmodified lines
211 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),188 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
212 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL),189 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL),
213 "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),190 "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),
214 "process.env.NODE_ENV": JSON.stringify(191 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
215 process.env.NODE_ENV ?? "development",
216 ),
217 },192 },
218 resolve: {193 resolve: {
219 tsconfigPaths: true,194 tsconfigPaths: true,
e2e/local/toolkits-mcp.test.ts+2−2
@@ -67,8 +67,8 @@ const pingSpec = (baseUrl: string): string => · 66 unmodified lines
67 });67 });
68 68
69const closeServer = (server: Server): Promise<void> =>69const closeServer = (server: Server): Promise<void> =>
70 new Promise((resolve, reject) => {70 new Promise((resolve) => {
71 server.close((error) => (error ? reject(error) : resolve()));71 server.close(() => resolve());
72 });72 });
73 73
74const servePingApi = Effect.acquireRelease(74const servePingApi = Effect.acquireRelease(
e2e/scenarios/toolkits-mcp.test.ts+2−2
@@ -56,8 +56,8 @@ const pingSpec = (baseUrl: string): string => · 55 unmodified lines
56 });56 });
57 57
58const closeServer = (server: Server): Promise<void> =>58const closeServer = (server: Server): Promise<void> =>
59 new Promise((resolve, reject) => {59 new Promise((resolve) => {
60 server.close((error) => (error ? reject(error) : resolve()));60 server.close(() => resolve());
61 });61 });
62 62
63const servePingApi = Effect.acquireRelease(63const servePingApi = Effect.acquireRelease(
e2e/selfhost/toolkits-ui.test.ts+8−2
@@ -156,13 +156,19 @@ scenario( · 155 unmodified lines
156 });156 });
157 157
158 await step("Remove the connection from the toolkit tools list", async () => {158 await step("Remove the connection from the toolkit tools list", async () => {
159 await page.getByRole("button", { name: /^Remove connection / }).first().click();159 await page
160 .getByRole("button", { name: /^Remove connection / })
161 .first()
162 .click();
160 await page.getByText("No connections added").waitFor();163 await page.getByText("No connections added").waitFor();
161 await page.getByRole("button", { name: "Add connection to toolkit" }).click();164 await page.getByRole("button", { name: "Add connection to toolkit" }).click();
162 const dialog = page.getByRole("dialog", { name: "Add connection" });165 const dialog = page.getByRole("dialog", { name: "Add connection" });
163 await dialog.waitFor();166 await dialog.waitFor();
164 await dialog.getByLabel("Search connections and tools").fill("policies.list");167 await dialog.getByLabel("Search connections and tools").fill("policies.list");
165 await dialog.getByRole("button", { name: /^Add connection / }).first().click();168 await dialog
169 .getByRole("button", { name: /^Add connection / })
170 .first()
171 .click();
166 await dialog.waitFor({ state: "hidden" });172 await dialog.waitFor({ state: "hidden" });
167 const toolkitTools = page.getByRole("region", { name: "Toolkit tools" });173 const toolkitTools = page.getByRole("region", { name: "Toolkit tools" });
168 await toolkitTools.getByLabel("Filter tools").fill("policies.list");174 await toolkitTools.getByLabel("Filter tools").fill("policies.list");
packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+12−54
@@ -49,17 +49,7 @@ const insertIntegrationRawConfig = ( · 48 unmodified lines
49 sql: `INSERT INTO integration49 sql: `INSERT INTO integration
50 (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)50 (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
51 VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,51 VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
52 args: [52 args: [row.rowId, row.tenant, row.slug, row.pluginId, row.slug, row.slug, row.config, now, now],
53 row.rowId,
54 row.tenant,
55 row.slug,
56 row.pluginId,
57 row.slug,
58 row.slug,
59 row.config,
60 now,
61 now,
62 ],
63 });53 });
64 54
65const insertBlob = (55const insertBlob = (
@@ -121,14 +111,7 @@ const insertTool = ( · 55 unmodified lines
121 sql: `INSERT INTO tool111 sql: `INSERT INTO tool
122 (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id)112 (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id)
123 VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`,113 VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`,
124 args: [114 args: [row.tenant, row.integration, row.pluginId, now, now, `tool-${row.integration}`],
125 row.tenant,
126 row.integration,
127 row.pluginId,
128 now,
129 now,
130 `tool-${row.integration}`,
131 ],
132 });115 });
133 116
134const insertDefinition = (117const insertDefinition = (
@@ -143,21 +126,13 @@ const insertDefinition = ( · 8 unmodified lines
143 sql: `INSERT INTO definition126 sql: `INSERT INTO definition
144 (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id)127 (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id)
145 VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`,128 VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`,
146 args: [129 args: [row.tenant, row.integration, row.pluginId, now, `definition-${row.integration}`],
147 row.tenant,
148 row.integration,
149 row.pluginId,
150 now,
151 `definition-${row.integration}`,
152 ],
153 });130 });
154 131
155describe("runSqliteGoogleOpenApiOwnershipMigration", () => {132describe("runSqliteGoogleOpenApiOwnershipMigration", () => {
156 it.effect("skips malformed legacy integration config rows", () =>133 it.effect("skips malformed legacy integration config rows", () =>
157 Effect.gen(function* () {134 Effect.gen(function* () {
158 const db = yield* Effect.promise(() =>135 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
159 createSqliteTestFumaDb({ tables: collectTables() }),
160 );
161 136
162 yield* Effect.promise(() =>137 yield* Effect.promise(() =>
163 insertIntegrationRawConfig(db.client, {138 insertIntegrationRawConfig(db.client, {
@@ -169,16 +144,12 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 5 unmodified lines
169 }),144 }),
170 );145 );
171 146
172 expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe(147 expect(yield* runSqliteGoogleOpenApiOwnershipMigration(db.client)).toBe(0);
173 0,
174 );
175 148
176 const integrations = yield* Effect.promise(() =>149 const integrations = yield* Effect.promise(() =>
177 db.client.execute("SELECT slug, plugin_id, config FROM integration"),150 db.client.execute("SELECT slug, plugin_id, config FROM integration"),
178 );151 );
179 expect(integrations.rows).toEqual([152 expect(integrations.rows).toEqual([{ slug: "broken", plugin_id: "openapi", config: "" }]);
180 { slug: "broken", plugin_id: "openapi", config: "" },
181 ]);
182 153
183 yield* Effect.promise(() => db.close());154 yield* Effect.promise(() => db.close());
184 }),155 }),
@@ -186,9 +157,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 1 unmodified line
186 157
187 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>158 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>
188 Effect.gen(function* () {159 Effect.gen(function* () {
189 const db = yield* Effect.promise(() =>160 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
190 createSqliteTestFumaDb({ tables: collectTables() }),
191 );
192 const client = db.client;161 const client = db.client;
193 162
194 yield* Effect.promise(() =>163 yield* Effect.promise(() =>
@@ -199,9 +168,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 4 unmodified lines
199 pluginId: "openapi",168 pluginId: "openapi",
200 config: {169 config: {
201 specHash: "googlehash",170 specHash: "googlehash",
202 googleDiscoveryUrls: [171 googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
203 "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
204 ],
205 },172 },
206 }),173 }),
207 );174 );
@@ -277,14 +244,9 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 69 unmodified lines
277 expect(googleBlob.rows).toEqual([{ value: "google spec" }]);244 expect(googleBlob.rows).toEqual([{ value: "google spec" }]);
278 245
279 const openApiBlobs = yield* Effect.promise(() =>246 const openApiBlobs = yield* Effect.promise(() =>
280 client.execute(247 client.execute("SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"),
281 "SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key",
282 ),
283 );248 );
284 expect(openApiBlobs.rows).toEqual([249 expect(openApiBlobs.rows).toEqual([{ key: "spec/googlehash" }, { key: "spec/stripehash" }]);
285 { key: "spec/googlehash" },
286 { key: "spec/stripehash" },
287 ]);
288 250
289 const storage = yield* Effect.promise(() =>251 const storage = yield* Effect.promise(() =>
290 client.execute(252 client.execute(
@@ -297,9 +259,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 6 unmodified lines
297 ]);259 ]);
298 260
299 const tools = yield* Effect.promise(() =>261 const tools = yield* Effect.promise(() =>
300 client.execute(262 client.execute("SELECT integration, plugin_id FROM tool ORDER BY integration"),
301 "SELECT integration, plugin_id FROM tool ORDER BY integration",
302 ),
303 );263 );
304 expect(tools.rows).toEqual([264 expect(tools.rows).toEqual([
305 { integration: "google", plugin_id: "google" },265 { integration: "google", plugin_id: "google" },
@@ -307,9 +267,7 @@ describe("runSqliteGoogleOpenApiOwnershipMigration", () => { · 1 unmodified line
307 ]);267 ]);
308 268
309 const definitions = yield* Effect.promise(() =>269 const definitions = yield* Effect.promise(() =>
310 client.execute(270 client.execute("SELECT integration, plugin_id FROM definition ORDER BY integration"),
311 "SELECT integration, plugin_id FROM definition ORDER BY integration",
312 ),
313 );271 );
314 expect(definitions.rows).toEqual([272 expect(definitions.rows).toEqual([
315 { integration: "google", plugin_id: "google" },273 { integration: "google", plugin_id: "google" },
packages/plugins/google/src/sdk/openapi-ownership-migration.ts+2−6
@@ -1,8 +1,5 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2import {2import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";
3 DataMigrationError,
4 type SqliteDataMigrationClient,
5} from "@executor-js/sdk/core";
6 3
7const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";4const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";
8const googleOpenApiCandidate = (alias?: string): string => {5const googleOpenApiCandidate = (alias?: string): string => {
@@ -16,8 +13,7 @@ const execute = ( · 7 unmodified lines
16) =>13) =>
17 Effect.tryPromise({14 Effect.tryPromise({
18 try: () => client.execute(stmt),15 try: () => client.execute(stmt),
19 catch: (cause) =>16 catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
20 new DataMigrationError({ migration: MIGRATION_NAME, cause }),
21 });17 });
22 18
23export const runSqliteGoogleOpenApiOwnershipMigration = (19export const runSqliteGoogleOpenApiOwnershipMigration = (
packages/plugins/toolkits/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1# @executor-js/plugin-toolkits
packages/plugins/toolkits/src/page.tsx+6−11
@@ -530,11 +530,12 @@ function CreateToolkitDialog(props: { · 529 unmodified lines
530function AddToolkitCard(props: { owner: Owner; onClick: () => void }) {530function AddToolkitCard(props: { owner: Owner; onClick: () => void }) {
531 const scopeLabel = addToolkitScopeLabel(props.owner);531 const scopeLabel = addToolkitScopeLabel(props.owner);
532 return (532 return (
533 <button533 <Button
534 type="button"534 type="button"
535 variant="ghost"
535 onClick={props.onClick}536 onClick={props.onClick}
536 aria-label={`Add ${scopeLabel} toolkit`}537 aria-label={`Add ${scopeLabel} toolkit`}
537 className="group flex min-h-36 min-w-0 self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 text-muted-foreground transition-[border-color,background-color,box-shadow,color] hover:border-foreground/25 hover:bg-muted/20 hover:text-foreground hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"538 className="group flex h-auto min-h-36 min-w-0 self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 p-0 text-muted-foreground transition-[border-color,background-color,box-shadow,color] hover:border-foreground/25 hover:bg-muted/20 hover:text-foreground hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"
538 style={toolkitCardStyle}539 style={toolkitCardStyle}
539 >540 >
540 <span541 <span
@@ -546,7 +547,7 @@ function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { · 5 unmodified lines
546 >547 >
547 <PlusIcon className="size-6" />548 <PlusIcon className="size-6" />
548 </span>549 </span>
549 </button>550 </Button>
550 );551 );
551}552}
552 553
@@ -1047,11 +1048,7 @@ function ToolkitWorkspace(props: { · 494 unmodified lines
1047 );1048 );
1048 const exposedToolIds = useMemo(1049 const exposedToolIds = useMemo(
1049 () =>1050 () =>
1050 new Set(1051 new Set(toolkitTools.filter((tool) => tool.policy.action !== "block").map((tool) => tool.id)),
1051 toolkitTools
1052 .filter((tool) => tool.policy.action !== "block")
1053 .map((tool) => tool.id),
1054 ),
1055 [toolkitTools],1052 [toolkitTools],
1056 );1053 );
1057 const selectedTool = selectedToolId1054 const selectedTool = selectedToolId
@@ -1231,9 +1228,7 @@ export function ToolkitsPage(props: PluginPageProps) { · 173 unmodified lines
1231 1228
1232 const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : [];1229 const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : [];
1233 const selectedToolkit =1230 const selectedToolkit =
1234 selectedToolkitSlug === null1231 selectedToolkitSlug === null ? null : toolkitByRouteSlug(toolkitRows, selectedToolkitSlug);
1235 ? null
1236 : toolkitByRouteSlug(toolkitRows, selectedToolkitSlug);
1237 1232
1238 const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : [];1233 const toolRows = AsyncResult.isSuccess(tools) ? (tools.value as readonly ToolRow[]) : [];
1239 const integrationRows = AsyncResult.isSuccess(integrations)1234 const integrationRows = AsyncResult.isSuccess(integrations)
packages/plugins/toolkits/src/server.ts+7−3
@@ -249,9 +249,13 @@ const makeToolkitsExtension = (ctx: PluginCtx<ToolkitStorage>) => { · 248 unmodified lines
249 const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId });249 const getEntry = (toolkitId: string) => storage.toolkits.get({ key: toolkitId });
250 250
251 const getBySlugEntry = (slug: string) =>251 const getBySlugEntry = (slug: string) =>
252 storage.toolkits.query({ where: { slug } }).pipe(252 storage.toolkits
253 Effect.map((entries) => entries.find((entry) => entry.owner === "org") ?? entries[0] ?? null),253 .query({ where: { slug } })
254 );254 .pipe(
255 Effect.map(
256 (entries) => entries.find((entry) => entry.owner === "org") ?? entries[0] ?? null,
257 ),
258 );
255 259
256 const requireToolkit = (toolkitId: string) =>260 const requireToolkit = (toolkitId: string) =>
257 getEntry(toolkitId).pipe(261 getEntry(toolkitId).pipe(