From e687fb583a4aeaebc1abe2dd326ce01b850b1162 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 2 May 2026 13:09:50 -0700 Subject: [PATCH 1/8] init plugin --- notes/dynamic-plugin-loading-v1.md | 860 +++++++++++++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 notes/dynamic-plugin-loading-v1.md diff --git a/notes/dynamic-plugin-loading-v1.md b/notes/dynamic-plugin-loading-v1.md new file mode 100644 index 000000000..565454d92 --- /dev/null +++ b/notes/dynamic-plugin-loading-v1.md @@ -0,0 +1,860 @@ +# Dynamic Plugin Loading — v1 + +Date: 2026-05-02 + +## Context + +Today plugins are workspace deps statically imported in `apps/local/src/server/executor.ts` +inside `createLocalPlugins()`. They register secret providers, connection providers, +and dynamic tools through `definePlugin`. The DX of authoring one is good — the +problem is that adding a plugin means editing host source. We want users to be +able to install plugins from npm and pick them up by editing config alone. + +The existing in-process plugin model already gets a lot right and we want to +keep it: Effect-native everything, end-to-end type inference from schema → +storage → extension → consumer (`executor[pluginId]`), scope-aware ctx, closure +methods, zero-allocation static tools delegating via `staticSources(self) => +[...]`. Whatever changes here must preserve those properties. + +What's missing is registration of three new surfaces from a plugin: + +- **API routes** — HTTP endpoints owned by the plugin, contributed as an + `HttpApiGroup` so they compose into the host's existing typed `HttpApi` +- **Frontend** — pages/widgets/components contributed to the host UI, with + reactive `AtomHttpApi`-backed clients matching the existing `toolsAtom` / + `sourcesAtom` pattern in `packages/react/src/api/atoms.tsx` +- **SDK** — already present via `extension`; the new HTTP routes give the + frontend a typed reactive client without hand-written fetch glue + +## Goal and non-goals + +**v1 goals:** + +- A plugin is a single npm package. `bun add @executor-js/plugin-foo`, + add it to `executor.config.ts`, restart, and it works. +- Plugins can register: extension methods (today), API routes (new), + frontend pages/widgets (new). +- Frontend half gets a typed reactive client from the plugin's `HttpApiGroup` + via the same `AtomHttpApi` pattern the core uses today. +- A plugin author can write the whole thing importing only from + `@executor-js/sdk` (and `@executor-js/sdk/client` on the frontend). Effect + imports are optional — for authors who want them, not required for those + who don't. +- `executor.config.ts` is the single source of truth — same file consumed by + the schema-gen CLI and the runtime. +- Type inference end-to-end stays intact (`executor.foo.method()` autocompletes). + +**Non-goals for v1:** + +- Cloud / multi-tenant deployment. +- Electron desktop dynamism. Desktop builds bake plugins at build time. +- Sandboxing, capability enforcement, marketplace, signing. +- Hot reload of plugin code (restart is fine). +- Loading plugins by string spec at runtime (we use import-and-call instead; + see decision #1). + +## Reference research summary + +Surveyed five plugin systems in `.reference/`: + +- **pi-mono** — filesystem scanning + jiti, factory function + ExtensionAPI. + Two-phase load (registration vs. action) is a clean idea. +- **opencode** — string specs in JSONC, dynamic `import()`, hook trigger + pattern with `(input, output)` mutation. +- **openclaw** — manifest-first control plane (`openclaw.plugin.json` declares + capabilities statically so the host can plan activation without loading code). +- **emdash** — **closest match to our setup.** Astro/Vite + React, plugins are + npm packages with separate `./` and `./admin` exports, registered via + import-and-call in `astro.config.mjs`. `definePlugin({ id, hooks, routes, + admin })` declarative shape. +- **dynamic-software** — most ambitious; Cloudflare Worker Loader for cloud + isolation, iframe + postMessage RPC for UI, Proxy-based typed API client. + +The pattern that fits us cleanest is emdash's: import-and-call in a config +file, single npm package with separate server/client exports, host integrates +via Vite. We don't need dynamic-software's Proxy RPC client because +`@effect/platform` already gives us `HttpApiClient` plus `AtomHttpApi` for +the reactive React side — both already used heavily in +`packages/core/api/src/api.ts` and `packages/react/src/api/`. + +## Decisions + +### 1. Config is import-and-call, not string specs + +`executor.config.ts` holds real imports of plugin factory functions. Type +inference flows naturally; no codegen step. + +```ts +// apps/local/executor.config.ts (after) +import { defineExecutorConfig } from "@executor-js/sdk" +import { openApiPlugin } from "@executor-js/plugin-openapi" +import { mcpPlugin } from "@executor-js/plugin-mcp" +import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets" + +export default defineExecutorConfig({ + dialect: "sqlite", + plugins: [ + openApiPlugin(), + mcpPlugin({ dangerouslyAllowStdioMCP: true }), + fileSecretsPlugin(), + ] as const, +}) +``` + +"Dynamic" means npm-installable, not load-by-string-name. Same model as emdash. +Codegen-based string specs (TanStack Router style) deferred to later if remote +config becomes a need. + +### 2. Plugin = single npm package, two exports + +```jsonc +// @executor-js/plugin-foo/package.json +{ + "name": "@executor-js/plugin-foo", + "type": "module", + "exports": { + "./server": "./dist/server.js", + "./client": "./dist/client.js" + }, + "executor": { + "id": "foo", + "version": "0.1.0" + } +} +``` + +``` +@executor-js/plugin-foo/ +├── package.json +├── src/ +│ ├── server.ts # Effect, Node deps, definePlugin +│ ├── client.tsx # React, defineClientPlugin +│ └── shared.ts # Schemas, types shared across the boundary +└── dist/ +``` + +Strict separation: server bundle never imports React, client bundle never +imports Effect/Node modules. Shared types live in `src/shared.ts` and are +imported by both halves. + +### 3. Extend `PluginSpec` with optional `routes`; add parallel `defineClientPlugin` + +Server side: keep `definePlugin`. Add optional `routes` field (the +`HttpApiGroup`) and `handlers` field (the typed `Layer`). No frontend +concepts on the server side. + +Client side: separate primitive `defineClientPlugin` lives in +`@executor-js/sdk/client`. Can only be imported in the `./client` entry, so +React types never leak into server bundles. + +**Layering — extension is the canonical SDK; routes/handlers is optional HTTP transport.** +The HTTP layer is *not a peer* of the SDK; it's a transport over it. Plugin +authors should treat extension as the implementation and write handlers as +thin wrappers that delegate via the `self` parameter (same pattern as +`staticSources(self) => [...]` already in the codebase). This keeps: + +- a single source of truth for the plugin's behavior +- in-process callers paying zero serialization cost +- HTTP callers getting auth/scope/observability middleware +- error contracts identical across the two surfaces + +Three plugin shapes fall out of this layering: + +- **SDK-only.** Pure programmatic. No `routes`, no `handlers`, no `./client` + export. Examples: file-secrets, keychain, onepassword, anything that's a + utility for other plugins or scripts. CLI/embedded consumers use + `executor..method()`. Vite plugin notices no `./client` and skips the + plugin in the frontend bundle entirely. +- **Both.** Extension *and* routes/handlers *and* a `./client`. Examples: + openapi, mcp, anything that needs a frontend. Routes are thin wrappers + over extension methods. +- **HTTP-only.** Rare — webhook receivers, OAuth callback URLs. Routes + without a meaningful in-process equivalent. May or may not have an + extension. + +### 4. Routes are an `HttpApiGroup`; client uses `AtomHttpApi` + +Plugins ship the same primitive the core uses (`HttpApiGroup` from +`effect/unstable/httpapi`). The host composes via the existing `addGroup` +helper at `packages/core/api/src/api.ts:21`. OpenAPI annotations and docs +flow automatically. + +For the frontend, plugins build a per-plugin `AtomHttpApi.Service` against +their own group, wrapped behind a `createPluginAtomClient(group, opts)` +helper. The resulting atoms are consumed via the existing +`useAtomValue` / `useAtomSet` + `AsyncResult.match` idiom — same pattern +as `toolsAtom`, `sourcesAtom`, etc. + +### 5. SDK re-exports the Effect HttpApi/Schema primitives + +Plugin authors can write a complete plugin importing only from +`@executor-js/sdk` (server) and `@executor-js/sdk/client` (frontend). The SDK +re-exports `Schema`, `HttpApi`, `HttpApiGroup`, `HttpApiEndpoint`, +`HttpApiBuilder`, `Effect` so authors who don't want to dig into Effect +don't have to. Authors who *do* want Effect-native code keep importing from +`effect` directly. This keeps the door open without forcing the dependency. + +### 6. Skip `capabilities` declaration for v1 + +`["read:secrets", "network:fetch"]` style declarations are useful for sandboxing +but unenforced metadata is just noise. Add when there's a real isolation story. + +### 7. `executor.config.ts` is the single source of truth + +Today the schema-gen CLI reads `executor.config.ts` but the runtime hardcodes +`createLocalPlugins()` in `apps/local/src/server/executor.ts:96`. Consolidate: +the runtime imports from `executor.config.ts` too. One list, two consumers. + +### 8. Canary plugin: build new tiny one first, then migrate openapi + +Validate the shape with a minimal `@executor-js/plugin-example` (one extension +method, one route, one widget) before changing real plugins. + +### 9. Cross-plugin pluggable capabilities: per-capability typed fields + +Some capabilities (secrets, eventually artifacts, maybe more) are +"pluggable" — many plugins can implement them, the host swaps between +providers via config, consumer code stays agnostic. + +Don't generalize this. The existing `secretProviders` field on the spec +already handles this exact pattern for secrets and works fine: + +```ts +// what plugins do today +secretProviders: (ctx) => [makeScopedProvider(...)] +``` + +Each new pluggable capability gets its *own* typed field on `PluginSpec`, +same shape. When artifacts lands, that's `artifactStore: () => +ArtifactStore`. If connection providers want to be modeled this way, the +existing `connectionProviders` field already is. + +No `provides` / `requires` / `service` machinery, no Effect-Tag-as-generic- +primitive abstraction, no "protocols" concept, no naming debate. The +artifacts note's "protocol" framing translates directly to "the v2 +`artifactStore` field on `PluginSpec`." + +If we eventually have 5–6 of these and the boilerplate genuinely screams +for generalization, we generalize then. Likely won't. + +For v1 nothing changes here — `secretProviders` is what it is, and +artifacts aren't shipping yet. + +## New type sketches + +### `PluginSpec` extension + +Existing fields unchanged. Add `routes` returning an `HttpApiGroup`: + +```ts +// packages/core/sdk/src/plugin.ts (extension) +import type { HttpApiGroup } from "effect/unstable/httpapi" + +export interface PluginSpec { + // ... existing: id, schema, storage, extension, staticSources, + // invokeTool, secretProviders, etc. + + /** HttpApiGroup contributed by this plugin. Composed into the host's + * HttpApi via the existing `addGroup` helper (api.ts:21). Host mounts + * it at /_executor/plugins/{id}/... and supplies auth + scope + * middleware. Endpoints automatically appear in the executor OpenAPI + * doc and the typed client. + * + * Type is `HttpApiGroup.Any` because the host composes a runtime array + * of groups; there's no compile-time way to track the full union. The + * strong typing of each group's endpoints lives inside the plugin — + * the plugin imports its own group directly in both `handlers` and + * the client (`createPluginAtomClient`), so endpoint payloads, + * responses, and errors are all concrete there. */ + readonly routes?: () => HttpApiGroup.Any + + /** Handlers Layer for this plugin's group. Built by the plugin against + * its own bundled API for full type safety on `.handle("name", ...)`, + * composes into the host's runtime `FullApi` because + * `HttpApiBuilder.group` keys the layer by group identity, not by the + * surrounding API. + * + * Receives `self: NoInfer` so handlers can delegate to + * extension methods (`self.listThings()`) — same pattern as + * `staticSources`. The extension is canonical; handlers are transport. */ + readonly handlers?: ( + self: NoInfer, + ) => Layer.Layer +} +``` + +Example plugin shape — group definition in `shared.ts` so client and +server both import it: + +```ts +// @executor-js/plugin-foo/src/shared.ts +import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk" + +export const Thing = Schema.Struct({ + id: Schema.String, + name: Schema.String, +}) + +export const FooApi = HttpApiGroup.make("foo") + .add( + HttpApiEndpoint.get("listThings")`/things` + .addSuccess(Schema.Array(Thing)), + ) + .add( + HttpApiEndpoint.post("syncThing")`/sync/:id` + .setPath(Schema.Struct({ id: Schema.String })) + .addSuccess(Thing) + .addError(SyncError), + ) +``` + +```ts +// @executor-js/plugin-foo/src/server.ts +import { definePlugin, HttpApi, HttpApiBuilder } from "@executor-js/sdk" +import { FooApi } from "./shared" + +// Bundle the group into a small HttpApi *for typing purposes only*. The +// handlers Layer is keyed by the FooApi group's identity, so it composes +// cleanly into the host's FullApi at runtime regardless of what other +// groups are around it. +const FooApiBundle = HttpApi.make("foo").add(FooApi) + +export const fooPlugin = definePlugin((opts?: FooConfig) => ({ + id: "foo" as const, + storage: () => ({ /* ... */ }), + + extension: (ctx) => ({ + listThings: () => /* Effect — canonical impl */, + syncThing: (id: string) => /* Effect — canonical impl */, + }), + + routes: () => FooApi, // exposes the group + + // Handlers are thin transport wrappers — they delegate to extension + // methods via `self`. Same pattern as `staticSources(self) => [...]`. + handlers: (self) => + HttpApiBuilder.group(FooApiBundle, "foo", (h) => + h + .handle("listThings", () => self.listThings()) + .handle("syncThing", ({ path }) => self.syncThing(path.id)), + ), +})) +``` + +Why `routes` and `handlers` are split: `routes` is the API description (a +group), `handlers` is the implementation Layer that delegates to the +extension. The host needs the group at composition time (to build +`FullApi`) and the Layer at serve time (to provide handler +implementations). Both are derived from the same `FooApi` in `shared.ts`. + +### `defineClientPlugin` + +Lives in `@executor-js/sdk/client`. Server bundles cannot import this module. + +```ts +// packages/core/sdk/src/client.ts +export interface ClientPluginSpec { + readonly id: TId + + /** Pages contributed to the host's TanStack router. Mounted under + * /plugins/{id}/{path}. Sidebar nav metadata declared on the route. */ + readonly pages?: readonly PageDecl[] + + /** Dashboard / overview widgets the host can render in known slots. */ + readonly widgets?: readonly WidgetDecl[] + + /** Components the host can render in named slots (e.g., source-detail + * panels, secret-picker variants). Slot names are part of the host + * contract — plugin opts in by registering. */ + readonly slots?: Record> +} + +type PageDecl = { + path: string // "/", "/edit/$id" + component: ComponentType + nav?: { label: string; section?: string } +} + +type WidgetDecl = { + id: string + component: ComponentType + size?: "half" | "full" +} + +export const defineClientPlugin = ( + spec: ClientPluginSpec, +) => spec +``` + +### `createPluginAtomClient` — typed reactive client per plugin + +Plugins build their own `AtomHttpApi.Service` against their own group +bundled into a small `HttpApi`. A helper hides the boilerplate so plugin +authors write one line per atom. Same shape as the existing +`ExecutorApiClient` in `packages/react/src/api/client.tsx:11`. + +```ts +// packages/core/sdk/src/client.ts (helper) +import { HttpApi } from "effect/unstable/httpapi" +import { FetchHttpClient } from "effect/unstable/http" +import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi" + +export const createPluginAtomClient = ( + group: G, + opts: { pluginId: string }, +) => { + const bundle = HttpApi.make(`plugin-${opts.pluginId}`).add(group) + return AtomHttpApi.Service<`Plugin_${string}Client`>()( + `Plugin_${opts.pluginId}Client`, + { + api: bundle, + httpClient: FetchHttpClient.layer, + baseUrl: `/_executor/plugins/${opts.pluginId}`, + }, + ) +} +``` + +Plugin author writes: + +```tsx +// @executor-js/plugin-foo/src/client.tsx +import { + defineClientPlugin, + createPluginAtomClient, + useAtomValue, + useAtomSet, + AsyncResult, +} from "@executor-js/sdk/client" +import { FooApi } from "./shared" + +const FooClient = createPluginAtomClient(FooApi, { pluginId: "foo" }) + +export const fooThingsAtom = FooClient.query("foo", "listThings", { + timeToLive: "30 seconds", + reactivityKeys: ["foo:things"], +}) + +export const fooSync = FooClient.mutation("foo", "syncThing") + +const FooPage = () => { + const things = useAtomValue(fooThingsAtom) + const doSync = useAtomSet(fooSync, { mode: "promise" }) + + return AsyncResult.match(things, { + onInitial: () => , + onFailure: () =>

