From aa15e79bc88ba33e460ed6f1b267523f394c524a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 19:23:35 +0000 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20agent-plugin?= =?UTF-8?q?s=20experiment=20flag=20+=20plugin=20discovery=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0+1 of Agent Plugins (agent-plugins.org 1.0.0) support: - New 'agent-plugins' experiment (default off, shown in Settings) - src/node/services/agentPlugins/: plugin.json manifest validation (§5) with distinct unsupported-version vs invalid-manifest rejection, and container discovery (§4/§6) with realpath containment and per-plugin/ per-component failure isolation (§11.3) --- src/common/constants/experiments.ts | 9 + .../services/agentPlugins/discovery.test.ts | 263 +++++++++++++++++ src/node/services/agentPlugins/discovery.ts | 278 ++++++++++++++++++ .../services/agentPlugins/manifest.test.ts | 180 ++++++++++++ src/node/services/agentPlugins/manifest.ts | 204 +++++++++++++ 5 files changed, 934 insertions(+) create mode 100644 src/node/services/agentPlugins/discovery.test.ts create mode 100644 src/node/services/agentPlugins/discovery.ts create mode 100644 src/node/services/agentPlugins/manifest.test.ts create mode 100644 src/node/services/agentPlugins/manifest.ts diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index 4c81c2de53..21344129bb 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -22,6 +22,7 @@ export const EXPERIMENT_IDS = { MEMORY_CONSOLIDATION: "memory-consolidation", TOOL_SEARCH: "tool-search", CLAUDE_SKILLS_COMPAT: "claude-skills-compat", + AGENT_PLUGINS: "agent-plugins", SKILL_DYNAMIC_CONTEXT: "skill-dynamic-context", TIMELINE: "timeline", } as const; @@ -177,6 +178,14 @@ export const EXPERIMENTS: Record = { enabledByDefault: false, showInSettings: true, }, + [EXPERIMENT_IDS.AGENT_PLUGINS]: { + id: EXPERIMENT_IDS.AGENT_PLUGINS, + name: "Agent Plugins", + description: + "Discover Agent Plugins (agent-plugins.org 1.0.0) from .mux/plugins, .agents/plugins, ~/.mux/plugins, and ~/.agents/plugins: plugin skills join skill discovery and plugin MCP servers appear disabled by default", + enabledByDefault: false, + showInSettings: true, + }, [EXPERIMENT_IDS.SKILL_DYNAMIC_CONTEXT]: { id: EXPERIMENT_IDS.SKILL_DYNAMIC_CONTEXT, name: "Skill dynamic context injection", diff --git a/src/node/services/agentPlugins/discovery.test.ts b/src/node/services/agentPlugins/discovery.test.ts new file mode 100644 index 0000000000..d3add81381 --- /dev/null +++ b/src/node/services/agentPlugins/discovery.test.ts @@ -0,0 +1,263 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { DisposableTempDir } from "@/node/services/tempDir"; +import { discoverAgentPlugins } from "./discovery"; +import { AGENT_PLUGIN_SCHEMA_ID_1_0_0 } from "./manifest"; + +async function writePlugin( + containerPath: string, + dirName: string, + options?: { + manifest?: unknown; + rawManifest?: string; + skills?: string[]; + mcpJson?: string; + } +): Promise { + const pluginDir = path.join(containerPath, dirName); + await fs.mkdir(pluginDir, { recursive: true }); + + const manifest = options?.manifest ?? { + $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: dirName, + }; + await fs.writeFile( + path.join(pluginDir, "plugin.json"), + options?.rawManifest ?? JSON.stringify(manifest), + "utf8" + ); + + for (const skillName of options?.skills ?? []) { + const skillDir = path.join(pluginDir, "skills", skillName); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: ${skillName}\ndescription: Test skill\n---\nBody\n`, + "utf8" + ); + } + + if (options?.mcpJson !== undefined) { + await fs.writeFile(path.join(pluginDir, "mcp.json"), options.mcpJson, "utf8"); + } + + return pluginDir; +} + +describe("discoverAgentPlugins", () => { + test("discovers a valid plugin with skills and mcp.json component paths", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await writePlugin(container, "hello-plugin", { skills: ["greet"], mcpJson: "{}" }); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toHaveLength(1); + const plugin = result.plugins[0]; + expect(plugin.name).toBe("hello-plugin"); + expect(plugin.scope).toBe("global"); + expect(plugin.rootPath).toBe(await fs.realpath(path.join(container, "hello-plugin"))); + expect(plugin.skillsDir).toBe(path.join(plugin.rootPath, "skills")); + expect(plugin.mcpConfigPath).toBe(path.join(plugin.rootPath, "mcp.json")); + expect(result.diagnostics).toEqual([]); + }); + + test("discovers a manifest-only plugin without components", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await writePlugin(container, "bare-plugin"); + + const result = await discoverAgentPlugins([{ path: container, scope: "project" }]); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].skillsDir).toBeUndefined(); + expect(result.plugins[0].mcpConfigPath).toBeUndefined(); + expect(result.diagnostics).toEqual([]); + }); + + test("silently skips entries without plugin.json (e.g. Codex marketplace dirs)", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await fs.mkdir(path.join(container, "not-a-plugin"), { recursive: true }); + await fs.writeFile(path.join(container, "not-a-plugin", "marketplace.json"), "{}", "utf8"); + // Loose file directly in the container is also skipped. + await fs.writeFile(path.join(container, "marketplace.json"), "{}", "utf8"); + await writePlugin(container, "real-plugin"); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins.map((p) => p.name)).toEqual(["real-plugin"]); + expect(result.diagnostics).toEqual([]); + }); + + test("a broken sibling plugin never affects a valid one", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await writePlugin(container, "a-broken", { rawManifest: "{ not json" }); + await writePlugin(container, "b-invalid", { + manifest: { $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "Bad--Name" }, + }); + await writePlugin(container, "c-valid"); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins.map((p) => p.name)).toEqual(["c-valid"]); + expect(result.diagnostics).toHaveLength(2); + expect(result.diagnostics.every((d) => d.severity === "error")).toBe(true); + }); + + test("reports unsupported $schema distinctly from invalid manifests", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await writePlugin(container, "future-plugin", { + manifest: { + $schema: "https://agent-plugins.org/schemas/9.0.0/plugin.schema.json", + name: "future-plugin", + }, + }); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("Unsupported Agent Plugins version"); + }); + + test("loads plugins with unknown top-level manifest fields and reports a warning", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await writePlugin(container, "extra-plugin", { + manifest: { + $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: "extra-plugin", + commands: ["x"], + }, + }); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins.map((p) => p.name)).toEqual(["extra-plugin"]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].severity).toBe("warning"); + expect(result.diagnostics[0].message).toContain("commands"); + }); + + test("rejects a plugin whose plugin.json symlink escapes the plugin root", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const outside = path.join(tmp.path, "outside.json"); + await fs.writeFile( + outside, + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "escaper" }), + "utf8" + ); + const pluginDir = path.join(container, "escaper"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.symlink(outside, path.join(pluginDir, "plugin.json")); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("outside the plugin root"); + }); + + test("skills symlink escaping the root invalidates only the skills component", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const outsideSkills = path.join(tmp.path, "outside-skills"); + await fs.mkdir(outsideSkills, { recursive: true }); + const pluginDir = await writePlugin(container, "escaping-skills", { mcpJson: "{}" }); + await fs.symlink(outsideSkills, path.join(pluginDir, "skills")); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].skillsDir).toBeUndefined(); + // MCP component is unaffected (§6.2 narrowest-scope invalidation). + expect(result.plugins[0].mcpConfigPath).toBeDefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("skills/"); + }); + + test("mcp.json of the wrong filesystem kind invalidates only the MCP component", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const pluginDir = await writePlugin(container, "dir-mcp", { skills: ["greet"] }); + await fs.mkdir(path.join(pluginDir, "mcp.json")); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].mcpConfigPath).toBeUndefined(); + expect(result.plugins[0].skillsDir).toBeDefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("mcp.json"); + }); + + test("skills location that is a file invalidates only the skills component", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const pluginDir = await writePlugin(container, "file-skills", { mcpJson: "{}" }); + await fs.writeFile(path.join(pluginDir, "skills"), "not a dir", "utf8"); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].skillsDir).toBeUndefined(); + expect(result.plugins[0].mcpConfigPath).toBeDefined(); + }); + + test("a symlinked plugin directory anchors containment at its realpath", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await fs.mkdir(container, { recursive: true }); + const actual = path.join(tmp.path, "elsewhere", "linked-plugin"); + await fs.mkdir(actual, { recursive: true }); + await fs.writeFile( + path.join(actual, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "linked-plugin" }), + "utf8" + ); + await fs.symlink(actual, path.join(container, "linked-plugin")); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].rootPath).toBe(await fs.realpath(actual)); + expect(result.diagnostics).toEqual([]); + }); + + test("missing containers yield no plugins and no diagnostics", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const result = await discoverAgentPlugins([ + { path: path.join(tmp.path, "does-not-exist"), scope: "global" }, + ]); + expect(result.plugins).toEqual([]); + expect(result.diagnostics).toEqual([]); + }); + + test("throws on relative container paths", async () => { + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + discoverAgentPlugins([{ path: "relative/plugins", scope: "global" }]) + ).rejects.toThrow("must be absolute"); + }); + + test("dedupes repeated container paths and orders plugins alphabetically per container", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await writePlugin(container, "zeta"); + await writePlugin(container, "alpha"); + + const result = await discoverAgentPlugins([ + { path: container, scope: "global" }, + { path: container, scope: "global" }, + ]); + + expect(result.plugins.map((p) => p.name)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts new file mode 100644 index 0000000000..df399b0117 --- /dev/null +++ b/src/node/services/agentPlugins/discovery.ts @@ -0,0 +1,278 @@ +import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; + +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "@/node/services/log"; +import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { + isValidAgentPluginName, + validatePluginManifest, + type AgentPluginManifest, +} from "./manifest"; + +/** + * Agent Plugins 1.0.0 discovery (§4, §6). + * + * A plugin is an immediate child directory of a configured container directory + * that holds a regular `plugin.json` file. Entries without a `plugin.json` are + * skipped silently (containers may hold unrelated files, e.g. Codex drops + * `marketplace.json` into `~/.agents/plugins`). Failure isolation is per §11.3: + * one broken plugin never affects sibling plugins, and one broken component + * (skills/ or mcp.json) never affects the plugin's other component. + * + * Local host filesystem only (v1): plugin containers are host paths, so + * discovery uses node:fs directly rather than a Runtime. + */ + +export type AgentPluginScope = "project" | "global"; + +export interface AgentPluginContainer { + /** Absolute host path of the container directory (e.g. `/.mux/plugins`). */ + path: string; + scope: AgentPluginScope; +} + +export interface AgentPluginInfo { + name: string; + scope: AgentPluginScope; + /** Canonical (realpath) plugin root directory. */ + rootPath: string; + manifest: AgentPluginManifest; + /** Canonical `skills/` directory; present only when it exists, is a directory, and stays inside the root (§6.2). */ + skillsDir?: string; + /** Canonical `mcp.json` path; present only when it exists, is a regular file, and stays inside the root (§6.2). */ + mcpConfigPath?: string; +} + +export interface AgentPluginDiagnostic { + /** Path of the plugin directory (or offending component path) the diagnostic refers to. */ + path: string; + scope: AgentPluginScope; + severity: "warning" | "error"; + message: string; +} + +export interface DiscoverAgentPluginsResult { + plugins: AgentPluginInfo[]; + diagnostics: AgentPluginDiagnostic[]; +} + +async function listChildDirectories(containerPath: string): Promise { + try { + const entries = await fsPromises.readdir(containerPath, { withFileTypes: true }); + // Include symlinks: a symlinked plugin directory is fine because its + // realpath becomes the plugin root for all containment checks. + return entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + } catch { + // Missing/unreadable containers are not errors (§6.1). + return []; + } +} + +/** + * Resolve a component location inside the plugin root (§6.2): + * - missing → absent (not an error) + * - wrong filesystem kind or realpath escape → invalid (diagnostic), but only + * for that component + * - otherwise → canonical path + */ +async function resolveComponentPath(args: { + rootReal: string; + relativePath: string; + expectKind: "file" | "directory"; + componentLabel: string; + scope: AgentPluginScope; + diagnostics: AgentPluginDiagnostic[]; +}): Promise { + const candidate = path.join(args.rootReal, args.relativePath); + + let canonical: string; + try { + canonical = await ensurePathContained(args.rootReal, candidate); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return undefined; + } + + const message = `${args.componentLabel} resolves outside the plugin root; ignoring this component: ${getErrorMessage(error)}`; + log.warn(`Agent plugin ${args.rootReal}: ${message}`); + args.diagnostics.push({ + path: candidate, + scope: args.scope, + severity: "error", + message, + }); + return undefined; + } + + let stat; + try { + stat = await fsPromises.stat(canonical); + } catch { + return undefined; + } + + const kindOk = args.expectKind === "file" ? stat.isFile() : stat.isDirectory(); + if (!kindOk) { + const message = `${args.componentLabel} must be a ${args.expectKind === "file" ? "regular file" : "directory"}; ignoring this component`; + log.warn(`Agent plugin ${args.rootReal}: ${message}`); + args.diagnostics.push({ + path: candidate, + scope: args.scope, + severity: "error", + message, + }); + return undefined; + } + + return canonical; +} + +async function discoverPluginAt(args: { + pluginDir: string; + scope: AgentPluginScope; + diagnostics: AgentPluginDiagnostic[]; +}): Promise { + const { pluginDir, scope, diagnostics } = args; + + const pushError = (targetPath: string, message: string): void => { + log.warn(`Agent plugin ${pluginDir}: ${message}`); + diagnostics.push({ path: targetPath, scope, severity: "error", message }); + }; + + // The canonical plugin root anchors every §4.1 containment check. + let rootReal: string; + try { + rootReal = await fsPromises.realpath(pluginDir); + } catch { + // Broken symlink / vanished entry: not a plugin. + return null; + } + + const manifestPath = path.join(rootReal, "plugin.json"); + let manifestReal: string; + try { + manifestReal = await ensurePathContained(rootReal, manifestPath); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + // No plugin.json → not a plugin (silent skip, e.g. Codex marketplace.json entries). + return null; + } + + pushError( + manifestPath, + `plugin.json resolves outside the plugin root: ${getErrorMessage(error)}` + ); + return null; + } + + let manifestStat; + try { + manifestStat = await fsPromises.stat(manifestReal); + } catch { + return null; + } + if (!manifestStat.isFile()) { + // plugin.json of the wrong filesystem kind: not a plugin candidate. + return null; + } + + let rawManifest: unknown; + try { + rawManifest = JSON.parse(await fsPromises.readFile(manifestReal, "utf8")) as unknown; + } catch (error) { + pushError(manifestPath, `Failed to read plugin.json: ${getErrorMessage(error)}`); + return null; + } + + const validation = validatePluginManifest(rawManifest); + if (!validation.ok) { + const label = + validation.reason === "unsupported-version" + ? "Unsupported Agent Plugins version" + : "Invalid plugin manifest"; + pushError(manifestPath, `${label}: ${validation.errors.join("; ")}`); + return null; + } + + for (const warning of validation.warnings) { + log.debug(`Agent plugin ${pluginDir}: ${warning}`); + diagnostics.push({ path: manifestPath, scope, severity: "warning", message: warning }); + } + + // Defensive: the validator guarantees a spec-valid name. + if (!isValidAgentPluginName(validation.manifest.name)) { + throw new Error( + `discoverPluginAt: validated manifest has spec-invalid name '${validation.manifest.name}'` + ); + } + + const skillsDir = await resolveComponentPath({ + rootReal, + relativePath: "skills", + expectKind: "directory", + componentLabel: "skills/", + scope, + diagnostics, + }); + + const mcpConfigPath = await resolveComponentPath({ + rootReal, + relativePath: "mcp.json", + expectKind: "file", + componentLabel: "mcp.json", + scope, + diagnostics, + }); + + return { + name: validation.manifest.name, + scope, + rootPath: rootReal, + manifest: validation.manifest, + ...(skillsDir !== undefined ? { skillsDir } : {}), + ...(mcpConfigPath !== undefined ? { mcpConfigPath } : {}), + }; +} + +/** + * Discover Agent Plugins in the given container directories. + * + * Containers are scanned in the given order; plugins within a container are + * ordered alphabetically for determinism. Duplicate plugin names are NOT + * deduplicated here — downstream consumers key on plugin root path (MCP) or + * dedupe by skill name at their own precedence rules (skills). + */ +export async function discoverAgentPlugins( + containers: AgentPluginContainer[] +): Promise { + const plugins: AgentPluginInfo[] = []; + const diagnostics: AgentPluginDiagnostic[] = []; + + const seenContainers = new Set(); + for (const container of containers) { + if (!path.isAbsolute(container.path)) { + throw new Error(`discoverAgentPlugins: container path must be absolute: ${container.path}`); + } + if (seenContainers.has(container.path)) { + continue; + } + seenContainers.add(container.path); + + for (const entryName of await listChildDirectories(container.path)) { + const plugin = await discoverPluginAt({ + pluginDir: path.join(container.path, entryName), + scope: container.scope, + diagnostics, + }); + if (plugin) { + plugins.push(plugin); + } + } + } + + return { plugins, diagnostics }; +} diff --git a/src/node/services/agentPlugins/manifest.test.ts b/src/node/services/agentPlugins/manifest.test.ts new file mode 100644 index 0000000000..3d995f5105 --- /dev/null +++ b/src/node/services/agentPlugins/manifest.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; + +import { AGENT_PLUGIN_SCHEMA_ID_1_0_0, validatePluginManifest } from "./manifest"; + +function minimalManifest(overrides?: Record): Record { + return { + $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: "hello-plugin", + ...overrides, + }; +} + +describe("validatePluginManifest", () => { + test("accepts a minimal valid manifest", () => { + const result = validatePluginManifest(minimalManifest()); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.manifest.name).toBe("hello-plugin"); + expect(result.manifest.schemaId).toBe(AGENT_PLUGIN_SCHEMA_ID_1_0_0); + expect(result.warnings).toEqual([]); + }); + + test("accepts a full manifest and carries fields through", () => { + const result = validatePluginManifest( + minimalManifest({ + version: "1.2.3", + description: "A test plugin", + author: { name: "Ada", email: "ada@example.com", url: "https://example.com" }, + homepage: "https://example.com", + repository: "https://github.com/example/hello-plugin", + license: "MIT", + keywords: ["testing", "example"], + extensions: { "com.example.tool": { anything: [1, 2, 3] } }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.manifest).toEqual({ + schemaId: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: "hello-plugin", + version: "1.2.3", + description: "A test plugin", + author: { name: "Ada", email: "ada@example.com", url: "https://example.com" }, + homepage: "https://example.com", + repository: "https://github.com/example/hello-plugin", + license: "MIT", + keywords: ["testing", "example"], + }); + expect(result.warnings).toEqual([]); + }); + + test("accepts names with dots and hyphens", () => { + for (const name of ["a", "a.b-c", "plugin.v2", "0-day.9"]) { + const result = validatePluginManifest(minimalManifest({ name })); + expect(result.ok).toBe(true); + } + }); + + test("unknown top-level fields load with a warning and are ignored", () => { + const result = validatePluginManifest(minimalManifest({ hooks: { pre: "x" } })); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("hooks"); + expect(result.manifest).not.toHaveProperty("hooks"); + }); + + test("non-object extensions loads with a warning and is ignored", () => { + for (const extensions of ["nope", 5, [1, 2], null]) { + const result = validatePluginManifest(minimalManifest({ extensions })); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.warnings.some((w) => w.includes("extensions"))).toBe(true); + } + }); + + test("extension namespace member contents are never validated", () => { + // The canonical JSON Schema types namespace members as objects, but the + // normative text says clients never validate namespace contents (§8.1). + const result = validatePluginManifest( + minimalManifest({ extensions: { "com.example.weird": "not-an-object" } }) + ); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.warnings).toEqual([]); + }); + + test("rejects missing or invalid name as invalid-manifest", () => { + const badNames: unknown[] = [ + undefined, + 42, + "", + "-starts-with-hyphen", + "ends-with-hyphen-", + "Has-Uppercase", + "double--hyphen", + "double..dot", + ".starts-with-dot", + "a".repeat(65), + ]; + for (const name of badNames) { + const manifest = minimalManifest(); + if (name === undefined) { + delete manifest.name; + } else { + manifest.name = name; + } + const result = validatePluginManifest(manifest); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.reason).toBe("invalid-manifest"); + } + }); + + test("accepts a 64-char name and rejects a 65-char name", () => { + const okResult = validatePluginManifest(minimalManifest({ name: "a".repeat(64) })); + expect(okResult.ok).toBe(true); + + const tooLong = validatePluginManifest(minimalManifest({ name: "a".repeat(65) })); + expect(tooLong.ok).toBe(false); + }); + + test("rejects unrecognized $schema as unsupported-version", () => { + for (const schema of [ + "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", + "https://example.com/other.schema.json", + "not-a-url", + ]) { + const result = validatePluginManifest(minimalManifest({ $schema: schema })); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.reason).toBe("unsupported-version"); + } + }); + + test("rejects missing or non-string $schema as invalid-manifest", () => { + for (const schema of [undefined, 42, null, ["x"]]) { + const manifest = minimalManifest(); + if (schema === undefined) { + delete manifest.$schema; + } else { + manifest.$schema = schema; + } + const result = validatePluginManifest(manifest); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.reason).toBe("invalid-manifest"); + } + }); + + test("rejects wrong-typed permitted fields as invalid-manifest", () => { + const badFields: Array> = [ + { version: 5 }, + { description: ["x"] }, + { homepage: 1 }, + { repository: {} }, + { license: null }, + { keywords: "not-an-array" }, + { keywords: [1, 2] }, + { author: "string-author" }, + { author: { name: 5 } }, + { author: { name: "x", unknownKey: "y" } }, + ]; + for (const fields of badFields) { + const result = validatePluginManifest(minimalManifest(fields)); + expect(result.ok).toBe(false); + if (result.ok) throw new Error(`expected rejection for ${JSON.stringify(fields)}`); + expect(result.reason).toBe("invalid-manifest"); + } + }); + + test("rejects non-object documents as invalid-manifest", () => { + for (const raw of [null, "string", 42, ["array"], true]) { + const result = validatePluginManifest(raw); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.reason).toBe("invalid-manifest"); + } + }); +}); diff --git a/src/node/services/agentPlugins/manifest.ts b/src/node/services/agentPlugins/manifest.ts new file mode 100644 index 0000000000..a42e09f195 --- /dev/null +++ b/src/node/services/agentPlugins/manifest.ts @@ -0,0 +1,204 @@ +/** + * Agent Plugins 1.0.0 manifest (`plugin.json`) validation. + * + * Implements §5 of the Agent Plugins specification (https://agent-plugins.org, + * spec repo: agentplugins/agent-plugins-spec) against the canonical + * plugin.schema.json: + * - Closed top-level schema, but unknown top-level fields are NON-fatal: + * report + ignore (§5.3). + * - `$schema` (required) dispatches the spec version; anything other than the + * recognized 1.0.0 const is rejected with the distinct "unsupported-version" + * reason so clients can report it separately from malformed manifests. + * - `name` (required): 1-64 chars, lowercase alphanumeric/dot/hyphen, + * alphanumeric start/end, no `--` or `..` runs. + * - Any other schema violation (wrong-typed permitted field) is fatal. + * - `extensions`: a non-object value is NON-fatal (report + ignore); namespace + * member contents are never validated (§8.1/§11.1) — Mux implements no + * extension namespace, so all payloads are treated as opaque. + */ + +/** Canonical `$schema` const for Agent Plugins 1.0.0 plugin manifests. */ +export const AGENT_PLUGIN_SCHEMA_ID_1_0_0 = + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; + +// Canonical name pattern from plugin.schema.json (JS supports the lookahead). +const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +const PLUGIN_NAME_MAX_LENGTH = 64; + +/** True when `name` satisfies the §5 plugin-name grammar. */ +export function isValidAgentPluginName(name: string): boolean { + return name.length <= PLUGIN_NAME_MAX_LENGTH && PLUGIN_NAME_PATTERN.test(name); +} + +export interface AgentPluginAuthor { + name?: string; + email?: string; + url?: string; +} + +export interface AgentPluginManifest { + /** The accepted `$schema` value (identifies the spec version). */ + schemaId: string; + name: string; + version?: string; + description?: string; + author?: AgentPluginAuthor; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; +} + +export type PluginManifestValidation = + | { ok: true; manifest: AgentPluginManifest; warnings: string[] } + | { ok: false; reason: "unsupported-version" | "invalid-manifest"; errors: string[] }; + +const PERMITTED_TOP_LEVEL_KEYS = new Set([ + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +]); + +const OPTIONAL_STRING_FIELDS = [ + "version", + "description", + "homepage", + "repository", + "license", +] as const; + +const AUTHOR_KEYS = ["name", "email", "url"] as const; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function validatePluginManifest(raw: unknown): PluginManifestValidation { + if (!isPlainObject(raw)) { + return { + ok: false, + reason: "invalid-manifest", + errors: ["plugin.json must be a JSON object"], + }; + } + + const warnings: string[] = []; + const errors: string[] = []; + + // §5.3: unknown top-level fields are ignored with a report, not fatal. + for (const key of Object.keys(raw)) { + if (!PERMITTED_TOP_LEVEL_KEYS.has(key)) { + warnings.push(`Unknown top-level field '${key}' ignored`); + } + } + + const schemaId = raw.$schema; + if (typeof schemaId !== "string") { + return { + ok: false, + reason: "invalid-manifest", + errors: ["'$schema' is required and must be a string"], + }; + } + if (schemaId !== AGENT_PLUGIN_SCHEMA_ID_1_0_0) { + // Distinct reason so callers can report "newer/unknown spec version" separately. + return { + ok: false, + reason: "unsupported-version", + errors: [`Unsupported Agent Plugins '$schema': '${schemaId}'`], + }; + } + + const name = raw.name; + if (typeof name !== "string" || !isValidAgentPluginName(name)) { + errors.push( + "'name' is required and must be 1-64 characters of lowercase [a-z0-9.-], starting/ending alphanumeric, without '--' or '..'" + ); + } + + for (const field of OPTIONAL_STRING_FIELDS) { + const value = raw[field]; + if (value !== undefined && typeof value !== "string") { + errors.push(`'${field}' must be a string`); + } + } + + const keywords = raw.keywords; + if ( + keywords !== undefined && + (!Array.isArray(keywords) || keywords.some((keyword) => typeof keyword !== "string")) + ) { + errors.push("'keywords' must be an array of strings"); + } + + let author: AgentPluginAuthor | undefined; + if (raw.author !== undefined) { + if (!isPlainObject(raw.author)) { + errors.push("'author' must be an object"); + } else { + // Canonical schema closes the author object (additionalProperties: false). + const authorRecord = raw.author; + const authorErrors: string[] = []; + for (const key of Object.keys(authorRecord)) { + if (!AUTHOR_KEYS.includes(key as (typeof AUTHOR_KEYS)[number])) { + authorErrors.push(`'author.${key}' is not a permitted field`); + } + } + for (const key of AUTHOR_KEYS) { + const value = authorRecord[key]; + if (value !== undefined && typeof value !== "string") { + authorErrors.push(`'author.${key}' must be a string`); + } + } + + if (authorErrors.length > 0) { + errors.push(...authorErrors); + } else { + author = { + ...(typeof authorRecord.name === "string" ? { name: authorRecord.name } : {}), + ...(typeof authorRecord.email === "string" ? { email: authorRecord.email } : {}), + ...(typeof authorRecord.url === "string" ? { url: authorRecord.url } : {}), + }; + } + } + } + + // §8.1/§11.1: a non-object `extensions` value is reported and ignored; object + // contents are opaque and never validated (do not enforce the JSON Schema's + // per-namespace member typing here). + if (raw.extensions !== undefined && !isPlainObject(raw.extensions)) { + warnings.push("'extensions' must be an object; ignoring"); + } + + if (errors.length > 0) { + return { ok: false, reason: "invalid-manifest", errors }; + } + + // Narrowing: `name` passed validation above or we'd have returned errors. + if (typeof name !== "string") { + throw new Error("validatePluginManifest: name must be a string after validation"); + } + + const manifest: AgentPluginManifest = { + schemaId, + name, + ...(typeof raw.version === "string" ? { version: raw.version } : {}), + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + ...(author !== undefined ? { author } : {}), + ...(typeof raw.homepage === "string" ? { homepage: raw.homepage } : {}), + ...(typeof raw.repository === "string" ? { repository: raw.repository } : {}), + ...(typeof raw.license === "string" ? { license: raw.license } : {}), + ...(Array.isArray(keywords) + ? { keywords: keywords.filter((k): k is string => typeof k === "string") } + : {}), + }; + + return { ok: true, manifest, warnings }; +} From dc8a1db12b0bcaa680f75773bbf5022717a2e5e5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 19:39:27 +0000 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=A4=96=20feat:=20integrate=20Agent?= =?UTF-8?q?=20Plugins=20skills=20into=20skill=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of Agent Plugins support (flag-gated by agent-plugins experiment): - AgentSkillsRoots gains projectPluginRoots/globalPluginRoots container dirs - Scan candidates expand plugin containers to per-plugin skills/ roots at lowest per-scope precedence, with §4.1 plugin-root containment plus the existing project containment posture for project-scope plugin roots - resolveSkillStorageContext/getDefaultAgentSkillsRoots take includeAgentPlugins (host-local only; remote runtimes never scan plugins) - Flag threaded through agent_skill_list/read/read_file, router, streamContextBuilder, aiService, agentSession; write tools untouched --- src/common/utils/tools/tools.ts | 2 + src/node/orpc/router.ts | 12 + src/node/services/agentSession.ts | 5 + .../agentSkills/agentSkillsService.test.ts | 222 ++++++++++++++++++ .../agentSkills/agentSkillsService.ts | 214 +++++++++++++++-- .../agentSkills/skillStorageContext.test.ts | 51 ++++ .../agentSkills/skillStorageContext.ts | 21 +- src/node/services/aiService.ts | 13 + src/node/services/streamContextBuilder.ts | 4 + .../services/tools/agent_skill_list.test.ts | 114 +++++++++ src/node/services/tools/agent_skill_list.ts | 49 ++++ src/node/services/tools/agent_skill_read.ts | 4 + .../services/tools/agent_skill_read_file.ts | 4 + 13 files changed, 693 insertions(+), 22 deletions(-) diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 785b1b56e5..f17d7582b8 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -285,6 +285,8 @@ export interface ToolConfiguration { toolSearch?: boolean; /** claude-skills-compat: discover skills from .claude/skills and ~/.claude/skills (read-only). */ claudeSkillsCompat?: boolean; + /** agent-plugins: discover Agent Plugins skills from .mux/plugins, .agents/plugins and their global counterparts (read-only). */ + agentPlugins?: boolean; }; /** Available sub-agents for the task tool description (dynamic context) */ availableSubagents?: AgentDefinitionDescriptor[]; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 157820575b..764fa01fb3 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1852,6 +1852,10 @@ export const router = (authToken?: string) => { includeClaudeSkills: context.experimentsService.isExperimentEnabled( EXPERIMENT_IDS.CLAUDE_SKILLS_COMPAT ), + // agent-plugins experiment: surface read-only plugin skills alongside other skills. + includeAgentPlugins: context.experimentsService.isExperimentEnabled( + EXPERIMENT_IDS.AGENT_PLUGINS + ), }); }), listDiagnostics: t @@ -1867,6 +1871,10 @@ export const router = (authToken?: string) => { includeClaudeSkills: context.experimentsService.isExperimentEnabled( EXPERIMENT_IDS.CLAUDE_SKILLS_COMPAT ), + // agent-plugins experiment: surface read-only plugin skills alongside other skills. + includeAgentPlugins: context.experimentsService.isExperimentEnabled( + EXPERIMENT_IDS.AGENT_PLUGINS + ), }); return diagnostics; }), @@ -1883,6 +1891,10 @@ export const router = (authToken?: string) => { includeClaudeSkills: context.experimentsService.isExperimentEnabled( EXPERIMENT_IDS.CLAUDE_SKILLS_COMPAT ), + // agent-plugins experiment: surface read-only plugin skills alongside other skills. + includeAgentPlugins: context.experimentsService.isExperimentEnabled( + EXPERIMENT_IDS.AGENT_PLUGINS + ), }); return result.package; }), diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b68123c8d2..e66bca3b45 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6444,8 +6444,13 @@ export class AgentSession { const includeClaudeSkills = typeof this.aiService.isClaudeSkillsCompatEnabled === "function" && this.aiService.isClaudeSkillsCompatEnabled(); + // agent-plugins experiment: same treatment for plugin-provided skills. + const includeAgentPlugins = + typeof this.aiService.isAgentPluginsEnabled === "function" && + this.aiService.isAgentPluginsEnabled(); resolved = await readAgentSkill(runtime, skillDiscoveryPath, parsedName.data, { includeClaudeSkills, + includeAgentPlugins, }); } catch (error) { if (ref.source === "slash") { diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index 87a081e98d..59f19df671 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -1083,3 +1083,225 @@ describe("agentSkillsService", () => { expect(found!.description).toBe("Symlinked SKILL.md"); }); }); + +// Agent Plugins (agent-plugins experiment): plugin skills join discovery at the +// lowest per-scope precedence. See src/node/services/agentPlugins/ for the +// container/manifest layer. +describe("agentSkillsService agent plugins", () => { + const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; + + async function writePlugin( + containerPath: string, + pluginName: string, + skills: Array<{ name: string; description: string }> + ): Promise { + const pluginDir = path.join(containerPath, pluginName); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "plugin.json"), + JSON.stringify({ $schema: PLUGIN_SCHEMA, name: pluginName }), + "utf-8" + ); + for (const skill of skills) { + await writeSkill(path.join(pluginDir, "skills"), skill.name, skill.description); + } + return pluginDir; + } + + test("getDefaultAgentSkillsRoots includes plugin containers only when includeAgentPlugins is set", () => { + using project = new DisposableTempDir("agent-skills-plugin-roots"); + const runtime = new LocalRuntime(project.path); + + const defaultRoots = getDefaultAgentSkillsRoots(runtime, project.path); + expect(defaultRoots.projectPluginRoots).toBeUndefined(); + expect(defaultRoots.globalPluginRoots).toBeUndefined(); + + const onRoots = getDefaultAgentSkillsRoots(runtime, project.path, { + includeAgentPlugins: true, + }); + expect(onRoots.projectPluginRoots).toEqual([ + path.join(project.path, ".mux", "plugins"), + path.join(project.path, ".agents", "plugins"), + ]); + expect(onRoots.globalPluginRoots).toEqual(["~/.mux/plugins", "~/.agents/plugins"]); + }); + + test("getDefaultAgentSkillsRoots never includes plugin containers for remote runtimes", () => { + using project = new DisposableTempDir("agent-skills-plugin-remote"); + const runtime = new RemotePathMappedRuntime(project.path, "/remote/workspace"); + + const onRoots = getDefaultAgentSkillsRoots(runtime, "/remote/workspace", { + includeAgentPlugins: true, + }); + expect(onRoots.projectPluginRoots).toBeUndefined(); + expect(onRoots.globalPluginRoots).toBeUndefined(); + }); + + test("experiment off: plugin skills stay invisible with default-shaped roots", async () => { + using project = new DisposableTempDir("agent-skills-plugin-off"); + using global = new DisposableTempDir("agent-skills-plugin-off-global"); + + await writePlugin(path.join(project.path, ".mux", "plugins"), "hello-plugin", [ + { name: "plugin-only", description: "from plugin" }, + ]); + await writeSkill(path.join(project.path, ".agents", "skills"), "agents-only", "from agents"); + + const runtime = new LocalRuntime(project.path); + const roots = { + ...getDefaultAgentSkillsRoots(runtime, project.path), + globalRoot: global.path, + universalRoot: "", + }; + + const skills = await discoverAgentSkills(runtime, project.path, { roots }); + + expect(skills.find((s) => s.name === "plugin-only")).toBeUndefined(); + // Sanity: sibling .agents root still discovered, so absence above is plugin-specific. + expect(skills.find((s) => s.name === "agents-only")).toMatchObject({ scope: "project" }); + }); + + test("experiment on: discovers plugin skills at lowest per-scope precedence", async () => { + using project = new DisposableTempDir("agent-skills-plugin-on"); + using global = new DisposableTempDir("agent-skills-plugin-on-global"); + using globalPlugins = new DisposableTempDir("agent-skills-plugin-on-global-plugins"); + + await writePlugin(path.join(project.path, ".mux", "plugins"), "project-plugin", [ + { name: "plugin-only", description: "from project plugin" }, + { name: "shared-mux", description: "from project plugin" }, + ]); + await writeSkill(path.join(project.path, ".mux", "skills"), "shared-mux", "from project mux"); + await writePlugin(globalPlugins.path, "global-plugin", [ + { name: "global-plugin-only", description: "from global plugin" }, + { name: "plugin-only", description: "from global plugin" }, + ]); + + const runtime = new LocalRuntime(project.path); + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + universalRoot: "", + projectPluginRoots: [path.join(project.path, ".mux", "plugins")], + globalPluginRoots: [globalPlugins.path], + }; + + const skills = await discoverAgentSkills(runtime, project.path, { roots }); + + // Project plugin skill loses the name collision to .mux/skills. + expect(skills.find((s) => s.name === "shared-mux")).toMatchObject({ + scope: "project", + description: "from project mux", + }); + // Project plugin beats global plugin for colliding names (scope precedence). + expect(skills.find((s) => s.name === "plugin-only")).toMatchObject({ + scope: "project", + description: "from project plugin", + }); + expect(skills.find((s) => s.name === "global-plugin-only")).toMatchObject({ + scope: "global", + description: "from global plugin", + }); + + // Read path resolves plugin skills with the same roots as discovery. + const pluginOnlyName = SkillNameSchema.parse("plugin-only"); + const resolved = await readAgentSkill(runtime, project.path, pluginOnlyName, { roots }); + expect(resolved.package.scope).toBe("project"); + expect(resolved.package.frontmatter.description).toBe("from project plugin"); + }); + + test("a broken sibling plugin or skill never hides valid plugin skills", async () => { + using project = new DisposableTempDir("agent-skills-plugin-isolation"); + using global = new DisposableTempDir("agent-skills-plugin-isolation-global"); + + const container = path.join(project.path, ".mux", "plugins"); + // Broken sibling plugin: invalid manifest. + const brokenDir = path.join(container, "a-broken"); + await fs.mkdir(brokenDir, { recursive: true }); + await fs.writeFile(path.join(brokenDir, "plugin.json"), "{ not json", "utf-8"); + // Valid plugin with one broken skill (missing frontmatter) and one valid skill. + const pluginDir = await writePlugin(container, "b-valid", [ + { name: "valid-skill", description: "works" }, + ]); + const brokenSkillDir = path.join(pluginDir, "skills", "broken-skill"); + await fs.mkdir(brokenSkillDir, { recursive: true }); + await fs.writeFile(path.join(brokenSkillDir, "SKILL.md"), "no frontmatter", "utf-8"); + + const runtime = new LocalRuntime(project.path); + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + universalRoot: "", + projectPluginRoots: [container], + }; + + const skills = await discoverAgentSkills(runtime, project.path, { roots }); + + expect(skills.find((s) => s.name === "valid-skill")).toMatchObject({ scope: "project" }); + expect(skills.find((s) => s.name === "broken-skill")).toBeUndefined(); + }); + + test("plugin skill SKILL.md symlink escaping the plugin root is skipped", async () => { + using project = new DisposableTempDir("agent-skills-plugin-escape"); + using global = new DisposableTempDir("agent-skills-plugin-escape-global"); + + const container = path.join(project.path, ".mux", "plugins"); + const pluginDir = await writePlugin(container, "escape-plugin", [ + { name: "safe-skill", description: "contained" }, + ]); + // A skill whose SKILL.md symlinks outside the plugin root (but inside the project). + const outside = path.join(project.path, "outside-skill.md"); + await fs.writeFile(outside, "---\nname: sneaky\ndescription: outside\n---\nBody\n", "utf-8"); + const sneakyDir = path.join(pluginDir, "skills", "sneaky"); + await fs.mkdir(sneakyDir, { recursive: true }); + await fs.symlink(outside, path.join(sneakyDir, "SKILL.md")); + + const runtime = new LocalRuntime(project.path); + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + universalRoot: "", + projectPluginRoots: [container], + }; + + const result = await discoverAgentSkillsDiagnostics(runtime, project.path, { + roots, + projectContainmentRoot: project.path, + }); + + expect(result.skills.find((s) => s.name === "safe-skill")).toBeDefined(); + expect(result.skills.find((s) => s.name === "sneaky")).toBeUndefined(); + expect( + result.invalidSkills.some( + (issue) => issue.directoryName === "sneaky" && issue.message.includes("plugin root") + ) + ).toBe(true); + }); + + test("project plugin whose root escapes the project containment is skipped", async () => { + using project = new DisposableTempDir("agent-skills-plugin-root-escape"); + using elsewhere = new DisposableTempDir("agent-skills-plugin-root-escape-target"); + using global = new DisposableTempDir("agent-skills-plugin-root-escape-global"); + + // Plugin lives outside the project and is symlinked into .mux/plugins. + await writePlugin(elsewhere.path, "linked-plugin", [ + { name: "linked-skill", description: "from outside" }, + ]); + const container = path.join(project.path, ".mux", "plugins"); + await fs.mkdir(container, { recursive: true }); + await fs.symlink(path.join(elsewhere.path, "linked-plugin"), path.join(container, "linked-plugin")); + + const runtime = new LocalRuntime(project.path); + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + universalRoot: "", + projectPluginRoots: [container], + }; + + const skills = await discoverAgentSkills(runtime, project.path, { + roots, + projectContainmentRoot: project.path, + }); + + expect(skills.find((s) => s.name === "linked-skill")).toBeUndefined(); + }); +}); diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index a0d28a5fb4..39f00895fc 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -29,11 +29,16 @@ import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFi import { AgentSkillParseError, parseSkillMarkdown } from "./parseSkillMarkdown"; import { getBuiltInSkillByName, getBuiltInSkillDescriptors } from "./builtInSkillDefinitions"; import type { ProjectSkillContainment } from "./skillStorageContext"; +import { discoverAgentPlugins } from "@/node/services/agentPlugins/discovery"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; const UNIVERSAL_SKILLS_ROOT = "~/.agents/skills"; // Claude Code compatibility roots (claude-skills-compat experiment): discovery-only, // lowest precedence within each scope. Write tools never target these roots. const CLAUDE_SKILLS_ROOT = "~/.claude/skills"; +// Agent Plugins containers (agent-plugins experiment): discovery-only, host-local, +// lowest precedence within each scope. Write tools never target plugin roots. +const UNIVERSAL_PLUGINS_ROOT = "~/.agents/plugins"; export interface AgentSkillsRoots { projectRoot: string; @@ -44,12 +49,16 @@ export interface AgentSkillsRoots { universalRoot?: string; /** ~/.claude/skills (claude-skills-compat experiment; read-only). */ globalClaudeRoot?: string; + /** Agent Plugins container dirs, e.g. /.mux/plugins (agent-plugins experiment; read-only). */ + projectPluginRoots?: string[]; + /** Agent Plugins container dirs, e.g. ~/.mux/plugins (agent-plugins experiment; read-only). */ + globalPluginRoots?: string[]; } export function getDefaultAgentSkillsRoots( runtime: Runtime, workspacePath: string, - options?: { includeClaudeSkills?: boolean } + options?: { includeClaudeSkills?: boolean; includeAgentPlugins?: boolean } ): AgentSkillsRoots { if (!workspacePath) { throw new Error("getDefaultAgentSkillsRoots: workspacePath is required"); @@ -68,6 +77,17 @@ export function getDefaultAgentSkillsRoots( globalClaudeRoot: CLAUDE_SKILLS_ROOT, } : {}), + // Agent Plugins discovery is host-filesystem-only (v1), so remote runtimes + // never get plugin containers. + ...(options?.includeAgentPlugins && !(runtime instanceof RemoteRuntime) + ? { + projectPluginRoots: [ + runtime.normalizePath(".mux/plugins", workspacePath), + runtime.normalizePath(".agents/plugins", workspacePath), + ], + globalPluginRoots: [`${runtime.getMuxHome()}/plugins`, UNIVERSAL_PLUGINS_ROOT], + } + : {}), }; } @@ -91,30 +111,112 @@ function getGlobalSkillRoots(roots: AgentSkillsRoots): string[] { return Array.from(new Set(orderedRoots)); } -function buildScanOrder(roots: AgentSkillsRoots): Array<{ scope: AgentSkillScope; root: string }> { - return [ - ...getProjectSkillRoots(roots).map((root) => ({ scope: "project" as const, root })), - ...getGlobalSkillRoots(roots).map((root) => ({ scope: "global" as const, root })), - ]; -} - interface AgentSkillScanCandidate { scope: AgentSkillScope; root: string; runtime: Runtime; + /** + * Agent Plugins only: canonical plugin root anchoring per-skill realpath + * containment (§4.1). Present exactly for plugin skills/ roots. + */ + pluginRoot?: string; } -function buildScanCandidates( +/** + * Agent Plugins (agent-plugins experiment): expand plugin container dirs into + * per-plugin `skills/` scan candidates. Host-local filesystem only (v1). + */ +async function buildPluginScanCandidates(args: { + containers: string[]; + scope: "project" | "global"; + workspacePath: string; + /** + * Project scope: plugin roots must additionally stay inside the project + * containment root so repo-controlled symlinks under .mux/plugins keep the + * same escape posture as .mux/skills. + */ + projectContainmentRoot?: string; +}): Promise { + if (args.containers.length === 0) { + return []; + } + + const localRuntime = new LocalRuntime(args.workspacePath); + const resolvedContainers: Array<{ path: string; scope: "project" | "global" }> = []; + for (const container of args.containers) { + try { + // Container paths may be tilde-form (e.g. ~/.agents/plugins). + resolvedContainers.push({ + path: await localRuntime.resolvePath(container), + scope: args.scope, + }); + } catch (err) { + log.warn(`Failed to resolve plugin container ${container}: ${getErrorMessage(err)}`); + } + } + + const { plugins } = await discoverAgentPlugins(resolvedContainers); + + const candidates: AgentSkillScanCandidate[] = []; + for (const plugin of plugins) { + if (plugin.skillsDir == null) { + continue; + } + + if (args.projectContainmentRoot != null) { + try { + await ensurePathContained(args.projectContainmentRoot, plugin.rootPath); + } catch (error) { + log.warn( + `Skipping project plugin '${plugin.name}' at '${plugin.rootPath}': plugin root escapes the project containment root: ${getErrorMessage(error)}` + ); + continue; + } + } + + candidates.push({ + scope: args.scope, + root: plugin.skillsDir, + runtime: localRuntime, + pluginRoot: plugin.rootPath, + }); + } + + return candidates; +} + +async function buildScanCandidates( runtime: Runtime, workspacePath: string, - roots: AgentSkillsRoots -): AgentSkillScanCandidate[] { + roots: AgentSkillsRoots, + containment: ProjectSkillContainment +): Promise { const globalRuntime = resolveGlobalRuntime(runtime, workspacePath); - return buildScanOrder(roots).map((scan) => ({ - ...scan, - runtime: scan.scope === "global" ? globalRuntime : runtime, - })); + // Plugin skills sit at the lowest precedence within each scope, after the + // standard (and .claude compat) roots of that scope. + const projectPluginCandidates = await buildPluginScanCandidates({ + containers: roots.projectPluginRoots ?? [], + scope: "project", + workspacePath, + projectContainmentRoot: containment.kind === "local" ? containment.root : undefined, + }); + const globalPluginCandidates = await buildPluginScanCandidates({ + containers: roots.globalPluginRoots ?? [], + scope: "global", + workspacePath, + }); + + return [ + ...getProjectSkillRoots(roots).map((root) => ({ scope: "project" as const, root, runtime })), + ...projectPluginCandidates, + ...getGlobalSkillRoots(roots).map((root) => ({ + scope: "global" as const, + root, + runtime: globalRuntime, + })), + ...globalPluginCandidates, + ]; } const NO_PROJECT_SKILL_CONTAINMENT: ProjectSkillContainment = { kind: "none" }; @@ -161,6 +263,31 @@ async function assertProjectSkillContained(args: { ); } +/** + * Agent Plugins: §4.1 per-skill containment anchored at the canonical plugin + * root. Returns false when the skill must be skipped: a missing SKILL.md means + * "not a skill" (silent, per §7.1), a realpath escape is logged and reported + * via onEscape. + */ +async function isPluginSkillContained(args: { + pluginRoot: string; + skillFilePath: string; + directoryName: string; + onEscape?: (message: string) => void; +}): Promise { + try { + await ensurePathContained(args.pluginRoot, args.skillFilePath); + return true; + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + const message = `Plugin skill '${args.directoryName}' at '${args.skillFilePath}' escapes the plugin root: ${getErrorMessage(error)}`; + log.warn(message); + args.onEscape?.(message); + } + return false; + } +} + async function listSkillDirectoriesFromLocalFs(root: string): Promise { try { const entries = await fs.readdir(root, { withFileTypes: true }); @@ -311,6 +438,8 @@ export async function discoverAgentSkills( dedupeByName?: boolean; /** claude-skills-compat experiment: also scan .claude/skills roots (used only when `roots` is absent). */ includeClaudeSkills?: boolean; + /** agent-plugins experiment: also scan Agent Plugins skills (used only when `roots` is absent). */ + includeAgentPlugins?: boolean; } ): Promise { if (!workspacePath) { @@ -321,6 +450,7 @@ export async function discoverAgentSkills( options?.roots ?? getDefaultAgentSkillsRoots(runtime, workspacePath, { includeClaudeSkills: options?.includeClaudeSkills, + includeAgentPlugins: options?.includeAgentPlugins, }); const containment = resolveProjectSkillContainment(options); @@ -330,7 +460,7 @@ export async function discoverAgentSkills( const discoveredSkills: AgentSkillDescriptor[] = []; // Scan order encodes precedence: earlier roots win when names collide. - const scans = buildScanCandidates(runtime, workspacePath, roots); + const scans = await buildScanCandidates(runtime, workspacePath, roots, containment); for (const scan of scans) { let resolvedRoot: string; @@ -362,7 +492,16 @@ export async function discoverAgentSkills( const skillDir = scan.runtime.normalizePath(directoryName, resolvedRoot); const skillFilePath = scan.runtime.normalizePath("SKILL.md", skillDir); - if (scan.scope === "project") { + if (scan.pluginRoot != null) { + // Plugin candidates use plugin-root containment; the plugin root itself + // was already validated against the project containment root. + const contained = await isPluginSkillContained({ + pluginRoot: scan.pluginRoot, + skillFilePath, + directoryName, + }); + if (!contained) continue; + } else if (scan.scope === "project") { try { await assertProjectSkillContained({ runtime: scan.runtime, @@ -428,6 +567,8 @@ export async function discoverAgentSkillsDiagnostics( projectContainmentRoot?: string | null; /** claude-skills-compat experiment: also scan .claude/skills roots (used only when `roots` is absent). */ includeClaudeSkills?: boolean; + /** agent-plugins experiment: also scan Agent Plugins skills (used only when `roots` is absent). */ + includeAgentPlugins?: boolean; } ): Promise { if (!workspacePath) { @@ -438,6 +579,7 @@ export async function discoverAgentSkillsDiagnostics( options?.roots ?? getDefaultAgentSkillsRoots(runtime, workspacePath, { includeClaudeSkills: options?.includeClaudeSkills, + includeAgentPlugins: options?.includeAgentPlugins, }); const containment = resolveProjectSkillContainment(options); @@ -446,7 +588,7 @@ export async function discoverAgentSkillsDiagnostics( const invalidSkills: AgentSkillIssue[] = []; // Scan order encodes precedence: earlier roots win when names collide. - const scans = buildScanCandidates(runtime, workspacePath, roots); + const scans = await buildScanCandidates(runtime, workspacePath, roots, containment); for (const scan of scans) { let resolvedRoot: string; @@ -485,7 +627,25 @@ export async function discoverAgentSkillsDiagnostics( const skillDir = scan.runtime.normalizePath(directoryName, resolvedRoot); const skillFilePath = scan.runtime.normalizePath("SKILL.md", skillDir); - if (scan.scope === "project") { + if (scan.pluginRoot != null) { + // Plugin candidates use plugin-root containment; the plugin root itself + // was already validated against the project containment root. + const contained = await isPluginSkillContained({ + pluginRoot: scan.pluginRoot, + skillFilePath, + directoryName, + onEscape: (message) => { + invalidSkills.push({ + directoryName, + scope: scan.scope, + displayPath: skillFilePath, + message, + hint: "Remove the symlink escaping the plugin root or move the skill inside the plugin.", + }); + }, + }); + if (!contained) continue; + } else if (scan.scope === "project") { try { await assertProjectSkillContained({ runtime: scan.runtime, @@ -613,6 +773,8 @@ export async function readAgentSkill( projectContainmentRoot?: string | null; /** claude-skills-compat experiment: also scan .claude/skills roots (used only when `roots` is absent). */ includeClaudeSkills?: boolean; + /** agent-plugins experiment: also scan Agent Plugins skills (used only when `roots` is absent). */ + includeAgentPlugins?: boolean; } ): Promise { if (!workspacePath) { @@ -623,12 +785,13 @@ export async function readAgentSkill( options?.roots ?? getDefaultAgentSkillsRoots(runtime, workspacePath, { includeClaudeSkills: options?.includeClaudeSkills, + includeAgentPlugins: options?.includeAgentPlugins, }); const containment = resolveProjectSkillContainment(options); // Scan order encodes precedence: earlier roots win when names collide. - const candidates = buildScanCandidates(runtime, workspacePath, roots); + const candidates = await buildScanCandidates(runtime, workspacePath, roots, containment); for (const candidate of candidates) { let resolvedRoot: string; @@ -641,7 +804,16 @@ export async function readAgentSkill( const skillDir = candidate.runtime.normalizePath(name, resolvedRoot); const skillFilePath = candidate.runtime.normalizePath("SKILL.md", skillDir); - if (candidate.scope === "project") { + if (candidate.pluginRoot != null) { + // Plugin candidates use plugin-root containment; the plugin root itself + // was already validated against the project containment root. + const contained = await isPluginSkillContained({ + pluginRoot: candidate.pluginRoot, + skillFilePath, + directoryName: name, + }); + if (!contained) continue; + } else if (candidate.scope === "project") { try { await assertProjectSkillContained({ runtime: candidate.runtime, diff --git a/src/node/services/agentSkills/skillStorageContext.test.ts b/src/node/services/agentSkills/skillStorageContext.test.ts index 449d7f7f66..453711fdf4 100644 --- a/src/node/services/agentSkills/skillStorageContext.test.ts +++ b/src/node/services/agentSkills/skillStorageContext.test.ts @@ -134,6 +134,57 @@ describe("resolveSkillStorageContext", () => { }); }); + it("adds read-only Agent Plugins containers when includeAgentPlugins is set", () => { + using tempDir = new TestTempDir("skill-storage-context-plugin-roots"); + const runtime = new LocalRuntime(tempDir.path); + + const projectRoot = path.join(tempDir.path, "project"); + const muxScope: MuxToolScope = { + type: "project", + muxHome: tempDir.path, + projectRoot, + projectStorageAuthority: "host-local", + }; + + const offContext = resolveSkillStorageContext({ + runtime, + workspacePath: "/remote/workspace", + muxScope, + }); + expect(offContext.roots?.projectPluginRoots).toBeUndefined(); + expect(offContext.roots?.globalPluginRoots).toBeUndefined(); + + const projectContext = resolveSkillStorageContext({ + runtime, + workspacePath: "/remote/workspace", + muxScope, + includeAgentPlugins: true, + }); + expect(projectContext.roots?.projectPluginRoots).toEqual([ + path.join(projectRoot, ".mux", "plugins"), + path.join(projectRoot, ".agents", "plugins"), + ]); + expect(projectContext.roots?.globalPluginRoots).toEqual([ + path.join(tempDir.path, "plugins"), + "~/.agents/plugins", + ]); + + const globalContext = resolveSkillStorageContext({ + runtime, + workspacePath: tempDir.path, + muxScope: { + type: "global", + muxHome: tempDir.path, + }, + includeAgentPlugins: true, + }); + expect(globalContext.roots?.projectPluginRoots).toBeUndefined(); + expect(globalContext.roots?.globalPluginRoots).toEqual([ + path.join(tempDir.path, "plugins"), + "~/.agents/plugins", + ]); + }); + it("swaps devcontainer project-local contexts to a host-local runtime", async () => { using tempDir = new TestTempDir("skill-storage-context-project-local-devcontainer"); diff --git a/src/node/services/agentSkills/skillStorageContext.ts b/src/node/services/agentSkills/skillStorageContext.ts index 21e614e4d7..18b4c435e4 100644 --- a/src/node/services/agentSkills/skillStorageContext.ts +++ b/src/node/services/agentSkills/skillStorageContext.ts @@ -24,7 +24,7 @@ export interface SkillStorageContext { function buildProjectLocalRoots( muxScope: Extract, - options?: { includeClaudeSkills?: boolean } + options?: { includeClaudeSkills?: boolean; includeAgentPlugins?: boolean } ): AgentSkillsRoots { return { projectRoot: path.join(muxScope.projectRoot, ".mux", "skills"), @@ -38,6 +38,16 @@ function buildProjectLocalRoots( globalClaudeRoot: "~/.claude/skills", } : {}), + // agent-plugins experiment: read-only plugin containers at lowest precedence within each scope. + ...(options?.includeAgentPlugins + ? { + projectPluginRoots: [ + path.join(muxScope.projectRoot, ".mux", "plugins"), + path.join(muxScope.projectRoot, ".agents", "plugins"), + ], + globalPluginRoots: [path.join(muxScope.muxHome, "plugins"), "~/.agents/plugins"], + } + : {}), }; } @@ -45,6 +55,7 @@ function buildGlobalLocalRoots(input: { runtime: Runtime; muxScope?: MuxToolScope | null; includeClaudeSkills?: boolean; + includeAgentPlugins?: boolean; }): AgentSkillsRoots { const muxHome = input.muxScope?.muxHome ?? input.runtime.getMuxHome(); @@ -54,6 +65,10 @@ function buildGlobalLocalRoots(input: { universalRoot: "~/.agents/skills", // claude-skills-compat experiment: read-only root at lowest global precedence. ...(input.includeClaudeSkills ? { globalClaudeRoot: "~/.claude/skills" } : {}), + // agent-plugins experiment: read-only plugin containers at lowest global precedence. + ...(input.includeAgentPlugins + ? { globalPluginRoots: [path.join(muxHome, "plugins"), "~/.agents/plugins"] } + : {}), }; } @@ -80,6 +95,8 @@ export function resolveSkillStorageContext(input: { muxScope?: MuxToolScope | null; /** claude-skills-compat experiment: include read-only .claude/skills roots in discovery. */ includeClaudeSkills?: boolean; + /** agent-plugins experiment: include read-only Agent Plugins skill roots in discovery. */ + includeAgentPlugins?: boolean; }): SkillStorageContext { if (input.muxScope?.type !== "project") { return { @@ -92,6 +109,7 @@ export function resolveSkillStorageContext(input: { runtime: input.runtime, muxScope: input.muxScope, includeClaudeSkills: input.includeClaudeSkills, + includeAgentPlugins: input.includeAgentPlugins, }), containment: { kind: "none" }, }; @@ -118,6 +136,7 @@ export function resolveSkillStorageContext(input: { workspacePath: input.workspacePath, roots: buildProjectLocalRoots(input.muxScope, { includeClaudeSkills: input.includeClaudeSkills, + includeAgentPlugins: input.includeAgentPlugins, }), containment: { kind: "local", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 7cecc11e4c..9df856c286 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1037,6 +1037,16 @@ export class AIService extends EventEmitter { ); } + /** + * Host-evaluated gate for the agent-plugins experiment: when enabled, skill + * discovery/read paths also scan Agent Plugins containers (.mux/plugins, + * .agents/plugins, ~/.mux/plugins, ~/.agents/plugins; read-only, lowest + * precedence). Public for the same reason as isClaudeSkillsCompatEnabled. + */ + isAgentPluginsEnabled(): boolean { + return this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS) === true; + } + /** Stream a message conversation to the AI model. */ async streamMessage(opts: StreamMessageOptions): Promise> { const { @@ -1448,6 +1458,7 @@ export class AIService extends EventEmitter { // claude-skills-compat is host-evaluated (like memory-hot-set): sub-agents share the // host ExperimentsService, so it is not inherited through SendMessageOptions.experiments. const claudeSkillsCompatExperimentEnabled = this.isClaudeSkillsCompatEnabled(); + const agentPluginsExperimentEnabled = this.isAgentPluginsEnabled(); // Once final tool policy keeps the memory tool, upgrade the index-only // memory context (resolved pre-policy with includeHotMemories: false) to // the token-budgeted hot block for the model that will actually stream. @@ -1648,6 +1659,7 @@ export class AIService extends EventEmitter { memoryToolAvailable: toolset.memoryToolAvailable, hotMemoriesBlock: contextForModel?.hotMemoriesBlock ?? undefined, claudeSkillsCompatEnabled: claudeSkillsCompatExperimentEnabled, + agentPluginsEnabled: agentPluginsExperimentEnabled, }); // Build provisional agent context before tool policy finalizes the toolset. @@ -2206,6 +2218,7 @@ export class AIService extends EventEmitter { workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, toolSearch: toolSearchExperimentEnabled, claudeSkillsCompat: claudeSkillsCompatExperimentEnabled, + agentPlugins: agentPluginsExperimentEnabled, }, // Dynamic context for tool descriptions (moved from system prompt for better model attention) availableSubagents: agentDefinitions, diff --git a/src/node/services/streamContextBuilder.ts b/src/node/services/streamContextBuilder.ts index 583363b5c3..661751439b 100644 --- a/src/node/services/streamContextBuilder.ts +++ b/src/node/services/streamContextBuilder.ts @@ -271,6 +271,8 @@ export interface BuildStreamSystemContextOptions { hotMemoriesBlock?: string; /** claude-skills-compat experiment: discover skills from .claude/skills roots (read-only). */ claudeSkillsCompatEnabled?: boolean; + /** agent-plugins experiment: discover skills from Agent Plugins containers (read-only). */ + agentPluginsEnabled?: boolean; } /** Result of system context assembly. */ @@ -591,6 +593,7 @@ export async function buildStreamSystemContext( workspacePath, muxScope, includeClaudeSkills: opts.claudeSkillsCompatEnabled, + includeAgentPlugins: opts.agentPluginsEnabled, }); let availableSkills: Awaited> | undefined; @@ -600,6 +603,7 @@ export async function buildStreamSystemContext( containment: skillCtx.containment, // Used only for the project-runtime default-roots fallback (skillCtx.roots undefined). includeClaudeSkills: opts.claudeSkillsCompatEnabled, + includeAgentPlugins: opts.agentPluginsEnabled, }); } catch (error) { workspaceLog.warn("Failed to discover agent skills for tool description", { error }); diff --git a/src/node/services/tools/agent_skill_list.test.ts b/src/node/services/tools/agent_skill_list.test.ts index 69aeb2f1fe..faf2d9b6ac 100644 --- a/src/node/services/tools/agent_skill_list.test.ts +++ b/src/node/services/tools/agent_skill_list.test.ts @@ -56,6 +56,29 @@ function getSkill(skills: AgentSkillDescriptor[], name: string): AgentSkillDescr return skill!; } +/** Agent Plugins fixture: a container entry with a plugin.json manifest and skills. */ +async function writePlugin( + containerPath: string, + pluginName: string, + skills: Array<{ name: string; description: string }> +): Promise { + const pluginDir = path.join(containerPath, pluginName); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "plugin.json"), + JSON.stringify({ + $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + name: pluginName, + }), + "utf-8" + ); + for (const skill of skills) { + await writeSkill(path.join(pluginDir, "skills"), skill.name, { + description: skill.description, + }); + } +} + describe("agent_skill_list", () => { it("lists effective available skills across project and global scopes", async () => { using project = new TestTempDir("test-agent-skill-list-project"); @@ -245,6 +268,97 @@ describe("agent_skill_list", () => { }); }); + it("hides Agent Plugins skills when the agent-plugins experiment is off", async () => { + using homeDir = new TestTempDir("test-agent-skill-list-plugins-off-home"); + using project = new TestTempDir("test-agent-skill-list-plugins-off-project"); + using muxHomeDir = new TestTempDir("test-agent-skill-list-plugins-off-mux-home"); + + await withHomeDir(homeDir.path, async () => { + await withMuxRoot(muxHomeDir.path, async () => { + await writePlugin(path.join(project.path, ".mux", "plugins"), "project-plugin", [ + { name: "plugin-project", description: "from project plugin" }, + ]); + await writePlugin(path.join(muxHomeDir.path, "plugins"), "global-plugin", [ + { name: "plugin-global", description: "from global plugin" }, + ]); + + // Default tool config: no experiments => plugin roots must stay invisible. + const tool = createAgentSkillListTool( + createTestToolConfig(project.path, { + muxScope: { + type: "project", + muxHome: muxHomeDir.path, + projectRoot: project.path, + projectStorageAuthority: "host-local", + }, + }) + ); + const result = (await tool.execute!({}, mockToolCallOptions)) as AgentSkillListToolResult; + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(result.skills.find((skill) => skill.name === "plugin-project")).toBeUndefined(); + expect(result.skills.find((skill) => skill.name === "plugin-global")).toBeUndefined(); + }); + }); + }); + + it("lists Agent Plugins skills when the agent-plugins experiment is on", async () => { + using homeDir = new TestTempDir("test-agent-skill-list-plugins-on-home"); + using project = new TestTempDir("test-agent-skill-list-plugins-on-project"); + using muxHomeDir = new TestTempDir("test-agent-skill-list-plugins-on-mux-home"); + + await withHomeDir(homeDir.path, async () => { + await withMuxRoot(muxHomeDir.path, async () => { + await writePlugin(path.join(project.path, ".mux", "plugins"), "project-plugin", [ + { name: "plugin-project", description: "from project plugin" }, + ]); + await writePlugin(path.join(muxHomeDir.path, "plugins"), "global-plugin", [ + { name: "plugin-global", description: "from global plugin" }, + ]); + // Sibling non-plugin entry (e.g. Codex marketplace metadata) must not break listing. + await fs.mkdir(path.join(homeDir.path, ".agents", "plugins"), { recursive: true }); + await fs.writeFile( + path.join(homeDir.path, ".agents", "plugins", "marketplace.json"), + "{}", + "utf-8" + ); + + const tool = createAgentSkillListTool({ + ...createTestToolConfig(project.path, { + muxScope: { + type: "project", + muxHome: muxHomeDir.path, + projectRoot: project.path, + projectStorageAuthority: "host-local", + }, + }), + experiments: { agentPlugins: true }, + }); + const result = (await tool.execute!({}, mockToolCallOptions)) as AgentSkillListToolResult; + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(getSkill(result.skills, "plugin-project")).toMatchObject({ + name: "plugin-project", + description: "from project plugin", + scope: "project", + }); + expect(getSkill(result.skills, "plugin-global")).toMatchObject({ + name: "plugin-global", + description: "from global plugin", + scope: "global", + }); + }); + }); + }); + it("returns only the winning descriptor when project skills shadow global skills", async () => { using project = new TestTempDir("test-agent-skill-list-shadow-project"); using muxHome = new TestTempDir("test-agent-skill-list-shadow-home"); diff --git a/src/node/services/tools/agent_skill_list.ts b/src/node/services/tools/agent_skill_list.ts index d303ae105c..63c292e429 100644 --- a/src/node/services/tools/agent_skill_list.ts +++ b/src/node/services/tools/agent_skill_list.ts @@ -15,6 +15,7 @@ import type { AgentSkillListToolResult } from "@/common/types/tools"; import { getErrorMessage } from "@/common/utils/errors"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { discoverAgentPlugins } from "@/node/services/agentPlugins/discovery"; import { discoverAgentSkills, getDefaultAgentSkillsRoots, @@ -167,6 +168,8 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) // claude-skills-compat experiment: also list read-only .claude/skills roots. const includeClaudeSkills = config.experiments?.claudeSkillsCompat === true; + // agent-plugins experiment: also list read-only Agent Plugins skill roots. + const includeAgentPlugins = config.experiments?.agentPlugins === true; try { const skillCtx = resolveSkillStorageContext({ @@ -174,6 +177,7 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) workspacePath: config.cwd, muxScope: config.muxScope ?? null, includeClaudeSkills, + includeAgentPlugins, }); if (skillCtx.kind === "project-runtime") { @@ -181,6 +185,7 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) // listings include .mux/skills and .agents/skills plus ~/.mux/skills and ~/.agents/skills. const roots = getDefaultAgentSkillsRoots(skillCtx.runtime, skillCtx.workspacePath, { includeClaudeSkills, + includeAgentPlugins, }); const discovered = await discoverAgentSkills(skillCtx.runtime, skillCtx.workspacePath, { @@ -259,6 +264,50 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) ); } + if (includeAgentPlugins) { + // agent-plugins experiment: expand plugin containers into per-plugin skills/ roots. + const pluginContainers = [ + ...(muxScope.type === "project" + ? [ + { + path: path.join(muxScope.projectRoot, ".mux", "plugins"), + scope: "project" as const, + }, + { + path: path.join(muxScope.projectRoot, ".agents", "plugins"), + scope: "project" as const, + }, + ] + : []), + { path: path.join(muxScope.muxHome, "plugins"), scope: "global" as const }, + { path: path.join(userHome, ".agents", "plugins"), scope: "global" as const }, + ]; + const { plugins } = await discoverAgentPlugins(pluginContainers); + for (const plugin of plugins) { + if (plugin.skillsDir == null) { + continue; + } + // Project plugin roots keep the repo-symlink posture of other project + // roots: the plugin root itself must stay inside the project root. + if (plugin.scope === "project" && muxScope.type === "project") { + try { + await ensurePathContained(muxScope.projectRoot, plugin.rootPath); + } catch { + log.warn( + `Skipping project plugin '${plugin.name}': plugin root resolves outside the project root` + ); + continue; + } + } + // Per-skill containment anchors at the plugin root (§4.1). + roots.push({ + skillsRoot: plugin.skillsDir, + containmentRoot: plugin.rootPath, + scope: plugin.scope, + }); + } + } + const skills: AgentSkillDescriptor[] = []; for (const { skillsRoot, containmentRoot, scope } of roots) { let skillsRootReal: string; diff --git a/src/node/services/tools/agent_skill_read.ts b/src/node/services/tools/agent_skill_read.ts index bd38fc3f90..3aa7a9ee69 100644 --- a/src/node/services/tools/agent_skill_read.ts +++ b/src/node/services/tools/agent_skill_read.ts @@ -72,11 +72,14 @@ export const createAgentSkillReadTool: ToolFactory = (config: ToolConfiguration) try { // claude-skills-compat experiment: allow reading skills discovered from .claude roots. const includeClaudeSkills = config.experiments?.claudeSkillsCompat === true; + // agent-plugins experiment: allow reading skills discovered from Agent Plugins. + const includeAgentPlugins = config.experiments?.agentPlugins === true; const skillCtx = resolveSkillStorageContext({ runtime: config.runtime, workspacePath, muxScope: config.muxScope ?? null, includeClaudeSkills, + includeAgentPlugins, }); const resolved = await readAgentSkill( skillCtx.runtime, @@ -86,6 +89,7 @@ export const createAgentSkillReadTool: ToolFactory = (config: ToolConfiguration) roots: skillCtx.roots, containment: skillCtx.containment, includeClaudeSkills, + includeAgentPlugins, } ); return { diff --git a/src/node/services/tools/agent_skill_read_file.ts b/src/node/services/tools/agent_skill_read_file.ts index f6b0c1acf7..3cd2d04c9c 100644 --- a/src/node/services/tools/agent_skill_read_file.ts +++ b/src/node/services/tools/agent_skill_read_file.ts @@ -110,11 +110,14 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat // claude-skills-compat experiment: allow reading skill files discovered from .claude roots. const includeClaudeSkills = config.experiments?.claudeSkillsCompat === true; + // agent-plugins experiment: allow reading skill files discovered from Agent Plugins. + const includeAgentPlugins = config.experiments?.agentPlugins === true; const skillCtx = resolveSkillStorageContext({ runtime: config.runtime, workspacePath, muxScope: config.muxScope ?? null, includeClaudeSkills, + includeAgentPlugins, }); // Defensive: validate again even though inputSchema should guarantee shape. @@ -142,6 +145,7 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat roots: skillCtx.roots, containment: skillCtx.containment, includeClaudeSkills, + includeAgentPlugins, } ); From 47ca3616acbffa8649eeab3a4006b48efdd260fb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 20:15:37 +0000 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=A4=96=20feat:=20map=20Agent=20Plug?= =?UTF-8?q?ins=20mcp.json=20into=20MCP=20server=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the agent-plugins experiment: plugin mcp.json files (Agent Plugins 1.0.0 §7.2) normalize into default-disabled, read-only MCP server records keyed plugin::, merged into MCPConfigService.listServers at lowest precedence so both the engine and the UI see them. Stdio launches run in quoted argv mode with PLUGIN_ROOT/ PLUGIN_DATA injected after configured env, single-pass placeholder expansion, containment-validated command/cwd, and the plugin data dir created before launch. Plugin servers are skipped on remote runtimes and enabled per workspace via existing enabledServers overrides. --- .../WorkspaceMCPModal/WorkspaceMCPModal.tsx | 19 +- .../Settings/Sections/MCPSettingsSection.tsx | 95 +-- src/common/orpc/schemas/mcp.ts | 14 + src/common/types/mcp.ts | 24 + .../services/agentPlugins/expansion.test.ts | 33 + src/node/services/agentPlugins/expansion.ts | 25 + .../services/agentPlugins/mcpConfig.test.ts | 562 ++++++++++++++++++ src/node/services/agentPlugins/mcpConfig.ts | 521 ++++++++++++++++ src/node/services/coreServices.ts | 13 +- src/node/services/mcpConfigService.test.ts | 82 ++- src/node/services/mcpConfigService.ts | 28 +- src/node/services/mcpServerManager.test.ts | 145 ++++- src/node/services/mcpServerManager.ts | 81 ++- .../agent-plugins/broken-plugin/plugin.json | 1 + .../agent-plugins/hello-plugin/mcp.json | 19 + .../agent-plugins/hello-plugin/plugin.json | 9 + .../skills/hello-greeter/SKILL.md | 10 + 17 files changed, 1627 insertions(+), 54 deletions(-) create mode 100644 src/node/services/agentPlugins/expansion.test.ts create mode 100644 src/node/services/agentPlugins/expansion.ts create mode 100644 src/node/services/agentPlugins/mcpConfig.test.ts create mode 100644 src/node/services/agentPlugins/mcpConfig.ts create mode 100644 tests/fixtures/agent-plugins/broken-plugin/plugin.json create mode 100644 tests/fixtures/agent-plugins/hello-plugin/mcp.json create mode 100644 tests/fixtures/agent-plugins/hello-plugin/plugin.json create mode 100644 tests/fixtures/agent-plugins/hello-plugin/skills/hello-greeter/SKILL.md diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index bfd722fbf5..70620f66ad 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -319,6 +319,11 @@ export const WorkspaceMCPModal: React.FC = ({ const tools = getTools(name); const isLoadingTools = loadingTools[name]; const allowedTools = overrides.toolAllowlist?.[name] ?? tools ?? []; + // Agent Plugin servers keep the instance key as the override + // name but display readable provenance (plugin/server). + const displayName = info.plugin + ? `${info.plugin.pluginName}/${info.plugin.serverName}` + : name; return (
= ({ onCheckedChange={(checked) => toggleServerEnabled(name, checked, projectDisabled) } - aria-label={`Toggle ${name} MCP server`} + aria-label={`Toggle ${displayName} MCP server`} />
-
{name}
- {projectDisabled && ( -
(disabled at project level)
+
{displayName}
+ {info.plugin ? ( +
+ Agent Plugin ({info.plugin.sourceScope}) — disabled by default +
+ ) : ( + projectDisabled && ( +
(disabled at project level)
+ ) )}
diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx index 917369dcd9..f79960bf93 100644 --- a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx @@ -1163,6 +1163,12 @@ export const MCPSettingsSection: React.FC = () => { const isEditing = editing?.name === name; const isEnabled = !entry.disabled; const remoteEntry = entry.transport === "stdio" ? null : entry; + // Agent Plugin servers are read-only config entries: no + // global enable/edit/remove; enable them per workspace. + const isPluginEntry = entry.plugin !== undefined; + const displayName = entry.plugin + ? `${entry.plugin.pluginName}/${entry.plugin.serverName}` + : name; return (
{
void handleToggleEnabled(name, checked) } - aria-label={`Toggle ${name} enabled`} + aria-label={`Toggle ${displayName} enabled`} />
- {isEnabled ? "Disable server" : "Enable server"} + {isPluginEntry + ? "Agent Plugin servers are enabled per workspace (Workspace MCP)" + : isEnabled + ? "Disable server" + : "Enable server"}
- {name} + + {displayName} + + {isPluginEntry && ( + + plugin + + )} {cached?.result.success && !isEditing && isEnabled && ( @@ -1311,39 +1329,44 @@ export const MCPSettingsSection: React.FC = () => { Test connection - - - - - - - Edit server - - - - - - - - Remove server - + {/* Plugin entries are read-only: no edit/remove. */} + {!isPluginEntry && ( + <> + + + + + + + Edit server + + + + + + + + Remove server + + + )} )}
diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts index 57590f8dcf..039c00cc08 100644 --- a/src/common/orpc/schemas/mcp.ts +++ b/src/common/orpc/schemas/mcp.ts @@ -29,12 +29,23 @@ export const MCPTransportSchema = z.enum(["stdio", "http", "sse", "auto"]); export const MCPHeaderValueSchema = z.union([z.string(), z.object({ secret: z.string() })]); export const MCPHeadersSchema = z.record(z.string(), MCPHeaderValueSchema); +/** UI-safe Agent Plugin provenance (agent-plugins experiment; read-only entries). */ +export const MCPServerPluginProvenanceSchema = z.object({ + pluginName: z.string(), + serverName: z.string(), + sourceScope: z.enum(["project", "global"]), +}); + export const MCPServerInfoSchema = z.discriminatedUnion("transport", [ z.object({ transport: z.literal("stdio"), command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + cwd: z.string().optional(), disabled: z.boolean(), toolAllowlist: z.array(z.string()).optional(), + plugin: MCPServerPluginProvenanceSchema.optional(), }), z.object({ transport: z.literal("http"), @@ -42,6 +53,7 @@ export const MCPServerInfoSchema = z.discriminatedUnion("transport", [ headers: MCPHeadersSchema.optional(), disabled: z.boolean(), toolAllowlist: z.array(z.string()).optional(), + plugin: MCPServerPluginProvenanceSchema.optional(), }), z.object({ transport: z.literal("sse"), @@ -49,6 +61,7 @@ export const MCPServerInfoSchema = z.discriminatedUnion("transport", [ headers: MCPHeadersSchema.optional(), disabled: z.boolean(), toolAllowlist: z.array(z.string()).optional(), + plugin: MCPServerPluginProvenanceSchema.optional(), }), z.object({ transport: z.literal("auto"), @@ -56,6 +69,7 @@ export const MCPServerInfoSchema = z.discriminatedUnion("transport", [ headers: MCPHeadersSchema.optional(), disabled: z.boolean(), toolAllowlist: z.array(z.string()).optional(), + plugin: MCPServerPluginProvenanceSchema.optional(), }), ]); diff --git a/src/common/types/mcp.ts b/src/common/types/mcp.ts index 0082d21a57..be4767b43f 100644 --- a/src/common/types/mcp.ts +++ b/src/common/types/mcp.ts @@ -3,6 +3,18 @@ export type MCPServerTransport = "stdio" | "http" | "sse" | "auto"; export type MCPHeaderValue = string | { secret: string }; +/** + * UI-safe provenance for servers contributed by an Agent Plugin + * (agent-plugins experiment). Presence marks the server as a read-only + * config entry: never editable and never persisted into mcp.jsonc. + */ +export interface MCPServerPluginProvenance { + pluginName: string; + /** Server name as declared in the plugin's mcp.json (the map key is the instance key). */ + serverName: string; + sourceScope: "project" | "global"; +} + export interface MCPServerBaseInfo { transport: MCPServerTransport; disabled: boolean; @@ -12,12 +24,24 @@ export interface MCPServerBaseInfo { * If not set, all tools are exposed. */ toolAllowlist?: string[]; + /** Present when this server comes from an Agent Plugin. */ + plugin?: MCPServerPluginProvenance; } /** stdio server definition (local process). */ export interface MCPStdioServerInfo extends MCPServerBaseInfo { transport: "stdio"; command: string; + /** + * Argv appended to `command`. When set (even empty), launch composes the + * shell command by quoting `command` and each element; when unset, `command` + * is treated as a raw shell string (legacy mcp.jsonc behavior). + */ + args?: string[]; + /** Extra environment variables injected into the server process. */ + env?: Record; + /** Working directory; defaults to the workspace path when unset. */ + cwd?: string; } /** HTTP-based server definition. */ diff --git a/src/node/services/agentPlugins/expansion.test.ts b/src/node/services/agentPlugins/expansion.test.ts new file mode 100644 index 0000000000..629f11f15a --- /dev/null +++ b/src/node/services/agentPlugins/expansion.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; + +import { expandPluginPlaceholders } from "./expansion"; + +const VARS = { PLUGIN_ROOT: "/plugins/demo", PLUGIN_DATA: "/data/demo" }; + +describe("expandPluginPlaceholders", () => { + test("replaces every exact occurrence of both placeholders", () => { + expect(expandPluginPlaceholders("${PLUGIN_ROOT}/bin:${PLUGIN_DATA}/cache", VARS)).toBe( + "/plugins/demo/bin:/data/demo/cache" + ); + expect(expandPluginPlaceholders("${PLUGIN_ROOT}${PLUGIN_ROOT}", VARS)).toBe( + "/plugins/demo/plugins/demo" + ); + }); + + test("is single-pass and non-recursive: replacement text is never rescanned", () => { + const vars = { PLUGIN_ROOT: "${PLUGIN_DATA}", PLUGIN_DATA: "/data" }; + // If expansion rescanned replacements, this would become "/data". + expect(expandPluginPlaceholders("${PLUGIN_ROOT}", vars)).toBe("${PLUGIN_DATA}"); + }); + + test("leaves unrecognized placeholder-like text literal", () => { + expect( + expandPluginPlaceholders("${HOME}/x ${PLUGIN_ROOTS} $PLUGIN_ROOT ${plugin_root}", VARS) + ).toBe("${HOME}/x ${PLUGIN_ROOTS} $PLUGIN_ROOT ${plugin_root}"); + }); + + test("returns strings without placeholders unchanged", () => { + expect(expandPluginPlaceholders("plain text", VARS)).toBe("plain text"); + expect(expandPluginPlaceholders("", VARS)).toBe(""); + }); +}); diff --git a/src/node/services/agentPlugins/expansion.ts b/src/node/services/agentPlugins/expansion.ts new file mode 100644 index 0000000000..791b7a784e --- /dev/null +++ b/src/node/services/agentPlugins/expansion.ts @@ -0,0 +1,25 @@ +/** + * Agent Plugins 1.0.0 placeholder expansion (§9.2). + * + * Expansion is a single, non-recursive textual replacement of every exact + * occurrence of `${PLUGIN_ROOT}` / `${PLUGIN_DATA}`. Replacement text is never + * rescanned for further placeholders, unrecognized `${...}` text stays + * literal, and no other placeholder or environment-variable expansion is + * performed. + */ + +export interface PluginPlaceholderValues { + PLUGIN_ROOT: string; + PLUGIN_DATA: string; +} + +// String.replace with a global regex visits each match of the ORIGINAL string +// exactly once, so replacement output is never rescanned (single-pass §9.2). +const PLUGIN_PLACEHOLDER_PATTERN = /\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}/g; + +/** Expand `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` in a config string (single-pass). */ +export function expandPluginPlaceholders(value: string, vars: PluginPlaceholderValues): string { + return value.replace(PLUGIN_PLACEHOLDER_PATTERN, (_match, name: string) => + name === "PLUGIN_ROOT" ? vars.PLUGIN_ROOT : vars.PLUGIN_DATA + ); +} diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts new file mode 100644 index 0000000000..63464916ef --- /dev/null +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -0,0 +1,562 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { describe, expect, spyOn, test } from "bun:test"; + +import type { MCPStdioServerInfo } from "@/common/types/mcp"; +import { DisposableTempDir } from "@/node/services/tempDir"; +import type { AgentPluginInfo } from "./discovery"; +import { AGENT_PLUGIN_SCHEMA_ID_1_0_0 } from "./manifest"; +import { + AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + buildPluginServerKey, + computePluginInstanceId, + createAgentPluginsMcpProvider, + getPluginDataPath, + loadPluginMcpServers, +} from "./mcpConfig"; + +/** Create a plugin dir on disk and return its AgentPluginInfo. */ +async function makePlugin( + baseDir: string, + name: string, + mcpJson: unknown, + options?: { scope?: "project" | "global" } +): Promise { + const pluginDir = path.join(baseDir, name); + await fs.mkdir(pluginDir, { recursive: true }); + const rootPath = await fs.realpath(pluginDir); + const mcpConfigPath = path.join(rootPath, "mcp.json"); + await fs.writeFile( + mcpConfigPath, + typeof mcpJson === "string" ? mcpJson : JSON.stringify(mcpJson), + "utf8" + ); + return { + name, + scope: options?.scope ?? "global", + rootPath, + manifest: { schemaId: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name }, + mcpConfigPath, + }; +} + +function mcpDoc(servers: Record): unknown { + return { $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, mcpServers: servers }; +} + +const STDIO_ENTRY = { type: "stdio", command: "bunx", args: ["-y", "some-server"] }; + +describe("loadPluginMcpServers", () => { + test("normalizes a stdio entry: default-disabled, expanded args/env, reserved env last, default cwd", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "demo", + mcpDoc({ + everything: { + type: "stdio", + command: "bunx", + args: ["--data", "${PLUGIN_DATA}/d", "${PLUGIN_ROOT}"], + env: { CONFIG: "${PLUGIN_ROOT}/config.json", PLAIN: "x" }, + }, + }) + ); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(diagnostics).toEqual([]); + const instanceId = computePluginInstanceId(plugin.rootPath); + const dataPath = getPluginDataPath(tmp.path, instanceId); + const key = buildPluginServerKey(instanceId, "everything"); + expect(Object.keys(servers)).toEqual([key]); + + const info = servers[key] as MCPStdioServerInfo; + expect(info.transport).toBe("stdio"); + expect(info.disabled).toBe(true); + expect(info.command).toBe("bunx"); + expect(info.args).toEqual(["--data", `${dataPath}/d`, plugin.rootPath]); + expect(info.env).toEqual({ + CONFIG: `${plugin.rootPath}/config.json`, + PLAIN: "x", + PLUGIN_ROOT: plugin.rootPath, + PLUGIN_DATA: dataPath, + }); + // Reserved variables are appended last so configured env cannot shadow them. + expect(Object.keys(info.env ?? {}).slice(-2)).toEqual(["PLUGIN_ROOT", "PLUGIN_DATA"]); + expect(info.cwd).toBe(plugin.rootPath); + expect(info.plugin).toEqual({ + pluginName: "demo", + serverName: "everything", + sourceScope: "global", + }); + }); + + test("maps streamable-http to http and sse to sse; headerless remote entries load", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "remote", + mcpDoc({ + deploy: { type: "streamable-http", url: "https://deploy.example.com/mcp" }, + legacy: { type: "sse", url: "https://legacy.example.com/sse", headers: {} }, + }) + ); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(diagnostics).toEqual([]); + const values = Object.values(servers); + expect(values.map((s) => s.transport).sort()).toEqual(["http", "sse"]); + }); + + test("skips remote entries with configured headers (redirect conformance) with a warning", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "remote-headers", + mcpDoc({ + withHeaders: { + type: "streamable-http", + url: "https://x.example.com/mcp", + headers: { "X-Tenant": "t" }, + }, + plain: { type: "streamable-http", url: "https://y.example.com/mcp" }, + }) + ); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(Object.values(servers)).toHaveLength(1); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("warning"); + expect(diagnostics[0].message).toContain("headers"); + }); + + test("disables MCP for the plugin on invalid JSON / bad top-level / $schema mismatch", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + + const cases: Array<{ doc: unknown; messagePart: string }> = [ + { doc: "{ not json", messagePart: "not valid JSON" }, + { doc: [1, 2], messagePart: "must be a JSON object" }, + { + doc: { $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, mcpServers: {}, extra: 1 }, + messagePart: "unknown top-level field 'extra'", + }, + { doc: { mcpServers: {} }, messagePart: "'$schema'" }, + { + doc: { + $schema: "https://agent-plugins.org/schemas/9.0.0/mcp.schema.json", + mcpServers: {}, + }, + messagePart: "'$schema'", + }, + { doc: { $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0 }, messagePart: "'mcpServers'" }, + ]; + + for (const [index, testCase] of cases.entries()) { + const plugin = await makePlugin(tmp.path, `bad-${index}`, testCase.doc); + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + expect(servers).toEqual({}); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("error"); + expect(diagnostics[0].message).toContain(testCase.messagePart); + } + }); + + test("an empty mcpServers object is valid", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin(tmp.path, "empty", mcpDoc({})); + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + expect(servers).toEqual({}); + expect(diagnostics).toEqual([]); + }); + + test("skips invalid entries individually while loading valid siblings (§7.2.2)", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "mixed", + mcpDoc({ + notObject: "nope", + unknownType: { type: "websocket", url: "wss://x" }, + unknownField: { ...STDIO_ENTRY, extra: true }, + wrongVariantField: { type: "stdio", command: "x", url: "https://x" }, + good: STDIO_ENTRY, + }) + ); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + const instanceId = computePluginInstanceId(plugin.rootPath); + expect(Object.keys(servers)).toEqual([buildPluginServerKey(instanceId, "good")]); + expect(diagnostics).toHaveLength(4); + }); + + test("rejects non-token commands: shell strings, absolute/parent/backslash paths", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + for (const command of ["/usr/bin/env", "bin/tool", "../tool", "bin\\tool", ""]) { + const plugin = await makePlugin( + tmp.path, + `cmd-${Buffer.from(command).toString("hex")}`, + mcpDoc({ srv: { type: "stdio", command } }) + ); + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain("executable token"); + } + }); + + test("resolves './'-relative commands inside the plugin root", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "rel-cmd", + mcpDoc({ srv: { type: "stdio", command: "./bin/tool" } }) + ); + await fs.mkdir(path.join(plugin.rootPath, "bin"), { recursive: true }); + await fs.writeFile(path.join(plugin.rootPath, "bin", "tool"), "#!/bin/sh\n", "utf8"); + + const { servers } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + const info = Object.values(servers)[0] as MCPStdioServerInfo; + expect(info.command).toBe(path.join(plugin.rootPath, "bin", "tool")); + }); + + test("invalidates entries whose './'-relative command is missing or symlink-escapes the root", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + + const missing = await makePlugin( + tmp.path, + "missing-cmd", + mcpDoc({ srv: { type: "stdio", command: "./bin/nope" } }) + ); + const missingResult = await loadPluginMcpServers(missing, { muxHome: tmp.path }); + expect(missingResult.servers).toEqual({}); + expect(missingResult.diagnostics[0].message).toContain("does not exist"); + + const outside = path.join(tmp.path, "outside-tool"); + await fs.writeFile(outside, "#!/bin/sh\n", "utf8"); + const escaping = await makePlugin( + tmp.path, + "escaping-cmd", + mcpDoc({ srv: { type: "stdio", command: "./tool" } }) + ); + await fs.symlink(outside, path.join(escaping.rootPath, "tool")); + const escapeResult = await loadPluginMcpServers(escaping, { muxHome: tmp.path }); + expect(escapeResult.servers).toEqual({}); + expect(escapeResult.diagnostics[0].message).toContain("outside the plugin root"); + }); + + test("rejects entries with reserved env keys (PLUGIN_ROOT / PLUGIN_DATA)", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + for (const key of ["PLUGIN_ROOT", "PLUGIN_DATA"]) { + const plugin = await makePlugin( + tmp.path, + `reserved-${key.toLowerCase()}`, + mcpDoc({ srv: { type: "stdio", command: "x", env: { [key]: "/y" } } }) + ); + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain(key); + } + }); + + test("accepts every valid cwd form", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "cwd-forms", + mcpDoc({ + rel: { type: "stdio", command: "x", cwd: "./sub" }, + rootExact: { type: "stdio", command: "x", cwd: "${PLUGIN_ROOT}" }, + rootSub: { type: "stdio", command: "x", cwd: "${PLUGIN_ROOT}/sub" }, + dataExact: { type: "stdio", command: "x", cwd: "${PLUGIN_DATA}" }, + dataSub: { type: "stdio", command: "x", cwd: "${PLUGIN_DATA}/nested" }, + }) + ); + await fs.mkdir(path.join(plugin.rootPath, "sub"), { recursive: true }); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(diagnostics).toEqual([]); + const instanceId = computePluginInstanceId(plugin.rootPath); + const dataPath = getPluginDataPath(tmp.path, instanceId); + const cwdOf = (name: string) => + (servers[buildPluginServerKey(instanceId, name)] as MCPStdioServerInfo).cwd; + expect(cwdOf("rel")).toBe(path.join(plugin.rootPath, "sub")); + expect(cwdOf("rootExact")).toBe(plugin.rootPath); + expect(cwdOf("rootSub")).toBe(path.join(plugin.rootPath, "sub")); + expect(cwdOf("dataExact")).toBe(dataPath); + expect(cwdOf("dataSub")).toBe(path.join(dataPath, "nested")); + }); + + test("rejects invalid cwd forms and post-resolution escapes", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const cases: Array<{ cwd: string; messagePart: string }> = [ + { cwd: "sub", messagePart: "'cwd' must be" }, + { cwd: "/abs", messagePart: "'cwd' must be" }, + { cwd: "../up", messagePart: "'cwd' must be" }, + { cwd: "./..", messagePart: "escapes" }, + { cwd: "./sub/../../up", messagePart: "escapes" }, + { cwd: "${PLUGIN_ROOT}/../up", messagePart: "escapes" }, + { cwd: "${PLUGIN_DATA}/..", messagePart: "escapes" }, + ]; + for (const [index, testCase] of cases.entries()) { + const plugin = await makePlugin( + tmp.path, + `bad-cwd-${index}`, + mcpDoc({ srv: { type: "stdio", command: "x", cwd: testCase.cwd } }) + ); + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain(testCase.messagePart); + } + }); + + test("rejects a cwd that symlink-escapes the plugin root", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const outsideDir = path.join(tmp.path, "outside-dir"); + await fs.mkdir(outsideDir, { recursive: true }); + const plugin = await makePlugin( + tmp.path, + "cwd-symlink", + mcpDoc({ srv: { type: "stdio", command: "x", cwd: "./link" } }) + ); + await fs.symlink(outsideDir, path.join(plugin.rootPath, "link")); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain("escapes"); + }); + + test("validates remote URLs: userinfo, fragment, non-loopback http rejected; loopback http accepted", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "urls", + mcpDoc({ + userinfo: { type: "streamable-http", url: "https://user:pw@x.example.com/mcp" }, + fragment: { type: "streamable-http", url: "https://x.example.com/mcp#frag" }, + plainHttp: { type: "streamable-http", url: "http://x.example.com/mcp" }, + relative: { type: "streamable-http", url: "/mcp" }, + ftp: { type: "streamable-http", url: "ftp://x.example.com/mcp" }, + localhostHttp: { type: "streamable-http", url: "http://localhost:3000/mcp" }, + loopbackIp: { type: "streamable-http", url: "http://127.0.0.1:3000/mcp" }, + loopbackV6: { type: "streamable-http", url: "http://[::1]:3000/mcp" }, + }) + ); + + const { servers } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + const instanceId = computePluginInstanceId(plugin.rootPath); + const loaded = Object.keys(servers).sort(); + expect(loaded).toEqual( + ["localhostHttp", "loopbackIp", "loopbackV6"] + .map((name) => buildPluginServerKey(instanceId, name)) + .sort() + ); + }); + + test("rejects duplicate case-insensitive header names and invalid header fields", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "headers", + mcpDoc({ + dupes: { + type: "streamable-http", + url: "https://x.example.com/mcp", + headers: { "X-Tenant": "a", "x-tenant": "b" }, + }, + badName: { + type: "streamable-http", + url: "https://x.example.com/mcp", + headers: { "Bad Name": "a" }, + }, + badValue: { + type: "streamable-http", + url: "https://x.example.com/mcp", + headers: { "X-Ok": "a\r\nInjected: b" }, + }, + }) + ); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics).toHaveLength(3); + expect(diagnostics.every((d) => d.severity === "error")).toBe(true); + }); + + test("server keys and PLUGIN_DATA are stable across manifest renames and content changes", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin(tmp.path, "stable", mcpDoc({ srv: STDIO_ENTRY })); + + const first = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + // Simulate a manifest rename + version bump: same root path. + const renamed: AgentPluginInfo = { + ...plugin, + name: "renamed-plugin", + manifest: { schemaId: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "renamed-plugin", version: "2.0" }, + }; + const second = await loadPluginMcpServers(renamed, { muxHome: tmp.path }); + + expect(Object.keys(second.servers)).toEqual(Object.keys(first.servers)); + expect(Object.keys(first.servers)).toHaveLength(1); + const firstInfo = Object.values(first.servers)[0] as MCPStdioServerInfo; + const secondInfo = Object.values(second.servers)[0] as MCPStdioServerInfo; + expect(secondInfo.env?.PLUGIN_DATA).toBe(firstInfo.env?.PLUGIN_DATA); + }); + + test("duplicate plugin names in different roots get distinct keys and data dirs", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const a = await makePlugin(path.join(tmp.path, "a"), "same-name", mcpDoc({ srv: STDIO_ENTRY })); + const b = await makePlugin(path.join(tmp.path, "b"), "same-name", mcpDoc({ srv: STDIO_ENTRY })); + + const resultA = await loadPluginMcpServers(a, { muxHome: tmp.path }); + const resultB = await loadPluginMcpServers(b, { muxHome: tmp.path }); + + const keyA = Object.keys(resultA.servers)[0]; + const keyB = Object.keys(resultB.servers)[0]; + expect(keyA).not.toBe(keyB); + const dataA = (Object.values(resultA.servers)[0] as MCPStdioServerInfo).env?.PLUGIN_DATA; + const dataB = (Object.values(resultB.servers)[0] as MCPStdioServerInfo).env?.PLUGIN_DATA; + expect(dataA).not.toBe(dataB); + }); +}); + +async function withHomeDir(homeDir: string, callback: () => Promise): Promise { + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + const homedirSpy = spyOn(os, "homedir"); + + homedirSpy.mockReturnValue(homeDir); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + + try { + await callback(); + } finally { + homedirSpy.mockRestore(); + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = previousUserProfile; + } + } +} + +async function writeDiscoverablePlugin( + containerPath: string, + name: string, + mcpJson: unknown +): Promise { + const pluginDir = path.join(containerPath, name); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name }), + "utf8" + ); + await fs.writeFile( + path.join(pluginDir, "mcp.json"), + typeof mcpJson === "string" ? mcpJson : JSON.stringify(mcpJson), + "utf8" + ); +} + +describe("createAgentPluginsMcpProvider", () => { + test("returns no servers when the experiment is disabled", async () => { + using home = new DisposableTempDir("plugin-provider-home"); + using muxHome = new DisposableTempDir("plugin-provider-mux"); + await withHomeDir(home.path, async () => { + await writeDiscoverablePlugin( + path.join(muxHome.path, "plugins"), + "demo", + mcpDoc({ srv: STDIO_ENTRY }) + ); + + const provider = createAgentPluginsMcpProvider({ + muxHome: muxHome.path, + isEnabled: () => false, + }); + expect(await provider({ trusted: false })).toEqual({}); + }); + }); + + test("discovers global plugin servers and gates project containers on trust", async () => { + using home = new DisposableTempDir("plugin-provider-home"); + using muxHome = new DisposableTempDir("plugin-provider-mux"); + using project = new DisposableTempDir("plugin-provider-project"); + await withHomeDir(home.path, async () => { + await writeDiscoverablePlugin( + path.join(muxHome.path, "plugins"), + "global-plugin", + mcpDoc({ srv: STDIO_ENTRY }) + ); + await writeDiscoverablePlugin( + path.join(home.path, ".agents", "plugins"), + "universal-plugin", + mcpDoc({ srv: STDIO_ENTRY }) + ); + await writeDiscoverablePlugin( + path.join(project.path, ".mux", "plugins"), + "project-plugin", + mcpDoc({ srv: STDIO_ENTRY }) + ); + + const provider = createAgentPluginsMcpProvider({ + muxHome: muxHome.path, + isEnabled: () => true, + }); + + const pluginNamesOf = (servers: Record) => + Object.values(servers) + .map((s) => s.plugin?.pluginName) + .sort(); + + // Untrusted project: only global containers contribute. + const untrusted = await provider({ projectPath: project.path, trusted: false }); + expect(pluginNamesOf(untrusted)).toEqual(["global-plugin", "universal-plugin"]); + + // Trusted project: project containers contribute too. + const trusted = await provider({ projectPath: project.path, trusted: true }); + expect(pluginNamesOf(trusted)).toEqual([ + "global-plugin", + "project-plugin", + "universal-plugin", + ]); + + // No project at all: global only. + const globalOnly = await provider({ trusted: false }); + expect(pluginNamesOf(globalOnly)).toEqual(["global-plugin", "universal-plugin"]); + }); + }); + + test("a plugin with broken mcp.json never affects sibling plugins", async () => { + using home = new DisposableTempDir("plugin-provider-home"); + using muxHome = new DisposableTempDir("plugin-provider-mux"); + await withHomeDir(home.path, async () => { + const container = path.join(muxHome.path, "plugins"); + await writeDiscoverablePlugin(container, "broken", "{ not json"); + await writeDiscoverablePlugin(container, "healthy", mcpDoc({ srv: STDIO_ENTRY })); + + const provider = createAgentPluginsMcpProvider({ + muxHome: muxHome.path, + isEnabled: () => true, + }); + const servers = await provider({ trusted: false }); + + expect(Object.values(servers).map((s) => s.plugin?.pluginName)).toEqual(["healthy"]); + }); + }); +}); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts new file mode 100644 index 0000000000..335b2a810f --- /dev/null +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -0,0 +1,521 @@ +import { createHash } from "node:crypto"; +import * as fsPromises from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { MCPServerInfo, MCPStdioServerInfo } from "@/common/types/mcp"; +import assert from "@/common/utils/assert"; +import { getErrorMessage } from "@/common/utils/errors"; +import { log } from "@/node/services/log"; +import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import type { AgentPluginContainer, AgentPluginDiagnostic, AgentPluginInfo } from "./discovery"; +import { discoverAgentPlugins } from "./discovery"; +import { expandPluginPlaceholders, type PluginPlaceholderValues } from "./expansion"; + +/** + * Agent Plugins 1.0.0 MCP configuration (`mcp.json`, §7.2) → Mux MCPServerInfo. + * + * Loading rules follow §7.2.2 exactly: + * - invalid JSON / bad top-level / `$schema` mismatch → MCP disabled for that + * plugin only (diagnostic), other components unaffected; + * - an invalid or unsupported server entry → that entry skipped (diagnostic), + * siblings unaffected. + * + * Normalized servers are default-disabled, read-only config entries keyed by + * `plugin::` where `instanceId` hashes the canonical + * plugin root. The key is stable across manifest renames and content updates, + * so workspace `enabledServers` overrides and the `PLUGIN_DATA` directory + * survive plugin updates (§9.1). + */ + +/** Canonical `$schema` const for Agent Plugins 1.0.0 mcp.json documents. */ +export const AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0 = + "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; + +const PLUGIN_SERVER_KEY_PREFIX = "plugin:"; + +/** Stable plugin-instance identity keyed on the canonical root path (not name/content). */ +export function computePluginInstanceId(rootPath: string): string { + assert(path.isAbsolute(rootPath), "computePluginInstanceId: rootPath must be absolute"); + return createHash("sha256").update(rootPath).digest("hex").slice(0, 16); +} + +/** Client-managed persistent data directory for a plugin instance (§9.1). */ +export function getPluginDataPath(muxHome: string, instanceId: string): string { + assert(path.isAbsolute(muxHome), "getPluginDataPath: muxHome must be absolute"); + return path.join(muxHome, "plugin-data", instanceId); +} + +export function buildPluginServerKey(instanceId: string, serverName: string): string { + return `${PLUGIN_SERVER_KEY_PREFIX}${instanceId}:${serverName}`; +} + +export interface LoadPluginMcpServersResult { + servers: Record; + diagnostics: AgentPluginDiagnostic[]; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +// Closed variants (§7.2.1): an unknown field makes the entry invalid. +const STDIO_ENTRY_KEYS = new Set(["type", "command", "args", "env", "cwd"]); +const REMOTE_ENTRY_KEYS = new Set(["type", "url", "headers"]); + +// Canonical mcp.schema.json cwd pattern: `./…`, `${PLUGIN_ROOT}[/…]`, `${PLUGIN_DATA}[/…]`. +const CWD_FORM_PATTERN = /^(?:\.\/|\$\{PLUGIN_ROOT\}(?:\/|$)|\$\{PLUGIN_DATA\}(?:\/|$))/; + +// RFC 9110 token grammar for header field names. +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + +/** + * §7.2.1 command token rules: a single executable token — either a bare name + * (resolved via platform executable search) or a `./`-relative plugin path. + * No placeholder expansion, no shell strings, no absolute/parent paths. + */ +function classifyCommandToken(command: string): "bare" | "relative" | null { + if (command.length === 0) { + return null; + } + if (command.startsWith("./")) { + return "relative"; + } + if (command.includes("/") || command.includes("\\")) { + return null; + } + return "bare"; +} + +function isLoopbackHost(hostname: string): boolean { + const host = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + if (host === "localhost") { + return true; + } + if (host === "::1") { + return true; + } + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); +} + +/** Returns an error message for invalid remote URLs (§7.2.1), or null when valid. */ +function validateRemoteUrl(rawUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return "'url' must be an absolute HTTP or HTTPS URL"; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "'url' must use http or https"; + } + if (parsed.username !== "" || parsed.password !== "") { + return "'url' must not contain user information"; + } + if (parsed.hash !== "") { + return "'url' must not contain a fragment"; + } + if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) { + return "non-loopback endpoints must use HTTPS"; + } + return null; +} + +/** + * Containment that tolerates a not-yet-created containment root (the + * `PLUGIN_DATA` directory is created lazily before launch). When the root does + * not exist, nothing beneath it can be a symlink, so lexical containment is + * sufficient and equivalent. + */ +async function ensureContainedAllowMissingRoot(root: string, candidate: string): Promise { + try { + return await ensurePathContained(root, candidate, { allowMissing: true }); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + throw error; + } + const resolved = path.resolve(candidate); + const relative = path.relative(root, resolved); + if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) { + throw new Error("Path resolves outside containment root."); + } + return resolved; + } +} + +interface NormalizeContext { + plugin: AgentPluginInfo; + dataPath: string; + vars: PluginPlaceholderValues; +} + +/** Validate + normalize one stdio entry; returns null (with error) when invalid. */ +async function normalizeStdioEntry( + entry: Record, + ctx: NormalizeContext +): Promise<{ info: MCPStdioServerInfo } | { error: string }> { + for (const key of Object.keys(entry)) { + if (!STDIO_ENTRY_KEYS.has(key)) { + return { error: `unknown field '${key}' in stdio server entry` }; + } + } + + const command = entry.command; + if (typeof command !== "string" || classifyCommandToken(command) === null) { + return { + error: + "'command' must be a single executable token: a bare name or a './'-relative plugin path", + }; + } + + if (entry.args !== undefined) { + if (!Array.isArray(entry.args) || entry.args.some((arg) => typeof arg !== "string")) { + return { error: "'args' must be an array of strings" }; + } + } + const args = (entry.args as string[] | undefined) ?? []; + + if (entry.env !== undefined && !isPlainObject(entry.env)) { + return { error: "'env' must be an object of strings" }; + } + const env = entry.env ?? {}; + for (const [key, value] of Object.entries(env)) { + if (key === "PLUGIN_ROOT" || key === "PLUGIN_DATA") { + // §9.2: reserved names in `env` make the entry invalid; the client + // supplies these variables itself. + return { error: `'env' must not contain reserved variable '${key}'` }; + } + if (typeof value !== "string") { + return { error: `'env.${key}' must be a string` }; + } + } + + if (entry.cwd !== undefined && typeof entry.cwd !== "string") { + return { error: "'cwd' must be a string" }; + } + const cwd = entry.cwd; + if (cwd !== undefined && !CWD_FORM_PATTERN.test(cwd)) { + return { + error: "'cwd' must be './…', '${PLUGIN_ROOT}[/…]', or '${PLUGIN_DATA}[/…]'", + }; + } + + const rootPath = ctx.plugin.rootPath; + + // `./`-relative commands resolve against the plugin root and must + // realpath-resolve inside it (§4.1). Bare names use PATH search at launch. + let resolvedCommand = command; + if (command.startsWith("./")) { + try { + resolvedCommand = await ensurePathContained(rootPath, path.resolve(rootPath, command)); + } catch (error) { + return { + error: hasErrorCode(error, "ENOENT") + ? `'command' does not exist inside the plugin: ${command}` + : `'command' resolves outside the plugin root: ${getErrorMessage(error)}`, + }; + } + } + + // §9.2 expansion applies to args elements, env values, and cwd only. + const expandedArgs = args.map((arg) => expandPluginPlaceholders(arg, ctx.vars)); + const expandedEnv: Record = {}; + for (const [key, value] of Object.entries(env)) { + expandedEnv[key] = expandPluginPlaceholders(value as string, ctx.vars); + } + + // Default cwd is the plugin root; explicit cwd stays inside its anchor + // (plugin root or data dir) after expansion + resolution (§7.2.1). + let resolvedCwd = rootPath; + if (cwd !== undefined) { + const expandedCwd = expandPluginPlaceholders(cwd, ctx.vars); + const anchor = cwd.startsWith("${PLUGIN_DATA}") ? ctx.dataPath : rootPath; + try { + resolvedCwd = await ensureContainedAllowMissingRoot( + anchor, + path.resolve(rootPath, expandedCwd) + ); + } catch (error) { + return { error: `'cwd' escapes its containment root: ${getErrorMessage(error)}` }; + } + } + + return { + info: { + transport: "stdio", + command: resolvedCommand, + args: expandedArgs, + // §9.1 overlay order: configured env first, reserved variables last so + // they can never be shadowed. + env: { + ...expandedEnv, + PLUGIN_ROOT: rootPath, + PLUGIN_DATA: ctx.dataPath, + }, + cwd: resolvedCwd, + disabled: true, + plugin: { + pluginName: ctx.plugin.name, + serverName: "", // filled by caller + sourceScope: ctx.plugin.scope, + }, + }, + }; +} + +/** Validate + normalize one streamable-http/sse entry. */ +function normalizeRemoteEntry( + entry: Record, + ctx: NormalizeContext +): { info: MCPServerInfo } | { error: string } | { skip: string } { + for (const key of Object.keys(entry)) { + if (!REMOTE_ENTRY_KEYS.has(key)) { + return { error: `unknown field '${key}' in remote server entry` }; + } + } + + const url = entry.url; + if (typeof url !== "string") { + return { error: "'url' is required and must be a string" }; + } + const urlError = validateRemoteUrl(url); + if (urlError !== null) { + return { error: urlError }; + } + + if (entry.headers !== undefined) { + if (!isPlainObject(entry.headers)) { + return { error: "'headers' must be an object of strings" }; + } + const seenNames = new Set(); + for (const [name, value] of Object.entries(entry.headers)) { + if (!HEADER_NAME_PATTERN.test(name)) { + return { error: `invalid header name '${name}'` }; + } + if (typeof value !== "string" || /[\r\n\0]/.test(value)) { + return { error: `header '${name}' must be a valid HTTP header value` }; + } + const lower = name.toLowerCase(); + if (seenNames.has(lower)) { + return { error: `duplicate header name '${name}' (header names are case-insensitive)` }; + } + seenNames.add(lower); + } + + if (Object.keys(entry.headers).length > 0) { + // §7.2.1 forbids forwarding configured headers cross-origin via + // redirects; Mux remote transports follow redirects, so entries with + // configured headers are skipped rather than risk leaking them. + return { + skip: "configured 'headers' are not supported yet (cross-origin redirect header forwarding cannot be prevented)", + }; + } + } + + return { + info: { + transport: entry.type === "streamable-http" ? "http" : "sse", + url, + disabled: true, + plugin: { + pluginName: ctx.plugin.name, + serverName: "", // filled by caller + sourceScope: ctx.plugin.scope, + }, + }, + }; +} + +/** + * Load and normalize a plugin's `mcp.json` into default-disabled Mux server + * records keyed by `plugin::`. + */ +export async function loadPluginMcpServers( + plugin: AgentPluginInfo, + ctx: { muxHome: string } +): Promise { + assert( + plugin.mcpConfigPath !== undefined && path.isAbsolute(plugin.mcpConfigPath), + "loadPluginMcpServers: plugin.mcpConfigPath must be an absolute path" + ); + + const diagnostics: AgentPluginDiagnostic[] = []; + const servers: Record = {}; + + const disableMcp = (message: string): LoadPluginMcpServersResult => { + log.warn(`Agent plugin ${plugin.rootPath}: ${message}`); + diagnostics.push({ + path: plugin.mcpConfigPath!, + scope: plugin.scope, + severity: "error", + message, + }); + return { servers: {}, diagnostics }; + }; + + let raw: unknown; + try { + raw = JSON.parse(await fsPromises.readFile(plugin.mcpConfigPath, "utf8")) as unknown; + } catch (error) { + // §7.2.2 rule 2: invalid JSON disables MCP for this plugin only. + return disableMcp(`mcp.json is not valid JSON: ${getErrorMessage(error)}`); + } + + if (!isPlainObject(raw)) { + return disableMcp("mcp.json must be a JSON object"); + } + + // Closed top-level: exactly { $schema, mcpServers }, both required (§7.2.1). + for (const key of Object.keys(raw)) { + if (key !== "$schema" && key !== "mcpServers") { + return disableMcp(`mcp.json has unknown top-level field '${key}'`); + } + } + if (raw.$schema !== AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0) { + // Discovery only admits 1.0.0 manifests, so any other value is either an + // unsupported version or a version mismatch with plugin.json — both + // disable MCP for this plugin (§7.2.2 rule 2). + return disableMcp( + `mcp.json '$schema' must be '${AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0}' (matching plugin.json's Agent Plugins version)` + ); + } + if (!isPlainObject(raw.mcpServers)) { + return disableMcp("mcp.json 'mcpServers' is required and must be an object"); + } + + const instanceId = computePluginInstanceId(plugin.rootPath); + const dataPath = getPluginDataPath(ctx.muxHome, instanceId); + const normalizeCtx: NormalizeContext = { + plugin, + dataPath, + vars: { PLUGIN_ROOT: plugin.rootPath, PLUGIN_DATA: dataPath }, + }; + + for (const [serverName, entry] of Object.entries(raw.mcpServers)) { + const reportEntry = (severity: "warning" | "error", message: string): void => { + const fullMessage = `mcp.json server '${serverName}': ${message}; skipping this server`; + log.warn(`Agent plugin ${plugin.rootPath}: ${fullMessage}`); + diagnostics.push({ + path: plugin.mcpConfigPath!, + scope: plugin.scope, + severity, + message: fullMessage, + }); + }; + + if (!isPlainObject(entry)) { + reportEntry("error", "server entry must be an object"); + continue; + } + + // §7.2.2 rules 3-4: invalid entries and unknown transports are skipped + // individually; sibling servers keep loading. + let result: { info: MCPServerInfo } | { error: string } | { skip: string }; + if (entry.type === "stdio") { + result = await normalizeStdioEntry(entry, normalizeCtx); + } else if (entry.type === "streamable-http" || entry.type === "sse") { + result = normalizeRemoteEntry(entry, normalizeCtx); + } else { + reportEntry("error", `unknown server 'type' ${JSON.stringify(entry.type)}`); + continue; + } + + if ("error" in result) { + reportEntry("error", result.error); + continue; + } + if ("skip" in result) { + reportEntry("warning", result.skip); + continue; + } + + const info = result.info; + assert( + info.plugin !== undefined, + "loadPluginMcpServers: normalized info must carry provenance" + ); + info.plugin.serverName = serverName; + servers[buildPluginServerKey(instanceId, serverName)] = info; + } + + return { servers, diagnostics }; +} + +export interface AgentPluginsMcpProviderArgs { + /** Host project root for project-scope plugin containers (when applicable). */ + projectPath?: string; + /** Whether repo-local (project-scope) plugin config is allowed (Project Trust). */ + trusted: boolean; +} + +export type AgentPluginsMcpProvider = ( + args: AgentPluginsMcpProviderArgs +) => Promise>; + +/** + * Build the MCP server provider for Agent Plugins containers. Returns an empty + * map when the agent-plugins experiment is off. Project-scope containers are + * consulted only for trusted projects (mirroring repo `.mux/mcp.jsonc`). + * Failures in one plugin never affect others (§11.3). + */ +export function createAgentPluginsMcpProvider(ctx: { + muxHome: string; + isEnabled: () => boolean; +}): AgentPluginsMcpProvider { + assert( + path.isAbsolute(ctx.muxHome), + "createAgentPluginsMcpProvider: muxHome must be an absolute path" + ); + + return async (args) => { + if (!ctx.isEnabled()) { + return {}; + } + + const containers: AgentPluginContainer[] = []; + if (args.projectPath !== undefined && args.trusted && path.isAbsolute(args.projectPath)) { + containers.push({ path: path.join(args.projectPath, ".mux", "plugins"), scope: "project" }); + containers.push({ + path: path.join(args.projectPath, ".agents", "plugins"), + scope: "project", + }); + } + containers.push({ path: path.join(ctx.muxHome, "plugins"), scope: "global" }); + containers.push({ path: path.join(os.homedir(), ".agents", "plugins"), scope: "global" }); + + const merged: Record = {}; + try { + const { plugins } = await discoverAgentPlugins(containers); + for (const plugin of plugins) { + if (plugin.mcpConfigPath === undefined) { + continue; + } + // Project plugin roots keep the repo-symlink posture of repo config: + // the plugin root itself must stay inside the project root. + if (plugin.scope === "project" && args.projectPath !== undefined) { + try { + await ensurePathContained(args.projectPath, plugin.rootPath); + } catch (error) { + log.warn( + `Skipping project plugin '${plugin.name}' MCP config: plugin root escapes the project root: ${getErrorMessage(error)}` + ); + continue; + } + } + try { + const { servers } = await loadPluginMcpServers(plugin, { muxHome: ctx.muxHome }); + Object.assign(merged, servers); + } catch (error) { + // §11.3: one broken plugin never affects the others. + log.warn( + `Agent plugin ${plugin.rootPath}: failed to load MCP config: ${getErrorMessage(error)}` + ); + } + } + } catch (error) { + log.warn(`Agent Plugins MCP discovery failed: ${getErrorMessage(error)}`); + } + return merged; + }; +} diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index e381444bd9..a23c0492d7 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -18,6 +18,8 @@ import { type GoalLifecycleAnalyticsSink, type WorkspaceGoalServiceOptions, } from "@/node/services/workspaceGoalService"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { createAgentPluginsMcpProvider } from "@/node/services/agentPlugins/mcpConfig"; import { MCPConfigService } from "@/node/services/mcpConfigService"; import { MCPServerManager, type MCPServerManagerOptions } from "@/node/services/mcpServerManager"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; @@ -134,7 +136,16 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { ); // MCP: allow callers to override which Config provides server definitions - const mcpConfigService = new MCPConfigService(opts.mcpConfig ?? config); + const mcpConfig = opts.mcpConfig ?? config; + // Agent Plugins (agent-plugins experiment): read-only plugin MCP servers are + // merged into listings; without an ExperimentsService the provider is inert. + const mcpConfigService = new MCPConfigService(mcpConfig, { + agentPluginsMcpProvider: createAgentPluginsMcpProvider({ + muxHome: mcpConfig.rootDir, + isEnabled: () => + opts.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS) === true, + }), + }); const mcpServerManager = new MCPServerManager( mcpConfigService, opts.mcpServerManagerOptions, diff --git a/src/node/services/mcpConfigService.test.ts b/src/node/services/mcpConfigService.test.ts index fa5a4f947e..53702cb620 100644 --- a/src/node/services/mcpConfigService.test.ts +++ b/src/node/services/mcpConfigService.test.ts @@ -94,12 +94,13 @@ describe("MCPConfigService", () => { describe("MCP server disable filtering", () => { let tempDir: string; + let config: Config; let configService: MCPConfigService; let serverManager: MCPServerManager; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-test-")); - const config = new Config(tempDir); + config = new Config(tempDir); configService = new MCPConfigService(config); serverManager = new MCPServerManager(configService); @@ -137,6 +138,85 @@ describe("MCP server disable filtering", () => { "enabled-server": { transport: "stdio", command: "cmd1", disabled: false }, }); }); + + // --- Agent Plugins provider (agent-plugins experiment) --- + + const PLUGIN_SERVER = { + transport: "stdio" as const, + command: "bunx", + args: ["-y", "some-server"], + disabled: true, + plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" as const }, + }; + + test("listServers merges Agent Plugins servers at the lowest precedence", async () => { + const withProvider = new MCPConfigService(config, { + agentPluginsMcpProvider: () => + Promise.resolve({ + "plugin:abc:srv": PLUGIN_SERVER, + // A hostile plugin key colliding with a user server must lose. + collides: { ...PLUGIN_SERVER, command: "plugin-command" }, + }), + }); + await withProvider.addServer("collides", { transport: "stdio", command: "user-command" }); + + const servers = await withProvider.listServers(); + + expect(servers["plugin:abc:srv"]).toEqual(PLUGIN_SERVER); + expect(servers.collides).toEqual({ + transport: "stdio", + command: "user-command", + disabled: false, + toolAllowlist: undefined, + }); + }); + + test("listServers passes projectPath and trust through to the provider", async () => { + const seenArgs: Array<{ projectPath?: string; trusted: boolean }> = []; + const withProvider = new MCPConfigService(config, { + agentPluginsMcpProvider: (args) => { + seenArgs.push(args); + return Promise.resolve({}); + }, + }); + + await withProvider.listServers(); + await withProvider.listServers("/proj", false); + await withProvider.listServers("/proj", true); + + expect(seenArgs).toEqual([ + { projectPath: undefined, trusted: false }, + { projectPath: "/proj", trusted: false }, + { projectPath: "/proj", trusted: true }, + ]); + }); + + test("a throwing Agent Plugins provider never breaks listServers", async () => { + const withProvider = new MCPConfigService(config, { + agentPluginsMcpProvider: () => Promise.reject(new Error("boom")), + }); + await withProvider.addServer("still-there", { transport: "stdio", command: "cmd" }); + + const servers = await withProvider.listServers(); + + expect(Object.keys(servers)).toEqual(["still-there"]); + }); + + test("plugin servers are never persisted: mutations reject plugin keys", async () => { + const withProvider = new MCPConfigService(config, { + agentPluginsMcpProvider: () => Promise.resolve({ "plugin:abc:srv": PLUGIN_SERVER }), + }); + + expect((await withProvider.setServerEnabled("plugin:abc:srv", true)).success).toBe(false); + expect((await withProvider.removeServer("plugin:abc:srv")).success).toBe(false); + expect((await withProvider.setToolAllowlist("plugin:abc:srv", [])).success).toBe(false); + + // The on-disk global config never gained a plugin entry. + const globalRaw = await fs + .readFile(path.join(config.rootDir, "mcp.jsonc"), "utf-8") + .catch(() => ""); + expect(globalRaw).not.toContain("plugin:abc:srv"); + }); }); describe("Workspace MCP overrides filtering", () => { diff --git a/src/node/services/mcpConfigService.ts b/src/node/services/mcpConfigService.ts index d05b33a774..0ebd3d9798 100644 --- a/src/node/services/mcpConfigService.ts +++ b/src/node/services/mcpConfigService.ts @@ -12,19 +12,28 @@ import { Ok, Err } from "@/common/types/result"; import type { Result } from "@/common/types/result"; import assert from "@/common/utils/assert"; import type { Config } from "@/node/config"; +import type { AgentPluginsMcpProvider } from "@/node/services/agentPlugins/mcpConfig"; import { log } from "@/node/services/log"; import { getErrorMessage } from "@/common/utils/errors"; export class MCPConfigService { private readonly config: Config; + /** + * Agent Plugins (agent-plugins experiment): read-only extra server source + * merged into listings. Plugin servers are never persisted — every mutation + * below operates on the on-disk global config only, so `plugin:*` keys + * naturally fail with "not found". + */ + private readonly agentPluginsMcpProvider: AgentPluginsMcpProvider | null; - constructor(config: Config) { + constructor(config: Config, options?: { agentPluginsMcpProvider?: AgentPluginsMcpProvider }) { assert( typeof config.rootDir === "string" && config.rootDir.trim().length > 0, "MCPConfigService: config.rootDir must be a non-empty string" ); this.config = config; + this.agentPluginsMcpProvider = options?.agentPluginsMcpProvider ?? null; } private getGlobalConfigPath(): string { @@ -219,23 +228,36 @@ export class MCPConfigService { * - When no projectPath is provided: returns global servers from /mcp.jsonc * - When projectPath is provided and trusted=false: returns only global servers * - When projectPath is provided and trusted=true: merges global + /.mux/mcp.jsonc + * - Agent Plugins servers (when the experiment provider is wired) are merged + * at the lowest precedence: user config always wins on key collisions. */ async listServers(projectPath?: string, trusted = false): Promise> { + let pluginServers: Record = {}; + if (this.agentPluginsMcpProvider) { + try { + pluginServers = await this.agentPluginsMcpProvider({ projectPath, trusted }); + } catch (error) { + // Plugin discovery failures must never break MCP config listing. + log.warn("[MCP] Agent Plugins server discovery failed", { error }); + } + } + const globalCfg = await this.getGlobalConfig(); if (!projectPath) { - return globalCfg.servers; + return { ...pluginServers, ...globalCfg.servers }; } if (!trusted) { log.debug("[MCP] Skipping project-local MCP config for untrusted project", { projectPath }); - return globalCfg.servers; + return { ...pluginServers, ...globalCfg.servers }; } const repoCfg = await this.getRepoOverrideConfig(projectPath); // Repo overrides win by server name. return { + ...pluginServers, ...globalCfg.servers, ...repoCfg.servers, }; diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 5bcaef7f54..86a90b8541 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -1,15 +1,20 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { createServer } from "http"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import * as mcpSdk from "@ai-sdk/mcp"; import { MCPServerManager, isClosedClientError, + prepareStdioLaunch, runMCPToolWithDeadline, wrapMCPTools, } from "./mcpServerManager"; import type { MCPConfigService } from "./mcpConfigService"; import type { Runtime } from "@/node/runtime/Runtime"; +import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; +import { DisposableTempDir } from "@/node/services/tempDir"; import type { Tool } from "ai"; interface MCPServerManagerTestAccess { @@ -619,7 +624,7 @@ describe("MCPServerManager", () => { access.workspaceServers.set(workspaceId, { configSignature: JSON.stringify({ - slow: { transport: "stdio", command: "cmd-slow" }, + slow: { transport: "stdio", command: "cmd-slow", args: null, env: null, cwd: null }, }), instances: new Map(), stats: cachedStats(), @@ -703,7 +708,7 @@ describe("MCPServerManager", () => { const staleEntry = { configSignature: JSON.stringify({ - slow: { transport: "stdio", command: "cmd-1" }, + slow: { transport: "stdio", command: "cmd-1", args: null, env: null, cwd: null }, }), instances: new Map(), stats: cachedStats(), @@ -1164,6 +1169,142 @@ describe("MCPServerManager", () => { await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); expect(startServersMock).toHaveBeenCalledTimes(2); }); + + // --- Agent Plugins (agent-plugins experiment) --- + + const PLUGIN_KEY = "plugin:abcdef0123456789:everything"; + + function pluginStdioConfig(overrides: Record = {}) { + return { + [PLUGIN_KEY]: { + transport: "stdio" as const, + command: "bunx", + args: ["-y", "some-server"], + env: { PLUGIN_ROOT: "/plugins/demo", PLUGIN_DATA: "/tmp/mux-test-plugin-data" }, + cwd: "/plugins/demo", + disabled: true, + plugin: { + pluginName: "demo", + serverName: "everything", + sourceScope: "global" as const, + }, + ...overrides, + }, + }; + } + + test("default-disabled plugin servers start only with a workspace enabledServers override", async () => { + configService.listServers = mock(() => Promise.resolve(pluginStdioConfig())); + const startServersMock = spyOn(access, "startServers").mockImplementation( + (...args: unknown[]) => { + const servers = args[0] as Record; + return Promise.resolve(startResult(Object.keys(servers).map((name) => [name, undefined]))); + } + ); + + const withoutOverride = await manager.getToolsForWorkspace(workspaceRequest("ws-plugin-off")); + expect(withoutOverride.stats.enabledServerCount).toBe(0); + + const withOverride = await manager.getToolsForWorkspace( + workspaceRequest("ws-plugin-on", { overrides: { enabledServers: [PLUGIN_KEY] } }) + ); + expect(withOverride.stats.enabledServerCount).toBe(1); + const startedServers = startServersMock.mock.calls.at(-1)?.[0] as Record; + expect(Object.keys(startedServers)).toEqual([PLUGIN_KEY]); + }); + + test("plugin servers are excluded on remote runtimes", async () => { + configService.listServers = mock(() => Promise.resolve(pluginStdioConfig())); + spyOn(access, "startServers").mockImplementation(() => Promise.resolve(startResult([]))); + + // Runtime identity is all the gate needs; RemoteRuntime is abstract. + const remoteRuntime = Object.create(RemoteRuntime.prototype) as Runtime; + const result = await manager.getToolsForWorkspace( + workspaceRequest("ws-plugin-remote", { + runtime: remoteRuntime, + overrides: { enabledServers: [PLUGIN_KEY] }, + }) + ); + + expect(result.stats.enabledServerCount).toBe(0); + }); + + test("stdio config signature includes args/env/cwd so plugin mcp.json edits recycle servers", async () => { + const startServersMock = spyOn(access, "startServers").mockImplementation(() => + Promise.resolve(startResult([[PLUGIN_KEY, undefined]])) + ); + const overrides = { enabledServers: [PLUGIN_KEY] }; + + configService.listServers = mock(() => Promise.resolve(pluginStdioConfig())); + await manager.getToolsForWorkspace(workspaceRequest("ws-plugin-sig", { overrides })); + expect(startServersMock).toHaveBeenCalledTimes(1); + + // Same command, changed args: signature must change and servers restart. + configService.listServers = mock(() => + Promise.resolve(pluginStdioConfig({ args: ["-y", "some-server", "--changed"] })) + ); + await manager.getToolsForWorkspace(workspaceRequest("ws-plugin-sig", { overrides })); + expect(startServersMock).toHaveBeenCalledTimes(2); + + // Unchanged config: cached instances are reused. + await manager.getToolsForWorkspace(workspaceRequest("ws-plugin-sig", { overrides })); + expect(startServersMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("prepareStdioLaunch", () => { + test("keeps legacy raw shell-string behavior when args is unset", async () => { + const launch = await prepareStdioLaunch({ + transport: "stdio", + command: "bunx -y some-server", + disabled: false, + }); + expect(launch).toEqual({ command: "bunx -y some-server" }); + }); + + test("argv mode quotes command and each arg against shell injection", async () => { + const launch = await prepareStdioLaunch({ + transport: "stdio", + command: "/plugins/my plugin/bin/tool", + args: ["a b", "$(rm -rf /)", "`tick`", "it's", ""], + disabled: false, + }); + expect(launch.command).toBe( + "'/plugins/my plugin/bin/tool' 'a b' '$(rm -rf /)' '`tick`' 'it'\"'\"'s' ''" + ); + }); + + test("creates the PLUGIN_DATA directory for plugin servers before launch", async () => { + using tmp = new DisposableTempDir("mcp-plugin-data"); + const dataPath = path.join(tmp.path, "plugin-data", "abc123"); + + const launch = await prepareStdioLaunch({ + transport: "stdio", + command: "bunx", + args: [], + env: { PLUGIN_ROOT: tmp.path, PLUGIN_DATA: dataPath }, + cwd: tmp.path, + disabled: false, + plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" }, + }); + + expect((await fs.stat(dataPath)).isDirectory()).toBe(true); + expect(launch.cwd).toBe(tmp.path); + expect(launch.env?.PLUGIN_DATA).toBe(dataPath); + }); + + test("rejects plugin servers without an absolute PLUGIN_DATA env (defensive)", async () => { + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + prepareStdioLaunch({ + transport: "stdio", + command: "bunx", + args: [], + disabled: false, + plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" }, + }) + ).rejects.toThrow("PLUGIN_DATA"); + }); }); describe("isClosedClientError", () => { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 366b38cb55..4e6fa6a666 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1,3 +1,5 @@ +import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; import { createMCPClient, type OAuthClientProvider } from "@ai-sdk/mcp"; import type { Tool } from "ai"; import { log } from "@/node/services/log"; @@ -8,10 +10,14 @@ import type { MCPServerInfo, MCPServerMap, MCPServerTransport, + MCPStdioServerInfo, MCPTestResult, WorkspaceMCPOverrides, } from "@/common/types/mcp"; +import assert from "@/common/utils/assert"; +import { shellQuote } from "@/common/utils/shell"; import type { Runtime } from "@/node/runtime/Runtime"; +import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; import type { PolicyService } from "@/node/services/policyService"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import { @@ -411,13 +417,52 @@ async function extractBearerOauthChallenge(options: { export type { MCPTestResult } from "@/common/types/mcp"; +/** Shell command + exec options composed for a stdio server launch. */ +interface StdioLaunch { + command: string; + cwd?: string; + env?: Record; +} + +/** + * Compose the shell command string and exec options for a stdio server. + * + * Servers with `args` set (Agent Plugins) run in argv mode: `command` and each + * arg are individually shell-quoted, so hostile arg content cannot inject + * shell syntax. Legacy entries (no `args`) keep raw shell-string behavior. + * + * For Agent Plugin servers this also creates the `PLUGIN_DATA` directory, + * which the spec requires to exist before the subprocess launches (§9.1). + * + * Exported for tests. + */ +export async function prepareStdioLaunch(info: MCPStdioServerInfo): Promise { + const command = + info.args !== undefined ? [info.command, ...info.args].map(shellQuote).join(" ") : info.command; + + if (info.plugin !== undefined) { + const dataPath = info.env?.PLUGIN_DATA; + assert( + dataPath !== undefined && path.isAbsolute(dataPath), + "prepareStdioLaunch: plugin stdio server must carry an absolute PLUGIN_DATA env" + ); + await fsPromises.mkdir(dataPath, { recursive: true }); + } + + return { + command, + ...(info.cwd !== undefined ? { cwd: info.cwd } : {}), + ...(info.env !== undefined ? { env: info.env } : {}), + }; +} + /** * Run a test connection to an MCP server. * Connects, fetches tools, then closes. */ async function runServerTest( server: - | { transport: "stdio"; command: string } + | { transport: "stdio"; command: string; cwd?: string; env?: Record } | { transport: "http" | "sse" | "auto"; url: string; @@ -442,7 +487,8 @@ async function runServerTest( log.debug(`[MCP] Testing ${logContext}`, { transport: "stdio" }); const execStream = await runtime.exec(server.command, { - cwd: projectPath, + cwd: server.cwd ?? projectPath, + ...(server.env !== undefined ? { env: server.env } : {}), timeout: TEST_TIMEOUT_MS / 1000, }); @@ -932,7 +978,18 @@ export class MCPServerManager { } = options; // Fetch full server info for project-level allowlists and server filtering - const fullServerInfo = await this.getAllServers(projectPath, trusted); + const allServers = await this.getAllServers(projectPath, trusted); + + // Agent Plugins v1: plugin roots are host filesystem paths, so plugin + // servers are only offered when the workspace runtime executes on the host. + const fullServerInfo: Record = {}; + for (const [name, info] of Object.entries(allServers)) { + if (info.plugin !== undefined && runtime instanceof RemoteRuntime) { + log.debug("[MCP] Skipping Agent Plugin server on remote runtime", { workspaceId, name }); + continue; + } + fullServerInfo[name] = info; + } // Apply server-level overrides (enabled/disabled) before caching const enabledServers = this.filterServersByPolicy( @@ -947,7 +1004,14 @@ export class MCPServerManager { const signatureEntries: Record = {}; for (const [name, info] of enabledEntries) { if (info.transport === "stdio") { - signatureEntries[name] = { transport: "stdio", command: info.command }; + // args/env/cwd participate so plugin mcp.json edits recycle servers. + signatureEntries[name] = { + transport: "stdio", + command: info.command, + args: info.args ?? null, + env: info.env ?? null, + cwd: info.cwd ?? null, + }; continue; } @@ -1328,8 +1392,9 @@ export class MCPServerManager { } if (server.transport === "stdio") { + const launch = await prepareStdioLaunch(server); return runServerTest( - { transport: "stdio", command: server.command }, + { transport: "stdio", ...launch }, projectPath, `server "${trimmedName}"` ); @@ -1652,8 +1717,10 @@ export class MCPServerManager { if (info.transport === "stdio") { log.debug("[MCP] Spawning stdio server", { name }); - const execStream = await runtime.exec(info.command, { - cwd: workspacePath, + const launch = await prepareStdioLaunch(info); + const execStream = await runtime.exec(launch.command, { + cwd: launch.cwd ?? workspacePath, + ...(launch.env !== undefined ? { env: launch.env } : {}), timeout: 60 * 60 * 24, // 24 hours — process lifetime, not startup abortSignal: signal, }); diff --git a/tests/fixtures/agent-plugins/broken-plugin/plugin.json b/tests/fixtures/agent-plugins/broken-plugin/plugin.json new file mode 100644 index 0000000000..8c1ec8caeb --- /dev/null +++ b/tests/fixtures/agent-plugins/broken-plugin/plugin.json @@ -0,0 +1 @@ +{ "name": "broken-plugin", INVALID \ No newline at end of file diff --git a/tests/fixtures/agent-plugins/hello-plugin/mcp.json b/tests/fixtures/agent-plugins/hello-plugin/mcp.json new file mode 100644 index 0000000000..fec445d8c8 --- /dev/null +++ b/tests/fixtures/agent-plugins/hello-plugin/mcp.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "everything": { + "type": "stdio", + "command": "bunx", + "args": ["-y", "@modelcontextprotocol/server-everything"], + "env": { "DATA_DIR": "${PLUGIN_DATA}/d" } + }, + "plugin-files": { + "type": "stdio", + "command": "bunx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "${PLUGIN_ROOT}"] + }, + "broken-entry": { + "type": "stdio" + } + } +} diff --git a/tests/fixtures/agent-plugins/hello-plugin/plugin.json b/tests/fixtures/agent-plugins/hello-plugin/plugin.json new file mode 100644 index 0000000000..8b6975e6e6 --- /dev/null +++ b/tests/fixtures/agent-plugins/hello-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "hello-plugin", + "version": "1.0.0", + "description": "Mux fixture plugin exercising Agent Plugins skills + MCP discovery", + "author": { "name": "Mux" }, + "license": "MIT", + "keywords": ["fixture", "testing"] +} diff --git a/tests/fixtures/agent-plugins/hello-plugin/skills/hello-greeter/SKILL.md b/tests/fixtures/agent-plugins/hello-plugin/skills/hello-greeter/SKILL.md new file mode 100644 index 0000000000..b5df120232 --- /dev/null +++ b/tests/fixtures/agent-plugins/hello-plugin/skills/hello-greeter/SKILL.md @@ -0,0 +1,10 @@ +--- +name: hello-greeter +description: Fixture skill from the hello-plugin Agent Plugin; greets the user by name. +--- + +# Hello Greeter + +This skill ships inside the `hello-plugin` Agent Plugin fixture +(`tests/fixtures/agent-plugins/hello-plugin`). When invoked, greet the user +warmly and mention that this greeting came from an Agent Plugin skill. From 4ddf5f3be3db79658b7ef77968dbe9810600bd3d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 20:38:15 +0000 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=A4=96=20docs:=20document=20the=20A?= =?UTF-8?q?gent=20Plugins=20experiment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the claude-skills-compat precedent: a skills-discovery section in agent-skills.mdx and a server section in mcp-servers.mdx covering the default-disabled / read-only / trust-gated / host-only posture. Also fixes a stray prettier formatting nit in agentSkillsService.test.ts. --- docs/agents/agent-skills.mdx | 9 +++++++++ docs/config/mcp-servers.mdx | 11 ++++++++++ .../agentSkills/agentSkillsService.test.ts | 5 ++++- .../builtInSkillContent.generated.ts | 20 +++++++++++++++++++ 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 723f9719b9..0c9c4c6a0c 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -55,6 +55,15 @@ Many repositories already ship skills in Claude Code's directories. Enable the * Compat roots have the lowest precedence within their scope (`.mux/skills` > `.agents/skills` > `.claude/skills`, and likewise for the global roots), and they are read-only: Mux never writes or deletes skills in `.claude` directories. Skills that package Mux workflow scripts are not discovered from `.claude` roots. +### Agent Plugins (experiment) + +Enable the **Agent Plugins** experiment (Settings → Experiments) to also discover skills packaged in [Agent Plugins 1.0.0](https://agent-plugins.org) directories — a plugin is a directory with a `plugin.json` manifest plus an optional `skills/` folder. Plugins are discovered from: + +- **Workspace-local**: `.mux/plugins//` and `.agents/plugins//` +- **Global**: `~/.mux/plugins//` and `~/.agents/plugins//` + +Plugin skills have the lowest precedence within their scope and are read-only. A broken plugin (or a broken skill inside one) never affects other plugins or skills. Plugins can also ship MCP servers; see [MCP servers](/config/mcp-servers#agent-plugins-servers-experiment). + ## Skill layout A skill is a directory named after the skill: diff --git a/docs/config/mcp-servers.mdx b/docs/config/mcp-servers.mdx index deae4ecd24..9363298c80 100644 --- a/docs/config/mcp-servers.mdx +++ b/docs/config/mcp-servers.mdx @@ -49,6 +49,17 @@ These overrides are stored in a workspace-local file: `.mux/mcp.local.jsonc`. This means you configure servers once (globally or with repo overrides), but each workspace (branch) gets isolated server instances with independent state. +## Agent Plugins servers (experiment) + +With the **Agent Plugins** experiment enabled (Settings → Experiments), MCP servers declared in an [Agent Plugin's](https://agent-plugins.org) `mcp.json` also appear in server listings. Plugin servers are: + +- **Disabled by default** — enable them per workspace via the Workspace MCP dialog; there is no global enable +- **Read-only** — they cannot be edited or removed, and are never written into `mcp.jsonc` +- **Trust-gated** — servers from a repo's `.mux/plugins` / `.agents/plugins` only appear once the project is trusted +- **Host-only** — plugin servers are skipped for SSH and devcontainer workspaces + +Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`. + ## Behavior - **Hot reload** — Config changes apply on your next message (no restart needed) diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index 59f19df671..14350edad7 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -1287,7 +1287,10 @@ describe("agentSkillsService agent plugins", () => { ]); const container = path.join(project.path, ".mux", "plugins"); await fs.mkdir(container, { recursive: true }); - await fs.symlink(path.join(elsewhere.path, "linked-plugin"), path.join(container, "linked-plugin")); + await fs.symlink( + path.join(elsewhere.path, "linked-plugin"), + path.join(container, "linked-plugin") + ); const runtime = new LocalRuntime(project.path); const roots = { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 10eabfe0f0..f3cfb1ea4d 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1318,6 +1318,15 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Compat roots have the lowest precedence within their scope (`.mux/skills` > `.agents/skills` > `.claude/skills`, and likewise for the global roots), and they are read-only: Mux never writes or deletes skills in `.claude` directories. Skills that package Mux workflow scripts are not discovered from `.claude` roots.", "", + "### Agent Plugins (experiment)", + "", + "Enable the **Agent Plugins** experiment (Settings → Experiments) to also discover skills packaged in [Agent Plugins 1.0.0](https://agent-plugins.org) directories — a plugin is a directory with a `plugin.json` manifest plus an optional `skills/` folder. Plugins are discovered from:", + "", + "- **Workspace-local**: `.mux/plugins//` and `.agents/plugins//`", + "- **Global**: `~/.mux/plugins//` and `~/.agents/plugins//`", + "", + "Plugin skills have the lowest precedence within their scope and are read-only. A broken plugin (or a broken skill inside one) never affects other plugins or skills. Plugins can also ship MCP servers; see [MCP servers](/config/mcp-servers#agent-plugins-servers-experiment).", + "", "## Skill layout", "", "A skill is a directory named after the skill:", @@ -3142,6 +3151,17 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "This means you configure servers once (globally or with repo overrides), but each workspace (branch) gets isolated server instances with independent state.", "", + "## Agent Plugins servers (experiment)", + "", + "With the **Agent Plugins** experiment enabled (Settings → Experiments), MCP servers declared in an [Agent Plugin's](https://agent-plugins.org) `mcp.json` also appear in server listings. Plugin servers are:", + "", + "- **Disabled by default** — enable them per workspace via the Workspace MCP dialog; there is no global enable", + "- **Read-only** — they cannot be edited or removed, and are never written into `mcp.jsonc`", + "- **Trust-gated** — servers from a repo's `.mux/plugins` / `.agents/plugins` only appear once the project is trusted", + "- **Host-only** — plugin servers are skipped for SSH and devcontainer workspaces", + "", + "Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`.", + "", "## Behavior", "", "- **Hot reload** — Config changes apply on your next message (no restart needed)", From efd4a961a47d5afd4e4a215d97ea9510880d8a48 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 21:13:16 +0000 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20Codex=20r?= =?UTF-8?q?eview=20=E2=80=94=20off-host=20gating=20+=20worktree=20plugin?= =?UTF-8?q?=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Devcontainer exclusion: DevcontainerRuntime extends LocalBaseRuntime but execs inside the container, so plugin servers (host paths in command/cwd/ PLUGIN_ROOT/PLUGIN_DATA) were wrongly offered there. AIService now resolves an AgentPluginsMcpContext (null for SSH/Docker/devcontainer/multi-project) and the manager's runtime backstop also excludes DevcontainerRuntime. 2. Worktree discovery: workspace MCP flows now scan the ACTIVE checkout (resolveMuxProjectRootForHostFs) so plugin content follows the branch, matching skill discovery. Project plugin instance IDs now hash projectKey + container-relative location instead of the checkout realpath, so the engine (worktree) and UI (project checkout) agree on plugin:: keys and PLUGIN_DATA stays stable across worktrees. --- docs/config/mcp-servers.mdx | 1 + src/node/services/agentPlugins/discovery.ts | 10 +++ .../services/agentPlugins/mcpConfig.test.ts | 64 +++++++++++++- src/node/services/agentPlugins/mcpConfig.ts | 86 +++++++++++++++---- .../builtInSkillContent.generated.ts | 1 + src/node/services/aiService.test.ts | 45 ++++++++++ src/node/services/aiService.ts | 38 +++++++- src/node/services/mcpConfigService.test.ts | 18 ++-- src/node/services/mcpConfigService.ts | 24 +++++- src/node/services/mcpServerManager.test.ts | 51 ++++++++--- src/node/services/mcpServerManager.ts | 31 +++++-- 11 files changed, 319 insertions(+), 50 deletions(-) diff --git a/docs/config/mcp-servers.mdx b/docs/config/mcp-servers.mdx index 9363298c80..d8657b16c6 100644 --- a/docs/config/mcp-servers.mdx +++ b/docs/config/mcp-servers.mdx @@ -57,6 +57,7 @@ With the **Agent Plugins** experiment enabled (Settings → Experiments), MCP se - **Read-only** — they cannot be edited or removed, and are never written into `mcp.jsonc` - **Trust-gated** — servers from a repo's `.mux/plugins` / `.agents/plugins` only appear once the project is trusted - **Host-only** — plugin servers are skipped for SSH and devcontainer workspaces +- **Branch-following** — in a workspace, repo plugin servers load from the workspace's own checkout (matching skill discovery), while server identities stay stable across worktrees of the same project Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`. diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index df399b0117..0031838d60 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -37,6 +37,10 @@ export interface AgentPluginInfo { scope: AgentPluginScope; /** Canonical (realpath) plugin root directory. */ rootPath: string; + /** Lexical container directory this plugin was discovered in (as configured). */ + containerPath: string; + /** Directory entry name under the container (lexical, pre-realpath). */ + dirName: string; manifest: AgentPluginManifest; /** Canonical `skills/` directory; present only when it exists, is a directory, and stays inside the root (§6.2). */ skillsDir?: string; @@ -133,6 +137,8 @@ async function resolveComponentPath(args: { async function discoverPluginAt(args: { pluginDir: string; + containerPath: string; + dirName: string; scope: AgentPluginScope; diagnostics: AgentPluginDiagnostic[]; }): Promise { @@ -232,6 +238,8 @@ async function discoverPluginAt(args: { name: validation.manifest.name, scope, rootPath: rootReal, + containerPath: args.containerPath, + dirName: args.dirName, manifest: validation.manifest, ...(skillsDir !== undefined ? { skillsDir } : {}), ...(mcpConfigPath !== undefined ? { mcpConfigPath } : {}), @@ -265,6 +273,8 @@ export async function discoverAgentPlugins( for (const entryName of await listChildDirectories(container.path)) { const plugin = await discoverPluginAt({ pluginDir: path.join(container.path, entryName), + containerPath: container.path, + dirName: entryName, scope: container.scope, diagnostics, }); diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 63464916ef..bbdbb95515 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -37,6 +37,8 @@ async function makePlugin( name, scope: options?.scope ?? "global", rootPath, + containerPath: baseDir, + dirName: name, manifest: { schemaId: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name }, mcpConfigPath, }; @@ -525,11 +527,11 @@ describe("createAgentPluginsMcpProvider", () => { .sort(); // Untrusted project: only global containers contribute. - const untrusted = await provider({ projectPath: project.path, trusted: false }); + const untrusted = await provider({ projectRoot: project.path, trusted: false }); expect(pluginNamesOf(untrusted)).toEqual(["global-plugin", "universal-plugin"]); // Trusted project: project containers contribute too. - const trusted = await provider({ projectPath: project.path, trusted: true }); + const trusted = await provider({ projectRoot: project.path, trusted: true }); expect(pluginNamesOf(trusted)).toEqual([ "global-plugin", "project-plugin", @@ -559,4 +561,62 @@ describe("createAgentPluginsMcpProvider", () => { expect(Object.values(servers).map((s) => s.plugin?.pluginName)).toEqual(["healthy"]); }); }); + + test("project plugin keys are stable across checkouts of the same project", async () => { + using home = new DisposableTempDir("plugin-provider-home"); + using muxHome = new DisposableTempDir("plugin-provider-mux"); + using projectDir = new DisposableTempDir("plugin-provider-checkout-a"); + using worktreeDir = new DisposableTempDir("plugin-provider-checkout-b"); + await withHomeDir(home.path, async () => { + // Same plugin at the same repo-relative location in two checkouts + // (project checkout + workspace worktree). + for (const checkout of [projectDir.path, worktreeDir.path]) { + await writeDiscoverablePlugin( + path.join(checkout, ".mux", "plugins"), + "shared-plugin", + mcpDoc({ srv: STDIO_ENTRY }) + ); + } + + const provider = createAgentPluginsMcpProvider({ + muxHome: muxHome.path, + isEnabled: () => true, + }); + + // Engine flow: scans the worktree, keys by the project identity. + const fromWorktree = await provider({ + projectRoot: worktreeDir.path, + projectKey: projectDir.path, + trusted: true, + }); + // UI flow (Settings / workspace modal): scans the project checkout. + const fromProject = await provider({ + projectRoot: projectDir.path, + projectKey: projectDir.path, + trusted: true, + }); + + expect(Object.keys(fromWorktree)).toHaveLength(1); + expect(Object.keys(fromWorktree)).toEqual(Object.keys(fromProject)); + + // PLUGIN_DATA is shared too, but PLUGIN_ROOT follows the scanned checkout. + const worktreeInfo = Object.values(fromWorktree)[0] as MCPStdioServerInfo; + const projectInfo = Object.values(fromProject)[0] as MCPStdioServerInfo; + expect(worktreeInfo.env?.PLUGIN_DATA).toBe(projectInfo.env?.PLUGIN_DATA ?? ""); + expect(worktreeInfo.env?.PLUGIN_ROOT).toBe( + path.join(await fs.realpath(worktreeDir.path), ".mux", "plugins", "shared-plugin") + ); + expect(projectInfo.env?.PLUGIN_ROOT).toBe( + path.join(await fs.realpath(projectDir.path), ".mux", "plugins", "shared-plugin") + ); + + // A different project identity yields different keys even for the same rel path. + const otherProject = await provider({ + projectRoot: projectDir.path, + projectKey: "/some/other/project", + trusted: true, + }); + expect(Object.keys(otherProject)).not.toEqual(Object.keys(fromProject)); + }); + }); }); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 335b2a810f..f47fa2354f 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -34,10 +34,16 @@ export const AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0 = const PLUGIN_SERVER_KEY_PREFIX = "plugin:"; -/** Stable plugin-instance identity keyed on the canonical root path (not name/content). */ -export function computePluginInstanceId(rootPath: string): string { - assert(path.isAbsolute(rootPath), "computePluginInstanceId: rootPath must be absolute"); - return createHash("sha256").update(rootPath).digest("hex").slice(0, 16); +/** + * Stable plugin-instance identity. Global plugins hash their canonical root + * path; project plugins hash `\0` so + * the same plugin gets the same instance ID from every worktree of a project + * (worktree realpaths differ per workspace, but the project identity and the + * plugin's location inside the repo do not). + */ +export function computePluginInstanceId(identity: string): string { + assert(identity.length > 0, "computePluginInstanceId: identity must be non-empty"); + return createHash("sha256").update(identity).digest("hex").slice(0, 16); } /** Client-managed persistent data directory for a plugin instance (§9.1). */ @@ -329,11 +335,13 @@ function normalizeRemoteEntry( /** * Load and normalize a plugin's `mcp.json` into default-disabled Mux server - * records keyed by `plugin::`. + * records keyed by `plugin::`. The instance ID + * defaults to hashing the canonical plugin root; callers pass an explicit + * `instanceId` when a more stable identity exists (project plugins). */ export async function loadPluginMcpServers( plugin: AgentPluginInfo, - ctx: { muxHome: string } + ctx: { muxHome: string; instanceId?: string } ): Promise { assert( plugin.mcpConfigPath !== undefined && path.isAbsolute(plugin.mcpConfigPath), @@ -384,7 +392,7 @@ export async function loadPluginMcpServers( return disableMcp("mcp.json 'mcpServers' is required and must be an object"); } - const instanceId = computePluginInstanceId(plugin.rootPath); + const instanceId = ctx.instanceId ?? computePluginInstanceId(plugin.rootPath); const dataPath = getPluginDataPath(ctx.muxHome, instanceId); const normalizeCtx: NormalizeContext = { plugin, @@ -442,9 +450,27 @@ export async function loadPluginMcpServers( return { servers, diagnostics }; } -export interface AgentPluginsMcpProviderArgs { - /** Host project root for project-scope plugin containers (when applicable). */ - projectPath?: string; +/** + * Where and how plugin MCP discovery runs for a call. + * + * `projectRoot` is the host checkout whose `.mux/plugins` / `.agents/plugins` + * containers are scanned. For workspace flows this is the ACTIVE worktree (so + * plugin content follows the branch, matching skill discovery); for + * project-level flows (Settings, workspace MCP modal) it is the project path. + * + * `projectKey` is the stable project identity (`metadata.projectPath`) used to + * key project plugin instances. Because instance IDs hash + * `projectKey + container-relative location` rather than the checkout + * realpath, the engine (scanning a worktree) and the UI (scanning the project + * checkout) agree on `plugin::` keys, so workspace `enabledServers` + * overrides and `PLUGIN_DATA` stay coherent across worktrees. + */ +export interface AgentPluginsMcpContext { + projectRoot?: string; + projectKey?: string; +} + +export interface AgentPluginsMcpProviderArgs extends AgentPluginsMcpContext { /** Whether repo-local (project-scope) plugin config is allowed (Project Trust). */ trusted: boolean; } @@ -453,6 +479,20 @@ export type AgentPluginsMcpProvider = ( args: AgentPluginsMcpProviderArgs ) => Promise>; +/** Stable instance identity for a project-scope plugin (see AgentPluginsMcpContext). */ +function computeProjectPluginInstanceId(args: { + projectKey: string; + projectRoot: string; + plugin: AgentPluginInfo; +}): string { + // Lexical container-relative location, e.g. ".mux/plugins/hello-plugin". + const relativeLocation = path.join( + path.relative(args.projectRoot, args.plugin.containerPath), + args.plugin.dirName + ); + return computePluginInstanceId(`${args.projectKey}\0${relativeLocation}`); +} + /** * Build the MCP server provider for Agent Plugins containers. Returns an empty * map when the agent-plugins experiment is off. Project-scope containers are @@ -473,11 +513,12 @@ export function createAgentPluginsMcpProvider(ctx: { return {}; } + const projectRoot = args.projectRoot; const containers: AgentPluginContainer[] = []; - if (args.projectPath !== undefined && args.trusted && path.isAbsolute(args.projectPath)) { - containers.push({ path: path.join(args.projectPath, ".mux", "plugins"), scope: "project" }); + if (projectRoot !== undefined && args.trusted && path.isAbsolute(projectRoot)) { + containers.push({ path: path.join(projectRoot, ".mux", "plugins"), scope: "project" }); containers.push({ - path: path.join(args.projectPath, ".agents", "plugins"), + path: path.join(projectRoot, ".agents", "plugins"), scope: "project", }); } @@ -491,20 +532,29 @@ export function createAgentPluginsMcpProvider(ctx: { if (plugin.mcpConfigPath === undefined) { continue; } - // Project plugin roots keep the repo-symlink posture of repo config: - // the plugin root itself must stay inside the project root. - if (plugin.scope === "project" && args.projectPath !== undefined) { + let instanceId: string | undefined; + if (plugin.scope === "project" && projectRoot !== undefined) { + // Project plugin roots keep the repo-symlink posture of repo config: + // the plugin root itself must stay inside the scanned checkout. try { - await ensurePathContained(args.projectPath, plugin.rootPath); + await ensurePathContained(projectRoot, plugin.rootPath); } catch (error) { log.warn( `Skipping project plugin '${plugin.name}' MCP config: plugin root escapes the project root: ${getErrorMessage(error)}` ); continue; } + instanceId = computeProjectPluginInstanceId({ + projectKey: args.projectKey ?? projectRoot, + projectRoot, + plugin, + }); } try { - const { servers } = await loadPluginMcpServers(plugin, { muxHome: ctx.muxHome }); + const { servers } = await loadPluginMcpServers(plugin, { + muxHome: ctx.muxHome, + ...(instanceId !== undefined ? { instanceId } : {}), + }); Object.assign(merged, servers); } catch (error) { // §11.3: one broken plugin never affects the others. diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index f3cfb1ea4d..ddef86597f 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3159,6 +3159,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "- **Read-only** — they cannot be edited or removed, and are never written into `mcp.jsonc`", "- **Trust-gated** — servers from a repo's `.mux/plugins` / `.agents/plugins` only appear once the project is trusted", "- **Host-only** — plugin servers are skipped for SSH and devcontainer workspaces", + "- **Branch-following** — in a workspace, repo plugin servers load from the workspace's own checkout (matching skill discovery), while server identities stay stable across worktrees of the same project", "", "Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`.", "", diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 70587bc046..f9c516086c 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:te import { AIService, prepareProviderRequestMessages, + resolveAgentPluginsMcpContext, resolveMuxProjectRootForHostFs, } from "./aiService"; import { discoverAvailableSubagentsForToolContext } from "./streamContextBuilder"; @@ -25,7 +26,11 @@ import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { Config } from "@/node/config"; import * as runtimeFactory from "@/node/runtime/runtimeFactory"; +import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; +import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; +import type { Runtime } from "@/node/runtime/Runtime"; import { DisposableTempDir } from "@/node/services/tempDir"; import { createTaskTool } from "./tools/task"; @@ -562,6 +567,46 @@ describe("resolveMuxProjectRootForHostFs", () => { }); }); +describe("resolveAgentPluginsMcpContext", () => { + const projectPath = "/home/user/projects/my-app"; + const workspacePath = "/home/user/.mux/src/my-app/feature-branch"; + + function createMetadata(runtimeConfig: WorkspaceMetadata["runtimeConfig"]): WorkspaceMetadata { + return { + id: "workspace-id", + name: "feature-branch", + projectName: "my-app", + projectPath, + runtimeConfig, + }; + } + + it("scans the active checkout keyed by the project for host runtimes", () => { + expect( + resolveAgentPluginsMcpContext( + new LocalRuntime(projectPath), + createMetadata({ type: "local" }), + workspacePath + ) + ).toEqual({ projectRoot: workspacePath, projectKey: projectPath }); + }); + + it("returns null for runtimes whose exec runs off-host", () => { + const offHostRuntimes = [ + // Both classes exec off-host; DevcontainerRuntime despite extending LocalBaseRuntime. + Object.create(RemoteRuntime.prototype) as Runtime, + Object.create(DevcontainerRuntime.prototype) as Runtime, + // MultiProjectRuntime implements Runtime directly: excluded by the host allowlist. + Object.create(MultiProjectRuntime.prototype) as Runtime, + ]; + for (const runtime of offHostRuntimes) { + expect( + resolveAgentPluginsMcpContext(runtime, createMetadata({ type: "local" }), workspacePath) + ).toBeNull(); + } + }); +}); + describe("AIService.setupStreamEventForwarding", () => { interface ForwardingInternals { streamManager: StreamManager; diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 9df856c286..aec1d273eb 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -35,6 +35,10 @@ import { import { getGoalToolAvailability } from "@/common/utils/tools/toolAvailability"; import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; import { createRuntime } from "@/node/runtime/runtimeFactory"; +import type { Runtime } from "@/node/runtime/Runtime"; +import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; +import { LocalBaseRuntime } from "@/node/runtime/LocalBaseRuntime"; +import type { AgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, @@ -372,6 +376,28 @@ export function resolveMuxProjectRootForHostFs( return runtimeType === "ssh" || runtimeType === "docker" ? metadata.projectPath : workspacePath; } +/** + * Agent Plugins MCP context for a workspace, or null when plugin servers must + * not be offered because runtime.exec() does not run on the host filesystem + * (SSH/Docker remotes, devcontainers, multi-project fan-out). Discovery scans + * the ACTIVE host checkout so plugin content follows the workspace branch + * (matching skill discovery), while `projectKey` keeps instance IDs stable + * across worktrees of the same project. + */ +export function resolveAgentPluginsMcpContext( + runtime: Runtime, + metadata: WorkspaceMetadata, + workspacePath: string +): AgentPluginsMcpContext | null { + if (!(runtime instanceof LocalBaseRuntime) || runtime instanceof DevcontainerRuntime) { + return null; + } + return { + projectRoot: resolveMuxProjectRootForHostFs(metadata, workspacePath), + projectKey: metadata.projectPath, + }; +} + function resolveMuxToolScope( config: Config, metadata: WorkspaceMetadata, @@ -1550,13 +1576,22 @@ export class AIService extends EventEmitter { } recordStartupPhaseTiming("loadWorkspaceMcpOverridesMs", loadWorkspaceMcpOverridesStartedAt); + // Agent Plugins: discovery follows the active checkout and is disabled + // for runtimes that exec off-host (SSH/Docker/devcontainer). + const agentPluginsMcpContext = resolveAgentPluginsMcpContext( + runtime, + metadata, + workspacePath + ); + // Fetch MCP server config for system prompt (before building message). const listMcpServersStartedAt = Date.now(); const mcpServers = this.mcpServerManager ? await this.mcpServerManager.listServers( metadata.projectPath, mcpOverrides, - projectTrusted + projectTrusted, + agentPluginsMcpContext ) : undefined; recordStartupPhaseTiming("listMcpServersMs", listMcpServersStartedAt); @@ -1703,6 +1738,7 @@ export class AIService extends EventEmitter { trusted: projectTrusted, overrides: mcpOverrides, projectSecrets: await secretsToRecord(projectSecrets, this.opResolver), + agentPlugins: agentPluginsMcpContext, }); mcpTools = result.tools; diff --git a/src/node/services/mcpConfigService.test.ts b/src/node/services/mcpConfigService.test.ts index 53702cb620..a83ab02e19 100644 --- a/src/node/services/mcpConfigService.test.ts +++ b/src/node/services/mcpConfigService.test.ts @@ -171,8 +171,8 @@ describe("MCP server disable filtering", () => { }); }); - test("listServers passes projectPath and trust through to the provider", async () => { - const seenArgs: Array<{ projectPath?: string; trusted: boolean }> = []; + test("listServers resolves the Agent Plugins context: default, explicit, and null", async () => { + const seenArgs: Array<{ projectRoot?: string; projectKey?: string; trusted: boolean }> = []; const withProvider = new MCPConfigService(config, { agentPluginsMcpProvider: (args) => { seenArgs.push(args); @@ -180,14 +180,22 @@ describe("MCP server disable filtering", () => { }, }); + // Default: scan under projectPath, keyed by projectPath (project-level flows). await withProvider.listServers(); await withProvider.listServers("/proj", false); await withProvider.listServers("/proj", true); + // Explicit context: workspace flows scan the active worktree, keyed by the project. + await withProvider.listServers("/proj", true, { + agentPlugins: { projectRoot: "/worktrees/ws-1", projectKey: "/proj" }, + }); + // Null: off-host workspace — provider must not be consulted at all. + await withProvider.listServers("/proj", true, { agentPlugins: null }); expect(seenArgs).toEqual([ - { projectPath: undefined, trusted: false }, - { projectPath: "/proj", trusted: false }, - { projectPath: "/proj", trusted: true }, + { projectRoot: undefined, projectKey: undefined, trusted: false }, + { projectRoot: "/proj", projectKey: "/proj", trusted: false }, + { projectRoot: "/proj", projectKey: "/proj", trusted: true }, + { projectRoot: "/worktrees/ws-1", projectKey: "/proj", trusted: true }, ]); }); diff --git a/src/node/services/mcpConfigService.ts b/src/node/services/mcpConfigService.ts index 0ebd3d9798..676c5d3c43 100644 --- a/src/node/services/mcpConfigService.ts +++ b/src/node/services/mcpConfigService.ts @@ -12,7 +12,10 @@ import { Ok, Err } from "@/common/types/result"; import type { Result } from "@/common/types/result"; import assert from "@/common/utils/assert"; import type { Config } from "@/node/config"; -import type { AgentPluginsMcpProvider } from "@/node/services/agentPlugins/mcpConfig"; +import type { + AgentPluginsMcpContext, + AgentPluginsMcpProvider, +} from "@/node/services/agentPlugins/mcpConfig"; import { log } from "@/node/services/log"; import { getErrorMessage } from "@/common/utils/errors"; @@ -230,12 +233,25 @@ export class MCPConfigService { * - When projectPath is provided and trusted=true: merges global + /.mux/mcp.jsonc * - Agent Plugins servers (when the experiment provider is wired) are merged * at the lowest precedence: user config always wins on key collisions. + * + * `options.agentPlugins` controls plugin discovery: `null` disables it for + * this call (workspace executes off-host: SSH/devcontainer), an explicit + * context scans that host checkout, and omitting it defaults to scanning + * under `projectPath` (project-level flows: Settings, workspace MCP modal). */ - async listServers(projectPath?: string, trusted = false): Promise> { + async listServers( + projectPath?: string, + trusted = false, + options?: { agentPlugins?: AgentPluginsMcpContext | null } + ): Promise> { let pluginServers: Record = {}; - if (this.agentPluginsMcpProvider) { + if (this.agentPluginsMcpProvider && options?.agentPlugins !== null) { + const pluginContext = options?.agentPlugins ?? { + projectRoot: projectPath, + projectKey: projectPath, + }; try { - pluginServers = await this.agentPluginsMcpProvider({ projectPath, trusted }); + pluginServers = await this.agentPluginsMcpProvider({ ...pluginContext, trusted }); } catch (error) { // Plugin discovery failures must never break MCP config listing. log.warn("[MCP] Agent Plugins server discovery failed", { error }); diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 86a90b8541..34d15c42c3 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -13,6 +13,7 @@ import { } from "./mcpServerManager"; import type { MCPConfigService } from "./mcpConfigService"; import type { Runtime } from "@/node/runtime/Runtime"; +import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; import { DisposableTempDir } from "@/node/services/tempDir"; import type { Tool } from "ai"; @@ -984,8 +985,12 @@ describe("MCPServerManager", () => { workspaceRequest("ws-trusted-mcp", { trusted: true }) ); - expect(configService.listServers).toHaveBeenNthCalledWith(1, PROJECT_PATH, false); - expect(configService.listServers).toHaveBeenNthCalledWith(2, PROJECT_PATH, true); + expect(configService.listServers).toHaveBeenNthCalledWith(1, PROJECT_PATH, false, { + agentPlugins: undefined, + }); + expect(configService.listServers).toHaveBeenNthCalledWith(2, PROJECT_PATH, true, { + agentPlugins: undefined, + }); expect(Object.keys(untrustedResult.tools)).toEqual(["global_tool"]); expect(Object.keys(trustedResult.tools).sort()).toEqual(["global_tool", "repo_tool"]); @@ -1213,20 +1218,44 @@ describe("MCPServerManager", () => { expect(Object.keys(startedServers)).toEqual([PLUGIN_KEY]); }); - test("plugin servers are excluded on remote runtimes", async () => { + test("plugin servers are excluded on off-host runtimes (remote and devcontainer)", async () => { configService.listServers = mock(() => Promise.resolve(pluginStdioConfig())); spyOn(access, "startServers").mockImplementation(() => Promise.resolve(startResult([]))); - // Runtime identity is all the gate needs; RemoteRuntime is abstract. - const remoteRuntime = Object.create(RemoteRuntime.prototype) as Runtime; - const result = await manager.getToolsForWorkspace( - workspaceRequest("ws-plugin-remote", { - runtime: remoteRuntime, - overrides: { enabledServers: [PLUGIN_KEY] }, - }) + // Runtime identity is all the gate needs; both classes exec off-host. + // DevcontainerRuntime extends LocalBaseRuntime but execs inside the container. + const offHostRuntimes: Array<[string, Runtime]> = [ + ["ws-plugin-remote", Object.create(RemoteRuntime.prototype) as Runtime], + ["ws-plugin-devcontainer", Object.create(DevcontainerRuntime.prototype) as Runtime], + ]; + for (const [workspaceId, runtime] of offHostRuntimes) { + const result = await manager.getToolsForWorkspace( + workspaceRequest(workspaceId, { + runtime, + overrides: { enabledServers: [PLUGIN_KEY] }, + }) + ); + + expect(result.stats.enabledServerCount).toBe(0); + } + }); + + test("threads the agentPlugins context through to config listing", async () => { + configService.listServers = mock(() => Promise.resolve({})); + spyOn(access, "startServers").mockImplementation(() => Promise.resolve(startResult([]))); + + const context = { projectRoot: "/worktrees/ws-1", projectKey: PROJECT_PATH }; + await manager.getToolsForWorkspace( + workspaceRequest("ws-plugin-ctx", { agentPlugins: context }) ); + expect(configService.listServers).toHaveBeenLastCalledWith(PROJECT_PATH, false, { + agentPlugins: context, + }); - expect(result.stats.enabledServerCount).toBe(0); + await manager.listServers(PROJECT_PATH, undefined, true, null); + expect(configService.listServers).toHaveBeenLastCalledWith(PROJECT_PATH, true, { + agentPlugins: null, + }); }); test("stdio config signature includes args/env/cwd so plugin mcp.json edits recycle servers", async () => { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 4e6fa6a666..3c29571456 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -17,7 +17,9 @@ import type { import assert from "@/common/utils/assert"; import { shellQuote } from "@/common/utils/shell"; import type { Runtime } from "@/node/runtime/Runtime"; +import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; +import type { AgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; import type { PolicyService } from "@/node/services/policyService"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import { @@ -803,11 +805,12 @@ export class MCPServerManager { */ private async getAllServers( projectPath: string, - trusted = false + trusted = false, + agentPlugins?: AgentPluginsMcpContext | null ): Promise> { const configServers = this.ignoreConfigFile ? {} - : await this.configService.listServers(projectPath, trusted); + : await this.configService.listServers(projectPath, trusted, { agentPlugins }); // Inline servers override config file servers (always enabled) const inlineAsInfo: Record = {}; for (const [name, command] of Object.entries(this.inlineServers)) { @@ -827,13 +830,16 @@ export class MCPServerManager { * * @param projectPath - Project path to get servers for * @param overrides - Optional workspace-level overrides + * @param trusted - Whether repo-local MCP config is allowed + * @param agentPlugins - Agent Plugins discovery context (null = off-host workspace, no plugin servers) */ async listServers( projectPath: string, overrides?: WorkspaceMCPOverrides, - trusted = false + trusted = false, + agentPlugins?: AgentPluginsMcpContext | null ): Promise { - const allServers = await this.getAllServers(projectPath, trusted); + const allServers = await this.getAllServers(projectPath, trusted, agentPlugins); const enabled = this.applyServerOverrides(allServers, overrides); return this.filterServersByPolicy(enabled); } @@ -966,6 +972,8 @@ export class MCPServerManager { overrides?: WorkspaceMCPOverrides; /** Project secrets, used for resolving {secret: "KEY"} header references. */ projectSecrets?: Record; + /** Agent Plugins discovery context (null = off-host workspace, no plugin servers). */ + agentPlugins?: AgentPluginsMcpContext | null; }): Promise { const { workspaceId, @@ -975,17 +983,22 @@ export class MCPServerManager { trusted = false, overrides, projectSecrets, + agentPlugins, } = options; // Fetch full server info for project-level allowlists and server filtering - const allServers = await this.getAllServers(projectPath, trusted); + const allServers = await this.getAllServers(projectPath, trusted, agentPlugins); - // Agent Plugins v1: plugin roots are host filesystem paths, so plugin - // servers are only offered when the workspace runtime executes on the host. + // Agent Plugins v1: plugin servers launch via host fs paths (command, cwd, + // PLUGIN_ROOT/PLUGIN_DATA), so they are only offered when the runtime + // executes on the host. Backstop for callers that omit agentPlugins: SSH / + // Docker remotes exec remotely, and DevcontainerRuntime execs inside the + // container even though it extends LocalBaseRuntime. const fullServerInfo: Record = {}; + const execsOffHost = runtime instanceof RemoteRuntime || runtime instanceof DevcontainerRuntime; for (const [name, info] of Object.entries(allServers)) { - if (info.plugin !== undefined && runtime instanceof RemoteRuntime) { - log.debug("[MCP] Skipping Agent Plugin server on remote runtime", { workspaceId, name }); + if (info.plugin !== undefined && execsOffHost) { + log.debug("[MCP] Skipping Agent Plugin server on off-host runtime", { workspaceId, name }); continue; } fullServerInfo[name] = info; From 31762c406f775c57dfad33667630f5104df27b8f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 21:32:08 +0000 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20thread=20workspace?= =?UTF-8?q?=20Agent=20Plugins=20context=20through=20mcp.list/mcp.test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 2: WorkspaceMCPModal listed plugin servers from the project checkout while the engine discovered them from the active worktree, so branch-only plugins were invisible to the modal and off-host workspaces listed servers the engine filters out. resolveAgentPluginsMcpContext is now metadata-based and lives in agentPlugins/mcpConfig.ts so AIService and the oRPC router resolve the identical context. mcp.list and mcp.test accept an optional workspaceId; the router maps it to the workspace's context (worktree scan for host workspaces, null for SSH/Docker/devcontainer/multi-project) and mcpServerManager.test forwards it for named-server lookups. The modal passes workspaceId on both list and fetch-tools calls. --- .../WorkspaceMCPModal/WorkspaceMCPModal.tsx | 8 ++- src/common/orpc/schemas/mcp.ts | 10 +++ src/node/orpc/router.ts | 54 +++++++++++++++- .../services/agentPlugins/mcpConfig.test.ts | 63 +++++++++++++++++++ src/node/services/agentPlugins/mcpConfig.ts | 27 ++++++++ src/node/services/aiService.test.ts | 45 ------------- src/node/services/aiService.ts | 35 +---------- src/node/services/mcpServerManager.ts | 5 +- 8 files changed, 164 insertions(+), 83 deletions(-) diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index 70620f66ad..c36889bea9 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -60,7 +60,9 @@ export const WorkspaceMCPModal: React.FC = ({ setError(null); try { const [projectServers, workspaceOverrides] = await Promise.all([ - api.mcp.list({ projectPath }), + // workspaceId scopes Agent Plugins servers to this workspace's active + // checkout (and hides them entirely for off-host workspaces). + api.mcp.list({ projectPath, workspaceId }), api.workspace.mcp.get({ workspaceId }), ]); setServers(projectServers ?? {}); @@ -81,7 +83,7 @@ export const WorkspaceMCPModal: React.FC = ({ if (!api) return; setLoadingTools((prev) => ({ ...prev, [serverName]: true })); try { - const result = await api.mcp.test({ projectPath, name: serverName }); + const result = await api.mcp.test({ projectPath, name: serverName, workspaceId }); setResult(serverName, result); if (!result.success) { setError(`Failed to fetch tools for ${serverName}: ${result.error}`); @@ -92,7 +94,7 @@ export const WorkspaceMCPModal: React.FC = ({ setLoadingTools((prev) => ({ ...prev, [serverName]: false })); } }, - [api, projectPath, setResult] + [api, projectPath, workspaceId, setResult] ); /** diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts index 039c00cc08..eeaa7c20b4 100644 --- a/src/common/orpc/schemas/mcp.ts +++ b/src/common/orpc/schemas/mcp.ts @@ -77,6 +77,14 @@ export const MCPServerMapSchema = z.record(z.string(), MCPServerInfoSchema); export const MCPListParamsSchema = z.object({ projectPath: z.string().optional(), + /** + * Workspace whose Agent Plugins MCP context should apply (agent-plugins + * experiment): plugin servers are discovered from that workspace's active + * checkout, and omitted entirely for off-host workspaces. Without a + * workspaceId, plugin discovery scans under projectPath (project-level + * flows like Settings). + */ + workspaceId: z.string().optional(), }); const MCPAddParamsBaseSchema = z.object({ @@ -167,6 +175,8 @@ const MCPTestParamsBaseSchema = z.object({ command: z.string().optional(), url: z.string().optional(), headers: MCPHeadersSchema.optional(), + /** Workspace whose Agent Plugins MCP context should apply; see MCPListParamsSchema. */ + workspaceId: z.string().optional(), }); type MCPTestParamsLike = z.infer; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 764fa01fb3..2ea8987176 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -48,6 +48,10 @@ import { createRuntimeForWorkspace, resolveWorkspaceRootPath, } from "@/node/runtime/runtimeHelpers"; +import { + resolveAgentPluginsMcpContext, + type AgentPluginsMcpContext, +} from "@/node/services/agentPlugins/mcpConfig"; import { readPlanFile } from "@/node/utils/runtime/helpers"; import { parseMemoryPath, @@ -283,6 +287,40 @@ async function resolveAgentDiscoveryContext( return { runtime, discoveryPath: input.projectPath! }; } +/** + * Agent Plugins MCP context for an optional workspaceId input (agent-plugins + * experiment). Returns undefined (project-level default: scan under + * projectPath) when no workspace is given or its metadata cannot be resolved; + * otherwise the workspace's own context — worktree-scanning for host + * workspaces, null for off-host workspaces so plugin servers match what the + * engine actually offers there. + */ +async function resolveWorkspaceAgentPluginsMcpContext( + context: ORPCContext, + workspaceId: string | null | undefined +): Promise { + const trimmed = workspaceId?.trim(); + if (!trimmed) { + return undefined; + } + try { + const metadataResult = await context.aiService.getWorkspaceMetadata(trimmed); + if (!metadataResult.success) { + return undefined; + } + const metadata = metadataResult.data; + const runtime = createRuntimeForWorkspace(metadata); + const workspacePath = resolveWorkspaceRootPath(metadata, runtime); + return resolveAgentPluginsMcpContext(metadata, workspacePath); + } catch (error) { + log.debug("Failed to resolve Agent Plugins MCP context for workspace", { + workspaceId: trimmed, + error, + }); + return undefined; + } +} + function isTrustedProjectPath(context: ORPCContext, projectPath?: string | null): boolean { return isProjectTrusted(context.config, projectPath); } @@ -2774,7 +2812,13 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { const servers = await context.mcpConfigService.listServers( input.projectPath, - isTrustedProjectPath(context, input.projectPath) + isTrustedProjectPath(context, input.projectPath), + { + agentPlugins: await resolveWorkspaceAgentPluginsMcpContext( + context, + input.workspaceId + ), + } ); if (!context.policyService.isEnforced()) { @@ -2915,11 +2959,16 @@ export const router = (authToken?: string) => { opResolver ); + const agentPlugins = await resolveWorkspaceAgentPluginsMcpContext( + context, + input.workspaceId + ); const configuredTransport = input.name ? ( await context.mcpConfigService.listServers( projectPathProvided ? resolvedProjectPath : undefined, - trusted + trusted, + { agentPlugins } ) )[input.name]?.transport : undefined; @@ -2942,6 +2991,7 @@ export const router = (authToken?: string) => { url: input.url, headers: input.headers, projectSecrets: secrets, + agentPlugins, }); const durationMs = Date.now() - start; diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index bbdbb95515..37b70120b0 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -5,6 +5,7 @@ import * as path from "node:path"; import { describe, expect, spyOn, test } from "bun:test"; import type { MCPStdioServerInfo } from "@/common/types/mcp"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; import { DisposableTempDir } from "@/node/services/tempDir"; import type { AgentPluginInfo } from "./discovery"; import { AGENT_PLUGIN_SCHEMA_ID_1_0_0 } from "./manifest"; @@ -15,6 +16,7 @@ import { createAgentPluginsMcpProvider, getPluginDataPath, loadPluginMcpServers, + resolveAgentPluginsMcpContext, } from "./mcpConfig"; /** Create a plugin dir on disk and return its AgentPluginInfo. */ @@ -620,3 +622,64 @@ describe("createAgentPluginsMcpProvider", () => { }); }); }); + +describe("resolveAgentPluginsMcpContext", () => { + const projectPath = "/home/user/projects/my-app"; + const workspacePath = "/home/user/.mux/src/my-app/feature-branch"; + + function createMetadata( + runtimeConfig: WorkspaceMetadata["runtimeConfig"], + extra?: Partial + ): WorkspaceMetadata { + return { + id: "workspace-id", + name: "feature-branch", + projectName: "my-app", + projectPath, + runtimeConfig, + ...extra, + }; + } + + test("scans the active checkout keyed by the project for host runtimes", () => { + for (const runtimeConfig of [ + { type: "local" } as const, + { type: "worktree", srcBaseDir: "/home/user/.mux/src" } as const, + ]) { + expect(resolveAgentPluginsMcpContext(createMetadata(runtimeConfig), workspacePath)).toEqual({ + projectRoot: workspacePath, + projectKey: projectPath, + }); + } + }); + + test("returns null for workspaces that exec off-host", () => { + const offHostConfigs: Array = [ + { type: "ssh", host: "remote", srcBaseDir: "/home/remote/.mux/src" }, + { type: "docker", image: "ubuntu:22.04" }, + // Devcontainer checkouts are host paths, but exec runs inside the container. + { type: "devcontainer", configPath: ".devcontainer/devcontainer.json" }, + ]; + for (const runtimeConfig of offHostConfigs) { + expect( + resolveAgentPluginsMcpContext(createMetadata(runtimeConfig), workspacePath) + ).toBeNull(); + } + + // Multi-project fan-out execs per-project; the shared root is not a checkout. + expect( + resolveAgentPluginsMcpContext( + createMetadata( + { type: "local" }, + { + projects: [ + { projectPath: "/proj/a", projectName: "a" }, + { projectPath: "/proj/b", projectName: "b" }, + ], + } + ), + workspacePath + ) + ).toBeNull(); + }); +}); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index f47fa2354f..739798556e 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -4,8 +4,10 @@ import * as os from "node:os"; import * as path from "node:path"; import type { MCPServerInfo, MCPStdioServerInfo } from "@/common/types/mcp"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; +import { isMultiProject } from "@/common/utils/multiProject"; import { log } from "@/node/services/log"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; import type { AgentPluginContainer, AgentPluginDiagnostic, AgentPluginInfo } from "./discovery"; @@ -475,6 +477,31 @@ export interface AgentPluginsMcpProviderArgs extends AgentPluginsMcpContext { trusted: boolean; } +/** + * Agent Plugins MCP context for a workspace, or null when plugin servers must + * not be offered because the workspace executes off-host: SSH/Docker remotes, + * devcontainers (exec runs inside the container even though the checkout is a + * host path), and multi-project fan-out. `workspacePath` is the active host + * checkout, so plugin content follows the workspace branch (matching skill + * discovery), while `projectKey` keeps instance IDs stable across worktrees. + * + * Metadata-based (not Runtime-based) so both AIService streams and oRPC + * handlers resolve the identical context for a workspace. + */ +export function resolveAgentPluginsMcpContext( + metadata: WorkspaceMetadata, + workspacePath: string +): AgentPluginsMcpContext | null { + if (isMultiProject(metadata)) { + return null; + } + const runtimeType = metadata.runtimeConfig.type; + if (runtimeType !== "local" && runtimeType !== "worktree") { + return null; + } + return { projectRoot: workspacePath, projectKey: metadata.projectPath }; +} + export type AgentPluginsMcpProvider = ( args: AgentPluginsMcpProviderArgs ) => Promise>; diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index f9c516086c..70587bc046 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -10,7 +10,6 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:te import { AIService, prepareProviderRequestMessages, - resolveAgentPluginsMcpContext, resolveMuxProjectRootForHostFs, } from "./aiService"; import { discoverAvailableSubagentsForToolContext } from "./streamContextBuilder"; @@ -26,11 +25,7 @@ import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { Config } from "@/node/config"; import * as runtimeFactory from "@/node/runtime/runtimeFactory"; -import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; -import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; -import type { Runtime } from "@/node/runtime/Runtime"; import { DisposableTempDir } from "@/node/services/tempDir"; import { createTaskTool } from "./tools/task"; @@ -567,46 +562,6 @@ describe("resolveMuxProjectRootForHostFs", () => { }); }); -describe("resolveAgentPluginsMcpContext", () => { - const projectPath = "/home/user/projects/my-app"; - const workspacePath = "/home/user/.mux/src/my-app/feature-branch"; - - function createMetadata(runtimeConfig: WorkspaceMetadata["runtimeConfig"]): WorkspaceMetadata { - return { - id: "workspace-id", - name: "feature-branch", - projectName: "my-app", - projectPath, - runtimeConfig, - }; - } - - it("scans the active checkout keyed by the project for host runtimes", () => { - expect( - resolveAgentPluginsMcpContext( - new LocalRuntime(projectPath), - createMetadata({ type: "local" }), - workspacePath - ) - ).toEqual({ projectRoot: workspacePath, projectKey: projectPath }); - }); - - it("returns null for runtimes whose exec runs off-host", () => { - const offHostRuntimes = [ - // Both classes exec off-host; DevcontainerRuntime despite extending LocalBaseRuntime. - Object.create(RemoteRuntime.prototype) as Runtime, - Object.create(DevcontainerRuntime.prototype) as Runtime, - // MultiProjectRuntime implements Runtime directly: excluded by the host allowlist. - Object.create(MultiProjectRuntime.prototype) as Runtime, - ]; - for (const runtime of offHostRuntimes) { - expect( - resolveAgentPluginsMcpContext(runtime, createMetadata({ type: "local" }), workspacePath) - ).toBeNull(); - } - }); -}); - describe("AIService.setupStreamEventForwarding", () => { interface ForwardingInternals { streamManager: StreamManager; diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index aec1d273eb..cda68c2d6a 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -35,10 +35,7 @@ import { import { getGoalToolAvailability } from "@/common/utils/tools/toolAvailability"; import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; import { createRuntime } from "@/node/runtime/runtimeFactory"; -import type { Runtime } from "@/node/runtime/Runtime"; -import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; -import { LocalBaseRuntime } from "@/node/runtime/LocalBaseRuntime"; -import type { AgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; +import { resolveAgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, @@ -376,28 +373,6 @@ export function resolveMuxProjectRootForHostFs( return runtimeType === "ssh" || runtimeType === "docker" ? metadata.projectPath : workspacePath; } -/** - * Agent Plugins MCP context for a workspace, or null when plugin servers must - * not be offered because runtime.exec() does not run on the host filesystem - * (SSH/Docker remotes, devcontainers, multi-project fan-out). Discovery scans - * the ACTIVE host checkout so plugin content follows the workspace branch - * (matching skill discovery), while `projectKey` keeps instance IDs stable - * across worktrees of the same project. - */ -export function resolveAgentPluginsMcpContext( - runtime: Runtime, - metadata: WorkspaceMetadata, - workspacePath: string -): AgentPluginsMcpContext | null { - if (!(runtime instanceof LocalBaseRuntime) || runtime instanceof DevcontainerRuntime) { - return null; - } - return { - projectRoot: resolveMuxProjectRootForHostFs(metadata, workspacePath), - projectKey: metadata.projectPath, - }; -} - function resolveMuxToolScope( config: Config, metadata: WorkspaceMetadata, @@ -1577,12 +1552,8 @@ export class AIService extends EventEmitter { recordStartupPhaseTiming("loadWorkspaceMcpOverridesMs", loadWorkspaceMcpOverridesStartedAt); // Agent Plugins: discovery follows the active checkout and is disabled - // for runtimes that exec off-host (SSH/Docker/devcontainer). - const agentPluginsMcpContext = resolveAgentPluginsMcpContext( - runtime, - metadata, - workspacePath - ); + // for workspaces that exec off-host (SSH/Docker/devcontainer). + const agentPluginsMcpContext = resolveAgentPluginsMcpContext(metadata, workspacePath); // Fetch MCP server config for system prompt (before building message). const listMcpServersStartedAt = Date.now(); diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 3c29571456..17253c1571 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1377,6 +1377,8 @@ export class MCPServerManager { url?: string; headers?: Record; projectSecrets?: Record; + /** Agent Plugins discovery context for named-server lookups (null = no plugin servers). */ + agentPlugins?: AgentPluginsMcpContext | null; }): Promise { const isTransportAllowed = (t: MCPServerTransport): boolean => { return !this.policyService?.isEnforced() || this.policyService.isMcpTransportAllowed(t); @@ -1390,11 +1392,12 @@ export class MCPServerManager { url, headers, projectSecrets, + agentPlugins, } = options; const trimmedName = name?.trim(); if (trimmedName && !command?.trim() && !url?.trim()) { - const servers = await this.configService.listServers(projectPath, trusted); + const servers = await this.configService.listServers(projectPath, trusted, { agentPlugins }); const server = servers[trimmedName]; if (!server) { return { success: false, error: `Server "${trimmedName}" not found in configuration` }; From 7731cf3f9203b73a38dfc6bbcf3614e3762b5f52 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 21:41:50 +0000 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20create=20nested=20P?= =?UTF-8?q?LUGIN=5FDATA=20cwd=20before=20plugin=20server=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 3: a valid ${PLUGIN_DATA}/nested cwd passed normalization but only the PLUGIN_DATA root was created at launch, so exec() rejected the missing cwd on first start. prepareStdioLaunch now mkdir -p's cwds contained in the data dir (client-managed writable state); plugin-root cwds are shipped content and stay untouched. --- src/node/services/mcpServerManager.test.ts | 22 ++++++++++++++++++++++ src/node/services/mcpServerManager.ts | 15 +++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 34d15c42c3..9f91306efa 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -1320,6 +1320,28 @@ describe("prepareStdioLaunch", () => { expect((await fs.stat(dataPath)).isDirectory()).toBe(true); expect(launch.cwd).toBe(tmp.path); expect(launch.env?.PLUGIN_DATA).toBe(dataPath); + // Plugin-root cwd is shipped plugin content: never created by launch. + expect(await fs.readdir(tmp.path)).toEqual(["plugin-data"]); + }); + + test("creates a nested PLUGIN_DATA cwd recursively before launch", async () => { + using tmp = new DisposableTempDir("mcp-plugin-data-nested"); + const dataPath = path.join(tmp.path, "plugin-data", "abc123"); + const nestedCwd = path.join(dataPath, "nested", "deep"); + + const launch = await prepareStdioLaunch({ + transport: "stdio", + command: "bunx", + args: [], + env: { PLUGIN_ROOT: tmp.path, PLUGIN_DATA: dataPath }, + cwd: nestedCwd, + disabled: false, + plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" }, + }); + + // exec() requires an existing cwd; data-dir cwds are client-managed state. + expect((await fs.stat(nestedCwd)).isDirectory()).toBe(true); + expect(launch.cwd).toBe(nestedCwd); }); test("rejects plugin servers without an absolute PLUGIN_DATA env (defensive)", async () => { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 17253c1571..7c81c69acb 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -449,6 +449,21 @@ export async function prepareStdioLaunch(info: MCPStdioServerInfo): Promise Date: Thu, 6 Aug 2026 21:52:03 +0000 Subject: [PATCH 08/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20scan=20checkout=20r?= =?UTF-8?q?oot=20for=20plugin=20MCP=20+=20reject=20missing=20plugin-root?= =?UTF-8?q?=20cwds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 4: 1. subProjectPath workspaces: the stream path scanned the execution path (//.mux/plugins) while the oRPC listing path used resolveWorkspaceRootPath, so the modal could enable a checkout-root plugin the engine then omitted. The stream context now also resolves the checkout root via resolveWorkspaceRootPath. 2. Plugin-root-anchored cwds (./x, ${PLUGIN_ROOT}/x) now require strict existence: launch only creates PLUGIN_DATA dirs and exec() rejects a missing cwd, so accepting one produced an enableable server that could never start. ${PLUGIN_DATA} cwds keep the allow-missing behavior, and a lexical pre-check still reports ../-style breakouts as escapes. --- .../services/agentPlugins/mcpConfig.test.ts | 6 +++- src/node/services/agentPlugins/mcpConfig.ts | 30 +++++++++++++++---- src/node/services/aiService.ts | 14 +++++++-- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 37b70120b0..7211e8df4c 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -296,7 +296,7 @@ describe("loadPluginMcpServers", () => { expect(cwdOf("dataSub")).toBe(path.join(dataPath, "nested")); }); - test("rejects invalid cwd forms and post-resolution escapes", async () => { + test("rejects invalid cwd forms, post-resolution escapes, and missing plugin-root cwds", async () => { using tmp = new DisposableTempDir("plugin-mcp"); const cases: Array<{ cwd: string; messagePart: string }> = [ { cwd: "sub", messagePart: "'cwd' must be" }, @@ -306,6 +306,10 @@ describe("loadPluginMcpServers", () => { { cwd: "./sub/../../up", messagePart: "escapes" }, { cwd: "${PLUGIN_ROOT}/../up", messagePart: "escapes" }, { cwd: "${PLUGIN_DATA}/..", messagePart: "escapes" }, + // Plugin-root cwds are shipped content: launch only creates PLUGIN_DATA + // dirs and exec() rejects a missing cwd, so these entries are invalid. + { cwd: "./missing", messagePart: "does not exist" }, + { cwd: "${PLUGIN_ROOT}/missing", messagePart: "does not exist" }, ]; for (const [index, testCase] of cases.entries()) { const plugin = await makePlugin( diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 739798556e..4c9cd7646c 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -238,13 +238,33 @@ async function normalizeStdioEntry( let resolvedCwd = rootPath; if (cwd !== undefined) { const expandedCwd = expandPluginPlaceholders(cwd, ctx.vars); - const anchor = cwd.startsWith("${PLUGIN_DATA}") ? ctx.dataPath : rootPath; + const isDataAnchored = cwd.startsWith("${PLUGIN_DATA}"); + const anchor = isDataAnchored ? ctx.dataPath : rootPath; + const candidate = path.resolve(rootPath, expandedCwd); + + // Lexical pre-check: report `../`-style breakouts as escapes before any + // filesystem access (the target may not even exist). + const lexicalRelative = path.relative(anchor, candidate); + if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) { + return { error: `'cwd' escapes its containment root: ${cwd}` }; + } + try { - resolvedCwd = await ensureContainedAllowMissingRoot( - anchor, - path.resolve(rootPath, expandedCwd) - ); + if (isDataAnchored) { + // Data-dir cwds are client-managed writable state that launch creates + // on demand, so missing paths are fine here. + resolvedCwd = await ensureContainedAllowMissingRoot(anchor, candidate); + } else { + // Plugin-root cwds refer to shipped plugin content: they must exist, + // because launch only creates PLUGIN_DATA directories and exec() + // rejects a missing cwd — accepting one yields an enableable server + // that can never start. + resolvedCwd = await ensurePathContained(anchor, candidate); + } } catch (error) { + if (!isDataAnchored && hasErrorCode(error, "ENOENT")) { + return { error: `'cwd' does not exist inside the plugin: ${cwd}` }; + } return { error: `'cwd' escapes its containment root: ${getErrorMessage(error)}` }; } } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index cda68c2d6a..d06c4fdc52 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -40,6 +40,7 @@ import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, resolveWorkspaceExecutionPath, + resolveWorkspaceRootPath, } from "@/node/runtime/runtimeHelpers"; import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; @@ -1552,8 +1553,17 @@ export class AIService extends EventEmitter { recordStartupPhaseTiming("loadWorkspaceMcpOverridesMs", loadWorkspaceMcpOverridesStartedAt); // Agent Plugins: discovery follows the active checkout and is disabled - // for workspaces that exec off-host (SSH/Docker/devcontainer). - const agentPluginsMcpContext = resolveAgentPluginsMcpContext(metadata, workspacePath); + // for workspaces that exec off-host (SSH/Docker/devcontainer). Scan the + // CHECKOUT ROOT, not `workspacePath`: for subProjectPath workspaces the + // execution path includes the subdirectory, which would miss checkout- + // level plugin containers (and diverge from the oRPC listing path, which + // also uses resolveWorkspaceRootPath). + const agentPluginsMcpContext = singleProjectContext + ? resolveAgentPluginsMcpContext( + metadata, + resolveWorkspaceRootPath(metadataWithPath, runtime) + ) + : null; // Fetch MCP server config for system prompt (before building message). const listMcpServersStartedAt = Date.now(); From dcbfc0699a76738c07a8d6801b51186ae3ac104c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 22:23:06 +0000 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20Codex=20r?= =?UTF-8?q?ound=205=20=E2=80=94=20workspace=20trust=20binding,=20checkout-?= =?UTF-8?q?root=20skill=20containers,=20workspace-scoped=20test=20cache,?= =?UTF-8?q?=20directory=20cwd=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceMCPModal/WorkspaceMCPModal.tsx | 2 +- src/browser/hooks/useMCPTestCache.ts | 11 ++++-- src/common/constants/storage.ts | 8 +++-- src/common/types/toolScope.ts | 7 ++++ src/node/orpc/router.ts | 24 +++++++++++-- .../services/agentPlugins/mcpConfig.test.ts | 36 +++++++++++++++++++ src/node/services/agentPlugins/mcpConfig.ts | 14 ++++++++ .../agentSkills/skillStorageContext.ts | 11 ++++-- src/node/services/aiService.test.ts | 1 + src/node/services/aiService.ts | 32 ++++++++++------- 10 files changed, 122 insertions(+), 24 deletions(-) diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index c36889bea9..6779733439 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -40,7 +40,7 @@ export const WorkspaceMCPModal: React.FC = ({ const [error, setError] = useState(null); // Use shared cache for tool test results - const { getTools, setResult, reload: reloadCache } = useMCPTestCache(projectPath); + const { getTools, setResult, reload: reloadCache } = useMCPTestCache(projectPath, workspaceId); // Ref so the effect can call reloadCache without depending on its identity. // We only want to re-fire the effect when the modal opens (open/api/ids change), diff --git a/src/browser/hooks/useMCPTestCache.ts b/src/browser/hooks/useMCPTestCache.ts index 93ec2fc3a6..0c8be208dc 100644 --- a/src/browser/hooks/useMCPTestCache.ts +++ b/src/browser/hooks/useMCPTestCache.ts @@ -8,11 +8,16 @@ type CachedResults = Record; /** * Hook for managing MCP server test results cache. * Persists results to localStorage, shared across Settings and WorkspaceMCPModal. + * + * Pass `workspaceId` when the listing is workspace-scoped (agent-plugins + * experiment): plugin server keys are deliberately stable across worktrees of + * a project, but their tool lists follow each workspace's own checkout, so + * results cached from one branch must not leak into another workspace's modal. */ -export function useMCPTestCache(projectPath: string) { +export function useMCPTestCache(projectPath: string, workspaceId?: string) { const storageKey = useMemo( - () => (projectPath ? getMCPTestResultsKey(projectPath) : ""), - [projectPath] + () => (projectPath ? getMCPTestResultsKey(projectPath, workspaceId) : ""), + [projectPath, workspaceId] ); const [cache, setCache] = useState(() => diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index da6cde5207..febb4b7a44 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -131,8 +131,12 @@ export const DEFAULT_RUNTIME_KEY = "defaultRuntime"; * Format: "mcpTestResults:{projectPath}" * Stores: Record */ -export function getMCPTestResultsKey(projectPath: string): string { - return `mcpTestResults:${projectPath}`; +export function getMCPTestResultsKey(projectPath: string, workspaceId?: string): string { + // Workspace-scoped results (agent-plugins experiment): plugin tool lists + // follow each workspace's checkout, so they must not be shared per-project. + return workspaceId + ? `mcpTestResults:${projectPath}:${workspaceId}` + : `mcpTestResults:${projectPath}`; } /** diff --git a/src/common/types/toolScope.ts b/src/common/types/toolScope.ts index c1bc203119..5dfba06c56 100644 --- a/src/common/types/toolScope.ts +++ b/src/common/types/toolScope.ts @@ -17,4 +17,11 @@ export type MuxToolScope = readonly muxHome: string; readonly projectRoot: string; readonly projectStorageAuthority: ProjectStorageAuthority; + /** + * Host checkout root when it differs from `projectRoot` (workspaces with + * a `subProjectPath` execute in a subdirectory of the checkout). Agent + * Plugins containers live at the checkout root, matching the UI-facing + * discovery paths (agent-plugins experiment). + */ + readonly checkoutRoot?: string; }; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 2ea8987176..a5234dffc0 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -294,10 +294,18 @@ async function resolveAgentDiscoveryContext( * otherwise the workspace's own context — worktree-scanning for host * workspaces, null for off-host workspaces so plugin servers match what the * engine actually offers there. + * + * SECURITY: the workspace must belong to the request's project. The caller's + * `trusted` flag is derived from `projectPath`, so honoring a workspaceId from + * a DIFFERENT (untrusted) project would scan that project's checkout under + * another project's trust and let mcp.test execute its repo-local stdio + * plugins. Mismatches fall back to the projectPath-scoped default, whose + * trust and scan root describe the same project. */ async function resolveWorkspaceAgentPluginsMcpContext( context: ORPCContext, - workspaceId: string | null | undefined + workspaceId: string | null | undefined, + projectPath: string | null | undefined ): Promise { const trimmed = workspaceId?.trim(); if (!trimmed) { @@ -309,6 +317,14 @@ async function resolveWorkspaceAgentPluginsMcpContext( return undefined; } const metadata = metadataResult.data; + if (metadata.projectPath !== projectPath?.trim()) { + log.debug("Ignoring Agent Plugins workspace context for mismatched project", { + workspaceId: trimmed, + requestedProjectPath: projectPath, + workspaceProjectPath: metadata.projectPath, + }); + return undefined; + } const runtime = createRuntimeForWorkspace(metadata); const workspacePath = resolveWorkspaceRootPath(metadata, runtime); return resolveAgentPluginsMcpContext(metadata, workspacePath); @@ -2816,7 +2832,8 @@ export const router = (authToken?: string) => { { agentPlugins: await resolveWorkspaceAgentPluginsMcpContext( context, - input.workspaceId + input.workspaceId, + input.projectPath ), } ); @@ -2961,7 +2978,8 @@ export const router = (authToken?: string) => { const agentPlugins = await resolveWorkspaceAgentPluginsMcpContext( context, - input.workspaceId + input.workspaceId, + projectPathProvided ? resolvedProjectPath : undefined ); const configuredTransport = input.name ? ( diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 7211e8df4c..0a6344be65 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -323,6 +323,42 @@ describe("loadPluginMcpServers", () => { } }); + test("rejects a cwd that resolves to a file (exec would fail with ENOTDIR)", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "cwd-file", + mcpDoc({ + rel: { type: "stdio", command: "x", cwd: "./afile" }, + rooted: { type: "stdio", command: "x", cwd: "${PLUGIN_ROOT}/afile" }, + }) + ); + await fs.writeFile(path.join(plugin.rootPath, "afile"), "not a dir", "utf8"); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics).toHaveLength(2); + expect(diagnostics.every((d) => d.message.includes("must be a directory"))).toBe(true); + }); + + test("rejects a data-anchored cwd whose existing target is a file", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "data-cwd-file", + mcpDoc({ srv: { type: "stdio", command: "x", cwd: "${PLUGIN_DATA}/blob" } }) + ); + const dataPath = getPluginDataPath(tmp.path, computePluginInstanceId(plugin.rootPath)); + await fs.mkdir(dataPath, { recursive: true }); + await fs.writeFile(path.join(dataPath, "blob"), "not a dir", "utf8"); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain("must be a directory"); + }); + test("rejects a cwd that symlink-escapes the plugin root", async () => { using tmp = new DisposableTempDir("plugin-mcp"); const outsideDir = path.join(tmp.path, "outside-dir"); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 4c9cd7646c..a51b25d3d1 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -267,6 +267,20 @@ async function normalizeStdioEntry( } return { error: `'cwd' escapes its containment root: ${getErrorMessage(error)}` }; } + + // An existing cwd must be a directory: exec() fails with ENOTDIR on files + // (and launch's mkdir of a data-dir cwd would fail the same way). + try { + const cwdStat = await fsPromises.stat(resolvedCwd); + if (!cwdStat.isDirectory()) { + return { error: `'cwd' must be a directory: ${cwd}` }; + } + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + return { error: `'cwd' is not accessible: ${getErrorMessage(error)}` }; + } + // Missing paths only reach here for data-anchored cwds (created at launch). + } } return { diff --git a/src/node/services/agentSkills/skillStorageContext.ts b/src/node/services/agentSkills/skillStorageContext.ts index 18b4c435e4..3d3483bbd1 100644 --- a/src/node/services/agentSkills/skillStorageContext.ts +++ b/src/node/services/agentSkills/skillStorageContext.ts @@ -39,11 +39,14 @@ function buildProjectLocalRoots( } : {}), // agent-plugins experiment: read-only plugin containers at lowest precedence within each scope. + // Containers anchor at the CHECKOUT root: for subProjectPath workspaces + // `projectRoot` is the execution subdirectory, but plugins live (and are + // listed by the UI) at the checkout level. ...(options?.includeAgentPlugins ? { projectPluginRoots: [ - path.join(muxScope.projectRoot, ".mux", "plugins"), - path.join(muxScope.projectRoot, ".agents", "plugins"), + path.join(muxScope.checkoutRoot ?? muxScope.projectRoot, ".mux", "plugins"), + path.join(muxScope.checkoutRoot ?? muxScope.projectRoot, ".agents", "plugins"), ], globalPluginRoots: [path.join(muxScope.muxHome, "plugins"), "~/.agents/plugins"], } @@ -140,7 +143,9 @@ export function resolveSkillStorageContext(input: { }), containment: { kind: "local", - root: input.muxScope.projectRoot, + // The checkout root (when present) contains projectRoot, so this stays a + // correct repo boundary while also covering checkout-level plugin roots. + root: input.muxScope.checkoutRoot ?? input.muxScope.projectRoot, }, }; } diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 70587bc046..51fe6586c9 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -2189,6 +2189,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { muxHome: muxHome.path, projectRoot: MULTI_PROJECT_CONFIG_KEY, projectStorageAuthority: "host-local", + checkoutRoot: MULTI_PROJECT_CONFIG_KEY, }); }); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index d06c4fdc52..ffaa93d706 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -377,7 +377,9 @@ export function resolveMuxProjectRootForHostFs( function resolveMuxToolScope( config: Config, metadata: WorkspaceMetadata, - workspacePath: string + workspacePath: string, + /** Host checkout root when known (subProjectPath workspaces execute in a subdirectory). */ + checkoutRoot?: string | null ): MuxToolScope { const projectConfig = config.loadConfigOrDefault().projects.get(metadata.projectPath); if ( @@ -400,6 +402,7 @@ function resolveMuxToolScope( projectRoot: resolveMuxProjectRootForHostFs(metadata, workspacePath), projectStorageAuthority: runtimeType === "ssh" || runtimeType === "docker" ? "runtime" : "host-local", + ...(checkoutRoot != null ? { checkoutRoot } : {}), }; } @@ -1552,17 +1555,22 @@ export class AIService extends EventEmitter { } recordStartupPhaseTiming("loadWorkspaceMcpOverridesMs", loadWorkspaceMcpOverridesStartedAt); + // Host checkout root for single-project host workspaces. This differs + // from `workspacePath` for subProjectPath workspaces, whose execution + // path is a subdirectory of the checkout; Agent Plugins containers (MCP + // and skills) anchor at the checkout root, matching the oRPC listing + // paths (resolveWorkspaceRootPath). + const hostCheckoutRoot = + singleProjectContext && + metadata.runtimeConfig.type !== "ssh" && + metadata.runtimeConfig.type !== "docker" + ? resolveWorkspaceRootPath(metadataWithPath, runtime) + : null; + // Agent Plugins: discovery follows the active checkout and is disabled - // for workspaces that exec off-host (SSH/Docker/devcontainer). Scan the - // CHECKOUT ROOT, not `workspacePath`: for subProjectPath workspaces the - // execution path includes the subdirectory, which would miss checkout- - // level plugin containers (and diverge from the oRPC listing path, which - // also uses resolveWorkspaceRootPath). - const agentPluginsMcpContext = singleProjectContext - ? resolveAgentPluginsMcpContext( - metadata, - resolveWorkspaceRootPath(metadataWithPath, runtime) - ) + // for workspaces that exec off-host (SSH/Docker/devcontainer). + const agentPluginsMcpContext = hostCheckoutRoot + ? resolveAgentPluginsMcpContext(metadata, hostCheckoutRoot) : null; // Fetch MCP server config for system prompt (before building message). @@ -1628,7 +1636,7 @@ export class AIService extends EventEmitter { }); recordStartupPhaseTiming("buildPlanInstructionsMs", buildPlanInstructionsStartedAt); - const muxScope = resolveMuxToolScope(this.config, metadata, workspacePath); + const muxScope = resolveMuxToolScope(this.config, metadata, workspacePath, hostCheckoutRoot); const desktopSessionManager = this.desktopSessionManager; let desktopCapabilityPromise: ReturnType | undefined; From e3ef82ced1834ee5b6713297006ce1b8bcd12d50 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 22:43:46 +0000 Subject: [PATCH 10/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20resolve=20checkout-?= =?UTF-8?q?root=20plugin=20skills=20in=20skill-list=20tool=20and=20slash?= =?UTF-8?q?=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.agentSkillSnapshot.test.ts | 67 +++++++++++++++++++ src/node/services/agentSession.ts | 39 +++++++++-- src/node/services/aiService.ts | 24 +++++++ .../services/tools/agent_skill_list.test.ts | 43 ++++++++++++ src/node/services/tools/agent_skill_list.ts | 17 +++-- 5 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentSession.agentSkillSnapshot.test.ts b/src/node/services/agentSession.agentSkillSnapshot.test.ts index e5009ccf93..435d87e72b 100644 --- a/src/node/services/agentSession.agentSkillSnapshot.test.ts +++ b/src/node/services/agentSession.agentSkillSnapshot.test.ts @@ -168,6 +168,73 @@ describe("AgentSession.sendMessage (agent skill snapshots)", () => { expect(snapshotText).toContain("Project override for init skill."); }); + it("resolves checkout-level plugin skills for subproject execution paths (agent-plugins)", async () => { + const workspaceId = "ws-test"; + + // agent-plugins experiment: the workspace executes in a subdirectory of the + // checkout, while the plugin container lives at the checkout level. + const checkout = await fs.mkdtemp(path.join(os.tmpdir(), "mux-agent-skill-checkout-")); + const subprojectPath = path.join(checkout, "packages", "app"); + await fs.mkdir(subprojectPath, { recursive: true }); + const muxHome = await fs.mkdtemp(path.join(os.tmpdir(), "mux-agent-skill-muxhome-")); + + const pluginDir = path.join(checkout, ".mux", "plugins", "demo-plugin"); + const skillDir = path.join(pluginDir, "skills", "plugin-skill"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "plugin.json"), + JSON.stringify({ + $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + name: "demo-plugin", + version: "1.0.0", + description: "demo", + }), + "utf-8" + ); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: plugin-skill\ndescription: Plugin skill\n---\n\nFollow the plugin skill.\n", + "utf-8" + ); + + const { session, appendToHistory, messages } = await createSessionHarness({ + workspaceId, + workspacePath: subprojectPath, + aiServiceOverrides: { + isAgentPluginsEnabled: () => true, + // Mirrors AIService: checkout root anchors plugin containers even though + // the execution path is the subproject directory. + resolveMuxToolScopeForWorkspace: () => ({ + type: "project", + muxHome, + projectRoot: subprojectPath, + projectStorageAuthority: "host-local", + checkoutRoot: checkout, + }), + }, + }); + + const result = await session.sendMessage("do X", { + model: "anthropic:claude-3-5-sonnet-latest", + agentId: "exec", + muxMetadata: { + type: "agent-skill", + rawCommand: "/plugin-skill do X", + skillName: "plugin-skill", + scope: "project", + }, + }); + + expect(result.success).toBe(true); + + expect(appendToHistory.mock.calls).toHaveLength(2); + const [snapshotMessage] = messages; + + expect(snapshotMessage.metadata?.agentSkillSnapshot?.skillName).toBe("plugin-skill"); + const snapshotText = snapshotMessage.parts.find((p) => p.type === "text")?.text; + expect(snapshotText).toContain("Follow the plugin skill."); + }); + it("dedupes identical skill snapshots when recently inserted", async () => { const workspaceId = "ws-test"; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e66bca3b45..9906c1a910 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -130,6 +130,7 @@ import { } from "@/common/utils/messages/retryEligibility"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { readAgentSkill } from "@/node/services/agentSkills/agentSkillsService"; +import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; import { createLoadedSkillSnapshot, extractLoadedSkillSnapshotsFromMessages, @@ -6448,10 +6449,40 @@ export class AgentSession { const includeAgentPlugins = typeof this.aiService.isAgentPluginsEnabled === "function" && this.aiService.isAgentPluginsEnabled(); - resolved = await readAgentSkill(runtime, skillDiscoveryPath, parsedName.data, { - includeClaudeSkills, - includeAgentPlugins, - }); + // agent-plugins experiment: resolve host-local project workspaces through + // the same storage context as the skill read tool so checkout-level + // plugin containers stay reachable — for subProjectPath workspaces the + // execution path is a subdirectory of the checkout and default + // discovery misses them. disableWorkspaceAgents keeps default + // discovery: it anchors at projectPath, which is already the + // checkout-level root the UI lists in that mode. + const muxScope = + !disableWorkspaceAgents && + typeof this.aiService.resolveMuxToolScopeForWorkspace === "function" + ? this.aiService.resolveMuxToolScopeForWorkspace(metadata, runtime, workspacePath) + : null; + const skillCtx = + muxScope?.type === "project" && muxScope.projectStorageAuthority === "host-local" + ? resolveSkillStorageContext({ + runtime, + workspacePath: skillDiscoveryPath, + muxScope, + includeClaudeSkills, + includeAgentPlugins, + }) + : null; + resolved = await readAgentSkill( + skillCtx?.runtime ?? runtime, + skillCtx?.workspacePath ?? skillDiscoveryPath, + parsedName.data, + { + ...(skillCtx != null + ? { roots: skillCtx.roots, containment: skillCtx.containment } + : {}), + includeClaudeSkills, + includeAgentPlugins, + } + ); } catch (error) { if (ref.source === "slash") { throw error; diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index ffaa93d706..17d4d0a964 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -42,6 +42,7 @@ import { resolveWorkspaceExecutionPath, resolveWorkspaceRootPath, } from "@/node/runtime/runtimeHelpers"; +import type { Runtime } from "@/node/runtime/Runtime"; import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; import { getMuxEnv, getRuntimeType } from "@/node/runtime/initHook"; @@ -1052,6 +1053,29 @@ export class AIService extends EventEmitter { return this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS) === true; } + /** + * Resolve the MuxToolScope a workspace's tools receive, including the host + * checkout root that anchors Agent Plugins containers (agent-plugins + * experiment). Public so AgentSession's slash-skill snapshot materialization + * resolves skills with the same roots/containment as the skill read tool: + * for subProjectPath workspaces the execution path is a subdirectory of the + * checkout, and default discovery there misses checkout-level plugin + * containers. Mirrors streamMessage's hostCheckoutRoot gating. + */ + resolveMuxToolScopeForWorkspace( + metadata: WorkspaceMetadata, + runtime: Runtime, + workspacePath: string + ): MuxToolScope { + const hostCheckoutRoot = + !isMultiProject(metadata) && + metadata.runtimeConfig.type !== "ssh" && + metadata.runtimeConfig.type !== "docker" + ? resolveWorkspaceRootPath(metadata, runtime) + : null; + return resolveMuxToolScope(this.config, metadata, workspacePath, hostCheckoutRoot); + } + /** Stream a message conversation to the AI model. */ async streamMessage(opts: StreamMessageOptions): Promise> { const { diff --git a/src/node/services/tools/agent_skill_list.test.ts b/src/node/services/tools/agent_skill_list.test.ts index faf2d9b6ac..5bb2c1cfbb 100644 --- a/src/node/services/tools/agent_skill_list.test.ts +++ b/src/node/services/tools/agent_skill_list.test.ts @@ -359,6 +359,49 @@ describe("agent_skill_list", () => { }); }); + it("lists checkout-level plugin skills when the workspace executes in a subproject", async () => { + using homeDir = new TestTempDir("test-agent-skill-list-plugins-subproject-home"); + using checkout = new TestTempDir("test-agent-skill-list-plugins-subproject-checkout"); + using muxHomeDir = new TestTempDir("test-agent-skill-list-plugins-subproject-mux-home"); + + await withHomeDir(homeDir.path, async () => { + await withMuxRoot(muxHomeDir.path, async () => { + // subProjectPath workspaces execute in a subdirectory of the checkout; + // plugin containers live at the checkout level. + const subprojectRoot = path.join(checkout.path, "packages", "app"); + await fs.mkdir(subprojectRoot, { recursive: true }); + await writePlugin(path.join(checkout.path, ".mux", "plugins"), "checkout-plugin", [ + { name: "plugin-checkout", description: "from checkout plugin" }, + ]); + + const tool = createAgentSkillListTool({ + ...createTestToolConfig(subprojectRoot, { + muxScope: { + type: "project", + muxHome: muxHomeDir.path, + projectRoot: subprojectRoot, + projectStorageAuthority: "host-local", + checkoutRoot: checkout.path, + }, + }), + experiments: { agentPlugins: true }, + }); + const result = (await tool.execute!({}, mockToolCallOptions)) as AgentSkillListToolResult; + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(getSkill(result.skills, "plugin-checkout")).toMatchObject({ + name: "plugin-checkout", + description: "from checkout plugin", + scope: "project", + }); + }); + }); + }); + it("returns only the winning descriptor when project skills shadow global skills", async () => { using project = new TestTempDir("test-agent-skill-list-shadow-project"); using muxHome = new TestTempDir("test-agent-skill-list-shadow-home"); diff --git a/src/node/services/tools/agent_skill_list.ts b/src/node/services/tools/agent_skill_list.ts index 63c292e429..54f194792e 100644 --- a/src/node/services/tools/agent_skill_list.ts +++ b/src/node/services/tools/agent_skill_list.ts @@ -266,15 +266,20 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) if (includeAgentPlugins) { // agent-plugins experiment: expand plugin containers into per-plugin skills/ roots. + // Containers anchor at the CHECKOUT root (matching buildProjectLocalRoots): + // for subProjectPath workspaces `projectRoot` is the execution + // subdirectory, but plugins live at the checkout level. + const pluginAnchor = + muxScope.type === "project" ? (muxScope.checkoutRoot ?? muxScope.projectRoot) : null; const pluginContainers = [ - ...(muxScope.type === "project" + ...(pluginAnchor != null ? [ { - path: path.join(muxScope.projectRoot, ".mux", "plugins"), + path: path.join(pluginAnchor, ".mux", "plugins"), scope: "project" as const, }, { - path: path.join(muxScope.projectRoot, ".agents", "plugins"), + path: path.join(pluginAnchor, ".agents", "plugins"), scope: "project" as const, }, ] @@ -288,10 +293,10 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) continue; } // Project plugin roots keep the repo-symlink posture of other project - // roots: the plugin root itself must stay inside the project root. - if (plugin.scope === "project" && muxScope.type === "project") { + // roots: the plugin root itself must stay inside the checkout root. + if (plugin.scope === "project" && pluginAnchor != null) { try { - await ensurePathContained(muxScope.projectRoot, plugin.rootPath); + await ensurePathContained(pluginAnchor, plugin.rootPath); } catch { log.warn( `Skipping project plugin '${plugin.name}': plugin root resolves outside the project root` From ebb91d17a494b1d75d2f7bba328d9d46038596f6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 22:53:42 +0000 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reject=20directory?= =?UTF-8?q?=20paths=20as=20relative=20plugin=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/agentPlugins/mcpConfig.test.ts | 15 +++++++++++++++ src/node/services/agentPlugins/mcpConfig.ts | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 0a6344be65..043aa7d751 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -253,6 +253,21 @@ describe("loadPluginMcpServers", () => { expect(escapeResult.diagnostics[0].message).toContain("outside the plugin root"); }); + test("rejects a './'-relative command that resolves to a directory (exec would fail)", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "dir-cmd", + mcpDoc({ srv: { type: "stdio", command: "./bin" } }) + ); + await fs.mkdir(path.join(plugin.rootPath, "bin"), { recursive: true }); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain("must be a file"); + }); + test("rejects entries with reserved env keys (PLUGIN_ROOT / PLUGIN_DATA)", async () => { using tmp = new DisposableTempDir("plugin-mcp"); for (const key of ["PLUGIN_ROOT", "PLUGIN_DATA"]) { diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index a51b25d3d1..82dd8edec0 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -224,6 +224,16 @@ async function normalizeStdioEntry( : `'command' resolves outside the plugin root: ${getErrorMessage(error)}`, }; } + // Containment only proves existence: a directory (e.g. "./bin") would pass + // but exec() rejects it, yielding an enableable server that can never start. + try { + const commandStat = await fsPromises.stat(resolvedCommand); + if (!commandStat.isFile()) { + return { error: `'command' must be a file: ${command}` }; + } + } catch (error) { + return { error: `'command' is not accessible: ${getErrorMessage(error)}` }; + } } // §9.2 expansion applies to args elements, env values, and cwd only. From a822257b0ee80a4decc98341a9f106848e4ed59c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 23:03:27 +0000 Subject: [PATCH 12/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reject=20whitespace?= =?UTF-8?q?=20bare=20commands=20+=20keep=20contained=20plugin=20skill=20sy?= =?UTF-8?q?mlinks=20listed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/agentPlugins/mcpConfig.test.ts | 16 ++++++ src/node/services/agentPlugins/mcpConfig.ts | 7 +++ .../services/tools/agent_skill_list.test.ts | 57 +++++++++++++++++++ src/node/services/tools/agent_skill_list.ts | 15 ++++- 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 043aa7d751..203e057380 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -268,6 +268,22 @@ describe("loadPluginMcpServers", () => { expect(diagnostics[0].message).toContain("must be a file"); }); + test("rejects bare commands containing whitespace or control characters", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + for (const [index, command] of ["node --version", " ", "no\tde", "no\nde"].entries()) { + const plugin = await makePlugin( + tmp.path, + `ws-cmd-${index}`, + mcpDoc({ srv: { type: "stdio", command } }) + ); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain("single executable token"); + } + }); + test("rejects entries with reserved env keys (PLUGIN_ROOT / PLUGIN_DATA)", async () => { using tmp = new DisposableTempDir("plugin-mcp"); for (const key of ["PLUGIN_ROOT", "PLUGIN_DATA"]) { diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 82dd8edec0..d180ad4ef8 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -92,6 +92,13 @@ function classifyCommandToken(command: string): "bare" | "relative" | null { if (command.includes("/") || command.includes("\\")) { return null; } + // Bare names are PATH-searched executable names. A shell fragment like + // "node --version" (or control characters) can never resolve — launch + // shell-quotes the whole value as one token — so it would otherwise yield + // an enableable server that can never start. + if (/[\s\p{Cc}]/u.test(command)) { + return null; + } return "bare"; } diff --git a/src/node/services/tools/agent_skill_list.test.ts b/src/node/services/tools/agent_skill_list.test.ts index 5bb2c1cfbb..1fa511f2ea 100644 --- a/src/node/services/tools/agent_skill_list.test.ts +++ b/src/node/services/tools/agent_skill_list.test.ts @@ -402,6 +402,63 @@ describe("agent_skill_list", () => { }); }); + it("lists plugin skills behind symlinks contained in the plugin root, rejects escaping ones", async () => { + using homeDir = new TestTempDir("test-agent-skill-list-plugins-symlink-home"); + using project = new TestTempDir("test-agent-skill-list-plugins-symlink-project"); + using muxHomeDir = new TestTempDir("test-agent-skill-list-plugins-symlink-mux-home"); + + await withHomeDir(homeDir.path, async () => { + await withMuxRoot(muxHomeDir.path, async () => { + await writePlugin(path.join(project.path, ".mux", "plugins"), "sym-plugin", []); + const pluginDir = path.join(project.path, ".mux", "plugins", "sym-plugin"); + await fs.mkdir(path.join(pluginDir, "skills"), { recursive: true }); + + // Contained symlink: skills/ -> ../real-skill (inside the plugin root). + await writeSkill(path.join(pluginDir, "real-skills"), "linked-skill", { + description: "behind a contained symlink", + }); + await fs.symlink( + path.join(pluginDir, "real-skills", "linked-skill"), + path.join(pluginDir, "skills", "linked-skill") + ); + + // Escaping symlink: resolves outside the plugin root; must stay hidden. + await writeSkill(path.join(project.path, "outside-skills"), "escaping-skill", { + description: "outside the plugin root", + }); + await fs.symlink( + path.join(project.path, "outside-skills", "escaping-skill"), + path.join(pluginDir, "skills", "escaping-skill") + ); + + const tool = createAgentSkillListTool({ + ...createTestToolConfig(project.path, { + muxScope: { + type: "project", + muxHome: muxHomeDir.path, + projectRoot: project.path, + projectStorageAuthority: "host-local", + }, + }), + experiments: { agentPlugins: true }, + }); + const result = (await tool.execute!({}, mockToolCallOptions)) as AgentSkillListToolResult; + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + expect(getSkill(result.skills, "linked-skill")).toMatchObject({ + name: "linked-skill", + description: "behind a contained symlink", + scope: "project", + }); + expect(result.skills.find((skill) => skill.name === "escaping-skill")).toBeUndefined(); + }); + }); + }); + it("returns only the winning descriptor when project skills shadow global skills", async () => { using project = new TestTempDir("test-agent-skill-list-shadow-project"); using muxHome = new TestTempDir("test-agent-skill-list-shadow-home"); diff --git a/src/node/services/tools/agent_skill_list.ts b/src/node/services/tools/agent_skill_list.ts index 54f194792e..7a94475429 100644 --- a/src/node/services/tools/agent_skill_list.ts +++ b/src/node/services/tools/agent_skill_list.ts @@ -216,6 +216,12 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) skillsRoot: string; containmentRoot: string; scope: "global" | "project"; + /** + * Agent Plugins root: per-skill containment anchors at the plugin + * root (§4.1), so contained symlinked skill dirs stay listed — + * matching stream discovery and agent_skill_read. + */ + isPlugin?: boolean; }> = [ { skillsRoot: path.join(muxScope.muxHome, "skills"), @@ -309,12 +315,13 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) skillsRoot: plugin.skillsDir, containmentRoot: plugin.rootPath, scope: plugin.scope, + isPlugin: true, }); } } const skills: AgentSkillDescriptor[] = []; - for (const { skillsRoot, containmentRoot, scope } of roots) { + for (const { skillsRoot, containmentRoot, scope, isPlugin } of roots) { let skillsRootReal: string; try { skillsRootReal = await fsPromises.realpath(skillsRoot); @@ -337,8 +344,10 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) const directoryEntries = await listSkillDirectories(skillsRootReal); for (const entry of directoryEntries) { // Project scope: reject symlinked skill directories to avoid resolving - // repo-controlled entries to out-of-project locations. - if (scope === "project" && entry.isSymbolicLink) { + // repo-controlled entries to out-of-project locations. Plugin roots + // are exempt: readSkillDescriptor enforces realpath containment at + // the plugin root, the same rule stream discovery applies. + if (scope === "project" && !isPlugin && entry.isSymbolicLink) { log.warn( `Skipping project skill '${entry.name}': skill directory is a symbolic link` ); From 043d65b85ef105984759a3e6e4e41a7d3d8bf4bf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 23:20:05 +0000 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stable=20symlinked?= =?UTF-8?q?=20global=20plugin=20identity=20+=20executable=20and=20NUL=20la?= =?UTF-8?q?unch=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/agentPlugins/mcpConfig.test.ts | 76 ++++++++++++++++++- src/node/services/agentPlugins/mcpConfig.ts | 60 ++++++++++++--- 2 files changed, 125 insertions(+), 11 deletions(-) diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 203e057380..6ede8eb137 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -220,7 +220,11 @@ describe("loadPluginMcpServers", () => { mcpDoc({ srv: { type: "stdio", command: "./bin/tool" } }) ); await fs.mkdir(path.join(plugin.rootPath, "bin"), { recursive: true }); - await fs.writeFile(path.join(plugin.rootPath, "bin", "tool"), "#!/bin/sh\n", "utf8"); + // mode 0o755: normalization verifies the execute bit on POSIX. + await fs.writeFile(path.join(plugin.rootPath, "bin", "tool"), "#!/bin/sh\n", { + encoding: "utf8", + mode: 0o755, + }); const { servers } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); @@ -284,6 +288,46 @@ describe("loadPluginMcpServers", () => { } }); + test.skipIf(process.platform === "win32")( + "rejects a './'-relative command without the execute bit", + async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const plugin = await makePlugin( + tmp.path, + "noexec-cmd", + mcpDoc({ srv: { type: "stdio", command: "./tool" } }) + ); + await fs.writeFile(path.join(plugin.rootPath, "tool"), "#!/bin/sh\n", { + encoding: "utf8", + mode: 0o644, + }); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain("not executable"); + } + ); + + test("rejects NUL bytes in args, env entries, cwd, and relative commands", async () => { + using tmp = new DisposableTempDir("plugin-mcp"); + const cases: Array<{ entry: Record; expect: string }> = [ + { entry: { type: "stdio", command: "x", args: ["a\0b"] }, expect: "NUL" }, + { entry: { type: "stdio", command: "x", env: { KEY: "a\0b" } }, expect: "NUL" }, + { entry: { type: "stdio", command: "x", env: { "K\0EY": "v" } }, expect: "NUL" }, + { entry: { type: "stdio", command: "x", cwd: "./a\0b" }, expect: "NUL" }, + { entry: { type: "stdio", command: "./a\0b" }, expect: "single executable token" }, + ]; + for (const [index, testCase] of cases.entries()) { + const plugin = await makePlugin(tmp.path, `nul-${index}`, mcpDoc({ srv: testCase.entry })); + + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { muxHome: tmp.path }); + + expect(servers).toEqual({}); + expect(diagnostics[0].message).toContain(testCase.expect); + } + }); + test("rejects entries with reserved env keys (PLUGIN_ROOT / PLUGIN_DATA)", async () => { using tmp = new DisposableTempDir("plugin-mcp"); for (const key of ["PLUGIN_ROOT", "PLUGIN_DATA"]) { @@ -617,6 +661,36 @@ describe("createAgentPluginsMcpProvider", () => { }); }); + test("global plugin identity survives symlink retargeting (versioned installs)", async () => { + using home = new DisposableTempDir("plugin-provider-home"); + using muxHome = new DisposableTempDir("plugin-provider-mux"); + await withHomeDir(home.path, async () => { + // Versioned install layout: plugins/demo is a symlink an updater + // retargets from v1 to v2. The realpath changes; identity must not. + const versions = path.join(muxHome.path, "versions"); + await writeDiscoverablePlugin(versions, "v1", mcpDoc({ srv: STDIO_ENTRY })); + await writeDiscoverablePlugin(versions, "v2", mcpDoc({ srv: STDIO_ENTRY })); + const container = path.join(muxHome.path, "plugins"); + await fs.mkdir(container, { recursive: true }); + const link = path.join(container, "demo"); + await fs.symlink(path.join(versions, "v1"), link); + + const provider = createAgentPluginsMcpProvider({ + muxHome: muxHome.path, + isEnabled: () => true, + }); + + const before = Object.keys(await provider({ trusted: false })).sort(); + expect(before).toHaveLength(1); + + await fs.unlink(link); + await fs.symlink(path.join(versions, "v2"), link); + + const after = Object.keys(await provider({ trusted: false })).sort(); + expect(after).toEqual(before); + }); + }); + test("a plugin with broken mcp.json never affects sibling plugins", async () => { using home = new DisposableTempDir("plugin-provider-home"); using muxHome = new DisposableTempDir("plugin-provider-mux"); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index d180ad4ef8..6f63df06e8 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; import * as fsPromises from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -24,10 +25,11 @@ import { expandPluginPlaceholders, type PluginPlaceholderValues } from "./expans * siblings unaffected. * * Normalized servers are default-disabled, read-only config entries keyed by - * `plugin::` where `instanceId` hashes the canonical - * plugin root. The key is stable across manifest renames and content updates, - * so workspace `enabledServers` overrides and the `PLUGIN_DATA` directory - * survive plugin updates (§9.1). + * `plugin::` where `instanceId` hashes the plugin's + * stable installation identity (lexical location; see + * computePluginInstanceId). The key is stable across manifest renames, + * content updates, and symlink retargets, so workspace `enabledServers` + * overrides and the `PLUGIN_DATA` directory survive plugin updates (§9.1). */ /** Canonical `$schema` const for Agent Plugins 1.0.0 mcp.json documents. */ @@ -37,9 +39,11 @@ export const AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0 = const PLUGIN_SERVER_KEY_PREFIX = "plugin:"; /** - * Stable plugin-instance identity. Global plugins hash their canonical root - * path; project plugins hash `\0` so - * the same plugin gets the same instance ID from every worktree of a project + * Stable plugin-instance identity. Global plugins hash their LEXICAL + * installation location (`/`) so a symlinked plugin + * dir keeps its identity when an updater retargets the link to a new version; + * project plugins hash `\0` so the + * same plugin gets the same instance ID from every worktree of a project * (worktree realpaths differ per workspace, but the project identity and the * plugin's location inside the repo do not). */ @@ -87,7 +91,10 @@ function classifyCommandToken(command: string): "bare" | "relative" | null { return null; } if (command.startsWith("./")) { - return "relative"; + // NUL can neither appear in a real path nor cross the spawn boundary; + // rejecting it here keeps the diagnostic precise (fs would otherwise + // fail with ERR_INVALID_ARG_VALUE during containment resolution). + return command.includes("\0") ? null : "relative"; } if (command.includes("/") || command.includes("\\")) { return null; @@ -190,6 +197,13 @@ async function normalizeStdioEntry( } } const args = (entry.args as string[] | undefined) ?? []; + // NUL bytes cannot cross the spawn boundary: child_process rejects argv and + // env entries containing '\0' (ERR_INVALID_ARG_VALUE), so accepting them + // would yield an enableable server whose every launch fails pre-spawn. + const argsNulIndex = args.findIndex((arg) => arg.includes("\0")); + if (argsNulIndex !== -1) { + return { error: `'args[${argsNulIndex}]' must not contain NUL bytes` }; + } if (entry.env !== undefined && !isPlainObject(entry.env)) { return { error: "'env' must be an object of strings" }; @@ -204,6 +218,10 @@ async function normalizeStdioEntry( if (typeof value !== "string") { return { error: `'env.${key}' must be a string` }; } + if (key.includes("\0") || value.includes("\0")) { + // Same spawn constraint as args: env entries must be NUL-free. + return { error: `'env' entry ${JSON.stringify(key)} must not contain NUL bytes` }; + } } if (entry.cwd !== undefined && typeof entry.cwd !== "string") { @@ -215,6 +233,11 @@ async function normalizeStdioEntry( error: "'cwd' must be './…', '${PLUGIN_ROOT}[/…]', or '${PLUGIN_DATA}[/…]'", }; } + if (cwd?.includes("\0")) { + // Fail with a precise diagnostic instead of the misleading fs error the + // containment resolution below would raise for a NUL-bearing path. + return { error: "'cwd' must not contain NUL bytes" }; + } const rootPath = ctx.plugin.rootPath; @@ -241,6 +264,15 @@ async function normalizeStdioEntry( } catch (error) { return { error: `'command' is not accessible: ${getErrorMessage(error)}` }; } + // POSIX: a non-executable file spawns then exits with 'permission denied' + // on every launch. Windows has no execute bit, so skip the check there. + if (process.platform !== "win32") { + try { + await fsPromises.access(resolvedCommand, fsConstants.X_OK); + } catch { + return { error: `'command' is not executable: ${command}` }; + } + } } // §9.2 expansion applies to args elements, env values, and cwd only. @@ -610,7 +642,7 @@ export function createAgentPluginsMcpProvider(ctx: { if (plugin.mcpConfigPath === undefined) { continue; } - let instanceId: string | undefined; + let instanceId: string; if (plugin.scope === "project" && projectRoot !== undefined) { // Project plugin roots keep the repo-symlink posture of repo config: // the plugin root itself must stay inside the scanned checkout. @@ -627,11 +659,19 @@ export function createAgentPluginsMcpProvider(ctx: { projectRoot, plugin, }); + } else { + // Global scope: hash the LEXICAL installation location, not the + // canonical root. A symlinked plugin dir (e.g. a version-managed + // install) realpaths to a version-specific target, so hashing + // rootPath would rotate the server key and PLUGIN_DATA on every + // update, silently disabling workspace-enabled servers and + // orphaning their persistent data. + instanceId = computePluginInstanceId(path.join(plugin.containerPath, plugin.dirName)); } try { const { servers } = await loadPluginMcpServers(plugin, { muxHome: ctx.muxHome, - ...(instanceId !== undefined ? { instanceId } : {}), + instanceId, }); Object.assign(merged, servers); } catch (error) { From a77d3dcef017e2269d6ac1ff20255744bbe8b7b3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 6 Aug 2026 23:34:03 +0000 Subject: [PATCH 14/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20enforce=20checkout?= =?UTF-8?q?=20containment=20for=20project=20plugin=20skills=20in=20default?= =?UTF-8?q?=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSkills/agentSkillsService.test.ts | 37 +++++++++++++++++++ .../agentSkills/agentSkillsService.ts | 8 +++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index 14350edad7..7b75c727b8 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -1137,6 +1137,43 @@ describe("agentSkillsService agent plugins", () => { expect(onRoots.globalPluginRoots).toBeUndefined(); }); + test("default discovery (no containment options) rejects project plugins symlinked outside the checkout", async () => { + using project = new DisposableTempDir("agent-skills-plugin-escape"); + using outside = new DisposableTempDir("agent-skills-plugin-escape-outside"); + using global = new DisposableTempDir("agent-skills-plugin-escape-global"); + + // A committed .mux/plugins/ symlink to an external plugin dir must + // stay invisible even for callers that pass no containment (UI list/get + // default discovery), matching stream discovery and the skill tools. + const externalPlugin = await writePlugin(outside.path, "external-plugin", [ + { name: "escaping-skill", description: "outside the checkout" }, + ]); + await fs.mkdir(path.join(project.path, ".mux", "plugins"), { recursive: true }); + await fs.symlink(externalPlugin, path.join(project.path, ".mux", "plugins", "external-plugin")); + await writePlugin(path.join(project.path, ".mux", "plugins"), "contained-plugin", [ + { name: "contained-skill", description: "inside the checkout" }, + ]); + + const runtime = new LocalRuntime(project.path); + const roots = { + ...getDefaultAgentSkillsRoots(runtime, project.path, { includeAgentPlugins: true }), + globalRoot: global.path, + universalRoot: "", + globalPluginRoots: [], + }; + + const skills = await discoverAgentSkills(runtime, project.path, { roots }); + + expect(skills.find((s) => s.name === "escaping-skill")).toBeUndefined(); + // Sanity: sibling contained plugin still discovered, so absence is containment-specific. + expect(skills.find((s) => s.name === "contained-skill")).toMatchObject({ scope: "project" }); + + // Read path applies the same intrinsic containment. + expect( + readAgentSkill(runtime, project.path, SkillNameSchema.parse("escaping-skill"), { roots }) + ).rejects.toThrow("not found"); + }); + test("experiment off: plugin skills stay invisible with default-shaped roots", async () => { using project = new DisposableTempDir("agent-skills-plugin-off"); using global = new DisposableTempDir("agent-skills-plugin-off-global"); diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index 39f00895fc..ad2048ce64 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -199,7 +199,13 @@ async function buildScanCandidates( containers: roots.projectPluginRoots ?? [], scope: "project", workspacePath, - projectContainmentRoot: containment.kind === "local" ? containment.root : undefined, + // Project plugin roots ALWAYS keep the repo-symlink posture: even callers + // without project containment (UI list/get default discovery) must not + // resolve a committed .mux/plugins/ symlink outside the checkout — + // otherwise the UI would offer plugin skills that stream discovery and + // the skill tools reject. Default containers derive from workspacePath, + // so it is the correct fallback anchor. + projectContainmentRoot: containment.kind === "local" ? containment.root : workspacePath, }); const globalPluginCandidates = await buildPluginScanCandidates({ containers: roots.globalPluginRoots ?? [], From 3f516da5fc45bc65c6cedc38fbdf9d659ab3be0e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 7 Aug 2026 00:03:34 +0000 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20seed=20workspace-sc?= =?UTF-8?q?oped=20MCP=20test=20cache=20key=20in=20WorkspaceMCPModal=20stor?= =?UTF-8?q?ies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx index b581ad9024..e79b3fc646 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx @@ -48,7 +48,10 @@ const POSTHOG_TOOLS = [ "experiment-create", ]; -const PROJECT_MCP_CACHE_KEY = getMCPTestResultsKey(PROJECT_PATH); +// The modal scopes its tool test cache by workspace (agent-plugins experiment: +// plugin server keys are stable across worktrees, but tool lists follow each +// workspace's checkout), so stories must seed the workspace-scoped key. +const PROJECT_MCP_CACHE_KEY = getMCPTestResultsKey(PROJECT_PATH, WORKSPACE_ID); interface WorkspaceMCPStoryOptions { servers?: Record; From 5ea5cb2f3a46b2bf9bf851335b7f15438260bebc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 7 Aug 2026 00:10:31 +0000 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20show=20plugin=20ins?= =?UTF-8?q?tall=20location=20so=20duplicate=20installations=20are=20distin?= =?UTF-8?q?guishable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceMCPModal/WorkspaceMCPModal.tsx | 3 ++- .../Settings/Sections/MCPSettingsSection.tsx | 6 +++-- src/common/orpc/schemas/mcp.ts | 2 ++ src/common/types/mcp.ts | 6 +++++ .../services/agentPlugins/mcpConfig.test.ts | 10 +++++++++ src/node/services/agentPlugins/mcpConfig.ts | 17 ++++++++++++++ src/node/services/mcpConfigService.test.ts | 7 +++++- src/node/services/mcpServerManager.test.ts | 22 ++++++++++++++++--- 8 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index 6779733439..ef4290cbb8 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -348,7 +348,8 @@ export const WorkspaceMCPModal: React.FC = ({
{displayName}
{info.plugin ? (
- Agent Plugin ({info.plugin.sourceScope}) — disabled by default + Agent Plugin ({info.plugin.sourceScope} · {info.plugin.sourceLocation} + ) — disabled by default
) : ( projectDisabled && ( diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx index f79960bf93..5a14a17f4a 100644 --- a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx @@ -1201,9 +1201,11 @@ export const MCPSettingsSection: React.FC = () => { {displayName} - {isPluginEntry && ( + {entry.plugin && ( - plugin + {/* Include the install location: same-name plugins can + exist in sibling containers (.mux vs .agents). */} + plugin · {entry.plugin.sourceLocation} )} {cached?.result.success && !isEditing && isEnabled && ( diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts index eeaa7c20b4..98886338c6 100644 --- a/src/common/orpc/schemas/mcp.ts +++ b/src/common/orpc/schemas/mcp.ts @@ -34,6 +34,8 @@ export const MCPServerPluginProvenanceSchema = z.object({ pluginName: z.string(), serverName: z.string(), sourceScope: z.enum(["project", "global"]), + /** Installation location discriminator, e.g. ".mux/plugins/demo" (same-name plugins can sit in sibling containers). */ + sourceLocation: z.string(), }); export const MCPServerInfoSchema = z.discriminatedUnion("transport", [ diff --git a/src/common/types/mcp.ts b/src/common/types/mcp.ts index be4767b43f..4e1f016442 100644 --- a/src/common/types/mcp.ts +++ b/src/common/types/mcp.ts @@ -13,6 +13,12 @@ export interface MCPServerPluginProvenance { /** Server name as declared in the plugin's mcp.json (the map key is the instance key). */ serverName: string; sourceScope: "project" | "global"; + /** + * Human-readable installation location, e.g. ".mux/plugins/demo". Same-name + * plugins can be installed in sibling containers of one scope (.mux vs + * .agents), so the UI needs this discriminator to tell instances apart. + */ + sourceLocation: string; } export interface MCPServerBaseInfo { diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 6ede8eb137..c48bf45239 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -94,6 +94,12 @@ describe("loadPluginMcpServers", () => { pluginName: "demo", serverName: "everything", sourceScope: "global", + // Last two container segments + plugin dir (makePlugin's container is tmp.path). + sourceLocation: path.join( + path.basename(path.dirname(tmp.path)), + path.basename(tmp.path), + "demo" + ), }); }); @@ -545,6 +551,10 @@ describe("loadPluginMcpServers", () => { const dataA = (Object.values(resultA.servers)[0] as MCPStdioServerInfo).env?.PLUGIN_DATA; const dataB = (Object.values(resultB.servers)[0] as MCPStdioServerInfo).env?.PLUGIN_DATA; expect(dataA).not.toBe(dataB); + // Displayed provenance must also distinguish the two installations. + const locA = (Object.values(resultA.servers)[0] as MCPStdioServerInfo).plugin?.sourceLocation; + const locB = (Object.values(resultB.servers)[0] as MCPStdioServerInfo).plugin?.sourceLocation; + expect(locA).not.toBe(locB); }); }); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 6f63df06e8..fb22248e9c 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -166,6 +166,21 @@ async function ensureContainedAllowMissingRoot(root: string, candidate: string): } } +/** + * Display discriminator for a plugin installation: the container's last two + * lexical segments plus the plugin dir, e.g. ".mux/plugins/demo" vs + * ".agents/plugins/demo". Same-name plugins can only collide across sibling + * containers of a scope, so this is unique per scope (cross-scope entries are + * already distinguished by the displayed sourceScope). + */ +function computePluginSourceLocation(plugin: AgentPluginInfo): string { + return path.join( + path.basename(path.dirname(plugin.containerPath)), + path.basename(plugin.containerPath), + plugin.dirName + ); +} + interface NormalizeContext { plugin: AgentPluginInfo; dataPath: string; @@ -350,6 +365,7 @@ async function normalizeStdioEntry( pluginName: ctx.plugin.name, serverName: "", // filled by caller sourceScope: ctx.plugin.scope, + sourceLocation: computePluginSourceLocation(ctx.plugin), }, }, }; @@ -413,6 +429,7 @@ function normalizeRemoteEntry( pluginName: ctx.plugin.name, serverName: "", // filled by caller sourceScope: ctx.plugin.scope, + sourceLocation: computePluginSourceLocation(ctx.plugin), }, }, }; diff --git a/src/node/services/mcpConfigService.test.ts b/src/node/services/mcpConfigService.test.ts index a83ab02e19..0f072b36ea 100644 --- a/src/node/services/mcpConfigService.test.ts +++ b/src/node/services/mcpConfigService.test.ts @@ -146,7 +146,12 @@ describe("MCP server disable filtering", () => { command: "bunx", args: ["-y", "some-server"], disabled: true, - plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" as const }, + plugin: { + pluginName: "demo", + serverName: "srv", + sourceScope: "global" as const, + sourceLocation: ".mux/plugins/demo", + }, }; test("listServers merges Agent Plugins servers at the lowest precedence", async () => { diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 9f91306efa..fb39ea8ca7 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -1192,6 +1192,7 @@ describe("MCPServerManager", () => { pluginName: "demo", serverName: "everything", sourceScope: "global" as const, + sourceLocation: ".mux/plugins/demo", }, ...overrides, }, @@ -1314,7 +1315,12 @@ describe("prepareStdioLaunch", () => { env: { PLUGIN_ROOT: tmp.path, PLUGIN_DATA: dataPath }, cwd: tmp.path, disabled: false, - plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" }, + plugin: { + pluginName: "demo", + serverName: "srv", + sourceScope: "global", + sourceLocation: ".mux/plugins/demo", + }, }); expect((await fs.stat(dataPath)).isDirectory()).toBe(true); @@ -1336,7 +1342,12 @@ describe("prepareStdioLaunch", () => { env: { PLUGIN_ROOT: tmp.path, PLUGIN_DATA: dataPath }, cwd: nestedCwd, disabled: false, - plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" }, + plugin: { + pluginName: "demo", + serverName: "srv", + sourceScope: "global", + sourceLocation: ".mux/plugins/demo", + }, }); // exec() requires an existing cwd; data-dir cwds are client-managed state. @@ -1352,7 +1363,12 @@ describe("prepareStdioLaunch", () => { command: "bunx", args: [], disabled: false, - plugin: { pluginName: "demo", serverName: "srv", sourceScope: "global" }, + plugin: { + pluginName: "demo", + serverName: "srv", + sourceScope: "global", + sourceLocation: ".mux/plugins/demo", + }, }) ).rejects.toThrow("PLUGIN_DATA"); }); From 6159691fc9d22eba53ba41d838b552372127e8b4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 7 Aug 2026 00:21:20 +0000 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20hide=20global=20too?= =?UTF-8?q?l-allowlist=20controls=20for=20plugin=20MCP=20servers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Sections/MCPSettingsSection.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx index 5a14a17f4a..ca04418997 100644 --- a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx @@ -1413,16 +1413,21 @@ export const MCPSettingsSection: React.FC = () => { )}
)} - {cached?.result.success && cached.result.tools.length > 0 && !isEditing && ( -
- -
- )} + {/* Plugin servers are read-only here (setToolAllowlist rejects + plugin keys); their allowlists live in Workspace MCP. */} + {!isPluginEntry && + cached?.result.success && + cached.result.tools.length > 0 && + !isEditing && ( +
+ +
+ )}
); }) From 05cc3d48fbc43228972053ca64d5969ebcc3e7f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 7 Aug 2026 00:40:14 +0000 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20wrap=20long=20plugi?= =?UTF-8?q?n=20provenance=20in=20narrow=20MCP=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceMCPModal.stories.tsx | 55 +++++++++++++++++++ .../WorkspaceMCPModal/WorkspaceMCPModal.tsx | 17 ++++-- .../Settings/Sections/MCPSettingsSection.tsx | 10 +++- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx index e79b3fc646..595a21e5d4 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.stories.tsx @@ -396,3 +396,58 @@ export const ToggleServerEnabled: Story = { }); }, }; + +/** + * Agent Plugin provenance with worst-case long names must not overflow the + * dialog horizontally — especially at the phone viewport, where an unbroken + * token would push the Fetch Tools button past the dialog edge. The play + * contract asserts no horizontal overflow at any viewport (the long fixture + * name overflows even the desktop max-w-2xl dialog without the min-w-0 fix); + * the Pixel phone variant guards the narrow rendering visually. + */ +export const WorkspaceMCPPluginServersNarrow: Story = { + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + ...meta.parameters, + pixel: { + matrix: { themes: ["dark"], viewports: ["phone"] }, + }, + }, + render: () => + renderWorkspaceMCPModal({ + servers: { + "plugin:abc123:everything": { + transport: "stdio", + command: "bunx", + disabled: true, + plugin: { + // No hyphens: hyphenated names wrap naturally, but the name grammar + // also allows unbroken alphanumeric/dot tokens, which only wrap when + // the layout allows shrinking (min-w-0) and breaking (break-words). + pluginName: "extremelylongpluginnamethatkeepsgoingandgoingforawhile0123456789", + serverName: "verylongmcpservernamewithmanytokensandthensome", + sourceScope: "project", + sourceLocation: + ".agents/plugins/extremelylongpluginnamethatkeepsgoingandgoingforawhile0123456789", + }, + }, + mux: { transport: "stdio", command: "npx -y @anthropics/mux-server", disabled: false }, + }, + testResults: { mux: MOCK_TOOLS }, + preCacheTools: true, + }), + play: async ({ canvasElement }) => { + const dialog = await findWorkspaceMCPDialog(canvasElement); + const modal = within(dialog); + + await expect( + modal.findByText(/\.agents\/plugins\/extremelylongpluginname/) + ).resolves.toBeInTheDocument(); + + // No horizontal overflow: long unbroken provenance must wrap/break inside + // the dialog instead of widening its scrollable content. + await expect(dialog.scrollWidth).toBeLessThanOrEqual(dialog.clientWidth); + }, +}; diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index ef4290cbb8..2f9cc04b00 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -335,8 +335,11 @@ export const WorkspaceMCPModal: React.FC = ({ !effectivelyEnabled && "opacity-50" )} > -
-
+
+ {/* min-w-0 chain: repo-controlled plugin/server names can be + long unbroken tokens; without it they push the Fetch + Tools button past the dialog edge at narrow widths. */} +
@@ -344,10 +347,14 @@ export const WorkspaceMCPModal: React.FC = ({ } aria-label={`Toggle ${displayName} MCP server`} /> -
-
{displayName}
+
+ {/* wrap-anywhere (not truncate/break-words): repo-controlled + names must stay fully readable, and only overflow-wrap: + anywhere shrinks intrinsic min-content so the dialog's + grid track cannot be inflated by an unbroken token. */} +
{displayName}
{info.plugin ? ( -
+
Agent Plugin ({info.plugin.sourceScope} · {info.plugin.sourceLocation} ) — disabled by default
diff --git a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx index ca04418997..6ae4039e47 100644 --- a/src/browser/features/Settings/Sections/MCPSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/MCPSettingsSection.tsx @@ -1197,12 +1197,16 @@ export const MCPSettingsSection: React.FC = () => {
-
- + {/* flex-wrap + wrap-anywhere: repo-controlled plugin names and + install locations can be long unbroken tokens; wrap-anywhere + shrinks their min-content so they cannot starve the actions + column at ~375px. */} +
+ {displayName} {entry.plugin && ( - + {/* Include the install location: same-name plugins can exist in sibling containers (.mux vs .agents). */} plugin · {entry.plugin.sourceLocation} From f07e2d8239ff4d40e3bb64c6b966b3a3001bbb81 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 7 Aug 2026 00:51:32 +0000 Subject: [PATCH 19/19] =?UTF-8?q?=F0=9F=A4=96=20fix:=20self-heal=20corrupt?= =?UTF-8?q?=20plugin=20data=20paths=20by=20quarantining=20stray=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/mcpServerManager.test.ts | 54 +++++++++++++++++++++ src/node/services/mcpServerManager.ts | 56 +++++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index fb39ea8ca7..5f69af06ba 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -1355,6 +1355,60 @@ describe("prepareStdioLaunch", () => { expect(launch.cwd).toBe(nestedCwd); }); + test("quarantines a stray file occupying the PLUGIN_DATA path and still launches", async () => { + using tmp = new DisposableTempDir("mcp-plugin-data-corrupt"); + // Corrupt state: plugin-data (the PARENT of every instance dir) is a file. + const dataRoot = path.join(tmp.path, "plugin-data"); + await fs.writeFile(dataRoot, "not a directory", "utf8"); + const dataPath = path.join(dataRoot, "abc123"); + + const launch = await prepareStdioLaunch({ + transport: "stdio", + command: "bunx", + args: [], + env: { PLUGIN_ROOT: tmp.path, PLUGIN_DATA: dataPath }, + disabled: false, + plugin: { + pluginName: "demo", + serverName: "srv", + sourceScope: "global", + sourceLocation: ".mux/plugins/demo", + }, + }); + + expect((await fs.stat(dataPath)).isDirectory()).toBe(true); + expect(launch.env?.PLUGIN_DATA).toBe(dataPath); + // The stray file is quarantined (renamed), not deleted. + const quarantined = (await fs.readdir(tmp.path)).find((name) => + name.startsWith("plugin-data.corrupt-") + ); + expect(quarantined).toBeDefined(); + expect(await fs.readFile(path.join(tmp.path, quarantined!), "utf8")).toBe("not a directory"); + }); + + test("quarantines a file occupying the instance data dir itself", async () => { + using tmp = new DisposableTempDir("mcp-plugin-data-corrupt-leaf"); + const dataPath = path.join(tmp.path, "plugin-data", "abc123"); + await fs.mkdir(path.dirname(dataPath), { recursive: true }); + await fs.writeFile(dataPath, "stale blob", "utf8"); + + await prepareStdioLaunch({ + transport: "stdio", + command: "bunx", + args: [], + env: { PLUGIN_ROOT: tmp.path, PLUGIN_DATA: dataPath }, + disabled: false, + plugin: { + pluginName: "demo", + serverName: "srv", + sourceScope: "global", + sourceLocation: ".mux/plugins/demo", + }, + }); + + expect((await fs.stat(dataPath)).isDirectory()).toBe(true); + }); + test("rejects plugin servers without an absolute PLUGIN_DATA env (defensive)", async () => { // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void await expect( diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 7c81c69acb..21dbeac22a 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -426,6 +426,58 @@ interface StdioLaunch { env?: Record; } +/** + * mkdir -p that self-heals corrupted plugin data state: when the target or one + * of its ancestors exists as a non-directory (a stray file where + * `~/.mux/plugin-data` or an instance dir should be), the offending entry is + * quarantined (renamed aside) and the mkdir retried, instead of ENOTDIR/EEXIST + * permanently bricking every test/launch until the user repairs disk state by + * hand. Renaming preserves whatever data the file held. + */ +async function mkdirSelfHealing(target: string): Promise { + try { + await fsPromises.mkdir(target, { recursive: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" && code !== "ENOTDIR") { + throw error; + } + } + + // Walk root→leaf to find the shallowest existing non-directory prefix. + const prefixes: string[] = []; + for (let current = target; ; current = path.dirname(current)) { + prefixes.unshift(current); + if (path.dirname(current) === current) { + break; + } + } + for (const prefix of prefixes) { + let isDirectory: boolean; + try { + // stat (not lstat): a symlink to a directory is a valid path segment. + isDirectory = (await fsPromises.stat(prefix)).isDirectory(); + } catch { + // Nothing (or a broken symlink) at this prefix. lstat distinguishes: + // a broken symlink still occupies the name and must be quarantined. + const lstat = await fsPromises.lstat(prefix).catch(() => null); + if (lstat === null) { + break; + } + isDirectory = false; + } + if (!isDirectory) { + const quarantine = `${prefix}.corrupt-${Date.now()}`; + log.warn(`[MCP] Quarantining non-directory plugin data path '${prefix}' to '${quarantine}'`); + await fsPromises.rename(prefix, quarantine); + break; + } + } + + await fsPromises.mkdir(target, { recursive: true }); +} + /** * Compose the shell command string and exec options for a stdio server. * @@ -448,7 +500,7 @@ export async function prepareStdioLaunch(info: MCPStdioServerInfo): Promise