Unified · Split
| @@ -7,16 +7,16 @@ · 6 unmodified lines | ||
| 7 | 7 | * |
| 8 | 8 | * Flow: |
| 9 | 9 | * 1. Spin up a tiny local OpenAPI server (one operation, returns 42). |
| 10 | − * 2. Write a temp executor.jsonc that points at it as a source. | |
| 11 | − * 3. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 | |
| 10 | + * 2. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 | |
| 12 | 11 | * and parse the `EXECUTOR_READY:<port>` sentinel. |
| 13 | − * 4. Connect via MCP streamable HTTP, call the `execute` tool with code | |
| 14 | − * that invokes the OpenAPI tool, assert the answer round-trips as 42. | |
| 12 | + * 3. Connect via MCP streamable HTTP, call the `execute` tool with code | |
| 13 | + * that registers and invokes the OpenAPI tool, assert the answer | |
| 14 | + * round-trips as 42. | |
| 15 | 15 | * |
| 16 | 16 | * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any |
| 17 | 17 | * deviation so it can gate CI. |
| 18 | 18 | */ |
| 19 | −import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 19 | +import { mkdtemp, rm } from "node:fs/promises"; | |
| 20 | 20 | import { tmpdir } from "node:os"; |
| 21 | 21 | import { join, resolve } from "node:path"; |
| 22 | 22 | import { spawn, type Subprocess } from "bun"; |
| @@ -182,18 +182,6 @@ const main = async () => { · 159 unmodified lines | ||
| 182 | 182 | console.log(`[smoke-sidecar] scope: ${scopeDir}`); |
| 183 | 183 | console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); |
| 184 | 184 | |
| 185 | − const config = { | |
| 186 | − sources: [ | |
| 187 | − { | |
| 188 | − kind: "openapi", | |
| 189 | − spec: `${openapi.origin}/openapi.json`, | |
| 190 | − baseUrl: openapi.origin, | |
| 191 | − namespace: "petstore", | |
| 192 | − }, | |
| 193 | − ], | |
| 194 | − }; | |
| 195 | − await writeFile(join(scopeDir, "executor.jsonc"), JSON.stringify(config, null, 2)); | |
| 196 | − | |
| 197 | 185 | const proc = spawn({ |
| 198 | 186 | cmd: [BINARY], |
| 199 | 187 | env: { |
| @@ -241,9 +229,16 @@ const main = async () => { · 41 unmodified lines | ||
| 241 | 229 | if (!hasExecute) fail(`MCP tools/list missing "execute": ${JSON.stringify(tools.tools)}`); |
| 242 | 230 | |
| 243 | 231 | // Drive the running OpenAPI server through a multi-step orchestration |
| 244 | − // in one execute. Covers: array list response, path param dispatch, and | |
| 245 | − // object responses — all going out over real HTTP from inside QuickJS. | |
| 232 | + // in one execute. Covers: source registration, array list response, path | |
| 233 | + // param dispatch, and object responses — all going out over real HTTP from | |
| 234 | + // inside QuickJS. | |
| 246 | 235 | const code = ` |
| 236 | +await tools.executor.openapi.addSource({ | |
| 237 | + scope: ${JSON.stringify(scopeDir)}, | |
| 238 | + spec: ${JSON.stringify(`${openapi.origin}/openapi.json`)}, | |
| 239 | + baseUrl: ${JSON.stringify(openapi.origin)}, | |
| 240 | + namespace: "petstore", | |
| 241 | +}); | |
| 247 | 242 | const list = await tools.petstore.pets.listPets({}); |
| 248 | 243 | const fetched = await tools.petstore.pets.getPet({ petId: list[1].id }); |
| 249 | 244 | return { |
| @@ -28,9 +28,9 @@ import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/ · 27 unmodified lines | ||
| 28 | 28 | |
| 29 | 29 | // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every |
| 30 | 30 | // Electron-side consumer (electron-store, electron-log, window-state) lands |
| 31 | −// at a predictable spot. User-mutable executor state (executor.jsonc, | |
| 32 | −// data.db) is pinned separately to ~/.executor in main/sidecar.ts — that | |
| 33 | −// path matches the CLI's default. | |
| 31 | +// at a predictable spot. User-mutable executor state (data.db and the optional | |
| 32 | +// executor.jsonc plugin manifest) is pinned separately to ~/.executor in | |
| 33 | +// main/sidecar.ts — that path matches the CLI's default. | |
| 34 | 34 | app.setName("Executor"); |
| 35 | 35 | app.setPath("userData", join(app.getPath("appData"), "Executor")); |
| 36 | 36 | |
| @@ -74,13 +74,14 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 73 unmodified lines | ||
| 74 | 74 | ); |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | − // executor.jsonc and data.db go to ~/.executor — the same path the CLI's | |
| 78 | − // `executor web` uses. Desktop and CLI share state on the same machine so | |
| 79 | − // sources/secrets/policies set up in one show up in the other, and | |
| 80 | − // user-facing commands like `executor mcp --scope ~/.executor` stay | |
| 81 | − // copy-paste-friendly. Electron's userData (set in main/index.ts) is | |
| 82 | − // still used for electron-store, electron-log, and window-state — those | |
| 83 | − // stay app-scoped to avoid colliding with anything else under HOME. | |
| 77 | + // data.db and the optional executor.jsonc plugin manifest live under | |
| 78 | + // ~/.executor — the same path the CLI's `executor web` uses. Desktop and CLI | |
| 79 | + // share state on the same machine so sources/secrets/policies set up in one | |
| 80 | + // show up in the other, and user-facing commands like | |
| 81 | + // `executor mcp --scope ~/.executor` stay copy-paste-friendly. Electron's | |
| 82 | + // userData (set in main/index.ts) is still used for electron-store, | |
| 83 | + // electron-log, and window-state — those stay app-scoped to avoid colliding | |
| 84 | + // with anything else under HOME. | |
| 84 | 85 | const scopeDir = join(homedir(), ".executor"); |
| 85 | 86 | mkdirSync(scopeDir, { recursive: true }); |
| 86 | 87 | |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | −import type { ConfigFileSink } from "@executor-js/config"; | |
| 3 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 4 | 3 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | 4 | import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api"; |
| @@ -14,24 +13,19 @@ import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/serv · 8 unmodified lines | ||
| 14 | 13 | // |
| 15 | 14 | // Consumed by: |
| 16 | 15 | // - the schema-gen CLI (reads `plugin.schema` only; calls `plugins({})`) |
| 17 | −// - the host runtime (calls `plugins({ configFile })` with a real sink) | |
| 16 | +// - the host runtime | |
| 18 | 17 | // |
| 19 | −// `TDeps` is inferred from the factory parameter annotation directly. | |
| 20 | 18 | // First-party and third-party plugins use the same import-and-call flow. |
| 21 | 19 | // --------------------------------------------------------------------------- |
| 22 | 20 | |
| 23 | −interface LocalPluginDeps { | |
| 24 | − readonly configFile?: ConfigFileSink; | |
| 25 | −} | |
| 26 | − | |
| 27 | 21 | export default defineExecutorConfig({ |
| 28 | 22 | dialect: "sqlite", |
| 29 | − plugins: ({ configFile }: LocalPluginDeps = {}) => | |
| 23 | + plugins: () => | |
| 30 | 24 | [ |
| 31 | − openApiHttpPlugin({ configFile }), | |
| 32 | − mcpHttpPlugin({ dangerouslyAllowStdioMCP: true, configFile }), | |
| 25 | + openApiHttpPlugin(), | |
| 26 | + mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), | |
| 33 | 27 | googleDiscoveryHttpPlugin(), |
| 34 | − graphqlHttpPlugin({ configFile }), | |
| 28 | + graphqlHttpPlugin(), | |
| 35 | 29 | keychainPlugin(), |
| 36 | 30 | fileSecretsPlugin(), |
| 37 | 31 | onepasswordHttpPlugin(), |
| @@ -1,225 +0,0 @@ | ||
| 1 | −// Boot-time replay of MCP auth from `executor.jsonc`. The plugin already | |
| 2 | −// resolves `secret-public-ref:` strings at connect time via | |
| 3 | −// `ctx.secrets.get` — the regression class here is auth being silently | |
| 4 | −// dropped at the config boundary. `mcp.addSource` persists the source | |
| 5 | −// row even when the remote is unreachable, so unreachable endpoints are | |
| 6 | −// enough to assert on the stored auth shape without running an MCP | |
| 7 | −// server. The new credential-binding flow does require referenced | |
| 8 | −// secrets/connections to exist at the target scope, hence the seeds | |
| 9 | −// before each test. | |
| 10 | − | |
| 11 | −import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; | |
| 12 | −import { Effect } from "effect"; | |
| 13 | −import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 14 | −import { tmpdir } from "node:os"; | |
| 15 | −import { join } from "node:path"; | |
| 16 | − | |
| 17 | −import { SECRET_REF_PREFIX, type ExecutorFileConfig } from "@executor-js/config"; | |
| 18 | −import { | |
| 19 | − ConnectionId, | |
| 20 | − CreateConnectionInput, | |
| 21 | − OAUTH2_PROVIDER_KEY, | |
| 22 | − ScopeId, | |
| 23 | − SecretId, | |
| 24 | − TokenMaterial, | |
| 25 | − createExecutor, | |
| 26 | − definePlugin, | |
| 27 | − makeTestConfig, | |
| 28 | − type SecretProvider, | |
| 29 | −} from "@executor-js/sdk"; | |
| 30 | −import { mcpPlugin } from "@executor-js/plugin-mcp"; | |
| 31 | − | |
| 32 | −import { syncFromConfig } from "./config-sync"; | |
| 33 | −import type { LocalExecutor } from "./executor"; | |
| 34 | − | |
| 35 | −const UNREACHABLE = "http://127.0.0.1:1/mcp"; | |
| 36 | −const TEST_SCOPE = ScopeId.make("test-scope"); | |
| 37 | − | |
| 38 | −const makeMemorySecretsPlugin = () => { | |
| 39 | − const store = new Map<string, string>(); | |
| 40 | − const provider: SecretProvider = { | |
| 41 | − key: "memory", | |
| 42 | − writable: true, | |
| 43 | − get: (id, scope) => Effect.sync(() => store.get(`${scope} ${id}`) ?? null), | |
| 44 | − set: (id, value, scope) => | |
| 45 | − Effect.sync(() => { | |
| 46 | − store.set(`${scope} ${id}`, value); | |
| 47 | − }), | |
| 48 | − delete: (id, scope) => Effect.sync(() => store.delete(`${scope} ${id}`)), | |
| 49 | − list: () => | |
| 50 | − Effect.sync(() => | |
| 51 | − Array.from(store.keys()).map((k) => { | |
| 52 | − const id = k.split(" ", 2)[1] ?? k; | |
| 53 | − return { id, name: id }; | |
| 54 | − }), | |
| 55 | − ), | |
| 56 | − }; | |
| 57 | − return definePlugin(() => ({ | |
| 58 | − id: "memory-secrets" as const, | |
| 59 | − storage: () => ({}), | |
| 60 | − secretProviders: [provider], | |
| 61 | − })); | |
| 62 | −}; | |
| 63 | − | |
| 64 | −let workDir: string; | |
| 65 | − | |
| 66 | −beforeEach(() => { | |
| 67 | − workDir = mkdtempSync(join(tmpdir(), "exec-config-sync-")); | |
| 68 | −}); | |
| 69 | − | |
| 70 | −afterEach(() => { | |
| 71 | − rmSync(workDir, { recursive: true, force: true }); | |
| 72 | −}); | |
| 73 | − | |
| 74 | −const writeConfig = (config: ExecutorFileConfig): string => { | |
| 75 | − const path = join(workDir, "executor.jsonc"); | |
| 76 | − writeFileSync(path, JSON.stringify(config, null, 2)); | |
| 77 | − return path; | |
| 78 | −}; | |
| 79 | − | |
| 80 | −const makeExecutor = () => | |
| 81 | − createExecutor(makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const })); | |
| 82 | − | |
| 83 | −describe("syncFromConfig — MCP auth replay", () => { | |
| 84 | − it.effect("strips secret-public-ref prefix from header auth", () => | |
| 85 | − Effect.gen(function* () { | |
| 86 | − const configPath = writeConfig({ | |
| 87 | − sources: [ | |
| 88 | − { | |
| 89 | − kind: "mcp", | |
| 90 | − transport: "remote", | |
| 91 | − name: "PostHog", | |
| 92 | − endpoint: UNREACHABLE, | |
| 93 | − namespace: "posthog", | |
| 94 | − auth: { | |
| 95 | − kind: "header", | |
| 96 | − headerName: "Authorization", | |
| 97 | − secret: `${SECRET_REF_PREFIX}posthog-api-key`, | |
| 98 | − prefix: "Bearer ", | |
| 99 | − }, | |
| 100 | − }, | |
| 101 | − ], | |
| 102 | − }); | |
| 103 | − const executor = yield* makeExecutor(); | |
| 104 | − yield* executor.secrets.set({ | |
| 105 | − id: SecretId.make("posthog-api-key"), | |
| 106 | − scope: TEST_SCOPE, | |
| 107 | − name: "PostHog API Key", | |
| 108 | − value: "phx_test_token", | |
| 109 | − provider: "memory", | |
| 110 | − }); | |
| 111 | − | |
| 112 | − yield* syncFromConfig({ | |
| 113 | − // The test executor uses a narrower plugin tuple than LocalExecutor (no | |
| 114 | − // openapi/graphql), but Match in addSourceFromConfig only dispatches to | |
| 115 | − // the mcp branch for these fixtures, so the missing methods are never | |
| 116 | − // touched at runtime. | |
| 117 | − // oxlint-disable-next-line executor/no-double-cast | |
| 118 | − executor: executor as unknown as LocalExecutor, | |
| 119 | − configPath, | |
| 120 | − targetScope: TEST_SCOPE, | |
| 121 | − }); | |
| 122 | − | |
| 123 | − const stored = yield* executor.mcp.getSource("posthog", TEST_SCOPE); | |
| 124 | − expect(stored).not.toBeNull(); | |
| 125 | − expect(stored!.config).toMatchObject({ | |
| 126 | − transport: "remote", | |
| 127 | − endpoint: UNREACHABLE, | |
| 128 | − auth: { | |
| 129 | − kind: "header", | |
| 130 | − headerName: "Authorization", | |
| 131 | − secretSlot: "auth:header", | |
| 132 | − prefix: "Bearer ", | |
| 133 | − }, | |
| 134 | − }); | |
| 135 | − }), | |
| 136 | − ); | |
| 137 | − | |
| 138 | − it.effect("passes oauth2 auth through unchanged", () => | |
| 139 | − Effect.gen(function* () { | |
| 140 | − const configPath = writeConfig({ | |
| 141 | − sources: [ | |
| 142 | − { | |
| 143 | − kind: "mcp", | |
| 144 | − transport: "remote", | |
| 145 | − name: "Linear", | |
| 146 | − endpoint: UNREACHABLE, | |
| 147 | − namespace: "linear", | |
| 148 | − auth: { kind: "oauth2", connectionId: "mcp-oauth2-linear" }, | |
| 149 | − }, | |
| 150 | − ], | |
| 151 | − }); | |
| 152 | − const executor = yield* makeExecutor(); | |
| 153 | − const connectionId = ConnectionId.make("mcp-oauth2-linear"); | |
| 154 | − yield* executor.connections.create( | |
| 155 | − CreateConnectionInput.make({ | |
| 156 | − id: connectionId, | |
| 157 | − scope: TEST_SCOPE, | |
| 158 | − provider: OAUTH2_PROVIDER_KEY, | |
| 159 | − identityLabel: "user@example.com", | |
| 160 | − accessToken: TokenMaterial.make({ | |
| 161 | − secretId: SecretId.make(`${connectionId}.access_token`), | |
| 162 | − name: "MCP Access Token", | |
| 163 | − value: "access-token-value", | |
| 164 | − }), | |
| 165 | − refreshToken: null, | |
| 166 | − expiresAt: null, | |
| 167 | − oauthScope: null, | |
| 168 | − providerState: { | |
| 169 | − endpoint: UNREACHABLE, | |
| 170 | − tokenType: "Bearer", | |
| 171 | − clientInformation: { client_id: "fake" }, | |
| 172 | − authorizationServerUrl: null, | |
| 173 | − authorizationServerMetadata: null, | |
| 174 | − resourceMetadataUrl: null, | |
| 175 | − resourceMetadata: null, | |
| 176 | − }, | |
| 177 | − }), | |
| 178 | − ); | |
| 179 | − | |
| 180 | − yield* syncFromConfig({ | |
| 181 | − // oxlint-disable-next-line executor/no-double-cast | |
| 182 | − executor: executor as unknown as LocalExecutor, | |
| 183 | − configPath, | |
| 184 | − targetScope: TEST_SCOPE, | |
| 185 | − }); | |
| 186 | − | |
| 187 | − const stored = yield* executor.mcp.getSource("linear", TEST_SCOPE); | |
| 188 | − expect(stored!.config).toMatchObject({ | |
| 189 | − transport: "remote", | |
| 190 | − auth: { kind: "oauth2", connectionSlot: "auth:oauth2:connection" }, | |
| 191 | − }); | |
| 192 | − }), | |
| 193 | − ); | |
| 194 | − | |
| 195 | − it.effect("preserves kind:none auth on replay", () => | |
| 196 | − Effect.gen(function* () { | |
| 197 | − const configPath = writeConfig({ | |
| 198 | − sources: [ | |
| 199 | − { | |
| 200 | − kind: "mcp", | |
| 201 | − transport: "remote", | |
| 202 | − name: "DeepWiki", | |
| 203 | − endpoint: UNREACHABLE, | |
| 204 | − namespace: "devin", | |
| 205 | − auth: { kind: "none" }, | |
| 206 | − }, | |
| 207 | − ], | |
| 208 | − }); | |
| 209 | − const executor = yield* makeExecutor(); | |
| 210 | − | |
| 211 | − yield* syncFromConfig({ | |
| 212 | − // oxlint-disable-next-line executor/no-double-cast | |
| 213 | − executor: executor as unknown as LocalExecutor, | |
| 214 | − configPath, | |
| 215 | − targetScope: TEST_SCOPE, | |
| 216 | − }); | |
| 217 | − | |
| 218 | − const stored = yield* executor.mcp.getSource("devin", TEST_SCOPE); | |
| 219 | − expect(stored!.config).toMatchObject({ | |
| 220 | − transport: "remote", | |
| 221 | − auth: { kind: "none" }, | |
| 222 | − }); | |
| 223 | − }), | |
| 224 | − ); | |
| 225 | −}); | |
| @@ -1,51 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | − | |
| 3 | −import { translateMcpAuth } from "./config-sync"; | |
| 4 | − | |
| 5 | −describe("translateMcpAuth", () => { | |
| 6 | − it("returns undefined when no auth is configured", () => { | |
| 7 | − expect(translateMcpAuth(undefined)).toBeUndefined(); | |
| 8 | − }); | |
| 9 | − | |
| 10 | − it("preserves the kind=none variant", () => { | |
| 11 | − expect(translateMcpAuth({ kind: "none" })).toEqual({ kind: "none" }); | |
| 12 | − }); | |
| 13 | − | |
| 14 | − it("preserves oauth2 connectionId so the source keeps its OAuth link across boots", () => { | |
| 15 | − expect(translateMcpAuth({ kind: "oauth2", connectionId: "mcp-oauth2-linear" })).toEqual({ | |
| 16 | − kind: "oauth2", | |
| 17 | − connectionId: "mcp-oauth2-linear", | |
| 18 | − }); | |
| 19 | − }); | |
| 20 | − | |
| 21 | − it("strips the secret-public-ref prefix from header auth", () => { | |
| 22 | − expect( | |
| 23 | − translateMcpAuth({ | |
| 24 | − kind: "header", | |
| 25 | − headerName: "Authorization", | |
| 26 | − secret: "secret-public-ref:my-token", | |
| 27 | − prefix: "Bearer ", | |
| 28 | − }), | |
| 29 | − ).toEqual({ | |
| 30 | − kind: "header", | |
| 31 | − headerName: "Authorization", | |
| 32 | − secretId: "my-token", | |
| 33 | − prefix: "Bearer ", | |
| 34 | − }); | |
| 35 | − }); | |
| 36 | − | |
| 37 | − it("passes through a raw secret id when no prefix is present", () => { | |
| 38 | − expect( | |
| 39 | − translateMcpAuth({ | |
| 40 | − kind: "header", | |
| 41 | − headerName: "X-Api-Key", | |
| 42 | − secret: "raw-id", | |
| 43 | − }), | |
| 44 | − ).toEqual({ | |
| 45 | − kind: "header", | |
| 46 | − headerName: "X-Api-Key", | |
| 47 | − secretId: "raw-id", | |
| 48 | − prefix: undefined, | |
| 49 | − }); | |
| 50 | − }); | |
| 51 | −}); | |
| @@ -1,213 +0,0 @@ | ||
| 1 | −// --------------------------------------------------------------------------- | |
| 2 | −// Boot-time sync — replays sources from executor.jsonc into the executor. | |
| 3 | −// Plugins upsert so a re-sync on an already-populated DB is a no-op. | |
| 4 | −// Write-back (DB → file) is handled by the ConfigFileSink passed to each | |
| 5 | −// plugin in executor.ts. | |
| 6 | −// --------------------------------------------------------------------------- | |
| 7 | − | |
| 8 | −import { Cause, Effect, Match } from "effect"; | |
| 9 | −import { join } from "node:path"; | |
| 10 | −import * as fs from "node:fs"; | |
| 11 | −import * as jsonc from "jsonc-parser"; | |
| 12 | − | |
| 13 | −import type { | |
| 14 | − SourceConfig, | |
| 15 | − ExecutorFileConfig, | |
| 16 | − ConfigHeaderValue, | |
| 17 | − McpAuthConfig, | |
| 18 | −} from "@executor-js/config"; | |
| 19 | −import { SECRET_REF_PREFIX } from "@executor-js/config"; | |
| 20 | −import type { ScopeId } from "@executor-js/sdk"; | |
| 21 | −import type { McpConnectionAuthInput } from "@executor-js/plugin-mcp"; | |
| 22 | − | |
| 23 | −import type { LocalExecutor } from "./executor"; | |
| 24 | − | |
| 25 | −// --------------------------------------------------------------------------- | |
| 26 | −// Header translation: config format → plugin format | |
| 27 | −// --------------------------------------------------------------------------- | |
| 28 | − | |
| 29 | −const translateHeader = ( | |
| 30 | − value: ConfigHeaderValue, | |
| 31 | −): string | { secretId: string; prefix?: string } => { | |
| 32 | − if (typeof value === "string") { | |
| 33 | − if (value.startsWith(SECRET_REF_PREFIX)) { | |
| 34 | − return { secretId: value.slice(SECRET_REF_PREFIX.length) }; | |
| 35 | − } | |
| 36 | − return value; | |
| 37 | − } | |
| 38 | − // Object form: { value, prefix? } | |
| 39 | − if (typeof value.value === "string" && value.value.startsWith(SECRET_REF_PREFIX)) { | |
| 40 | − return { | |
| 41 | − secretId: value.value.slice(SECRET_REF_PREFIX.length), | |
| 42 | − prefix: value.prefix, | |
| 43 | − }; | |
| 44 | − } | |
| 45 | − return value.value; | |
| 46 | −}; | |
| 47 | − | |
| 48 | −const translateHeaders = ( | |
| 49 | − headers: Record<string, ConfigHeaderValue> | undefined, | |
| 50 | −): Record<string, string | { secretId: string; prefix?: string }> | undefined => { | |
| 51 | − if (!headers) return undefined; | |
| 52 | − const out: Record<string, string | { secretId: string; prefix?: string }> = {}; | |
| 53 | − for (const [k, v] of Object.entries(headers)) { | |
| 54 | − out[k] = translateHeader(v); | |
| 55 | − } | |
| 56 | − return out; | |
| 57 | −}; | |
| 58 | − | |
| 59 | −// MCP auth translation: file format → plugin format. The header variant | |
| 60 | −// stores credentials as `secret-public-ref:<id>`; the plugin SDK takes the | |
| 61 | −// raw secret id. The oauth2 variant is structurally identical. | |
| 62 | −export const translateMcpAuth = ( | |
| 63 | − auth: McpAuthConfig | undefined, | |
| 64 | −): McpConnectionAuthInput | undefined => { | |
| 65 | − if (!auth) return undefined; | |
| 66 | − if (auth.kind === "none") return { kind: "none" }; | |
| 67 | − if (auth.kind === "header") { | |
| 68 | − const secretId = auth.secret.startsWith(SECRET_REF_PREFIX) | |
| 69 | − ? auth.secret.slice(SECRET_REF_PREFIX.length) | |
| 70 | − : auth.secret; | |
| 71 | − return { | |
| 72 | − kind: "header", | |
| 73 | − headerName: auth.headerName, | |
| 74 | − secretId, | |
| 75 | − prefix: auth.prefix, | |
| 76 | − }; | |
| 77 | − } | |
| 78 | − return { kind: "oauth2", connectionId: auth.connectionId }; | |
| 79 | −}; | |
| 80 | − | |
| 81 | −// --------------------------------------------------------------------------- | |
| 82 | −// Config path resolution | |
| 83 | −// --------------------------------------------------------------------------- | |
| 84 | − | |
| 85 | −export const resolveConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); | |
| 86 | − | |
| 87 | −// --------------------------------------------------------------------------- | |
| 88 | −// Load config (sync, no Effect deps — runs at startup) | |
| 89 | −// --------------------------------------------------------------------------- | |
| 90 | − | |
| 91 | −const loadConfigSync = (path: string): ExecutorFileConfig | null => { | |
| 92 | − if (!fs.existsSync(path)) return null; | |
| 93 | − const raw = fs.readFileSync(path, "utf-8"); | |
| 94 | − const errors: jsonc.ParseError[] = []; | |
| 95 | − const parsed = jsonc.parse(raw, errors); | |
| 96 | − if (errors.length > 0) { | |
| 97 | − console.warn(`[config-sync] Failed to parse ${path}:`, errors); | |
| 98 | − return null; | |
| 99 | − } | |
| 100 | − return parsed as ExecutorFileConfig; | |
| 101 | −}; | |
| 102 | − | |
| 103 | −// --------------------------------------------------------------------------- | |
| 104 | −// Sync from config → DB | |
| 105 | −// --------------------------------------------------------------------------- | |
| 106 | − | |
| 107 | −const addSourceFromConfig = ( | |
| 108 | − executor: LocalExecutor, | |
| 109 | − targetScope: ScopeId, | |
| 110 | − source: SourceConfig, | |
| 111 | −): Effect.Effect<void, unknown> => { | |
| 112 | − return Match.value(source).pipe( | |
| 113 | − Match.when({ kind: "openapi" }, (s) => | |
| 114 | − executor.openapi | |
| 115 | − .addSpec({ | |
| 116 | − spec: s.spec, | |
| 117 | − scope: targetScope, | |
| 118 | − baseUrl: s.baseUrl, | |
| 119 | − namespace: s.namespace, | |
| 120 | − headers: translateHeaders(s.headers), | |
| 121 | − credentialTargetScope: targetScope, | |
| 122 | − }) | |
| 123 | − .pipe(Effect.asVoid), | |
| 124 | − ), | |
| 125 | − Match.when({ kind: "graphql" }, (s) => | |
| 126 | − executor.graphql | |
| 127 | − .addSource({ | |
| 128 | − endpoint: s.endpoint, | |
| 129 | − scope: targetScope, | |
| 130 | − namespace: s.namespace, | |
| 131 | − headers: translateHeaders(s.headers) as Record<string, string> | undefined, | |
| 132 | − credentialTargetScope: targetScope, | |
| 133 | − }) | |
| 134 | − .pipe(Effect.asVoid), | |
| 135 | − ), | |
| 136 | − Match.when({ kind: "mcp" }, (s) => { | |
| 137 | − if (s.transport === "stdio") { | |
| 138 | − return executor.mcp | |
| 139 | − .addSource({ | |
| 140 | − transport: "stdio", | |
| 141 | − scope: targetScope, | |
| 142 | − name: s.name, | |
| 143 | − command: s.command, | |
| 144 | − args: s.args ? [...s.args] : undefined, | |
| 145 | − env: s.env, | |
| 146 | − cwd: s.cwd, | |
| 147 | − namespace: s.namespace, | |
| 148 | − }) | |
| 149 | − .pipe(Effect.asVoid); | |
| 150 | − } | |
| 151 | − return executor.mcp | |
| 152 | − .addSource({ | |
| 153 | − transport: "remote", | |
| 154 | − scope: targetScope, | |
| 155 | − name: s.name, | |
| 156 | − endpoint: s.endpoint, | |
| 157 | − remoteTransport: s.remoteTransport, | |
| 158 | − queryParams: s.queryParams, | |
| 159 | − headers: s.headers, | |
| 160 | − namespace: s.namespace, | |
| 161 | − auth: translateMcpAuth(s.auth), | |
| 162 | − credentialTargetScope: targetScope, | |
| 163 | − }) | |
| 164 | − .pipe(Effect.asVoid); | |
| 165 | − }), | |
| 166 | − Match.exhaustive, | |
| 167 | − ); | |
| 168 | −}; | |
| 169 | − | |
| 170 | −/** | |
| 171 | − * Read executor.jsonc and replay all sources into the executor. | |
| 172 | − * Each source is added independently — if one fails, the rest still load. | |
| 173 | − */ | |
| 174 | −export const syncFromConfig = (input: { | |
| 175 | − readonly executor: LocalExecutor; | |
| 176 | − readonly configPath: string; | |
| 177 | − readonly targetScope: ScopeId; | |
| 178 | −}): Effect.Effect<void> => | |
| 179 | − Effect.gen(function* () { | |
| 180 | − const { executor, configPath, targetScope } = input; | |
| 181 | − const config = loadConfigSync(configPath); | |
| 182 | − if (!config?.sources?.length) { | |
| 183 | − console.log(`[config-sync] ${configPath} missing or empty, skipping`); | |
| 184 | − return; | |
| 185 | − } | |
| 186 | − | |
| 187 | − console.log(`[config-sync] syncing ${config.sources.length} source(s) from ${configPath}`); | |
| 188 | − | |
| 189 | − const results = yield* Effect.forEach( | |
| 190 | − config.sources, | |
| 191 | − (source) => | |
| 192 | − addSourceFromConfig(executor, targetScope, source).pipe( | |
| 193 | − Effect.map(() => true as const), | |
| 194 | − Effect.catchCause((cause) => { | |
| 195 | − const ns = | |
| 196 | − "namespace" in source ? source.namespace : "name" in source ? source.name : "unknown"; | |
| 197 | − const squashed = Cause.squash(cause); | |
| 198 | − const message = | |
| 199 | − squashed && typeof squashed === "object" && "message" in squashed | |
| 200 | − ? String((squashed as { message: unknown }).message) | |
| 201 | − : Cause.pretty(cause); | |
| 202 | − console.warn(`[config-sync] Failed to load source "${ns}": ${message}`); | |
| 203 | − return Effect.succeed(false as const); | |
| 204 | − }), | |
| 205 | − ), | |
| 206 | − // Serial — bun:sqlite serializes transactions on a single connection, | |
| 207 | − // so concurrent addSpec calls race on BEGIN. | |
| 208 | − { concurrency: 1 }, | |
| 209 | − ); | |
| 210 | − | |
| 211 | − const ok = results.filter(Boolean).length; | |
| 212 | − console.log(`[config-sync] ${ok}/${results.length} source(s) synced`); | |
| 213 | − }); | |
| @@ -17,11 +17,9 @@ import { · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk"; |
| 19 | 19 | import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file"; |
| 20 | −import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; | |
| 21 | −import { loadPluginsFromJsonc, makeFileConfigSink } from "@executor-js/config"; | |
| 20 | +import { loadPluginsFromJsonc } from "@executor-js/config"; | |
| 22 | 21 | import * as executorSchema from "./executor-schema"; |
| 23 | 22 | |
| 24 | −import { syncFromConfig, resolveConfigPath } from "./config-sync"; | |
| 25 | 23 | import executorConfig from "../../executor.config"; |
| 26 | 24 | |
| 27 | 25 | // In dev mode the drizzle folder sits next to the source tree. In a compiled |
| @@ -208,6 +206,8 @@ const migrationHistoryMismatchMessage = (dataDir: string): string => · 180 unmodified lines | ||
| 208 | 206 | "Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.", |
| 209 | 207 | ].join("\n"); |
| 210 | 208 | |
| 209 | +const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); | |
| 210 | + | |
| 211 | 211 | export const checkDrizzleMigrationCompatibility = (input: { |
| 212 | 212 | readonly sqlite: Database; |
| 213 | 213 | readonly dbPath: string; |
| @@ -274,20 +274,13 @@ const createLocalExecutorLayer = () => { · 60 unmodified lines | ||
| 274 | 274 | if (legacySecrets.length > 0) { |
| 275 | 275 | importLegacySecrets(sqlite, scopeId, legacySecrets); |
| 276 | 276 | } |
| 277 | − const configPath = resolveConfigPath(cwd); | |
| 278 | − const configFile = makeFileConfigSink({ | |
| 279 | − path: configPath, | |
| 280 | − fsLayer: NodeFileSystem.layer, | |
| 281 | − }); | |
| 282 | − | |
| 283 | − const staticPlugins = executorConfig.plugins({ configFile }); | |
| 277 | + const configPath = resolvePluginConfigPath(cwd); | |
| 278 | + const staticPlugins = executorConfig.plugins(); | |
| 284 | 279 | const dynamicPlugins = |
| 285 | − (yield* Effect.promise(() => | |
| 286 | − loadPluginsFromJsonc({ path: configPath, deps: { configFile } }), | |
| 287 | − )) ?? []; | |
| 280 | + (yield* Effect.promise(() => loadPluginsFromJsonc({ path: configPath }))) ?? []; | |
| 288 | 281 | // Static config wins on conflict — mirrors @executor-js/vite-plugin's |
| 289 | − // ordering. Without this, a package listed in both surfaces would | |
| 290 | − // boot twice (double routes, double in-memory storage). | |
| 282 | + // ordering. Without this, a package listed in both surfaces would boot | |
| 283 | + // twice (double routes, double in-memory storage). | |
| 291 | 284 | const staticPackageNames = new Set( |
| 292 | 285 | staticPlugins.map((p) => p.packageName).filter((n): n is string => !!n), |
| 293 | 286 | ); |
| @@ -322,12 +315,6 @@ const createLocalExecutorLayer = () => { · 28 unmodified lines | ||
| 322 | 315 | oauthEndpointUrlPolicy: { allowHttp: true }, |
| 323 | 316 | }); |
| 324 | 317 | |
| 325 | − // Sync sources from executor.jsonc (idempotent — plugins upsert). | |
| 326 | − // Runs after plugins are wired so sources added here round-trip | |
| 327 | − // back through configFile — harmless because the file already | |
| 328 | − // contains them. | |
| 329 | − yield* syncFromConfig({ executor, configPath, targetScope: scope.id }); | |
| 330 | − | |
| 331 | 318 | return { executor, plugins }; |
| 332 | 319 | }), |
| 333 | 320 | ); |
| @@ -254,8 +254,8 @@ function StdioReadOnly(props: { · 253 unmodified lines | ||
| 254 | 254 | <div> |
| 255 | 255 | <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> |
| 256 | 256 | <p className="mt-1 text-sm text-muted-foreground"> |
| 257 | − Stdio MCP sources cannot be edited in the UI. Modify the executor.jsonc config file | |
| 258 | − directly. | |
| 257 | + Stdio MCP sources cannot be edited in the UI. Remove and recreate the source with the | |
| 258 | + updated command. | |
| 259 | 259 | </p> |
| 260 | 260 | </div> |
| 261 | 261 | |