Failed to load

, + onSuccess: ({ value }) => ( + doSync({ path: { id } })} + /> + ), + }) +} + +export default defineClientPlugin({ + id: "foo" as const, + pages: [{ path: "/", component: FooPage, nav: { label: "Foo" } }], + widgets: [{ id: "foo-status", component: FooStatus, size: "half" }], +}) +``` + +Type inference: `FooClient.query("foo", "listThings", ...)` is fully typed +against `FooApi` — same checks the existing `ExecutorApiClient.query("tools", +"list", ...)` performs. No codegen, no host-wide composed-API typing +required. Each plugin is self-contained in its client typing. + +Server-side composition: the host builds the runtime `FullApi` from +`routes()` results, then provides the `handlers()` layers when serving. +Each plugin's handlers Layer is keyed by its group's identity, so it +slots into `FullApi` without the host needing the typed structure: + +```ts +const FullApi = config.plugins.reduce( + (api, p) => p.routes ? api.add(p.routes()) : api, + CoreExecutorApi, +) +const PluginHandlerLayers = Layer.mergeAll( + ...config.plugins.flatMap((p) => p.handlers ? [p.handlers()] : []), +) +const ServerLive = HttpApiBuilder.api(FullApi).pipe( + Layer.provide(PluginHandlerLayers), + Layer.provide(CoreHandlerLayers), +) +``` + +Effect errors flow through the existing typed-error machinery — same as +core handlers in `packages/core/api/src/handlers/`. + +### Loader: `executor.config.ts` → runtime + frontend bundle + +Single config, two consumers. + +**Backend** (replaces `createLocalPlugins`): + +```ts +// apps/local/src/server/executor.ts (after) +import config from "../../executor.config.ts" + +const executor = yield* createExecutor({ + scopes: [scope], + adapter, + blobs, + plugins: config.plugins, + onElicitation: "accept-all", +}) +``` + +The plugins are already configured (factory called) by the time +`executor.config.ts` is evaluated, so no async loader needed. + +**Frontend** — Vite plugin reads the same config, resolves each plugin's +`./client` export, exposes a virtual module: + +```ts +// packages/vite-plugin-executor/src/index.ts (pseudocode) +export default function executorVite(): Plugin { + return { + name: "executor-plugins", + resolveId(id) { + if (id === "virtual:executor/plugins-client") return "\0" + id + }, + async load(id) { + if (id !== "\0virtual:executor/plugins-client") return + const config = await loadExecutorConfig() + const imports = config.plugins + .map((p, i) => `import p${i} from "${p.id}/client"`) + .join("\n") + const list = config.plugins.map((_, i) => `p${i}`).join(", ") + return `${imports}\nexport const plugins = [${list}]` + }, + } +} +``` + +Host app consumes: + +```tsx +// apps/local/src/main.tsx (pseudocode) +import { plugins } from "virtual:executor/plugins-client" +import { mountPluginRoutes, mountPluginWidgets } from "@executor-js/react" + +const router = createRouter({ + routeTree: extendRouteTree(baseRouteTree, plugins), +}) +``` + +HMR works because the virtual module is part of Vite's graph. Adding a plugin +needs a Vite restart (not a hot update — config changed). + +## End-to-end example plugin + +``` +@executor-js/plugin-example/ +├── package.json +└── src/ + ├── server.ts + ├── client.tsx + └── shared.ts +``` + +```ts +// src/shared.ts — only @executor-js/sdk imports, no raw effect imports +import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk" + +export const Greeting = Schema.Struct({ + message: Schema.String, + count: Schema.Number, +}) +export type Greeting = typeof Greeting.Type + +export const ExampleApi = HttpApiGroup.make("example") + .add( + HttpApiEndpoint.post("greet")`/greet` + .setPayload(Schema.Struct({ name: Schema.String })) + .addSuccess(Greeting), + ) +``` + +```ts +// src/server.ts +import { definePlugin, Effect, HttpApi, HttpApiBuilder } from "@executor-js/sdk" +import { ExampleApi } from "./shared" + +const ExampleApiBundle = HttpApi.make("example").add(ExampleApi) + +export const examplePlugin = definePlugin(() => ({ + id: "example" as const, + storage: () => ({ count: 0 }), + + // Canonical implementation lives here. + extension: (ctx) => ({ + greet: (name: string) => + Effect.sync(() => ({ + message: `hello ${name}`, + count: ++ctx.storage.count, + })), + }), + + routes: () => ExampleApi, + + // Handler delegates to extension. payload.name is fully typed. + handlers: (self) => + HttpApiBuilder.group(ExampleApiBundle, "example", (h) => + h.handle("greet", ({ payload }) => self.greet(payload.name)), + ), +})) + +export default examplePlugin +``` + +```tsx +// src/client.tsx +import { + defineClientPlugin, + createPluginAtomClient, + useAtomSet, +} from "@executor-js/sdk/client" +import { useState } from "react" +import { ExampleApi } from "./shared" + +const ExampleClient = createPluginAtomClient(ExampleApi, { pluginId: "example" }) + +const greetAtom = ExampleClient.mutation("example", "greet") + +const ExamplePage = () => { + const [name, setName] = useState("world") + const [result, setResult] = useState() + const doGreet = useAtomSet(greetAtom, { mode: "promise" }) + + return ( +
+ setName(e.target.value)} /> + + {result &&
{result}
} +
+ ) +} + +export default defineClientPlugin({ + id: "example" as const, + pages: [{ path: "/", component: ExamplePage, nav: { label: "Example" } }], +}) +``` + +```jsonc +// package.json +{ + "name": "@executor-js/plugin-example", + "type": "module", + "exports": { + "./server": "./dist/server.js", + "./client": "./dist/client.js" + }, + "executor": { "id": "example", "version": "0.1.0" }, + "peerDependencies": { + "@executor-js/sdk": "workspace:*", + "react": "catalog:" + } +} +``` + +User adds it: + +```ts +// apps/local/executor.config.ts +import { examplePlugin } from "@executor-js/plugin-example/server" +plugins: [/* ... */, examplePlugin()] +``` + +## SDK-only plugin example + +Many plugins don't need HTTP at all — utilities, providers, anything used +purely from other plugins or scripts. The plugin shape collapses to one +file with no `./client` export, no `routes`, no `handlers`. + +``` +@executor-js/plugin-rate-limiter/ +├── package.json +└── src/ + └── server.ts +``` + +```jsonc +// package.json +{ + "name": "@executor-js/plugin-rate-limiter", + "type": "module", + "exports": { "./server": "./dist/server.js" }, + "executor": { "id": "rateLimiter", "version": "0.1.0" }, + "peerDependencies": { "@executor-js/sdk": "workspace:*" } +} +``` + +```ts +// src/server.ts +import { definePlugin, defineSchema, Effect, Schema } from "@executor-js/sdk" + +interface RateLimiterConfig { + defaultLimit?: number +} + +class RateLimitExceeded extends Schema.TaggedError()( + "RateLimitExceeded", + { key: Schema.String, retryAfterMs: Schema.Number }, +) {} + +export const rateLimiterPlugin = definePlugin( + (opts: RateLimiterConfig = {}) => ({ + id: "rateLimiter" as const, + + schema: defineSchema({ + rate_buckets: { + fields: { + key: { type: "string", primary: true }, + tokens: { type: "number" }, + updated_at: { type: "number" }, + }, + }, + }), + + storage: ({ adapter }) => ({ + adapter, + defaultLimit: opts.defaultLimit ?? 60, + }), + + extension: (ctx) => ({ + check: (key: string, cost = 1) => + Effect.gen(function* () { + const bucket = yield* readOrCreateBucket(ctx.storage, key) + const refilled = refill(bucket, ctx.storage.defaultLimit) + if (refilled.tokens < cost) { + return yield* new RateLimitExceeded({ + key, + retryAfterMs: estimateRetry(refilled), + }) + } + yield* writeBucket(ctx.storage, key, refilled.tokens - cost) + return { allowed: true, remaining: refilled.tokens - cost } + }), + + reset: (key: string) => + Effect.tryPromise(() => + ctx.storage.adapter.delete({ + model: "rate_buckets", + where: [["key", "=", key]], + }), + ), + }), + }), +) +``` + +Pure programmatic consumption — works identically from CLI, tests, +embedded library use, or another plugin's `extension`: + +```ts +const result = yield* executor.rateLimiter.check("user-123", 5) +// ^? { allowed: true; remaining: number } + +yield* executor.rateLimiter.check("user-123", 1000).pipe( + Effect.catchTag("RateLimitExceeded", (err) => + Effect.log(`hit limit on ${err.key}, retry in ${err.retryAfterMs}ms`), + ), +) +``` + +What the host does with this plugin: + +- **Backend** composes it into `createExecutor`, exposes `executor.rateLimiter.*`. ✅ +- **HTTP server** sees no `routes`/`handlers` — mounts nothing. ✅ +- **Vite plugin** sees no `./client` export in `package.json`'s exports map — adds nothing to the frontend bundle. ✅ +- **CLI** uses `executor.rateLimiter.*` directly. ✅ + +The plugin is invisible to anything not calling it — no HTTP surface, no +frontend bundle cost, no auth surface to review. + +## Sequencing + +Rough build order. Each step lands independently and the system stays working +between them. + +1. **Consolidate config.** Make `apps/local/src/server/executor.ts` read from + `executor.config.ts`. Delete `createLocalPlugins`. No new features yet — + just verify the system runs unchanged with the new wiring. +2. **SDK re-exports.** Re-export `Schema`, `HttpApi`, `HttpApiGroup`, + `HttpApiEndpoint`, `HttpApiBuilder`, `Effect` from `@executor-js/sdk`. + Mirror equivalents in `@executor-js/sdk/client` (`useAtomValue`, + `useAtomSet`, `AsyncResult`). Cheap and lets later steps import from + the SDK only. +3. **Add `routes` to `PluginSpec`** + host-side composition via the existing + `addGroup` helper. Mount each plugin's group under + `/_executor/plugins/{id}/...` with shared scope/auth middleware. No + plugin uses it yet. +4. **Build `defineClientPlugin` + `createPluginAtomClient`** in + `@executor-js/sdk/client`. Build the Vite plugin + virtual module so the + host bundle picks up plugin client modules. No plugin uses it yet. +5. **Build `@executor-js/plugin-example`** end-to-end. This is the proof + that the contract works; if anything is wrong the friction shows up here. +6. **Migrate `@executor-js/plugin-openapi`** to expose an `HttpApiGroup` + for its existing extension methods + a basic source list page on the + frontend. Real-world test. + +## Open questions / deferred + +- **Pluggable artifacts.** When workflows lands, add an `artifactStore` + field on `PluginSpec` shaped like `secretProviders` — typed, no generic + abstraction. The artifacts note's "protocol package + provider plugin + + feature plugin" translates to "shared interface package + provider + plugin's `artifactStore` field + consumer plugin reading from `ctx`." +- **Codegen-based string specs.** If we ever want config from a database or + remote URL, switch to strings + a TanStack-Router-style generated `.d.ts`. + Not needed until multi-tenant. +- **Electron dynamism.** Currently desktop bakes plugins at build time. To let + desktop users install plugins post-ship, would need either a runtime ESM + story (import maps) or a "plugin pack" prebuilt-bundle model. Out of scope + for v1. +- **Sandboxing.** Plugins run in-process with full host trust. Worth revisiting + when we have untrusted plugins (marketplace) — likely via dynamic-software's + Worker Loader pattern or similar. +- **Per-version plugin isolation.** Cloud-only concern. +- **Hot reload of plugin code.** Restart is fine for v1; would be nice but + costs significant design effort. + +## References + +- emdash: `astro.config.mjs` import-and-call pattern, `package.json` exports + for separate admin entry — see `.reference/emdash/demos/plugins-demo/`. +- dynamic-software: PluginManifest + capability declarations idea (deferred); + Worker Loader for cloud isolation — see `.reference/dynamic-software/`. +- openclaw: manifest-first control plane idea (deferred but worth revisiting + when we add capability enforcement). +- Existing executor plugin contract: `packages/core/sdk/src/plugin.ts:308` + (`PluginSpec` interface), `packages/core/sdk/src/plugin.ts:451` + (`definePlugin` factory). +- Existing host `HttpApi` composition + plugin-group helper: + `packages/core/api/src/api.ts:14` (`CoreExecutorApi`), + `packages/core/api/src/api.ts:21` (`addGroup`). +- Existing reactive client pattern to mirror per-plugin: + `packages/react/src/api/client.tsx:11` (`AtomHttpApi.Service`), + `packages/react/src/api/atoms.tsx` (query/mutation atom definitions), + `packages/react/src/pages/sources.tsx:167` (`AsyncResult.match` idiom). +- Existing config consumer (CLI schema-gen): + `apps/local/executor.config.ts`. +- Existing runtime plugin list (to be consolidated): + `apps/local/src/server/executor.ts:96` (`createLocalPlugins`). +- Existing pluggable-capability shape: + `packages/plugins/file-secrets/src/index.ts:204` (the `secretProviders` + field). When artifacts ship, the new `artifactStore` field follows the + same per-capability-typed-field pattern. +- `notes/artifacts-workflows-and-generated-ui.md` — the artifacts/workflows + plan. Uses "protocol" terminology that we're not adopting; read its + "ArtifactStoreProtocol" as "the typed interface that goes into the + `artifactStore` field." See decision #9. +- Earlier plugin first-principles thinking: + `personal-notes/plugin-system-first-principles.md`, + `personal-notes/plugin-system-primitive-and-use-cases.md`. From 022bb7506ad157cec4dd12700fd12bbb3ccb12bd Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 2 May 2026 16:34:57 -0700 Subject: [PATCH 2/8] Spec-driven plugin loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single source of truth for the plugin list lives in `executor.config.ts` (local + cloud). Each plugin's spec contributes its own HttpApiGroup, late-binding handlers Layer, and a Service tag the host satisfies — at boot for local, per request for cloud. New SDK helpers compose them: composePluginApi(plugins) composePluginHandlers(plugins, executor) // local: eager binding composePluginHandlerLayer(plugins) // cloud: leave Tag unbound providePluginExtensions(plugins)(executor) // cloud: per-request Frontend mirrors the same shape: `defineClientPlugin` declares pages, widgets, slot components, plus optional `sourcePlugin` / `secretProviderPlugin` contributions. The new `@executor-js/vite-plugin` reads `executor.config.ts` and emits a `virtual:executor/plugins-client` module from each plugin's `./client` export. The host wraps once with `` at the route root; pages call `useSourcePlugins()` / `useSecretProviderPlugins()` / `useClientPlugins()` instead of importing from any per-app aggregator. The SDK ships re-exports of Effect, Schema, HttpApi*, plus reactive primitives on the client side, so plugin authors can stay within `@executor-js/sdk` and `@executor-js/sdk/client` without reaching for `effect/*` directly. Each plugin declares two strings: `id` for runtime ergonomics (`executor.openapi.foo()`) and `packageName` for build-time resolution (`${packageName}/client`). No transforms, no scope conventions, no fallback magic. The Vite plugin warns loudly if `packageName` is set but unresolvable so typos fail visibly. Six existing plugins migrated (openapi, mcp, graphql, google-discovery, onepassword, workos-vault). New plugin-example serves as a canary — shared HttpApiGroup, server with extension/routes/handlers, client contributing a navbar page through the catch-all `/plugins/$pluginId/$` route. Local + cloud servers no longer manually wire each plugin's `*Group` / `*Handlers` / `*ExtensionService`; cloud retains a few type-only `*ExtensionService` imports for the per-request middleware's `provides` clause (framework requires static Tag identifiers there). Routes/shells/pages have zero plugin imports. Adding a plugin is a single edit to `executor.config.ts` plus a Vite restart. --- apps/cloud/executor.config.ts | 53 ++- apps/cloud/package.json | 1 + apps/cloud/src/api/protected-layers.ts | 61 ++- apps/cloud/src/api/protected.ts | 44 +- apps/cloud/src/mcp-session.e2e.node.test.ts | 15 +- apps/cloud/src/routes/__root.tsx | 8 +- apps/cloud/src/routes/index.tsx | 7 +- apps/cloud/src/routes/secrets.tsx | 1 - apps/cloud/src/routes/sources.$namespace.tsx | 7 +- .../src/routes/sources.add.$pluginKey.tsx | 6 - .../services/__test-harness__/api-harness.ts | 26 +- apps/cloud/src/services/executor.ts | 39 +- apps/cloud/src/vite-env.d.ts | 5 + apps/cloud/src/web/shell.tsx | 7 +- apps/cloud/vite.config.ts | 2 + apps/local/executor.config.ts | 35 +- apps/local/package.json | 2 + apps/local/src/routeTree.gen.ts | 21 + apps/local/src/routes/__root.tsx | 6 +- apps/local/src/routes/index.tsx | 15 +- apps/local/src/routes/plugins.$pluginId.$.tsx | 41 ++ apps/local/src/routes/secrets.tsx | 5 +- apps/local/src/routes/sources.$namespace.tsx | 15 +- .../src/routes/sources.add.$pluginKey.tsx | 22 +- apps/local/src/server/executor-schema.ts | 7 - apps/local/src/server/executor.ts | 47 +- apps/local/src/server/main.ts | 81 ++-- apps/local/src/vite-env.d.ts | 5 + apps/local/src/web/shell.tsx | 65 ++- apps/local/vite.config.ts | 2 + bun.lock | 59 +++ notes/dynamic-plugin-loading-v1.md | 429 +++++++++++++----- packages/core/api/src/index.ts | 7 + packages/core/api/src/plugin-routes.ts | 185 ++++++++ packages/core/api/src/server.ts | 7 + packages/core/cli/src/commands/generate.ts | 5 +- packages/core/sdk/package.json | 20 +- packages/core/sdk/src/client.ts | 290 ++++++++++++ packages/core/sdk/src/config.ts | 55 ++- packages/core/sdk/src/index.ts | 25 +- packages/core/sdk/src/plugin.ts | 118 ++++- packages/core/sdk/tsup.config.ts | 3 +- packages/core/vite-plugin/package.json | 53 +++ packages/core/vite-plugin/src/index.ts | 185 ++++++++ packages/core/vite-plugin/tsconfig.json | 23 + packages/core/vite-plugin/tsup.config.ts | 12 + packages/plugins/example/package.json | 70 +++ packages/plugins/example/src/client.tsx | 81 ++++ packages/plugins/example/src/server.ts | 75 +++ packages/plugins/example/src/shared.ts | 25 + packages/plugins/example/tsconfig.json | 23 + packages/plugins/example/tsup.config.ts | 14 + .../plugins/google-discovery/package.json | 3 +- .../google-discovery/src/react/client.ts | 13 +- .../src/react/plugin-client.tsx | 8 + .../src/react/source-plugin.ts | 2 +- .../google-discovery/src/sdk/plugin.ts | 11 + packages/plugins/graphql/package.json | 3 +- packages/plugins/graphql/src/react/client.ts | 15 +- .../graphql/src/react/plugin-client.tsx | 8 + .../graphql/src/react/source-plugin.ts | 2 +- packages/plugins/graphql/src/sdk/plugin.ts | 10 +- packages/plugins/keychain/src/index.ts | 8 +- packages/plugins/mcp/package.json | 3 +- packages/plugins/mcp/src/react/client.ts | 15 +- .../plugins/mcp/src/react/plugin-client.tsx | 21 + .../plugins/mcp/src/react/source-plugin.tsx | 2 +- packages/plugins/mcp/src/sdk/plugin.ts | 19 +- packages/plugins/onepassword/package.json | 3 +- .../plugins/onepassword/src/react/client.ts | 15 +- .../onepassword/src/react/plugin-client.tsx | 8 + .../src/react/secret-provider-plugin.ts | 2 +- .../plugins/onepassword/src/sdk/plugin.ts | 11 + packages/plugins/openapi/package.json | 1 + packages/plugins/openapi/src/react/client.ts | 15 +- .../openapi/src/react/plugin-client.tsx | 20 + .../openapi/src/react/source-plugin.ts | 2 +- packages/plugins/openapi/src/sdk/plugin.ts | 23 +- packages/plugins/workos-vault/package.json | 3 +- .../workos-vault/src/react/plugin-client.tsx | 8 + .../src/react/secret-provider-plugin.ts | 2 +- .../plugins/workos-vault/src/sdk/plugin.ts | 1 + .../react/src/components/command-palette.tsx | 6 +- packages/react/src/pages/secrets.tsx | 5 +- packages/react/src/pages/source-detail.tsx | 12 +- packages/react/src/pages/sources-add.tsx | 6 +- packages/react/src/pages/sources.tsx | 27 +- .../src/plugins/secret-provider-plugin.tsx | 26 -- packages/react/src/plugins/source-plugin.tsx | 94 ---- 89 files changed, 2165 insertions(+), 683 deletions(-) create mode 100644 apps/local/src/routes/plugins.$pluginId.$.tsx create mode 100644 packages/core/api/src/plugin-routes.ts create mode 100644 packages/core/sdk/src/client.ts create mode 100644 packages/core/vite-plugin/package.json create mode 100644 packages/core/vite-plugin/src/index.ts create mode 100644 packages/core/vite-plugin/tsconfig.json create mode 100644 packages/core/vite-plugin/tsup.config.ts create mode 100644 packages/plugins/example/package.json create mode 100644 packages/plugins/example/src/client.tsx create mode 100644 packages/plugins/example/src/server.ts create mode 100644 packages/plugins/example/src/shared.ts create mode 100644 packages/plugins/example/tsconfig.json create mode 100644 packages/plugins/example/tsup.config.ts create mode 100644 packages/plugins/google-discovery/src/react/plugin-client.tsx create mode 100644 packages/plugins/graphql/src/react/plugin-client.tsx create mode 100644 packages/plugins/mcp/src/react/plugin-client.tsx create mode 100644 packages/plugins/onepassword/src/react/plugin-client.tsx create mode 100644 packages/plugins/openapi/src/react/plugin-client.tsx create mode 100644 packages/plugins/workos-vault/src/react/plugin-client.tsx delete mode 100644 packages/react/src/plugins/secret-provider-plugin.tsx delete mode 100644 packages/react/src/plugins/source-plugin.tsx diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index c0a3fb0e9..e023cf39c 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -1,25 +1,52 @@ -import { defineExecutorConfig } from "@executor-js/sdk"; +import { + defineExecutorConfig, + type ConfigPluginDeps, +} from "@executor-js/sdk"; import { openApiPlugin } from "@executor-js/plugin-openapi"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { graphqlPlugin } from "@executor-js/plugin-graphql"; import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; // --------------------------------------------------------------------------- -// Executor config for CLI schema generation. +// Single source of truth for the cloud app's plugin list. // -// The CLI reads `plugins` + `dialect` to produce a drizzle schema file. -// Plugin credentials are stubs — the CLI only reads `plugin.schema`, -// never calls the plugin at runtime. +// Consumed by: +// - the schema-gen CLI (reads `plugin.schema` only; calls `plugins({})`) +// - the host runtime (calls `plugins({ workosCredentials })` per request) +// - the test harness (calls `plugins({ workosVaultClient })` per test) +// +// Cloud only ships plugins safe to run in a multi-tenant setting — no +// stdio MCP, no keychain/file-secrets/1password/google-discovery. // --------------------------------------------------------------------------- +declare module "@executor-js/sdk" { + interface ConfigPluginDeps { + /** WorkOS vault credentials. Provided per-request from `env.WORKOS_*` + * in production; the test harness leaves this undefined and uses + * `workosVaultClient` to inject an in-memory fake instead. */ + readonly workosCredentials?: { + readonly apiKey: string; + readonly clientId: string; + }; + /** Pluggable WorkOS Vault HTTP client — set by the test harness to + * bypass the real WorkOS API. Production leaves this undefined and + * falls back to the credential-driven default. */ + readonly workosVaultClient?: import( + "@executor-js/plugin-workos-vault" + ).WorkOSVaultClient; + } +} + export default defineExecutorConfig({ dialect: "pg", - plugins: [ - openApiPlugin(), - mcpPlugin({ dangerouslyAllowStdioMCP: false }), - graphqlPlugin(), - workosVaultPlugin({ - credentials: { apiKey: "", clientId: "" }, - }), - ], + plugins: ({ workosCredentials, workosVaultClient }) => + [ + openApiPlugin(), + mcpPlugin({ dangerouslyAllowStdioMCP: false }), + graphqlPlugin(), + workosVaultPlugin({ + credentials: workosCredentials ?? { apiKey: "", clientId: "" }, + ...(workosVaultClient ? { client: workosVaultClient } : {}), + }), + ] as const, }); diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 15a1c2aca..74ac59f9e 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -40,6 +40,7 @@ "@executor-js/sdk": "workspace:*", "@executor-js/storage-core": "workspace:*", "@executor-js/storage-postgres": "workspace:*", + "@executor-js/vite-plugin": "workspace:*", "@microlabs/otel-cf-workers": "^1.0.0-rc.52", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "~1.9.0", diff --git a/apps/cloud/src/api/protected-layers.ts b/apps/cloud/src/api/protected-layers.ts index 1b5685141..898c079dd 100644 --- a/apps/cloud/src/api/protected-layers.ts +++ b/apps/cloud/src/api/protected-layers.ts @@ -3,25 +3,39 @@ // non-protected/org handlers (which transitively import // `@tanstack/react-start`, unresolvable in the Workers test runtime). -import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpRouter, HttpServer } from "effect/unstable/http"; import { Layer } from "effect"; +import { CoreExecutorApi, observabilityMiddleware } from "@executor-js/api"; import { - CoreExecutorApi, - observabilityMiddleware, -} from "@executor-js/api"; -import { CoreHandlers } from "@executor-js/api/server"; -import { OpenApiGroup, OpenApiHandlers } from "@executor-js/plugin-openapi/api"; -import { McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; -import { GraphqlGroup, GraphqlHandlers } from "@executor-js/plugin-graphql/api"; + CoreHandlers, + composePluginApi, + composePluginHandlerLayer, +} from "@executor-js/api/server"; +// Type-only Group imports — needed for `HttpApiClient.ForApi` to type test clients precisely. Runtime +// composition is data-driven via `composePluginApi(cloudPlugins)`. +// These imports erase at build time and are NOT a fanout of plugin +// runtime wiring. +import type { OpenApiGroup } from "@executor-js/plugin-openapi/api"; +import type { McpGroup } from "@executor-js/plugin-mcp/api"; +import type { GraphqlGroup } from "@executor-js/plugin-graphql/api"; +import executorConfig from "../../executor.config"; import { UserStoreService } from "../auth/context"; import { WorkOSAuth } from "../auth/workos"; import { AutumnService } from "../services/autumn"; import { DbService } from "../services/db"; import { ErrorCaptureLive } from "../observability"; +// Plugin list at module-eval time — `plugins({})` is safe because +// `.routes()` doesn't read host deps; the per-request env credentials +// are only consumed when the plugin is actually constructed inside +// `createScopedExecutor`. The schema-gen CLI relies on the same +// property. +const cloudPlugins = executorConfig.plugins({}); + // `ProtectedCloudApi` deliberately does NOT declare `.middleware(OrgAuth)` // — auth + per-request execution stack construction live in a single // `HttpRouter` middleware (`ExecutionStackMiddleware` in `./protected.ts`) @@ -30,9 +44,22 @@ import { ErrorCaptureLive } from "../observability"; // it INSIDE the router middleware (wrong order), and added a second auth // pass on top of the existing one in `protected.ts`'s outer effect. The // router-middleware approach folds both into one place. -export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) - .add(McpGroup) - .add(GraphqlGroup); +// +// Runtime composition is via `composePluginApi(cloudPlugins)` (loosely +// typed). The typed cast below recovers the precise group union for +// `HttpApiClient.ForApi` — needed by the +// test harness's typed clients. +type CoreGroups = + typeof CoreExecutorApi extends HttpApi.HttpApi ? G : never; + +export type ProtectedCloudApiShape = HttpApi.HttpApi< + "executor", + CoreGroups | typeof OpenApiGroup | typeof McpGroup | typeof GraphqlGroup +>; + +export const ProtectedCloudApi = composePluginApi( + cloudPlugins, +) as unknown as ProtectedCloudApiShape; const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); @@ -49,14 +76,14 @@ export const SharedServices = Layer.mergeAll( export const RouterConfig = Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 }); -// Every handler the ProtectedCloudApi routes to. The test harness builds -// its own api-live by merging this with its own per-request middleware -// fakes; prod uses `ProtectedCloudApiLive` below. +// Every handler the ProtectedCloudApi routes to. Plugin handler layers +// are late-binding — they require their plugin's `extensionService` +// Tag, which the per-request `ExecutionStackMiddleware` satisfies via +// `providePluginExtensions`. The test harness mirrors this; nothing +// else needs to know which plugins are wired. export const ProtectedCloudApiHandlers = Layer.mergeAll( CoreHandlers, - OpenApiHandlers, - McpHandlers, - GraphqlHandlers, + composePluginHandlerLayer(cloudPlugins), ); // `ErrorCaptureLive` is provided above the handler + middleware layers diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 608eed7a7..c3a213009 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -12,11 +12,16 @@ import { Effect, Layer } from "effect"; import { ExecutionEngineService, ExecutorService, + providePluginExtensions, } from "@executor-js/api/server"; -import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; -import { McpExtensionService } from "@executor-js/plugin-mcp/api"; -import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; +// Type-only imports — needed in the `provides` clause so the framework +// knows which Service tags this middleware satisfies. Runtime binding +// is data-driven via `providePluginExtensions(cloudPlugins)`. +import type { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; +import type { McpExtensionService } from "@executor-js/plugin-mcp/api"; +import type { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; +import executorConfig from "../../executor.config"; import { AuthContext } from "../auth/middleware"; import { authorizeOrganization } from "../auth/authorize-organization"; import { UserStoreService } from "../auth/context"; @@ -33,23 +38,15 @@ import { } from "./protected-layers"; import { requestScopedMiddleware } from "./request-scoped"; -// One `HttpRouter` middleware that: -// 1. authenticates the WorkOS sealed session, -// 2. verifies live org membership (closes the JWT-cache gap — see -// `auth/authorize-organization.ts`), -// 3. resolves the org name, -// 4. builds the per-request executor + engine, -// 5. provides `AuthContext` + the execution-stack services to the handler. -// -// Replaces both the old outer `Effect.gen` in this file (which did its own -// WorkOS lookup) and the per-route `OrgAuth` HttpApiMiddleware (which did -// a second one). -// -// Errors are NOT caught here: failures propagate as typed errors and are -// rendered to a JSON response by the framework's `Respondable` pipeline -// (see `HttpResponseError` in `./error-response.ts`). Letting `unhandled` -// pass through is what satisfies `HttpRouter.middleware`'s brand check -// without any type casts. +// Plugin list at module-eval time — same property as +// `protected-layers.ts`: `plugins({})` is safe because no plugin's +// extension construction runs here. +const cloudPlugins = executorConfig.plugins({}); + +// Pre-compute the per-plugin `Effect.provideService(extensionService, +// executor[id])` chain. The plugin spec carries the Service tag so +// this file doesn't import each plugin's `*/api` directly. +const provideExecutorExtensions = providePluginExtensions(cloudPlugins); // One `HttpRouter` middleware that: // 1. authenticates the WorkOS sealed session, @@ -74,6 +71,9 @@ import { requestScopedMiddleware } from "./request-scoped"; // fresh per request so the postgres.js socket lives in the request // fiber's scope, not the worker's boot scope. const ExecutionStackMiddleware = HttpRouter.middleware<{ + // Listed for layer-level satisfaction. Runtime binding is data-driven + // via `providePluginExtensions(cloudPlugins)(executor)` below — no + // per-plugin `*ExtensionService` value imports needed. provides: | AuthContext | ExecutorService @@ -117,9 +117,7 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ Effect.provideService(AuthContext, auth), Effect.provideService(ExecutorService, executor), Effect.provideService(ExecutionEngineService, engine), - Effect.provideService(OpenApiExtensionService, executor.openapi), - Effect.provideService(McpExtensionService, executor.mcp), - Effect.provideService(GraphqlExtensionService, executor.graphql), + provideExecutorExtensions(executor), ); }).pipe(Effect.provideContext(longLived)); }), diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index a7dfdcb2c..0f8eac095 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -37,11 +37,7 @@ import { makePostgresAdapter, makePostgresBlobStore, } from "@executor-js/storage-postgres"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; -import { graphqlPlugin } from "@executor-js/plugin-graphql"; -import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; - +import executorConfig from "../executor.config"; import { DbService } from "./services/db"; import { makeFakeVaultClient } from "./services/__test-harness__/api-harness"; @@ -105,12 +101,9 @@ const buildScopedExecutor = ( ) => Effect.gen(function* () { const { db } = yield* DbService; - const basePlugins = [ - openApiPlugin(), - mcpPlugin({ dangerouslyAllowStdioMCP: false }), - graphqlPlugin(), - workosVaultPlugin({ client: makeFakeVaultClient() }), - ] as const; + const basePlugins = executorConfig.plugins({ + workosVaultClient: makeFakeVaultClient(), + }); const plugins = options.withElicitingPlugin ? ([...basePlugins, elicitingTestPlugin()] as const) : basePlugins; diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 8b81b5c2b..5374743dc 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -7,6 +7,8 @@ import { PostHogProvider } from "posthog-js/react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { Skeleton } from "@executor-js/react/components/skeleton"; import { Toaster } from "@executor-js/react/components/sonner"; +import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { AuthProvider, useAuth } from "../web/auth"; import { LoginPage } from "../web/pages/login"; import { OnboardingPage } from "../web/pages/onboarding"; @@ -161,8 +163,10 @@ function AuthGate() { return ( }> - - + + + + ); diff --git a/apps/cloud/src/routes/index.tsx b/apps/cloud/src/routes/index.tsx index ad2a93e7e..01273b87a 100644 --- a/apps/cloud/src/routes/index.tsx +++ b/apps/cloud/src/routes/index.tsx @@ -1,11 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { SourcesPage } from "@executor-js/react/pages/sources"; -import { openApiSourcePlugin } from "@executor-js/plugin-openapi/react"; -import { mcpSourcePlugin } from "@executor-js/plugin-mcp/react"; -import { graphqlSourcePlugin } from "@executor-js/plugin-graphql/react"; - -const sourcePlugins = [openApiSourcePlugin, mcpSourcePlugin, graphqlSourcePlugin]; export const Route = createFileRoute("/")({ - component: () => , + component: SourcesPage, }); diff --git a/apps/cloud/src/routes/secrets.tsx b/apps/cloud/src/routes/secrets.tsx index 40904354c..aff164652 100644 --- a/apps/cloud/src/routes/secrets.tsx +++ b/apps/cloud/src/routes/secrets.tsx @@ -4,7 +4,6 @@ import { SecretsPage } from "@executor-js/react/pages/secrets"; export const Route = createFileRoute("/secrets")({ component: () => ( { const { namespace } = Route.useParams(); - return ; + return ; }, }); diff --git a/apps/cloud/src/routes/sources.add.$pluginKey.tsx b/apps/cloud/src/routes/sources.add.$pluginKey.tsx index f6a849eaa..9d5043201 100644 --- a/apps/cloud/src/routes/sources.add.$pluginKey.tsx +++ b/apps/cloud/src/routes/sources.add.$pluginKey.tsx @@ -1,11 +1,6 @@ import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; -import { openApiSourcePlugin } from "@executor-js/plugin-openapi/react"; -import { mcpSourcePlugin } from "@executor-js/plugin-mcp/react"; -import { graphqlSourcePlugin } from "@executor-js/plugin-graphql/react"; - -const sourcePlugins = [openApiSourcePlugin, mcpSourcePlugin, graphqlSourcePlugin]; const SearchParams = Schema.toStandardSchemaV1( Schema.Struct({ @@ -26,7 +21,6 @@ export const Route = createFileRoute("/sources/add/$pluginKey")({ url={url} preset={preset} namespace={namespace} - sourcePlugins={sourcePlugins} /> ); }, diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index 2810ed5da..99e4dd75f 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -26,7 +26,12 @@ import { import { ExecutionEngineService, ExecutorService, + providePluginExtensions, } from "@executor-js/api/server"; +// Type-only imports — see `protected.ts` for the same pattern. +import type { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; +import type { McpExtensionService } from "@executor-js/plugin-mcp/api"; +import type { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { @@ -39,20 +44,14 @@ import { makePostgresAdapter, makePostgresBlobStore, } from "@executor-js/storage-postgres"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; -import { graphqlPlugin } from "@executor-js/plugin-graphql"; import { - workosVaultPlugin, WorkOSVaultClientError, type WorkOSVaultClient, type WorkOSVaultObject, type WorkOSVaultObjectMetadata, } from "@executor-js/plugin-workos-vault"; -import { OpenApiExtensionService } from "@executor-js/plugin-openapi/api"; -import { McpExtensionService } from "@executor-js/plugin-mcp/api"; -import { GraphqlExtensionService } from "@executor-js/plugin-graphql/api"; +import executorConfig from "../../../executor.config"; import { AuthContext } from "../../auth/middleware"; import { ProtectedCloudApi, @@ -174,6 +173,7 @@ export const makeFakeVaultClient = (): WorkOSVaultClient => { // --------------------------------------------------------------------------- const fakeVault = makeFakeVaultClient(); +const testPlugins = executorConfig.plugins({ workosVaultClient: fakeVault }); const createTestScopedExecutor = ( userId: string, @@ -182,12 +182,7 @@ const createTestScopedExecutor = ( ) => Effect.gen(function* () { const { db } = yield* DbService; - const plugins = [ - openApiPlugin(), - mcpPlugin({ dangerouslyAllowStdioMCP: false }), - graphqlPlugin(), - workosVaultPlugin({ client: fakeVault }), - ] as const; + const plugins = testPlugins; const schema = collectSchemas(plugins); const adapter = makePostgresAdapter({ db, schema }); const blobs = makePostgresBlobStore({ db }); @@ -234,6 +229,7 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ // for the same pattern. Effect.gen(function* () { const context = yield* Effect.context(); + const provideExecutorExtensions = providePluginExtensions(testPlugins); return (httpEffect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; @@ -265,9 +261,7 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ ), Effect.provideService(ExecutorService, executor), Effect.provideService(ExecutionEngineService, engine), - Effect.provideService(OpenApiExtensionService, executor.openapi), - Effect.provideService(McpExtensionService, executor.mcp), - Effect.provideService(GraphqlExtensionService, executor.graphql), + provideExecutorExtensions(executor), ); }).pipe(Effect.provideContext(context)); }), diff --git a/apps/cloud/src/services/executor.ts b/apps/cloud/src/services/executor.ts index eaf6848a0..33d9d8f54 100644 --- a/apps/cloud/src/services/executor.ts +++ b/apps/cloud/src/services/executor.ts @@ -20,36 +20,27 @@ import { makePostgresAdapter, makePostgresBlobStore, } from "@executor-js/storage-postgres"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; -import { graphqlPlugin } from "@executor-js/plugin-graphql"; -import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; import { env } from "cloudflare:workers"; +import executorConfig from "../../executor.config"; import { DbService } from "./db"; // --------------------------------------------------------------------------- -// Plugin list — one place, used for both the runtime and the CLI config -// (executor.config.ts). No stdio MCP in cloud; no keychain/file-secrets/ -// 1password/google-discovery. -// -// NOTE: the CLI config (executor.config.ts) imports these same plugins with -// stub credentials because it only reads `plugin.schema`. Here we pass -// real credentials from the env. +// Plugin list lives in `executor.config.ts` — that file is the single +// source of truth, also consumed by the schema-gen CLI and the test +// harness. Per-request runtime values (WorkOS credentials from the +// Worker env) are passed through the factory's `deps` parameter. // --------------------------------------------------------------------------- -const createOrgPlugins = () => - [ - openApiPlugin(), - mcpPlugin({ dangerouslyAllowStdioMCP: false }), - graphqlPlugin(), - workosVaultPlugin({ - credentials: { - apiKey: env.WORKOS_API_KEY, - clientId: env.WORKOS_CLIENT_ID, - }, - }), - ] as const; +export type CloudPlugins = ReturnType; + +const orgPlugins = (): CloudPlugins => + executorConfig.plugins({ + workosCredentials: { + apiKey: env.WORKOS_API_KEY, + clientId: env.WORKOS_CLIENT_ID, + }, + }); // --------------------------------------------------------------------------- // Create a fresh executor for a (user, org) pair (stateless, per-request). @@ -74,7 +65,7 @@ export const createScopedExecutor = ( Effect.gen(function* () { const { db } = yield* DbService; - const plugins = createOrgPlugins(); + const plugins = orgPlugins(); const schema = collectSchemas(plugins); const adapter = makePostgresAdapter({ db, schema }); const blobs = makePostgresBlobStore({ db }); diff --git a/apps/cloud/src/vite-env.d.ts b/apps/cloud/src/vite-env.d.ts index 11f02fe2a..4c4f44cb3 100644 --- a/apps/cloud/src/vite-env.d.ts +++ b/apps/cloud/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +declare module "virtual:executor/plugins-client" { + import type { ClientPluginSpec } from "@executor-js/sdk/client"; + export const plugins: readonly ClientPluginSpec[]; +} diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index d42602499..a8f4825d9 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -29,9 +29,6 @@ import { } from "@executor-js/react/components/dropdown-menu"; import { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { CommandPalette } from "@executor-js/react/components/command-palette"; -import { openApiSourcePlugin } from "@executor-js/plugin-openapi/react"; -import { mcpSourcePlugin } from "@executor-js/plugin-mcp/react"; -import { graphqlSourcePlugin } from "@executor-js/plugin-graphql/react"; import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { AUTH_PATHS } from "../auth/api"; import { organizationsAtom, switchOrganization, useAuth } from "./auth"; @@ -40,8 +37,6 @@ import { useCreateOrganizationForm, } from "./components/create-organization-form"; -const sourcePlugins = [openApiSourcePlugin, mcpSourcePlugin, graphqlSourcePlugin]; - // ── NavItem ────────────────────────────────────────────────────────────── function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { @@ -430,7 +425,7 @@ export function Shell() { return (
- + {/* Desktop sidebar */}