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
77 *
88 * Flow:
99 * 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
1211 * 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.
1515 *
1616 * Run after `bun ./scripts/build-sidecar.ts`. Exits non-zero on any
1717 * deviation so it can gate CI.
1818 */
19−import { mkdtemp, rm, writeFile } from "node:fs/promises";
19+import { mkdtemp, rm } from "node:fs/promises";
2020 import { tmpdir } from "node:os";
2121 import { join, resolve } from "node:path";
2222 import { spawn, type Subprocess } from "bun";
@@ -182,18 +182,6 @@ const main = async () => { · 159 unmodified lines
182182 console.log(`[smoke-sidecar] scope: ${scopeDir}`);
183183 console.log(`[smoke-sidecar] openapi: ${openapi.origin}`);
184184
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−
197185 const proc = spawn({
198186 cmd: [BINARY],
199187 env: {
@@ -241,9 +229,16 @@ const main = async () => { · 41 unmodified lines
241229 if (!hasExecute) fail(`MCP tools/list missing "execute": ${JSON.stringify(tools.tools)}`);
242230
243231 // 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.
246235 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+});
247242 const list = await tools.petstore.pets.listPets({});
248243 const fetched = await tools.petstore.pets.getPet({ petId: list[1].id });
249244 return {
apps/desktop/src/main/index.ts+3−3
@@ -28,9 +28,9 @@ import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/ · 27 unmodified lines
2828
2929 // Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every
3030 // 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.
3434 app.setName("Executor");
3535 app.setPath("userData", join(app.getPath("appData"), "Executor"));
3636
apps/desktop/src/main/sidecar.ts+8−7
@@ -74,13 +74,14 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC · 73 unmodified lines
7474 );
7575 }
7676
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.
8485 const scopeDir = join(homedir(), ".executor");
8586 mkdirSync(scopeDir, { recursive: true });
8687
apps/local/executor.config.ts+5−11
@@ -1,5 +1,4 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
2−import type { ConfigFileSink } from "@executor-js/config";
32 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
43 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
54 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
1413 //
1514 // Consumed by:
1615 // - 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
1817 //
19−// `TDeps` is inferred from the factory parameter annotation directly.
2018 // First-party and third-party plugins use the same import-and-call flow.
2119 // ---------------------------------------------------------------------------
2220
23−interface LocalPluginDeps {
24− readonly configFile?: ConfigFileSink;
25−}
26−
2721 export default defineExecutorConfig({
2822 dialect: "sqlite",
29− plugins: ({ configFile }: LocalPluginDeps = {}) =>
23+ plugins: () =>
3024 [
31− openApiHttpPlugin({ configFile }),
32− mcpHttpPlugin({ dangerouslyAllowStdioMCP: true, configFile }),
25+ openApiHttpPlugin(),
26+ mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
3327 googleDiscoveryHttpPlugin(),
34− graphqlHttpPlugin({ configFile }),
28+ graphqlHttpPlugin(),
3529 keychainPlugin(),
3630 fileSecretsPlugin(),
3731 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−
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−});
apps/local/src/server/config-sync.test.ts+0−51
@@ -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−});
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−
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− });
apps/local/src/server/executor.ts+8−21
@@ -17,11 +17,9 @@ import { · 16 unmodified lines
1717
1818 import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk";
1919 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";
2221 import * as executorSchema from "./executor-schema";
2322
24−import { syncFromConfig, resolveConfigPath } from "./config-sync";
2523 import executorConfig from "../../executor.config";
2624
2725 // 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
208206 "Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.",
209207 ].join("\n");
210208
209+const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc");
210+
211211 export const checkDrizzleMigrationCompatibility = (input: {
212212 readonly sqlite: Database;
213213 readonly dbPath: string;
@@ -274,20 +274,13 @@ const createLocalExecutorLayer = () => { · 60 unmodified lines
274274 if (legacySecrets.length > 0) {
275275 importLegacySecrets(sqlite, scopeId, legacySecrets);
276276 }
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();
284279 const dynamicPlugins =
285− (yield* Effect.promise(() =>
286− loadPluginsFromJsonc({ path: configPath, deps: { configFile } }),
287− )) ?? [];
280+ (yield* Effect.promise(() => loadPluginsFromJsonc({ path: configPath }))) ?? [];
288281 // 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).
291284 const staticPackageNames = new Set(
292285 staticPlugins.map((p) => p.packageName).filter((n): n is string => !!n),
293286 );
@@ -322,12 +315,6 @@ const createLocalExecutorLayer = () => { · 28 unmodified lines
322315 oauthEndpointUrlPolicy: { allowHttp: true },
323316 });
324317
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−
331318 return { executor, plugins };
332319 }),
333320 );
packages/plugins/mcp/src/react/EditMcpSource.tsx+2−2
@@ -254,8 +254,8 @@ function StdioReadOnly(props: { · 253 unmodified lines
254254 <div>
255255 <h1 className="text-xl font-semibold text-foreground">Edit MCP Source</h1>
256256 <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.
259259 </p>
260260 </div>
261261