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:
gsxdsm
2026-07-26 08:35:04 -07:00
parent d47d44c669
commit af897d9e3c
9 changed files with 364 additions and 15 deletions

View 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.

View File

@@ -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(); });
});

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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();
});
});

View File

@@ -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);
} }

View File

@@ -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 });
});
});

View File

@@ -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: