Unified · Split
| @@ -7,16 +7,16 @@ · 6 unmodified lines | |||
| 7 | * | 7 | * |
| 8 | * Flow: | 8 | * Flow: |
| 9 | * 1. Spin up a tiny local OpenAPI server (one operation, returns 42). | 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. | 10 | * 2. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 |
| 11 | * 3. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 | ||
| 12 | * and parse the `EXECUTOR_READY:<port>` sentinel. | 11 | * and parse the `EXECUTOR_READY:<port>` sentinel. |
| 13 | * 4. Connect via MCP streamable HTTP, call the `execute` tool with code | 12 | * 3. Connect via MCP streamable HTTP, call the `execute` tool with code |
| 14 | * that invokes the OpenAPI tool, assert the answer round-trips as 42. | 13 | * that registers and invokes the OpenAPI tool, assert the answer |
| 14 | * round-trips as 42. | ||
| 15 | * | 15 | * |
| 16 | * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any | 16 | * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any |
| 17 | * deviation so it can gate CI. | 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 | import { tmpdir } from "node:os"; | 20 | import { tmpdir } from "node:os"; |
| 21 | import { join, resolve } from "node:path"; | 21 | import { join, resolve } from "node:path"; |
| 22 | import { spawn, type Subprocess } from "bun"; | 22 | import { spawn, type Subprocess } from "bun"; |
| @@ -182,18 +182,6 @@ const main = async () => { · 159 unmodified lines | |||
| 182 | console.log(`[smoke-sidecar] scope: ${scopeDir}`); | 182 | console.log(`[smoke-sidecar] scope: ${scopeDir}`); |
| 183 | console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); | 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 | const proc = spawn({ | 185 | const proc = spawn({ |
| 198 | cmd: [BINARY], | 186 | cmd: [BINARY], |
| 199 | env: { | 187 | env: { |
| @@ -241,9 +229,16 @@ const main = async () => { · 41 unmodified lines | |||
| 241 | if (!hasExecute) fail(`MCP tools/list missing "execute": ${JSON.stringify(tools.tools)}`); | 229 | if (!hasExecute) fail(`MCP tools/list missing "execute": ${JSON.stringify(tools.tools)}`); |
| 242 | 230 | ||
| 243 | // Drive the running OpenAPI server through a multi-step orchestration | 231 | // Drive the running OpenAPI server through a multi-step orchestration |
| 244 | // in one execute. Covers: array list response, path param dispatch, and | 232 | // in one execute. Covers: source registration, array list response, path |
| 245 | // object responses — all going out over real HTTP from inside QuickJS. | 233 | // param dispatch, and object responses — all going out over real HTTP from |
| 234 | // inside QuickJS. | ||
| 246 | const code = ` | 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 | const list = await tools.petstore.pets.listPets({}); | 242 | const list = await tools.petstore.pets.listPets({}); |
| 248 | const fetched = await tools.petstore.pets.getPet({ petId: list[1].id }); | 243 | const fetched = await tools.petstore.pets.getPet({ petId: list[1].id }); |
| 249 | return { | 244 | return { |
| @@ -28,9 +28,9 @@ import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/ · 27 unmodified lines | |||
| 28 | 28 | ||
| 29 | // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every | 29 | // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every |
| 30 | // Electron-side consumer (electron-store, electron-log, window-state) lands | 30 | // Electron-side consumer (electron-store, electron-log, window-state) lands |
| 31 | // at a predictable spot. User-mutable executor state (executor.jsonc, | 31 | // at a predictable spot. User-mutable executor state (data.db and the optional |
| 32 | // data.db) is pinned separately to ~/.executor in main/sidecar.ts — that | 32 | // executor.jsonc plugin manifest) is pinned separately to ~/.executor in |
| 33 | // path matches the CLI's default. | 33 | // main/sidecar.ts — that path matches the CLI's default. |
| 34 | app.setName("Executor"); | 34 | app.setName("Executor"); |
| 35 | app.setPath("userData", join(app.getPath("appData"), "Executor")); | 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 | 77 | // data.db and the optional executor.jsonc plugin manifest live under |
| 78 | // `executor web` uses. Desktop and CLI share state on the same machine so | 78 | // ~/.executor — the same path the CLI's `executor web` uses. Desktop and CLI |
| 79 | // sources/secrets/policies set up in one show up in the other, and | 79 | // share state on the same machine so sources/secrets/policies set up in one |
| 80 | // user-facing commands like `executor mcp --scope ~/.executor` stay | 80 | // show up in the other, and user-facing commands like |
| 81 | // copy-paste-friendly. Electron's userData (set in main/index.ts) is | 81 | // `executor mcp --scope ~/.executor` stay copy-paste-friendly. Electron's |
| 82 | // still used for electron-store, electron-log, and window-state — those | 82 | // userData (set in main/index.ts) is still used for electron-store, |
| 83 | // stay app-scoped to avoid colliding with anything else under HOME. | 83 | // electron-log, and window-state — those stay app-scoped to avoid colliding |
| 84 | // with anything else under HOME. | ||
| 84 | const scopeDir = join(homedir(), ".executor"); | 85 | const scopeDir = join(homedir(), ".executor"); |
| 85 | mkdirSync(scopeDir, { recursive: true }); | 86 | mkdirSync(scopeDir, { recursive: true }); |
| 86 | 87 | ||
| @@ -1,5 +1,4 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import type { ConfigFileSink } from "@executor-js/config"; | ||
| 3 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 3 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api"; | 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 | // Consumed by: | 14 | // Consumed by: |
| 16 | // - the schema-gen CLI (reads `plugin.schema` only; calls `plugins({})`) | 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 | // First-party and third-party plugins use the same import-and-call flow. | 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 | export default defineExecutorConfig({ | 21 | export default defineExecutorConfig({ |
| 28 | dialect: "sqlite", | 22 | dialect: "sqlite", |
| 29 | plugins: ({ configFile }: LocalPluginDeps = {}) => | 23 | plugins: () => |
| 30 | [ | 24 | [ |
| 31 | openApiHttpPlugin({ configFile }), | 25 | openApiHttpPlugin(), |
| 32 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true, configFile }), | 26 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 33 | googleDiscoveryHttpPlugin(), | 27 | googleDiscoveryHttpPlugin(), |
| 34 | graphqlHttpPlugin({ configFile }), | 28 | graphqlHttpPlugin(), |
| 35 | keychainPlugin(), | 29 | keychainPlugin(), |
| 36 | fileSecretsPlugin(), | 30 | fileSecretsPlugin(), |
| 37 | onepasswordHttpPlugin(), | 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 | import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk"; | 18 | import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk"; |
| 19 | import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file"; | 19 | import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file"; |
| 20 | import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; | 20 | import { loadPluginsFromJsonc } from "@executor-js/config"; |
| 21 | import { loadPluginsFromJsonc, makeFileConfigSink } from "@executor-js/config"; | ||
| 22 | import * as executorSchema from "./executor-schema"; | 21 | import * as executorSchema from "./executor-schema"; |
| 23 | 22 | ||
| 24 | import { syncFromConfig, resolveConfigPath } from "./config-sync"; | ||
| 25 | import executorConfig from "../../executor.config"; | 23 | import executorConfig from "../../executor.config"; |
| 26 | 24 | ||
| 27 | // In dev mode the drizzle folder sits next to the source tree. In a compiled | 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 | "Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.", | 206 | "Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.", |
| 209 | ].join("\n"); | 207 | ].join("\n"); |
| 210 | 208 | ||
| 209 | const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); | ||
| 210 | |||
| 211 | export const checkDrizzleMigrationCompatibility = (input: { | 211 | export const checkDrizzleMigrationCompatibility = (input: { |
| 212 | readonly sqlite: Database; | 212 | readonly sqlite: Database; |
| 213 | readonly dbPath: string; | 213 | readonly dbPath: string; |
| @@ -274,20 +274,13 @@ const createLocalExecutorLayer = () => { · 60 unmodified lines | |||
| 274 | if (legacySecrets.length > 0) { | 274 | if (legacySecrets.length > 0) { |
| 275 | importLegacySecrets(sqlite, scopeId, legacySecrets); | 275 | importLegacySecrets(sqlite, scopeId, legacySecrets); |
| 276 | } | 276 | } |
| 277 | const configPath = resolveConfigPath(cwd); | 277 | const configPath = resolvePluginConfigPath(cwd); |
| 278 | const configFile = makeFileConfigSink({ | 278 | const staticPlugins = executorConfig.plugins(); |
| 279 | path: configPath, | ||
| 280 | fsLayer: NodeFileSystem.layer, | ||
| 281 | }); | ||
| 282 | |||
| 283 | const staticPlugins = executorConfig.plugins({ configFile }); | ||
| 284 | const dynamicPlugins = | 279 | const dynamicPlugins = |
| 285 | (yield* Effect.promise(() => | 280 | (yield* Effect.promise(() => loadPluginsFromJsonc({ path: configPath }))) ?? []; |
| 286 | loadPluginsFromJsonc({ path: configPath, deps: { configFile } }), | ||
| 287 | )) ?? []; | ||
| 288 | // Static config wins on conflict — mirrors @executor-js/vite-plugin's | 281 | // Static config wins on conflict — mirrors @executor-js/vite-plugin's |
| 289 | // ordering. Without this, a package listed in both surfaces would | 282 | // ordering. Without this, a package listed in both surfaces would boot |
| 290 | // boot twice (double routes, double in-memory storage). | 283 | // twice (double routes, double in-memory storage). |
| 291 | const staticPackageNames = new Set( | 284 | const staticPackageNames = new Set( |
| 292 | staticPlugins.map((p) => p.packageName).filter((n): n is string => !!n), | 285 | staticPlugins.map((p) => p.packageName).filter((n): n is string => !!n), |
| 293 | ); | 286 | ); |
| @@ -322,12 +315,6 @@ const createLocalExecutorLayer = () => { · 28 unmodified lines | |||
| 322 | oauthEndpointUrlPolicy: { allowHttp: true }, | 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 | return { executor, plugins }; | 318 | return { executor, plugins }; |
| 332 | }), | 319 | }), |
| 333 | ); | 320 | ); |
| @@ -254,8 +254,8 @@ function StdioReadOnly(props: { · 253 unmodified lines | |||
| 254 | <div> | 254 | <div> |
| 255 | <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> | 255 | <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1> |
| 256 | <p className="mt-1 text-sm text-muted-foreground"> | 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 | 257 | Stdio MCP sources cannot be edited in the UI. Remove and recreate the source with the |
| 258 | directly. | 258 | updated command. |
| 259 | </p> | 259 | </p> |
| 260 | </div> | 260 | </div> |
| 261 | 261 | ||