diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 0c9c4c6a0c..b586a874e4 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -64,6 +64,8 @@ Enable the **Agent Plugins** experiment (Settings → Experiments) to also disco 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). +Global plugins can be installed from git via **Settings → Plugins** (paste a git URL or `owner/repo[@ref]`); the install preview lists every skill the plugin would contribute before anything is written. + ## 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 d8657b16c6..e91e6bcd93 100644 --- a/docs/config/mcp-servers.mdx +++ b/docs/config/mcp-servers.mdx @@ -61,6 +61,8 @@ With the **Agent Plugins** experiment enabled (Settings → Experiments), MCP se Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`. +**Settings → Plugins** installs plugins from git into `~/.mux/plugins` (paste a git URL or `owner/repo[@ref]`). Before anything is written, a consent preview lists the plugin's manifest, every skill, and every MCP server command line. Installs are pinned to the resolved commit; update checks compare the tracked branch or tag against the pinned commit and never auto-apply. Applying an update replaces the plugin directory wholesale — local edits to a managed plugin directory are discarded — and restarts that plugin's running MCP servers. Uninstalling removes the directory, the registry entry, and the plugin's per-workspace server overrides, but keeps `~/.mux/plugin-data/` unless you opt in to deleting it. + ## Behavior - **Hot reload** — Config changes apply on your next message (no restart needed) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 8b1903912b..bed5d4ac9e 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -216,6 +216,7 @@ function AppInner() { const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false); const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); + const agentPluginsEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_PLUGINS); // Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally; // collapse remains a separate toggle and the drag handle is hidden in mobile-touch overlay mode. @@ -992,6 +993,7 @@ function AppInner() { onStartWorkspaceCreation: openNewWorkspaceFromPalette, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, multiProjectWorkspacesEnabled, + agentPluginsEnabled, onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette, getBranchesForProject, onSelectWorkspace: selectWorkspaceFromPalette, diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index 2f9cc04b00..8bb8da6e79 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -34,6 +34,10 @@ export const WorkspaceMCPModal: React.FC = ({ // State for project servers and workspace overrides const [servers, setServers] = useState>({}); const [overrides, setOverrides] = useState({}); + // Revision of the loaded overrides snapshot. Saves pass it back so the + // backend can reject stale snapshots (e.g. after a plugin uninstall pruned + // this workspace's plugin: keys while the dialog was open). + const [overridesRevision, setOverridesRevision] = useState(null); const [loadingTools, setLoadingTools] = useState>({}); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -66,7 +70,8 @@ export const WorkspaceMCPModal: React.FC = ({ api.workspace.mcp.get({ workspaceId }), ]); setServers(projectServers ?? {}); - setOverrides(workspaceOverrides ?? {}); + setOverrides(workspaceOverrides.overrides ?? {}); + setOverridesRevision(workspaceOverrides.revision); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load MCP configuration"); } finally { @@ -235,11 +240,15 @@ export const WorkspaceMCPModal: React.FC = ({ // Save overrides const handleSave = useCallback(async () => { - if (!api) return; + if (!api || overridesRevision === null) return; setSaving(true); setError(null); try { - const result = await api.workspace.mcp.set({ workspaceId, overrides }); + const result = await api.workspace.mcp.set({ + workspaceId, + overrides, + expectedRevision: overridesRevision, + }); if (!result.success) { setError(result.error); } else { @@ -250,7 +259,7 @@ export const WorkspaceMCPModal: React.FC = ({ } finally { setSaving(false); } - }, [api, workspaceId, overrides, onOpenChange]); + }, [api, workspaceId, overrides, overridesRevision, onOpenChange]); const serverEntries = Object.entries(servers); diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx new file mode 100644 index 0000000000..731ad09719 --- /dev/null +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -0,0 +1,306 @@ +import { useRef } from "react"; +import type { FC, ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { userEvent, within } from "@storybook/test"; + +import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { ExperimentsProvider } from "@/browser/contexts/ExperimentsContext"; +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import { createMockORPCClient, type MockORPCClientOptions } from "@/browser/stories/mocks/orpc"; +import type { AgentPluginListItem } from "@/common/orpc/schemas/agentPlugins"; + +import { PluginsSettingsSection } from "./PluginsSettingsSection"; + +const MANAGED_ITEM: AgentPluginListItem = { + name: "grill", + managed: true, + present: true, + location: "~/.mux/plugins/grill", + version: "1.2.0", + description: "Relentlessly grills your plans before you commit to them.", + source: { + type: "git", + url: "https://github.com/example/grill.git", + ref: "main", + refType: "branch", + }, + lockedSha: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + installedAt: "2026-08-01T12:00:00.000Z", + skillCount: 3, + mcpServerCount: 1, +}; + +const PINNED_ITEM: AgentPluginListItem = { + name: "deploy-tools", + managed: true, + present: true, + location: "~/.mux/plugins/deploy-tools", + version: "2.0.0", + source: { + type: "git", + url: "git@git.corp:infra/deploy-tools.git", + ref: "v2.0.0", + refType: "tag", + }, + lockedSha: "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1", + installedAt: "2026-07-15T09:30:00.000Z", + skillCount: 0, + mcpServerCount: 2, +}; + +const UNMANAGED_ITEM: AgentPluginListItem = { + name: "handmade", + managed: false, + present: true, + location: "~/.agents/plugins/handmade", + description: "Copied into the container by hand; Mux lists it read-only.", + skillCount: 1, + mcpServerCount: 0, +}; + +const MISSING_ITEM: AgentPluginListItem = { + name: "vanished", + managed: true, + present: false, + location: "~/.mux/plugins/vanished", + version: "0.4.0", + source: { + type: "git", + url: "https://github.com/example/vanished.git", + ref: "main", + refType: "branch", + }, + lockedSha: "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2", + installedAt: "2026-06-01T00:00:00.000Z", + skillCount: 0, + mcpServerCount: 0, +}; + +/** Valid max-length (64-char, separator-free) name: the worst case for narrow-width wrapping. */ +const MAX_LENGTH_NAME = "a".repeat(64); +const MAX_LENGTH_ITEM: AgentPluginListItem = { + name: MAX_LENGTH_NAME, + managed: true, + present: true, + location: `~/.mux/plugins/${MAX_LENGTH_NAME}`, + version: "1.0.0", + source: { + type: "git", + url: `https://github.com/example/${MAX_LENGTH_NAME}.git`, + ref: "main", + refType: "branch", + }, + lockedSha: "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", + installedAt: "2026-08-01T12:00:00.000Z", + skillCount: 1, + mcpServerCount: 0, +}; + +const PluginsSectionStoryShell: FC<{ options: MockORPCClientOptions; children: ReactNode }> = ({ + options, + children, +}) => { + const clientRef = useRef(null); + clientRef.current ??= createMockORPCClient(options); + + return ( + + + + {children} + + + + ); +}; + +const meta: Meta = { + title: "Features/Settings/Sections/PluginsSettingsSection", + component: PluginsSettingsSection, + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Installed plugins"); + await canvas.findByText("No plugins installed yet."); + }, +}; + +export const InstalledWithUpdateStates: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText("grill"); + await canvas.findByText("update available"); + await canvas.findByText("tag moved"); + await canvas.findByText("unmanaged"); + await canvas.findByText("missing"); + // Update action appears only for rows whose tracking ref moved. + await canvas.findAllByRole("button", { name: /Update/ }); + }, +}; + +/** + * Pinned phone viewport for the row layout: long repo paths, badge clusters, + * and the action group must not overflow the right edge or starve each other + * at narrow widths (AGENTS.md Storybook responsive rule). + */ +export const InstalledPhoneViewport: Story = { + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + layout: "fullscreen", + pixel: { + matrix: { themes: ["dark"], viewports: ["phone"] }, + }, + }, + render: () => ( + + {/* Fixed phone width so the play's overflow assertion holds in the CI + test-runner too, which ignores viewport globals (AGENTS.md). */} +
+ +
+
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("grill"); + await canvas.findByText("update available"); + await canvas.findByRole("button", { name: /Update/ }); + // Max-length separator-free names must wrap instead of overflowing the + // card's right edge at phone width. + const maxRow = await canvas.findByText(MAX_LENGTH_NAME); + const card = maxRow.closest("div[class*='rounded-md']"); + if (card instanceof HTMLElement && card.scrollWidth > card.clientWidth + 1) { + throw new Error("Max-length plugin row overflows its card at phone width"); + } + }, +}; + +export const UninstallConfirmation: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const uninstallButton = await canvas.findByRole("button", { name: /Uninstall grill/ }); + await userEvent.click(uninstallButton); + + // Preserve-by-default: the plugin-data checkbox starts unchecked. + await canvas.findByText(/Also delete stored plugin data/); + const checkbox = await canvas.findByRole("checkbox"); + if (checkbox.getAttribute("data-state") !== "unchecked") { + throw new Error("Plugin-data checkbox must start unchecked (preserve by default)"); + } + }, +}; + +export const AddPluginConsentPreview: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(await canvas.findByRole("button", { name: /Add plugin/ })); + await userEvent.type(await canvas.findByLabelText(/Git URL or owner\/repo/), "example/grill"); + await userEvent.click(await canvas.findByRole("button", { name: /Preview/ })); + + // Consent card: manifest + every skill + every MCP command line before install. + await canvas.findByText("Skills (2)"); + await canvas.findByText("grill-lite"); + await canvas.findByText("MCP servers (1)"); + await canvas.findByText(/server\.js --db/); + await canvas.findByText(/Unknown top-level field 'hooks' ignored/); + await canvas.findByRole("button", { name: /Install/ }); + }, +}; diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx new file mode 100644 index 0000000000..c96fa0b22f --- /dev/null +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -0,0 +1,667 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + ArrowDownToLine, + ArrowLeft, + CircleAlert, + Loader2, + Plus, + RefreshCw, + Trash2, + TriangleAlert, + XCircle, +} from "lucide-react"; +import { useAPI } from "@/browser/contexts/API"; +import { Button } from "@/browser/components/Button/Button"; +import { Checkbox } from "@/browser/components/Checkbox/Checkbox"; +import { cn } from "@/common/lib/utils"; +import type { + AgentPluginInstallPreview, + AgentPluginListItem, + AgentPluginUpdateCheck, +} from "@/common/orpc/schemas/agentPlugins"; +import { getErrorMessage } from "@/common/utils/errors"; +import { + consumePendingPluginsSectionIntent, + subscribePluginsSectionIntents, + type PluginsSectionIntent, +} from "./pluginsSectionIntents"; + +/** + * Settings → Plugins (agent-plugins experiment; global scope only). + * + * Managed installs come from the `~/.mux/plugins.json` registry; + * unmanaged plugin directories found by discovery are listed read-only. + * Update checks run on section open and on the explicit button only — no + * background timers, and updates never auto-apply. + */ + +/** Compact source display, e.g. "github.com/foo/grill @ main". */ +function formatSource(item: AgentPluginListItem): string | null { + if (!item.source) { + return null; + } + const url = item.source.url + .replace(/^https:\/\//, "") + .replace(/^git@([^:]+):/, "$1/") + .replace(/\.git$/, ""); + const ref = item.source.refType === "commit" ? item.source.ref.slice(0, 12) : item.source.ref; + return `${url} @ ${ref}`; +} + +const Badge: React.FC<{ + tone: "muted" | "accent" | "warning" | "error"; + children: React.ReactNode; +}> = (props) => ( + + {props.children} + +); + +/** Two-phase add flow: source input → consent preview → install. */ +const AddPluginPanel: React.FC<{ + onInstalled: () => void; + onClose: () => void; +}> = (props) => { + const { api } = useAPI(); + const [input, setInput] = useState(""); + const [ref, setRef] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + + const handlePreview = async () => { + if (!api || input.trim().length === 0 || busy) return; + setBusy(true); + setError(null); + try { + const result = await api.agentPlugins.preview({ + input: input.trim(), + ref: ref.trim().length > 0 ? ref.trim() : null, + }); + if (result.success) { + setPreview(result.data); + } else { + setError(result.error); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusy(false); + } + }; + + const handleInstall = async () => { + if (!api || !preview || busy) return; + setBusy(true); + setError(null); + try { + const result = await api.agentPlugins.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + if (result.success) { + props.onInstalled(); + } else { + setError(result.error); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+ {preview === null ? ( + <> +
+ + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handlePreview(); + }} + spellCheck={false} + className="bg-modal-bg border-border-medium focus:border-accent w-full rounded border px-2 py-1.5 font-mono text-sm focus:outline-none" + /> +
+
+ + setRef(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handlePreview(); + }} + spellCheck={false} + className="bg-modal-bg border-border-medium focus:border-accent w-full rounded border px-2 py-1.5 font-mono text-sm focus:outline-none" + /> +
+ {error && ( +
+ + {error} +
+ )} +
+ + +
+ + ) : ( + <> + {/* Consent preview: everything the plugin will contribute, before anything is written. */} +
+
+ {preview.manifest.name} + {preview.manifest.version && ( + v{preview.manifest.version} + )} + + {preview.source.refType} · {preview.lockedSha.slice(0, 12)} + +
+ {preview.manifest.description && ( +

{preview.manifest.description}

+ )} +

+ {preview.source.url} @ {preview.source.ref} →{" "} + {preview.targetPath} + {preview.manifest.authorName ? ` · by ${preview.manifest.authorName}` : ""} + {preview.manifest.license ? ` · ${preview.manifest.license}` : ""} +

+
+ + {preview.warnings.length > 0 && ( +
+ {preview.warnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+ )} + +
+

+ Skills ({preview.skills.length}) +

+ {preview.skills.length === 0 ? ( +

None

+ ) : ( +
    + {preview.skills.map((skill) => ( +
  • + {skill.name} + {skill.description && ( + — {skill.description} + )} +
  • + ))} +
+ )} +
+ +
+

+ MCP servers ({preview.mcpServers.length}) +

+ {preview.mcpServers.length === 0 ? ( +

None

+ ) : ( +
    + {preview.mcpServers.map((server) => ( +
  • + {server.serverName}{" "} + {server.transport} +
    +                      {server.summary}
    +                    
    +
  • + ))} +
+ )} +

+ MCP servers stay disabled until you enable them per workspace. +

+
+ + {error && ( +
+ + {error} +
+ )} + +
+ + +
+ + )} +
+ ); +}; + +/** Inline uninstall confirmation (conditional rendering keeps this testable without portals). */ +const UninstallConfirm: React.FC<{ + item: AgentPluginListItem; + busy: boolean; + onConfirm: (deletePluginData: boolean) => void; + onCancel: () => void; +}> = (props) => { + const [deletePluginData, setDeletePluginData] = useState(false); + + return ( +
+

+ Uninstall {props.item.name}? This removes the plugin + directory and its workspace MCP overrides. +

+ +
+ + +
+
+ ); +}; + +export const PluginsSettingsSection: React.FC = () => { + const { api } = useAPI(); + const [items, setItems] = useState(null); + // List/mutation errors and update-check errors live in separate state: the + // mount-time list query and update check run concurrently, and a later + // refresh success must not clear a check failure (an unreachable remote has + // to stay visibly unknown, never silently "up to date"). + const [error, setError] = useState(null); + const [updateCheckError, setUpdateCheckError] = useState(null); + const [updateChecks, setUpdateChecks] = useState>( + () => new Map() + ); + const [checkingUpdates, setCheckingUpdates] = useState(false); + // Palette intents (keyboard rule: install/uninstall/update need keyboard + // paths). The initializer covers palette → fresh mount; the subscription + // below covers commands invoked while this section is already on screen + // (same-route navigation preserves the mounted component, so no re-init + // happens). + const [initialIntent] = useState(() => consumePendingPluginsSectionIntent()); + const [addOpen, setAddOpen] = useState(initialIntent?.type === "open-add-panel"); + const [uninstallTarget, setUninstallTarget] = useState( + initialIntent?.type === "confirm-uninstall" ? initialIntent.name : null + ); + /** Name of the plugin with an update/uninstall in flight. */ + const [busyPlugin, setBusyPlugin] = useState(null); + /** Monotonic ids of the latest list/update-check requests; stale responses must not commit state. */ + const listGenerationRef = useRef(0); + const checkGenerationRef = useRef(0); + + const refresh = async () => { + if (!api) return; + // Overlapping list requests race the same way update checks do (mount + // fetch vs a refresh published after a palette mutation): an older + // response resolving last would resurrect removed rows or old versions. + const generation = ++listGenerationRef.current; + try { + const result = await api.agentPlugins.list(); + if (generation !== listGenerationRef.current) { + return; // A newer list request superseded this one. + } + if (result.success) { + setItems(result.data); + setError(null); + } else { + setItems([]); + setError(result.error); + } + } catch (err) { + if (generation === listGenerationRef.current) { + setItems([]); + setError(getErrorMessage(err)); + } + } + }; + + const checkForUpdates = async () => { + if (!api) return; + // Overlapping checks race (mount-time check vs a refresh published by a + // palette update): only the latest request may commit state, or a stale + // response can resurrect an update badge the update just cleared. + const generation = ++checkGenerationRef.current; + setCheckingUpdates(true); + try { + const result = await api.agentPlugins.checkUpdates(); + if (generation !== checkGenerationRef.current) { + return; // A newer check superseded this one. + } + if (result.success) { + setUpdateChecks(new Map(result.data.map((check) => [check.name, check]))); + setUpdateCheckError(null); + } else { + setUpdateCheckError(result.error); + } + } catch (err) { + if (generation === checkGenerationRef.current) { + setUpdateCheckError(getErrorMessage(err)); + } + } finally { + if (generation === checkGenerationRef.current) { + setCheckingUpdates(false); + } + } + }; + + // Approved update policy: passive check on section open + explicit button only. + useEffect(() => { + void refresh(); + void checkForUpdates(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- fetch on mount / API reconnect only; refresh/checkForUpdates are plain handlers (compiler-memoized), not inputs + }, [api]); + + // Live palette intents while mounted (see pluginsSectionIntents). + useEffect(() => { + return subscribePluginsSectionIntents((intent: PluginsSectionIntent) => { + switch (intent.type) { + case "open-add-panel": + setAddOpen(true); + break; + case "confirm-uninstall": + setUninstallTarget(intent.name); + break; + case "refresh": + void refresh(); + void checkForUpdates(); + break; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- resubscribe on API reconnect only; the listener reads the latest handlers via closure per subscription + }, [api]); + + const handleUpdate = async (name: string) => { + if (!api || busyPlugin !== null) return; + setBusyPlugin(name); + setError(null); + try { + const result = await api.agentPlugins.update({ name }); + // Refresh regardless of outcome (the swap may be partially visible), + // but re-assert the mutation error AFTER the refresh: refresh's + // success path clears the error state, which would silently swallow + // the failure the user needs to see. + await refresh(); + await checkForUpdates(); + if (!result.success) { + setError(result.error); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusyPlugin(null); + } + }; + + const handleUninstall = async (name: string, deletePluginData: boolean) => { + if (!api || busyPlugin !== null) return; + setBusyPlugin(name); + setError(null); + try { + const result = await api.agentPlugins.uninstall({ name, deletePluginData }); + if (result.success) { + setUninstallTarget(null); + await refresh(); + } else { + // Keep the confirmation open and surface the error after the list + // refresh (whose success path clears error state). + await refresh(); + setError(result.error); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusyPlugin(null); + } + }; + + return ( +
+
+

+ Install Agent Plugins from git repositories into{" "} + ~/.mux/plugins. Plugins contribute skills and + default-disabled MCP servers. Installs are global (shared by all projects); updates are + manual, and updating discards any local edits to the plugin directory. +

+
+ +
+
+

Installed plugins

+
+ + {!addOpen && ( + + )} +
+
+ + {addOpen && ( +
+ { + setAddOpen(false); + void refresh(); + void checkForUpdates(); + }} + onClose={() => setAddOpen(false)} + /> +
+ )} + + {error && ( +
+ + {error} +
+ )} + {updateCheckError && ( +
+ + Update check failed: {updateCheckError} +
+ )} + +
+ {items === null ? ( +
+ + Loading plugins… +
+ ) : items.length === 0 ? ( +

No plugins installed yet.

+ ) : ( + items.map((item) => { + const check = updateChecks.get(item.name); + const updateAvailable = + item.managed && + (check?.status === "update-available" || check?.status === "tag-moved"); + const isBusy = busyPlugin === item.name; + + return ( +
+
+
+
+ {/* break-all: names can be 64 separator-free chars. */} + + {item.name} + + {item.version && ( + v{item.version} + )} + {!item.managed && unmanaged} + {item.managed && !item.present && missing} + {check?.status === "update-available" && ( + update available + )} + {check?.status === "tag-moved" && tag moved} + {check?.status === "pinned" && pinned} + {check?.status === "error" && check failed} +
+ {item.description && ( +

{item.description}

+ )} + {/* break-all: locations/sources can contain unbreakable + 64-char tokens (max-length plugin names) that would + otherwise overflow the card at phone widths. */} +

+ {item.skillCount} skill{item.skillCount === 1 ? "" : "s"} ·{" "} + {item.mcpServerCount} MCP server{item.mcpServerCount === 1 ? "" : "s"} ·{" "} + {item.location} +

+ {formatSource(item) && ( +

+ {formatSource(item)} + {item.lockedSha ? ` · ${item.lockedSha.slice(0, 12)}` : ""} +

+ )} + {check?.status === "error" && check.message && ( +

+ + {check.message} +

+ )} +
+ + {item.managed && ( +
+ {updateAvailable && ( + + )} + +
+ )} +
+ + {uninstallTarget === item.name && ( + + void handleUninstall(item.name, deletePluginData) + } + onCancel={() => setUninstallTarget(null)} + /> + )} +
+ ); + }) + )} +
+
+
+ ); +}; diff --git a/src/browser/features/Settings/Sections/pluginsSectionIntents.ts b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts new file mode 100644 index 0000000000..e156c41ec7 --- /dev/null +++ b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts @@ -0,0 +1,52 @@ +/** + * Intents for the Settings → Plugins section, published by command-palette + * actions that run outside the section's React tree. + * + * Two delivery paths cover both palette contexts: + * - section not mounted yet: the intent is buffered and consumed by the + * section's mount effect after palette navigation; + * - section already mounted: same-route navigation preserves the component, + * so the mounted section's subscription receives the intent directly. + * + * Module-level (not persisted) on purpose: intents are meaningful only for + * the palette invocation that just happened. + */ + +export type PluginsSectionIntent = + /** Expand the Add Plugin form. */ + | { type: "open-add-panel" } + /** Open the uninstall confirmation for a managed plugin. */ + | { type: "confirm-uninstall"; name: string } + /** Backend plugin state changed outside the section (e.g. palette Update All); re-query. */ + | { type: "refresh" }; + +let pendingIntent: PluginsSectionIntent | null = null; +const listeners = new Set<(intent: PluginsSectionIntent) => void>(); + +export function publishPluginsSectionIntent(intent: PluginsSectionIntent): void { + if (listeners.size > 0) { + for (const listener of listeners) { + listener(intent); + } + return; + } + // No mounted section: buffer the latest intent for the upcoming mount. + pendingIntent = intent; +} + +/** Consume the buffered intent (mount path); returns null when none is pending. */ +export function consumePendingPluginsSectionIntent(): PluginsSectionIntent | null { + const intent = pendingIntent; + pendingIntent = null; + return intent; +} + +/** Subscribe a mounted section; returns an unsubscribe. */ +export function subscribePluginsSectionIntents( + listener: (intent: PluginsSectionIntent) => void +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/browser/features/Settings/SettingsPage.test.tsx b/src/browser/features/Settings/SettingsPage.test.tsx index 621313f524..ebf1461617 100644 --- a/src/browser/features/Settings/SettingsPage.test.tsx +++ b/src/browser/features/Settings/SettingsPage.test.tsx @@ -4,7 +4,7 @@ import { getSettingsSectionRedirect, getSettingsSections } from "./SettingsPage" describe("SettingsPage", () => { test("keeps Goals and Heartbeat out of settings navigation", () => { - const labels = getSettingsSections(true, true).map((section) => section.label); + const labels = getSettingsSections(true, true, true).map((section) => section.label); expect(labels).not.toContain("Goals"); expect(labels).not.toContain("Heartbeat"); @@ -12,23 +12,44 @@ describe("SettingsPage", () => { }); test("normalizes stale Goals and Heartbeat routes to Experiments with replace navigation", () => { - expect(getSettingsSectionRedirect("goals", true, true)).toEqual({ + expect(getSettingsSectionRedirect("goals", true, true, true)).toEqual({ section: "experiments", replace: true, }); - expect(getSettingsSectionRedirect("heartbeat", true, true)).toEqual({ + expect(getSettingsSectionRedirect("heartbeat", true, true, true)).toEqual({ section: "experiments", replace: true, }); }); test("shows the Memory section only while the memory experiment is enabled", () => { - expect(getSettingsSections(false, true).map((section) => section.id)).toContain("memory"); - expect(getSettingsSections(false, false).map((section) => section.id)).not.toContain("memory"); + expect(getSettingsSections(false, true, false).map((section) => section.id)).toContain( + "memory" + ); + expect(getSettingsSections(false, false, false).map((section) => section.id)).not.toContain( + "memory" + ); }); test("redirects the memory route away while the memory experiment is disabled", () => { - expect(getSettingsSectionRedirect("memory", false, false)).toEqual({ section: "general" }); - expect(getSettingsSectionRedirect("memory", false, true)).toBeNull(); + expect(getSettingsSectionRedirect("memory", false, false, false)).toEqual({ + section: "general", + }); + expect(getSettingsSectionRedirect("memory", false, true, false)).toBeNull(); + }); + + test("shows the Plugins section next to MCP only while agent-plugins is enabled", () => { + const ids = getSettingsSections(false, false, true).map((section) => section.id); + expect(ids.indexOf("plugins")).toBe(ids.indexOf("mcp") + 1); + expect(getSettingsSections(false, false, false).map((section) => section.id)).not.toContain( + "plugins" + ); + }); + + test("redirects the plugins route away while agent-plugins is disabled", () => { + expect(getSettingsSectionRedirect("plugins", false, false, false)).toEqual({ + section: "general", + }); + expect(getSettingsSectionRedirect("plugins", false, false, true)).toBeNull(); }); }); diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index e51f292602..ab8f8f6d8b 100644 --- a/src/browser/features/Settings/SettingsPage.tsx +++ b/src/browser/features/Settings/SettingsPage.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { ArrowLeft, + Blocks, Brain, Menu, Settings, @@ -30,6 +31,7 @@ import { GovernorSection } from "./Sections/GovernorSection"; import { MemorySection } from "./Sections/MemorySection"; import { Button } from "@/browser/components/Button/Button"; import { MCPSettingsSection } from "./Sections/MCPSettingsSection"; +import { PluginsSettingsSection } from "./Sections/PluginsSettingsSection"; import { SecretsSection } from "./Sections/SecretsSection"; import { LayoutsSection } from "./Sections/LayoutsSection"; import { RuntimesSection } from "./Sections/RuntimesSection"; @@ -123,9 +125,20 @@ interface SettingsSectionRedirect { export function getSettingsSections( governorEnabled: boolean, - memoryEnabled: boolean + memoryEnabled: boolean, + agentPluginsEnabled: boolean ): SettingsSection[] { const sections = [...BASE_SECTIONS]; + if (agentPluginsEnabled) { + // Next to MCP: plugins contribute skills + MCP servers. + const mcpIndex = sections.findIndex((section) => section.id === "mcp"); + sections.splice(mcpIndex + 1, 0, { + id: "plugins", + label: "Plugins", + icon: , + component: PluginsSettingsSection, + }); + } if (memoryEnabled) { sections.push({ id: "memory", @@ -148,7 +161,8 @@ export function getSettingsSections( export function getSettingsSectionRedirect( activeSection: string, governorEnabled: boolean, - memoryEnabled: boolean + memoryEnabled: boolean, + agentPluginsEnabled: boolean ): SettingsSectionRedirect | null { if (LEGACY_EXPERIMENT_SETTINGS_SECTION_IDS.has(activeSection)) { return { section: "experiments", replace: true }; @@ -162,6 +176,10 @@ export function getSettingsSectionRedirect( return { section: BASE_SECTIONS[0]?.id ?? "general" }; } + if (!agentPluginsEnabled && activeSection === "plugins") { + return { section: BASE_SECTIONS[0]?.id ?? "general" }; + } + return null; } @@ -175,10 +193,16 @@ export function SettingsPage(props: SettingsPageProps) { const onboardingPause = useOnboardingPause(); const governorEnabled = useExperimentValue(EXPERIMENT_IDS.MUX_GOVERNOR); const memoryEnabled = useExperimentValue(EXPERIMENT_IDS.MEMORY); + const agentPluginsEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_PLUGINS); // Keep routing on a valid section when experiment-owned settings move or disappear. useEffect(() => { - const redirect = getSettingsSectionRedirect(activeSection, governorEnabled, memoryEnabled); + const redirect = getSettingsSectionRedirect( + activeSection, + governorEnabled, + memoryEnabled, + agentPluginsEnabled + ); if (!redirect) { return; } @@ -189,7 +213,7 @@ export function SettingsPage(props: SettingsPageProps) { } setActiveSection(redirect.section); - }, [activeSection, setActiveSection, governorEnabled, memoryEnabled]); + }, [activeSection, setActiveSection, governorEnabled, memoryEnabled, agentPluginsEnabled]); // Close settings on Escape. Uses bubble phase so inner surfaces (Select dropdowns, // Popover, Dialog) that call stopPropagation/preventDefault on Escape get first @@ -208,7 +232,7 @@ export function SettingsPage(props: SettingsPageProps) { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [close]); - const sections = getSettingsSections(governorEnabled, memoryEnabled); + const sections = getSettingsSections(governorEnabled, memoryEnabled, agentPluginsEnabled); const currentSection = sections.find((section) => section.id === activeSection) ?? sections[0]; const SectionComponent = currentSection.component; diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 922c35b474..4107421f9a 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -39,6 +39,11 @@ import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; import type { NameGenerationError } from "@/common/types/errors"; import type { Secret } from "@/common/types/secrets"; import type { MCPHttpServerInfo, MCPServerInfo } from "@/common/types/mcp"; +import type { + AgentPluginInstallPreview, + AgentPluginListItem, + AgentPluginUpdateCheck, +} from "@/common/orpc/schemas/agentPlugins"; import type { MCPOAuthAuthStatus } from "@/common/types/mcpOauth"; import type { ChatStats } from "@/common/types/chatStats"; import { @@ -119,6 +124,13 @@ type ProjectRemoveError = z.infer; export interface MockORPCClientOptions { /** Layout presets config for Settings → Layouts stories */ layoutPresets?: LayoutPresetsConfig; + /** Agent Plugin installer mock data (Settings → Plugins). */ + agentPlugins?: { + items?: AgentPluginListItem[]; + updateChecks?: AgentPluginUpdateCheck[]; + /** Returned by agentPlugins.preview; omit to make preview fail. */ + preview?: AgentPluginInstallPreview; + }; projects?: Map; workspaces?: FrontendWorkspaceMetadata[]; /** Pre-seeded multi-project git status rows keyed by workspace ID. */ @@ -370,6 +382,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl projectSecrets = new Map(), terminalSessions: initialTerminalSessions = [], globalMcpServers = {}, + agentPlugins: agentPluginsMock, mcpServers = new Map(), mcpOverrides = new Map(), mcpTestResults = new Map(), @@ -1083,6 +1096,29 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl return Promise.resolve({ success: true, data: undefined }); }, }, + agentPlugins: { + list: () => Promise.resolve({ success: true, data: agentPluginsMock?.items ?? [] }), + checkUpdates: () => + Promise.resolve({ success: true, data: agentPluginsMock?.updateChecks ?? [] }), + preview: () => + agentPluginsMock?.preview + ? Promise.resolve({ success: true, data: agentPluginsMock.preview }) + : Promise.resolve({ success: false, error: "No preview configured in this story" }), + install: (input: { source: AgentPluginInstallPreview["source"]; expectedSha: string }) => + Promise.resolve({ + success: true, + data: { + name: agentPluginsMock?.preview?.manifest.name ?? "plugin", + scope: "global" as const, + source: input.source, + lockedSha: input.expectedSha, + installedAt: new Date().toISOString(), + }, + }), + uninstall: () => Promise.resolve({ success: true, data: undefined }), + update: (input: { name: string }) => + Promise.resolve({ success: false, error: `No update mock for '${input.name}'` }), + }, mcp: { list: (input?: { projectPath?: string }) => { const projectPath = typeof input?.projectPath === "string" ? input.projectPath.trim() : ""; @@ -1718,8 +1754,15 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, mcp: { get: (input: { workspaceId: string }) => - Promise.resolve(mcpOverrides.get(input.workspaceId) ?? {}), - set: (input: { workspaceId: string; overrides: MockMcpOverrides }) => { + Promise.resolve({ + overrides: mcpOverrides.get(input.workspaceId) ?? {}, + revision: "mock-revision", + }), + set: (input: { + workspaceId: string; + overrides: MockMcpOverrides; + expectedRevision: string; + }) => { mcpOverrides.set(input.workspaceId, input.overrides); return Promise.resolve({ success: true, data: undefined }); }, diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 359815fd66..fafbb57703 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -92,6 +92,12 @@ export const CommandIds = { settingsOpen: () => "settings:open" as const, settingsOpenSection: (section: string) => `settings:open:${section}` as const, + // Agent Plugin commands (agent-plugins experiment) + pluginsInstall: () => "plugins:install" as const, + pluginsUninstall: () => "plugins:uninstall" as const, + pluginsCheckUpdates: () => "plugins:check-updates" as const, + pluginsUpdateAll: () => "plugins:update-all" as const, + // Help commands helpKeybinds: () => "help:keybinds" as const, } as const; diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 0ff2b9e5fb..cc6d3f3427 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -54,6 +54,7 @@ const mk = (over: Partial[0]> = {}) => { onStartScratchCreation: () => undefined, onStartMultiProjectWorkspaceCreation: () => undefined, multiProjectWorkspacesEnabled: true, + agentPluginsEnabled: false, onArchiveMergedWorkspacesInProject: () => Promise.resolve(), onSelectWorkspace: () => undefined, onRemoveWorkspace: () => Promise.resolve({ success: true }), diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index bf743aee8d..df72c3dfa1 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -25,6 +25,7 @@ import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; +import { publishPluginsSectionIntent } from "@/browser/features/Settings/Sections/pluginsSectionIntents"; import { isTabType, type TabType } from "@/browser/types/rightSidebar"; import { getOrderedBaseTabIds, @@ -112,6 +113,8 @@ export interface BuildSourcesParams { onStartWorkspaceCreation: (projectPath: string) => void; onStartMultiProjectWorkspaceCreation: () => void; multiProjectWorkspacesEnabled: boolean; + /** agent-plugins experiment: gates the Settings → Plugins palette entry. */ + agentPluginsEnabled: boolean; onArchiveMergedWorkspacesInProject: (projectPath: string) => Promise; getBranchesForProject: (projectPath: string) => Promise; onSelectWorkspace: (sel: { @@ -1568,6 +1571,200 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi keywords: ["model", "custom", "add"], run: () => openSettings("models"), }, + ...(p.agentPluginsEnabled + ? ([ + { + id: CommandIds.settingsOpenSection("plugins"), + title: "Settings: Plugins", + subtitle: "Install and manage Agent Plugins", + section: section.settings, + keywords: ["plugin", "install", "agent", "skill", "mcp", "update"], + run: () => openSettings("plugins"), + }, + { + id: CommandIds.pluginsInstall(), + title: "Install Agent Plugin…", + subtitle: "Paste a git URL or owner/repo", + section: section.settings, + keywords: ["plugin", "install", "add", "git", "clone"], + run: () => { + // Open the section with the add-plugin form already expanded. + publishPluginsSectionIntent({ type: "open-add-panel" }); + openSettings("plugins"); + }, + }, + { + id: CommandIds.pluginsUninstall(), + title: "Uninstall Agent Plugin…", + section: section.settings, + keywords: ["plugin", "uninstall", "remove", "delete"], + run: () => undefined, + prompt: { + title: "Uninstall Agent Plugin", + fields: [ + { + type: "select", + name: "pluginName", + label: "Managed plugin", + placeholder: "Search installed plugins…", + getOptions: async () => { + const result = await p.api?.agentPlugins.list(); + if (!result?.success) { + return []; + } + return result.data + .filter((item) => item.managed) + .map((item) => ({ + id: item.name, + label: item.version ? `${item.name} (v${item.version})` : item.name, + keywords: [item.name, item.location], + })); + }, + }, + ], + onSubmit: (values) => { + // Route through the section's confirmation flow (plugin-data + // checkbox, explicit destructive button) — the palette never + // uninstalls directly. + publishPluginsSectionIntent({ + type: "confirm-uninstall", + name: values.pluginName, + }); + openSettings("plugins"); + }, + }, + }, + { + id: CommandIds.pluginsCheckUpdates(), + title: "Check for Plugin Updates", + section: section.settings, + keywords: ["plugin", "update", "check", "outdated"], + run: async () => { + const result = await p.api?.agentPlugins.checkUpdates(); + if (!result) return; + if (!result.success) { + showCommandFeedbackToast({ type: "error", message: result.error }); + return; + } + const updatable = result.data.filter( + (check) => check.status === "update-available" || check.status === "tag-moved" + ); + // Per-plugin failures ride inside a successful result; an + // unreachable remote is an unknown state, not "up to date" — + // and it stays in the summary even when updates were found. + const failed = result.data.filter((check) => check.status === "error"); + const summary: string[] = []; + if (updatable.length > 0) { + summary.push( + `Updates available: ${updatable.map((check) => check.name).join(", ")}` + ); + } + if (failed.length > 0) { + summary.push( + `Update check failed for ${failed.map((check) => check.name).join(", ")}` + ); + } + // A mounted section keeps its own stale updateChecks map; + // tell it to re-query so badges match the toast. + publishPluginsSectionIntent({ type: "refresh" }); + if (summary.length === 0) { + showCommandFeedbackToast({ + type: "success", + message: "All plugins are up to date.", + }); + return; + } + showCommandFeedbackToast({ + type: failed.length > 0 ? "error" : "success", + message: summary.join(". "), + }); + openSettings("plugins"); + }, + }, + { + id: CommandIds.pluginsUpdateAll(), + title: "Update All Plugins", + subtitle: "Apply pending plugin updates", + section: section.settings, + keywords: ["plugin", "update", "upgrade", "all"], + run: async () => { + const api = p.api; + if (!api) return; + const checks = await api.agentPlugins.checkUpdates(); + if (!checks.success) { + showCommandFeedbackToast({ type: "error", message: checks.error }); + return; + } + // Moved tags are excluded from the bulk apply: tags are + // supposed to be immutable, so a moved tag warrants the + // section's per-plugin review — but it must never read as + // "up to date", so it stays in the summary below. + const updatable = checks.data.filter( + (check) => check.status === "update-available" + ); + const tagMoved = checks.data + .filter((check) => check.status === "tag-moved") + .map((check) => check.name); + // Unreachable remotes are an unknown state, never "up to date" — + // and they must stay visible even when other updates succeed. + const checkFailures = checks.data + .filter((check) => check.status === "error") + .map((check) => check.name); + + const updateFailures: string[] = []; + const updatedNames: string[] = []; + for (const check of updatable) { + const result = await api.agentPlugins.update({ name: check.name }); + if (result.success) { + updatedNames.push(check.name); + } else { + updateFailures.push(`${check.name}: ${result.error}`); + } + } + // A mounted section only re-queries from its own handlers, so + // tell it the state changed under it. This runs even when no + // branch update applied: the fresh check may have discovered + // moved tags or per-plugin errors the section should show. + publishPluginsSectionIntent({ type: "refresh" }); + + const summary: string[] = []; + if (updatedNames.length > 0) { + summary.push(`Updated ${updatedNames.join(", ")}`); + } + if (updateFailures.length > 0) { + summary.push(`Update failed — ${updateFailures.join("; ")}`); + } + if (tagMoved.length > 0) { + summary.push( + `Tag moved for ${tagMoved.join(", ")} — review in Settings → Plugins` + ); + } + if (checkFailures.length > 0) { + summary.push(`Update check failed for ${checkFailures.join(", ")}`); + } + if (summary.length === 0) { + showCommandFeedbackToast({ + type: "success", + message: "All plugins are up to date.", + }); + return; + } + showCommandFeedbackToast({ + // Anything unexpected taints the toast: a partial success or + // a moved tag must not read as a verified all-clear. + type: + updateFailures.length > 0 || checkFailures.length > 0 || tagMoved.length > 0 + ? "error" + : "success", + message: summary.join(". "), + }); + if (tagMoved.length > 0 || checkFailures.length > 0) { + openSettings("plugins"); + } + }, + }, + ] satisfies CommandAction[]) + : []), ]); } diff --git a/src/common/config/schemas/agentPluginInstalls.ts b/src/common/config/schemas/agentPluginInstalls.ts new file mode 100644 index 0000000000..5c754cc9ad --- /dev/null +++ b/src/common/config/schemas/agentPluginInstalls.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { + AGENT_PLUGIN_NAME_MAX_LENGTH, + AGENT_PLUGIN_NAME_PATTERN, +} from "@/common/utils/agentPluginName"; + +/** + * Managed Agent Plugin install registry — persisted as `~/.mux/plugins.json` + * with the shape `{ plugins: AgentPluginInstallEntry[] }`. + * + * A standalone file (not a `~/.mux/config.json` section) on purpose: older + * builds rebuild config.json from known fields on every save, so a downgrade + * would silently drop an embedded registry. A file older builds never touch + * survives upgrade↔downgrade round-trips. The install service additionally + * rewrites the file from its RAW entry list (entries validated per-element + * on read, matched by `name` on mutation), so entries and fields written by + * newer builds survive mutations on this build. + * + * Semantics (mirroring lazy.nvim / Claude Code): `source.ref` is the tracking + * channel and `lockedSha` is what is actually on disk and runs. Install + * resolves ref → SHA and records both; the runtime never follows a branch + * implicitly — updates apply only on explicit user action. + * + * The registry only annotates installs. Plugin discovery + * (src/node/services/agentPlugins/discovery.ts) remains the source of truth + * for what loads, so drift between registry and disk self-heals: directories + * without a registry entry show as "unmanaged", entries without a directory + * show as "missing". + */ + +export const AgentPluginGitSourceSchema = z.object({ + type: z.literal("git"), + /** Normalized clone URL (https or ssh) derived from the user's input. */ + url: z.string().min(1), + /** Tracking ref: branch name, tag name, or full 40-hex commit SHA. */ + ref: z.string().min(1), + /** + * How `ref` is treated by update checks: branches track their remote tip, + * tags are pinned but warn when the tag moves, commits are fully pinned. + */ + refType: z.enum(["branch", "tag", "commit"]), + /** + * Repo-relative directory of the plugin for monorepo installs. Parsed and + * persisted from day one so the descriptor grammar is stable, but v1 + * rejects subpath installs (sparse-checkout staging lands in v2). + */ + subpath: z.string().optional(), +}); + +/** + * Tagged union so future source kinds (`path`, `archive`, `catalog`) slot in + * without a registry migration. + */ +export const AgentPluginInstallSourceSchema = z.discriminatedUnion("type", [ + AgentPluginGitSourceSchema, +]); + +export const AgentPluginInstallEntrySchema = z.object({ + /** + * plugin.json `name`; also the directory name under `~/.mux/plugins`. + * Pattern-enforced because it is joined into filesystem paths that + * uninstall deletes recursively — `.`/`..`/separators must never validate. + */ + name: z.string().max(AGENT_PLUGIN_NAME_MAX_LENGTH).regex(AGENT_PLUGIN_NAME_PATTERN), + /** v1 installs are global-only; the installer never writes into project checkouts. */ + scope: z.literal("global"), + source: AgentPluginInstallSourceSchema, + /** Commit SHA of the tree installed on disk (what actually runs). */ + lockedSha: z.string().min(1), + /** ISO-8601 install timestamp. */ + installedAt: z.string().min(1), + /** ISO-8601 timestamp of the most recent applied update. */ + updatedAt: z.string().optional(), + /** Cached manifest metadata so the list UI works offline / when the dir is missing. */ + manifest: z + .object({ + version: z.string().optional(), + description: z.string().optional(), + }) + .optional(), + /** Reserved: per-plugin opt-in auto-update. Unused in v1 — updates are badge + manual. */ + autoUpdate: z.boolean().optional(), +}); + +export const AgentPluginInstallsSchema = z.array(AgentPluginInstallEntrySchema); + +export type AgentPluginGitSource = z.infer; +export type AgentPluginInstallSource = z.infer; +export type AgentPluginInstallEntry = z.infer; diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index a3b237fef8..e8442c51a1 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -17,6 +17,19 @@ export { UserPreferencesSchema } from "./userPreferences"; export type { UserPreferences } from "./userPreferences"; export { TaskSettingsSchema } from "./taskSettings"; export type { TaskSettings } from "./taskSettings"; +// Managed Agent Plugin installs live in ~/.mux/plugins.json (see +// ./agentPluginInstalls.ts for why they are NOT a config.json section). +export { + AgentPluginGitSourceSchema, + AgentPluginInstallEntrySchema, + AgentPluginInstallSourceSchema, + AgentPluginInstallsSchema, +} from "./agentPluginInstalls"; +export type { + AgentPluginGitSource, + AgentPluginInstallEntry, + AgentPluginInstallSource, +} from "./agentPluginInstalls"; export const AgentAiDefaultsEntrySchema = z.object({ modelString: z.string().optional(), diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 279160fcb6..ff94748b2e 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -311,6 +311,7 @@ export { desktop, general, menu, + agentPlugins, agentSkills, agents, workflows, diff --git a/src/common/orpc/schemas/agentPlugins.ts b/src/common/orpc/schemas/agentPlugins.ts new file mode 100644 index 0000000000..707b9f2451 --- /dev/null +++ b/src/common/orpc/schemas/agentPlugins.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +import { + AgentPluginGitSourceSchema, + AgentPluginInstallEntrySchema, +} from "@/common/config/schemas/agentPluginInstalls"; + +/** + * oRPC shapes for the managed Agent Plugin installer (agent-plugins + * experiment). Registry entry + source schemas are shared with the on-disk + * config schema (single source of truth). + */ + +export { AgentPluginGitSourceSchema, AgentPluginInstallEntrySchema }; + +export const AgentPluginPreviewSkillSchema = z.object({ + name: z.string(), + description: z.string().optional(), +}); + +export const AgentPluginPreviewMcpServerSchema = z.object({ + serverName: z.string(), + transport: z.enum(["stdio", "http", "sse"]), + /** Human-readable command line (stdio) or URL (remote) shown in the consent preview. */ + summary: z.string(), +}); + +/** Manifest metadata surfaced in the consent preview (UI-safe projection of plugin.json). */ +export const AgentPluginManifestSummarySchema = z.object({ + name: z.string(), + version: z.string().optional(), + description: z.string().optional(), + authorName: z.string().optional(), + homepage: z.string().optional(), + repository: z.string().optional(), + license: z.string().optional(), +}); + +/** + * Everything a user consents to before anything is written: the resolved + * source + SHA, the manifest, every skill, and every MCP server command line. + */ +export const AgentPluginInstallPreviewSchema = z.object({ + source: AgentPluginGitSourceSchema, + /** Commit SHA the preview was computed from; install verifies it gets the same tree. */ + lockedSha: z.string(), + manifest: AgentPluginManifestSummarySchema, + skills: z.array(AgentPluginPreviewSkillSchema), + mcpServers: z.array(AgentPluginPreviewMcpServerSchema), + /** Manifest warnings + component diagnostics from validating the staged clone. */ + warnings: z.array(z.string()), + /** Final install directory (~/.mux/plugins/). */ + targetPath: z.string(), +}); + +export const AgentPluginListItemSchema = z.object({ + name: z.string(), + /** True when a registry entry exists; unmanaged dirs found by discovery are read-only. */ + managed: z.boolean(), + /** False for managed entries whose directory vanished (registry self-heal display). */ + present: z.boolean(), + /** Display location, e.g. "~/.mux/plugins/demo". */ + location: z.string(), + version: z.string().optional(), + description: z.string().optional(), + source: AgentPluginGitSourceSchema.optional(), + lockedSha: z.string().optional(), + installedAt: z.string().optional(), + updatedAt: z.string().optional(), + skillCount: z.number().int().nonnegative(), + mcpServerCount: z.number().int().nonnegative(), +}); + +export const AgentPluginUpdateCheckSchema = z.object({ + name: z.string(), + status: z.enum(["up-to-date", "update-available", "tag-moved", "pinned", "error"]), + /** Remote tip SHA for update-available / tag-moved. */ + remoteSha: z.string().optional(), + /** Error detail when status is "error". */ + message: z.string().optional(), +}); + +export type AgentPluginPreviewSkill = z.infer; +export type AgentPluginPreviewMcpServer = z.infer; +export type AgentPluginManifestSummary = z.infer; +export type AgentPluginInstallPreview = z.infer; +export type AgentPluginListItem = z.infer; +export type AgentPluginUpdateCheck = z.infer; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index fd85f7957c..c3494f9046 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -117,6 +117,13 @@ import { MCPTestResultSchema, WorkspaceMCPOverridesSchema, } from "./mcp"; +import { + AgentPluginGitSourceSchema, + AgentPluginInstallEntrySchema, + AgentPluginInstallPreviewSchema, + AgentPluginListItemSchema, + AgentPluginUpdateCheckSchema, +} from "./agentPlugins"; import { PolicyGetResponseSchema } from "./policy"; import { AgentAiDefaultsSchema, @@ -916,6 +923,55 @@ export const mcp = { }, }; +/** + * Managed Agent Plugin installs (agent-plugins experiment; global scope only). + * + * Human-driven surfaces only (Settings + palette) — there is deliberately no + * agent-facing installer tool in v1. All endpoints return Result values; the + * backend service gates on the experiment flag. + */ +export const agentPlugins = { + /** Temp shallow clone + validation of the staged tree; writes nothing permanent. */ + preview: { + input: z.object({ + input: z.string(), + ref: z.string().nullish(), + subpath: z.string().nullish(), + }), + output: ResultSchema(AgentPluginInstallPreviewSchema, z.string()), + }, + /** Fetch the consented SHA, promote into ~/.mux/plugins, write the registry entry. */ + install: { + input: z.object({ + source: AgentPluginGitSourceSchema, + /** SHA from the preview the user consented to. */ + expectedSha: z.string(), + }), + output: ResultSchema(AgentPluginInstallEntrySchema, z.string()), + }, + list: { + input: z.void(), + output: ResultSchema(z.array(AgentPluginListItemSchema), z.string()), + }, + uninstall: { + input: z.object({ + name: z.string(), + /** Also delete ~/.mux/plugin-data/ (default off — preserve data). */ + deletePluginData: z.boolean(), + }), + output: ResultSchema(z.void(), z.string()), + }, + /** git ls-remote per managed entry vs lockedSha; no fetch, no timers. */ + checkUpdates: { + input: z.void(), + output: ResultSchema(z.array(AgentPluginUpdateCheckSchema), z.string()), + }, + update: { + input: z.object({ name: z.string() }), + output: ResultSchema(AgentPluginInstallEntrySchema, z.string()), + }, +}; + /** * Secrets store. * @@ -1778,12 +1834,23 @@ export const workspace = { mcp: { get: { input: z.object({ workspaceId: z.string() }), - output: WorkspaceMCPOverridesSchema, + output: z.object({ + overrides: WorkspaceMCPOverridesSchema, + /** Opaque token for optimistic-concurrency saves (set.expectedRevision). */ + revision: z.string(), + }), }, set: { input: z.object({ workspaceId: z.string(), overrides: WorkspaceMCPOverridesSchema, + /** + * Revision returned by get. The save is rejected if the stored + * overrides changed since then, so a stale dialog snapshot cannot + * silently restore entries removed by a concurrent writer (e.g. an + * Agent Plugin uninstall pruning its `plugin:` keys). + */ + expectedRevision: z.string(), }), output: ResultSchema(z.void(), z.string()), }, diff --git a/src/common/utils/agentPluginName.ts b/src/common/utils/agentPluginName.ts new file mode 100644 index 0000000000..31271a45b2 --- /dev/null +++ b/src/common/utils/agentPluginName.ts @@ -0,0 +1,18 @@ +/** + * Agent Plugins 1.0.0 plugin-name grammar (§5, canonical plugin.schema.json). + * + * Lives in src/common so both the node-side manifest validator and the shared + * registry schema (src/common/config/schemas/agentPluginInstalls.ts) enforce + * the same rule. Registry names double as directory names under + * `~/.mux/plugins`, so this validation is also a filesystem-safety gate: + * the pattern excludes path separators, `.`/`..`, and `..` runs. + */ + +// Canonical name pattern from plugin.schema.json (JS supports the lookahead). +export const AGENT_PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +export const AGENT_PLUGIN_NAME_MAX_LENGTH = 64; + +/** True when `name` satisfies the §5 plugin-name grammar. */ +export function isValidAgentPluginName(name: string): boolean { + return name.length <= AGENT_PLUGIN_NAME_MAX_LENGTH && AGENT_PLUGIN_NAME_PATTERN.test(name); +} diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index d865094651..431f4b9cfd 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -26,6 +26,7 @@ import type { MemoryConsolidationService } from "@/node/services/memoryConsolida import type { MemoryMetaService } from "@/node/services/memoryMeta"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; +import type { AgentPluginInstallService } from "@/node/services/agentPlugins/installService"; import type { TelemetryService } from "@/node/services/telemetryService"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { TimelineService } from "@/node/services/timelineService"; @@ -72,6 +73,7 @@ export interface ORPCContext { mcpOauthService: McpOauthService; workspaceMcpOverridesService: WorkspaceMcpOverridesService; mcpServerManager: MCPServerManager; + agentPluginInstallService: AgentPluginInstallService; sessionTimingService: SessionTimingService; timelineService: TimelineService; telemetryService: TelemetryService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index a5234dffc0..9bb2f27433 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3134,6 +3134,81 @@ export const router = (authToken?: string) => { return result; }), }, + // Managed Agent Plugin installs (agent-plugins experiment). The service + // gates every method on the experiment flag and throws user-facing + // errors; handlers translate them into Result values. + agentPlugins: { + preview: t + .input(schemas.agentPlugins.preview.input) + .output(schemas.agentPlugins.preview.output) + .handler(async ({ context, input }) => { + try { + const data = await context.agentPluginInstallService.preview({ + input: input.input, + ref: input.ref ?? undefined, + subpath: input.subpath ?? undefined, + }); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + install: t + .input(schemas.agentPlugins.install.input) + .output(schemas.agentPlugins.install.output) + .handler(async ({ context, input }) => { + try { + const data = await context.agentPluginInstallService.install(input); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + list: t + .input(schemas.agentPlugins.list.input) + .output(schemas.agentPlugins.list.output) + .handler(async ({ context }) => { + try { + const data = await context.agentPluginInstallService.list(); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + uninstall: t + .input(schemas.agentPlugins.uninstall.input) + .output(schemas.agentPlugins.uninstall.output) + .handler(async ({ context, input }) => { + try { + await context.agentPluginInstallService.uninstall(input); + return { success: true, data: undefined }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + checkUpdates: t + .input(schemas.agentPlugins.checkUpdates.input) + .output(schemas.agentPlugins.checkUpdates.output) + .handler(async ({ context }) => { + try { + const data = await context.agentPluginInstallService.checkUpdates(); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + update: t + .input(schemas.agentPlugins.update.input) + .output(schemas.agentPlugins.update.output) + .handler(async ({ context, input }) => { + try { + const data = await context.agentPluginInstallService.update(input); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + }, mcpOauth: { startDesktopFlow: t .input(schemas.mcpOauth.startDesktopFlow.input) @@ -5532,7 +5607,7 @@ export const router = (authToken?: string) => { policy.mcp.allowUserDefined.remote === false; if (mcpDisabledByPolicy) { - return {}; + return { overrides: {}, revision: "mcp-disabled-by-policy" }; } try { @@ -5540,8 +5615,10 @@ export const router = (authToken?: string) => { input.workspaceId ); } catch { - // Defensive: overrides must never brick workspace UI. - return {}; + // Defensive: overrides must never brick workspace UI. The + // sentinel revision never matches a real one, so a save from + // this unknown state is rejected instead of clobbering data. + return { overrides: {}, revision: "unavailable" }; } }), set: t @@ -5551,7 +5628,8 @@ export const router = (authToken?: string) => { try { await context.workspaceMcpOverridesService.setOverridesForWorkspace( input.workspaceId, - input.overrides + input.overrides, + { expectedRevision: input.expectedRevision } ); return { success: true, data: undefined }; } catch (error) { diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 0031838d60..f0d920899c 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -246,6 +246,34 @@ async function discoverPluginAt(args: { }; } +/** + * Discover a single Agent Plugin at an arbitrary root directory. + * + * Public wrapper around the per-entry discovery used by container scans, so + * callers (e.g. the install service validating a staged temp clone) can run + * the exact same manifest + component validation against a directory that is + * not (yet) inside a configured container. Returns `plugin: null` when the + * directory is not a valid plugin; diagnostics carry the reasons. + */ +export async function discoverAgentPluginAt(args: { + pluginDir: string; + scope: AgentPluginScope; +}): Promise<{ plugin: AgentPluginInfo | null; diagnostics: AgentPluginDiagnostic[] }> { + if (!path.isAbsolute(args.pluginDir)) { + throw new Error(`discoverAgentPluginAt: pluginDir must be absolute: ${args.pluginDir}`); + } + + const diagnostics: AgentPluginDiagnostic[] = []; + const plugin = await discoverPluginAt({ + pluginDir: args.pluginDir, + containerPath: path.dirname(args.pluginDir), + dirName: path.basename(args.pluginDir), + scope: args.scope, + diagnostics, + }); + return { plugin, diagnostics }; +} + /** * Discover Agent Plugins in the given container directories. * diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts new file mode 100644 index 0000000000..9823996dd2 --- /dev/null +++ b/src/node/services/agentPlugins/installService.test.ts @@ -0,0 +1,1209 @@ +/* eslint-disable @typescript-eslint/await-thenable -- bun:test types `await expect(...).rejects.toThrow()` as void */ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as fsPromises from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { Config } from "@/node/config"; +import type { MCPServerManager } from "@/node/services/mcpServerManager"; +import { + WorkspaceMcpOverridesConflictError, + type WorkspaceMcpOverridesService, +} from "@/node/services/workspaceMcpOverridesService"; +import { execFileAsync } from "@/node/utils/disposableExec"; +import { AgentPluginInstallService } from "./installService"; +import { + AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + computePluginInstanceId, + getPluginDataPath, +} from "./mcpConfig"; +import { AGENT_PLUGIN_SCHEMA_ID_1_0_0 } from "./manifest"; + +/** + * Lifecycle tests against a real local git "remote". Local-path remotes go + * through the same clone/ls-remote plumbing as network URLs, so the full + * preview → install → check → update → uninstall loop runs hermetically. + */ + +async function git(cwd: string, ...args: string[]): Promise { + using proc = execFileAsync("git", ["-C", cwd, ...args]); + return (await proc.result).stdout; +} + +async function initRemote(dir: string): Promise { + using proc = execFileAsync("git", ["init", "--quiet", "-b", "main", dir]); + await proc.result; + await git(dir, "config", "user.email", "test@example.com"); + await git(dir, "config", "user.name", "Test"); +} + +async function commitAll(dir: string, message: string): Promise { + await git(dir, "add", "-A"); + await git(dir, "commit", "--quiet", "-m", message); + return (await git(dir, "rev-parse", "HEAD")).trim(); +} + +async function writePluginFixture(dir: string, opts?: { version?: string }): Promise { + await fsPromises.writeFile( + path.join(dir, "plugin.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: "demo-plugin", + version: opts?.version ?? "1.0.0", + description: "Demo plugin", + }) + ); + await fsPromises.mkdir(path.join(dir, "skills", "greet"), { recursive: true }); + await fsPromises.writeFile( + path.join(dir, "skills", "greet", "SKILL.md"), + "---\nname: greet\ndescription: Greets people\n---\n\nSay hi.\n" + ); + await fsPromises.writeFile( + path.join(dir, "mcp.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + echo: { type: "stdio", command: "node", args: ["${PLUGIN_ROOT}/server.js"] }, + }, + }) + ); +} + +describe("AgentPluginInstallService", () => { + let muxRoot: string; + let remoteDir: string; + let config: Config; + let service: AgentPluginInstallService; + let enabled = true; + + const pluginsDir = () => path.join(muxRoot, "plugins"); + const stagingDir = () => path.join(muxRoot, "plugin-staging"); + const registryFile = () => path.join(muxRoot, "plugins.json"); + const registry = async (): Promise => { + try { + const raw = await fsPromises.readFile(registryFile(), "utf8"); + return (JSON.parse(raw) as { plugins: unknown[] }).plugins; + } catch { + return []; + } + }; + const pathExists = async (p: string) => + fsPromises.access(p).then( + () => true, + () => false + ); + const stagingLeftovers = async () => + (await pathExists(stagingDir())) ? fsPromises.readdir(stagingDir()) : []; + + beforeEach(async () => { + muxRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-plugin-test-")); + remoteDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-plugin-remote-")); + config = new Config(muxRoot); + enabled = true; + service = new AgentPluginInstallService(config, { isEnabled: () => enabled }); + await initRemote(remoteDir); + await writePluginFixture(remoteDir); + await commitAll(remoteDir, "init"); + }); + + afterEach(async () => { + await fsPromises.rm(muxRoot, { recursive: true, force: true }); + await fsPromises.rm(remoteDir, { recursive: true, force: true }); + }); + + test("consent preview discloses symlinked skills and warns on escaping symlinks", async () => { + // Runtime discovery loads symlinked skill dirs, so the preview must + // disclose them; symlinks escaping the plugin root are warned about. + await fsPromises.mkdir(path.join(remoteDir, "shared", "linked-skill"), { recursive: true }); + await fsPromises.writeFile( + path.join(remoteDir, "shared", "linked-skill", "SKILL.md"), + "---\nname: linked-skill\ndescription: Lives outside skills/, reached via symlink\n---\n\nBody.\n" + ); + await fsPromises.symlink( + "../shared/linked-skill", + path.join(remoteDir, "skills", "linked-skill") + ); + await fsPromises.symlink("/etc", path.join(remoteDir, "skills", "escaping")); + await commitAll(remoteDir, "symlinked skills"); + + const preview = await service.preview({ input: remoteDir }); + expect(preview.skills.map((skill) => skill.name)).toEqual(["greet", "linked-skill"]); + expect(preview.warnings.some((warning) => warning.includes("skills/escaping"))).toBe(true); + }); + + test("preview stages+validates without writing; install promotes and records the registry", async () => { + const head = (await git(remoteDir, "rev-parse", "HEAD")).trim(); + + const preview = await service.preview({ input: remoteDir }); + expect(preview.source).toEqual({ + type: "git", + url: remoteDir, + ref: "main", + refType: "branch", + }); + expect(preview.lockedSha).toBe(head); + expect(preview.manifest).toMatchObject({ name: "demo-plugin", version: "1.0.0" }); + expect(preview.skills).toEqual([{ name: "greet", description: "Greets people" }]); + expect(preview.mcpServers).toHaveLength(1); + expect(preview.mcpServers[0].serverName).toBe("echo"); + expect(preview.mcpServers[0].transport).toBe("stdio"); + // Command line shows the FINAL install path, not the staging clone path. + expect(preview.mcpServers[0].summary).toBe( + `node ${path.join(pluginsDir(), "demo-plugin", "server.js")}` + ); + + // Cancelling after preview = nothing written anywhere. + expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); + expect(await registry()).toEqual([]); + expect(await stagingLeftovers()).toEqual([]); + + const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + expect(entry.name).toBe("demo-plugin"); + expect(entry.lockedSha).toBe(head); + + const installedDir = path.join(pluginsDir(), "demo-plugin"); + expect(await pathExists(path.join(installedDir, "plugin.json"))).toBe(true); + // Plain content snapshot: provenance lives in the registry, not .git. + expect(await pathExists(path.join(installedDir, ".git"))).toBe(false); + expect(await registry()).toHaveLength(1); + expect((await registry())[0]).toMatchObject({ + name: "demo-plugin", + lockedSha: head, + scope: "global", + }); + expect(await stagingLeftovers()).toEqual([]); + + const items = await service.list(); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + name: "demo-plugin", + managed: true, + present: true, + skillCount: 1, + mcpServerCount: 1, + lockedSha: head, + }); + }); + + test("never overwrites: registry and directory collisions are clear errors", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // Managed entry with the same name. + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/already installed/); + await expect( + service.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/already installed/); + + // Unmanaged directory at the target path (registry entry removed, dir kept). + await fsPromises.rm(registryFile(), { force: true }); + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/already exists/); + }); + + test("update: badge on branch movement, atomic swap, lockedSha bump, local edits discarded", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + expect(await service.checkUpdates()).toEqual([{ name: "demo-plugin", status: "up-to-date" }]); + + await writePluginFixture(remoteDir, { version: "2.0.0" }); + const newHead = await commitAll(remoteDir, "v2"); + + expect(await service.checkUpdates()).toEqual([ + { name: "demo-plugin", status: "update-available", remoteSha: newHead }, + ]); + + // Local edits to a managed dir are discarded on update (documented behavior). + const installedDir = path.join(pluginsDir(), "demo-plugin"); + await fsPromises.writeFile(path.join(installedDir, "local-edit.txt"), "scratch"); + + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(newHead); + expect(updated.updatedAt).toBeDefined(); + expect(updated.manifest?.version).toBe("2.0.0"); + expect(await pathExists(path.join(installedDir, "local-edit.txt"))).toBe(false); + expect(((await registry())[0] as { lockedSha: string }).lockedSha).toBe(newHead); + expect(await stagingLeftovers()).toEqual([]); + }); + + test("tag refs pin; a moved tag reports tag-moved; commit refs report pinned", async () => { + const firstSha = (await git(remoteDir, "rev-parse", "HEAD")).trim(); + await git(remoteDir, "tag", "v1"); + + const tagPreview = await service.preview({ input: remoteDir, ref: "v1" }); + expect(tagPreview.source.refType).toBe("tag"); + expect(tagPreview.lockedSha).toBe(firstSha); + await service.install({ source: tagPreview.source, expectedSha: tagPreview.lockedSha }); + + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await commitAll(remoteDir, "v2"); + await git(remoteDir, "tag", "-f", "v1"); + + const checks = await service.checkUpdates(); + expect(checks[0].status).toBe("tag-moved"); + + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + expect(await registry()).toEqual([]); + expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); + + // Full-SHA install pins hard: no update checks apply. + const shaPreview = await service.preview({ input: remoteDir, ref: firstSha }); + expect(shaPreview.source.refType).toBe("commit"); + await service.install({ source: shaPreview.source, expectedSha: firstSha }); + expect(await service.checkUpdates()).toEqual([{ name: "demo-plugin", status: "pinned" }]); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/pinned/); + }); + + test("update stops the plugin's MCP servers before the old tree moves", async () => { + // Snapshot which tree is installed at each recycle: the pre-swap stop + // must observe the OLD tree still intact (a live server losing its files + // mid-swap on POSIX / holding locks on Windows is the failure mode). + const observedVersions: Array = []; + const mcpStub = { + stopServersWithKeyPrefix: async () => { + try { + const manifest = JSON.parse( + await fsPromises.readFile(path.join(pluginsDir(), "demo-plugin", "plugin.json"), "utf8") + ) as { version: string }; + observedVersions.push(manifest.version); + } catch { + observedVersions.push(null); + } + }, + } as unknown as MCPServerManager; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub, + }); + + const preview = await serviceWithMcp.preview({ input: remoteDir }); + await serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }); + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await commitAll(remoteDir, "v2"); + + await serviceWithMcp.update({ name: "demo-plugin" }); + + // Two recycles: pre-swap (old tree, servers stopped while their files + // still exist) and post-promote (new content behind the stable path). + expect(observedVersions.length).toBe(2); + expect(observedVersions[0]).toBe("1.0.0"); + expect(observedVersions[1]).toBe("2.0.0"); + }); + + test("uninstall completes even when deleting the staged tree fails", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // Force the best-effort trash deletion to fail (e.g. a Windows file + // lock). It must not abort uninstall before override pruning runs. + const internals = service as unknown as { removeDir: (dir: string) => Promise }; + const removeDirSpy = spyOn(internals, "removeDir").mockImplementationOnce(() => + Promise.reject(new Error("EBUSY: resource busy or locked")) + ); + try { + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + } finally { + removeDirSpy.mockRestore(); + } + + // Uninstall completed: registry entry + container dir gone; the staged + // tree remains under staging for stale-dir reclamation. + expect(await registry()).toEqual([]); + expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); + expect((await stagingLeftovers()).some((name) => name.startsWith("trash-"))).toBe(true); + + // And reinstall is not blocked by leftover state. + const preview2 = await service.preview({ input: remoteDir }); + const entry = await service.install({ + source: preview2.source, + expectedSha: preview2.lockedSha, + }); + expect(entry.name).toBe("demo-plugin"); + }); + + test("uninstall re-invalidates MCP servers after the tree is removed", async () => { + // A getToolsForWorkspace that starts right after the pre-rename stop can + // discover the plugin before the rename and start a server from the + // removed tree; the post-removal invalidation must catch it. Snapshot + // the tree state at each recycle: first stop sees the tree, second stop + // must run after it is gone. + const treeStates: boolean[] = []; + const mcpStub = { + stopServersWithKeyPrefix: async () => { + treeStates.push(await pathExists(path.join(pluginsDir(), "demo-plugin"))); + }, + } as unknown as MCPServerManager; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub, + }); + + const preview = await serviceWithMcp.preview({ input: remoteDir }); + await serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }); + await serviceWithMcp.uninstall({ name: "demo-plugin", deletePluginData: false }); + + expect(treeStates).toEqual([true, false]); + }); + + test("uninstall aborts intact when pruning enumeration fails (pre-commit)", async () => { + let stops = 0; + const mcpStub = { + stopServersWithKeyPrefix: () => { + stops += 1; + return Promise.resolve(); + }, + } as unknown as MCPServerManager; + // An overrides service makes uninstall enumerate workspace metadata (the + // only pruning step that can fail wholesale, outside the per-workspace + // catch). That enumeration must happen BEFORE anything commits: a + // post-commit failure would strand stale enabled-server overrides with + // no Settings row left to retry from, and a reinstall (same instance ID) + // would silently re-enable those servers. + const overridesStub = { + getOverridesForWorkspace: () => Promise.resolve({ overrides: {}, revision: "r0" }), + setOverridesForWorkspace: () => Promise.resolve(), + }; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + + const preview = await serviceWithMcp.preview({ input: remoteDir }); + await serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }); + + stops = 0; + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementationOnce(() => + Promise.reject(new Error("metadata enumeration failed")) + ); + try { + await expect( + serviceWithMcp.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/metadata enumeration failed/); + } finally { + metadataSpy.mockRestore(); + } + + // Nothing was committed and no servers were stopped: the install is fully + // intact and the row remains, so the user can simply retry. + expect(stops).toBe(0); + expect(await registry()).toHaveLength(1); + expect(await pathExists(path.join(pluginsDir(), "demo-plugin", "plugin.json"))).toBe(true); + + // The retry completes the uninstall, including both invalidations. + await serviceWithMcp.uninstall({ name: "demo-plugin", deletePluginData: false }); + expect(stops).toBe(2); + expect(await registry()).toEqual([]); + }); + + test("failed per-workspace prunes persist a tombstone that gates reinstall and self-heals", async () => { + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const serverKey = `plugin:${instanceId}:echo`; + + // One local workspace with the plugin's server enabled; its override + // file is temporarily unwritable. + let overridesBroken = true; + let storedOverrides: Record = { enabledServers: [serverKey] }; + const overridesStub = { + getOverridesForWorkspace: () => { + if (overridesBroken) { + return Promise.reject(new Error("checkout unavailable")); + } + return Promise.resolve({ + overrides: storedOverrides, + revision: JSON.stringify(storedOverrides), + }); + }, + setOverridesForWorkspace: (_id: string, overrides: Record) => { + storedOverrides = overrides; + return Promise.resolve(); + }, + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([{ id: "ws-1", runtimeConfig: { type: "local" } }] as unknown as Awaited< + ReturnType + >) + ); + + try { + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + await serviceWithOverrides.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + await serviceWithOverrides.uninstall({ name: "demo-plugin", deletePluginData: false }); + + // Uninstall committed, but the failed prune left a persisted tombstone. + expect(await registry()).toEqual([]); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: Array<{ prefix: string; workspaceIds: string[] }>; + }; + expect(doc.pendingOverridePrunes).toEqual([ + { prefix: `plugin:${instanceId}:`, workspaceIds: ["ws-1"] }, + ]); + + // Reinstalling the same name is gated while the stale override remains: + // the same instance ID would silently re-enable the server. + const preview2 = await serviceWithOverrides.preview({ input: remoteDir }); + await expect( + serviceWithOverrides.install({ source: preview2.source, expectedSha: preview2.lockedSha }) + ).rejects.toThrow(/could not clean up its workspace MCP overrides/); + + // Once the workspace is reachable again, the retry (section open or the + // install gate itself) prunes the override and unblocks reinstall. + overridesBroken = false; + const entry = await serviceWithOverrides.install({ + source: preview2.source, + expectedSha: preview2.lockedSha, + }); + expect(entry.name).toBe("demo-plugin"); + expect(storedOverrides.enabledServers ?? []).toEqual([]); + const docAfter = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: unknown; + }; + expect(docAfter.pendingOverridePrunes).toBeUndefined(); + } finally { + metadataSpy.mockRestore(); + } + }); + + test("prune retries after a concurrent overrides save conflicts instead of tombstoning", async () => { + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const serverKey = `plugin:${instanceId}:echo`; + + // A Workspace MCP dialog save lands between the prune's read and write + // exactly once; the prune must re-read and complete rather than treating + // the transient conflict as a failed workspace. + let storedOverrides: Record = { enabledServers: [serverKey, "other"] }; + let conflictsRemaining = 1; + const overridesStub = { + getOverridesForWorkspace: () => + Promise.resolve({ overrides: storedOverrides, revision: JSON.stringify(storedOverrides) }), + setOverridesForWorkspace: (_id: string, overrides: Record) => { + if (conflictsRemaining > 0) { + conflictsRemaining -= 1; + return Promise.reject(new WorkspaceMcpOverridesConflictError()); + } + storedOverrides = overrides; + return Promise.resolve(); + }, + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([{ id: "ws-1", runtimeConfig: { type: "local" } }] as unknown as Awaited< + ReturnType + >) + ); + + try { + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + await serviceWithOverrides.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + await serviceWithOverrides.uninstall({ name: "demo-plugin", deletePluginData: false }); + } finally { + metadataSpy.mockRestore(); + } + + // Plugin keys pruned, non-plugin keys kept, and no tombstone persisted. + expect(storedOverrides).toEqual({ enabledServers: ["other"] }); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: unknown; + }; + expect(doc.pendingOverridePrunes).toBeUndefined(); + }); + + test("tombstone survives even when both the prune and the shrink write fail", async () => { + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const overridesStub = { + getOverridesForWorkspace: () => Promise.reject(new Error("checkout unavailable")), + setOverridesForWorkspace: () => Promise.resolve(), + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([{ id: "ws-1", runtimeConfig: { type: "local" } }] as unknown as Awaited< + ReturnType + >) + ); + + try { + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + await serviceWithOverrides.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + + // The commit write (which must carry the pessimistic tombstone) runs + // for real; the post-prune shrink write fails. + const internals = serviceWithOverrides as unknown as { + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; + }; + const originalWrite = internals.writeRegistry.bind(serviceWithOverrides); + let writeCalls = 0; + const writeSpy = spyOn(internals, "writeRegistry").mockImplementation( + (envelope: Record, entries: unknown[]) => { + writeCalls += 1; + if (writeCalls === 2) { + return Promise.reject(new Error("ENOSPC: no space left on device")); + } + return originalWrite(envelope, entries); + } + ); + try { + await serviceWithOverrides.uninstall({ name: "demo-plugin", deletePluginData: false }); + } finally { + writeSpy.mockRestore(); + } + + // The durable record is the COMMIT write's pessimistic tombstone: even + // with the shrink write lost, reinstall stays gated. + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: Array<{ prefix: string; workspaceIds: string[] }>; + }; + expect(doc.pendingOverridePrunes).toEqual([ + { prefix: `plugin:${instanceId}:`, workspaceIds: ["ws-1"] }, + ]); + const preview2 = await serviceWithOverrides.preview({ input: remoteDir }); + await expect( + serviceWithOverrides.install({ source: preview2.source, expectedSha: preview2.lockedSha }) + ).rejects.toThrow(/could not clean up its workspace MCP overrides/); + } finally { + metadataSpy.mockRestore(); + } + }); + + test("tombstones for deleted workspaces retire instead of blocking reinstall forever", async () => { + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + // Overrides service that permanently throws (as it would for a workspace + // that no longer exists in config). + const overridesStub = { + getOverridesForWorkspace: () => Promise.reject(new Error("Workspace metadata not found")), + setOverridesForWorkspace: () => Promise.resolve(), + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + + // Seed a tombstone naming a workspace that is not in config anymore. + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [], + pendingOverridePrunes: [{ prefix: `plugin:${instanceId}:`, workspaceIds: ["ws-deleted"] }], + }) + ); + + // The deleted workspace can never reactivate anything, so the reinstall + // gate drops it instead of blocking forever on its permanent failure. + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + const entry = await serviceWithOverrides.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + expect(entry.name).toBe("demo-plugin"); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: unknown; + }; + expect(doc.pendingOverridePrunes).toBeUndefined(); + }); + + test("tombstone rewrites preserve unknown variants and fields from newer builds", async () => { + // A newer build's tombstone variant (unrecognized shape) plus a + // recognized tombstone carrying an unknown field, for an unrelated + // prefix whose workspace no longer exists (so it retires by itself). + const futureVariant = { kind: "future-cleanup", payload: { x: 1 } }; + const foreignPrune = { + prefix: "plugin:0000000000000000:", + workspaceIds: ["ws-gone"], + reason: "future-field", + }; + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ plugins: [], pendingOverridePrunes: [futureVariant, foreignPrune] }) + ); + + // A full uninstall cycle rewrites pendingOverridePrunes twice (commit + + // shrink); the unknown variant must ride through verbatim. + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes: unknown[]; + }; + expect(doc.pendingOverridePrunes).toContainEqual(futureVariant); + // The recognized foreign tombstone kept its unknown field (ws-gone is not + // in this config, so a retry would retire it — but no retry ran for it + // during uninstall, which only touches its own prefix). + expect(doc.pendingOverridePrunes).toContainEqual(foreignPrune); + }); + + test("tombstone retries on list are serialized with registry mutations", async () => { + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "other-name")); + // A tombstone whose prune blocks until released, so a mutation can be + // issued while the retry's read-modify-write is in flight. + let releasePrune!: () => void; + const pruneGate = new Promise((resolve) => { + releasePrune = resolve; + }); + const overridesStub = { + getOverridesForWorkspace: async () => { + await pruneGate; + return { overrides: {}, revision: "r0" }; + }, + setOverridesForWorkspace: () => Promise.resolve(), + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([{ id: "ws-1", runtimeConfig: { type: "local" } }] as unknown as Awaited< + ReturnType + >) + ); + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [], + pendingOverridePrunes: [{ prefix: `plugin:${instanceId}:`, workspaceIds: ["ws-1"] }], + }) + ); + + try { + // list() starts the retry, which parks inside the (locked) prune. + const listPromise = serviceWithOverrides.list(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // A concurrent install must serialize AFTER the retry's write: without + // the shared mutation lock, the retry's stale snapshot would erase the + // newly installed entry. + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + const installPromise = serviceWithOverrides.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + releasePrune(); + + await listPromise; + await installPromise; + + // The installed entry survived the retry's write, and the tombstone cleared. + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: Array<{ name: string }>; + pendingOverridePrunes?: unknown; + }; + expect(doc.plugins.map((entry) => entry.name)).toEqual(["demo-plugin"]); + expect(doc.pendingOverridePrunes).toBeUndefined(); + } finally { + metadataSpy.mockRestore(); + } + }); + + test("uninstall stages plugin-data before committing when deletion is requested", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const dataPath = getPluginDataPath(muxRoot, instanceId); + await fsPromises.mkdir(dataPath, { recursive: true }); + await fsPromises.writeFile(path.join(dataPath, "state.json"), "{}"); + + // Make the data dir unstageable: rename mutates the parent (plugin-data/). + await fsPromises.chmod(path.join(muxRoot, "plugin-data"), 0o555); + try { + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: true }) + ).rejects.toThrow(/Failed to remove the plugin data/); + } finally { + await fsPromises.chmod(path.join(muxRoot, "plugin-data"), 0o755); + } + + // The uninstall did not commit: the Settings row survives so the user can + // retry the requested cleanup, and nothing was half-removed. + expect(await registry()).toHaveLength(1); + expect(await pathExists(path.join(pluginsDir(), "demo-plugin", "plugin.json"))).toBe(true); + expect(await pathExists(path.join(dataPath, "state.json"))).toBe(true); + + // Retry succeeds and honors the data-deletion request. + await service.uninstall({ name: "demo-plugin", deletePluginData: true }); + expect(await registry()).toEqual([]); + expect(await pathExists(dataPath)).toBe(false); + }); + + test("uninstall preserves plugin-data by default and deletes it when asked", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const dataPath = getPluginDataPath(muxRoot, instanceId); + await fsPromises.mkdir(dataPath, { recursive: true }); + await fsPromises.writeFile(path.join(dataPath, "state.json"), "{}"); + + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + expect(await pathExists(dataPath)).toBe(true); + + const preview2 = await service.preview({ input: remoteDir }); + await service.install({ source: preview2.source, expectedSha: preview2.lockedSha }); + await service.uninstall({ name: "demo-plugin", deletePluginData: true }); + expect(await pathExists(dataPath)).toBe(false); + }); + + test("failure paths leave no partial state", async () => { + // Unreachable remote. + await expect(service.preview({ input: "/nonexistent/repo/path" })).rejects.toThrow( + /Could not reach/ + ); + + // Repo that is not a plugin. + const notPlugin = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-not-plugin-")); + try { + await initRemote(notPlugin); + await fsPromises.writeFile(path.join(notPlugin, "README.md"), "hi"); + await commitAll(notPlugin, "init"); + await expect(service.preview({ input: notPlugin })).rejects.toThrow(/No plugin\.json/); + + // Claude Code collection → clear message naming the limitation. + await fsPromises.mkdir(path.join(notPlugin, ".claude-plugin"), { recursive: true }); + await fsPromises.writeFile(path.join(notPlugin, ".claude-plugin", "plugin.json"), "{}"); + await commitAll(notPlugin, "claude"); + await expect(service.preview({ input: notPlugin })).rejects.toThrow(/Claude Code/); + } finally { + await fsPromises.rm(notPlugin, { recursive: true, force: true }); + } + + // Subpath installs are parsed but rejected in v1. + await expect(service.preview({ input: remoteDir, subpath: "sub" })).rejects.toThrow(/v2/); + + // Unknown ref. + await expect(service.preview({ input: remoteDir, ref: "does-not-exist" })).rejects.toThrow( + /not found on the remote/ + ); + + // Nothing was written by any of the failures above. + expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); + expect(await registry()).toEqual([]); + expect(await stagingLeftovers()).toEqual([]); + + // Disabled experiment gates every method. + enabled = false; + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/not enabled/); + await expect(service.list()).rejects.toThrow(/not enabled/); + enabled = true; + + // Remote moved between preview and install: the exact consented SHA is + // installed (never the newer unreviewed tip). If the SHA became + // unfetchable, install fails with "moved since the preview" instead. + const preview = await service.preview({ input: remoteDir }); + await writePluginFixture(remoteDir, { version: "9.9.9" }); + await commitAll(remoteDir, "moved"); + const entry = await service.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + expect(entry.lockedSha).toBe(preview.lockedSha); + expect(entry.manifest?.version).toBe("1.0.0"); + const installedManifest = JSON.parse( + await fsPromises.readFile(path.join(pluginsDir(), "demo-plugin", "plugin.json"), "utf8") + ) as { version: string }; + expect(installedManifest.version).toBe("1.0.0"); + }); + + test("registry rewrites preserve entries and fields from newer builds", async () => { + // Simulate a newer build's registry content: an unknown source kind and + // an extra per-entry field this build's schemas do not know about. + const futureEntry = { + name: "future-plugin", + scope: "global", + source: { type: "archive", url: "https://example.com/p.tgz", sha256: "ab" }, + lockedSha: "b".repeat(40), + installedAt: "2026-09-01T00:00:00.000Z", + futureField: { nested: true }, + }; + await fsPromises.writeFile(registryFile(), JSON.stringify({ plugins: [futureEntry] })); + + // Full lifecycle on this build: install, update, uninstall of a git plugin. + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await commitAll(remoteDir, "v2"); + await service.update({ name: "demo-plugin" }); + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + + // The unrecognized entry survived every rewrite verbatim. + expect(await registry()).toEqual([futureEntry]); + // And it never surfaced as a managed row this build could mutate. + expect((await service.list()).map((item) => item.name)).not.toContain("future-plugin"); + }); + + test("update preserves unknown nested fields inside the entry's source and manifest", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // A newer build stored extra metadata INSIDE the git source and manifest + // of this entry; a shallow merge of the Zod-parsed entry would strip it. + const onDisk = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: Array>; + }; + (onDisk.plugins[0].source as Record).integrity = "sha256-future"; + onDisk.plugins[0].manifest = { + ...(onDisk.plugins[0].manifest as Record), + icon: "sparkles", + }; + await fsPromises.writeFile(registryFile(), JSON.stringify(onDisk)); + + await writePluginFixture(remoteDir, { version: "2.0.0" }); + const newHead = await commitAll(remoteDir, "v2"); + await service.update({ name: "demo-plugin" }); + + const after = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: Array<{ + lockedSha: string; + source: Record; + manifest: Record; + }>; + }; + expect(after.plugins[0].lockedSha).toBe(newHead); + // Owned fields updated… + expect(after.plugins[0].manifest.version).toBe("2.0.0"); + // …unknown nested metadata untouched. + expect(after.plugins[0].source.integrity).toBe("sha256-future"); + expect(after.plugins[0].manifest.icon).toBe("sparkles"); + }); + + test("managed list rows keep registry identity when the manifest name drifts", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // Local edit renames the manifest to another VALID plugin name. + const manifestPath = path.join(pluginsDir(), "demo-plugin", "plugin.json"); + const manifest = JSON.parse(await fsPromises.readFile(manifestPath, "utf8")) as { + name: string; + }; + manifest.name = "impostor"; + await fsPromises.writeFile(manifestPath, JSON.stringify(manifest)); + + // The row keeps the registry name (update/uninstall look up by it) and + // surfaces the drift; the operations remain usable. + const items = await service.list(); + const row = items.find((item) => item.managed); + expect(row?.name).toBe("demo-plugin"); + expect(row?.description).toContain("impostor"); + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + expect(await registry()).toEqual([]); + }); + + test("mutations refuse a corrupted registry file instead of orphaning entries", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // Corrupt the registry file (invalid JSON, not just an invalid entry). + await fsPromises.writeFile(registryFile(), "{ not json"); + + // Reads stay lenient: the section still renders, dirs show unmanaged. + const items = await service.list(); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ name: "demo-plugin", managed: false }); + + // Mutations refuse with a repair message — treating the corrupt file as + // empty would let this install rewrite it with one entry, permanently + // orphaning everything previously managed. + const remote2 = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-plugin-remote2-")); + try { + await initRemote(remote2); + await writePluginFixture(remote2); + await fsPromises.writeFile( + path.join(remote2, "plugin.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: "other-plugin", + version: "1.0.0", + }) + ); + await commitAll(remote2, "init"); + await expect(service.preview({ input: remote2 })).rejects.toThrow(/corrupted/); + } finally { + await fsPromises.rm(remote2, { recursive: true, force: true }); + } + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/corrupted/); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/corrupted/); + + // The corrupt file was never rewritten. + expect(await fsPromises.readFile(registryFile(), "utf8")).toBe("{ not json"); + + // Structurally invalid envelopes (parseable JSON without a plugins + // array) are corruption too — {} or {"plugins": null} must not let a + // mutation rewrite the registry down to a single entry. + for (const invalidEnvelope of ["{}", '{ "plugins": null }', "[]"]) { + await fsPromises.writeFile(registryFile(), invalidEnvelope); + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/corrupted/); + expect(await fsPromises.readFile(registryFile(), "utf8")).toBe(invalidEnvelope); + } + }); + + test("install refuses names owned by entries this build cannot parse", async () => { + // A newer build's entry (unknown source kind) named demo-plugin, with no + // directory on disk: this build must still treat the name as taken — + // installing over it would filter the raw entry out and replace it. + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [ + { + name: "demo-plugin", + scope: "global", + source: { type: "archive", url: "https://example.com/p.tgz" }, + lockedSha: "c".repeat(40), + installedAt: "2026-09-01T00:00:00.000Z", + }, + ], + }) + ); + + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/already installed/); + // The unrecognized entry is untouched. + expect(await registry()).toHaveLength(1); + }); + + test("mutations refuse an unreadable registry file (non-ENOENT read failure)", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + await fsPromises.chmod(registryFile(), 0o000); + try { + // Reads degrade to unmanaged; mutations refuse instead of letting the + // atomic write replace the unreadable file and erase its entries. + const items = await service.list(); + expect(items[0]).toMatchObject({ name: "demo-plugin", managed: false }); + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/cannot be read/); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/cannot be read/); + } finally { + await fsPromises.chmod(registryFile(), 0o644); + } + + // Registry intact once readable again. + expect(await registry()).toHaveLength(1); + expect((await service.list())[0]).toMatchObject({ name: "demo-plugin", managed: true }); + }); + + test("registry rewrites preserve unknown top-level envelope fields", async () => { + // A newer build added top-level registry metadata alongside `plugins`. + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ registryVersion: 2, migrationState: { seeded: true }, plugins: [] }) + ); + + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await commitAll(remoteDir, "v2"); + await service.update({ name: "demo-plugin" }); + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + + // Every mutation rewrote only `plugins`; the envelope survived verbatim. + const after = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as Record< + string, + unknown + >; + expect(after.registryVersion).toBe(2); + expect(after.migrationState).toEqual({ seeded: true }); + expect(after.plugins).toEqual([]); + }); + + test("update recycles MCP servers even when the registry write fails post-promote", async () => { + let stops = 0; + const mcpStub = { + stopServersWithKeyPrefix: () => { + stops += 1; + return Promise.resolve(); + }, + } as unknown as MCPServerManager; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub, + }); + + const preview = await serviceWithMcp.preview({ input: remoteDir }); + await serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }); + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await commitAll(remoteDir, "v2"); + + stops = 0; + const internals = serviceWithMcp as unknown as { + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; + }; + const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(() => + Promise.reject(new Error("ENOSPC: no space left on device")) + ); + try { + await expect(serviceWithMcp.update({ name: "demo-plugin" })).rejects.toThrow(/ENOSPC/); + } finally { + writeSpy.mockRestore(); + } + + // Both recycles ran (pre-swap + post-promote) despite the failed write: + // the tree already swapped, so a server started from the replaced tree + // must not be retained. + expect(stops).toBe(2); + // Stale lockedSha keeps the badge; a retry self-heals. + expect(((await registry())[0] as { lockedSha: string }).lockedSha).toBe(preview.lockedSha); + const retried = await serviceWithMcp.update({ name: "demo-plugin" }); + expect(retried.manifest?.version).toBe("2.0.0"); + }); + + test("update rejects a tracked ref whose kind changed on the remote", async () => { + await git(remoteDir, "branch", "track"); + const preview = await service.preview({ input: remoteDir, ref: "track" }); + expect(preview.source.refType).toBe("branch"); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // The tracked branch is deleted and a tag with the same name appears, + // pointing at newer content. + await writePluginFixture(remoteDir, { version: "2.0.0" }); + const newHead = await commitAll(remoteDir, "v2"); + await git(remoteDir, "branch", "-D", "track"); + await git(remoteDir, "tag", "track", newHead); + + // A stale Update click must not install tag content while the registry + // still claims a branch. + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/now a tag/); + expect(((await registry())[0] as { lockedSha: string }).lockedSha).toBe(preview.lockedSha); + }); + + test("registry survives config.json rewrites and drops traversal names on read", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // Registry is a standalone file: rebuilding config.json (what older + // builds do on every save) cannot drop it. + await config.editConfig((cfg) => { + cfg.defaultModel = "openai:gpt-4o"; + return cfg; + }); + expect(await registry()).toHaveLength(1); + expect((await service.list())[0]).toMatchObject({ name: "demo-plugin", managed: true }); + + // Malicious/corrupt entries with traversal names must never reach the + // filesystem layer: uninstall of ".." would delete the entire mux root. + const onDisk = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: unknown[]; + }; + const template = onDisk.plugins[0] as Record; + onDisk.plugins.push({ ...template, name: ".." }, { ...template, name: "a/../b" }); + await fsPromises.writeFile(registryFile(), JSON.stringify(onDisk)); + + const items = await service.list(); + expect(items.map((item) => item.name)).toEqual(["demo-plugin"]); + await expect(service.uninstall({ name: "..", deletePluginData: false })).rejects.toThrow( + /not a managed plugin/ + ); + }); + + test("uninstall restores the registry entry when the tree cannot be staged out", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // Force the stage-out rename to fail by making the container read-only + // (rename mutates the parent directory). + await fsPromises.chmod(pluginsDir(), 0o555); + try { + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/Failed to remove the plugin directory/); + } finally { + await fsPromises.chmod(pluginsDir(), 0o755); + } + + // No partial state: the install is fully intact and still managed. + expect(await registry()).toHaveLength(1); + expect(await pathExists(path.join(pluginsDir(), "demo-plugin", "plugin.json"))).toBe(true); + expect((await service.list())[0]).toMatchObject({ name: "demo-plugin", managed: true }); + + // And the retry succeeds once the obstruction is gone. + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + expect(await registry()).toEqual([]); + }); + + test("install rolls back the promoted dir when the registry write fails", async () => { + const preview = await service.preview({ input: remoteDir }); + + const internals = service as unknown as { + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; + }; + const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(() => + Promise.reject(new Error("ENOSPC: no space left on device")) + ); + try { + await expect( + service.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/persist the plugin registry/); + } finally { + writeSpy.mockRestore(); + } + + // No partial state: the promoted dir was rolled back. + expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); + expect(await stagingLeftovers()).toEqual([]); + + // The retry of the same consented install succeeds. + const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + expect(entry.name).toBe("demo-plugin"); + expect(await registry()).toHaveLength(1); + }); + + test("falls back to a branch clone when the remote refuses direct SHA fetches", async () => { + // GitHub-style servers can reject fetching unadvertised objects; simulate + // by pointing the exact-SHA fetch at a file:// remote with SHA-in-want + // disabled, so only the advertised branch tip is fetchable. + await git(remoteDir, "config", "uploadpack.allowAnySHA1InWant", "false"); + await git(remoteDir, "config", "uploadpack.allowReachableSHA1InWant", "false"); + const fileUrl = `file://${remoteDir}`; + + const preview = await service.preview({ input: fileUrl }); + const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + expect(entry.lockedSha).toBe(preview.lockedSha); + expect(await pathExists(path.join(pluginsDir(), "demo-plugin", "plugin.json"))).toBe(true); + expect(await stagingLeftovers()).toEqual([]); + }); + + test("list surfaces unmanaged plugin dirs read-only and missing managed installs", async () => { + // Unmanaged: a directory dropped into the container by hand. + const unmanagedDir = path.join(pluginsDir(), "handmade"); + await fsPromises.mkdir(unmanagedDir, { recursive: true }); + await fsPromises.writeFile( + path.join(unmanagedDir, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "handmade" }) + ); + + // Missing managed install: registry entry without a directory. + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + await fsPromises.rm(path.join(pluginsDir(), "demo-plugin"), { recursive: true, force: true }); + + const items = await service.list(); + expect(items).toHaveLength(2); + const managed = items.find((item) => item.name === "demo-plugin"); + expect(managed).toMatchObject({ managed: true, present: false, version: "1.0.0" }); + const unmanaged = items.find((item) => item.name === "handmade"); + expect(unmanaged).toMatchObject({ managed: false, present: true }); + }); +}); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts new file mode 100644 index 0000000000..c956fdc79e --- /dev/null +++ b/src/node/services/agentPlugins/installService.ts @@ -0,0 +1,1593 @@ +import * as fsPromises from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import writeFileAtomic from "write-file-atomic"; + +import { + AgentPluginInstallEntrySchema, + type AgentPluginGitSource, + type AgentPluginInstallEntry, +} from "@/common/config/schemas/agentPluginInstalls"; +import { isValidAgentPluginName } from "@/common/utils/agentPluginName"; +import type { + AgentPluginInstallPreview, + AgentPluginListItem, + AgentPluginManifestSummary, + AgentPluginPreviewMcpServer, + AgentPluginPreviewSkill, + AgentPluginUpdateCheck, +} from "@/common/orpc/schemas/agentPlugins"; +import assert from "@/common/utils/assert"; +import { getErrorMessage } from "@/common/utils/errors"; +import type { Config } from "@/node/config"; +import { parseSkillMarkdown } from "@/node/services/agentSkills/parseSkillMarkdown"; +import { log } from "@/node/services/log"; +import type { MCPServerManager } from "@/node/services/mcpServerManager"; +import { + WorkspaceMcpOverridesConflictError, + type WorkspaceMcpOverridesService, +} from "@/node/services/workspaceMcpOverridesService"; +import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { execFileAsync } from "@/node/utils/disposableExec"; +import { + discoverAgentPluginAt, + discoverAgentPlugins, + type AgentPluginContainer, + type AgentPluginInfo, +} from "./discovery"; +import type { AgentPluginManifest } from "./manifest"; +import { + buildPluginServerKey, + computePluginInstanceId, + getPluginDataPath, + loadPluginMcpServers, +} from "./mcpConfig"; +import { isFullCommitSha, parseAgentPluginSourceInput } from "./sourceInput"; + +/** + * Managed Agent Plugin installer (agent-plugins experiment; global scope only). + * + * Flow: parse input → shallow clone to a staging dir under ~/.mux → + * validate the STAGED clone with the same manifest/component discovery used + * at runtime → return a consent preview → on confirm, re-clone the exact SHA, + * promote into ~/.mux/plugins/, and record a registry entry + * ({source, ref, lockedSha}) in ~/.mux/plugins.json. + * + * The registry is a standalone file (NOT a config.json section): older builds + * rebuild config.json from known fields on save, so a downgrade would drop an + * embedded registry — and owning the file lets writes THROW on failure so + * install/update/uninstall can roll back instead of silently succeeding with + * an unpersisted registry. + * + * Invariants: + * - The installer NEVER writes into a project checkout (v1 is global-only). + * - `lockedSha` is what runs; branches are only a tracking channel for the + * update badge. Nothing auto-applies. + * - Update = temp clone + wholesale directory swap (rename-old → promote-new + * → delete-old), never in-place `git pull` — local edits to a managed + * plugin dir are discarded on update. + * - Applying an update or uninstalling recycles that plugin's running MCP + * servers: content can change behind an unchanged stdio command line, so + * the config-signature check cannot notice (correctness, not polish). + * - Failure paths must leave no partial state: staging dirs are cleaned up, + * and promote + registry-write failures roll back. + */ + +/** Registry file name under the mux home dir. */ +const REGISTRY_FILE_NAME = "plugins.json"; + +/** Preview/staging clones live here — NOT under ~/.mux/plugins, which discovery scans. */ +const STAGING_DIR_NAME = "plugin-staging"; + +/** Staging dirs left behind by crashes are reclaimed after this age. */ +const STALE_STAGING_MAX_AGE_MS = 60 * 60 * 1000; + +const LS_REMOTE_TIMEOUT_MS = 30_000; +const CLONE_TIMEOUT_MS = 120_000; + +/** Result of resolving a user-supplied ref against the remote. */ +interface ResolvedRemoteRef { + ref: string; + refType: "branch" | "tag" | "commit"; + /** Peeled commit SHA for branch/tag; the ref itself for commit. */ + sha: string; +} + +function gitEnv(): Record { + // Fail fast instead of hanging on credential prompts: installs run from the + // UI with no terminal attached (acceptance: "private repo without auth" must + // fail cleanly). + const env: Record = { GIT_TERMINAL_PROMPT: "0" }; + if (process.env.GIT_SSH_COMMAND === undefined) { + env.GIT_SSH_COMMAND = "ssh -oBatchMode=yes"; + } + return env; +} + +async function runGit(args: string[], opts?: { timeoutMs?: number }): Promise { + using proc = execFileAsync("git", args, { + env: gitEnv(), + timeoutMs: opts?.timeoutMs ?? CLONE_TIMEOUT_MS, + }); + const { stdout } = await proc.result; + return stdout; +} + +async function pathExists(candidate: string): Promise { + try { + await fsPromises.access(candidate); + return true; + } catch { + return false; + } +} + +function shortenHome(absPath: string): string { + const home = os.homedir(); + if (absPath === home) { + return "~"; + } + return absPath.startsWith(home + path.sep) ? `~${absPath.slice(home.length)}` : absPath; +} + +function manifestSummary(manifest: AgentPluginManifest): AgentPluginManifestSummary { + return { + name: manifest.name, + ...(manifest.version !== undefined ? { version: manifest.version } : {}), + ...(manifest.description !== undefined ? { description: manifest.description } : {}), + ...(manifest.author?.name !== undefined ? { authorName: manifest.author.name } : {}), + ...(manifest.homepage !== undefined ? { homepage: manifest.homepage } : {}), + ...(manifest.repository !== undefined ? { repository: manifest.repository } : {}), + ...(manifest.license !== undefined ? { license: manifest.license } : {}), + }; +} + +export class AgentPluginInstallService { + private readonly containerDir: string; + private readonly stagingRoot: string; + private readonly registryFile: string; + /** Serializes mutations (install/update/uninstall) so directory swaps and registry writes cannot interleave. */ + private mutationQueue: Promise = Promise.resolve(); + + constructor( + private readonly config: Config, + private readonly deps: { + isEnabled: () => boolean; + /** Recycles running MCP servers whose config key starts with the given prefix. */ + mcpServerManager?: MCPServerManager; + /** Used to prune plugin server keys from per-workspace overrides on uninstall. */ + workspaceMcpOverridesService?: WorkspaceMcpOverridesService; + } + ) { + assert(path.isAbsolute(config.rootDir), "AgentPluginInstallService: rootDir must be absolute"); + this.containerDir = path.join(config.rootDir, "plugins"); + this.stagingRoot = path.join(config.rootDir, STAGING_DIR_NAME); + this.registryFile = path.join(config.rootDir, REGISTRY_FILE_NAME); + } + + // --------------------------------------------------------------------- + // Registry persistence (~/.mux/plugins.json) + // --------------------------------------------------------------------- + + /** + * The registry document as stored on disk: the top-level ENVELOPE (an + * object that must hold a `plugins` array, and may hold future top-level + * fields like a registry version) plus the raw entry list. Mutations + * operate on the raw entries (matching by their `name` property) and write + * the envelope back with only `plugins` replaced, so both unknown entry + * fields and unknown top-level fields written by newer builds survive an + * install/update/uninstall on this build (upgrade↔downgrade stays + * lossless). + * + * A missing file is an empty registry; corrupted content — unparseable + * JSON or a structurally invalid envelope like `{}` / `{"plugins": null}` + * — is not. Reads ("lenient") degrade corruption to an empty list so the + * section still renders (dirs show as unmanaged), but mutations ("strict") + * must refuse: treating a corrupted file as empty would let the next + * install rewrite it with a single entry, permanently orphaning every + * previously managed install. + */ + private async readRegistryDocument(mode: "lenient" | "strict"): Promise<{ + envelope: Record; + rawEntries: unknown[]; + }> { + const corrupted = (detail: string): never => { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) is corrupted: ${detail}. Repair or remove the file, then retry.` + ); + }; + + let raw: string; + try { + raw = await fsPromises.readFile(this.registryFile, "utf8"); + } catch (error) { + // Only a MISSING file is an empty registry. Any other read failure + // (e.g. an unreadable mode-000 file in a writable ~/.mux) must block + // mutations: the atomic write replaces the file wholesale, so treating + // "unreadable" as "empty" would erase every existing entry. + if (hasErrorCode(error, "ENOENT")) { + return { envelope: {}, rawEntries: [] }; + } + if (mode === "strict") { + corrupted(`it cannot be read (${getErrorMessage(error)})`); + } + log.warn("Ignoring unreadable plugin registry file", { + file: this.registryFile, + error: getErrorMessage(error), + }); + return { envelope: {}, rawEntries: [] }; + } + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(raw); + } catch (error) { + if (mode === "strict") { + corrupted(`it cannot be parsed (${getErrorMessage(error)})`); + } + log.warn("Ignoring unparseable plugin registry file", { + file: this.registryFile, + error: getErrorMessage(error), + }); + return { envelope: {}, rawEntries: [] }; + } + + if ( + typeof parsedJson !== "object" || + parsedJson === null || + Array.isArray(parsedJson) || + !Array.isArray((parsedJson as { plugins?: unknown }).plugins) + ) { + if (mode === "strict") { + corrupted("expected an object with a 'plugins' array"); + } + log.warn("Ignoring structurally invalid plugin registry file", { + file: this.registryFile, + }); + return { envelope: {}, rawEntries: [] }; + } + + return { + envelope: parsedJson as Record, + rawEntries: (parsedJson as { plugins: unknown[] }).plugins, + }; + } + + /** + * Lenient-on-read: entries this build does not recognize degrade to + * "unmanaged dirs" rather than errors (discovery stays the source of truth + * for what loads; the registry only annotates) — but they stay in the raw + * file. Name validation in the schema doubles as a filesystem-safety gate: + * a traversal name like `..` must never reach targetPathFor. + */ + private parseRegistryEntries(rawEntries: unknown[]): AgentPluginInstallEntry[] { + const entries: AgentPluginInstallEntry[] = []; + for (const rawEntry of rawEntries) { + const parsed = AgentPluginInstallEntrySchema.safeParse(rawEntry); + if (parsed.success) { + entries.push(parsed.data); + } else { + log.debug("Skipping unrecognized managed plugin registry entry (preserved on disk)", { + entry: rawEntry, + error: parsed.error.message, + }); + } + } + return entries; + } + + private async readRegistry(mode: "lenient" | "strict"): Promise { + return this.parseRegistryEntries((await this.readRegistryDocument(mode)).rawEntries); + } + + /** `name` of a raw registry entry, for identity matching during raw rewrites. */ + private rawEntryName(rawEntry: unknown): string | undefined { + if (typeof rawEntry !== "object" || rawEntry === null) { + return undefined; + } + const name = (rawEntry as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; + } + + /** + * Atomic write that THROWS on failure (unlike Config.saveConfig's + * log-and-swallow) so callers can roll back filesystem changes instead of + * reporting success with an unpersisted registry. Takes the RAW envelope + * and entry list so unrecognized top-level fields and entries are written + * back verbatim (only `plugins` is replaced). + */ + private async writeRegistry( + envelope: Record, + rawEntries: unknown[] + ): Promise { + await writeFileAtomic( + this.registryFile, + JSON.stringify({ ...envelope, plugins: rawEntries }, null, 2), + "utf-8" + ); + } + + private assertEnabled(): void { + if (!this.deps.isEnabled()) { + throw new Error("Agent Plugins experiment is not enabled."); + } + } + + private runExclusive(fn: () => Promise): Promise { + const run = this.mutationQueue.then(fn, fn); + this.mutationQueue = run.catch(() => undefined); + return run; + } + + /** + * Lexical install location — the identity `computePluginInstanceId` hashes + * for global plugins. The name grammar excludes `.`/`..`/separators, so a + * malformed registry entry can never resolve outside the container (this + * path is deleted recursively on uninstall). + */ + private targetPathFor(name: string): string { + assert(isValidAgentPluginName(name), `invalid plugin name: ${JSON.stringify(name)}`); + const target = path.join(this.containerDir, name); + assert( + path.dirname(target) === this.containerDir, + "targetPathFor: resolved path must be an immediate child of the container" + ); + return target; + } + + private instanceIdFor(name: string): string { + return computePluginInstanceId(this.targetPathFor(name)); + } + + // --------------------------------------------------------------------- + // Staging helpers + // --------------------------------------------------------------------- + + /** + * Staging lives under ~/.mux (same filesystem as the container) so promote + * is a plain rename, and outside ~/.mux/plugins so a staged clone can never + * be discovered as an installed plugin. + */ + private async createStagingDir(): Promise { + await fsPromises.mkdir(this.stagingRoot, { recursive: true }); + await this.purgeStaleStaging(); + return fsPromises.mkdtemp(path.join(this.stagingRoot, "stage-")); + } + + /** Best-effort reclaim of staging dirs orphaned by crashes. */ + private async purgeStaleStaging(): Promise { + try { + const now = Date.now(); + for (const entry of await fsPromises.readdir(this.stagingRoot)) { + const entryPath = path.join(this.stagingRoot, entry); + try { + const stat = await fsPromises.stat(entryPath); + if (now - stat.mtimeMs > STALE_STAGING_MAX_AGE_MS) { + await fsPromises.rm(entryPath, { recursive: true, force: true }); + } + } catch { + // Entry vanished or is unreadable — skip. + } + } + } catch { + // Missing staging root is fine. + } + } + + private async removeDir(dirPath: string): Promise { + await fsPromises.rm(dirPath, { recursive: true, force: true }); + } + + // --------------------------------------------------------------------- + // Git plumbing + // --------------------------------------------------------------------- + + /** Resolve what a preview/install/update should check out, via `git ls-remote` (no fetch). */ + private async resolveRemoteRef(url: string, ref: string | undefined): Promise { + if (ref !== undefined && isFullCommitSha(ref)) { + return { ref: ref.toLowerCase(), refType: "commit", sha: ref.toLowerCase() }; + } + if (ref === undefined) { + // Remote default branch: `ls-remote --symref HEAD` prints + // ref: refs/heads/\tHEAD + // \tHEAD + const output = await this.lsRemote(url, ["--symref", url, "HEAD"]); + const symrefMatch = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(output); + const shaMatch = /^([0-9a-f]{40})\s+HEAD$/m.exec(output); + if (!symrefMatch || !shaMatch) { + throw new Error(`Could not determine the default branch of ${url}.`); + } + return { ref: symrefMatch[1], refType: "branch", sha: shaMatch[1] }; + } + + if (/^[0-9a-f]{7,39}$/i.test(ref)) { + // A short SHA can't be fetched shallowly and can't be resolved by ls-remote. + throw new Error( + `'${ref}' looks like an abbreviated commit SHA. Use the full 40-character SHA, a branch, or a tag.` + ); + } + + const output = await this.lsRemote(url, [ + url, + `refs/heads/${ref}`, + `refs/tags/${ref}`, + `refs/tags/${ref}^{}`, + ]); + const lines = output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + let branchSha: string | undefined; + let tagSha: string | undefined; + let peeledTagSha: string | undefined; + for (const line of lines) { + const [sha, refName] = line.split(/\s+/); + if (!sha || !refName) continue; + if (refName === `refs/heads/${ref}`) branchSha = sha; + else if (refName === `refs/tags/${ref}^{}`) peeledTagSha = sha; + else if (refName === `refs/tags/${ref}`) tagSha = sha; + } + + if (branchSha !== undefined) { + return { ref, refType: "branch", sha: branchSha }; + } + // Annotated tags list both the tag object and the peeled commit (^{}); + // lockedSha must be the commit so it can be compared against `rev-parse HEAD`. + const resolvedTagSha = peeledTagSha ?? tagSha; + if (resolvedTagSha !== undefined) { + return { ref, refType: "tag", sha: resolvedTagSha }; + } + throw new Error(`Ref '${ref}' was not found on the remote (no matching branch or tag).`); + } + + private async lsRemote(url: string, args: string[]): Promise { + try { + return await runGit(["ls-remote", ...args], { timeoutMs: LS_REMOTE_TIMEOUT_MS }); + } catch (error) { + throw new Error(`Could not reach ${url}: ${getErrorMessage(error)}`); + } + } + + /** Shallow-clone `resolved` into a fresh staging dir; returns { dir, sha } with sha = HEAD. */ + private async cloneResolved( + url: string, + resolved: ResolvedRemoteRef + ): Promise<{ dir: string; sha: string }> { + const dir = await this.createStagingDir(); + try { + if (resolved.refType === "commit") { + await this.fetchExactSha(url, resolved.sha, dir); + } else { + await runGit([ + "clone", + "--depth", + "1", + "--single-branch", + "--branch", + resolved.ref, + "-c", + "advice.detachedHead=false", + url, + dir, + ]); + } + const sha = (await runGit(["-C", dir, "rev-parse", "HEAD"])).trim(); + assert(isFullCommitSha(sha), "cloneResolved: rev-parse HEAD must be a full SHA"); + return { dir, sha }; + } catch (error) { + await this.removeDir(dir); + throw new Error(`Failed to clone ${url}: ${getErrorMessage(error)}`); + } + } + + /** + * Clone exactly `sha` (what the user consented to). Prefers a direct SHA + * fetch (GitHub allows it); falls back to cloning the tracking ref and + * verifying HEAD still matches, so a remote that moved between preview and + * install fails loudly instead of installing unreviewed content. + */ + private async cloneExactSha(source: AgentPluginGitSource, sha: string): Promise { + const dir = await this.createStagingDir(); + try { + try { + await this.fetchExactSha(source.url, sha, dir); + } catch { + if (source.refType === "commit") { + throw new Error(`Could not fetch commit ${sha} from ${source.url}.`); + } + // fetchExactSha left an initialized repo behind; git clone refuses a + // non-empty destination, so reset the staging dir before falling back. + await this.removeDir(dir); + await fsPromises.mkdir(dir, { recursive: true }); + await runGit([ + "clone", + "--depth", + "1", + "--single-branch", + "--branch", + source.ref, + "-c", + "advice.detachedHead=false", + source.url, + dir, + ]); + } + const head = (await runGit(["-C", dir, "rev-parse", "HEAD"])).trim(); + if (head !== sha) { + throw new Error( + `The remote moved since the preview (expected ${sha.slice(0, 12)}, got ${head.slice(0, 12)}). Run the preview again.` + ); + } + return dir; + } catch (error) { + await this.removeDir(dir); + throw error instanceof Error ? error : new Error(getErrorMessage(error)); + } + } + + private async fetchExactSha(url: string, sha: string, dir: string): Promise { + await runGit(["init", "--quiet", dir]); + await runGit(["-C", dir, "remote", "add", "origin", url]); + await runGit(["-C", dir, "fetch", "--depth", "1", "origin", sha]); + await runGit([ + "-C", + dir, + "-c", + "advice.detachedHead=false", + "checkout", + "--quiet", + "FETCH_HEAD", + ]); + } + + // --------------------------------------------------------------------- + // Staged-clone validation + preview assembly + // --------------------------------------------------------------------- + + /** + * Run the exact runtime validation (manifest + component discovery) against + * a staged clone. Throws user-facing errors for non-plugins, including a + * clear message for Claude Code plugin/marketplace repos (explicit non-goal). + */ + private async validateStagedClone(stagedDir: string): Promise<{ + plugin: AgentPluginInfo; + warnings: string[]; + }> { + const hasManifest = await pathExists(path.join(stagedDir, "plugin.json")); + if (!hasManifest) { + if ( + (await pathExists(path.join(stagedDir, ".claude-plugin", "plugin.json"))) || + (await pathExists(path.join(stagedDir, ".claude-plugin", "marketplace.json"))) + ) { + throw new Error( + "This repository is a Claude Code plugin or marketplace (found .claude-plugin/). Mux implements the vendor-neutral Agent Plugins 1.0.0 format and cannot install Claude Code collections." + ); + } + throw new Error( + "No plugin.json found at the repository root. The repo is not an Agent Plugin — if the plugin lives in a subdirectory, monorepo subpath installs land in v2." + ); + } + + const { plugin, diagnostics } = await discoverAgentPluginAt({ + pluginDir: stagedDir, + scope: "global", + }); + if (!plugin) { + const reasons = diagnostics.map((d) => d.message); + throw new Error( + reasons.length > 0 ? `Invalid plugin: ${reasons.join("; ")}` : "Invalid plugin manifest." + ); + } + return { plugin, warnings: diagnostics.map((d) => d.message) }; + } + + private async collectSkills( + plugin: Pick, + warnings: string[] + ): Promise { + const skillsDir = plugin.skillsDir; + if (skillsDir === undefined) { + return []; + } + const skills: AgentPluginPreviewSkill[] = []; + let entries: string[] = []; + try { + // Include symlinked skill dirs, matching runtime discovery + // (listSkillDirectoriesFromLocalFs): a symlinked skill activates after + // install, so it MUST appear in the consent preview. + entries = (await fsPromises.readdir(skillsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + } catch { + return []; + } + for (const dirName of entries) { + const skillPath = path.join(skillsDir, dirName, "SKILL.md"); + // Spec §4.1 containment anchored at the plugin root, mirroring runtime + // component checks: a symlink escaping the plugin is surfaced as a + // warning instead of silently ignored. + let containedSkillPath: string; + try { + // allowMissing (matching runtime assertSkillDirValid): resolve through + // the symlinked dir even when SKILL.md is absent, so an escaping + // symlink fails containment instead of hiding behind ENOENT. + containedSkillPath = await ensurePathContained(plugin.rootPath, skillPath, { + allowMissing: true, + }); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + warnings.push(`skills/${dirName}: resolves outside the plugin root; it will not load`); + } + // ENOENT (unresolvable path) → not a skill dir; skip silently. + continue; + } + let stat; + try { + stat = await fsPromises.stat(containedSkillPath); + } catch { + continue; + } + if (!stat.isFile()) continue; + try { + const content = await fsPromises.readFile(containedSkillPath, "utf8"); + const parsed = parseSkillMarkdown({ content, byteSize: stat.size }); + skills.push({ + name: parsed.frontmatter.name, + ...(parsed.frontmatter.description !== undefined + ? { description: parsed.frontmatter.description } + : {}), + }); + } catch (error) { + warnings.push(`skills/${dirName}: ${getErrorMessage(error)}`); + } + } + return skills; + } + + /** + * Normalize the staged plugin's mcp.json into the preview list. Uses the + * FINAL instance identity so `PLUGIN_DATA` paths shown to the user match + * what will run; staged-root path fragments are rewritten to the final + * install path for readability. + */ + private async collectMcpServers( + plugin: AgentPluginInfo, + finalTargetPath: string, + instanceId: string, + warnings: string[] + ): Promise { + if (plugin.mcpConfigPath === undefined) { + return []; + } + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { + muxHome: this.config.rootDir, + instanceId, + }); + warnings.push(...diagnostics.map((d) => d.message)); + + const rewrite = (value: string): string => value.split(plugin.rootPath).join(finalTargetPath); + + const result: AgentPluginPreviewMcpServer[] = []; + for (const info of Object.values(servers)) { + assert(info.plugin !== undefined, "plugin server info must carry provenance"); + if (info.transport === "stdio") { + const commandLine = [info.command, ...(info.args ?? [])].map(rewrite).join(" "); + const envKeys = Object.keys(info.env ?? {}).filter( + (key) => key !== "PLUGIN_ROOT" && key !== "PLUGIN_DATA" + ); + result.push({ + serverName: info.plugin.serverName, + transport: "stdio", + summary: envKeys.length > 0 ? `${commandLine} (env: ${envKeys.join(", ")})` : commandLine, + }); + } else { + result.push({ + serverName: info.plugin.serverName, + transport: info.transport === "http" ? "http" : "sse", + summary: info.url, + }); + } + } + return result.sort((a, b) => a.serverName.localeCompare(b.serverName)); + } + + // --------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------- + + /** + * Stage + validate an install without writing anything permanent. The + * staged clone is deleted before returning (stateless preview): install + * re-fetches the exact consented SHA, so cancelling leaves no state. + */ + async preview(args: { + input: string; + ref?: string | undefined; + subpath?: string | undefined; + }): Promise { + this.assertEnabled(); + + const parsed = parseAgentPluginSourceInput(args.input); + const explicitRef = args.ref?.trim() ?? ""; + if (explicitRef.length > 0 && parsed.ref !== undefined && parsed.ref !== explicitRef) { + throw new Error( + `Conflicting refs: '@${parsed.ref}' in the source and '${explicitRef}' in the ref field.` + ); + } + const ref = parsed.ref ?? (explicitRef.length > 0 ? explicitRef : undefined); + const subpath = parsed.subpath ?? (args.subpath?.trim() ? args.subpath.trim() : undefined); + if (subpath !== undefined) { + // Approved v1 scope: the descriptor grammar knows subpaths, installs don't. + throw new Error( + "Monorepo subpath installs land in v2. Point at a repo whose root is the plugin." + ); + } + + const resolved = await this.resolveRemoteRef(parsed.url, ref); + const { dir: stagedDir, sha } = await this.cloneResolved(parsed.url, resolved); + try { + const { plugin, warnings } = await this.validateStagedClone(stagedDir); + const targetPath = this.targetPathFor(plugin.name); + await this.assertNoCollision(plugin.name); + + const skills = await this.collectSkills(plugin, warnings); + const mcpServers = await this.collectMcpServers( + plugin, + targetPath, + this.instanceIdFor(plugin.name), + warnings + ); + + if (resolved.refType === "tag" && sha !== resolved.sha) { + warnings.push( + `Tag '${resolved.ref}' moved between resolution and clone — installing ${sha.slice(0, 12)}.` + ); + } + + const source: AgentPluginGitSource = { + type: "git", + url: parsed.url, + ref: resolved.ref, + refType: resolved.refType, + }; + return { + source, + lockedSha: sha, + manifest: manifestSummary(plugin.manifest), + skills, + mcpServers, + warnings, + targetPath: shortenHome(targetPath), + }; + } finally { + await this.removeDir(stagedDir); + } + } + + private async assertNoCollision(name: string): Promise { + // Strict: a corrupted registry must fail installs up front (with the + // repair message) instead of letting a later strict read fail mid-flow. + // Collide on RAW entry names, not just parsed ones: an entry this build + // cannot parse (written by a newer build) still owns its name — the + // install rewrite would otherwise filter it out and replace it. + const { rawEntries } = await this.readRegistryDocument("strict"); + if (rawEntries.some((rawEntry) => this.rawEntryName(rawEntry) === name)) { + throw new Error(`A managed plugin named '${name}' is already installed. Uninstall it first.`); + } + if (await pathExists(this.targetPathFor(name))) { + // Never overwrite: an unmanaged dir may hold local work. + throw new Error( + `${shortenHome(this.targetPathFor(name))} already exists. Remove the directory first — the installer never overwrites.` + ); + } + } + + /** Fetch the consented SHA, validate again, promote into the container, and record the registry entry. */ + async install(args: { + source: AgentPluginGitSource; + expectedSha: string; + }): Promise { + this.assertEnabled(); + assert(isFullCommitSha(args.expectedSha), "install: expectedSha must be a full commit SHA"); + if (args.source.subpath !== undefined) { + throw new Error("Monorepo subpath installs land in v2."); + } + + return this.runExclusive(async () => { + const stagedDir = await this.cloneExactSha(args.source, args.expectedSha); + try { + const { plugin } = await this.validateStagedClone(stagedDir); + const name = plugin.name; + await this.assertNoCollision(name); + await this.assertNoPendingOverridePrune(name); + const targetPath = this.targetPathFor(name); + + // The installed tree is a plain content snapshot: the registry holds + // all provenance, and updates replace the directory wholesale, so a + // .git dir would only invite in-place edits that updates discard. + await this.removeDir(path.join(stagedDir, ".git")); + + await fsPromises.mkdir(this.containerDir, { recursive: true }); + await fsPromises.rename(stagedDir, targetPath); + + const entry: AgentPluginInstallEntry = { + name, + scope: "global", + source: args.source, + lockedSha: args.expectedSha, + installedAt: new Date().toISOString(), + manifest: { + ...(plugin.manifest.version !== undefined ? { version: plugin.manifest.version } : {}), + ...(plugin.manifest.description !== undefined + ? { description: plugin.manifest.description } + : {}), + }, + }; + try { + const { envelope, rawEntries } = await this.readRegistryDocument("strict"); + await this.writeRegistry(envelope, [ + ...rawEntries.filter((rawEntry) => this.rawEntryName(rawEntry) !== name), + entry, + ]); + } catch (error) { + // No partial state: a promote without a registry entry would look + // like an unmanaged dir and block reinstall. + await this.removeDir(targetPath); + throw new Error(`Failed to persist the plugin registry: ${getErrorMessage(error)}`); + } + log.info(`Installed agent plugin '${name}' at ${args.expectedSha.slice(0, 12)}`); + return entry; + } finally { + await this.removeDir(stagedDir); + } + }); + } + + /** Managed registry entries merged with unmanaged plugins found by global discovery. */ + async list(): Promise { + this.assertEnabled(); + + // Section open is the natural retry moment for override-prune tombstones + // left by uninstalls whose workspaces were temporarily unreachable. + await this.retryPendingOverridePrunes().catch((error: unknown) => { + log.warn("Failed to retry pending override prunes", { error: getErrorMessage(error) }); + }); + + const registry = await this.readRegistry("lenient"); + const containers: AgentPluginContainer[] = [ + { path: this.containerDir, scope: "global" }, + { path: path.join(os.homedir(), ".agents", "plugins"), scope: "global" }, + ]; + const { plugins } = await discoverAgentPlugins(containers); + + const items: AgentPluginListItem[] = []; + const managedByName = new Map(registry.map((entry) => [entry.name, entry])); + + for (const plugin of plugins) { + const isManagedLocation = + plugin.containerPath === this.containerDir && managedByName.has(plugin.dirName); + const entry = isManagedLocation ? managedByName.get(plugin.dirName) : undefined; + if (entry) { + managedByName.delete(plugin.dirName); + } + + const warnings: string[] = []; + const skillCount = (await this.collectSkills(plugin, warnings)).length; + let mcpServerCount = 0; + if (plugin.mcpConfigPath !== undefined) { + try { + const { servers } = await loadPluginMcpServers(plugin, { + muxHome: this.config.rootDir, + instanceId: computePluginInstanceId(path.join(plugin.containerPath, plugin.dirName)), + }); + mcpServerCount = Object.keys(servers).length; + } catch (error) { + log.warn(`Agent plugin ${plugin.rootPath}: failed to count MCP servers`, { error }); + } + } + + // Managed rows keep their REGISTRY identity: update/uninstall look + // entries up by this name, so a locally edited/corrupted manifest name + // must not make the row unrepairable from Settings. The drift is still + // surfaced in the description. + const manifestNameDrift = + entry !== undefined && plugin.name !== entry.name + ? `plugin.json names itself '${plugin.name}' — the installed name '${entry.name}' stays authoritative.` + : undefined; + const description = manifestNameDrift ?? plugin.manifest.description; + items.push({ + name: entry?.name ?? plugin.name, + managed: entry !== undefined, + present: true, + location: shortenHome(path.join(plugin.containerPath, plugin.dirName)), + ...(plugin.manifest.version !== undefined ? { version: plugin.manifest.version } : {}), + ...(description !== undefined ? { description } : {}), + ...(entry !== undefined + ? { + source: entry.source, + lockedSha: entry.lockedSha, + installedAt: entry.installedAt, + ...(entry.updatedAt !== undefined ? { updatedAt: entry.updatedAt } : {}), + } + : {}), + skillCount, + mcpServerCount, + }); + } + + // Registry entries whose directory vanished (self-heal display; uninstall still works). + for (const entry of managedByName.values()) { + items.push({ + name: entry.name, + managed: true, + present: false, + location: shortenHome(this.targetPathFor(entry.name)), + ...(entry.manifest?.version !== undefined ? { version: entry.manifest.version } : {}), + ...(entry.manifest?.description !== undefined + ? { description: entry.manifest.description } + : {}), + source: entry.source, + lockedSha: entry.lockedSha, + installedAt: entry.installedAt, + ...(entry.updatedAt !== undefined ? { updatedAt: entry.updatedAt } : {}), + skillCount: 0, + mcpServerCount: 0, + }); + } + + return items.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Uninstall: delete dir + registry entry + prune that plugin's per-workspace + * MCP overrides (reinstall re-attaches the same instanceId, so stale + * overrides would silently re-enable servers — violating default-disabled). + * PLUGIN_DATA is preserved unless `deletePluginData` is set. + */ + async uninstall(args: { name: string; deletePluginData: boolean }): Promise { + this.assertEnabled(); + + return this.runExclusive(async () => { + const { envelope, rawEntries: rawRegistry } = await this.readRegistryDocument("strict"); + const registry = this.parseRegistryEntries(rawRegistry); + const entry = registry.find((e) => e.name === args.name); + if (!entry) { + throw new Error(`'${args.name}' is not a managed plugin install.`); + } + + const targetPath = this.targetPathFor(entry.name); + const instanceId = this.instanceIdFor(entry.name); + const serverKeyPrefix = buildPluginServerKey(instanceId, ""); + + // Enumerate pruning targets BEFORE committing anything: if this fails, + // the uninstall aborts with the install fully intact (retryable from + // Settings) instead of leaving stale overrides behind post-commit. + const workspaceIdsToPrune = await this.listWorkspaceIdsForOverridePruning(); + + // Stop running servers before deleting the tree out from under them. + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + + // Stage the tree — and, when requested, the plugin-data dir — out + // BEFORE touching the registry so every step can fail without partial + // state: a failed rename (e.g. a locked file on Windows) leaves the + // install fully intact, and a failed registry write renames everything + // back. Deleting the staged dirs afterwards is best-effort — they sit + // under the staging root, where stale-dir reclamation cleans up + // leftovers, so a locked dir cannot strand the user in a state where + // the Settings row is gone but their requested cleanup never happens. + await fsPromises.mkdir(this.stagingRoot, { recursive: true }); + const trashDir = path.join(this.stagingRoot, `trash-${Date.now()}-${entry.name}`); + let stagedTree = false; + try { + await fsPromises.rename(targetPath, trashDir); + stagedTree = true; + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + throw new Error(`Failed to remove the plugin directory: ${getErrorMessage(error)}`); + } + // Missing tree (present:false row): registry-only uninstall. + } + + const restoreTree = async (context: string): Promise => { + if (!stagedTree) { + return; + } + await fsPromises.rename(trashDir, targetPath).catch((rollbackError: unknown) => { + log.error(`Failed to restore plugin dir after ${context}`, { + targetPath, + rollbackError, + }); + }); + }; + + const dataPath = getPluginDataPath(this.config.rootDir, instanceId); + const dataTrashDir = path.join(this.stagingRoot, `trash-data-${Date.now()}-${entry.name}`); + let stagedData = false; + if (args.deletePluginData) { + try { + await fsPromises.rename(dataPath, dataTrashDir); + stagedData = true; + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + // Fail BEFORE the registry commit so the row stays and the user + // can retry the requested cleanup. + await restoreTree("failed plugin-data staging"); + throw new Error(`Failed to remove the plugin data: ${getErrorMessage(error)}`); + } + // No data dir: nothing to delete. + } + } + + // The commit write carries a PESSIMISTIC tombstone for every workspace + // that needs pruning: if a prune later fails — or the best-effort + // shrink write below fails — the durable record already exists. + // Over-blocking a reinstall until cleanup is confirmed is safe; + // silently losing the record (stale enabledServers reactivating a + // reinstalled server) is not. + const commitEnvelope = { ...envelope }; + const pendingForCommit = this.updateRawPendingPrunes( + this.rawPendingPrunes(envelope), + serverKeyPrefix, + workspaceIdsToPrune + ); + if (pendingForCommit.length > 0) { + commitEnvelope.pendingOverridePrunes = pendingForCommit; + } else { + delete commitEnvelope.pendingOverridePrunes; + } + try { + await this.writeRegistry( + commitEnvelope, + rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== entry.name) + ); + } catch (error) { + await restoreTree("failed registry write"); + if (stagedData) { + await fsPromises.rename(dataTrashDir, dataPath).catch((rollbackError: unknown) => { + log.error("Failed to restore plugin data after failed registry write", { + dataPath, + rollbackError, + }); + }); + } + throw new Error(`Failed to persist the plugin registry: ${getErrorMessage(error)}`); + } + + // The uninstall is committed; everything below is best-effort cleanup + // that must not abort the remaining steps. + if (stagedTree) { + await this.removeDir(trashDir).catch((error: unknown) => { + log.warn("Failed to delete uninstalled plugin tree; leaving it for staging reclamation", { + trashDir, + error: getErrorMessage(error), + }); + }); + } + if (stagedData) { + await this.removeDir(dataTrashDir).catch((error: unknown) => { + log.warn("Failed to delete plugin data; leaving it for staging reclamation", { + dataTrashDir, + error: getErrorMessage(error), + }); + }); + } + + // Re-invalidate AFTER the tree is gone: a getToolsForWorkspace call + // that started right after the pre-rename stop snapshots the new epoch, + // and can still have discovered the plugin before the rename — its + // freshly started server would otherwise publish validly and keep + // running from the removed tree. This runs BEFORE override pruning so + // pruning problems cannot skip the correctness-critical invalidation. + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + + // Per-workspace failures are caught inside; the failure-prone + // enumeration already happened pre-commit and the pessimistic + // tombstone is already durable (commit write above). Shrink it to what + // actually failed — best-effort: a failed shrink leaves the over-broad + // tombstone, which self-heals on the next retry (section open or the + // reinstall gate). + const failedPruneIds = await this.pruneWorkspaceOverrides( + serverKeyPrefix, + workspaceIdsToPrune + ); + if (workspaceIdsToPrune.length > 0) { + // STRICT re-read for the shrink: a lenient read degrading a transient + // I/O error or corruption to an empty document would make this write + // rewrite plugins.json with an empty plugin list, orphaning every + // other managed install. On any failure the pessimistic tombstone + // from the commit write simply stays (safe, self-heals on retry). + try { + const { envelope: envelopeAfter, rawEntries: entriesAfter } = + await this.readRegistryDocument("strict"); + const pendingAfter = this.updateRawPendingPrunes( + this.rawPendingPrunes(envelopeAfter), + serverKeyPrefix, + failedPruneIds + ); + await this.writePendingOverridePrunes(envelopeAfter, entriesAfter, pendingAfter); + } catch (error) { + log.warn("Failed to shrink pending override prune tombstone (kept pessimistic)", { + serverKeyPrefix, + failedPruneIds, + error: getErrorMessage(error), + }); + } + } + + log.info(`Uninstalled agent plugin '${entry.name}'`); + }); + } + + /** + * Enumerate the local/worktree workspace IDs whose MCP overrides an + * uninstall must prune. Called BEFORE the uninstall commits anything: + * enumeration is the only pruning step that can fail wholesale (outside + * the per-workspace catch), and a post-commit failure would leave stale + * overrides with no Settings row left to retry from — a reinstall reuses + * the same instance ID and would silently re-enable those servers. + * Remote runtimes are skipped — they never see plugin servers + * (resolveAgentPluginsMcpContext returns null off-host). + */ + private async listWorkspaceIdsForOverridePruning(): Promise { + if (!this.deps.workspaceMcpOverridesService) { + return []; + } + const allMetadata = await this.config.getAllWorkspaceMetadata(); + return allMetadata + .filter((metadata) => { + const runtimeType = metadata.runtimeConfig.type; + return runtimeType === "local" || runtimeType === "worktree"; + }) + .map((metadata) => metadata.id); + } + + /** + * Remove `plugin::*` keys from the given workspaces' MCP + * overrides. Best-effort per workspace: a missing checkout must not block + * uninstall. Returns the workspace IDs whose prune FAILED so callers can + * persist a retryable tombstone — silently discarding a failure would let + * a reinstall (same instance ID) pick up the stale override and re-enable + * the server without consent. + */ + private async pruneWorkspaceOverrides( + serverKeyPrefix: string, + workspaceIds: string[] + ): Promise { + const overridesService = this.deps.workspaceMcpOverridesService; + if (!overridesService) { + return []; + } + // A concurrent Workspace MCP dialog save can land between our read and + // write; expectedRevision detects that, and we re-read + re-filter. + const MAX_CAS_ATTEMPTS = 3; + const failedWorkspaceIds: string[] = []; + for (const workspaceId of workspaceIds) { + try { + for (let attempt = 1; ; attempt++) { + const { overrides, revision } = + await overridesService.getOverridesForWorkspace(workspaceId); + const dropKey = (key: string) => key.startsWith(serverKeyPrefix); + const enabledServers = overrides.enabledServers?.filter((key) => !dropKey(key)); + const disabledServers = overrides.disabledServers?.filter((key) => !dropKey(key)); + const toolAllowlist = overrides.toolAllowlist + ? Object.fromEntries( + Object.entries(overrides.toolAllowlist).filter(([key]) => !dropKey(key)) + ) + : undefined; + + const changed = + (overrides.enabledServers?.length ?? 0) !== (enabledServers?.length ?? 0) || + (overrides.disabledServers?.length ?? 0) !== (disabledServers?.length ?? 0) || + Object.keys(overrides.toolAllowlist ?? {}).length !== + Object.keys(toolAllowlist ?? {}).length; + if (!changed) { + break; + } + try { + await overridesService.setOverridesForWorkspace( + workspaceId, + { + ...(enabledServers !== undefined ? { enabledServers } : {}), + ...(disabledServers !== undefined ? { disabledServers } : {}), + ...(toolAllowlist !== undefined ? { toolAllowlist } : {}), + }, + { expectedRevision: revision } + ); + break; + } catch (error) { + if (error instanceof WorkspaceMcpOverridesConflictError && attempt < MAX_CAS_ATTEMPTS) { + continue; + } + throw error; + } + } + } catch (error) { + failedWorkspaceIds.push(workspaceId); + log.warn("Failed to prune plugin MCP overrides for workspace", { + workspaceId, + error: getErrorMessage(error), + }); + } + } + return failedWorkspaceIds; + } + + /** + * Pending override prunes ("tombstones") persisted in the registry + * envelope under `pendingOverridePrunes`: uninstalls whose per-workspace + * override cleanup failed (checkout temporarily unavailable, unwritable + * override file). They are retried on section open (list) and gate a + * reinstall of the same instance ID, so a stale `enabledServers` key can + * never silently re-enable a reinstalled plugin's server. + * + * Rewrites operate on the RAW item list, mirroring the registry-entry + * rules: items this build cannot parse (a newer release's tombstone + * variant) pass through untouched, and recognized items keep their unknown + * fields when their `workspaceIds` shrink. + */ + private isRecognizedPrune( + item: unknown + ): item is { prefix: string; workspaceIds: string[] } & Record { + if (typeof item !== "object" || item === null) { + return false; + } + const prefix = (item as { prefix?: unknown }).prefix; + const workspaceIds = (item as { workspaceIds?: unknown }).workspaceIds; + return ( + typeof prefix === "string" && + prefix.length > 0 && + Array.isArray(workspaceIds) && + workspaceIds.every((id): id is string => typeof id === "string") + ); + } + + /** The raw `pendingOverridePrunes` array as stored (unknown variants included). */ + private rawPendingPrunes(envelope: Record): unknown[] { + const raw = envelope.pendingOverridePrunes; + return Array.isArray(raw) ? raw : []; + } + + /** Recognized tombstones only (for matching/retrying). */ + private parsePendingOverridePrunes( + envelope: Record + ): Array<{ prefix: string; workspaceIds: string[] }> { + return this.rawPendingPrunes(envelope) + .filter((item) => this.isRecognizedPrune(item)) + .map((item) => ({ prefix: item.prefix, workspaceIds: item.workspaceIds })); + } + + /** + * Set this build's tombstone for `prefix` within the raw item list: + * removes the recognized item for that prefix (merging its unknown fields + * into the replacement) and appends the new one when `workspaceIds` is + * non-empty. Unrecognized items are preserved verbatim. + */ + private updateRawPendingPrunes( + rawPending: unknown[], + prefix: string, + workspaceIds: string[] + ): unknown[] { + const existing = rawPending.find( + (item) => this.isRecognizedPrune(item) && item.prefix === prefix + ); + const next = rawPending.filter( + (item) => !(this.isRecognizedPrune(item) && item.prefix === prefix) + ); + if (workspaceIds.length > 0) { + next.push({ + ...((existing as Record | undefined) ?? {}), + prefix, + workspaceIds, + }); + } + return next; + } + + /** Persist the raw tombstone list into the envelope (removing the key when empty). */ + private async writePendingOverridePrunes( + envelope: Record, + rawEntries: unknown[], + rawPending: unknown[] + ): Promise { + const nextEnvelope = { ...envelope }; + if (rawPending.length > 0) { + nextEnvelope.pendingOverridePrunes = rawPending; + } else { + delete nextEnvelope.pendingOverridePrunes; + } + await this.writeRegistry(nextEnvelope, rawEntries); + } + + /** + * Retry one tombstone's pruning. Workspaces that no longer exist in the + * config are dropped first — a deleted workspace's overrides can never + * reactivate anything, so keeping its ID would block reinstall forever. + * Returns the IDs that still need pruning (existing workspaces whose + * prune failed, or everything when metadata enumeration itself failed). + */ + private async retryPrune(prune: { prefix: string; workspaceIds: string[] }): Promise { + let liveWorkspaceIds = prune.workspaceIds; + try { + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const knownIds = new Set(allMetadata.map((metadata) => metadata.id)); + liveWorkspaceIds = prune.workspaceIds.filter((workspaceId) => knownIds.has(workspaceId)); + } catch (error) { + // Enumeration failed: keep the full list (over-blocking is safe). + log.warn("Failed to reconcile pending override prune against workspaces", { + error: getErrorMessage(error), + }); + } + return this.pruneWorkspaceOverrides(prune.prefix, liveWorkspaceIds); + } + + /** + * Reinstall gate: a plugin name maps to the same instance ID, so a pending + * prune for its prefix means stale workspace overrides could re-enable the + * reinstalled plugin's servers without consent. Retry the prune now; only + * a fully successful cleanup unblocks the install. Runs under the caller's + * exclusive mutation lock (install's runExclusive). + */ + private async assertNoPendingOverridePrune(name: string): Promise { + const serverKeyPrefix = buildPluginServerKey(this.instanceIdFor(name), ""); + const { envelope, rawEntries } = await this.readRegistryDocument("strict"); + const pending = this.parsePendingOverridePrunes(envelope); + const match = pending.find((prune) => prune.prefix === serverKeyPrefix); + if (!match) { + return; + } + + const failed = await this.retryPrune(match); + const remaining = this.updateRawPendingPrunes( + this.rawPendingPrunes(envelope), + serverKeyPrefix, + failed + ); + await this.writePendingOverridePrunes(envelope, rawEntries, remaining); + if (failed.length > 0) { + throw new Error( + `A previous uninstall of '${name}' could not clean up its workspace MCP overrides yet (workspaces: ${failed.join(", ")}). Retry once those workspaces are accessible.` + ); + } + } + + /** + * Retry all pending override prunes; persists progress. Best-effort: runs + * on section open (list), so transient failures self-heal the next time + * the affected checkout is reachable. The read-modify-write runs under the + * exclusive mutation queue — an install/update/uninstall committing while + * the workspace I/O is in flight would otherwise be clobbered by this + * write's stale registry snapshot. + */ + private async retryPendingOverridePrunes(): Promise { + return this.runExclusive(async () => { + const { envelope, rawEntries } = await this.readRegistryDocument("lenient"); + const pending = this.parsePendingOverridePrunes(envelope); + if (pending.length === 0) { + return; + } + + let rawPending = this.rawPendingPrunes(envelope); + let progressed = false; + for (const prune of pending) { + const failed = await this.retryPrune(prune); + if (failed.length !== prune.workspaceIds.length) { + progressed = true; + rawPending = this.updateRawPendingPrunes(rawPending, prune.prefix, failed); + } + } + + if (progressed) { + await this.writePendingOverridePrunes(envelope, rawEntries, rawPending).catch( + (error: unknown) => { + log.warn("Failed to persist pending override prune progress", { + error: getErrorMessage(error), + }); + } + ); + } + }); + } + + /** + * Compare each managed entry's tracking ref against `lockedSha` via + * `git ls-remote` (no fetch). Runs on Settings-section open and on the + * explicit "Check for updates" action only — no background timers. + */ + async checkUpdates(): Promise { + this.assertEnabled(); + + const registry = await this.readRegistry("lenient"); + return Promise.all( + registry.map(async (entry): Promise => { + if (entry.source.refType === "commit") { + return { name: entry.name, status: "pinned" }; + } + try { + const resolved = await this.resolveRemoteRef(entry.source.url, entry.source.ref); + if (resolved.refType !== entry.source.refType) { + // e.g. a tracked branch was deleted and a tag with the same name exists now. + return { + name: entry.name, + status: "error", + message: `Tracked ${entry.source.refType} '${entry.source.ref}' is now a ${resolved.refType} on the remote.`, + }; + } + if (resolved.sha === entry.lockedSha) { + return { name: entry.name, status: "up-to-date" }; + } + return { + name: entry.name, + // A moved tag is suspicious (tags are supposed to be immutable) — warn, don't just offer. + status: entry.source.refType === "tag" ? "tag-moved" : "update-available", + remoteSha: resolved.sha, + }; + } catch (error) { + return { name: entry.name, status: "error", message: getErrorMessage(error) }; + } + }) + ); + } + + /** + * Apply an update: temp clone at the new SHA → re-validate → wholesale + * directory swap (rename-old → promote-new → delete-old) → bump lockedSha → + * recycle that plugin's MCP servers. Never an in-place `git pull`; local + * edits to the managed dir are discarded. + */ + async update(args: { name: string }): Promise { + this.assertEnabled(); + + return this.runExclusive(async () => { + const { envelope, rawEntries: rawRegistry } = await this.readRegistryDocument("strict"); + const registry = this.parseRegistryEntries(rawRegistry); + const entry = registry.find((e) => e.name === args.name); + if (!entry) { + throw new Error(`'${args.name}' is not a managed plugin install.`); + } + if (entry.source.refType === "commit") { + throw new Error( + `'${entry.name}' is pinned to commit ${entry.lockedSha.slice(0, 12)}; uninstall and reinstall to change it.` + ); + } + + const resolved = await this.resolveRemoteRef(entry.source.url, entry.source.ref); + if (resolved.refType !== entry.source.refType) { + // The ref name now resolves to a different kind on the remote (e.g. a + // tracked branch was deleted and a tag of the same name exists). The + // update check flags this as an error; a stale Update click must not + // silently install content from a different ref kind while the + // registry keeps claiming the old one. + throw new Error( + `Tracked ${entry.source.refType} '${entry.source.ref}' is now a ${resolved.refType} on the remote. Uninstall and reinstall to track it.` + ); + } + if (resolved.sha === entry.lockedSha) { + return entry; // Already current. + } + + const stagedDir = await this.cloneExactSha(entry.source, resolved.sha); + try { + const { plugin } = await this.validateStagedClone(stagedDir); + if (plugin.name !== entry.name) { + // Container-entry names are identity (instanceId, PLUGIN_DATA, + // workspace overrides hash the path) — never rename on update. + throw new Error( + `The plugin renamed itself upstream ('${entry.name}' → '${plugin.name}'). Uninstall and reinstall to adopt the new name.` + ); + } + await this.removeDir(path.join(stagedDir, ".git")); + + const targetPath = this.targetPathFor(entry.name); + const serverKeyPrefix = buildPluginServerKey(this.instanceIdFor(entry.name), ""); + const trashDir = path.join(this.stagingRoot, `trash-${Date.now()}-${entry.name}`); + const hadOldTree = await pathExists(targetPath); + + // Stop this plugin's running MCP servers BEFORE the old tree moves: + // a live server can lose its files mid-swap on POSIX, and open + // handles can make the rename itself fail on Windows. + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + + if (hadOldTree) { + await fsPromises.rename(targetPath, trashDir); + } + try { + await fsPromises.mkdir(this.containerDir, { recursive: true }); + await fsPromises.rename(stagedDir, targetPath); + } catch (error) { + if (hadOldTree) { + // Roll the old tree back so a failed swap never leaves the plugin missing. + await fsPromises.rename(trashDir, targetPath).catch((rollbackError: unknown) => { + log.error("Failed to roll back plugin dir after failed update swap", { + targetPath, + rollbackError, + }); + }); + } + throw error; + } + if (hadOldTree) { + // Best-effort: the trash dir sits under the staging root, where + // stale-dir reclamation cleans up leftovers. + await this.removeDir(trashDir).catch((error: unknown) => { + log.warn("Failed to delete replaced plugin tree; leaving it for staging reclamation", { + trashDir, + error: getErrorMessage(error), + }); + }); + } + + const updated: AgentPluginInstallEntry = { + ...entry, + lockedSha: resolved.sha, + updatedAt: new Date().toISOString(), + manifest: { + ...(plugin.manifest.version !== undefined ? { version: plugin.manifest.version } : {}), + ...(plugin.manifest.description !== undefined + ? { description: plugin.manifest.description } + : {}), + }, + }; + // The new tree is already promoted; a failed write surfaces as an + // error and the stale lockedSha keeps the update badge visible, so + // retrying the update self-heals the mismatch. + // + // Patch ONLY the fields this update owns (lockedSha, updatedAt, and + // the manifest's version/description) into the RAW entry: spreading + // the Zod-parsed entry would replace `source`/`manifest` wholesale + // with their stripped counterparts, deleting nested metadata a newer + // build may have stored there (breaking downgrade round-trips). + try { + await this.writeRegistry( + envelope, + rawRegistry.map((rawEntry) => { + if (this.rawEntryName(rawEntry) !== entry.name) { + return rawEntry; + } + const rawRecord = rawEntry as Record; + const rawManifest = + typeof rawRecord.manifest === "object" && + rawRecord.manifest !== null && + !Array.isArray(rawRecord.manifest) + ? (rawRecord.manifest as Record) + : {}; + // version/description are owned by the update (they mirror the + // newly installed plugin.json), so stale values are dropped and + // fresh ones written; unknown manifest keys pass through. + const { + version: _staleVersion, + description: _staleDescription, + ...preservedManifest + } = rawManifest; + return { + ...rawRecord, + lockedSha: updated.lockedSha, + updatedAt: updated.updatedAt, + manifest: { ...preservedManifest, ...updated.manifest }, + }; + }) + ); + } finally { + // Recycle post-promote even when the registry write fails: the tree + // already swapped, so (1) content changed behind a stable path — + // possibly an unchanged stdio command line — which the config + // signature cannot see, and (2) a concurrent getToolsForWorkspace + // that began after the pre-swap invalidation but discovered the + // plugin before the rename may have published a server from the + // replaced tree. Servers restart on next use; default-disabled + // state and workspace overrides are untouched (identity is the + // lexical path). + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + } + + log.info( + `Updated agent plugin '${entry.name}' ${entry.lockedSha.slice(0, 12)} → ${resolved.sha.slice(0, 12)}` + ); + return updated; + } finally { + await this.removeDir(stagedDir); + } + }); + } +} diff --git a/src/node/services/agentPlugins/manifest.ts b/src/node/services/agentPlugins/manifest.ts index a42e09f195..0ca3a2f786 100644 --- a/src/node/services/agentPlugins/manifest.ts +++ b/src/node/services/agentPlugins/manifest.ts @@ -21,14 +21,10 @@ 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; +// Name grammar shared with the install registry schema (see the module's doc comment). +import { isValidAgentPluginName } from "@/common/utils/agentPluginName"; -/** 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 { isValidAgentPluginName }; export interface AgentPluginAuthor { name?: string; diff --git a/src/node/services/agentPlugins/sourceInput.test.ts b/src/node/services/agentPlugins/sourceInput.test.ts new file mode 100644 index 0000000000..b68e12e2f2 --- /dev/null +++ b/src/node/services/agentPlugins/sourceInput.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { isFullCommitSha, parseAgentPluginSourceInput } from "./sourceInput"; + +describe("parseAgentPluginSourceInput", () => { + // Shorthand expansion depends on SSH-agent presence; pin it for determinism. + let savedSshAuthSock: string | undefined; + beforeEach(() => { + savedSshAuthSock = process.env.SSH_AUTH_SOCK; + delete process.env.SSH_AUTH_SOCK; + }); + afterEach(() => { + if (savedSshAuthSock === undefined) { + delete process.env.SSH_AUTH_SOCK; + } else { + process.env.SSH_AUTH_SOCK = savedSshAuthSock; + } + }); + + test("expands owner/repo shorthand to an https clone URL", () => { + expect(parseAgentPluginSourceInput("coder/mux")).toEqual({ + url: "https://github.com/coder/mux.git", + }); + }); + + test("expands owner/repo shorthand to ssh when an SSH agent is present", () => { + process.env.SSH_AUTH_SOCK = "/tmp/fake-agent.sock"; + expect(parseAgentPluginSourceInput("coder/mux").url).toBe("git@github.com:coder/mux.git"); + }); + + test("parses @ref from shorthand (branch, tag, or sha all land in ref)", () => { + expect(parseAgentPluginSourceInput("coder/mux@main")).toEqual({ + url: "https://github.com/coder/mux.git", + ref: "main", + }); + expect(parseAgentPluginSourceInput("coder/mux@v1.2.3").ref).toBe("v1.2.3"); + const sha = "a".repeat(40); + expect(parseAgentPluginSourceInput(`coder/mux@${sha}`).ref).toBe(sha); + }); + + test("parses monorepo subpath segments from shorthand", () => { + expect(parseAgentPluginSourceInput("coder/mux/plugins/demo@main")).toEqual({ + url: "https://github.com/coder/mux.git", + ref: "main", + subpath: "plugins/demo", + }); + }); + + test("passes through full URLs unchanged (with query/fragment stripped)", () => { + expect(parseAgentPluginSourceInput("https://github.com/coder/mux.git")).toEqual({ + url: "https://github.com/coder/mux.git", + }); + expect(parseAgentPluginSourceInput("https://github.com/coder/mux.git?tab=readme").url).toBe( + "https://github.com/coder/mux.git" + ); + expect(parseAgentPluginSourceInput("git@github.com:coder/mux.git")).toEqual({ + url: "git@github.com:coder/mux.git", + }); + expect(parseAgentPluginSourceInput("ssh://git@git.corp:2222/x/y.git").url).toBe( + "ssh://git@git.corp:2222/x/y.git" + ); + }); + + test("does not treat @ inside URLs as a ref separator", () => { + // git@host URLs keep their @ — refs for URL inputs come from the ref field. + const parsed = parseAgentPluginSourceInput("git@github.com:coder/mux.git"); + expect(parsed.ref).toBeUndefined(); + }); + + test("passes through absolute local paths (git handles local remotes)", () => { + expect(parseAgentPluginSourceInput("/tmp/some-repo").url).toBe("/tmp/some-repo"); + }); + + test("expands home-relative paths (git is spawned without a shell)", () => { + expect(parseAgentPluginSourceInput("~/plugins/demo").url).toBe( + path.join(os.homedir(), "plugins/demo") + ); + expect(parseAgentPluginSourceInput("~").url).toBe(os.homedir()); + // Windows-native separator: `~\plugins\demo` must expand too, not reach + // git as a literal tilde. + expect(parseAgentPluginSourceInput("~\\plugins\\demo").url).toBe( + path.join(os.homedir(), "plugins\\demo") + ); + }); + + test("rejects unusable inputs with actionable messages", () => { + expect(() => parseAgentPluginSourceInput("")).toThrow(/git URL or owner\/repo/); + expect(() => parseAgentPluginSourceInput("just-a-name")).toThrow(/not a git URL/); + expect(() => parseAgentPluginSourceInput("./relative/path")).toThrow(/relative path/); + expect(() => parseAgentPluginSourceInput("coder/mux@")).toThrow(/must not be empty/); + expect(() => parseAgentPluginSourceInput("-bad/owner")).toThrow(/not a valid owner\/repo/); + }); +}); + +describe("isFullCommitSha", () => { + test("accepts only full 40-hex SHAs", () => { + expect(isFullCommitSha("a".repeat(40))).toBe(true); + expect(isFullCommitSha("A1B2C3D4E5".repeat(4))).toBe(true); + expect(isFullCommitSha("a".repeat(39))).toBe(false); + expect(isFullCommitSha("a".repeat(41))).toBe(false); + expect(isFullCommitSha("main")).toBe(false); + }); +}); diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts new file mode 100644 index 0000000000..6a93932214 --- /dev/null +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -0,0 +1,108 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +import { GITHUB_SHORTHAND_PATTERN, normalizeRepoUrlForClone } from "@/node/utils/gitUrls"; + +/** + * Agent Plugin install source grammar. + * + * Accepted inputs (one text field): + * - `owner/repo` — GitHub shorthand + * - `owner/repo@ref` — shorthand with a branch, tag, or full 40-hex commit SHA + * - `owner/repo/sub/path[@ref]` — shorthand with a monorepo subpath (parsed + * and persisted from day one; the v1 installer rejects subpath installs) + * - any git remote URL (`https://…`, `ssh://…`, `git@host:path`, `file://…`, + * absolute local paths) — passed to git unchanged; refs for URL inputs come + * from the separate ref field because `@` is ambiguous inside URLs + */ + +export interface ParsedAgentPluginSourceInput { + /** Normalized git clone URL. */ + url: string; + /** Branch/tag name or full commit SHA parsed from `@ref` shorthand. */ + ref?: string; + /** Repo-relative plugin directory parsed from shorthand (monorepo installs; v2). */ + subpath?: string; +} + +const FULL_COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/i; + +/** True when `ref` is a full 40-hex commit SHA (short SHAs cannot be fetched shallowly). */ +export function isFullCommitSha(ref: string): boolean { + return FULL_COMMIT_SHA_PATTERN.test(ref); +} + +function isUrlLike(input: string): boolean { + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(input)) { + return true; // protocol URLs: https://, ssh://, git://, file://, … + } + if (input.startsWith("git@")) { + return true; // common SCP-style form + } + if (input.startsWith("/") || input.startsWith("~") || /^[a-zA-Z]:[\\/]/.test(input)) { + return true; // absolute local paths (incl. Windows drive letters) + } + // Other SCP-style forms ([user@]host:path). Exclude `owner/repo@ref` + // shorthand, which has no colon. + return /^[a-zA-Z0-9._-]+@[^:]+:.+$/.test(input); +} + +/** + * Parse the Add Plugin source input. Throws with a user-facing message when + * the input matches no accepted form. + */ +export function parseAgentPluginSourceInput(rawInput: string): ParsedAgentPluginSourceInput { + const input = rawInput.trim(); + if (input.length === 0) { + throw new Error("Enter a git URL or owner/repo shorthand."); + } + + if (isUrlLike(input)) { + // Git is spawned without a shell, so `~` never expands on its own — + // resolve home-relative local paths here (both separator styles, so a + // Windows-native `~\plugins\demo` doesn't hand git a literal tilde). + if (input === "~") { + return { url: os.homedir() }; + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return { url: path.join(os.homedir(), input.slice(2)) }; + } + // normalizeRepoUrlForClone strips query strings/fragments from URL-like inputs. + return { url: normalizeRepoUrlForClone(input) }; + } + + if (input.startsWith(".")) { + throw new Error( + `'${input}' looks like a relative path. Use an absolute path, a git URL, or owner/repo shorthand.` + ); + } + + // Shorthand: owner/repo[/sub/path][@ref]. Split the ref at the first `@` — + // GitHub owner/repo segments cannot contain `@`. + const atIndex = input.indexOf("@"); + const pathPart = atIndex === -1 ? input : input.slice(0, atIndex); + const refPart = atIndex === -1 ? undefined : input.slice(atIndex + 1); + + if (refPart?.length === 0) { + throw new Error("Ref after '@' must not be empty (use owner/repo@branch, @tag, or @sha)."); + } + + const segments = pathPart.split("/"); + if (segments.length < 2 || segments.some((segment) => segment.length === 0)) { + throw new Error( + `'${input}' is not a git URL or owner/repo shorthand. Examples: coder/mux, coder/mux@main, https://github.com/coder/mux.git` + ); + } + + const ownerRepo = `${segments[0]}/${segments[1]}`; + if (!GITHUB_SHORTHAND_PATTERN.test(ownerRepo)) { + throw new Error(`'${ownerRepo}' is not a valid owner/repo shorthand.`); + } + + const subpath = segments.slice(2).join("/"); + return { + url: normalizeRepoUrlForClone(ownerRepo), + ...(refPart !== undefined ? { ref: refPart } : {}), + ...(subpath.length > 0 ? { subpath } : {}), + }; +} diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index ddef86597f..a72ee0c3c9 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1327,6 +1327,8 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "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).", "", + "Global plugins can be installed from git via **Settings → Plugins** (paste a git URL or `owner/repo[@ref]`); the install preview lists every skill the plugin would contribute before anything is written.", + "", "## Skill layout", "", "A skill is a directory named after the skill:", @@ -3163,6 +3165,8 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`.", "", + "**Settings → Plugins** installs plugins from git into `~/.mux/plugins` (paste a git URL or `owner/repo[@ref]`). Before anything is written, a consent preview lists the plugin's manifest, every skill, and every MCP server command line. Installs are pinned to the resolved commit; update checks compare the tracked branch or tag against the pinned commit and never auto-apply. Applying an update replaces the plugin directory wholesale — local edits to a managed plugin directory are discarded — and restarts that plugin's running MCP servers. Uninstalling removes the directory, the registry entry, and the plugin's per-workspace server overrides, but keeps `~/.mux/plugin-data/` unless you opt in to deleting it.", + "", "## Behavior", "", "- **Hot reload** — Config changes apply on your next message (no restart needed)", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index af3bc54158..9452fba051 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1568,8 +1568,9 @@ export class AIService extends EventEmitter { let mcpOverrides: WorkspaceMCPOverrides | undefined; const loadWorkspaceMcpOverridesStartedAt = Date.now(); try { - mcpOverrides = - await this.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId); + mcpOverrides = ( + await this.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId) + ).overrides; } catch (error) { log.warn("[MCP] Failed to load workspace MCP overrides; continuing without overrides", { workspaceId, diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 5f69af06ba..2c51aaa25f 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -121,6 +121,166 @@ describe("MCPServerManager", () => { manager.dispose(); }); + test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup, then retries them", async () => { + const workspaceId = "ws-swap-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + + // Block startServers mid-flight so a plugin swap can land while the + // instance exists but is not yet published in workspaceServers. + let releaseStartup!: () => void; + const startupGate = new Promise((resolve) => { + releaseStartup = resolve; + }); + const close = mock(() => Promise.resolve(undefined)); + access.startServers = async () => { + await startupGate; + return startResult([[pluginKey, { close }]]); + }; + + const toolsPromise = manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + // Give getToolsForWorkspace time to enter the (gated) startServers call. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The updater's recycle runs while startup is in flight: the scan sees + // nothing (not yet published), so the epoch record must catch it. + await manager.stopServersWithKeyPrefix("plugin:abc123:"); + + releaseStartup(); + const result = await toolsPromise; + + // The stale instance was closed instead of published. + expect(close).toHaveBeenCalledTimes(1); + expect(Object.keys(result.tools)).toEqual([]); + const entry = access.workspaceServers.get(workspaceId) as { + instances: Map; + timedOutServerNames: string[]; + }; + expect(entry.instances.size).toBe(0); + + // The entry was published under the UNCHANGED config signature, so the + // next call hits the cached path — the removed server must carry a retry + // marker there, or the updated plugin's tools stay unavailable forever. + expect(entry.timedOutServerNames).toContain(pluginKey); + const echoTool = testTool(); + const close2 = mock(() => Promise.resolve(undefined)); + access.startServers = () => + Promise.resolve(startResult([[pluginKey, { tools: { echo: echoTool }, close: close2 }]])); + + const second = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + + // Restarted from the (new) tree via the retry path — not served from the + // reduced cached map, and not torn down again. + expect(close2).toHaveBeenCalledTimes(0); + expect(Object.keys(second.tools)).toHaveLength(1); + const secondEntry = access.workspaceServers.get(workspaceId) as { + instances: Map; + timedOutServerNames: string[]; + }; + expect(secondEntry.instances.size).toBe(1); + expect(secondEntry.timedOutServerNames).toEqual([]); + }); + + test("invalidation landing between the final epoch scan and cache publication never publishes the stale instance", async () => { + const workspaceId = "ws-publish-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + + // The invalidation scan iterates the instances map ([...instances]), so a + // one-shot iterator hook that QUEUES a microtask runs stopServersWithKeyPrefix + // strictly after that scan's checks but before the awaiting continuation + // publishes: the stop's epoch record lands after the scan read it, and its + // own published-map scan runs before workspaceServers.set — the exact + // window where both mechanisms used to miss. + const close = mock(() => Promise.resolve(undefined)); + let stopPromise: Promise | undefined; + const instances = new Map([[pluginKey, testInstance(pluginKey, { close })]]); + let armed = true; + const originalIterator = instances[Symbol.iterator].bind(instances); + instances[Symbol.iterator] = () => { + if (armed) { + armed = false; + queueMicrotask(() => { + stopPromise = manager.stopServersWithKeyPrefix("plugin:abc123:"); + }); + } + return originalIterator(); + }; + + access.startServers = () => + Promise.resolve({ instances, failedServerNames: [], timedOutServerNames: [] }); + + const result = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(stopPromise).toBeDefined(); + await stopPromise; + + // The stale-tree instance was closed, never published, and carries a + // retry marker so the next call restarts it from the new tree. + expect(close).toHaveBeenCalledTimes(1); + expect(Object.keys(result.tools)).toEqual([]); + const entry = access.workspaceServers.get(workspaceId) as { + instances: Map; + timedOutServerNames: string[]; + }; + expect(entry.instances.size).toBe(0); + expect(entry.timedOutServerNames).toContain(pluginKey); + + const echoTool = testTool(); + access.startServers = () => + Promise.resolve(startResult([[pluginKey, { tools: { echo: echoTool } }]])); + const second = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(Object.keys(second.tools)).toHaveLength(1); + }); + + test("stopServersWithKeyPrefix closes only matching instances and retries them on next use", async () => { + const workspaceId = "ws-selective-stop"; + const pluginKey = "plugin:abc123:echo"; + const userServer = "user-server"; + configService.listServers.mockImplementation(() => + Promise.resolve({ + [pluginKey]: stdioConfig("node server.js"), + [userServer]: stdioConfig("npx user-server"), + }) + ); + + const pluginClose = mock(() => Promise.resolve(undefined)); + const userClose = mock(() => Promise.resolve(undefined)); + const userTool = testTool(); + access.startServers = () => + Promise.resolve( + startResult([ + [pluginKey, { close: pluginClose }], + [userServer, { tools: { toolu: userTool }, close: userClose }], + ]) + ); + + await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + // Simulate a live agent stream holding the workspace's servers. + manager.acquireLease(workspaceId); + try { + await manager.stopServersWithKeyPrefix("plugin:abc123:"); + + // Only the plugin instance was closed; the unrelated healthy client + // survives underneath the live lease. + expect(pluginClose).toHaveBeenCalledTimes(1); + expect(userClose).toHaveBeenCalledTimes(0); + const entry = access.workspaceServers.get(workspaceId) as { + instances: Map; + timedOutServerNames: string[]; + }; + expect(entry.instances.has(userServer)).toBe(true); + expect(entry.instances.has(pluginKey)).toBe(false); + // The stopped plugin server is queued for restart on next use. + expect(entry.timedOutServerNames).toContain(pluginKey); + } finally { + manager.releaseLease(workspaceId); + } + }); + test("cleanupIdleServers stops idle servers when workspace is not leased", () => { const workspaceId = "ws-idle"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 21dbeac22a..db7b65382c 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -719,6 +719,16 @@ export interface MCPServerManagerOptions { export class MCPServerManager { private readonly workspaceServers = new Map(); private readonly workspaceLeases = new Map(); + /** + * Monotonic clock for key-prefix invalidations (stopServersWithKeyPrefix). + * getToolsForWorkspace snapshots it before reading config; any prefix + * invalidated after that snapshot marks the startup's matching instances + * stale, because they may have launched from a plugin tree that was + * swapped/deleted mid-startup. + */ + private prefixInvalidationClock = 0; + /** Latest invalidation epoch per key prefix. */ + private readonly prefixInvalidations = new Map(); private readonly idleCheckInterval: ReturnType; private inlineServers: Record = {}; private readonly policyService: PolicyService | null; @@ -1053,6 +1063,11 @@ export class MCPServerManager { agentPlugins, } = options; + // Snapshot BEFORE reading config: a plugin swap that lands after this + // point may invalidate instances this call starts (see + // closeInvalidatedInstances). + const startupEpoch = this.prefixInvalidationClock; + // Fetch full server info for project-level allowlists and server filtering const allServers = await this.getAllServers(projectPath, trusted, agentPlugins); @@ -1211,19 +1226,33 @@ export class MCPServerManager { return this.getToolsForWorkspace(options); } - for (const [serverName, instance] of retriedInstances) { - existing.instances.set(serverName, instance); - } + // Drop retried instances whose plugin tree was swapped mid-startup; + // they rejoin the retry list below so the next call restarts them + // from the new tree (the filter would otherwise drop them: they + // were in retryingServerNames but have no live instance). The merge + // into the published entry happens inside the stable-clock callback + // so no invalidation can land between the final scan and the merge. + await this.closeInvalidatedInstancesThenPublish( + retriedInstances, + startupEpoch, + workspaceId, + (invalidatedRetryKeys) => { + for (const [serverName, instance] of retriedInstances) { + existing.instances.set(serverName, instance); + } - existing.timedOutServerNames = [ - ...existing.timedOutServerNames.filter( - (serverName) => - enabledServerNames.has(serverName) && - !retryingServerNames.has(serverName) && - !existing.instances.has(serverName) - ), - ...retryTimedOutNames, - ]; + existing.timedOutServerNames = [ + ...existing.timedOutServerNames.filter( + (serverName) => + enabledServerNames.has(serverName) && + !retryingServerNames.has(serverName) && + !existing.instances.has(serverName) + ), + ...retryTimedOutNames, + ...invalidatedRetryKeys, + ]; + } + ); const failedServerNames = [ ...existing.stats.failedServerNames.filter( @@ -1318,9 +1347,23 @@ export class MCPServerManager { restartFailedNames = failedNames; restartTimedOutNames = timedOutNames; - for (const [serverName, instance] of restartedInstances) { - existing.instances.set(serverName, instance); - } + // Drop restarted instances whose plugin tree was swapped mid-startup; + // route them through the retry list so the entry (kept under its + // unchanged signature) restarts them on the next call. The merge into + // the published entry happens inside the stable-clock callback so no + // invalidation can land between the final scan and the merge. + await this.closeInvalidatedInstancesThenPublish( + restartedInstances, + startupEpoch, + workspaceId, + (invalidatedRestartKeys) => { + restartTimedOutNames = [...restartTimedOutNames, ...invalidatedRestartKeys]; + + for (const [serverName, instance] of restartedInstances) { + existing.instances.set(serverName, instance); + } + } + ); } log.info("[MCP] Deferring MCP server restart while stream is active", { @@ -1391,17 +1434,33 @@ export class MCPServerManager { () => this.markActivity(workspaceId) ); + // A plugin update/uninstall can swap the tree while startServers was + // running; its stopServersWithKeyPrefix scan cannot see instances that + // are not published yet, so close them here instead of publishing. The + // removed keys join the retry list: this entry is published under the + // full (unchanged) config signature, so without a retry marker the + // cached path would serve the reduced map indefinitely. Publication + // happens inside the stable-clock callback so no invalidation can land + // between the final scan and workspaceServers.set (see + // closeInvalidatedInstancesThenPublish). const allFailedNames = [...restartFailedNames, ...startFailedNames]; - const stats = this.createWorkspaceStats(enabledEntries.length, instances, allFailedNames); - - this.workspaceServers.set(workspaceId, { - configSignature: signature, + let stats!: MCPWorkspaceStats; + await this.closeInvalidatedInstancesThenPublish( instances, - stats, - timedOutServerNames: startTimedOutNames, - retryingTimedOutServerNames: new Set(), - lastActivity: Date.now(), - }); + startupEpoch, + workspaceId, + (invalidatedKeys) => { + stats = this.createWorkspaceStats(enabledEntries.length, instances, allFailedNames); + this.workspaceServers.set(workspaceId, { + configSignature: signature, + instances, + stats, + timedOutServerNames: [...startTimedOutNames, ...invalidatedKeys], + retryingTimedOutServerNames: new Set(), + lastActivity: Date.now(), + }); + } + ); return { tools: this.collectTools(instances, fullServerInfo, overrides), @@ -1409,6 +1468,152 @@ export class MCPServerManager { }; } + /** + * Recycle every workspace's server set that includes a running server whose + * config key starts with `prefix` (e.g. `plugin::`). + * + * Used by the Agent Plugin installer on update/uninstall: plugin content + * can change behind an unchanged stdio command line, which the config + * signature (command/args/env/cwd) cannot detect — so recycling must be + * explicit. Stopped servers restart on the workspace's next MCP use. + */ + async stopServersWithKeyPrefix(prefix: string): Promise { + assert(prefix.length > 0, "stopServersWithKeyPrefix: prefix must be non-empty"); + // Record the invalidation FIRST: a getToolsForWorkspace call currently + // inside startServers has not published its instances yet, so the scan + // below cannot see them — the publish paths compare their pre-startup + // epoch snapshot against this record and close matching instances + // instead of publishing them. + this.prefixInvalidations.set(prefix, ++this.prefixInvalidationClock); + + // Close ONLY the matching instances. The rest of the workspace's servers + // stay running: a live agent stream may hold a lease or be mid tool call + // on an unrelated healthy client, so tearing down the whole workspace + // set here would close it underneath them. + for (const [workspaceId, entry] of this.workspaceServers) { + const removedKeys: string[] = []; + for (const [serverKey, instance] of [...entry.instances]) { + if (!serverKey.startsWith(prefix)) { + continue; + } + entry.instances.delete(serverKey); + removedKeys.push(serverKey); + try { + await instance.close(); + } catch (error) { + log.warn("Failed to stop MCP server", { error, name: instance.name }); + } + } + if (removedKeys.length === 0) { + continue; + } + + log.info("[MCP] Stopped plugin servers for key prefix", { workspaceId, removedKeys }); + // The workspace entry survives under its unchanged config signature, so + // subsequent calls hit the same-signature cache path — mark the removed + // servers for the timed-out retry machinery so that path restarts them + // (from the new plugin tree) instead of serving the reduced map forever. + this.markServersForRetry(entry, removedKeys); + } + } + + /** + * Queue server keys for restart on the next same-signature + * getToolsForWorkspace call. Reuses the timed-out retry machinery: entries + * in `timedOutServerNames` that are enabled but have no live instance are + * restarted by the cached path (see getTimedOutServerNamesToRetry). + */ + private markServersForRetry(entry: WorkspaceServers, serverKeys: string[]): void { + const pending = new Set(entry.timedOutServerNames); + for (const serverKey of serverKeys) { + if (!pending.has(serverKey)) { + entry.timedOutServerNames.push(serverKey); + } + } + } + + /** + * Close and drop instances whose keys match a prefix invalidated after + * `startedAtEpoch` (the caller's pre-startup snapshot of the invalidation + * clock). Such instances may be running code from a plugin tree that was + * swapped or deleted while they were starting; the returned keys MUST be + * queued for retry by the caller (markServersForRetry) so the next MCP use + * restarts them from the current tree — publishing the reduced map under + * the unchanged config signature would otherwise cache them away forever. + */ + private async closeInvalidatedInstances( + instances: Map, + startedAtEpoch: number, + workspaceId: string + ): Promise { + const removedKeys: string[] = []; + for (const [serverKey, instance] of [...instances]) { + let invalidated = false; + for (const [prefix, epoch] of this.prefixInvalidations) { + if (epoch > startedAtEpoch && serverKey.startsWith(prefix)) { + invalidated = true; + break; + } + } + if (!invalidated) { + continue; + } + + instances.delete(serverKey); + removedKeys.push(serverKey); + log.info("[MCP] Closing instance invalidated during startup (plugin tree swapped)", { + workspaceId, + serverKey, + }); + try { + await instance.close(); + } catch (error) { + log.warn("Failed to close invalidated MCP server instance", { error, serverKey }); + } + } + return removedKeys; + } + + /** + * Scan for invalidated instances until the invalidation clock is stable + * across a full scan, then invoke `publish` SYNCHRONOUSLY in the same + * continuation as the final clock check. + * + * Why the loop + sync callback: closeInvalidatedInstances is awaited, so + * there is a microtask yield between its final scan and any code that runs + * after it. A stopServersWithKeyPrefix continuation scheduled into that + * yield records its epoch AFTER the scan checked it and scans the published + * map BEFORE the caller publishes these instances — both mechanisms miss, + * and a server started from a removed/replaced plugin tree would stay + * alive. Re-checking the clock in the caller's continuation and publishing + * synchronously (no await between check and publish) closes the window: + * any invalidation that lands after the check runs its own scan strictly + * after publication, so it sees the published entry and closes matches. + * + * `publish` MUST NOT await; it receives every key closed across all scans + * and must queue them for retry (see closeInvalidatedInstances docs). + */ + private async closeInvalidatedInstancesThenPublish( + instances: Map, + startedAtEpoch: number, + workspaceId: string, + publish: (invalidatedKeys: string[]) => void + ): Promise { + const invalidatedKeys: string[] = []; + for (;;) { + const clockBeforeScan = this.prefixInvalidationClock; + invalidatedKeys.push( + ...(await this.closeInvalidatedInstances(instances, startedAtEpoch, workspaceId)) + ); + // Terminates: the clock only advances on stopServersWithKeyPrefix + // calls, which are finite user-driven plugin update/uninstall events. + if (this.prefixInvalidationClock === clockBeforeScan) { + publish(invalidatedKeys); + return; + } + } + } + async stopServers(workspaceId: string): Promise { const entry = this.workspaceServers.get(workspaceId); if (!entry) return; diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 85a3eaae8e..66200fbeeb 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -15,6 +15,7 @@ import type { Secret } from "@/common/types/secrets"; import type { Stats } from "fs"; import * as fsPromises from "fs/promises"; import { execFileAsync, killProcessTree } from "@/node/utils/disposableExec"; +import { normalizeRepoUrlForClone } from "@/node/utils/gitUrls"; import { buildFileCompletionsIndex, EMPTY_FILE_COMPLETIONS_INDEX, @@ -226,52 +227,6 @@ function deriveRepoFolderName(repoUrl: string): string { return safeFolderName; } -const GITHUB_SHORTHAND_PATTERN = /^[a-zA-Z0-9][\w-]*\/[a-zA-Z0-9][\w.-]*$/; - -function hasLikelySshCredentials(): boolean { - const sshAgentSocket = process.env.SSH_AUTH_SOCK; - // Be conservative: only prefer git@github.com shorthand when the session has an active - // SSH agent. The mere presence of local key files does not imply GitHub SSH access. - return typeof sshAgentSocket === "string" && sshAgentSocket.trim().length > 0; -} - -/** - * Normalize a repo URL so git clone receives a valid remote. - * Expands "owner/repo" shorthand to either SSH or HTTPS based on likely local credentials. - * All other inputs (HTTPS URLs, SSH URLs, SCP-style, etc.) pass through unchanged. - */ -function normalizeRepoUrlForClone(repoUrl: string): string { - const trimmedRepoUrl = repoUrl.trim(); - const shorthandCandidate = trimmedRepoUrl.replace(/[\\/]+$/, ""); - - // owner/repo shorthand: exactly two non-empty segments separated by a single slash, - // where the first segment looks like a GitHub username (letters, digits, hyphens). - // Excludes local paths like ../repo, ./foo, foo/bar/baz, and absolute paths. - // Note: bare `foo/bar` style local relative paths are intentionally treated as GitHub - // shorthand here because this function is only called from the Clone dialog, which is - // specifically for remote repos. Users cloning local repos should use the "Local folder" tab. - if (GITHUB_SHORTHAND_PATTERN.test(shorthandCandidate)) { - // Strip existing .git suffix before appending to avoid double .git (e.g. owner/repo.git → owner/repo.git.git) - const withoutGitSuffix = shorthandCandidate.replace(/\.git$/i, ""); - - // Prefer SSH for shorthand only when the current session has an active SSH agent. - // This avoids assuming GitHub access from unrelated key files on disk. - if (hasLikelySshCredentials()) { - return `git@github.com:${withoutGitSuffix}.git`; - } - - return `https://github.com/${withoutGitSuffix}.git`; - } - - // Strip query strings and fragments only from URL-like inputs (protocol:// or git@), - // not from local paths where # and ? may be valid filename characters. - if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmedRepoUrl) || trimmedRepoUrl.startsWith("git@")) { - return trimmedRepoUrl.replace(/[?#].*$/, ""); - } - - return trimmedRepoUrl; -} - function parseScpStyleSshUrl(url: string): { host: string } | undefined { const trimmedUrl = url.trim(); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 2320641e8b..79b7d04665 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -47,6 +47,8 @@ import { } from "@/node/services/analytics/analyticsService"; import { ExperimentsService } from "@/node/services/experimentsService"; import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import { AgentPluginInstallService } from "@/node/services/agentPlugins/installService"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { McpOauthService } from "@/node/services/mcpOauthService"; import { HeartbeatService } from "@/node/services/heartbeatService"; import { AgentStatusService } from "@/node/services/agentStatusService"; @@ -119,6 +121,7 @@ export class ServiceContainer { public readonly voiceService: VoiceService; public readonly mcpOauthService: McpOauthService; public readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService; + public readonly agentPluginInstallService: AgentPluginInstallService; public readonly telemetryService: TelemetryService; public readonly sessionTimingService: SessionTimingService; public readonly timelineService: TimelineService; @@ -232,6 +235,16 @@ export class ServiceContainer { this.extensionMetadata = core.extensionMetadata; this.backgroundProcessManager = core.backgroundProcessManager; + // Managed Agent Plugin installer (agent-plugins experiment). Gated on the + // backend ExperimentsService exactly like the plugin MCP provider; the + // MCP manager dependency lets update/uninstall recycle running plugin + // servers whose content changed behind an unchanged command line. + this.agentPluginInstallService = new AgentPluginInstallService(config, { + isEnabled: () => this.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS), + mcpServerManager: this.mcpServerManager, + workspaceMcpOverridesService: this.workspaceMcpOverridesService, + }); + this.projectService = new ProjectService(config, this.sshPromptService); this.projectService.setWorkspaceService(this.workspaceService); this.desktopSessionManager = new DesktopSessionManager({ @@ -615,6 +628,7 @@ export class ServiceContainer { mcpOauthService: this.mcpOauthService, workspaceMcpOverridesService: this.workspaceMcpOverridesService, mcpServerManager: this.mcpServerManager, + agentPluginInstallService: this.agentPluginInstallService, sessionTimingService: this.sessionTimingService, timelineService: this.timelineService, telemetryService: this.telemetryService, diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index 1a2b5be719..2dbfb5d230 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -5,7 +5,10 @@ import * as path from "path"; import { Config } from "@/node/config"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { execBuffered } from "@/node/utils/runtime/helpers"; -import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; +import { + WorkspaceMcpOverridesConflictError, + WorkspaceMcpOverridesService, +} from "./workspaceMcpOverridesService"; function getWorkspacePath(args: { srcDir: string; @@ -64,7 +67,7 @@ describe("WorkspaceMcpOverridesService", () => { }); const service = new WorkspaceMcpOverridesService(config); - const overrides = await service.getOverridesForWorkspace(workspaceId); + const { overrides } = await service.getOverridesForWorkspace(workspaceId); expect(overrides).toEqual({}); expect(await pathExists(path.join(workspacePath, ".mux", "mcp.local.jsonc"))).toBe(false); @@ -165,12 +168,74 @@ describe("WorkspaceMcpOverridesService", () => { expect(await pathExists(filePath)).toBe(true); const roundTrip = await service.getOverridesForWorkspace(workspaceId); - expect(roundTrip).toEqual({ + expect(roundTrip.overrides).toEqual({ disabledServers: ["server-a"], toolAllowlist: { "server-b": ["tool1"] }, }); }); + it("rejects saves with a stale revision instead of clobbering newer overrides", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + await fs.mkdir(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + }, + ], + }); + return cfg; + }); + + const service = new WorkspaceMcpOverridesService(config); + await service.setOverridesForWorkspace(workspaceId, { + enabledServers: ["plugin:abc:server"], + }); + + // Dialog snapshot taken here... + const snapshot = await service.getOverridesForWorkspace(workspaceId); + + // ...then a concurrent writer (e.g. plugin uninstall prune) removes the key. + await service.setOverridesForWorkspace( + workspaceId, + {}, + { expectedRevision: snapshot.revision } + ); + + // Replaying the stale snapshot must fail, not restore the pruned key. + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + service.setOverridesForWorkspace(workspaceId, snapshot.overrides, { + expectedRevision: snapshot.revision, + }) + ).rejects.toThrow(WorkspaceMcpOverridesConflictError); + + const current = await service.getOverridesForWorkspace(workspaceId); + expect(current.overrides).toEqual({}); + + // A save with the CURRENT revision goes through. + await service.setOverridesForWorkspace( + workspaceId, + { disabledServers: ["other"] }, + { expectedRevision: current.revision } + ); + const after = await service.getOverridesForWorkspace(workspaceId); + expect(after.overrides).toEqual({ disabledServers: ["other"] }); + }); + it("removes workspace-local file when overrides are set to empty", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; @@ -241,7 +306,7 @@ describe("WorkspaceMcpOverridesService", () => { }); const service = new WorkspaceMcpOverridesService(config); - const overrides = await service.getOverridesForWorkspace(workspaceId); + const { overrides } = await service.getOverridesForWorkspace(workspaceId); expect(overrides).toEqual({ disabledServers: ["server-a"], diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index de5df5a7d9..408972581e 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as path from "path"; import * as jsonc from "jsonc-parser"; import assert from "@/common/utils/assert"; @@ -93,6 +94,28 @@ function normalizeWorkspaceMcpOverrides(raw: unknown): WorkspaceMCPOverrides { return normalized; } +/** + * Opaque revision token for optimistic-concurrency saves. Derived from the + * normalized overrides content, so any successful write (including the Agent + * Plugin uninstaller pruning `plugin:` keys) changes the revision and stale + * snapshots held by an open Workspace MCP dialog are rejected instead of + * silently restoring removed entries. + */ +function computeOverridesRevision(overrides: WorkspaceMCPOverrides): string { + return createHash("sha256").update(JSON.stringify(overrides)).digest("hex").slice(0, 16); +} + +/** Thrown when a save's expectedRevision no longer matches the stored overrides. */ +export class WorkspaceMcpOverridesConflictError extends Error { + constructor() { + super( + "Workspace MCP settings changed while this dialog was open. " + + "Close and reopen it to load the latest values, then reapply your changes." + ); + this.name = "WorkspaceMcpOverridesConflictError"; + } +} + function isEmptyOverrides(overrides: WorkspaceMCPOverrides): boolean { return ( (!overrides.disabledServers || overrides.disabledServers.length === 0) && @@ -314,8 +337,13 @@ export class WorkspaceMcpOverridesService { runtime: ReturnType, workspacePath: string ): Promise { - // Best-effort: remove both file names so we never leave conflicting sources behind. - await execBuffered( + // Remove both file names so we never leave conflicting sources behind. + // The exit code MUST be checked: callers (e.g. the Agent Plugin + // uninstaller retiring override-prune tombstones) rely on + // setOverridesForWorkspace rejecting when clearing overrides failed — + // a swallowed `rm` failure would leave a stale enabledServers key that + // a plugin reinstall could silently reactivate. + const result = await execBuffered( runtime, `rm -f "${MCP_OVERRIDES_DIR}/${MCP_OVERRIDES_JSONC}" "${MCP_OVERRIDES_DIR}/${MCP_OVERRIDES_JSON}"`, { @@ -323,6 +351,11 @@ export class WorkspaceMcpOverridesService { timeout: 10, } ); + if (result.exitCode !== 0) { + throw new Error( + `Failed to remove workspace MCP overrides file: ${result.stderr.trim() || `rm exited with code ${result.exitCode}`}` + ); + } } /** @@ -330,8 +363,18 @@ export class WorkspaceMcpOverridesService { * * If the file doesn't exist, we fall back to legacy overrides stored in ~/.mux/config.json * and migrate them into the workspace-local file. + * + * The returned revision is an opaque token for setOverridesForWorkspace's + * expectedRevision check. */ - async getOverridesForWorkspace(workspaceId: string): Promise { + async getOverridesForWorkspace( + workspaceId: string + ): Promise<{ overrides: WorkspaceMCPOverrides; revision: string }> { + const overrides = await this.loadOverrides(workspaceId); + return { overrides, revision: computeOverridesRevision(overrides) }; + } + + private async loadOverrides(workspaceId: string): Promise { const { metadata, runtime, workspacePath } = await this.getRuntimeAndWorkspacePath(workspaceId); const { jsoncPath, jsonPath } = this.getOverridesFilePaths( workspacePath, @@ -382,32 +425,63 @@ export class WorkspaceMcpOverridesService { return normalizedLegacy; } + /** + * All writes flow through this queue so the expectedRevision check-and-set + * in setOverridesForWorkspace is atomic within the main process (the only + * writer of these files). + */ + private writeQueue: Promise = Promise.resolve(); + + private runExclusive(fn: () => Promise): Promise { + const run = () => fn(); + const next = this.writeQueue.then(run, run); + this.writeQueue = next.catch(() => undefined); + return next; + } + /** * Persist workspace MCP overrides to /.mux/mcp.local.jsonc. * * Empty overrides remove the workspace-local file. + * + * When options.expectedRevision is provided, the write is rejected with + * WorkspaceMcpOverridesConflictError if the stored overrides changed since + * that revision was read — a stale Workspace MCP dialog snapshot must not + * silently restore entries removed by a concurrent writer (e.g. the Agent + * Plugin uninstaller pruning `plugin::` keys). */ async setOverridesForWorkspace( workspaceId: string, - overrides: WorkspaceMCPOverrides + overrides: WorkspaceMCPOverrides, + options?: { expectedRevision?: string } ): Promise { assert(overrides && typeof overrides === "object", "overrides must be an object"); - const { metadata, runtime, workspacePath } = await this.getRuntimeAndWorkspacePath(workspaceId); - const { jsoncPath } = this.getOverridesFilePaths(workspacePath, metadata.runtimeConfig); + return this.runExclusive(async () => { + if (options?.expectedRevision !== undefined) { + const current = await this.loadOverrides(workspaceId); + if (computeOverridesRevision(current) !== options.expectedRevision) { + throw new WorkspaceMcpOverridesConflictError(); + } + } - const normalized = normalizeWorkspaceMcpOverrides(overrides); + const { metadata, runtime, workspacePath } = + await this.getRuntimeAndWorkspacePath(workspaceId); + const { jsoncPath } = this.getOverridesFilePaths(workspacePath, metadata.runtimeConfig); - // Always clear any legacy storage so we converge on the workspace-local file. - await this.clearLegacyOverridesInConfig(workspaceId); + const normalized = normalizeWorkspaceMcpOverrides(overrides); - if (isEmptyOverrides(normalized)) { - await this.removeOverridesFile(runtime, workspacePath); - return; - } + // Always clear any legacy storage so we converge on the workspace-local file. + await this.clearLegacyOverridesInConfig(workspaceId); + + if (isEmptyOverrides(normalized)) { + await this.removeOverridesFile(runtime, workspacePath); + return; + } - await this.ensureOverridesDir(runtime, workspacePath, metadata.runtimeConfig); - await writeFileString(runtime, jsoncPath, JSON.stringify(normalized, null, 2) + "\n"); - await this.ensureOverridesGitignored(runtime, workspacePath, metadata.runtimeConfig); + await this.ensureOverridesDir(runtime, workspacePath, metadata.runtimeConfig); + await writeFileString(runtime, jsoncPath, JSON.stringify(normalized, null, 2) + "\n"); + await this.ensureOverridesGitignored(runtime, workspacePath, metadata.runtimeConfig); + }); } } diff --git a/src/node/utils/gitUrls.ts b/src/node/utils/gitUrls.ts new file mode 100644 index 0000000000..9b8f001aff --- /dev/null +++ b/src/node/utils/gitUrls.ts @@ -0,0 +1,52 @@ +/** + * Git remote URL helpers shared by the project clone flow and the Agent + * Plugin installer. + */ + +/** + * `owner/repo` GitHub shorthand: exactly two non-empty segments separated by a + * single slash, where the first segment looks like a GitHub username. + */ +export const GITHUB_SHORTHAND_PATTERN = /^[a-zA-Z0-9][\w-]*\/[a-zA-Z0-9][\w.-]*$/; + +function hasLikelySshCredentials(): boolean { + const sshAgentSocket = process.env.SSH_AUTH_SOCK; + // Be conservative: only prefer git@github.com shorthand when the session has an active + // SSH agent. The mere presence of local key files does not imply GitHub SSH access. + return typeof sshAgentSocket === "string" && sshAgentSocket.trim().length > 0; +} + +/** + * Normalize a repo URL so git clone receives a valid remote. + * Expands "owner/repo" shorthand to either SSH or HTTPS based on likely local credentials. + * All other inputs (HTTPS URLs, SSH URLs, SCP-style, etc.) pass through unchanged. + */ +export function normalizeRepoUrlForClone(repoUrl: string): string { + const trimmedRepoUrl = repoUrl.trim(); + const shorthandCandidate = trimmedRepoUrl.replace(/[\\/]+$/, ""); + + // owner/repo shorthand: excludes local paths like ../repo, ./foo, foo/bar/baz, and + // absolute paths. Note: bare `foo/bar` style local relative paths are intentionally + // treated as GitHub shorthand here because callers (Clone dialog, plugin installer) + // are specifically for remote repos. + if (GITHUB_SHORTHAND_PATTERN.test(shorthandCandidate)) { + // Strip existing .git suffix before appending to avoid double .git (e.g. owner/repo.git → owner/repo.git.git) + const withoutGitSuffix = shorthandCandidate.replace(/\.git$/i, ""); + + // Prefer SSH for shorthand only when the current session has an active SSH agent. + // This avoids assuming GitHub access from unrelated key files on disk. + if (hasLikelySshCredentials()) { + return `git@github.com:${withoutGitSuffix}.git`; + } + + return `https://github.com/${withoutGitSuffix}.git`; + } + + // Strip query strings and fragments only from URL-like inputs (protocol:// or git@), + // not from local paths where # and ? may be valid filename characters. + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmedRepoUrl) || trimmedRepoUrl.startsWith("git@")) { + return trimmedRepoUrl.replace(/[?#].*$/, ""); + } + + return trimmedRepoUrl; +}