Unified · Split
| @@ -1,10 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { protectedResourceMetadataUrlFor, resourceUrlFor, toolkitSlugFromRequest } from "./auth"; |
| 4 | protectedResourceMetadataUrlFor, | ||
| 5 | resourceUrlFor, | ||
| 6 | toolkitSlugFromRequest, | ||
| 7 | } from "./auth"; | ||
| 8 | import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; | 4 | import { classifyMcpPath, prepareMcpOrgScope } from "./mount"; |
| 9 | 5 | ||
| 10 | describe("cloud MCP toolkit route normalization", () => { | 6 | describe("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 | ); |
| @@ -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 === undefined | 113 | 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`. |
| @@ -46,8 +46,7 @@ import { BetterAuth } from "../auth/better-auth"; · 45 unmodified lines | |||
| 46 | // --------------------------------------------------------------------------- | 46 | // --------------------------------------------------------------------------- |
| 47 | 47 | ||
| 48 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; | 48 | const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"; |
| 49 | const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = | 49 | const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`; |
| 50 | `${PROTECTED_RESOURCE_METADATA_PATH}/mcp/toolkits/:toolkitSlug`; | ||
| 51 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; | 50 | const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"; |
| 52 | const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; | 51 | const TOOLKIT_MCP_SEGMENT = "/mcp/toolkits/"; |
| 53 | 52 | ||
| @@ -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 | }); |
| @@ -49,8 +49,7 @@ const makeTenantId = (cwd: string): string => { · 48 unmodified lines | |||
| 49 | return `${folder}-${hash}`; | 49 | return `${folder}-${hash}`; |
| 50 | }; | 50 | }; |
| 51 | 51 | ||
| 52 | const resolvePluginConfigPath = (scopeDir: string): string => | 52 | const 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 tuple | 55 | // - `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 | staticPlugins | 75 | 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 | ||
| 104 | class LocalExecutorTag extends Context.Service< | 100 | class LocalExecutorTag extends Context.Service<LocalExecutorTag, LocalExecutorBundle>()( |
| 105 | LocalExecutorTag, | 101 | "@executor-js/local/Executor", |
| 106 | LocalExecutorBundle | 102 | ) {} |
| 107 | >()("@executor-js/local/Executor") {} | ||
| 108 | 103 | ||
| 109 | export type LocalExecutor = LocalExecutorBundle["executor"]; | 104 | export type LocalExecutor = LocalExecutorBundle["executor"]; |
| 110 | 105 | ||
| 111 | class LocalExecutorCreateError extends Data.TaggedError( | 106 | class 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 | ||
| 118 | class LocalExecutorDisposeError extends Data.TaggedError( | 111 | class 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_URL | 200 | // 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 | ||
| 250 | export const createExecutorHandle = async ( | 237 | export 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 | ||
| 278 | export const getExecutor = () => | 263 | export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor); |
| 279 | loadSharedHandle().then((handle) => handle.executor); | ||
| 280 | export const getExecutorBundle = () => loadSharedHandle(); | 264 | export const getExecutorBundle = () => loadSharedHandle(); |
| 281 | 265 | ||
| 282 | export const disposeExecutor = async (): Promise<void> => { | 266 | export const disposeExecutor = async (): Promise<void> => { |
| 283 | const currentHandlePromise = sharedHandlePromise; | 267 | const currentHandlePromise = sharedHandlePromise; |
| 284 | sharedHandlePromise = null; | 268 | sharedHandlePromise = null; |
| 285 | 269 | ||
| 286 | const handle = currentHandlePromise | 270 | 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 | } |
| @@ -53,9 +53,7 @@ const closeServerHandlers = async (handlers: ServerHandlers): Promise<void> => { · 52 unmodified lines | |||
| 53 | ); | 53 | ); |
| 54 | }; | 54 | }; |
| 55 | 55 | ||
| 56 | export const createServerHandlers = async ( | 56 | export 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). The | 57 | // 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 | ||
| 93 | export class ServerHandlersService extends Context.Service< | 91 | export class ServerHandlersService extends Context.Service<ServerHandlersService, ServerHandlers>()( |
| 94 | ServerHandlersService, | 92 | "@executor-js/local/ServerHandlersService", |
| 95 | ServerHandlers | 93 | ) {} |
| 96 | >()("@executor-js/local/ServerHandlersService") {} | ||
| 97 | 94 | ||
| 98 | // The handlers are built once per process and memoized. The boot token is | 95 | // 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 the | 96 | // 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. |
| 101 | let serverHandlersRuntime: ManagedRuntime.ManagedRuntime< | 98 | let serverHandlersRuntime: ManagedRuntime.ManagedRuntime<ServerHandlersService, never> | null = |
| 102 | ServerHandlersService, | 99 | null; |
| 103 | never | ||
| 104 | > | null = null; | ||
| 105 | 100 | ||
| 106 | const getServerHandlersRuntime = ( | 101 | const getServerHandlersRuntime = ( |
| 107 | token: string, | 102 | token: string, |
| @@ -67,9 +67,7 @@ const formatBoundaryError = (error: unknown): unknown => { · 66 unmodified lines | |||
| 67 | return error; | 67 | return error; |
| 68 | }; | 68 | }; |
| 69 | 69 | ||
| 70 | const ignoreClose = ( | 70 | const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => |
| 71 | close: (() => Promise<void>) | undefined, | ||
| 72 | ): Promise<void> => | ||
| 73 | close | 71 | 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 | ||
| 84 | const pausedRequestPattern = | 82 | const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; |
| 85 | /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; | 83 | const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; |
| 86 | const approvalRequestPattern = | ||
| 87 | /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; | ||
| 88 | 84 | ||
| 89 | const json = (value: unknown, status = 200): Response => | 85 | const 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 | ||
| 105 | const resumeApprovalResult = ( | 99 | const 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 | ||
| 124 | const engineFromConfig = ( | 115 | const 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 | ||
| 128 | const normalizeHandlerConfig = ( | 118 | const 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 | ||
| 133 | export const createMcpRequestHandler = ( | 122 | export 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 = resourceConfig | 193 | 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 transport | 282 | // Stdio transport |
| 328 | // --------------------------------------------------------------------------- | 283 | // --------------------------------------------------------------------------- |
| 329 | 284 | ||
| 330 | export const runMcpStdioServer = async ( | 285 | export 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)); |
| @@ -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 | ||
| 38 | function collectStaticRoutes( | 38 | function 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 assets | 40 | // 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 | */ |
| 70 | function embeddedToStaticRoutes( | 67 | function 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 ready | 162 | // 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 | ||
| 274 | const corsPreflightResponse = ( | 266 | const 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 | ||
| 280 | export async function startServer( | 269 | export 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 token | 272 | // ONE credential, always present: an explicit override or the stable token |
| 286 | // from auth.json (minted on first run). Auth is unconditionally on — loopback | 273 | // 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 local | 274 | // 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 the | 309 | // 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 directly | 310 | // 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 checker | 314 | // 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 CLI | 344 | // 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 and | 350 | // 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 under | 352 | // 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 | ||
| 458 | if (import.meta.main) { | 435 | if (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 | } |
| @@ -6,10 +6,7 @@ import { defineConfig, type Plugin } from "vite"; · 5 unmodified lines | |||
| 6 | import appPlugin from "@executor-js/app/vite"; | 6 | import appPlugin from "@executor-js/app/vite"; |
| 7 | import { loadOrMintLocalAuthToken } from "./src/auth"; | 7 | import { loadOrMintLocalAuthToken } from "./src/auth"; |
| 8 | import { consumeOAuthResult } from "./src/oauth-result-store"; | 8 | import { consumeOAuthResult } from "./src/oauth-result-store"; |
| 9 | import { | 9 | import { 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.json | 11 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 15 | const rootPackage = JSON.parse( | 12 | const rootPackage = JSON.parse( |
| @@ -26,9 +23,7 @@ const cliPackage = JSON.parse( · 10 unmodified lines | |||
| 26 | ) as { version?: string }; | 23 | ) as { version?: string }; |
| 27 | 24 | ||
| 28 | const repositoryUrl = | 25 | const 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 | ||
| 33 | const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; | 28 | const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; |
| 34 | const EXECUTOR_GITHUB_URL = ( | 29 | const 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" && address | 65 | 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 the | 90 | // 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, |
| @@ -67,8 +67,8 @@ const pingSpec = (baseUrl: string): string => · 66 unmodified lines | |||
| 67 | }); | 67 | }); |
| 68 | 68 | ||
| 69 | const closeServer = (server: Server): Promise<void> => | 69 | const 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 | ||
| 74 | const servePingApi = Effect.acquireRelease( | 74 | const servePingApi = Effect.acquireRelease( |
| @@ -56,8 +56,8 @@ const pingSpec = (baseUrl: string): string => · 55 unmodified lines | |||
| 56 | }); | 56 | }); |
| 57 | 57 | ||
| 58 | const closeServer = (server: Server): Promise<void> => | 58 | const 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 | ||
| 63 | const servePingApi = Effect.acquireRelease( | 63 | const servePingApi = Effect.acquireRelease( |
| @@ -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"); |
| @@ -49,17 +49,7 @@ const insertIntegrationRawConfig = ( · 48 unmodified lines | |||
| 49 | sql: `INSERT INTO integration | 49 | 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 | ||
| 65 | const insertBlob = ( | 55 | const insertBlob = ( |
| @@ -121,14 +111,7 @@ const insertTool = ( · 55 unmodified lines | |||
| 121 | sql: `INSERT INTO tool | 111 | 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 | ||
| 134 | const insertDefinition = ( | 117 | const insertDefinition = ( |
| @@ -143,21 +126,13 @@ const insertDefinition = ( · 8 unmodified lines | |||
| 143 | sql: `INSERT INTO definition | 126 | 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 | ||
| 155 | describe("runSqliteGoogleOpenApiOwnershipMigration", () => { | 132 | describe("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" }, |
| @@ -1,8 +1,5 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect } from "effect"; |
| 2 | import { | 2 | import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core"; |
| 3 | DataMigrationError, | ||
| 4 | type SqliteDataMigrationClient, | ||
| 5 | } from "@executor-js/sdk/core"; | ||
| 6 | 3 | ||
| 7 | const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; | 4 | const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; |
| 8 | const googleOpenApiCandidate = (alias?: string): string => { | 5 | const 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 | ||
| 23 | export const runSqliteGoogleOpenApiOwnershipMigration = ( | 19 | export const runSqliteGoogleOpenApiOwnershipMigration = ( |
| @@ -0,0 +1 @@ | |||
| 1 | # @executor-js/plugin-toolkits | ||
| @@ -530,11 +530,12 @@ function CreateToolkitDialog(props: { · 529 unmodified lines | |||
| 530 | function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { | 530 | function AddToolkitCard(props: { owner: Owner; onClick: () => void }) { |
| 531 | const scopeLabel = addToolkitScopeLabel(props.owner); | 531 | const scopeLabel = addToolkitScopeLabel(props.owner); |
| 532 | return ( | 532 | return ( |
| 533 | <button | 533 | <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 | <span | 541 | <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 = selectedToolId | 1054 | 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 === null | 1231 | 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) |
| @@ -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( |