From af897d9e3ce4a9806000075b86b49530874fde87 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 08:35:04 -0700 Subject: [PATCH] FN-8596: isolate cross-root plugin MCP discovery Prevent cross-root MCP discovery from unloading active plugin runtimes. - Isolate discovery loader lifecycle and runtime-state persistence. - Preserve shared plugin owners when non-owner loader participants stop. - Cover core, dashboard, and engine cross-root discovery behavior. Files changed: .changeset/fn-8596-plugin-discovery-isolation.md | 7 ++ .../plugin-loader-lifecycle-scope.test.ts | 12 +++ .../plugin-mcp-servers-discovery-isolation.test.ts | 115 +++++++++++++++++++++ packages/core/src/plugin-loader.ts | 34 +++++- packages/core/src/plugin-mcp-servers.ts | 8 +- .../context-plugin-mcp-discovery-isolation.test.ts | 48 +++++++++ packages/dashboard/src/routes/context.ts | 39 ++++++- ...-runtime-plugin-mcp-discovery-isolation.test.ts | 69 +++++++++++++ packages/engine/src/runtimes/in-process-runtime.ts | 47 +++++++-- 9 files changed, 364 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-8596 Fusion-Task-Lineage: 231e53b6-a9a3-4a65-9732-3dabe44da198 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8596-plugin-discovery-isolation.md | 7 ++ .../plugin-loader-lifecycle-scope.test.ts | 12 ++ ...in-mcp-servers-discovery-isolation.test.ts | 115 ++++++++++++++++++ packages/core/src/plugin-loader.ts | 34 +++++- packages/core/src/plugin-mcp-servers.ts | 8 +- ...ext-plugin-mcp-discovery-isolation.test.ts | 48 ++++++++ packages/dashboard/src/routes/context.ts | 39 +++++- ...ime-plugin-mcp-discovery-isolation.test.ts | 69 +++++++++++ .../engine/src/runtimes/in-process-runtime.ts | 47 +++++-- 9 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 .changeset/fn-8596-plugin-discovery-isolation.md create mode 100644 packages/core/src/__tests__/plugin-loader-lifecycle-scope.test.ts create mode 100644 packages/core/src/__tests__/plugin-mcp-servers-discovery-isolation.test.ts create mode 100644 packages/dashboard/src/__tests__/context-plugin-mcp-discovery-isolation.test.ts create mode 100644 packages/engine/src/__tests__/in-process-runtime-plugin-mcp-discovery-isolation.test.ts diff --git a/.changeset/fn-8596-plugin-discovery-isolation.md b/.changeset/fn-8596-plugin-discovery-isolation.md new file mode 100644 index 0000000000..eb99133acf --- /dev/null +++ b/.changeset/fn-8596-plugin-discovery-isolation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent cross-project plugin discovery from unloading enabled plugin skills. +category: fix +dev: Discovery loaders use isolated lifecycles; shared non-owner stops now detach only. diff --git a/packages/core/src/__tests__/plugin-loader-lifecycle-scope.test.ts b/packages/core/src/__tests__/plugin-loader-lifecycle-scope.test.ts new file mode 100644 index 0000000000..b84663af73 --- /dev/null +++ b/packages/core/src/__tests__/plugin-loader-lifecycle-scope.test.ts @@ -0,0 +1,12 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PluginLoader } from "../plugin-loader.js"; +import type { PluginInstallation } from "../plugin-types.js"; +const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true }))); }); +async function fixture() { const root = await mkdtemp(join(tmpdir(), "fusion-loader-scope-")); roots.push(root); const path = join(root, "plugin.mjs"); await writeFile(path, `export default { manifest: { id: "scope", name: "Scope", version: "1.0.0", description: "fixture" }, state: "installed", hooks: {} };`); const plugin: PluginInstallation = { id: "scope", name: "Scope", version: "1.0.0", description: "fixture", path, enabled: true, state: "installed", settings: {}, dependencies: [], createdAt: "", updatedAt: "" }; const pluginStore = { getPlugin: vi.fn(async () => ({ ...plugin })), listPlugins: vi.fn(async () => [{ ...plugin }]), updatePluginState: vi.fn(async () => undefined) }; const taskStore = { getRootDir: () => root, preflightPluginSchema: vi.fn(() => null), runPluginSchemaInits: vi.fn(async () => undefined), recordPluginActivation: vi.fn() }; return { pluginStore, taskStore }; } +describe("PluginLoader lifecycle scopes", () => { + it("reloads an isolated loader privately without replacing the shared owner", async () => { const f = await fixture(); const owner = new PluginLoader({ pluginStore: f.pluginStore as any, taskStore: f.taskStore as any }); const isolated = new PluginLoader({ pluginStore: f.pluginStore as any, taskStore: f.taskStore as any, lifecycleScope: "isolated" }); await owner.loadPlugin("scope"); const owned = owner.getPlugin("scope"); await isolated.loadPlugin("scope"); f.pluginStore.updatePluginState.mockClear(); await isolated.reloadPlugin("scope"); expect(owner.getPlugin("scope")).toBe(owned); expect(owner.isPluginLoaded("scope")).toBe(true); expect(f.pluginStore.updatePluginState).not.toHaveBeenCalled(); }); + it("detaches a shared participant without stopping its owner", async () => { const f = await fixture(); const owner = new PluginLoader({ pluginStore: f.pluginStore as any, taskStore: f.taskStore as any }); const participant = new PluginLoader({ pluginStore: f.pluginStore as any, taskStore: f.taskStore as any }); await owner.loadPlugin("scope"); await participant.loadPlugin("scope"); f.pluginStore.updatePluginState.mockClear(); await participant.stopPlugin("scope"); expect(owner.isPluginLoaded("scope")).toBe(true); expect(participant.isPluginLoaded("scope")).toBe(false); expect(f.pluginStore.updatePluginState).not.toHaveBeenCalled(); }); +}); diff --git a/packages/core/src/__tests__/plugin-mcp-servers-discovery-isolation.test.ts b/packages/core/src/__tests__/plugin-mcp-servers-discovery-isolation.test.ts new file mode 100644 index 0000000000..912c720ec7 --- /dev/null +++ b/packages/core/src/__tests__/plugin-mcp-servers-discovery-isolation.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PluginLoader } from "../plugin-loader.js"; +import { createProjectScopedPluginMcpProvider } from "../plugin-mcp-servers.js"; +import type { PluginInstallation } from "../plugin-types.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function createProject(options: { plugins?: Array<{ id: string; dependencies?: string[]; broken?: boolean }>; root?: string } = {}) { + const root = options.root ?? await mkdtemp(join(tmpdir(), "fusion-mcp-isolation-")); + if (!options.root) roots.push(root); + const definitions = options.plugins ?? [{ id: "fixture" }]; + const installations: PluginInstallation[] = await Promise.all(definitions.map(async ({ id, dependencies = [], broken }) => { + const path = join(root, `${id}.mjs`); + await writeFile(path, broken ? "export default { broken: true };" : `export default { + manifest: { id: "${id}", name: "${id}", version: "1.0.0", description: "fixture", dependencies: ${JSON.stringify(dependencies)} }, + state: "installed", hooks: {}, + mcpServers: [{ name: "${id}-mcp", transport: "stdio", command: "${id}" }] + };`); + return { id, name: id, version: "1.0.0", description: "fixture", path, enabled: true, state: "installed", settings: {}, dependencies, createdAt: "", updatedAt: id }; + })); + const pluginStore = { + init: vi.fn(async () => undefined), + getPlugin: vi.fn(async (id: string) => { + const plugin = installations.find((candidate) => candidate.id === id); + if (!plugin) throw new Error(`missing ${id}`); + return { ...plugin }; + }), + listPlugins: vi.fn(async () => installations.map((plugin) => ({ ...plugin }))), + updatePluginState: vi.fn(async () => undefined), + }; + const taskStore = { getRootDir: () => root, getPluginStore: () => pluginStore, preflightPluginSchema: vi.fn(() => null), runPluginSchemaInits: vi.fn(async () => undefined), recordPluginActivation: vi.fn() }; + return { root, pluginStore, taskStore }; +} + +function discoveryProvider(host: Awaited>) { + return createProjectScopedPluginMcpProvider({ + hostRootDir: host.root, + hostLoader: new PluginLoader({ pluginStore: host.pluginStore as any, taskStore: host.taskStore as any }), + createScopedLoader: (scoped) => new PluginLoader({ pluginStore: scoped.getPluginStore() as any, taskStore: scoped as any, lifecycleScope: "isolated", persistRuntimeState: false }), + }); +} + +function lifecycleKeysFor(root: string): string[] { + return [...((PluginLoader as any).processPluginLifecycles as Map).keys()].filter((key) => key.startsWith(`${root}\0`)); +} + +describe("project-scoped plugin MCP discovery isolation", () => { + it("preserves an already-loaded cross-root owner, registry, and runtime state", async () => { + const target = await createProject(); + const owner = new PluginLoader({ pluginStore: target.pluginStore as any, taskStore: target.taskStore as any }); + await owner.loadAllPlugins(); + const registryBefore = lifecycleKeysFor(target.root); + target.pluginStore.updatePluginState.mockClear(); + + const entries = await discoveryProvider(await createProject()).get(target.taskStore); + + expect(entries.map((entry) => entry.server.name)).toEqual(["fixture-mcp"]); + expect(owner.isPluginLoaded("fixture")).toBe(true); + expect(owner.getPluginMcpServers()).toHaveLength(1); + expect(lifecycleKeysFor(target.root)).toEqual(registryBefore); + expect(target.pluginStore.updatePluginState).not.toHaveBeenCalled(); + await owner.stopAllPlugins(); + }); + + it("discovers an unloaded project privately without creating shared lifecycle state", async () => { + const target = await createProject(); + target.pluginStore.updatePluginState.mockClear(); + const entries = await discoveryProvider(await createProject()).get(target.taskStore); + expect(entries.map((entry) => entry.pluginId)).toEqual(["fixture"]); + expect(lifecycleKeysFor(target.root)).toEqual([]); + expect(target.pluginStore.updatePluginState).not.toHaveBeenCalled(); + }); + + it("returns no entries and constructs no loader for a project with zero enabled plugins", async () => { + const target = await createProject({ plugins: [] }); + const host = await createProject(); + const createScopedLoader = vi.fn(); + const provider = createProjectScopedPluginMcpProvider({ hostRootDir: host.root, hostLoader: new PluginLoader({ pluginStore: host.pluginStore as any, taskStore: host.taskStore as any }), createScopedLoader }); + await expect(provider.get(target.taskStore)).resolves.toEqual([]); + expect(createScopedLoader).not.toHaveBeenCalled(); + expect(target.pluginStore.updatePluginState).not.toHaveBeenCalled(); + }); + + it("returns multiple enabled contributions in dependency load order without persistence", async () => { + const target = await createProject({ plugins: [{ id: "dependent", dependencies: ["base"] }, { id: "base" }] }); + target.pluginStore.updatePluginState.mockClear(); + const entries = await discoveryProvider(await createProject()).get(target.taskStore); + expect(entries.map((entry) => entry.pluginId)).toEqual(["base", "dependent"]); + expect(target.pluginStore.updatePluginState).not.toHaveBeenCalled(); + expect(lifecycleKeysFor(target.root)).toEqual([]); + }); + + it("tears down a failed discovery privately without shared registry or state changes", async () => { + const target = await createProject({ plugins: [{ id: "broken", broken: true }] }); + target.pluginStore.updatePluginState.mockClear(); + await expect(discoveryProvider(await createProject()).get(target.taskStore)).resolves.toEqual([]); + expect(lifecycleKeysFor(target.root)).toEqual([]); + expect(target.pluginStore.updatePluginState).not.toHaveBeenCalled(); + }); + + it("keeps same-root lookup on the host loader without constructing discovery loader", async () => { + const host = await createProject(); + const hostLoader = new PluginLoader({ pluginStore: host.pluginStore as any, taskStore: host.taskStore as any }); + await hostLoader.loadAllPlugins(); + const createScopedLoader = vi.fn(); + const provider = createProjectScopedPluginMcpProvider({ hostRootDir: host.root, hostLoader, createScopedLoader }); + await expect(provider.get(host.taskStore)).resolves.toHaveLength(1); + expect(createScopedLoader).not.toHaveBeenCalled(); + await hostLoader.stopAllPlugins(); + }); +}); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 8476569bda..186da0d855 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -161,6 +161,13 @@ export interface PluginLoaderOptions { npmPrefix?: string; /** Persist started/stopped/error runtime state transitions (default true). */ persistRuntimeState?: boolean; + /** + * FNXC:PluginLoader 2026-07-23-12:00: + * FN-8596 discovery loaders may read another project's contributions, but + * must never join, mutate, or tear down the daemon-wide lifecycle registry + * or persist runtime state. Isolated loaders own only private instances. + */ + lifecycleScope?: "shared" | "isolated"; } /** @@ -240,7 +247,7 @@ export class PluginLoader extends EventEmitter<{ state: PluginInstallation["state"], error?: string, ): Promise { - if (this.options.persistRuntimeState === false) return; + if (this.options.persistRuntimeState === false || this.options.lifecycleScope === "isolated") return; await this.options.pluginStore.updatePluginState(pluginId, state, error); } @@ -364,8 +371,12 @@ export class PluginLoader extends EventEmitter<{ return this.plugins.get(pluginId)!; } - // Resolve plugin path + // Resolve plugin path. Discovery instances deliberately bypass the shared + // registry so their teardown can affect only their private plugin maps. const pluginPath = this.resolvePluginPath(installation.path); + if (this.options.lifecycleScope === "isolated") { + return await this.loadPluginFresh(pluginId, installation, pluginPath); + } const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath); const existingLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey); if (existingLifecycle) { @@ -643,6 +654,14 @@ export class PluginLoader extends EventEmitter<{ ): Promise { const installation = await this.options.pluginStore.getPlugin(pluginId); const pluginPath = this.resolvePluginPath(installation.path); + /* + FNXC:PluginLoader 2026-07-23-12:00: + FN-8596 requires explicit private reload semantics for isolated discovery + loaders. Never synchronize an inspection instance into shared participants. + */ + if (this.options.lifecycleScope === "isolated") { + return await this.reloadPluginFresh(pluginId, installation, pluginPath, options); + } const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath); const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey); const precedingLifecycle = processLifecycle?.promise; @@ -1023,6 +1042,10 @@ export class PluginLoader extends EventEmitter<{ return; } const pluginPath = this.resolvePluginPath(installation.path); + if (this.options.lifecycleScope === "isolated") { + await this.stopPluginFresh(pluginId, pluginPath); + return; + } const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath); const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey); if (!processLifecycle) { @@ -1036,6 +1059,13 @@ export class PluginLoader extends EventEmitter<{ } catch { // A rejected load has already cleaned its local state. } + if (processLifecycle.owner !== this) { + // Participants adopted the owner's instance; stopping one only detaches + // that view and must not unload or persist state for the shared owner. + processLifecycle.participants.delete(this); + this.discardProcessPlugin(pluginId, pluginPath); + return; + } await processLifecycle.owner.stopPluginFresh(pluginId, pluginPath); for (const loader of processLifecycle.participants) { if (loader === processLifecycle.owner) continue; diff --git a/packages/core/src/plugin-mcp-servers.ts b/packages/core/src/plugin-mcp-servers.ts index d454422cda..3222b50df0 100644 --- a/packages/core/src/plugin-mcp-servers.ts +++ b/packages/core/src/plugin-mcp-servers.ts @@ -52,8 +52,14 @@ export function createProjectScopedPluginMcpProvider(options: ProjectScopedPlugi } const enabledIds = new Set(enabled.map((plugin) => plugin.id)); + /* + FNXC:PluginMcpServers 2026-07-23-12:00: + FN-8596 requires other-root discovery to use an isolated loader: it may + read enabled MCP contributions and run private teardown, but must never + join or mutate shared lifecycle state or persist runtime transitions. + */ // Same-root callers reuse their active loader. Other roots load only their - // own enabled plugins and never persist lifecycle state during discovery. + // own enabled plugins through the caller's isolated construction seam. if (root === normalizedHostRoot) { const entries = options.hostLoader.getPluginMcpServers().filter((entry) => enabledIds.has(entry.pluginId)); cache.set(root, { enabledKey, entries }); diff --git a/packages/dashboard/src/__tests__/context-plugin-mcp-discovery-isolation.test.ts b/packages/dashboard/src/__tests__/context-plugin-mcp-discovery-isolation.test.ts new file mode 100644 index 0000000000..2b5dd89f31 --- /dev/null +++ b/packages/dashboard/src/__tests__/context-plugin-mcp-discovery-isolation.test.ts @@ -0,0 +1,48 @@ +/* + * The route-context closure delegates provider construction to the exported + * narrow binding seam. This exercises that exact production seam with a real + * cross-root provider pass, without booting the dashboard HTTP server. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PluginLoader, type PluginInstallation } from "@fusion/core"; +import { createDashboardProjectScopedPluginMcpProvider, createDiscoveryPluginLoaderOptions } from "../routes/context.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function project() { + const root = await mkdtemp(join(tmpdir(), "fusion-dashboard-mcp-isolation-")); + roots.push(root); + const path = join(root, "plugin.mjs"); + await writeFile(path, `export default { manifest: { id: "dashboard-fixture", name: "Dashboard fixture", version: "1.0.0", description: "fixture" }, state: "installed", hooks: {}, mcpServers: [{ name: "dashboard-mcp", transport: "stdio", command: "fixture" }] };`); + const installation: PluginInstallation = { id: "dashboard-fixture", name: "Dashboard fixture", version: "1.0.0", description: "fixture", path, enabled: true, state: "installed", settings: {}, dependencies: [], createdAt: "", updatedAt: "1" }; + const pluginStore = { init: vi.fn(async () => undefined), getPlugin: vi.fn(async () => ({ ...installation })), listPlugins: vi.fn(async () => [{ ...installation }]), updatePluginState: vi.fn(async () => undefined) }; + const taskStore = { getRootDir: () => root, getPluginStore: () => pluginStore, preflightPluginSchema: vi.fn(() => null), runPluginSchemaInits: vi.fn(async () => undefined), recordPluginActivation: vi.fn() }; + return { root, pluginStore, taskStore }; +} + +describe("dashboard plugin MCP discovery binding", () => { + it("uses an isolated loader and preserves an owning target loader during cross-root discovery", async () => { + const target = await project(); + const owner = new PluginLoader({ pluginStore: target.pluginStore as any, taskStore: target.taskStore as any }); + await owner.loadAllPlugins(); + target.pluginStore.updatePluginState.mockClear(); + const host = await project(); + + const entries = await createDashboardProjectScopedPluginMcpProvider({ + hostRootDir: host.root, + hostLoader: new PluginLoader({ pluginStore: host.pluginStore as any, taskStore: host.taskStore as any }), + }).get(target.taskStore); + + expect(createDiscoveryPluginLoaderOptions(target.taskStore as any)).toMatchObject({ lifecycleScope: "isolated", persistRuntimeState: false }); + expect(entries.map((entry) => entry.server.name)).toEqual(["dashboard-mcp"]); + expect(owner.isPluginLoaded("dashboard-fixture")).toBe(true); + expect(owner.getPluginMcpServers()).toHaveLength(1); + expect(target.pluginStore.updatePluginState).not.toHaveBeenCalled(); + expect([...((PluginLoader as any).processPluginLifecycles as Map).keys()].some((key) => key.startsWith(`${target.root}\0`))).toBe(true); + await owner.stopAllPlugins(); + }); +}); diff --git a/packages/dashboard/src/routes/context.ts b/packages/dashboard/src/routes/context.ts index e396cddecd..a789e9fb2f 100644 --- a/packages/dashboard/src/routes/context.ts +++ b/packages/dashboard/src/routes/context.ts @@ -4,6 +4,7 @@ import { createProjectScopedPluginMcpProvider, PluginLoader, type PluginStore, + type PluginLoaderOptions, type TaskStore, } from "@fusion/core"; import type { ServerOptions } from "../server.js"; @@ -32,6 +33,38 @@ function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server er throw internalError(fallbackMessage); } +/** + * FNXC:PluginMcpServers 2026-07-23-12:00: + * FN-8596 requires dashboard cross-root MCP inspection to use a private, + * non-persisting loader so ordinary requests cannot unload an engine runtime. + */ +export function createDiscoveryPluginLoaderOptions( + otherStore: Pick, +): PluginLoaderOptions { + return { + pluginStore: otherStore.getPluginStore() as PluginStore, + taskStore: otherStore as TaskStore, + lifecycleScope: "isolated", + persistRuntimeState: false, + }; +} + +/** + * FNXC:PluginMcpServers 2026-07-23-12:00: + * FN-8596 keeps this narrow dashboard binding seam testable: every cross-root + * request must construct only an isolated, non-persisting discovery loader. + */ +export function createDashboardProjectScopedPluginMcpProvider(input: { + hostRootDir: string; + hostLoader: PluginLoader; +}): ReturnType { + return createProjectScopedPluginMcpProvider({ + hostRootDir: input.hostRootDir, + hostLoader: input.hostLoader, + createScopedLoader: (otherStore) => new PluginLoader(createDiscoveryPluginLoaderOptions(otherStore as TaskStore)), + }); +} + export function classifyRemoteRouteError(error: unknown): RemoteRouteErrorClassification { const fallbackMessage = String(error); @@ -379,13 +412,9 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions let provider = projectMcpProviders.get(loader); if (!provider) { - provider = createProjectScopedPluginMcpProvider({ + provider = createDashboardProjectScopedPluginMcpProvider({ hostRootDir: context.store.getRootDir(), hostLoader: loader, - createScopedLoader: (otherStore) => new PluginLoader({ - pluginStore: otherStore.getPluginStore() as PluginStore, - taskStore: otherStore as TaskStore, - }), }); projectMcpProviders.set(loader, provider); } diff --git a/packages/engine/src/__tests__/in-process-runtime-plugin-mcp-discovery-isolation.test.ts b/packages/engine/src/__tests__/in-process-runtime-plugin-mcp-discovery-isolation.test.ts new file mode 100644 index 0000000000..ccc29bf3d8 --- /dev/null +++ b/packages/engine/src/__tests__/in-process-runtime-plugin-mcp-discovery-isolation.test.ts @@ -0,0 +1,69 @@ +/* + * This test drives the exported factory seam used by InProcessRuntime.start(), + * rather than recreating its provider options. A full runtime is unnecessary: + * a real owner loader and PluginRunner expose the cache/skill symptom directly. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PluginLoader, createProjectScopedPluginMcpProvider, type PluginInstallation } from "@fusion/core"; +import { PluginRunner } from "../plugin-runner.js"; +import { collectPluginSkillNames } from "../session-skill-context.js"; +import { createDiscoveryPluginLoaderOptions, createRuntimePluginMcpProviderOptions } from "../runtimes/in-process-runtime.js"; + +const roots: string[] = []; +afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + +async function project(root?: string) { + const projectRoot = root ?? await mkdtemp(join(tmpdir(), "fusion-engine-mcp-isolation-")); + if (!root) roots.push(projectRoot); + const path = join(projectRoot, "plugin.mjs"); + await writeFile(path, `export default { + manifest: { id: "engine-fixture", name: "Engine fixture", version: "1.0.0", description: "fixture" }, state: "installed", hooks: {}, + skills: [{ skillId: "fixture-skill", name: "fixture-skill", description: "fixture", skillFiles: [], enabled: true }], + mcpServers: [{ name: "fixture-mcp", transport: "stdio", command: "fixture" }] + };`); + const installation: PluginInstallation = { id: "engine-fixture", name: "Engine fixture", version: "1.0.0", description: "fixture", path, enabled: true, state: "installed", settings: {}, dependencies: [], createdAt: "", updatedAt: "1" }; + const pluginStore = { init: vi.fn(async () => undefined), getPlugin: vi.fn(async () => ({ ...installation })), listPlugins: vi.fn(async () => [{ ...installation }]), updatePluginState: vi.fn(async () => undefined) }; + const taskStore = { getRootDir: () => projectRoot, getPluginStore: () => pluginStore, preflightPluginSchema: vi.fn(() => null), runPluginSchemaInits: vi.fn(async () => undefined), recordPluginActivation: vi.fn() }; + return { root: projectRoot, pluginStore, taskStore }; +} + +describe("InProcessRuntime plugin MCP discovery factory", () => { + it("keeps PluginRunner caches and session skills stable across production-seam cross-root discovery", async () => { + const target = await project(); + const owner = new PluginLoader({ pluginStore: target.pluginStore as any, taskStore: target.taskStore as any }); + await owner.loadAllPlugins(); + const runner = new PluginRunner({ pluginLoader: owner, pluginStore: target.pluginStore as any, taskStore: target.taskStore as any, rootDir: target.root }); + expect(runner.getPluginSkills()).toHaveLength(1); + expect(runner.getPluginMcpServers()).toHaveLength(1); + const skillNamesBefore = collectPluginSkillNames(runner, target.root).names; + const versionsBefore = { skills: (runner as any).skillsCacheVersion, mcp: (runner as any).mcpServersCacheVersion }; + target.pluginStore.updatePluginState.mockClear(); + + const host = await project(); + const options = createRuntimePluginMcpProviderOptions({ hostRootDir: host.root, hostLoader: new PluginLoader({ pluginStore: host.pluginStore as any, taskStore: host.taskStore as any }), PluginLoaderClass: PluginLoader }); + const entries = await createProjectScopedPluginMcpProvider(options).get(target.taskStore); + + expect(entries.map((entry) => entry.server.name)).toEqual(["fixture-mcp"]); + expect(owner.isPluginLoaded("engine-fixture")).toBe(true); + expect(runner.getPluginSkills()).toHaveLength(1); + expect(runner.getPluginMcpServers()).toHaveLength(1); + expect(collectPluginSkillNames(runner, target.root).names).toEqual(skillNamesBefore); + expect((runner as any).skillsCacheVersion).toBe(versionsBefore.skills); + expect((runner as any).mcpServersCacheVersion).toBe(versionsBefore.mcp); + expect(target.pluginStore.updatePluginState).not.toHaveBeenCalled(); + expect([...((PluginLoader as any).processPluginLifecycles as Map).keys()].some((key) => key.startsWith(`${target.root}\0`))).toBe(true); + await owner.stopAllPlugins(); + }); + + it("constructs isolated non-persisting loaders through the runtime seam", () => { + const scopedStore = { getPluginStore: () => ({}) }; + expect(createDiscoveryPluginLoaderOptions(scopedStore)).toMatchObject({ lifecycleScope: "isolated", persistRuntimeState: false }); + let captured: unknown; + class Loader { constructor(options: unknown) { captured = options; } getPluginMcpServers() { return []; } } + createRuntimePluginMcpProviderOptions({ hostRootDir: "/host", hostLoader: new Loader() as any, PluginLoaderClass: Loader as any }).createScopedLoader(scopedStore); + expect(captured).toMatchObject({ lifecycleScope: "isolated", persistRuntimeState: false }); + }); +}); diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 3a85a4a208..d5d225695e 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -9,6 +9,7 @@ import type { AgentHeartbeatRun, PluginStore, PluginLoader, + PluginLoaderOptions, MessageStore, RoutineStore, GithubIssueAction, @@ -63,6 +64,39 @@ import { seedPreReleasePlanReviewContinuation } from "../plan-review-continuatio const yieldEventLoop = (): Promise => new Promise((resolve) => setImmediateCb(resolve)); +/** + * FNXC:PluginMcpServers 2026-07-23-12:00: + * FN-8596 keeps cross-root MCP discovery private to its throwaway loader. This + * exported production seam lets regression tests prove runtime construction + * cannot reintroduce shared lifecycle registration or state persistence. + */ +export function createDiscoveryPluginLoaderOptions( + scopedStore: { getPluginStore(): unknown }, +): PluginLoaderOptions { + return { + pluginStore: scopedStore.getPluginStore() as PluginStore, + taskStore: scopedStore as TaskStore, + lifecycleScope: "isolated", + persistRuntimeState: false, + }; +} + +export function createRuntimePluginMcpProviderOptions(input: { + hostRootDir: string; + hostLoader: Pick; + PluginLoaderClass: new (options: PluginLoaderOptions) => PluginLoader; +}): { + hostRootDir: string; + hostLoader: Pick; + createScopedLoader: (store: { getPluginStore(): unknown }) => PluginLoader; +} { + return { + hostRootDir: input.hostRootDir, + hostLoader: input.hostLoader, + createScopedLoader: (scopedStore) => new input.PluginLoaderClass(createDiscoveryPluginLoaderOptions(scopedStore)), + }; +} + export const CLI_AGENT_AWAITING_INPUT_EVENT = "cli-agent-awaiting-input" as const; const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:"; @@ -482,14 +516,13 @@ export class InProcessRuntime * resolveMcpServersForStore consumes this filtered seam across every AI * lane; it never sees PluginRunner's raw contribution list. */ - const projectScopedPluginMcpProvider = createProjectScopedPluginMcpProvider({ - hostRootDir: this.config.workingDirectory, - hostLoader: this.pluginLoader, - createScopedLoader: (scopedStore) => new PluginLoaderClass({ - pluginStore: scopedStore.getPluginStore() as PluginStore, - taskStore: scopedStore as TaskStore, + const projectScopedPluginMcpProvider = createProjectScopedPluginMcpProvider( + createRuntimePluginMcpProviderOptions({ + hostRootDir: this.config.workingDirectory, + hostLoader: this.pluginLoader, + PluginLoaderClass, }), - }); + ); (this.taskStore as TaskStore & { getProjectScopedPluginMcpServers?: () => Promise> }).getProjectScopedPluginMcpServers = () => projectScopedPluginMcpProvider.get(this.taskStore); /* * FNXC:PluginMcpServers 2026-07-22-15:35: