diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index 62d95c351..9b125883d 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -7,16 +7,16 @@ * * Flow: * 1. Spin up a tiny local OpenAPI server (one operation, returns 42). - * 2. Write a temp executor.jsonc that points at it as a source. - * 3. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 + * 2. Spawn the compiled `executor-sidecar` binary with EXECUTOR_PORT=0 * and parse the `EXECUTOR_READY:` sentinel. - * 4. Connect via MCP streamable HTTP, call the `execute` tool with code - * that invokes the OpenAPI tool, assert the answer round-trips as 42. + * 3. Connect via MCP streamable HTTP, call the `execute` tool with code + * that registers and invokes the OpenAPI tool, assert the answer + * round-trips as 42. * * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any * deviation so it can gate CI. */ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawn, type Subprocess } from "bun"; @@ -182,18 +182,6 @@ const main = async () => { console.log(`[smoke-sidecar] scope: ${scopeDir}`); console.log(`[smoke-sidecar] openapi: ${openapi.origin}`); - const config = { - sources: [ - { - kind: "openapi", - spec: `${openapi.origin}/openapi.json`, - baseUrl: openapi.origin, - namespace: "petstore", - }, - ], - }; - await writeFile(join(scopeDir, "executor.jsonc"), JSON.stringify(config, null, 2)); - const proc = spawn({ cmd: [BINARY], env: { @@ -241,9 +229,16 @@ const main = async () => { if (!hasExecute) fail(`MCP tools/list missing "execute": ${JSON.stringify(tools.tools)}`); // Drive the running OpenAPI server through a multi-step orchestration - // in one execute. Covers: array list response, path param dispatch, and - // object responses — all going out over real HTTP from inside QuickJS. + // in one execute. Covers: source registration, array list response, path + // param dispatch, and object responses — all going out over real HTTP from + // inside QuickJS. const code = ` +await tools.executor.openapi.addSource({ + scope: ${JSON.stringify(scopeDir)}, + spec: ${JSON.stringify(`${openapi.origin}/openapi.json`)}, + baseUrl: ${JSON.stringify(openapi.origin)}, + namespace: "petstore", +}); const list = await tools.petstore.pets.listPets({}); const fetched = await tools.petstore.pets.getPet({ petId: list[1].id }); return { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 861d171e5..0c083e990 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -28,9 +28,9 @@ import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/ // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every // Electron-side consumer (electron-store, electron-log, window-state) lands -// at a predictable spot. User-mutable executor state (executor.jsonc, -// data.db) is pinned separately to ~/.executor in main/sidecar.ts — that -// path matches the CLI's default. +// at a predictable spot. User-mutable executor state (data.db and the optional +// executor.jsonc plugin manifest) is pinned separately to ~/.executor in +// main/sidecar.ts — that path matches the CLI's default. app.setName("Executor"); app.setPath("userData", join(app.getPath("appData"), "Executor")); diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index 300f8807e..b88e8feba 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -74,13 +74,14 @@ export async function startSidecar(options: StartOptions = {}): Promise + plugins: () => [ - openApiHttpPlugin({ configFile }), - mcpHttpPlugin({ dangerouslyAllowStdioMCP: true, configFile }), + openApiHttpPlugin(), + mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), googleDiscoveryHttpPlugin(), - graphqlHttpPlugin({ configFile }), + graphqlHttpPlugin(), keychainPlugin(), fileSecretsPlugin(), onepasswordHttpPlugin(), diff --git a/apps/local/src/server/config-sync.boot.test.ts b/apps/local/src/server/config-sync.boot.test.ts deleted file mode 100644 index 32902b74f..000000000 --- a/apps/local/src/server/config-sync.boot.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -// Boot-time replay of MCP auth from `executor.jsonc`. The plugin already -// resolves `secret-public-ref:` strings at connect time via -// `ctx.secrets.get` — the regression class here is auth being silently -// dropped at the config boundary. `mcp.addSource` persists the source -// row even when the remote is unreachable, so unreachable endpoints are -// enough to assert on the stored auth shape without running an MCP -// server. The new credential-binding flow does require referenced -// secrets/connections to exist at the target scope, hence the seeds -// before each test. - -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { SECRET_REF_PREFIX, type ExecutorFileConfig } from "@executor-js/config"; -import { - ConnectionId, - CreateConnectionInput, - OAUTH2_PROVIDER_KEY, - ScopeId, - SecretId, - TokenMaterial, - createExecutor, - definePlugin, - makeTestConfig, - type SecretProvider, -} from "@executor-js/sdk"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; - -import { syncFromConfig } from "./config-sync"; -import type { LocalExecutor } from "./executor"; - -const UNREACHABLE = "http://127.0.0.1:1/mcp"; -const TEST_SCOPE = ScopeId.make("test-scope"); - -const makeMemorySecretsPlugin = () => { - const store = new Map(); - const provider: SecretProvider = { - key: "memory", - writable: true, - get: (id, scope) => Effect.sync(() => store.get(`${scope} ${id}`) ?? null), - set: (id, value, scope) => - Effect.sync(() => { - store.set(`${scope} ${id}`, value); - }), - delete: (id, scope) => Effect.sync(() => store.delete(`${scope} ${id}`)), - list: () => - Effect.sync(() => - Array.from(store.keys()).map((k) => { - const id = k.split(" ", 2)[1] ?? k; - return { id, name: id }; - }), - ), - }; - return definePlugin(() => ({ - id: "memory-secrets" as const, - storage: () => ({}), - secretProviders: [provider], - })); -}; - -let workDir: string; - -beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), "exec-config-sync-")); -}); - -afterEach(() => { - rmSync(workDir, { recursive: true, force: true }); -}); - -const writeConfig = (config: ExecutorFileConfig): string => { - const path = join(workDir, "executor.jsonc"); - writeFileSync(path, JSON.stringify(config, null, 2)); - return path; -}; - -const makeExecutor = () => - createExecutor(makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const })); - -describe("syncFromConfig — MCP auth replay", () => { - it.effect("strips secret-public-ref prefix from header auth", () => - Effect.gen(function* () { - const configPath = writeConfig({ - sources: [ - { - kind: "mcp", - transport: "remote", - name: "PostHog", - endpoint: UNREACHABLE, - namespace: "posthog", - auth: { - kind: "header", - headerName: "Authorization", - secret: `${SECRET_REF_PREFIX}posthog-api-key`, - prefix: "Bearer ", - }, - }, - ], - }); - const executor = yield* makeExecutor(); - yield* executor.secrets.set({ - id: SecretId.make("posthog-api-key"), - scope: TEST_SCOPE, - name: "PostHog API Key", - value: "phx_test_token", - provider: "memory", - }); - - yield* syncFromConfig({ - // The test executor uses a narrower plugin tuple than LocalExecutor (no - // openapi/graphql), but Match in addSourceFromConfig only dispatches to - // the mcp branch for these fixtures, so the missing methods are never - // touched at runtime. - // oxlint-disable-next-line executor/no-double-cast - executor: executor as unknown as LocalExecutor, - configPath, - targetScope: TEST_SCOPE, - }); - - const stored = yield* executor.mcp.getSource("posthog", TEST_SCOPE); - expect(stored).not.toBeNull(); - expect(stored!.config).toMatchObject({ - transport: "remote", - endpoint: UNREACHABLE, - auth: { - kind: "header", - headerName: "Authorization", - secretSlot: "auth:header", - prefix: "Bearer ", - }, - }); - }), - ); - - it.effect("passes oauth2 auth through unchanged", () => - Effect.gen(function* () { - const configPath = writeConfig({ - sources: [ - { - kind: "mcp", - transport: "remote", - name: "Linear", - endpoint: UNREACHABLE, - namespace: "linear", - auth: { kind: "oauth2", connectionId: "mcp-oauth2-linear" }, - }, - ], - }); - const executor = yield* makeExecutor(); - const connectionId = ConnectionId.make("mcp-oauth2-linear"); - yield* executor.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: TEST_SCOPE, - provider: OAUTH2_PROVIDER_KEY, - identityLabel: "user@example.com", - accessToken: TokenMaterial.make({ - secretId: SecretId.make(`${connectionId}.access_token`), - name: "MCP Access Token", - value: "access-token-value", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: { - endpoint: UNREACHABLE, - tokenType: "Bearer", - clientInformation: { client_id: "fake" }, - authorizationServerUrl: null, - authorizationServerMetadata: null, - resourceMetadataUrl: null, - resourceMetadata: null, - }, - }), - ); - - yield* syncFromConfig({ - // oxlint-disable-next-line executor/no-double-cast - executor: executor as unknown as LocalExecutor, - configPath, - targetScope: TEST_SCOPE, - }); - - const stored = yield* executor.mcp.getSource("linear", TEST_SCOPE); - expect(stored!.config).toMatchObject({ - transport: "remote", - auth: { kind: "oauth2", connectionSlot: "auth:oauth2:connection" }, - }); - }), - ); - - it.effect("preserves kind:none auth on replay", () => - Effect.gen(function* () { - const configPath = writeConfig({ - sources: [ - { - kind: "mcp", - transport: "remote", - name: "DeepWiki", - endpoint: UNREACHABLE, - namespace: "devin", - auth: { kind: "none" }, - }, - ], - }); - const executor = yield* makeExecutor(); - - yield* syncFromConfig({ - // oxlint-disable-next-line executor/no-double-cast - executor: executor as unknown as LocalExecutor, - configPath, - targetScope: TEST_SCOPE, - }); - - const stored = yield* executor.mcp.getSource("devin", TEST_SCOPE); - expect(stored!.config).toMatchObject({ - transport: "remote", - auth: { kind: "none" }, - }); - }), - ); -}); diff --git a/apps/local/src/server/config-sync.test.ts b/apps/local/src/server/config-sync.test.ts deleted file mode 100644 index dc776ec36..000000000 --- a/apps/local/src/server/config-sync.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { translateMcpAuth } from "./config-sync"; - -describe("translateMcpAuth", () => { - it("returns undefined when no auth is configured", () => { - expect(translateMcpAuth(undefined)).toBeUndefined(); - }); - - it("preserves the kind=none variant", () => { - expect(translateMcpAuth({ kind: "none" })).toEqual({ kind: "none" }); - }); - - it("preserves oauth2 connectionId so the source keeps its OAuth link across boots", () => { - expect(translateMcpAuth({ kind: "oauth2", connectionId: "mcp-oauth2-linear" })).toEqual({ - kind: "oauth2", - connectionId: "mcp-oauth2-linear", - }); - }); - - it("strips the secret-public-ref prefix from header auth", () => { - expect( - translateMcpAuth({ - kind: "header", - headerName: "Authorization", - secret: "secret-public-ref:my-token", - prefix: "Bearer ", - }), - ).toEqual({ - kind: "header", - headerName: "Authorization", - secretId: "my-token", - prefix: "Bearer ", - }); - }); - - it("passes through a raw secret id when no prefix is present", () => { - expect( - translateMcpAuth({ - kind: "header", - headerName: "X-Api-Key", - secret: "raw-id", - }), - ).toEqual({ - kind: "header", - headerName: "X-Api-Key", - secretId: "raw-id", - prefix: undefined, - }); - }); -}); diff --git a/apps/local/src/server/config-sync.ts b/apps/local/src/server/config-sync.ts deleted file mode 100644 index b3ac30b79..000000000 --- a/apps/local/src/server/config-sync.ts +++ /dev/null @@ -1,213 +0,0 @@ -// --------------------------------------------------------------------------- -// Boot-time sync — replays sources from executor.jsonc into the executor. -// Plugins upsert so a re-sync on an already-populated DB is a no-op. -// Write-back (DB → file) is handled by the ConfigFileSink passed to each -// plugin in executor.ts. -// --------------------------------------------------------------------------- - -import { Cause, Effect, Match } from "effect"; -import { join } from "node:path"; -import * as fs from "node:fs"; -import * as jsonc from "jsonc-parser"; - -import type { - SourceConfig, - ExecutorFileConfig, - ConfigHeaderValue, - McpAuthConfig, -} from "@executor-js/config"; -import { SECRET_REF_PREFIX } from "@executor-js/config"; -import type { ScopeId } from "@executor-js/sdk"; -import type { McpConnectionAuthInput } from "@executor-js/plugin-mcp"; - -import type { LocalExecutor } from "./executor"; - -// --------------------------------------------------------------------------- -// Header translation: config format → plugin format -// --------------------------------------------------------------------------- - -const translateHeader = ( - value: ConfigHeaderValue, -): string | { secretId: string; prefix?: string } => { - if (typeof value === "string") { - if (value.startsWith(SECRET_REF_PREFIX)) { - return { secretId: value.slice(SECRET_REF_PREFIX.length) }; - } - return value; - } - // Object form: { value, prefix? } - if (typeof value.value === "string" && value.value.startsWith(SECRET_REF_PREFIX)) { - return { - secretId: value.value.slice(SECRET_REF_PREFIX.length), - prefix: value.prefix, - }; - } - return value.value; -}; - -const translateHeaders = ( - headers: Record | undefined, -): Record | undefined => { - if (!headers) return undefined; - const out: Record = {}; - for (const [k, v] of Object.entries(headers)) { - out[k] = translateHeader(v); - } - return out; -}; - -// MCP auth translation: file format → plugin format. The header variant -// stores credentials as `secret-public-ref:`; the plugin SDK takes the -// raw secret id. The oauth2 variant is structurally identical. -export const translateMcpAuth = ( - auth: McpAuthConfig | undefined, -): McpConnectionAuthInput | undefined => { - if (!auth) return undefined; - if (auth.kind === "none") return { kind: "none" }; - if (auth.kind === "header") { - const secretId = auth.secret.startsWith(SECRET_REF_PREFIX) - ? auth.secret.slice(SECRET_REF_PREFIX.length) - : auth.secret; - return { - kind: "header", - headerName: auth.headerName, - secretId, - prefix: auth.prefix, - }; - } - return { kind: "oauth2", connectionId: auth.connectionId }; -}; - -// --------------------------------------------------------------------------- -// Config path resolution -// --------------------------------------------------------------------------- - -export const resolveConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); - -// --------------------------------------------------------------------------- -// Load config (sync, no Effect deps — runs at startup) -// --------------------------------------------------------------------------- - -const loadConfigSync = (path: string): ExecutorFileConfig | null => { - if (!fs.existsSync(path)) return null; - const raw = fs.readFileSync(path, "utf-8"); - const errors: jsonc.ParseError[] = []; - const parsed = jsonc.parse(raw, errors); - if (errors.length > 0) { - console.warn(`[config-sync] Failed to parse ${path}:`, errors); - return null; - } - return parsed as ExecutorFileConfig; -}; - -// --------------------------------------------------------------------------- -// Sync from config → DB -// --------------------------------------------------------------------------- - -const addSourceFromConfig = ( - executor: LocalExecutor, - targetScope: ScopeId, - source: SourceConfig, -): Effect.Effect => { - return Match.value(source).pipe( - Match.when({ kind: "openapi" }, (s) => - executor.openapi - .addSpec({ - spec: s.spec, - scope: targetScope, - baseUrl: s.baseUrl, - namespace: s.namespace, - headers: translateHeaders(s.headers), - credentialTargetScope: targetScope, - }) - .pipe(Effect.asVoid), - ), - Match.when({ kind: "graphql" }, (s) => - executor.graphql - .addSource({ - endpoint: s.endpoint, - scope: targetScope, - namespace: s.namespace, - headers: translateHeaders(s.headers) as Record | undefined, - credentialTargetScope: targetScope, - }) - .pipe(Effect.asVoid), - ), - Match.when({ kind: "mcp" }, (s) => { - if (s.transport === "stdio") { - return executor.mcp - .addSource({ - transport: "stdio", - scope: targetScope, - name: s.name, - command: s.command, - args: s.args ? [...s.args] : undefined, - env: s.env, - cwd: s.cwd, - namespace: s.namespace, - }) - .pipe(Effect.asVoid); - } - return executor.mcp - .addSource({ - transport: "remote", - scope: targetScope, - name: s.name, - endpoint: s.endpoint, - remoteTransport: s.remoteTransport, - queryParams: s.queryParams, - headers: s.headers, - namespace: s.namespace, - auth: translateMcpAuth(s.auth), - credentialTargetScope: targetScope, - }) - .pipe(Effect.asVoid); - }), - Match.exhaustive, - ); -}; - -/** - * Read executor.jsonc and replay all sources into the executor. - * Each source is added independently — if one fails, the rest still load. - */ -export const syncFromConfig = (input: { - readonly executor: LocalExecutor; - readonly configPath: string; - readonly targetScope: ScopeId; -}): Effect.Effect => - Effect.gen(function* () { - const { executor, configPath, targetScope } = input; - const config = loadConfigSync(configPath); - if (!config?.sources?.length) { - console.log(`[config-sync] ${configPath} missing or empty, skipping`); - return; - } - - console.log(`[config-sync] syncing ${config.sources.length} source(s) from ${configPath}`); - - const results = yield* Effect.forEach( - config.sources, - (source) => - addSourceFromConfig(executor, targetScope, source).pipe( - Effect.map(() => true as const), - Effect.catchCause((cause) => { - const ns = - "namespace" in source ? source.namespace : "name" in source ? source.name : "unknown"; - const squashed = Cause.squash(cause); - const message = - squashed && typeof squashed === "object" && "message" in squashed - ? String((squashed as { message: unknown }).message) - : Cause.pretty(cause); - console.warn(`[config-sync] Failed to load source "${ns}": ${message}`); - return Effect.succeed(false as const); - }), - ), - // Serial — bun:sqlite serializes transactions on a single connection, - // so concurrent addSpec calls race on BEGIN. - { concurrency: 1 }, - ); - - const ok = results.filter(Boolean).length; - console.log(`[config-sync] ${ok}/${results.length} source(s) synced`); - }); diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 8fd513db7..87b8829ce 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -17,11 +17,9 @@ import { import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk"; import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file"; -import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; -import { loadPluginsFromJsonc, makeFileConfigSink } from "@executor-js/config"; +import { loadPluginsFromJsonc } from "@executor-js/config"; import * as executorSchema from "./executor-schema"; -import { syncFromConfig, resolveConfigPath } from "./config-sync"; import executorConfig from "../../executor.config"; // In dev mode the drizzle folder sits next to the source tree. In a compiled @@ -208,6 +206,8 @@ const migrationHistoryMismatchMessage = (dataDir: string): string => "Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.", ].join("\n"); +const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); + export const checkDrizzleMigrationCompatibility = (input: { readonly sqlite: Database; readonly dbPath: string; @@ -274,20 +274,13 @@ const createLocalExecutorLayer = () => { if (legacySecrets.length > 0) { importLegacySecrets(sqlite, scopeId, legacySecrets); } - const configPath = resolveConfigPath(cwd); - const configFile = makeFileConfigSink({ - path: configPath, - fsLayer: NodeFileSystem.layer, - }); - - const staticPlugins = executorConfig.plugins({ configFile }); + const configPath = resolvePluginConfigPath(cwd); + const staticPlugins = executorConfig.plugins(); const dynamicPlugins = - (yield* Effect.promise(() => - loadPluginsFromJsonc({ path: configPath, deps: { configFile } }), - )) ?? []; + (yield* Effect.promise(() => loadPluginsFromJsonc({ path: configPath }))) ?? []; // Static config wins on conflict — mirrors @executor-js/vite-plugin's - // ordering. Without this, a package listed in both surfaces would - // boot twice (double routes, double in-memory storage). + // ordering. Without this, a package listed in both surfaces would boot + // twice (double routes, double in-memory storage). const staticPackageNames = new Set( staticPlugins.map((p) => p.packageName).filter((n): n is string => !!n), ); @@ -322,12 +315,6 @@ const createLocalExecutorLayer = () => { oauthEndpointUrlPolicy: { allowHttp: true }, }); - // Sync sources from executor.jsonc (idempotent — plugins upsert). - // Runs after plugins are wired so sources added here round-trip - // back through configFile — harmless because the file already - // contains them. - yield* syncFromConfig({ executor, configPath, targetScope: scope.id }); - return { executor, plugins }; }), ); diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 75b05ec08..e0a1d69f0 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -254,8 +254,8 @@ function StdioReadOnly(props: {

Edit MCP Source

- Stdio MCP sources cannot be edited in the UI. Modify the executor.jsonc config file - directly. + Stdio MCP sources cannot be edited in the UI. Remove and recreate the source with the + updated command.