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;
|
||||
/** 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<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);
|
||||
}
|
||||
|
||||
@@ -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<FusionPlugin> {
|
||||
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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
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<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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
PluginStore,
|
||||
PluginLoader,
|
||||
PluginLoaderOptions,
|
||||
MessageStore,
|
||||
RoutineStore,
|
||||
GithubIssueAction,
|
||||
@@ -63,6 +64,39 @@ import { seedPreReleasePlanReviewContinuation } from "../plan-review-continuatio
|
||||
|
||||
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;
|
||||
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<ReturnType<PluginRunner["getPluginMcpServers"]>> }).getProjectScopedPluginMcpServers = () => projectScopedPluginMcpProvider.get(this.taskStore);
|
||||
/*
|
||||
* FNXC:PluginMcpServers 2026-07-22-15:35:
|
||||
|
||||
Reference in New Issue
Block a user