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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8596-plugin-discovery-isolation.md
Normal file
7
.changeset/fn-8596-plugin-discovery-isolation.md
Normal file
@@ -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.
|
||||||
@@ -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(); });
|
||||||
|
});
|
||||||
@@ -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<ReturnType<typeof createProject>>) {
|
||||||
|
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<string, unknown>).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -161,6 +161,13 @@ export interface PluginLoaderOptions {
|
|||||||
npmPrefix?: string;
|
npmPrefix?: string;
|
||||||
/** Persist started/stopped/error runtime state transitions (default true). */
|
/** Persist started/stopped/error runtime state transitions (default true). */
|
||||||
persistRuntimeState?: boolean;
|
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"],
|
state: PluginInstallation["state"],
|
||||||
error?: string,
|
error?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.options.persistRuntimeState === false) return;
|
if (this.options.persistRuntimeState === false || this.options.lifecycleScope === "isolated") return;
|
||||||
await this.options.pluginStore.updatePluginState(pluginId, state, error);
|
await this.options.pluginStore.updatePluginState(pluginId, state, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,8 +371,12 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
return this.plugins.get(pluginId)!;
|
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);
|
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 lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath);
|
||||||
const existingLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
const existingLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
||||||
if (existingLifecycle) {
|
if (existingLifecycle) {
|
||||||
@@ -643,6 +654,14 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
): Promise<FusionPlugin> {
|
): Promise<FusionPlugin> {
|
||||||
const installation = await this.options.pluginStore.getPlugin(pluginId);
|
const installation = await this.options.pluginStore.getPlugin(pluginId);
|
||||||
const pluginPath = this.resolvePluginPath(installation.path);
|
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 lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath);
|
||||||
const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
||||||
const precedingLifecycle = processLifecycle?.promise;
|
const precedingLifecycle = processLifecycle?.promise;
|
||||||
@@ -1023,6 +1042,10 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pluginPath = this.resolvePluginPath(installation.path);
|
const pluginPath = this.resolvePluginPath(installation.path);
|
||||||
|
if (this.options.lifecycleScope === "isolated") {
|
||||||
|
await this.stopPluginFresh(pluginId, pluginPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath);
|
const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath);
|
||||||
const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
||||||
if (!processLifecycle) {
|
if (!processLifecycle) {
|
||||||
@@ -1036,6 +1059,13 @@ export class PluginLoader extends EventEmitter<{
|
|||||||
} catch {
|
} catch {
|
||||||
// A rejected load has already cleaned its local state.
|
// 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);
|
await processLifecycle.owner.stopPluginFresh(pluginId, pluginPath);
|
||||||
for (const loader of processLifecycle.participants) {
|
for (const loader of processLifecycle.participants) {
|
||||||
if (loader === processLifecycle.owner) continue;
|
if (loader === processLifecycle.owner) continue;
|
||||||
|
|||||||
@@ -52,8 +52,14 @@ export function createProjectScopedPluginMcpProvider(options: ProjectScopedPlugi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledIds = new Set(enabled.map((plugin) => plugin.id));
|
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
|
// 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) {
|
if (root === normalizedHostRoot) {
|
||||||
const entries = options.hostLoader.getPluginMcpServers().filter((entry) => enabledIds.has(entry.pluginId));
|
const entries = options.hostLoader.getPluginMcpServers().filter((entry) => enabledIds.has(entry.pluginId));
|
||||||
cache.set(root, { enabledKey, entries });
|
cache.set(root, { enabledKey, entries });
|
||||||
|
|||||||
@@ -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<string, unknown>).keys()].some((key) => key.startsWith(`${target.root}\0`))).toBe(true);
|
||||||
|
await owner.stopAllPlugins();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createProjectScopedPluginMcpProvider,
|
createProjectScopedPluginMcpProvider,
|
||||||
PluginLoader,
|
PluginLoader,
|
||||||
type PluginStore,
|
type PluginStore,
|
||||||
|
type PluginLoaderOptions,
|
||||||
type TaskStore,
|
type TaskStore,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { ServerOptions } from "../server.js";
|
import type { ServerOptions } from "../server.js";
|
||||||
@@ -32,6 +33,38 @@ function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server er
|
|||||||
throw internalError(fallbackMessage);
|
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<TaskStore, "getPluginStore">,
|
||||||
|
): 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<typeof createProjectScopedPluginMcpProvider> {
|
||||||
|
return createProjectScopedPluginMcpProvider({
|
||||||
|
hostRootDir: input.hostRootDir,
|
||||||
|
hostLoader: input.hostLoader,
|
||||||
|
createScopedLoader: (otherStore) => new PluginLoader(createDiscoveryPluginLoaderOptions(otherStore as TaskStore)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function classifyRemoteRouteError(error: unknown): RemoteRouteErrorClassification {
|
export function classifyRemoteRouteError(error: unknown): RemoteRouteErrorClassification {
|
||||||
const fallbackMessage = String(error);
|
const fallbackMessage = String(error);
|
||||||
|
|
||||||
@@ -379,13 +412,9 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
|||||||
|
|
||||||
let provider = projectMcpProviders.get(loader);
|
let provider = projectMcpProviders.get(loader);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
provider = createProjectScopedPluginMcpProvider({
|
provider = createDashboardProjectScopedPluginMcpProvider({
|
||||||
hostRootDir: context.store.getRootDir(),
|
hostRootDir: context.store.getRootDir(),
|
||||||
hostLoader: loader,
|
hostLoader: loader,
|
||||||
createScopedLoader: (otherStore) => new PluginLoader({
|
|
||||||
pluginStore: otherStore.getPluginStore() as PluginStore,
|
|
||||||
taskStore: otherStore as TaskStore,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
projectMcpProviders.set(loader, provider);
|
projectMcpProviders.set(loader, provider);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>).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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
AgentHeartbeatRun,
|
AgentHeartbeatRun,
|
||||||
PluginStore,
|
PluginStore,
|
||||||
PluginLoader,
|
PluginLoader,
|
||||||
|
PluginLoaderOptions,
|
||||||
MessageStore,
|
MessageStore,
|
||||||
RoutineStore,
|
RoutineStore,
|
||||||
GithubIssueAction,
|
GithubIssueAction,
|
||||||
@@ -63,6 +64,39 @@ import { seedPreReleasePlanReviewContinuation } from "../plan-review-continuatio
|
|||||||
|
|
||||||
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
|
const yieldEventLoop = (): Promise<void> => 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<PluginLoader, "getPluginMcpServers">;
|
||||||
|
PluginLoaderClass: new (options: PluginLoaderOptions) => PluginLoader;
|
||||||
|
}): {
|
||||||
|
hostRootDir: string;
|
||||||
|
hostLoader: Pick<PluginLoader, "getPluginMcpServers">;
|
||||||
|
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;
|
export const CLI_AGENT_AWAITING_INPUT_EVENT = "cli-agent-awaiting-input" as const;
|
||||||
const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
|
const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
|
||||||
|
|
||||||
@@ -482,14 +516,13 @@ export class InProcessRuntime
|
|||||||
* resolveMcpServersForStore consumes this filtered seam across every AI
|
* resolveMcpServersForStore consumes this filtered seam across every AI
|
||||||
* lane; it never sees PluginRunner's raw contribution list.
|
* lane; it never sees PluginRunner's raw contribution list.
|
||||||
*/
|
*/
|
||||||
const projectScopedPluginMcpProvider = createProjectScopedPluginMcpProvider({
|
const projectScopedPluginMcpProvider = createProjectScopedPluginMcpProvider(
|
||||||
hostRootDir: this.config.workingDirectory,
|
createRuntimePluginMcpProviderOptions({
|
||||||
hostLoader: this.pluginLoader,
|
hostRootDir: this.config.workingDirectory,
|
||||||
createScopedLoader: (scopedStore) => new PluginLoaderClass({
|
hostLoader: this.pluginLoader,
|
||||||
pluginStore: scopedStore.getPluginStore() as PluginStore,
|
PluginLoaderClass,
|
||||||
taskStore: scopedStore as TaskStore,
|
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
(this.taskStore as TaskStore & { getProjectScopedPluginMcpServers?: () => Promise<ReturnType<PluginRunner["getPluginMcpServers"]>> }).getProjectScopedPluginMcpServers = () => projectScopedPluginMcpProvider.get(this.taskStore);
|
(this.taskStore as TaskStore & { getProjectScopedPluginMcpServers?: () => Promise<ReturnType<PluginRunner["getPluginMcpServers"]>> }).getProjectScopedPluginMcpServers = () => projectScopedPluginMcpProvider.get(this.taskStore);
|
||||||
/*
|
/*
|
||||||
* FNXC:PluginMcpServers 2026-07-22-15:35:
|
* FNXC:PluginMcpServers 2026-07-22-15:35:
|
||||||
|
|||||||
Reference in New Issue
Block a user