History

Remove executor jsonc source sync (#807)

Rhys Sullivan committed 6 months ago

Commit 05d456c1 parent 44b435b9 files · +40 −552Browse files

Unified · Split

9 files
  1. apps/desktop/scripts/smoke-sidecar.ts +14 −19
  2. apps/desktop/src/main/index.ts +3 −3
  3. apps/desktop/src/main/sidecar.ts +8 −7
  4. apps/local/executor.config.ts +5 −11
  5. apps/local/src/server/config-sync.boot.test.ts +0 −225
  6. apps/local/src/server/config-sync.test.ts +0 −51
  7. apps/local/src/server/config-sync.ts +0 −213
  8. apps/local/src/server/executor.ts +8 −21
  9. packages/plugins/mcp/src/react/EditMcpSource.tsx +2 −2
apps/desktop/scripts/smoke-sidecar.ts+14−19
@@ -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 code12 * 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 any16 * 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 */
19import { mkdtemp, rm, writeFile } from "node:fs/promises";19import { mkdtemp, rm } from "node:fs/promises";
20import { tmpdir } from "node:os";20import { tmpdir } from "node:os";
21import { join, resolve } from "node:path";21import { join, resolve } from "node:path";
22import { spawn, type Subprocess } from "bun";22import { 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 orchestration231 // Drive the running OpenAPI server through a multi-step orchestration
244 // in one execute. Covers: array list response, path param dispatch, and232 // 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 = `
236await 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});
247const list = await tools.petstore.pets.listPets({});242const list = await tools.petstore.pets.listPets({});
248const fetched = await tools.petstore.pets.getPet({ petId: list[1].id });243const fetched = await tools.petstore.pets.getPet({ petId: list[1].id });
249return {244return {
apps/desktop/src/main/index.ts+3−3
@@ -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 every29// Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every
30// Electron-side consumer (electron-store, electron-log, window-state) lands30// 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 — that32// 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.
34app.setName("Executor");34app.setName("Executor");
35app.setPath("userData", join(app.getPath("appData"), "Executor"));35app.setPath("userData", join(app.getPath("appData"), "Executor"));
36 36
apps/desktop/src/main/sidecar.ts+8−7
@@ -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's77 // data.db and the optional executor.jsonc plugin manifest live under
78 // `executor web` uses. Desktop and CLI share state on the same machine so78 // ~/.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, and79 // share state on the same machine so sources/secrets/policies set up in one
80 // user-facing commands like `executor mcp --scope ~/.executor` stay80 // show up in the other, and user-facing commands like
81 // copy-paste-friendly. Electron's userData (set in main/index.ts) is81 // `executor mcp --scope ~/.executor` stay copy-paste-friendly. Electron's
82 // still used for electron-store, electron-log, and window-state — those82 // 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
apps/local/executor.config.ts+5−11
@@ -1,5 +1,4 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import type { ConfigFileSink } from "@executor-js/config";
3import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
4import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";3import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
5import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api";4import { 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
23interface LocalPluginDeps {
24 readonly configFile?: ConfigFileSink;
25}
26
27export default defineExecutorConfig({21export 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(),
apps/local/src/server/config-sync.boot.test.ts+0−225
@@ -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
11import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
12import { Effect } from "effect";
13import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join } from "node:path";
16
17import { SECRET_REF_PREFIX, type ExecutorFileConfig } from "@executor-js/config";
18import {
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";
30import { mcpPlugin } from "@executor-js/plugin-mcp";
31
32import { syncFromConfig } from "./config-sync";
33import type { LocalExecutor } from "./executor";
34
35const UNREACHABLE = "http://127.0.0.1:1/mcp";
36const TEST_SCOPE = ScopeId.make("test-scope");
37
38const 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
64let workDir: string;
65
66beforeEach(() => {
67 workDir = mkdtempSync(join(tmpdir(), "exec-config-sync-"));
68});
69
70afterEach(() => {
71 rmSync(workDir, { recursive: true, force: true });
72});
73
74const writeConfig = (config: ExecutorFileConfig): string => {
75 const path = join(workDir, "executor.jsonc");
76 writeFileSync(path, JSON.stringify(config, null, 2));
77 return path;
78};
79
80const makeExecutor = () =>
81 createExecutor(makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const }));
82
83describe("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});
apps/local/src/server/config-sync.test.ts+0−51
@@ -1,51 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { translateMcpAuth } from "./config-sync";
4
5describe("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});
apps/local/src/server/config-sync.ts+0−213
@@ -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
8import { Cause, Effect, Match } from "effect";
9import { join } from "node:path";
10import * as fs from "node:fs";
11import * as jsonc from "jsonc-parser";
12
13import type {
14 SourceConfig,
15 ExecutorFileConfig,
16 ConfigHeaderValue,
17 McpAuthConfig,
18} from "@executor-js/config";
19import { SECRET_REF_PREFIX } from "@executor-js/config";
20import type { ScopeId } from "@executor-js/sdk";
21import type { McpConnectionAuthInput } from "@executor-js/plugin-mcp";
22
23import type { LocalExecutor } from "./executor";
24
25// ---------------------------------------------------------------------------
26// Header translation: config format → plugin format
27// ---------------------------------------------------------------------------
28
29const 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
48const 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.
62export 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
85export const resolveConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc");
86
87// ---------------------------------------------------------------------------
88// Load config (sync, no Effect deps — runs at startup)
89// ---------------------------------------------------------------------------
90
91const 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
107const 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 */
174export 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 });
apps/local/src/server/executor.ts+8−21
@@ -17,11 +17,9 @@ import { · 16 unmodified lines
17 17
18import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk";18import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk";
19import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file";19import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file";
20import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";20import { loadPluginsFromJsonc } from "@executor-js/config";
21import { loadPluginsFromJsonc, makeFileConfigSink } from "@executor-js/config";
22import * as executorSchema from "./executor-schema";21import * as executorSchema from "./executor-schema";
23 22
24import { syncFromConfig, resolveConfigPath } from "./config-sync";
25import executorConfig from "../../executor.config";23import executorConfig from "../../executor.config";
26 24
27// In dev mode the drizzle folder sits next to the source tree. In a compiled25// 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
209const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc");
210
211export const checkDrizzleMigrationCompatibility = (input: {211export 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's281 // Static config wins on conflict — mirrors @executor-js/vite-plugin's
289 // ordering. Without this, a package listed in both surfaces would282 // 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 );
packages/plugins/mcp/src/react/EditMcpSource.tsx+2−2
@@ -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 file257 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