From c4c1f983db557221707419a53901ffad2c42e7b9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 11:24:51 +0000 Subject: [PATCH 01/63] feat: Agent Plugins install/update UX (Settings + palette, managed installs) Squashed from 24 commits (original history preserved on PR #3820 review timeline and local branch backup-pre-rebase-9e9c94e2b) to make the rebase onto main tractable: main independently rewrote mcpServerManager (MCP SDK v2) and added a different agentPlugins oRPC schema file. --- docs/agents/agent-skills.mdx | 2 + docs/config/mcp-servers.mdx | 2 + src/browser/App.tsx | 2 + .../WorkspaceMCPModal/WorkspaceMCPModal.tsx | 17 +- .../PluginsSettingsSection.stories.tsx | 306 ++++ .../Sections/PluginsSettingsSection.tsx | 667 +++++++ .../Sections/pluginsSectionIntents.ts | 52 + .../features/Settings/SettingsPage.test.tsx | 41 +- .../features/Settings/SettingsPage.tsx | 34 +- src/browser/stories/mocks/orpc.ts | 47 +- src/browser/utils/commandIds.ts | 6 + src/browser/utils/commands/sources.test.ts | 1 + src/browser/utils/commands/sources.ts | 197 ++ .../config/schemas/agentPluginInstalls.ts | 90 + src/common/config/schemas/appConfigOnDisk.ts | 13 + src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/agentPlugins.ts | 87 + src/common/orpc/schemas/api.ts | 69 +- src/common/utils/agentPluginName.ts | 18 + src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 93 +- src/node/services/agentPlugins/discovery.ts | 28 + .../agentPlugins/installService.test.ts | 1209 +++++++++++++ .../services/agentPlugins/installService.ts | 1593 +++++++++++++++++ src/node/services/agentPlugins/manifest.ts | 10 +- .../services/agentPlugins/sourceInput.test.ts | 105 ++ src/node/services/agentPlugins/sourceInput.ts | 111 ++ .../builtInSkillContent.generated.ts | 4 + src/node/services/aiService.ts | 5 +- src/node/services/mcpServerManager.test.ts | 160 ++ src/node/services/mcpServerManager.ts | 258 ++- src/node/services/projectService.ts | 53 +- src/node/services/serviceContainer.ts | 14 + .../workspaceMcpOverridesService.test.ts | 73 +- .../services/workspaceMcpOverridesService.ts | 106 +- src/node/utils/gitUrls.ts | 58 + 36 files changed, 5399 insertions(+), 135 deletions(-) create mode 100644 src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx create mode 100644 src/browser/features/Settings/Sections/PluginsSettingsSection.tsx create mode 100644 src/browser/features/Settings/Sections/pluginsSectionIntents.ts create mode 100644 src/common/config/schemas/agentPluginInstalls.ts create mode 100644 src/common/utils/agentPluginName.ts create mode 100644 src/node/services/agentPlugins/installService.test.ts create mode 100644 src/node/services/agentPlugins/installService.ts create mode 100644 src/node/services/agentPlugins/sourceInput.test.ts create mode 100644 src/node/services/agentPlugins/sourceInput.ts create mode 100644 src/node/utils/gitUrls.ts diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 9a72370531f..e9d9535da18 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -63,6 +63,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 c2e779565fc..54b3b45353f 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 `~/.xum/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 87a92d55ca9..f599e83d551 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -217,6 +217,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. @@ -993,6 +994,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 2f9cc04b004..8bb8da6e796 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 00000000000..731ad09719d --- /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 00000000000..c96fa0b22f3 --- /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 00000000000..e156c41ec79 --- /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 f4b7cccf94a..b731a34a198 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,31 +12,52 @@ 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({ + expect(getSettingsSectionRedirect("memory", false, false, false)).toEqual({ section: "general", }); - expect(getSettingsSectionRedirect("memory", false, true)).toBeNull(); + 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(); }); test("always shows the Backup section", () => { - expect(getSettingsSections(false, false).map((section) => section.id)).toContain("backup"); - expect(getSettingsSections(true, true).map((section) => section.id)).toContain("backup"); - expect(getSettingsSectionRedirect("backup", false, false)).toBeNull(); + expect(getSettingsSections(false, false, false).map((section) => section.id)).toContain( + "backup" + ); + expect(getSettingsSections(true, true, false).map((section) => section.id)).toContain("backup"); + expect(getSettingsSectionRedirect("backup", false, false, false)).toBeNull(); }); }); diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index 45baed902ff..7a303b3b164 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, @@ -32,6 +33,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 { InstructionsSection } from "./Sections/InstructionsSection"; import { LayoutsSection } from "./Sections/LayoutsSection"; @@ -133,9 +135,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", @@ -165,7 +178,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 }; @@ -179,6 +193,10 @@ export function getSettingsSectionRedirect( return { section: BASE_SECTIONS[0]?.id ?? "general" }; } + if (!agentPluginsEnabled && activeSection === "plugins") { + return { section: BASE_SECTIONS[0]?.id ?? "general" }; + } + return null; } @@ -192,10 +210,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; } @@ -206,7 +230,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 @@ -225,7 +249,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 50b941b9014..23adc1b36ad 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 { @@ -127,6 +132,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. */ @@ -379,6 +391,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl projectSecrets = new Map(), terminalSessions: initialTerminalSessions = [], globalMcpServers = {}, + agentPlugins: agentPluginsMock, mcpServers = new Map(), mcpOverrides = new Map(), mcpTestResults = new Map(), @@ -1094,6 +1107,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() : ""; @@ -1741,8 +1777,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 4c3b86999c8..d95971b7205 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -95,6 +95,12 @@ export const CommandIds = { coderDisconnect: () => "providers:coder:disconnect" as const, coderRefreshModels: () => "providers:coder:refresh-models" 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 a7f56223752..313d96f58a8 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 5a000559f84..3b02c173428 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -29,6 +29,7 @@ import { } from "@/common/constants/storage"; import { readPersistedState, 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, @@ -116,6 +117,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: { @@ -1600,6 +1603,200 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // generic Providers list. run: () => openSettings("providers", { expandProvider: "coder", startCoderLogin: true }), }, + ...(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 00000000000..5c754cc9ad8 --- /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 b11c177ca87..4a26dc81457 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -18,6 +18,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"; /** * Sparse delegated-run (sub-agent) override profile nested under an agent's diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 62df3b741e5..f0b25177263 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -320,6 +320,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 index da433207809..88c081e2899 100644 --- a/src/common/orpc/schemas/agentPlugins.ts +++ b/src/common/orpc/schemas/agentPlugins.ts @@ -1,5 +1,92 @@ 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; + /** * Agent Plugins (agent-plugins.org) oRPC schemas: manifest-contributed slash * commands and the per-workspace composition inspector payload. diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index a804312acb9..b3558688c37 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -119,6 +119,13 @@ import { MCPTestResultSchema, WorkspaceMCPOverridesSchema, } from "./mcp"; +import { + AgentPluginGitSourceSchema, + AgentPluginInstallEntrySchema, + AgentPluginInstallPreviewSchema, + AgentPluginListItemSchema, + AgentPluginUpdateCheckSchema, +} from "./agentPlugins"; import { PolicyGetResponseSchema } from "./policy"; import { AgentAiDefaultsSchema, @@ -985,6 +992,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. * @@ -1852,7 +1908,11 @@ 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(), + }), }, prompts: { list: { @@ -1864,6 +1924,13 @@ export const workspace = { 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 00000000000..31271a45b29 --- /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 e89668211df..55fac51a600 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -27,6 +27,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"; @@ -74,6 +75,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 48d7b6ec38f..18e814b89ab 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3187,6 +3187,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) @@ -5629,7 +5704,7 @@ export const router = (authToken?: string) => { policy.mcp.allowUserDefined.remote === false; if (mcpDisabledByPolicy) { - return {}; + return { overrides: {}, revision: "mcp-disabled-by-policy" }; } try { @@ -5637,8 +5712,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" }; } }), prompts: { @@ -5674,9 +5751,10 @@ export const router = (authToken?: string) => { if (!readyResult.ready) { throw new Error(readyResult.error); } - const overrides = await context.workspaceMcpOverridesService.getOverridesForWorkspace( - input.workspaceId - ); + const { overrides } = + await context.workspaceMcpOverridesService.getOverridesForWorkspace( + input.workspaceId + ); // Match streamMessage and the prompt-invocation resolver: multi-project // workspaces need every project's secrets, not just the primary's. const projectSecrets = await secretsToRecord( @@ -5710,7 +5788,8 @@ export const router = (authToken?: string) => { try { await context.workspaceMcpOverridesService.setOverridesForWorkspace( input.workspaceId, - input.overrides + input.overrides, + { expectedRevision: input.expectedRevision } ); // Prompt invocation can hit cached servers before the next stream // recomputes enablement, so sync the manager's view immediately. diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 5cf8adaf250..a1a1da838dd 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -332,6 +332,34 @@ export async function discoverWorkspaceAgentPlugins(args: { return { plugins: contained, diagnostics }; } +/** + * 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 00000000000..9823996dd24 --- /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 00000000000..c956fdc79e8 --- /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 8aaf3b35a11..8b01affa2c0 100644 --- a/src/node/services/agentPlugins/manifest.ts +++ b/src/node/services/agentPlugins/manifest.ts @@ -25,14 +25,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 00000000000..b68e12e2f2d --- /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 00000000000..72e438530fe --- /dev/null +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -0,0 +1,111 @@ +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. The installer intentionally uses only the primary cloneUrl: the + // SSH→HTTPS fallback is a clone-dialog affordance, while plugin installs + // record one canonical source URL for later update fetches. + return { url: normalizeRepoUrlForClone(input).cloneUrl }; + } + + 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).cloneUrl, + ...(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 b7bc4d2725f..db5b387a1bc 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1951,6 +1951,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:", @@ -3787,6 +3789,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 `~/.xum/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 2cc19d35335..59b134f84bf 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1954,8 +1954,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 2ec3880f564..25ec1599979 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -152,6 +152,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 3449884ff38..5dbf5bf9bba 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1077,6 +1077,16 @@ export class MCPServerManager { * probed against. */ private readonly eraVerdicts = 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; @@ -1526,6 +1536,11 @@ export class MCPServerManager { // reads so enablement repair can detect them. const configGenerationUsed = this.configService.configGeneration; + // 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); @@ -1640,19 +1655,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( @@ -1770,9 +1799,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", { @@ -1932,16 +1975,33 @@ export class MCPServerManager { return { tools: {}, toolServerNames: {}, stats, promptDescriptors: [] }; } - const entry: WorkspaceServers = { - configSignature: signature, + // 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). + let entry!: WorkspaceServers; + await this.closeInvalidatedInstancesThenPublish( instances, - enabledServerNames, - stats, - timedOutServerNames: startTimedOutNames, - retryingTimedOutServerNames: new Set(), - lastActivity: Date.now(), - }; - this.workspaceServers.set(workspaceId, entry); + startupEpoch, + workspaceId, + (invalidatedKeys) => { + entry = { + configSignature: signature, + instances, + enabledServerNames, + stats: this.createWorkspaceStats(enabledEntries.length, instances, allFailedNames), + timedOutServerNames: [...startTimedOutNames, ...invalidatedKeys], + retryingTimedOutServerNames: new Set(), + lastActivity: Date.now(), + }; + this.workspaceServers.set(workspaceId, entry); + } + ); // Repair first so the awaited refresh never queries a server revoked // during startup, then again after it so mutations landing during the @@ -1964,7 +2024,9 @@ export class MCPServerManager { return { ...this.collectTools(instances, fullServerInfo, overrides), - stats, + // entry.stats, not the pre-publication `stats`: invalidated instances + // were closed before publication and must not count as started. + stats: entry.stats, promptDescriptors: this.promptDescriptorsFor(entry), }; }); @@ -2379,6 +2441,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, options?: { retainRestartOptions?: boolean } diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index dcbcba5053c..e24528454c2 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -16,6 +16,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, @@ -228,58 +229,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): { - cloneUrl: string; - fallbackCloneUrl?: 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, ""); - const httpsUrl = `https://github.com/${withoutGitSuffix}.git`; - - // 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()) { - // GitHub SSH requires a recognized key even for public repositories, and an agent - // socket does not prove one is available. Keep HTTPS as a fallback for readable repos. - return { cloneUrl: `git@github.com:${withoutGitSuffix}.git`, fallbackCloneUrl: httpsUrl }; - } - - return { cloneUrl: httpsUrl }; - } - - // 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 { cloneUrl: trimmedRepoUrl.replace(/[?#].*$/, "") }; - } - - return { cloneUrl: 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 92bcd33b226..b25e2a743cd 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -49,6 +49,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"; @@ -120,6 +122,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; @@ -227,6 +230,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({ @@ -590,6 +603,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 1a2b5be719a..2dbfb5d2308 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 de5df5a7d93..408972581ea 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 00000000000..0bec26d706a --- /dev/null +++ b/src/node/utils/gitUrls.ts @@ -0,0 +1,58 @@ +/** + * 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): { + cloneUrl: string; + fallbackCloneUrl?: 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, ""); + const httpsUrl = `https://github.com/${withoutGitSuffix}.git`; + + // 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()) { + // GitHub SSH requires a recognized key even for public repositories, and an agent + // socket does not prove one is available. Keep HTTPS as a fallback for readable repos. + return { cloneUrl: `git@github.com:${withoutGitSuffix}.git`, fallbackCloneUrl: httpsUrl }; + } + + return { cloneUrl: httpsUrl }; + } + + // 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 { cloneUrl: trimmedRepoUrl.replace(/[?#].*$/, "") }; + } + + return { cloneUrl: trimmedRepoUrl }; +} From d11b256779b07eb50252e17158279752922cdaa3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 12:14:31 +0000 Subject: [PATCH 02/63] feat: mark the Plugins settings section experimental Same posture as the Backup section: FlaskConical icon on the nav entry (SettingsPage experimental flag) plus an in-section warning banner. --- .../Settings/Sections/PluginsSettingsSection.tsx | 10 ++++++++++ src/browser/features/Settings/SettingsPage.tsx | 1 + 2 files changed, 11 insertions(+) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index c96fa0b22f3..ec75cd972db 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -485,6 +485,16 @@ export const PluginsSettingsSection: React.FC = () => { return (
+ {/* Mirrors the Backup section's experimental posture: nav flask icon + (SettingsPage `experimental: true`) + in-section warning banner. */} +
+
+

Install Agent Plugins from git repositories into{" "} diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index 7a303b3b164..689c32e43de 100644 --- a/src/browser/features/Settings/SettingsPage.tsx +++ b/src/browser/features/Settings/SettingsPage.tsx @@ -147,6 +147,7 @@ export function getSettingsSections( label: "Plugins", icon: , component: PluginsSettingsSection, + experimental: true, }); } if (memoryEnabled) { From a324888b75ac6f713c8c02a26fbc6bad083c537e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 12:42:41 +0000 Subject: [PATCH 03/63] fix: address Codex review round 21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consent preview discloses executable hooks.js (path + the exact tool grants resolvePluginHookGrants honors) — hooks load automatically after install and can observe/rewrite/block tool calls, so consent must surface them. - Override prune reads are strict: a temporarily unreadable or unparseable mcp.local.jsonc fails that workspace's prune (tombstone preserved for retry) instead of reading as {} and retiring the tombstone against keys never seen. Lenient reads stay for UI paths; strict distinguishes genuinely-absent (ENOENT/ENOTDIR, incl. RuntimeError-wrapped causes) from unreadable. - SCP-style source inputs accept the documented optional user portion ([user@]host:path), so host-only remotes like git.example.com:team/plugin.git reach git instead of failing shorthand parsing. --- .../Sections/PluginsSettingsSection.tsx | 18 ++++++ src/common/orpc/schemas/agentPlugins.ts | 14 +++++ .../agentPlugins/installService.test.ts | 25 +++++++++ .../services/agentPlugins/installService.ts | 34 ++++++++++- .../services/agentPlugins/sourceInput.test.ts | 7 +++ src/node/services/agentPlugins/sourceInput.ts | 8 ++- .../workspaceMcpOverridesService.test.ts | 48 ++++++++++++++++ .../services/workspaceMcpOverridesService.ts | 56 +++++++++++++++---- 8 files changed, 195 insertions(+), 15 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index ec75cd972db..2193895129f 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -257,6 +257,24 @@ const AddPluginPanel: React.FC<{

+ {preview.hook && ( +
+

Hooks

+ {/* Executable code that loads automatically: consent must say so. */} +

+ {preview.hook.path}{" "} + + — runs sandboxed on every agent request and can observe, rewrite, or block tool + calls + {preview.hook.toolGrants.length > 0 + ? ` for: ${preview.hook.toolGrants.join(", ")}` + : " (no tool visibility granted)"} + . + +

+
+ )} + {error && (
diff --git a/src/common/orpc/schemas/agentPlugins.ts b/src/common/orpc/schemas/agentPlugins.ts index 88c081e2899..ba18cdd3d11 100644 --- a/src/common/orpc/schemas/agentPlugins.ts +++ b/src/common/orpc/schemas/agentPlugins.ts @@ -25,6 +25,17 @@ export const AgentPluginPreviewMcpServerSchema = z.object({ summary: z.string(), }); +/** + * Executable hooks.js disclosure: hooks load automatically after install and + * can observe/rewrite/block tool calls, so consent must surface them. + */ +export const AgentPluginPreviewHookSchema = z.object({ + /** Plugin-relative path to the hook entry file (e.g. "hooks.js"). */ + path: z.string(), + /** Tool names the manifest requests visibility into (empty = least privilege, no tools). */ + toolGrants: z.array(z.string()), +}); + /** Manifest metadata surfaced in the consent preview (UI-safe projection of plugin.json). */ export const AgentPluginManifestSummarySchema = z.object({ name: z.string(), @@ -47,6 +58,8 @@ export const AgentPluginInstallPreviewSchema = z.object({ manifest: AgentPluginManifestSummarySchema, skills: z.array(AgentPluginPreviewSkillSchema), mcpServers: z.array(AgentPluginPreviewMcpServerSchema), + /** Present when the plugin ships an executable hooks.js (absent = no hooks). */ + hook: AgentPluginPreviewHookSchema.optional(), /** Manifest warnings + component diagnostics from validating the staged clone. */ warnings: z.array(z.string()), /** Final install directory (~/.mux/plugins/). */ @@ -82,6 +95,7 @@ export const AgentPluginUpdateCheckSchema = z.object({ export type AgentPluginPreviewSkill = z.infer; export type AgentPluginPreviewMcpServer = z.infer; +export type AgentPluginPreviewHook = z.infer; export type AgentPluginManifestSummary = z.infer; export type AgentPluginInstallPreview = z.infer; export type AgentPluginListItem = z.infer; diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 9823996dd24..49092582e13 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -131,6 +131,31 @@ describe("AgentPluginInstallService", () => { expect(preview.warnings.some((warning) => warning.includes("skills/escaping"))).toBe(true); }); + test("consent preview discloses executable hooks with their tool grants", async () => { + // hooks.js loads automatically after install and can rewrite/block tool + // calls — consent must surface it (with the grants the runtime honors). + expect((await service.preview({ input: remoteDir })).hook).toBeUndefined(); + + await fsPromises.writeFile( + path.join(remoteDir, "hooks.js"), + "({ 'tool.execute.before': () => undefined })\n" + ); + await commitAll(remoteDir, "least-privilege hook"); + const leastPrivilege = await service.preview({ input: remoteDir }); + expect(leastPrivilege.hook).toEqual({ path: "hooks.js", toolGrants: [] }); + + const manifestPath = path.join(remoteDir, "plugin.json"); + const manifest = JSON.parse(await fsPromises.readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + manifest.extensions = { mux: { hooks: { tools: ["bash", "file_read"] } } }; + await fsPromises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + await commitAll(remoteDir, "hook with tool grants"); + const granted = await service.preview({ input: remoteDir }); + expect(granted.hook).toEqual({ path: "hooks.js", toolGrants: ["bash", "file_read"] }); + }); + test("preview stages+validates without writing; install promotes and records the registry", async () => { const head = (await git(remoteDir, "rev-parse", "HEAD")).trim(); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index c956fdc79e8..3df815f50c4 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -14,10 +14,12 @@ import type { AgentPluginInstallPreview, AgentPluginListItem, AgentPluginManifestSummary, + AgentPluginPreviewHook, AgentPluginPreviewMcpServer, AgentPluginPreviewSkill, AgentPluginUpdateCheck, } from "@/common/orpc/schemas/agentPlugins"; +import { resolvePluginHookGrants } from "@/node/services/agentPlugins/hookSandbox"; import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import type { Config } from "@/node/config"; @@ -582,6 +584,27 @@ export class AgentPluginInstallService { return { plugin, warnings: diagnostics.map((d) => d.message) }; } + /** + * Executable hooks.js disclosure for the consent preview: hooks load + * automatically before request assembly and can observe/rewrite/block tool + * calls, so installing one without disclosure would consent to less than + * what activates. toolGrants mirrors resolvePluginHookGrants — the exact + * grants the runtime will honor. + */ + private collectHook( + plugin: Pick + ): AgentPluginPreviewHook | undefined { + if (plugin.hooksPath === undefined) { + return undefined; + } + const grants = resolvePluginHookGrants(plugin.manifest); + assert(grants.bridgeTools.allow !== "all", "plugin hook grants must enumerate tools"); + return { + path: path.relative(plugin.rootPath, plugin.hooksPath), + toolGrants: [...grants.bridgeTools.allow], + }; + } + private async collectSkills( plugin: Pick, warnings: string[] @@ -739,6 +762,7 @@ export class AgentPluginInstallService { this.instanceIdFor(plugin.name), warnings ); + const hook = this.collectHook(plugin); if (resolved.refType === "tag" && sha !== resolved.sha) { warnings.push( @@ -758,6 +782,7 @@ export class AgentPluginInstallService { manifest: manifestSummary(plugin.manifest), skills, mcpServers, + ...(hook !== undefined ? { hook } : {}), warnings, targetPath: shortenHome(targetPath), }; @@ -1166,8 +1191,13 @@ export class AgentPluginInstallService { for (const workspaceId of workspaceIds) { try { for (let attempt = 1; ; attempt++) { - const { overrides, revision } = - await overridesService.getOverridesForWorkspace(workspaceId); + // Strict read: a temporarily unreadable overrides file must FAIL + // this workspace's prune (preserving its tombstone for retry), not + // read as "{}" and retire the tombstone against keys never seen. + const { overrides, revision } = await overridesService.getOverridesForWorkspace( + workspaceId, + { mode: "strict" } + ); const dropKey = (key: string) => key.startsWith(serverKeyPrefix); const enabledServers = overrides.enabledServers?.filter((key) => !dropKey(key)); const disabledServers = overrides.disabledServers?.filter((key) => !dropKey(key)); diff --git a/src/node/services/agentPlugins/sourceInput.test.ts b/src/node/services/agentPlugins/sourceInput.test.ts index b68e12e2f2d..790221ccc65 100644 --- a/src/node/services/agentPlugins/sourceInput.test.ts +++ b/src/node/services/agentPlugins/sourceInput.test.ts @@ -61,6 +61,13 @@ describe("parseAgentPluginSourceInput", () => { expect(parseAgentPluginSourceInput("ssh://git@git.corp:2222/x/y.git").url).toBe( "ssh://git@git.corp:2222/x/y.git" ); + // SCP-style user portion is optional (git-clone#_git_urls): host-only + // remotes must reach git instead of failing shorthand parsing. + expect(parseAgentPluginSourceInput("git.example.com:team/plugin.git")).toEqual({ + url: "git.example.com:team/plugin.git", + }); + // ...while slash-before-colon inputs stay on the shorthand path. + expect(parseAgentPluginSourceInput("coder/mux@main").ref).toBe("main"); }); test("does not treat @ inside URLs as a ref separator", () => { diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts index 72e438530fe..e868a5b3538 100644 --- a/src/node/services/agentPlugins/sourceInput.ts +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -42,9 +42,11 @@ function isUrlLike(input: string): boolean { 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); + // Other SCP-style forms ([user@]host:path) — the user portion is optional + // per Git's documented grammar (git-clone#_git_urls). `owner/repo@ref` + // shorthand never matches: it has no colon, and the host char class + // excludes `/` (Git's own rule: no slash before the first colon). + return /^(?:[a-zA-Z0-9._-]+@)?[a-zA-Z0-9._-]+:.+$/.test(input); } /** diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index 2dbfb5d2308..fb48e7deecc 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -236,6 +236,54 @@ describe("WorkspaceMcpOverridesService", () => { expect(after.overrides).toEqual({ disabledServers: ["other"] }); }); + it("strict reads throw on unreadable content instead of reporting empty 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(path.join(workspacePath, ".mux"), { recursive: true }); + // Content exists but is not parseable: the plugin uninstaller's prune + // must NOT see "{}" here — it would retire its tombstone against keys it + // never read, resurrecting stale enabledServers on reinstall. + await fs.writeFile( + path.join(workspacePath, ".mux", "mcp.local.jsonc"), + '{ "enabledServers": ["plugin:abc:echo"' + ); + + 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); + // Lenient (UI/list paths): degrade to empty. + const lenient = await service.getOverridesForWorkspace(workspaceId); + expect(lenient.overrides).toEqual({}); + // Strict (prune path): fail loudly so the caller keeps its retry state. + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.getOverridesForWorkspace(workspaceId, { mode: "strict" })).rejects.toThrow( + /parse errors/ + ); + // Strict on a genuinely absent file is still fine (no overrides). + await fs.rm(path.join(workspacePath, ".mux", "mcp.local.jsonc")); + const absent = await service.getOverridesForWorkspace(workspaceId, { mode: "strict" }); + expect(absent.overrides).toEqual({}); + }); + it("removes workspace-local file when overrides are set to empty", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index 408972581ea..ba11c9643c5 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -9,6 +9,7 @@ import type { Config } from "@/node/config"; import { type createRuntime } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; +import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; import { log } from "@/node/services/log"; import { getErrorMessage } from "@/common/utils/errors"; @@ -124,14 +125,33 @@ function isEmptyOverrides(overrides: WorkspaceMCPOverrides): boolean { ); } +/** True when the error (or its RuntimeError-wrapped cause) carries the fs code. */ +function hasFsCode(error: unknown, code: string): boolean { + if (hasErrorCode(error, code)) { + return true; + } + const cause = error instanceof Error ? error.cause : undefined; + return hasErrorCode(cause, code); +} + async function statIsFile( runtime: ReturnType, - filePath: string + filePath: string, + mode: "lenient" | "strict" ): Promise { try { const stat = await runtime.stat(filePath); return !stat.isDirectory; - } catch { + } catch (error) { + // Strict callers must distinguish "file genuinely absent" (fine: no + // overrides) from "cannot tell" (EACCES, I/O error): treating the latter + // as absent would let the plugin uninstaller retire a prune tombstone + // against a file it never actually read. Strict reads only run against + // local/worktree runtimes, so node fs error codes are reliable here + // (RuntimeError wraps them as `cause`). + if (mode === "strict" && !hasFsCode(error, "ENOENT") && !hasFsCode(error, "ENOTDIR")) { + throw error; + } return false; } } @@ -229,13 +249,22 @@ export class WorkspaceMcpOverridesService { private async readOverridesFile( runtime: ReturnType, - filePath: string + filePath: string, + mode: "lenient" | "strict" ): Promise { try { const raw = await readFileString(runtime, filePath); const errors: jsonc.ParseError[] = []; const parsed: unknown = jsonc.parse(raw, errors) as unknown; if (errors.length > 0) { + // Strict callers (the plugin uninstaller's override prune) must not + // see "{}" for a file whose real content is unreadable: retiring a + // prune tombstone against that empty view would let the stale + // enabledServers key silently re-enable a reinstalled plugin's + // server once the file becomes readable again. + if (mode === "strict") { + throw new Error(`Workspace MCP overrides file has JSONC parse errors: ${filePath}`); + } log.warn("[MCP] Failed to parse workspace MCP overrides (JSONC parse errors)", { filePath, errorCount: errors.length, @@ -244,6 +273,9 @@ export class WorkspaceMcpOverridesService { } return parsed; } catch (error) { + if (mode === "strict") { + throw error; + } // Treat any read failure as "no overrides". log.debug("[MCP] Failed to read workspace MCP overrides file", { filePath, error }); return {}; @@ -368,13 +400,17 @@ export class WorkspaceMcpOverridesService { * expectedRevision check. */ async getOverridesForWorkspace( - workspaceId: string + workspaceId: string, + options?: { mode?: "lenient" | "strict" } ): Promise<{ overrides: WorkspaceMCPOverrides; revision: string }> { - const overrides = await this.loadOverrides(workspaceId); + const overrides = await this.loadOverrides(workspaceId, options?.mode ?? "lenient"); return { overrides, revision: computeOverridesRevision(overrides) }; } - private async loadOverrides(workspaceId: string): Promise { + private async loadOverrides( + workspaceId: string, + mode: "lenient" | "strict" = "lenient" + ): Promise { const { metadata, runtime, workspacePath } = await this.getRuntimeAndWorkspacePath(workspaceId); const { jsoncPath, jsonPath } = this.getOverridesFilePaths( workspacePath, @@ -382,15 +418,15 @@ export class WorkspaceMcpOverridesService { ); // Prefer JSONC, then JSON. - const jsoncExists = await statIsFile(runtime, jsoncPath); + const jsoncExists = await statIsFile(runtime, jsoncPath, mode); if (jsoncExists) { - const parsed = await this.readOverridesFile(runtime, jsoncPath); + const parsed = await this.readOverridesFile(runtime, jsoncPath, mode); return normalizeWorkspaceMcpOverrides(parsed); } - const jsonExists = await statIsFile(runtime, jsonPath); + const jsonExists = await statIsFile(runtime, jsonPath, mode); if (jsonExists) { - const parsed = await this.readOverridesFile(runtime, jsonPath); + const parsed = await this.readOverridesFile(runtime, jsonPath, mode); return normalizeWorkspaceMcpOverrides(parsed); } From f58205a4a65f8073cc1b961b2b1a6ef74c8d840d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 14:24:29 +0000 Subject: [PATCH 04/63] fix: address Codex review round 22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install rollback invalidates the plugin's server prefix: a getToolsForWorkspace running during the promote↔rollback window can have discovered the briefly-visible tree; without the invalidation a server from the deleted, unregistered tree would survive the failed install. - workspace.mcp.set validates newly ADDED plugin:: keys against the current registry (buildAddedPluginKeyValidator, run inside the exclusive write queue): content-derived revisions cannot detect an uninstall that left overrides byte-identical, so a stale dialog could persist a key for an uninstalled plugin and a reinstall would silently enable it. Existing keys round-trip untouched. - Override pruning patches the RAW parsed document (prunePluginOverrideKeys on the overrides service, inside its write queue): only prefix-matching keys in the three known fields are dropped, so a newer build's extra top-level fields survive downgrade-side prunes. Strict reads preserved (unreadable file → tombstone retry). The CAS-retry loop in the installer became obsolete — the prune now serializes with dialog saves by construction. --- src/node/orpc/router.ts | 12 +- .../agentPlugins/installService.test.ts | 142 ++++++++---------- .../services/agentPlugins/installService.ts | 81 ++++------ src/node/services/agentPlugins/mcpConfig.ts | 64 +++++++- .../workspaceMcpOverridesService.test.ts | 63 ++++++++ .../services/workspaceMcpOverridesService.ts | 93 +++++++++++- 6 files changed, 324 insertions(+), 131 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 18e814b89ab..47509335baf 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -50,6 +50,7 @@ import { resolveWorkspaceRootPath, } from "@/node/runtime/runtimeHelpers"; import { + buildAddedPluginKeyValidator, resolveAgentPluginsMcpContext, type AgentPluginsMcpContext, } from "@/node/services/agentPlugins/mcpConfig"; @@ -5789,7 +5790,16 @@ export const router = (authToken?: string) => { await context.workspaceMcpOverridesService.setOverridesForWorkspace( input.workspaceId, input.overrides, - { expectedRevision: input.expectedRevision } + { + expectedRevision: input.expectedRevision, + // Content-derived revisions cannot detect an uninstall that + // left overrides byte-identical ({} before and after), so a + // stale dialog could persist a plugin: key for a plugin + // that is gone — validate additions against the registry. + validateAgainstCurrent: buildAddedPluginKeyValidator(() => + context.agentPluginInstallService.listInstalledInstanceIds() + ), + } ); // Prompt invocation can hit cached servers before the next stream // recomputes enablement, so sync the manager's view immediately. diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 49092582e13..d57d39d0b75 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -6,14 +6,12 @@ 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 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, + buildAddedPluginKeyValidator, computePluginInstanceId, getPluginDataPath, } from "./mcpConfig"; @@ -385,8 +383,7 @@ describe("AgentPluginInstallService", () => { // 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(), + prunePluginOverrideKeys: () => Promise.resolve(), }; const serviceWithMcp = new AgentPluginInstallService(config, { isEnabled: () => true, @@ -430,17 +427,15 @@ describe("AgentPluginInstallService", () => { let overridesBroken = true; let storedOverrides: Record = { enabledServers: [serverKey] }; const overridesStub = { - getOverridesForWorkspace: () => { + prunePluginOverrideKeys: (_id: string, keyPrefix: string) => { 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; + storedOverrides = { + enabledServers: (storedOverrides.enabledServers as string[]).filter( + (key) => !key.startsWith(keyPrefix) + ), + }; return Promise.resolve(); }, }; @@ -496,61 +491,10 @@ describe("AgentPluginInstallService", () => { } }); - 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(), + prunePluginOverrideKeys: () => Promise.reject(new Error("checkout unavailable")), }; const serviceWithOverrides = new AgentPluginInstallService(config, { isEnabled: () => true, @@ -613,8 +557,7 @@ describe("AgentPluginInstallService", () => { // 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(), + prunePluginOverrideKeys: () => Promise.reject(new Error("Workspace metadata not found")), }; const serviceWithOverrides = new AgentPluginInstallService(config, { isEnabled: () => true, @@ -684,11 +627,9 @@ describe("AgentPluginInstallService", () => { releasePrune = resolve; }); const overridesStub = { - getOverridesForWorkspace: async () => { + prunePluginOverrideKeys: async () => { await pruneGate; - return { overrides: {}, revision: "r0" }; }, - setOverridesForWorkspace: () => Promise.resolve(), }; const serviceWithOverrides = new AgentPluginInstallService(config, { isEnabled: () => true, @@ -1169,9 +1110,24 @@ describe("AgentPluginInstallService", () => { }); test("install rolls back the promoted dir when the registry write fails", async () => { - const preview = await service.preview({ input: remoteDir }); + // A getToolsForWorkspace running during the promote↔rollback window can + // have discovered the briefly-visible tree; the rollback must invalidate + // the plugin prefix (like update/uninstall) so no server survives from + // the deleted, unregistered tree. + const stoppedPrefixes: string[] = []; + const mcpStub = { + stopServersWithKeyPrefix: (prefix: string) => { + stoppedPrefixes.push(prefix); + return Promise.resolve(); + }, + }; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub as unknown as MCPServerManager, + }); + const preview = await serviceWithMcp.preview({ input: remoteDir }); - const internals = service as unknown as { + const internals = serviceWithMcp as unknown as { writeRegistry: (envelope: Record, entries: unknown[]) => Promise; }; const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(() => @@ -1179,22 +1135,56 @@ describe("AgentPluginInstallService", () => { ); try { await expect( - service.install({ source: preview.source, expectedSha: preview.lockedSha }) + serviceWithMcp.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. + // No partial state: the promoted dir was rolled back and any server + // started from the briefly-visible tree was invalidated. expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); expect(await stagingLeftovers()).toEqual([]); + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`]); // The retry of the same consented install succeeds. - const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const entry = await serviceWithMcp.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); expect(entry.name).toBe("demo-plugin"); expect(await registry()).toHaveLength(1); }); + test("added plugin override keys are rejected for uninstalled instances", async () => { + // The overrides revision is content-derived, so a dialog opened before an + // uninstall (overrides {}) sees an unchanged revision after it — only a + // registry check at save time can reject the ghost row's new key. + 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 installedKey = `plugin:${instanceId}:echo`; + + const validator = buildAddedPluginKeyValidator(() => service.listInstalledInstanceIds()); + + // Installed instance: addition accepted. + await validator({}, { enabledServers: [installedKey] }); + + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + + // Uninstalled instance: NEW key rejected (enabled list, allowlist alike)… + await expect(validator({}, { enabledServers: [installedKey] })).rejects.toThrow( + /no longer installed/ + ); + await expect(validator({}, { toolAllowlist: { [installedKey]: [] } })).rejects.toThrow( + /no longer installed/ + ); + // …while round-tripping an EXISTING stale key and non-plugin keys stays allowed. + await validator({ enabledServers: [installedKey] }, { enabledServers: [installedKey] }); + await validator({}, { enabledServers: ["ordinary-server"] }); + }); + 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 diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 3df815f50c4..c8040e60d52 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -26,10 +26,7 @@ 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 type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; import { execFileAsync } from "@/node/utils/disposableExec"; import { @@ -860,6 +857,13 @@ export class AgentPluginInstallService { // No partial state: a promote without a registry entry would look // like an unmanaged dir and block reinstall. await this.removeDir(targetPath); + // A getToolsForWorkspace running during the promote↔rollback window + // can have discovered the briefly-visible tree and be starting a + // server from it; invalidate the prefix (same as update/uninstall) + // so it is closed instead of surviving the failed install. + await this.deps.mcpServerManager?.stopServersWithKeyPrefix( + `plugin:${this.instanceIdFor(name)}:` + ); throw new Error(`Failed to persist the plugin registry: ${getErrorMessage(error)}`); } log.info(`Installed agent plugin '${name}' at ${args.expectedSha.slice(0, 12)}`); @@ -870,6 +874,25 @@ export class AgentPluginInstallService { }); } + /** + * Instance IDs owned by current registry entries (including entries this + * build cannot parse — raw names still own their identity). Used by the + * workspace.mcp.set handler to reject newly added `plugin:` override keys + * for uninstalled plugins. No enablement assert: validation must hold even + * while the experiment is being toggled. + */ + async listInstalledInstanceIds(): Promise> { + const { rawEntries } = await this.readRegistryDocument("lenient"); + const instanceIds = new Set(); + for (const rawEntry of rawEntries) { + const name = this.rawEntryName(rawEntry); + if (name !== undefined) { + instanceIds.add(this.instanceIdFor(name)); + } + } + return instanceIds; + } + /** Managed registry entries merged with unmanaged plugins found by global discovery. */ async list(): Promise { this.assertEnabled(); @@ -1184,55 +1207,13 @@ export class AgentPluginInstallService { 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++) { - // Strict read: a temporarily unreadable overrides file must FAIL - // this workspace's prune (preserving its tombstone for retry), not - // read as "{}" and retire the tombstone against keys never seen. - const { overrides, revision } = await overridesService.getOverridesForWorkspace( - workspaceId, - { mode: "strict" } - ); - 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; - } - } + // Raw in-queue patch: preserves unknown fields written by newer + // builds, throws on unreadable files (tombstone retry), and cannot + // interleave with a dialog save (shared exclusive write queue). + await overridesService.prunePluginOverrideKeys(workspaceId, serverKeyPrefix); } catch (error) { failedWorkspaceIds.push(workspaceId); log.warn("Failed to prune plugin MCP overrides for workspace", { diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 14dcd942d16..ca554b5cc8a 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -3,7 +3,7 @@ import { constants as fsConstants } from "node:fs"; import * as fsPromises from "node:fs/promises"; import * as path from "node:path"; -import type { MCPServerInfo, MCPStdioServerInfo } from "@/common/types/mcp"; +import type { MCPServerInfo, MCPStdioServerInfo, WorkspaceMCPOverrides } from "@/common/types/mcp"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; @@ -61,6 +61,68 @@ export function buildPluginServerKey(instanceId: string, serverName: string): st return `${PLUGIN_SERVER_KEY_PREFIX}${instanceId}:${serverName}`; } +/** Instance ID embedded in a `plugin::` override key (undefined for non-plugin keys). */ +export function pluginInstanceIdFromServerKey(serverKey: string): string | undefined { + if (!serverKey.startsWith(PLUGIN_SERVER_KEY_PREFIX)) { + return undefined; + } + const instanceId = serverKey.slice(PLUGIN_SERVER_KEY_PREFIX.length).split(":")[0]; + return instanceId !== undefined && instanceId.length > 0 ? instanceId : undefined; +} + +function collectPluginOverrideKeys(overrides: WorkspaceMCPOverrides): Set { + return new Set( + [ + ...(overrides.enabledServers ?? []), + ...(overrides.disabledServers ?? []), + ...Object.keys(overrides.toolAllowlist ?? {}), + ].filter((key) => key.startsWith(PLUGIN_SERVER_KEY_PREFIX)) + ); +} + +/** + * Save-time validator for workspace MCP override writes: rejects NEWLY ADDED + * `plugin::` keys whose instance is not currently installed. + * + * Why additions-only, at write time: the overrides revision is content-derived, + * so a dialog opened while a default-disabled plugin had no override key sees + * the same revision ({} hash) before and after that plugin's uninstall — the + * CAS check alone cannot tell the snapshot is stale. Without this, the stale + * dialog could enable the ghost row, persist its key, and a later reinstall of + * the same instance ID would silently re-enable the server without consent. + * Existing keys round-trip untouched so unrelated saves never break. + */ +export function buildAddedPluginKeyValidator( + listInstalledInstanceIds: () => Promise> +): (current: WorkspaceMCPOverrides, incoming: WorkspaceMCPOverrides) => Promise { + return async (current, incoming) => { + const currentKeys = collectPluginOverrideKeys(current); + const addedKeys = [...collectPluginOverrideKeys(incoming)].filter( + (key) => !currentKeys.has(key) + ); + if (addedKeys.length === 0) { + return; + } + let installedIds: Set; + try { + installedIds = await listInstalledInstanceIds(); + } catch { + // Cannot confirm → reject the additions (never accept unverifiable keys). + installedIds = new Set(); + } + const staleKeys = addedKeys.filter((key) => { + const instanceId = pluginInstanceIdFromServerKey(key); + return instanceId === undefined || !installedIds.has(instanceId); + }); + if (staleKeys.length > 0) { + throw new Error( + `Cannot save: ${staleKeys.join(", ")} belongs to a plugin that is no longer installed. ` + + "Close and reopen this dialog to load the current server list." + ); + } + }; +} + export interface LoadPluginMcpServersResult { servers: Record; diagnostics: AgentPluginDiagnostic[]; diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index fb48e7deecc..2dfd19cdddb 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -284,6 +284,69 @@ describe("WorkspaceMcpOverridesService", () => { expect(absent.overrides).toEqual({}); }); + it("prunePluginOverrideKeys removes only prefix keys and preserves unknown fields", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + // A newer build's file: extra top-level field + mixed keys. The prune + // must drop ONLY the plugin's keys and keep everything else byte-safe + // for downgrade round-trips (AGENTS.md upgrade↔downgrade rule). + await fs.writeFile( + filePath, + JSON.stringify({ + futureField: { keep: "me" }, + enabledServers: ["plugin:abc:echo", "other-server"], + disabledServers: ["plugin:abc:beta"], + toolAllowlist: { "plugin:abc:echo": ["t1"], "other-server": ["t2"] }, + }) + ); + + 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.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + + const after = JSON.parse(await fs.readFile(filePath, "utf-8")) as Record; + expect(after).toEqual({ + futureField: { keep: "me" }, + enabledServers: ["other-server"], + disabledServers: [], + toolAllowlist: { "other-server": ["t2"] }, + }); + + // Unreadable content must throw (callers keep their retry tombstones). + await fs.writeFile(filePath, "{ not json"); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( + /parse errors/ + ); + + // A missing file is nothing to prune (plugin keys only ever live in + // workspace-local files). + await fs.rm(filePath); + await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + }); + it("removes workspace-local file when overrides are set to empty", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index ba11c9643c5..76539cf4855 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -489,16 +489,34 @@ export class WorkspaceMcpOverridesService { async setOverridesForWorkspace( workspaceId: string, overrides: WorkspaceMCPOverrides, - options?: { expectedRevision?: string } + options?: { + expectedRevision?: string; + /** + * Extra write-time validation run inside the exclusive queue after the + * CAS check, with the CURRENT stored overrides and the normalized + * incoming ones. Throwing rejects the save. Used by the oRPC handler to + * refuse newly added `plugin:` keys for uninstalled plugins, which the + * content-derived revision alone cannot catch (see + * buildAddedPluginKeyValidator). + */ + validateAgainstCurrent?: ( + current: WorkspaceMCPOverrides, + incoming: WorkspaceMCPOverrides + ) => Promise; + } ): Promise { assert(overrides && typeof overrides === "object", "overrides must be an object"); return this.runExclusive(async () => { - if (options?.expectedRevision !== undefined) { + if (options?.expectedRevision !== undefined || options?.validateAgainstCurrent) { const current = await this.loadOverrides(workspaceId); - if (computeOverridesRevision(current) !== options.expectedRevision) { + if ( + options.expectedRevision !== undefined && + computeOverridesRevision(current) !== options.expectedRevision + ) { throw new WorkspaceMcpOverridesConflictError(); } + await options.validateAgainstCurrent?.(current, normalizeWorkspaceMcpOverrides(overrides)); } const { metadata, runtime, workspacePath } = @@ -520,4 +538,73 @@ export class WorkspaceMcpOverridesService { await this.ensureOverridesGitignored(runtime, workspacePath, metadata.runtimeConfig); }); } + + /** + * Remove every override key starting with `keyPrefix` from this workspace's + * override files, PRESERVING all fields this build does not recognize. + * + * Used by the Agent Plugin uninstaller. It patches the RAW parsed document + * (only filtering the three known fields) rather than round-tripping + * through get+set: a newer build's extra top-level fields must survive a + * downgrade-side prune (AGENTS.md upgrade↔downgrade rule). Runs inside the + * exclusive write queue, so it cannot interleave with a dialog save's + * read-modify-write. Reads are strict: an unreadable file throws so the + * caller keeps its retry tombstone instead of retiring it against content + * it never saw. A missing file means nothing to prune — plugin keys are + * only ever written to workspace-local files (legacy config.json storage + * predates Agent Plugins). + */ + async prunePluginOverrideKeys(workspaceId: string, keyPrefix: string): Promise { + assert(keyPrefix.length > 0, "prunePluginOverrideKeys: keyPrefix must be non-empty"); + + return this.runExclusive(async () => { + const { metadata, runtime, workspacePath } = + await this.getRuntimeAndWorkspacePath(workspaceId); + const { jsoncPath, jsonPath } = this.getOverridesFilePaths( + workspacePath, + metadata.runtimeConfig + ); + + for (const filePath of [jsoncPath, jsonPath]) { + if (!(await statIsFile(runtime, filePath, "strict"))) { + continue; + } + const parsed = await this.readOverridesFile(runtime, filePath, "strict"); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + continue; + } + const raw = { ...(parsed as Record) }; + let changed = false; + + for (const field of ["enabledServers", "disabledServers"] as const) { + const value = raw[field]; + if (!Array.isArray(value)) { + continue; + } + const filtered = value.filter( + (key) => !(typeof key === "string" && key.startsWith(keyPrefix)) + ); + if (filtered.length !== value.length) { + raw[field] = filtered; + changed = true; + } + } + + const allowlist = raw.toolAllowlist; + if (allowlist !== null && typeof allowlist === "object" && !Array.isArray(allowlist)) { + const entries = Object.entries(allowlist as Record); + const kept = entries.filter(([key]) => !key.startsWith(keyPrefix)); + if (kept.length !== entries.length) { + raw.toolAllowlist = Object.fromEntries(kept); + changed = true; + } + } + + if (!changed) { + continue; + } + await writeFileString(runtime, filePath, JSON.stringify(raw, null, 2) + "\n"); + } + }); + } } From 83ea913f55084763e545b0dda5a1cd9793f6aa32 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 14:39:56 +0000 Subject: [PATCH 05/63] fix: address Codex review round 23 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: the added-plugin-key validator now checks against the workspace's DISCOVERED plugin server keys (the same mcpConfigService.listServers set the modal lists from — managed installs, project containers, ~/.agents/plugins, unmanaged dirs) instead of only the managed registry, which wrongly blocked enabling non-managed plugin servers. Key-based matching also validates the exact server, not just the instance. listInstalledInstanceIds and pluginInstanceIdFromServerKey became unused and were removed. - P2: source input rejects credential-bearing URLs (userinfo or known token query params, via the shared hasUrlCredentials helper the backup-repository schema uses): sources are persisted verbatim in plugins.json and rendered in Settings/consent previews. SSH usernames remain allowed (routing data). --- src/node/orpc/router.ts | 29 +++++++++-- .../agentPlugins/installService.test.ts | 48 +++++++++++-------- .../services/agentPlugins/installService.ts | 19 -------- src/node/services/agentPlugins/mcpConfig.ts | 33 +++++-------- .../services/agentPlugins/sourceInput.test.ts | 17 +++++++ src/node/services/agentPlugins/sourceInput.ts | 12 +++++ 6 files changed, 95 insertions(+), 63 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 47509335baf..5826f7aa65c 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -5795,10 +5795,31 @@ export const router = (authToken?: string) => { // Content-derived revisions cannot detect an uninstall that // left overrides byte-identical ({} before and after), so a // stale dialog could persist a plugin: key for a plugin - // that is gone — validate additions against the registry. - validateAgainstCurrent: buildAddedPluginKeyValidator(() => - context.agentPluginInstallService.listInstalledInstanceIds() - ), + // that is gone. Validate additions against the DISCOVERED + // plugin server keys for this workspace (managed installs, + // project containers, ~/.agents/plugins, unmanaged dirs) — + // the same set the modal lists from. + validateAgainstCurrent: buildAddedPluginKeyValidator(async () => { + const metadataResult = await context.aiService.getWorkspaceMetadata( + input.workspaceId + ); + if (!metadataResult.success) { + throw new Error(metadataResult.error); + } + const projectPath = metadataResult.data.projectPath; + const servers = await context.mcpConfigService.listServers( + projectPath, + isTrustedProjectPath(context, projectPath), + { + agentPlugins: await resolveWorkspaceAgentPluginsMcpContext( + context, + input.workspaceId, + projectPath + ), + } + ); + return new Set(Object.keys(servers).filter((key) => key.startsWith("plugin:"))); + }), } ); // Prompt invocation can hit cached servers before the next stream diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index d57d39d0b75..ebfeaa2be22 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1157,32 +1157,40 @@ describe("AgentPluginInstallService", () => { expect(await registry()).toHaveLength(1); }); - test("added plugin override keys are rejected for uninstalled instances", async () => { + test("added plugin override keys are validated against discovered servers", async () => { // The overrides revision is content-derived, so a dialog opened before an // uninstall (overrides {}) sees an unchanged revision after it — only a - // registry check at save time can reject the ghost row's new key. - 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 installedKey = `plugin:${instanceId}:echo`; - - const validator = buildAddedPluginKeyValidator(() => service.listInstalledInstanceIds()); - - // Installed instance: addition accepted. - await validator({}, { enabledServers: [installedKey] }); - - await service.uninstall({ name: "demo-plugin", deletePluginData: false }); - - // Uninstalled instance: NEW key rejected (enabled list, allowlist alike)… - await expect(validator({}, { enabledServers: [installedKey] })).rejects.toThrow( - /no longer installed/ + // discovery check at save time can reject the ghost row's new key. The + // source is DISCOVERED server keys (managed + project + ~/.agents + + // unmanaged containers), not the managed registry, so non-managed plugin + // servers stay enableable. + const discoveredKey = "plugin:abc123:echo"; + const validator = buildAddedPluginKeyValidator(() => Promise.resolve(new Set([discoveredKey]))); + + // Discovered server (managed or not): addition accepted. + await validator({}, { enabledServers: [discoveredKey] }); + + // Undiscovered plugin key: NEW key rejected (enabled list, allowlist alike)… + await expect(validator({}, { enabledServers: ["plugin:gone:echo"] })).rejects.toThrow( + /does not match any available plugin server/ ); - await expect(validator({}, { toolAllowlist: { [installedKey]: [] } })).rejects.toThrow( - /no longer installed/ + await expect(validator({}, { toolAllowlist: { "plugin:gone:echo": [] } })).rejects.toThrow( + /does not match any available plugin server/ ); // …while round-tripping an EXISTING stale key and non-plugin keys stays allowed. - await validator({ enabledServers: [installedKey] }, { enabledServers: [installedKey] }); + await validator( + { enabledServers: ["plugin:gone:echo"] }, + { enabledServers: ["plugin:gone:echo"] } + ); await validator({}, { enabledServers: ["ordinary-server"] }); + + // Discovery failure → additions rejected (never accept unverifiable keys). + const failingValidator = buildAddedPluginKeyValidator(() => + Promise.reject(new Error("discovery unavailable")) + ); + await expect(failingValidator({}, { enabledServers: [discoveredKey] })).rejects.toThrow( + /does not match any available plugin server/ + ); }); test("falls back to a branch clone when the remote refuses direct SHA fetches", async () => { diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index c8040e60d52..ecea732a31c 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -874,25 +874,6 @@ export class AgentPluginInstallService { }); } - /** - * Instance IDs owned by current registry entries (including entries this - * build cannot parse — raw names still own their identity). Used by the - * workspace.mcp.set handler to reject newly added `plugin:` override keys - * for uninstalled plugins. No enablement assert: validation must hold even - * while the experiment is being toggled. - */ - async listInstalledInstanceIds(): Promise> { - const { rawEntries } = await this.readRegistryDocument("lenient"); - const instanceIds = new Set(); - for (const rawEntry of rawEntries) { - const name = this.rawEntryName(rawEntry); - if (name !== undefined) { - instanceIds.add(this.instanceIdFor(name)); - } - } - return instanceIds; - } - /** Managed registry entries merged with unmanaged plugins found by global discovery. */ async list(): Promise { this.assertEnabled(); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index ca554b5cc8a..68414b153a8 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -61,15 +61,6 @@ export function buildPluginServerKey(instanceId: string, serverName: string): st return `${PLUGIN_SERVER_KEY_PREFIX}${instanceId}:${serverName}`; } -/** Instance ID embedded in a `plugin::` override key (undefined for non-plugin keys). */ -export function pluginInstanceIdFromServerKey(serverKey: string): string | undefined { - if (!serverKey.startsWith(PLUGIN_SERVER_KEY_PREFIX)) { - return undefined; - } - const instanceId = serverKey.slice(PLUGIN_SERVER_KEY_PREFIX.length).split(":")[0]; - return instanceId !== undefined && instanceId.length > 0 ? instanceId : undefined; -} - function collectPluginOverrideKeys(overrides: WorkspaceMCPOverrides): Set { return new Set( [ @@ -82,7 +73,7 @@ function collectPluginOverrideKeys(overrides: WorkspaceMCPOverrides): Set:` keys whose instance is not currently installed. + * `plugin:` keys that do not name a currently-discoverable plugin server. * * Why additions-only, at write time: the overrides revision is content-derived, * so a dialog opened while a default-disabled plugin had no override key sees @@ -90,10 +81,15 @@ function collectPluginOverrideKeys(overrides: WorkspaceMCPOverrides): Set Promise> + listDiscoveredPluginServerKeys: () => Promise> ): (current: WorkspaceMCPOverrides, incoming: WorkspaceMCPOverrides) => Promise { return async (current, incoming) => { const currentKeys = collectPluginOverrideKeys(current); @@ -103,20 +99,17 @@ export function buildAddedPluginKeyValidator( if (addedKeys.length === 0) { return; } - let installedIds: Set; + let discoveredKeys: Set; try { - installedIds = await listInstalledInstanceIds(); + discoveredKeys = await listDiscoveredPluginServerKeys(); } catch { // Cannot confirm → reject the additions (never accept unverifiable keys). - installedIds = new Set(); + discoveredKeys = new Set(); } - const staleKeys = addedKeys.filter((key) => { - const instanceId = pluginInstanceIdFromServerKey(key); - return instanceId === undefined || !installedIds.has(instanceId); - }); + const staleKeys = addedKeys.filter((key) => !discoveredKeys.has(key)); if (staleKeys.length > 0) { throw new Error( - `Cannot save: ${staleKeys.join(", ")} belongs to a plugin that is no longer installed. ` + + `Cannot save: ${staleKeys.join(", ")} does not match any available plugin server. ` + "Close and reopen this dialog to load the current server list." ); } diff --git a/src/node/services/agentPlugins/sourceInput.test.ts b/src/node/services/agentPlugins/sourceInput.test.ts index 790221ccc65..90a53f70e84 100644 --- a/src/node/services/agentPlugins/sourceInput.test.ts +++ b/src/node/services/agentPlugins/sourceInput.test.ts @@ -70,6 +70,23 @@ describe("parseAgentPluginSourceInput", () => { expect(parseAgentPluginSourceInput("coder/mux@main").ref).toBe("main"); }); + test("rejects credential-bearing URLs (persisted + rendered verbatim)", () => { + // Sources land in ~/.mux/plugins.json and Settings; embedded secrets must + // never reach either. SSH usernames are routing data and stay allowed. + expect(() => parseAgentPluginSourceInput("https://user:token@host/repo.git")).toThrow( + /embedded credentials/ + ); + expect(() => parseAgentPluginSourceInput("https://token@host/repo.git")).toThrow( + /embedded credentials/ + ); + expect(parseAgentPluginSourceInput("git@github.com:coder/mux.git").url).toBe( + "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"); diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts index e868a5b3538..cf1ca534931 100644 --- a/src/node/services/agentPlugins/sourceInput.ts +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -1,6 +1,7 @@ import * as os from "node:os"; import * as path from "node:path"; +import { hasUrlCredentials } from "@/common/config/schemas/settingsBackup"; import { GITHUB_SHORTHAND_PATTERN, normalizeRepoUrlForClone } from "@/node/utils/gitUrls"; /** @@ -59,6 +60,17 @@ export function parseAgentPluginSourceInput(rawInput: string): ParsedAgentPlugin throw new Error("Enter a git URL or owner/repo shorthand."); } + // Plugin sources are persisted verbatim in ~/.mux/plugins.json and rendered + // in Settings/consent previews, so a credential-bearing URL (userinfo or + // known token query parameters) would land on disk and on screen. Reject it + // up front — git credential helpers are the supported path for private + // repos. Same policy (and helper) as the persisted backup-repository URL. + if (hasUrlCredentials(input)) { + throw new Error( + "Remove the embedded credentials from the URL. Private repositories authenticate via git credential helpers or SSH." + ); + } + 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 From 17a446918bcfa1ad9a796d687ac6dfa3ce5a967a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 14:55:05 +0000 Subject: [PATCH 06/63] fix: address Codex review round 24 Disclose agent definitions (agents/*.md), executable workflow scripts (workflows/*.js), and manifest composer slash commands (contributes.slashCommands) in the plugin install consent preview so users see every runtime-activatable component before installing. --- .../PluginsSettingsSection.stories.tsx | 10 ++++ .../Sections/PluginsSettingsSection.tsx | 50 +++++++++++++++++++ src/common/orpc/schemas/agentPlugins.ts | 13 +++++ .../agentPlugins/installService.test.ts | 32 ++++++++++++ .../services/agentPlugins/installService.ts | 38 ++++++++++++++ 5 files changed, 143 insertions(+) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx index 731ad09719d..688dcb07828 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -279,6 +279,9 @@ export const AddPluginConsentPreview: Story = { summary: "node ~/.mux/plugins/grill/server.js --db ${PLUGIN_DATA}/state.sqlite", }, ], + agents: ["grill-master.md"], + workflows: ["grill-report.js"], + slashCommands: [{ name: "grill", description: "Grill the current plan" }], warnings: ["Unknown top-level field 'hooks' ignored"], targetPath: "~/.mux/plugins/grill", }, @@ -300,6 +303,13 @@ export const AddPluginConsentPreview: Story = { await canvas.findByText("grill-lite"); await canvas.findByText("MCP servers (1)"); await canvas.findByText(/server\.js --db/); + // Every activatable component type is disclosed, not just skills/MCP. + await canvas.findByText("Agents (1)"); + await canvas.findByText(/grill-master\.md/); + await canvas.findByText("Workflows (1)"); + await canvas.findByText(/grill-report\.js/); + await canvas.findByText("Slash commands (1)"); + await canvas.findByText("/grill"); 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 index 2193895129f..ad6c95b44f8 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -257,6 +257,56 @@ const AddPluginPanel: React.FC<{

+ {preview.agents.length > 0 && ( +
+

+ Agents ({preview.agents.length}) +

+ {/* Activatable components: consent must name everything that + becomes available after install, not just skills/MCP. */} +

+ + {preview.agents.join(", ")} + {" "} + — become selectable agent definitions. +

+
+ )} + + {preview.workflows.length > 0 && ( +
+

+ Workflows ({preview.workflows.length}) +

+

+ + {preview.workflows.join(", ")} + {" "} + + — executable workflow scripts, invokable after install. + +

+
+ )} + + {preview.slashCommands.length > 0 && ( +
+

+ Slash commands ({preview.slashCommands.length}) +

+
    + {preview.slashCommands.map((command) => ( +
  • + /{command.name} + {command.description && ( + — {command.description} + )} +
  • + ))} +
+
+ )} + {preview.hook && (

Hooks

diff --git a/src/common/orpc/schemas/agentPlugins.ts b/src/common/orpc/schemas/agentPlugins.ts index ba18cdd3d11..808af2636fc 100644 --- a/src/common/orpc/schemas/agentPlugins.ts +++ b/src/common/orpc/schemas/agentPlugins.ts @@ -36,6 +36,12 @@ export const AgentPluginPreviewHookSchema = z.object({ toolGrants: z.array(z.string()), }); +/** Composer slash command declared by the manifest (data-driven expansion). */ +export const AgentPluginPreviewSlashCommandSchema = z.object({ + name: z.string(), + description: z.string().optional(), +}); + /** Manifest metadata surfaced in the consent preview (UI-safe projection of plugin.json). */ export const AgentPluginManifestSummarySchema = z.object({ name: z.string(), @@ -60,6 +66,12 @@ export const AgentPluginInstallPreviewSchema = z.object({ mcpServers: z.array(AgentPluginPreviewMcpServerSchema), /** Present when the plugin ships an executable hooks.js (absent = no hooks). */ hook: AgentPluginPreviewHookSchema.optional(), + /** Agent definition files (agents/*.md) that become selectable agents. */ + agents: z.array(z.string()), + /** Executable workflow scripts (workflows/*.js) invokable after install. */ + workflows: z.array(z.string()), + /** Composer slash commands the manifest contributes. */ + slashCommands: z.array(AgentPluginPreviewSlashCommandSchema), /** Manifest warnings + component diagnostics from validating the staged clone. */ warnings: z.array(z.string()), /** Final install directory (~/.mux/plugins/). */ @@ -96,6 +108,7 @@ export const AgentPluginUpdateCheckSchema = z.object({ export type AgentPluginPreviewSkill = z.infer; export type AgentPluginPreviewMcpServer = z.infer; export type AgentPluginPreviewHook = z.infer; +export type AgentPluginPreviewSlashCommand = z.infer; export type AgentPluginManifestSummary = z.infer; export type AgentPluginInstallPreview = z.infer; export type AgentPluginListItem = z.infer; diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index ebfeaa2be22..a87ab3a8b29 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -154,6 +154,38 @@ describe("AgentPluginInstallService", () => { expect(granted.hook).toEqual({ path: "hooks.js", toolGrants: ["bash", "file_read"] }); }); + test("consent preview discloses agents, workflows, and slash commands", async () => { + // Every activatable component must be named before install, not just + // skills/MCP/hooks: agents become selectable, workflow scripts are + // executable, slash commands appear in the composer. + const bare = await service.preview({ input: remoteDir }); + expect(bare.agents).toEqual([]); + expect(bare.workflows).toEqual([]); + expect(bare.slashCommands).toEqual([]); + + await fsPromises.mkdir(path.join(remoteDir, "agents"), { recursive: true }); + await fsPromises.writeFile(path.join(remoteDir, "agents", "reviewer.md"), "# reviewer\n"); + await fsPromises.mkdir(path.join(remoteDir, "workflows"), { recursive: true }); + await fsPromises.writeFile(path.join(remoteDir, "workflows", "release.js"), "// wf\n"); + await fsPromises.writeFile(path.join(remoteDir, "workflows", "notes.txt"), "not a script\n"); + const manifestPath = path.join(remoteDir, "plugin.json"); + const manifest = JSON.parse(await fsPromises.readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + manifest.contributes = { + slashCommands: [{ name: "standup", description: "Daily standup", expansion: "Do standup" }], + }; + await fsPromises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + await commitAll(remoteDir, "agents + workflows + slash commands"); + + const preview = await service.preview({ input: remoteDir }); + expect(preview.agents).toEqual(["reviewer.md"]); + // Only *.js is executable by workflow discovery; notes.txt is not listed. + expect(preview.workflows).toEqual(["release.js"]); + expect(preview.slashCommands).toEqual([{ name: "standup", description: "Daily standup" }]); + }); + test("preview stages+validates without writing; install promotes and records the registry", async () => { const head = (await git(remoteDir, "rev-parse", "HEAD")).trim(); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index ecea732a31c..cc8463f1910 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -602,6 +602,35 @@ export class AgentPluginInstallService { }; } + /** + * Agent definition files (agents/*.md) and executable workflow scripts + * (workflows/*.js) for the consent preview, mirroring the runtime listers + * (agentDefinitionsService / workflowScriptDiscovery: top-level files and + * symlinks with the matching extension, sorted). These activate after + * install, so consent must name them. + */ + private async collectComponentFiles( + dir: string | undefined, + extension: string + ): Promise { + if (dir === undefined) { + return []; + } + try { + const entries = await fsPromises.readdir(dir, { withFileTypes: true }); + return entries + .filter( + (entry) => + (entry.isFile() || entry.isSymbolicLink()) && + entry.name.toLowerCase().endsWith(extension) + ) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + } catch { + return []; + } + } + private async collectSkills( plugin: Pick, warnings: string[] @@ -760,6 +789,12 @@ export class AgentPluginInstallService { warnings ); const hook = this.collectHook(plugin); + const agents = await this.collectComponentFiles(plugin.agentsDir, ".md"); + const workflows = await this.collectComponentFiles(plugin.workflowsDir, ".js"); + const slashCommands = (plugin.manifest.contributes?.slashCommands ?? []).map((command) => ({ + name: command.name, + ...(command.description !== undefined ? { description: command.description } : {}), + })); if (resolved.refType === "tag" && sha !== resolved.sha) { warnings.push( @@ -780,6 +815,9 @@ export class AgentPluginInstallService { skills, mcpServers, ...(hook !== undefined ? { hook } : {}), + agents, + workflows, + slashCommands, warnings, targetPath: shortenHome(targetPath), }; From c935d8c980e84223a8feda5b3bcc7a71cc62fb97 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 15:10:50 +0000 Subject: [PATCH 07/63] fix: address Codex review round 25 Validate persisted uninstall tombstone prefixes against the canonical plugin:: shape before executing them. A corrupted plugins.json prefix (e.g. "g") is now treated like an unknown tombstone variant: preserved verbatim, never handed to prunePluginOverrideKeys where it could strip arbitrary workspace override keys. --- .../agentPlugins/installService.test.ts | 42 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 7 +++- src/node/services/agentPlugins/mcpConfig.ts | 13 ++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index a87ab3a8b29..f58131ea844 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -650,6 +650,48 @@ describe("AgentPluginInstallService", () => { expect(doc.pendingOverridePrunes).toContainEqual(foreignPrune); }); + test("corrupted tombstone prefixes are never executed and pass through verbatim", async () => { + // A corrupted plugins.json could carry an arbitrary prefix (e.g. "g"); + // handing it to prunePluginOverrideKeys would strip every matching + // enabled/disabled/tool-allowlist key from workspace overrides. Such a + // tombstone must be treated as unrecognized: preserved, never retried. + const pruneCalls: string[] = []; + const overridesStub = { + prunePluginOverrideKeys: (_workspaceId: string, prefix: string) => { + pruneCalls.push(prefix); + return Promise.resolve(); + }, + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + // The named workspace exists, so a recognized tombstone WOULD be retried + // (and its prefix executed) on section open. + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([{ id: "ws-1", runtimeConfig: { type: "local" } }] as unknown as Awaited< + ReturnType + >) + ); + try { + const corrupted = { prefix: "g", workspaceIds: ["ws-1"] }; + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ plugins: [], pendingOverridePrunes: [corrupted] }) + ); + + await serviceWithOverrides.list(); + + expect(pruneCalls).toEqual([]); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes: unknown[]; + }; + expect(doc.pendingOverridePrunes).toContainEqual(corrupted); + } finally { + metadataSpy.mockRestore(); + } + }); + 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 diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index cc8463f1910..bf1b3675e5f 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -40,6 +40,7 @@ import { buildPluginServerKey, computePluginInstanceId, getPluginDataPath, + isCanonicalPluginServerKeyPrefix, loadPluginMcpServers, } from "./mcpConfig"; import { isFullCommitSha, parseAgentPluginSourceInput } from "./sourceInput"; @@ -1267,7 +1268,11 @@ export class AgentPluginInstallService { const workspaceIds = (item as { workspaceIds?: unknown }).workspaceIds; return ( typeof prefix === "string" && - prefix.length > 0 && + // Only canonical `plugin::` prefixes are executable: a + // corrupted prefix (e.g. "g") must never reach prunePluginOverrideKeys, + // where it would destructively strip arbitrary workspace override keys. + // Invalid tombstones pass through verbatim like unknown variants. + isCanonicalPluginServerKeyPrefix(prefix) && Array.isArray(workspaceIds) && workspaceIds.every((id): id is string => typeof id === "string") ); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 68414b153a8..3b5176f7478 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -61,6 +61,19 @@ export function buildPluginServerKey(instanceId: string, serverName: string): st return `${PLUGIN_SERVER_KEY_PREFIX}${instanceId}:${serverName}`; } +/** + * Canonical uninstall-tombstone prefix shape: `plugin::` where + * the instance ID is the 16-hex-char computePluginInstanceId output. Persisted + * tombstones are validated against this before being executed so a corrupted + * `plugins.json` prefix (e.g. `"g"`) can never destructively prune arbitrary + * workspace override keys. + */ +const CANONICAL_PLUGIN_KEY_PREFIX_PATTERN = /^plugin:[0-9a-f]{16}:$/; + +export function isCanonicalPluginServerKeyPrefix(prefix: string): boolean { + return CANONICAL_PLUGIN_KEY_PREFIX_PATTERN.test(prefix); +} + function collectPluginOverrideKeys(overrides: WorkspaceMCPOverrides): Set { return new Set( [ From 583b2a54f0f6f7fd5f2f47ffa9124ddace0c8441 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 15:23:53 +0000 Subject: [PATCH 08/63] fix: address Codex review round 26 - Preserve a non-array pendingOverridePrunes shape (written by a newer build) verbatim across registry rewrites instead of deleting or replacing it; uninstalls that would need to record a tombstone refuse up-front with the install intact. - Merge duplicate recognized tombstones for the same prefix (workspace-ID union) before retrying/retiring, so per-prefix rewrites can no longer silently drop a duplicate's cleanup record and permit reinstall while stale overrides remain. --- .../agentPlugins/installService.test.ts | 122 ++++++++++++++++++ .../services/agentPlugins/installService.ts | 98 +++++++++++--- 2 files changed, 200 insertions(+), 20 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index f58131ea844..642eb8b9fcc 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -692,6 +692,128 @@ describe("AgentPluginInstallService", () => { } }); + test("duplicate tombstones for one prefix merge instead of dropping cleanup records", async () => { + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const prefix = `plugin:${instanceId}:`; + // Corrupted state: two recognized tombstones for the same prefix. + // ws-1's prune succeeds; ws-2's fails — its cleanup record must survive + // the per-prefix rewrite (which replaces every matching item) as one + // merged tombstone instead of being silently discarded. + const pruned: string[] = []; + const overridesStub = { + prunePluginOverrideKeys: (workspaceId: string) => { + if (workspaceId === "ws-2") { + return Promise.reject(new Error("checkout unavailable")); + } + pruned.push(workspaceId); + 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" } }, + { id: "ws-2", runtimeConfig: { type: "local" } }, + ] as unknown as Awaited>) + ); + try { + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [], + pendingOverridePrunes: [ + { prefix, workspaceIds: ["ws-1"] }, + { prefix, workspaceIds: ["ws-2"] }, + ], + }) + ); + + await serviceWithOverrides.list(); + + expect(pruned).toEqual(["ws-1"]); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: unknown[]; + }; + expect(doc.pendingOverridePrunes).toEqual([{ prefix, workspaceIds: ["ws-2"] }]); + } finally { + metadataSpy.mockRestore(); + } + }); + + test("uninstall preserves an opaque pendingOverridePrunes shape from a newer build", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // A newer build may represent pendingOverridePrunes with a non-array + // shape. It is opaque to this build and must ride through the uninstall + // commit write verbatim — deleting or replacing it would destroy that + // build's cleanup metadata on downgrade. + const opaque = { version: 2, queue: [{ prefix: "plugin:0000000000000000:" }] }; + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as Record< + string, + unknown + >; + doc.pendingOverridePrunes = opaque; + await fsPromises.writeFile(registryFile(), JSON.stringify(doc)); + + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + + expect(await registry()).toEqual([]); + const after = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: unknown; + }; + expect(after.pendingOverridePrunes).toEqual(opaque); + }); + + test("uninstall refuses to clobber an opaque pendingOverridePrunes shape when cleanup must be recorded", async () => { + const overridesStub = { + prunePluginOverrideKeys: () => 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, + }); + + const opaque = { version: 2 }; + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as Record< + string, + unknown + >; + doc.pendingOverridePrunes = opaque; + await fsPromises.writeFile(registryFile(), JSON.stringify(doc)); + + // ws-1 needs pruning, so a pessimistic tombstone would have to be + // recorded — impossible without clobbering the opaque shape. The + // uninstall must refuse up-front with the install fully intact. + await expect( + serviceWithOverrides.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/newer version of Mux/); + expect((await registry()).map((entry) => (entry as { name: string }).name)).toEqual([ + "demo-plugin", + ]); + const after = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: unknown; + }; + expect(after.pendingOverridePrunes).toEqual(opaque); + } finally { + metadataSpy.mockRestore(); + } + }); + 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 diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index bf1b3675e5f..ef85f142fae 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1034,6 +1034,16 @@ export class AgentPluginInstallService { // Settings) instead of leaving stale overrides behind post-commit. const workspaceIdsToPrune = await this.listWorkspaceIdsForOverridePruning(); + // A newer build's pendingOverridePrunes shape is opaque to this build, + // so the pessimistic tombstone below could only clobber it. Refuse + // up-front (install fully intact) rather than destroy that build's + // cleanup metadata — or silently skip recording our own. + if (workspaceIdsToPrune.length > 0 && this.hasOpaquePendingPrunes(envelope)) { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) contains pending cleanup state written by a newer version of Mux. Run the uninstall with that version, or let it finish its cleanup first.` + ); + } + // Stop running servers before deleting the tree out from under them. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); @@ -1100,7 +1110,10 @@ export class AgentPluginInstallService { serverKeyPrefix, workspaceIdsToPrune ); - if (pendingForCommit.length > 0) { + if (this.hasOpaquePendingPrunes(envelope)) { + // Opaque newer-build shape rides through verbatim (kept by the + // spread above; the up-front guard ensured nothing needs recording). + } else if (pendingForCommit.length > 0) { commitEnvelope.pendingOverridePrunes = pendingForCommit; } else { delete commitEnvelope.pendingOverridePrunes; @@ -1284,38 +1297,74 @@ export class AgentPluginInstallService { return Array.isArray(raw) ? raw : []; } - /** Recognized tombstones only (for matching/retrying). */ + /** + * True when `pendingOverridePrunes` exists with a shape this build does + * not understand (a newer release's representation). It is opaque: every + * rewrite must preserve it verbatim — deleting or replacing it would + * destroy that build's cleanup metadata on downgrade. + */ + private hasOpaquePendingPrunes(envelope: Record): boolean { + return ( + envelope.pendingOverridePrunes !== undefined && !Array.isArray(envelope.pendingOverridePrunes) + ); + } + + /** + * Recognized tombstones only (for matching/retrying). Corrupted persisted + * state can carry several recognized tombstones for one prefix; they are + * merged (workspace-ID union) so per-prefix rewrites, which replace every + * matching item, can never silently drop a duplicate's cleanup record. + */ 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 })); + const merged = new Map(); + for (const item of this.rawPendingPrunes(envelope)) { + if (!this.isRecognizedPrune(item)) { + continue; + } + const existing = merged.get(item.prefix); + if (existing) { + for (const workspaceId of item.workspaceIds) { + if (!existing.includes(workspaceId)) { + existing.push(workspaceId); + } + } + } else { + merged.set(item.prefix, [...item.workspaceIds]); + } + } + return [...merged.entries()].map(([prefix, workspaceIds]) => ({ prefix, 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. + * removes every recognized item for that prefix (merging their 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) - ); + const matches: Array> = []; + const next: unknown[] = []; + for (const item of rawPending) { + if (this.isRecognizedPrune(item) && item.prefix === prefix) { + matches.push(item); + } else { + next.push(item); + } + } if (workspaceIds.length > 0) { - next.push({ - ...((existing as Record | undefined) ?? {}), - prefix, - workspaceIds, - }); + const replacement: Record = {}; + for (const match of matches) { + Object.assign(replacement, match); + } + replacement.prefix = prefix; + replacement.workspaceIds = workspaceIds; + next.push(replacement); } return next; } @@ -1327,7 +1376,16 @@ export class AgentPluginInstallService { rawPending: unknown[] ): Promise { const nextEnvelope = { ...envelope }; - if (rawPending.length > 0) { + if (this.hasOpaquePendingPrunes(envelope)) { + // A newer build's opaque shape rides through verbatim (kept by the + // spread above). Nothing can need recording here: recognized + // tombstones only ever come from an array shape, and uninstall + // refuses up-front when it would have to record one. + assert( + rawPending.length === 0, + "writePendingOverridePrunes: cannot merge tombstones into an opaque pendingOverridePrunes shape" + ); + } else if (rawPending.length > 0) { nextEnvelope.pendingOverridePrunes = rawPending; } else { delete nextEnvelope.pendingOverridePrunes; From 88948504b5be5534f15f6941765d87ea93adaa4c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 15:30:52 +0000 Subject: [PATCH 09/63] fix: address Codex review round 27 Block installs while an opaque (newer-build) pendingOverridePrunes shape exists: this build cannot tell whether it references the same instance ID, and reinstalling would let stale workspace enabledServers keys silently reactivate the plugin's MCP server. Over-blocking until the newer build resolves its cleanup is the safe direction. --- .../agentPlugins/installService.test.ts | 17 +++++++++++++++++ .../services/agentPlugins/installService.ts | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 642eb8b9fcc..d1fc5b55d05 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -768,6 +768,23 @@ describe("AgentPluginInstallService", () => { expect(after.pendingOverridePrunes).toEqual(opaque); }); + test("install is blocked while an opaque pendingOverridePrunes shape exists", async () => { + // A newer build's opaque cleanup state may reference this very instance + // ID; this build cannot tell. Installing anyway would reuse the instance + // ID, letting a stale enabledServers key silently reactivate the + // plugin's server — so the reinstall gate must over-block. + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ plugins: [], pendingOverridePrunes: { version: 2 } }) + ); + + const preview = await service.preview({ input: remoteDir }); + await expect( + service.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/newer version of Mux/); + expect(await registry()).toEqual([]); + }); + test("uninstall refuses to clobber an opaque pendingOverridePrunes shape when cleanup must be recorded", async () => { const overridesStub = { prunePluginOverrideKeys: () => Promise.resolve(), diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index ef85f142fae..fabfcbca6a0 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1425,6 +1425,15 @@ export class AgentPluginInstallService { private async assertNoPendingOverridePrune(name: string): Promise { const serverKeyPrefix = buildPluginServerKey(this.instanceIdFor(name), ""); const { envelope, rawEntries } = await this.readRegistryDocument("strict"); + // An opaque newer-build shape is unreadable here, so it may contain a + // pending cleanup for this very instance ID — reinstalling would reuse + // that ID and stale workspace overrides could silently re-enable its + // servers. Over-blocking until the newer build resolves it is safe. + if (this.hasOpaquePendingPrunes(envelope)) { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) contains pending cleanup state written by a newer version of Mux. Install with that version, or let it finish its cleanup first.` + ); + } const pending = this.parsePendingOverridePrunes(envelope); const match = pending.find((prune) => prune.prefix === serverKeyPrefix); if (!match) { From 17fdca379734f6a8f5cd5b9e3c0eeb15018901d8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 15:40:04 +0000 Subject: [PATCH 10/63] fix: address Codex review round 28 - Isolate install-rollback cleanup steps: a failed promoted-tree deletion (e.g. locked file on Windows) no longer skips the MCP prefix invalidation, and both cleanup failures surface alongside the registry error instead of masking it. - Enable killTreeOnTermination for plugin git calls so a stalled SSH/credential-helper child cannot keep preview/update requests hanging past the advertised timeout. - Re-reject credential-bearing source URLs at the install boundary: a direct API request bypasses source-input parsing, and the URL is persisted to plugins.json and rendered in Settings. --- .../agentPlugins/installService.test.ts | 58 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 46 ++++++++++++--- src/node/services/agentPlugins/sourceInput.ts | 29 ++++++---- 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index d1fc5b55d05..5e1c9ce0f6f 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1370,6 +1370,64 @@ describe("AgentPluginInstallService", () => { expect(await registry()).toHaveLength(1); }); + test("install rollback invalidates servers even when deleting the promoted tree fails", async () => { + // A locked file (e.g. on Windows) can make the rollback deletion reject; + // the prefix invalidation must still run, or a server started from the + // briefly-visible tree survives an install that reported failure. + const stoppedPrefixes: string[] = []; + const mcpStub = { + stopServersWithKeyPrefix: (prefix: string) => { + stoppedPrefixes.push(prefix); + return Promise.resolve(); + }, + }; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub as unknown as MCPServerManager, + }); + const preview = await serviceWithMcp.preview({ input: remoteDir }); + + const targetPath = path.join(pluginsDir(), "demo-plugin"); + const internals = serviceWithMcp as unknown as { + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; + removeDir: (dir: string) => Promise; + }; + const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(() => + Promise.reject(new Error("ENOSPC: no space left on device")) + ); + const realRemoveDir = internals.removeDir.bind(internals); + const removeSpy = spyOn(internals, "removeDir").mockImplementation((dir: string) => + dir === targetPath ? Promise.reject(new Error("EBUSY: resource busy")) : realRemoveDir(dir) + ); + try { + // Both failures surface in one error; the invalidation still ran. + await expect( + serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/persist the plugin registry.*could not be removed/s); + } finally { + writeSpy.mockRestore(); + removeSpy.mockRestore(); + } + const instanceId = computePluginInstanceId(targetPath); + expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`]); + expect(await registry()).toEqual([]); + }); + + test("install rejects a source URL with embedded credentials", async () => { + // parseAgentPluginSourceInput already rejects these, but a direct API + // request can hand install() a source that never went through the + // parser — and the URL would be persisted to plugins.json and rendered + // in Settings. + const preview = await service.preview({ input: remoteDir }); + await expect( + service.install({ + source: { ...preview.source, url: "https://user:token@example.com/repo.git" }, + expectedSha: preview.lockedSha, + }) + ).rejects.toThrow(/embedded credentials/); + expect(await registry()).toEqual([]); + }); + test("added plugin override keys are validated against discovered servers", async () => { // The overrides revision is content-derived, so a dialog opened before an // uninstall (overrides {}) sees an unchanged revision after it — only a diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index fabfcbca6a0..04c6b1453a5 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -43,7 +43,11 @@ import { isCanonicalPluginServerKeyPrefix, loadPluginMcpServers, } from "./mcpConfig"; -import { isFullCommitSha, parseAgentPluginSourceInput } from "./sourceInput"; +import { + assertNoAgentPluginUrlCredentials, + isFullCommitSha, + parseAgentPluginSourceInput, +} from "./sourceInput"; /** * Managed Agent Plugin installer (agent-plugins experiment; global scope only). @@ -109,6 +113,10 @@ async function runGit(args: string[], opts?: { timeoutMs?: number }): Promise { this.assertEnabled(); assert(isFullCommitSha(args.expectedSha), "install: expectedSha must be a full commit SHA"); + // Re-checked here (not just in source-input parsing): a direct API + // request can hand install() a source that never went through the + // parser, and this URL is persisted to plugins.json and rendered in + // Settings. + assertNoAgentPluginUrlCredentials(args.source.url); if (args.source.subpath !== undefined) { throw new Error("Monorepo subpath installs land in v2."); } @@ -894,16 +907,35 @@ export class AgentPluginInstallService { ]); } catch (error) { // No partial state: a promote without a registry entry would look - // like an unmanaged dir and block reinstall. - await this.removeDir(targetPath); + // like an unmanaged dir and block reinstall. Each cleanup step is + // isolated so a failure (e.g. a locked file on Windows) cannot + // skip the others or mask the registry error. + const cleanupNotes: string[] = []; + try { + await this.removeDir(targetPath); + } catch (cleanupError) { + cleanupNotes.push( + `the promoted plugin tree could not be removed — delete ${shortenHome(targetPath)} manually (${getErrorMessage(cleanupError)})` + ); + } // A getToolsForWorkspace running during the promote↔rollback window // can have discovered the briefly-visible tree and be starting a // server from it; invalidate the prefix (same as update/uninstall) - // so it is closed instead of surviving the failed install. - await this.deps.mcpServerManager?.stopServersWithKeyPrefix( - `plugin:${this.instanceIdFor(name)}:` + // so it is closed instead of surviving the failed install. Must run + // even when the rollback deletion above failed. + try { + await this.deps.mcpServerManager?.stopServersWithKeyPrefix( + `plugin:${this.instanceIdFor(name)}:` + ); + } catch (cleanupError) { + cleanupNotes.push( + `the plugin's MCP servers could not be stopped (${getErrorMessage(cleanupError)})` + ); + } + const notes = cleanupNotes.length > 0 ? ` Additionally, ${cleanupNotes.join("; ")}.` : ""; + throw new Error( + `Failed to persist the plugin registry: ${getErrorMessage(error)}${notes}` ); - 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; diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts index cf1ca534931..1767ce10b13 100644 --- a/src/node/services/agentPlugins/sourceInput.ts +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -50,6 +50,24 @@ function isUrlLike(input: string): boolean { return /^(?:[a-zA-Z0-9._-]+@)?[a-zA-Z0-9._-]+:.+$/.test(input); } +/** + * Plugin sources are persisted verbatim in ~/.mux/plugins.json and rendered + * in Settings/consent previews, so a credential-bearing URL (userinfo or + * known token query parameters) would land on disk and on screen. Reject it + * up front — git credential helpers are the supported path for private + * repos. Same policy (and helper) as the persisted backup-repository URL. + * Enforced both at input parsing and at the install boundary, because a + * direct API request can hand `install` a source that never went through the + * parser. + */ +export function assertNoAgentPluginUrlCredentials(url: string): void { + if (hasUrlCredentials(url)) { + throw new Error( + "Remove the embedded credentials from the URL. Private repositories authenticate via git credential helpers or SSH." + ); + } +} + /** * Parse the Add Plugin source input. Throws with a user-facing message when * the input matches no accepted form. @@ -60,16 +78,7 @@ export function parseAgentPluginSourceInput(rawInput: string): ParsedAgentPlugin throw new Error("Enter a git URL or owner/repo shorthand."); } - // Plugin sources are persisted verbatim in ~/.mux/plugins.json and rendered - // in Settings/consent previews, so a credential-bearing URL (userinfo or - // known token query parameters) would land on disk and on screen. Reject it - // up front — git credential helpers are the supported path for private - // repos. Same policy (and helper) as the persisted backup-repository URL. - if (hasUrlCredentials(input)) { - throw new Error( - "Remove the embedded credentials from the URL. Private repositories authenticate via git credential helpers or SSH." - ); - } + assertNoAgentPluginUrlCredentials(input); if (isUrlLike(input)) { // Git is spawned without a shell, so `~` never expands on its own — From 6689c056ff518e7c70ad2c791209e967d25037ad Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 15:51:29 +0000 Subject: [PATCH 11/63] fix: address Codex review round 29 - Block installs while an unrecognized pendingOverridePrunes array entry exists: a newer build's per-entry variant (or corrupted data) may reference the same instance ID, so the reinstall gate over-blocks just like the non-array shape. Uninstalls stay possible since they preserve unrecognized entries verbatim. - Wrap the consent preview's source/target path line and manifest name with break-all: a valid 64-char separator-free plugin name has no break points and overflowed the card at phone widths. Adds a pinned phone-viewport consent story with an overflow assertion. --- .../PluginsSettingsSection.stories.tsx | 71 +++++++++++++++++++ .../Sections/PluginsSettingsSection.tsx | 11 ++- .../agentPlugins/installService.test.ts | 38 ++++++++-- .../services/agentPlugins/installService.ts | 10 +++ 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx index 688dcb07828..7f968370e56 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -314,3 +314,74 @@ export const AddPluginConsentPreview: Story = { await canvas.findByRole("button", { name: /Install/ }); }, }; + +/** + * Pinned phone viewport for the consent preview: the source URL and target + * path line carries a 64-char separator-free plugin dir name (no natural + * break points) and must wrap instead of overflowing the card + * (AGENTS.md Storybook responsive rule). + */ +export const AddPluginConsentPreviewPhoneViewport: 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 userEvent.click(await canvas.findByRole("button", { name: /Add plugin/ })); + await userEvent.type( + await canvas.findByLabelText(/Git URL or owner\/repo/), + `example/${MAX_LENGTH_NAME}` + ); + await userEvent.click(await canvas.findByRole("button", { name: /Preview/ })); + + // The separator-free target path must wrap instead of overflowing the + // consent card's right edge at phone width. + const pathCode = await canvas.findByText(`~/.mux/plugins/${MAX_LENGTH_NAME}`); + const card = pathCode.closest("div[class*='rounded-md']"); + if (!(card instanceof HTMLElement)) { + throw new Error("Consent preview card not found"); + } + if (card.scrollWidth > card.clientWidth + 1) { + throw new Error("Consent preview overflows its card at phone width"); + } + }, +}; diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index ad6c95b44f8..3a77168602a 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -183,7 +183,11 @@ const AddPluginPanel: React.FC<{ {/* Consent preview: everything the plugin will contribute, before anything is written. */}
- {preview.manifest.name} + {/* break-all: a valid 64-char separator-free name has no natural + break points and would overflow the card on phone widths. */} + + {preview.manifest.name} + {preview.manifest.version && ( v{preview.manifest.version} )} @@ -194,7 +198,10 @@ const AddPluginPanel: React.FC<{ {preview.manifest.description && (

{preview.manifest.description}

)} -

+ {/* break-all: URLs and a 64-char separator-free plugin dir name + have no natural break points and would overflow the card on + phone widths. */} +

{preview.source.url} @ {preview.source.ref} →{" "} {preview.targetPath} {preview.manifest.authorName ? ` · by ${preview.manifest.authorName}` : ""} diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 5e1c9ce0f6f..b86e5ce6ed5 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -629,15 +629,20 @@ describe("AgentPluginInstallService", () => { workspaceIds: ["ws-gone"], reason: "future-field", }; - await fsPromises.writeFile( - registryFile(), - JSON.stringify({ plugins: [], pendingOverridePrunes: [futureVariant, foreignPrune] }) - ); + // Install BEFORE seeding: the reinstall gate over-blocks installs while + // an unrecognized tombstone entry exists (it may reference the same + // instance ID), but uninstalls only append their own tombstone. + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const seeded = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as Record< + string, + unknown + >; + seeded.pendingOverridePrunes = [futureVariant, foreignPrune]; + await fsPromises.writeFile(registryFile(), JSON.stringify(seeded)); // 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 { @@ -768,6 +773,27 @@ describe("AgentPluginInstallService", () => { expect(after.pendingOverridePrunes).toEqual(opaque); }); + test("install is blocked while an unrecognized tombstone array entry exists", async () => { + // A newer build can keep the array shape but change the per-entry shape + // (or the entry may be corrupted, e.g. an invalid prefix). This build + // cannot rule out that it references the same instance ID, so the + // reinstall gate must over-block — while uninstalls (which merely append + // this build's tombstone) stay possible. + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [], + pendingOverridePrunes: [{ kind: "future-variant", instance: "?" }], + }) + ); + + const preview = await service.preview({ input: remoteDir }); + await expect( + service.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/cannot read/); + expect(await registry()).toEqual([]); + }); + test("install is blocked while an opaque pendingOverridePrunes shape exists", async () => { // A newer build's opaque cleanup state may reference this very instance // ID; this build cannot tell. Installing anyway would reuse the instance diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 04c6b1453a5..e07f203aa34 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1466,6 +1466,16 @@ export class AgentPluginInstallService { `The plugin registry (${shortenHome(this.registryFile)}) contains pending cleanup state written by a newer version of Mux. Install with that version, or let it finish its cleanup first.` ); } + // Same reasoning per ITEM: an unrecognized array entry (a newer build's + // per-entry variant, or corrupted data) may reference this very instance + // ID — this build cannot rule that out, so it blocks installs too. + // (Uninstalls stay possible: appending this build's tombstone preserves + // unrecognized entries verbatim.) + if (this.rawPendingPrunes(envelope).some((item) => !this.isRecognizedPrune(item))) { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) contains pending cleanup records this version cannot read (written by a newer version of Mux, or corrupted). Install with that version, or repair the file's pendingOverridePrunes entries first.` + ); + } const pending = this.parsePendingOverridePrunes(envelope); const match = pending.find((prune) => prune.prefix === serverKeyPrefix); if (!match) { From f7494e58bd81141ab48d217bfe6e92af40a2eada Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 16:05:45 +0000 Subject: [PATCH 12/63] fix: address Codex review round 30 Preserve JSONC comments and formatting when pruning plugin override keys from .mux/mcp.local.jsonc: apply targeted jsonc-parser edits to the affected array items and toolAllowlist entries instead of serializing the parsed object with JSON.stringify, which erased every comment in the user-maintained file. --- .../workspaceMcpOverridesService.test.ts | 61 ++++++++++++++++++ .../services/workspaceMcpOverridesService.ts | 64 ++++++++++++------- 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index 2dfd19cdddb..f569792dd63 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import * as fs from "fs/promises"; +import { parse as jsoncParse } from "jsonc-parser"; import * as os from "os"; import * as path from "path"; import { Config } from "@/node/config"; @@ -347,6 +348,66 @@ describe("WorkspaceMcpOverridesService", () => { await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); }); + it("prunePluginOverrideKeys preserves JSONC comments and formatting", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + // User-maintained .jsonc: comments must survive the prune (only the + // plugin's keys may be edited out — no wholesale JSON.stringify rewrite). + await fs.writeFile( + filePath, + `{ + // Keep me: explains why other-server is enabled. + "enabledServers": [ + "plugin:abc:echo", + "other-server" // trailing comment survives too + ], + /* block comment */ + "toolAllowlist": { + "plugin:abc:echo": ["t1"], + "other-server": ["t2"] + } +} +` + ); + + 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.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + + const after = await fs.readFile(filePath, "utf-8"); + expect(after).toContain("// Keep me: explains why other-server is enabled."); + expect(after).toContain("// trailing comment survives too"); + expect(after).toContain("/* block comment */"); + expect(after).not.toContain("plugin:abc:echo"); + const parsed = jsoncParse(after) as Record; + expect(parsed).toEqual({ + enabledServers: ["other-server"], + toolAllowlist: { "other-server": ["t2"] }, + }); + }); + it("removes workspace-local file when overrides are set to empty", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index 76539cf4855..f7688fd74c8 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -569,41 +569,61 @@ export class WorkspaceMcpOverridesService { if (!(await statIsFile(runtime, filePath, "strict"))) { continue; } - const parsed = await this.readOverridesFile(runtime, filePath, "strict"); + // Strict read: unreadable/unparseable content must throw so the + // caller keeps its retry tombstone (mirrors readOverridesFile). + const original = await readFileString(runtime, filePath); + const parseErrors: jsonc.ParseError[] = []; + const parsed: unknown = jsonc.parse(original, parseErrors) as unknown; + if (parseErrors.length > 0) { + throw new Error(`Workspace MCP overrides file has JSONC parse errors: ${filePath}`); + } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { continue; } - const raw = { ...(parsed as Record) }; - let changed = false; - for (const field of ["enabledServers", "disabledServers"] as const) { - const value = raw[field]; - if (!Array.isArray(value)) { - continue; - } - const filtered = value.filter( - (key) => !(typeof key === "string" && key.startsWith(keyPrefix)) + // Targeted jsonc edits, NOT JSON.stringify of the parsed object: the + // .jsonc file is user-maintained and may carry comments/formatting a + // wholesale rewrite would erase. + let text = original; + const removeAt = (jsonPath: jsonc.JSONPath): void => { + text = jsonc.applyEdits( + text, + jsonc.modify(text, jsonPath, undefined, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) ); - if (filtered.length !== value.length) { - raw[field] = filtered; - changed = true; + }; + + for (const field of ["enabledServers", "disabledServers"] as const) { + // Re-parse after each removal: array indices shift as items go. + for (;;) { + const current = jsonc.parse(text) as Record; + const value = current[field]; + if (!Array.isArray(value)) { + break; + } + const index = value.findIndex( + (key) => typeof key === "string" && key.startsWith(keyPrefix) + ); + if (index === -1) { + break; + } + removeAt([field, index]); } } - const allowlist = raw.toolAllowlist; + const allowlist = (jsonc.parse(text) as Record).toolAllowlist; if (allowlist !== null && typeof allowlist === "object" && !Array.isArray(allowlist)) { - const entries = Object.entries(allowlist as Record); - const kept = entries.filter(([key]) => !key.startsWith(keyPrefix)); - if (kept.length !== entries.length) { - raw.toolAllowlist = Object.fromEntries(kept); - changed = true; + for (const key of Object.keys(allowlist)) { + if (key.startsWith(keyPrefix)) { + removeAt(["toolAllowlist", key]); + } } } - if (!changed) { - continue; + if (text !== original) { + await writeFileString(runtime, filePath, text); } - await writeFileString(runtime, filePath, JSON.stringify(raw, null, 2) + "\n"); } }); } From 710d15cb2d5133aff9611fde4aeeaec64bdb24d1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 20 Aug 2026 16:16:31 +0000 Subject: [PATCH 13/63] fix: address Codex review round 31 Reject duplicate JSONC properties before path-based override pruning: jsonc.parse exposes the last value of a duplicated property while jsonc.modify resolves the first matching path, so the edit loop could spin forever on an entry it can never remove (duplicate enabledServers/disabledServers) or declare success while a stale plugin key survives in the shadowing property (duplicate toolAllowlist or duplicate keys inside it). Detection walks the JSONC syntax tree; rejection keeps the caller's retry tombstone. Also assert each targeted edit actually changes the text so a parse/modify disagreement can never loop silently. --- .../workspaceMcpOverridesService.test.ts | 74 +++++++++++++++++++ .../services/workspaceMcpOverridesService.ts | 61 ++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index f569792dd63..25568f81585 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -408,6 +408,80 @@ describe("WorkspaceMcpOverridesService", () => { }); }); + it("prunePluginOverrideKeys rejects duplicate properties instead of mis-editing", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.mkdir(path.dirname(filePath), { 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); + + // Duplicate toolAllowlist properties: jsonc.parse exposes the LAST + // object (holding the plugin key) while jsonc.modify edits the FIRST, + // so a "successful" prune would leave the stale key in the effective + // value. The prune must throw (caller keeps its retry tombstone). + const duplicateAllowlist = `{ + "toolAllowlist": { "other": ["t2"] }, + "toolAllowlist": { "plugin:abc:echo": ["t1"] } +} +`; + await fs.writeFile(filePath, duplicateAllowlist); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( + /duplicate "toolAllowlist"/ + ); + expect(await fs.readFile(filePath, "utf-8")).toBe(duplicateAllowlist); + + // Duplicate enabledServers: the same parse/modify disagreement makes the + // index-based removal loop spin on the unchanged effective array. + await fs.writeFile( + filePath, + `{ + "enabledServers": ["other"], + "enabledServers": ["plugin:abc:echo"] +} +` + ); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( + /duplicate "enabledServers"/ + ); + + // Duplicate keys INSIDE toolAllowlist: removal by name hits the first, + // parse exposes the last — the stale key would survive. + await fs.writeFile( + filePath, + `{ + "toolAllowlist": { "plugin:abc:echo": ["t1"], "plugin:abc:echo": ["t2"] } +} +` + ); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( + /duplicate "plugin:abc:echo"/ + ); + }); + it("removes workspace-local file when overrides are set to empty", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index f7688fd74c8..d46292596e6 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -581,17 +581,34 @@ export class WorkspaceMcpOverridesService { continue; } + // Duplicate properties make jsonc.parse (last value wins) and + // jsonc.modify (first matching path wins) disagree: the edit loop + // below could spin forever on an entry it can never remove, or + // declare success while a stale plugin key survives in the shadowed + // property. Reject up front — the caller keeps its retry tombstone + // until the malformed file is repaired. + const duplicateName = findDuplicateOverrideProperty(jsonc.parseTree(original)); + if (duplicateName !== undefined) { + throw new Error( + `Workspace MCP overrides file has duplicate "${duplicateName}" properties: ${filePath}` + ); + } + // Targeted jsonc edits, NOT JSON.stringify of the parsed object: the // .jsonc file is user-maintained and may carry comments/formatting a // wholesale rewrite would erase. let text = original; const removeAt = (jsonPath: jsonc.JSONPath): void => { - text = jsonc.applyEdits( + const next = jsonc.applyEdits( text, jsonc.modify(text, jsonPath, undefined, { formattingOptions: { insertSpaces: true, tabSize: 2 }, }) ); + // A no-op edit means parse and modify disagreed about the path; + // looping on it would never terminate. + assert(next !== text, "prunePluginOverrideKeys: targeted edit produced no change"); + text = next; }; for (const field of ["enabledServers", "disabledServers"] as const) { @@ -628,3 +645,45 @@ export class WorkspaceMcpOverridesService { }); } } + +/** Property names prunePluginOverrideKeys edits by JSON path. */ +const PRUNED_OVERRIDE_FIELDS = new Set(["enabledServers", "disabledServers", "toolAllowlist"]); + +/** + * Detect duplicate JSONC properties that would break path-based edits in + * prunePluginOverrideKeys: a root-level duplicate of an edited field, or any + * duplicate key inside toolAllowlist. jsonc.parse exposes the LAST value for + * a duplicated property while jsonc.modify resolves the FIRST matching path, + * so editing such a file can loop forever or silently miss the effective + * (shadowing) value. Returns the duplicated property name, if any. + */ +function findDuplicateOverrideProperty(root: jsonc.Node | undefined): string | undefined { + const duplicateIn = ( + node: jsonc.Node | undefined, + names?: ReadonlySet + ): string | undefined => { + if (node?.type !== "object") { + return undefined; + } + const seen = new Set(); + for (const property of node.children ?? []) { + const name: unknown = property.children?.[0]?.value; + if (typeof name !== "string" || (names !== undefined && !names.has(name))) { + continue; + } + if (seen.has(name)) { + return name; + } + seen.add(name); + } + return undefined; + }; + + const rootDuplicate = duplicateIn(root, PRUNED_OVERRIDE_FIELDS); + if (rootDuplicate !== undefined) { + return rootDuplicate; + } + return duplicateIn( + root === undefined ? undefined : jsonc.findNodeAtLocation(root, ["toolAllowlist"]) + ); +} From adc540e0e182771d099e55a376564912a612cc27 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 08:37:52 +0000 Subject: [PATCH 14/63] fix: address Codex review round 32 - Security: updates reject capability increases/changes (new hooks.js, expanded hook tool grants, added or changed MCP servers) instead of silently applying them; uninstall + reinstall routes through the full install consent preview. - Consent preview renders stdio argv shell-quoted per token exactly like the runtime, so argument boundaries cannot be concealed or faked. - Reject Windows-reserved device names (con, prn, aux, nul, com1-9, lpt1-9, with or without extension) in the shared plugin-name validator on every platform. - Bound untrusted git subprocess output (10 MiB) so a noisy remote cannot exhaust main-process memory before the timeout. - checkUpdates reads the registry strictly (corrupted registry surfaces as the update-check error state, not a false all-clear) and bounds concurrent ls-remote lookups to 4. - prunePluginOverrideKeys rejects opaque enabledServers/disabledServers/ toolAllowlist shapes from newer builds so tombstones stay retryable instead of retiring against uninspectable content. - Use shared warning color tokens (bg-warning/text-warning) instead of fixed yellow-500 utilities in the Plugins section. --- .../Sections/PluginsSettingsSection.tsx | 10 +- .../config/schemas/agentPluginInstalls.ts | 9 +- src/common/utils/agentPluginName.ts | 16 +- .../agentPlugins/installService.test.ts | 134 +++++++++++++- .../services/agentPlugins/installService.ts | 168 +++++++++++++++++- .../workspaceMcpOverridesService.test.ts | 47 +++++ .../services/workspaceMcpOverridesService.ts | 20 ++- 7 files changed, 386 insertions(+), 18 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 3a77168602a..37c343fa5fe 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -57,7 +57,7 @@ const Badge: React.FC<{ "rounded px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap", props.tone === "muted" && "bg-foreground/10 text-muted", props.tone === "accent" && "bg-accent/15 text-accent", - props.tone === "warning" && "bg-yellow-500/15 text-yellow-500", + props.tone === "warning" && "bg-warning/15 text-warning", props.tone === "error" && "bg-destructive/15 text-destructive" )} > @@ -210,9 +210,9 @@ const AddPluginPanel: React.FC<{

{preview.warnings.length > 0 && ( -
+
{preview.warnings.map((warning) => ( -
+
{warning}
@@ -625,7 +625,7 @@ export const PluginsSettingsSection: React.FC = () => {
)} {updateCheckError && ( -
+
Update check failed: {updateCheckError}
@@ -689,7 +689,7 @@ export const PluginsSettingsSection: React.FC = () => {

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

+

{check.message}

diff --git a/src/common/config/schemas/agentPluginInstalls.ts b/src/common/config/schemas/agentPluginInstalls.ts index 5c754cc9ad8..54d8dcd5599 100644 --- a/src/common/config/schemas/agentPluginInstalls.ts +++ b/src/common/config/schemas/agentPluginInstalls.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { AGENT_PLUGIN_NAME_MAX_LENGTH, AGENT_PLUGIN_NAME_PATTERN, + isValidAgentPluginName, } from "@/common/utils/agentPluginName"; /** @@ -62,7 +63,13 @@ export const AgentPluginInstallEntrySchema = z.object({ * 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), + name: z + .string() + .max(AGENT_PLUGIN_NAME_MAX_LENGTH) + .regex(AGENT_PLUGIN_NAME_PATTERN) + // Full validator on top of the grammar: also rejects Windows-reserved + // device names, which pattern+length alone admit. + .refine(isValidAgentPluginName, { message: "reserved or invalid plugin name" }), /** v1 installs are global-only; the installer never writes into project checkouts. */ scope: z.literal("global"), source: AgentPluginInstallSourceSchema, diff --git a/src/common/utils/agentPluginName.ts b/src/common/utils/agentPluginName.ts index 31271a45b29..a3c08e81890 100644 --- a/src/common/utils/agentPluginName.ts +++ b/src/common/utils/agentPluginName.ts @@ -12,7 +12,19 @@ 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. */ +// Windows reserves device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) as +// file/directory names — with or without an extension (`con.plugin` is also +// reserved). Such a name would pass consent yet fail at promotion into the +// plugins container on Windows. Rejected on every platform so a plugin +// installable on one OS is installable on all. Names are lowercase by +// grammar, so a lowercase pattern suffices. +const WINDOWS_RESERVED_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/; + +/** True when `name` satisfies the §5 plugin-name grammar and is usable as a directory name on every supported OS. */ export function isValidAgentPluginName(name: string): boolean { - return name.length <= AGENT_PLUGIN_NAME_MAX_LENGTH && AGENT_PLUGIN_NAME_PATTERN.test(name); + return ( + name.length <= AGENT_PLUGIN_NAME_MAX_LENGTH && + AGENT_PLUGIN_NAME_PATTERN.test(name) && + !WINDOWS_RESERVED_NAME_PATTERN.test(name) + ); } diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index b86e5ce6ed5..a22b9ed0f32 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -202,9 +202,11 @@ describe("AgentPluginInstallService", () => { 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. + // Command line shows the FINAL install path, not the staging clone path, + // shell-quoted per token exactly like the runtime renders it (argument + // boundaries in the consent preview must match what will run). expect(preview.mcpServers[0].summary).toBe( - `node ${path.join(pluginsDir(), "demo-plugin", "server.js")}` + `'node' '${path.join(pluginsDir(), "demo-plugin", "server.js")}'` ); // Cancelling after preview = nothing written anywhere. @@ -281,6 +283,134 @@ describe("AgentPluginInstallService", () => { expect(await stagingLeftovers()).toEqual([]); }); + test("update rejects capability increases (new hook, expanded grants, new/changed MCP servers)", async () => { + // Security gate: a compromised upstream must not auto-load new executable + // capabilities through a routine update click. Additions/changes are + // rejected; the user re-consents via uninstall + reinstall. + const preview = await service.preview({ input: remoteDir }); + const installedSha = preview.lockedSha; + await service.install({ source: preview.source, expectedSha: installedSha }); + const installedDir = path.join(pluginsDir(), "demo-plugin"); + + // Upstream adds hooks.js with a bash grant and a NEW MCP server. + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await fsPromises.writeFile(path.join(remoteDir, "hooks.js"), "export default {};\n"); + await fsPromises.writeFile( + path.join(remoteDir, "plugin.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: "demo-plugin", + version: "2.0.0", + description: "Demo plugin", + extensions: { mux: { hooks: { tools: ["bash"] } } }, + }) + ); + await fsPromises.writeFile( + path.join(remoteDir, "mcp.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + echo: { type: "stdio", command: "node", args: ["${PLUGIN_ROOT}/server.js"] }, + exfil: { type: "stdio", command: "node", args: ["${PLUGIN_ROOT}/exfil.js"] }, + }, + }) + ); + await commitAll(remoteDir, "v2 adds hook + server"); + + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /adds executable hooks \(hooks\.js with tool grants: bash\).*adds MCP server 'exfil'.*uninstall/s + ); + // Rejected update leaves the install untouched. + expect(((await registry())[0] as { lockedSha: string }).lockedSha).toBe(installedSha); + expect(await pathExists(path.join(installedDir, "hooks.js"))).toBe(false); + expect(await stagingLeftovers()).toEqual([]); + + // Changing an EXISTING server's command line is likewise rejected. + await writePluginFixture(remoteDir, { version: "2.0.1" }); + await fsPromises.writeFile( + path.join(remoteDir, "mcp.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + echo: { type: "stdio", command: "node", args: ["${PLUGIN_ROOT}/other.js"] }, + }, + }) + ); + await commitAll(remoteDir, "v2.0.1 changes echo argv"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /changes MCP server 'echo'/ + ); + + // A capability-neutral update (same hook-less, same servers) applies. + await writePluginFixture(remoteDir, { version: "3.0.0" }); + await fsPromises.rm(path.join(remoteDir, "hooks.js")); + const cleanHead = await commitAll(remoteDir, "v3 capability-neutral"); + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(cleanHead); + }); + + test("checkUpdates surfaces a corrupted registry instead of a false all-clear", async () => { + await fsPromises.writeFile(registryFile(), "{ not json"); + + await expect(service.checkUpdates()).rejects.toThrow(/corrupted/); + }); + + test("checkUpdates bounds concurrent remote lookups", async () => { + // Seed a registry with many entries; a gate inside resolveRemoteRef + // measures how many lookups run simultaneously. + const entries = Array.from({ length: 9 }, (_, i) => ({ + name: `plugin-${i}`, + scope: "global", + source: { type: "git", url: remoteDir, ref: "main", refType: "branch" }, + lockedSha: "a".repeat(40), + installedAt: "2026-08-01T00:00:00.000Z", + })); + await fsPromises.writeFile(registryFile(), JSON.stringify({ plugins: entries })); + + let inFlight = 0; + let maxInFlight = 0; + const internals = service as unknown as { + resolveRemoteRef: (url: string, ref: string) => Promise; + }; + const resolveSpy = spyOn(internals, "resolveRemoteRef").mockImplementation(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + return { refType: "branch", ref: "main", sha: "a".repeat(40) }; + }); + try { + const checks = await service.checkUpdates(); + expect(checks).toHaveLength(9); + expect(checks.every((check) => check.status === "up-to-date")).toBe(true); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(4); + } finally { + resolveSpy.mockRestore(); + } + }); + + test("Windows-reserved plugin names are rejected at consent time", async () => { + // `con` (with or without extension) is a reserved device name on + // Windows: promotion into ~/.mux/plugins/ would fail there, so + // consent must reject it up front on every platform. + await fsPromises.writeFile( + path.join(remoteDir, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "con", version: "1.0.0" }) + ); + await commitAll(remoteDir, "reserved name"); + + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/name/); + + await fsPromises.writeFile( + path.join(remoteDir, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "com1.tools", version: "1" }) + ); + await commitAll(remoteDir, "reserved name with extension"); + + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/name/); + }); + 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"); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index e07f203aa34..82702b11692 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -28,6 +28,7 @@ import { log } from "@/node/services/log"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { shellQuote } from "@/common/utils/shell"; import { execFileAsync } from "@/node/utils/disposableExec"; import { discoverAgentPluginAt, @@ -117,11 +118,45 @@ async function runGit(args: string[], opts?: { timeoutMs?: number }): Promise( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + assert(limit > 0, "mapWithConcurrency: limit must be positive"); + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const index = nextIndex++; + if (index >= items.length) { + return; + } + results[index] = await fn(items[index]); + } + }); + await Promise.all(workers); + return results; +} + async function pathExists(candidate: string): Promise { try { await fsPromises.access(candidate); @@ -611,6 +646,102 @@ export class AgentPluginInstallService { }; } + /** + * Security-relevant capability surface of a plugin tree: the auto-loading + * hook (entry path + tool grants) and MCP servers (transport + exact + * argv/env/url, root-path-normalized so staged and installed trees compare + * equal). Skills, agents, workflows, and slash commands are excluded: they + * stay inert until the user explicitly invokes them and are listed in + * Settings, whereas a hook auto-executes on requests and an enabled MCP + * server's command changes silently behind its stable server key. + */ + private async capabilitySurface( + plugin: AgentPluginInfo, + instanceId: string + ): Promise<{ hook: AgentPluginPreviewHook | undefined; servers: Map }> { + const hook = this.collectHook(plugin); + const servers = new Map(); + if (plugin.mcpConfigPath !== undefined) { + const { servers: infos } = await loadPluginMcpServers(plugin, { + muxHome: this.config.rootDir, + instanceId, + }); + const normalize = (value: string): string => value.split(plugin.rootPath).join(""); + for (const info of Object.values(infos)) { + assert(info.plugin !== undefined, "plugin server info must carry provenance"); + const fingerprint = + info.transport === "stdio" + ? JSON.stringify({ + transport: "stdio", + argv: [info.command, ...(info.args ?? [])].map(normalize), + env: Object.fromEntries( + Object.entries(info.env ?? {}).map(([key, value]) => [key, normalize(value)]) + ), + }) + : JSON.stringify({ transport: info.transport, url: info.url }); + servers.set(info.plugin.serverName, fingerprint); + } + } + return { hook, servers }; + } + + /** + * Update gate: reject capability increases/changes between the installed + * tree and the staged new tree. A missing or invalid installed tree yields + * an empty surface, so everything staged counts as an addition + * (conservative: nothing inspectable was consented to at this path). + * Capability REMOVALS and grant reductions apply without re-consent. + */ + private async assertNoCapabilityIncrease( + name: string, + installedPath: string, + stagedPlugin: AgentPluginInfo + ): Promise { + const instanceId = this.instanceIdFor(name); + const { plugin: currentPlugin } = await discoverAgentPluginAt({ + pluginDir: installedPath, + scope: "global", + }); + const staged = await this.capabilitySurface(stagedPlugin, instanceId); + const current = + currentPlugin === null ? undefined : await this.capabilitySurface(currentPlugin, instanceId); + + const changes: string[] = []; + if (staged.hook !== undefined) { + const currentHook = current?.hook; + if (currentHook === undefined) { + const grantSuffix = + staged.hook.toolGrants.length > 0 + ? ` with tool grants: ${staged.hook.toolGrants.join(", ")}` + : ""; + changes.push(`adds executable hooks (${staged.hook.path}${grantSuffix})`); + } else { + if (staged.hook.path !== currentHook.path) { + changes.push(`moves its hook entry (${currentHook.path} → ${staged.hook.path})`); + } + const newGrants = staged.hook.toolGrants.filter( + (grant) => !currentHook.toolGrants.includes(grant) + ); + if (newGrants.length > 0) { + changes.push(`expands hook tool grants: ${newGrants.join(", ")}`); + } + } + } + for (const [serverName, fingerprint] of staged.servers) { + const currentFingerprint = current?.servers.get(serverName); + if (currentFingerprint === undefined) { + changes.push(`adds MCP server '${serverName}'`); + } else if (currentFingerprint !== fingerprint) { + changes.push(`changes MCP server '${serverName}'`); + } + } + if (changes.length > 0) { + throw new Error( + `The update to '${name}' ${changes.join("; ")}. Updates cannot expand a plugin's capabilities without review — uninstall it and reinstall to see the full consent preview.` + ); + } + } + /** * Agent definition files (agents/*.md) and executable workflow scripts * (workflows/*.js) for the consent preview, mirroring the runtime listers @@ -731,7 +862,14 @@ export class AgentPluginInstallService { 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(" "); + // Mirror the runtime's rendering (MCPServerManager shell-quotes every + // token): the consent preview must show the exact argument boundaries + // that will run — an arg containing whitespace/quotes could otherwise + // masquerade as several args or hide a boundary. + const commandLine = + info.args !== undefined + ? [info.command, ...info.args].map(rewrite).map(shellQuote).join(" ") + : rewrite(info.command); const envKeys = Object.keys(info.env ?? {}).filter( (key) => key !== "PLUGIN_ROOT" && key !== "PLUGIN_DATA" ); @@ -1542,9 +1680,17 @@ export class AgentPluginInstallService { async checkUpdates(): Promise { this.assertEnabled(); - const registry = await this.readRegistry("lenient"); - return Promise.all( - registry.map(async (entry): Promise => { + // STRICT: a lenient read would degrade an unreadable/corrupted registry + // to an empty list and report a false "everything is up to date". The + // thrown error surfaces nonfatally in the UI as the update-check error + // state instead. + const registry = await this.readRegistry("strict"); + // Bounded concurrency: one ls-remote process per entry at once would let + // a large registry exhaust sockets/file descriptors on section open. + return mapWithConcurrency( + registry, + UPDATE_CHECK_CONCURRENCY, + async (entry): Promise => { if (entry.source.refType === "commit") { return { name: entry.name, status: "pinned" }; } @@ -1570,7 +1716,7 @@ export class AgentPluginInstallService { } catch (error) { return { name: entry.name, status: "error", message: getErrorMessage(error) }; } - }) + } ); } @@ -1621,9 +1767,19 @@ export class AgentPluginInstallService { `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); + // Security: an update must not silently expand what the plugin can + // do — a compromised upstream could add hooks.js plus a bash grant + // and auto-load it on the next request. Compare the staged tree's + // capability surface against the installed tree and reject + // increases/changes; uninstall + reinstall routes through the full + // install consent preview. (In-place re-consent UX for updates is + // a v2 item.) + await this.assertNoCapabilityIncrease(entry.name, targetPath, plugin); + + await this.removeDir(path.join(stagedDir, ".git")); + const serverKeyPrefix = buildPluginServerKey(this.instanceIdFor(entry.name), ""); const trashDir = path.join(this.stagingRoot, `trash-${Date.now()}-${entry.name}`); const hadOldTree = await pathExists(targetPath); diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index 25568f81585..b41d2e72311 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -408,6 +408,53 @@ describe("WorkspaceMcpOverridesService", () => { }); }); + it("prunePluginOverrideKeys rejects opaque field shapes instead of declaring success", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.mkdir(path.dirname(filePath), { 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); + + // A newer release may represent an owned field with a shape this build + // cannot inspect; "successfully pruning" it would retire the caller's + // tombstone while plugin keys embedded in that shape survive. + await fs.writeFile(filePath, JSON.stringify({ enabledServers: { v2: ["plugin:abc:echo"] } })); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( + /unrecognized "enabledServers" shape/ + ); + + await fs.writeFile(filePath, JSON.stringify({ toolAllowlist: ["plugin:abc:echo"] })); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( + /unrecognized "toolAllowlist" shape/ + ); + + // Absent fields stay fine (nothing to prune). + await fs.writeFile(filePath, JSON.stringify({ somethingElse: true })); + await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + }); + it("prunePluginOverrideKeys rejects duplicate properties instead of mis-editing", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index d46292596e6..603dcbe96c3 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -611,14 +611,27 @@ export class WorkspaceMcpOverridesService { text = next; }; + // A newer release may represent an owned field with a shape this + // build cannot inspect. Declaring success would retire the caller's + // tombstone while plugin keys embedded in that shape survive — + // reactivating the server on reinstall. Throw instead: the tombstone + // stays retryable (same doctrine as unreadable files). + const opaqueShape = (field: string): Error => + new Error( + `Workspace MCP overrides file has an unrecognized "${field}" shape (written by a newer version?): ${filePath}` + ); + for (const field of ["enabledServers", "disabledServers"] as const) { // Re-parse after each removal: array indices shift as items go. for (;;) { const current = jsonc.parse(text) as Record; const value = current[field]; - if (!Array.isArray(value)) { + if (value === undefined) { break; } + if (!Array.isArray(value)) { + throw opaqueShape(field); + } const index = value.findIndex( (key) => typeof key === "string" && key.startsWith(keyPrefix) ); @@ -630,7 +643,10 @@ export class WorkspaceMcpOverridesService { } const allowlist = (jsonc.parse(text) as Record).toolAllowlist; - if (allowlist !== null && typeof allowlist === "object" && !Array.isArray(allowlist)) { + if (allowlist !== undefined) { + if (allowlist === null || typeof allowlist !== "object" || Array.isArray(allowlist)) { + throw opaqueShape("toolAllowlist"); + } for (const key of Object.keys(allowlist)) { if (key.startsWith(keyPrefix)) { removeAt(["toolAllowlist", key]); From 07ddfcabe4af81fc74bb28ef185a19c18fdcef49 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 08:56:40 +0000 Subject: [PATCH 15/63] fix: address Codex review round 33 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include stdio cwd in the update capability fingerprint: a cwd-only change (e.g. plugin root -> writable PLUGIN_DATA) silently moves relative module/config resolution, so it requires re-consent. - Settings copy shows the backend-provided managed plugin container path (new agentPlugins.containerLocation route) instead of a hardcoded ~/.mux/plugins: the active root is config-derived. - checkUpdates errors when the registry holds entries this build cannot parse (newer source kinds / corruption) instead of silently skipping them and reporting a false all-clear. - Uninstall surfaces a failed user-requested plugin-data deletion (with the staged path for manual cleanup) instead of confirming success; staging reclamation may otherwise never run. - New 'Update Agent Plugin…' palette command: a per-plugin selector so keyboard-only users can apply moved-tag updates after reviewing the warning (bulk update intentionally excludes them). --- .../Sections/PluginsSettingsSection.tsx | 15 +++- src/browser/stories/mocks/orpc.ts | 1 + src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.ts | 55 +++++++++++++++ src/common/orpc/schemas/api.ts | 5 ++ src/node/orpc/router.ts | 4 ++ .../agentPlugins/installService.test.ts | 68 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 39 ++++++++++- 8 files changed, 184 insertions(+), 4 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 37c343fa5fe..eedcedaf67a 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -417,6 +417,9 @@ export const PluginsSettingsSection: React.FC = () => { () => new Map() ); const [checkingUpdates, setCheckingUpdates] = useState(false); + // Backend-provided container path: the root is config-derived (canonically + // ~/.shux, possibly custom/legacy), so this copy must never hardcode it. + const [containerLocation, setContainerLocation] = useState(null); // 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 @@ -492,6 +495,7 @@ export const PluginsSettingsSection: React.FC = () => { useEffect(() => { void refresh(); void checkForUpdates(); + void api?.agentPlugins.containerLocation().then(setContainerLocation, () => undefined); // eslint-disable-next-line react-hooks/exhaustive-deps -- fetch on mount / API reconnect only; refresh/checkForUpdates are plain handlers (compiler-memoized), not inputs }, [api]); @@ -573,9 +577,14 @@ export const PluginsSettingsSection: React.FC = () => {

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. + {containerLocation !== null ? ( + {containerLocation} + ) : ( + "the managed plugins directory" + )} + . 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.

diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 23adc1b36ad..9daa662a54b 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -1109,6 +1109,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, agentPlugins: { list: () => Promise.resolve({ success: true, data: agentPluginsMock?.items ?? [] }), + containerLocation: () => Promise.resolve("~/.mux/plugins"), checkUpdates: () => Promise.resolve({ success: true, data: agentPluginsMock?.updateChecks ?? [] }), preview: () => diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index d95971b7205..5f21fad2358 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -100,6 +100,7 @@ export const CommandIds = { pluginsUninstall: () => "plugins:uninstall" as const, pluginsCheckUpdates: () => "plugins:check-updates" as const, pluginsUpdateAll: () => "plugins:update-all" as const, + pluginsUpdateOne: () => "plugins:update-one" as const, // Help commands helpKeybinds: () => "help:keybinds" as const, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 3b02c173428..dd58b9c8d20 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1795,6 +1795,61 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi } }, }, + { + id: CommandIds.pluginsUpdateOne(), + title: "Update Agent Plugin…", + subtitle: "Apply one plugin's pending update", + section: section.settings, + keywords: ["plugin", "update", "upgrade", "single", "one"], + run: () => undefined, + prompt: { + title: "Update Agent Plugin", + fields: [ + { + type: "select", + name: "pluginName", + label: "Plugin with a pending update", + placeholder: "Search updatable plugins…", + getOptions: async () => { + const checks = await p.api?.agentPlugins.checkUpdates(); + if (!checks?.success) { + return []; + } + // Moved tags are updatable here BY DESIGN: bulk update + // excludes them so they get per-plugin review, and this + // selector (with its warning label) is that reviewed, + // keyboard-accessible path. + return checks.data + .filter( + (check) => + check.status === "update-available" || check.status === "tag-moved" + ) + .map((check) => ({ + id: check.name, + label: + check.status === "tag-moved" + ? `${check.name} — tag moved (review: tags should be immutable)` + : `${check.name} — update available`, + keywords: [check.name, check.status], + })); + }, + }, + ], + onSubmit: async (values) => { + const api = p.api; + if (!api) return; + const result = await api.agentPlugins.update({ name: values.pluginName }); + // A mounted section keeps its own stale updateChecks map; + // tell it to re-query so badges match the toast. + publishPluginsSectionIntent({ type: "refresh" }); + showCommandFeedbackToast( + result.success + ? { type: "success", message: `Updated ${values.pluginName}.` } + : { type: "error", message: result.error } + ); + }, + }, + }, ] satisfies CommandAction[]) : []), ]); diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index b3558688c37..ba4be6b5e42 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1022,6 +1022,11 @@ export const agentPlugins = { input: z.void(), output: ResultSchema(z.array(AgentPluginListItemSchema), z.string()), }, + /** Display path of the ACTIVE managed plugin container (config-derived root; never hardcode it in UI). */ + containerLocation: { + input: z.void(), + output: z.string(), + }, uninstall: { input: z.object({ name: z.string(), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 5826f7aa65c..5d4a3720137 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3229,6 +3229,10 @@ export const router = (authToken?: string) => { return { success: false, error: getErrorMessage(error) }; } }), + containerLocation: t + .input(schemas.agentPlugins.containerLocation.input) + .output(schemas.agentPlugins.containerLocation.output) + .handler(({ context }) => context.agentPluginInstallService.containerLocation()), uninstall: t .input(schemas.agentPlugins.uninstall.input) .output(schemas.agentPlugins.uninstall.output) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index a22b9ed0f32..fd2fd55379d 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -341,6 +341,28 @@ describe("AgentPluginInstallService", () => { /changes MCP server 'echo'/ ); + // Changing ONLY the cwd (same argv/env) is likewise consent-relevant: + // relative module/config resolution moves (e.g. to writable PLUGIN_DATA). + await writePluginFixture(remoteDir, { version: "2.0.2" }); + await fsPromises.writeFile( + path.join(remoteDir, "mcp.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + echo: { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/server.js"], + cwd: "${PLUGIN_DATA}", + }, + }, + }) + ); + await commitAll(remoteDir, "v2.0.2 changes echo cwd only"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /changes MCP server 'echo'/ + ); + // A capability-neutral update (same hook-less, same servers) applies. await writePluginFixture(remoteDir, { version: "3.0.0" }); await fsPromises.rm(path.join(remoteDir, "hooks.js")); @@ -355,6 +377,52 @@ describe("AgentPluginInstallService", () => { await expect(service.checkUpdates()).rejects.toThrow(/corrupted/); }); + test("checkUpdates surfaces unrecognized registry entries instead of skipping them", async () => { + // A newer version's entry (e.g. a new source kind) parses as unrecognized + // and would be silently dropped by the lenient entry parser — the check + // would then report "all up to date" without ever checking that install. + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [{ name: "future-plugin", scope: "global", source: { type: "registry-v2" } }], + }) + ); + + await expect(service.checkUpdates()).rejects.toThrow(/cannot read/); + }); + + test("uninstall surfaces a failed requested plugin-data deletion", 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.txt"), "data"); + + // The staged-data deletion fails post-commit (e.g. a locked file on + // Windows). The uninstall itself is committed, but the user explicitly + // requested the deletion — reporting success would strand the data under + // plugin-staging indefinitely (reclamation only runs during a later + // staging operation). + const internals = service as unknown as { removeDir: (dir: string) => Promise }; + const realRemoveDir = internals.removeDir.bind(internals); + const removeSpy = spyOn(internals, "removeDir").mockImplementation((dir: string) => + path.basename(dir).startsWith("trash-data-") + ? Promise.reject(new Error("EBUSY: resource busy")) + : realRemoveDir(dir) + ); + try { + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: true }) + ).rejects.toThrow(/uninstalled, but deleting its stored data failed.*delete it manually/s); + } finally { + removeSpy.mockRestore(); + } + // The uninstall committed; the staged data remains for manual cleanup. + expect(await registry()).toEqual([]); + expect((await stagingLeftovers()).some((name) => name.startsWith("trash-data-"))).toBe(true); + }); + test("checkUpdates bounds concurrent remote lookups", async () => { // Seed a registry with many entries; a gate inside resolveRemoteRef // measures how many lookups run simultaneously. diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 82702b11692..bd78e005014 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -209,6 +209,15 @@ export class AgentPluginInstallService { this.registryFile = path.join(config.rootDir, REGISTRY_FILE_NAME); } + /** + * Display path of the ACTIVE managed plugin container for UI copy. The + * root is config-derived (canonically ~/.shux, possibly a custom or + * legacy-compat root), so the UI must never hardcode it. + */ + containerLocation(): string { + return shortenHome(this.containerDir); + } + // --------------------------------------------------------------------- // Registry persistence (~/.mux/plugins.json) // --------------------------------------------------------------------- @@ -320,8 +329,21 @@ export class AgentPluginInstallService { return entries; } + /** + * In strict mode, an entry this build cannot parse (a newer version's + * source kind, or corruption) is an error: callers like checkUpdates would + * otherwise silently skip that managed install and report a false + * "everything is up to date". + */ private async readRegistry(mode: "lenient" | "strict"): Promise { - return this.parseRegistryEntries((await this.readRegistryDocument(mode)).rawEntries); + const { rawEntries } = await this.readRegistryDocument(mode); + const entries = this.parseRegistryEntries(rawEntries); + if (mode === "strict" && entries.length !== rawEntries.length) { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) contains ${rawEntries.length - entries.length} entr${rawEntries.length - entries.length === 1 ? "y" : "ies"} this version cannot read (written by a newer version of Mux, or corrupted).` + ); + } + return entries; } /** `name` of a raw registry entry, for identity matching during raw rewrites. */ @@ -677,6 +699,9 @@ export class AgentPluginInstallService { env: Object.fromEntries( Object.entries(info.env ?? {}).map(([key, value]) => [key, normalize(value)]) ), + // cwd changes relative module/config resolution (e.g. plugin + // root → writable PLUGIN_DATA), so it is consent-relevant. + ...(info.cwd !== undefined ? { cwd: normalize(info.cwd) } : {}), }) : JSON.stringify({ transport: info.transport, url: info.url }); servers.set(info.plugin.serverName, fingerprint); @@ -1316,8 +1341,14 @@ export class AgentPluginInstallService { }); }); } + let dataDeletionFailure: string | undefined; if (stagedData) { + // The user EXPLICITLY requested this deletion, so a failure (e.g. a + // locked file on Windows) must surface rather than report success: + // stale-staging reclamation only runs during a later staging + // operation, which may never happen. await this.removeDir(dataTrashDir).catch((error: unknown) => { + dataDeletionFailure = `The plugin was uninstalled, but deleting its stored data failed (${getErrorMessage(error)}). The data was moved to ${shortenHome(dataTrashDir)} — delete it manually.`; log.warn("Failed to delete plugin data; leaving it for staging reclamation", { dataTrashDir, error: getErrorMessage(error), @@ -1368,6 +1399,12 @@ export class AgentPluginInstallService { } log.info(`Uninstalled agent plugin '${entry.name}'`); + // Thrown LAST so the remaining cleanup above (invalidation, override + // pruning) still ran; the uninstall itself is committed and the message + // says so. + if (dataDeletionFailure !== undefined) { + throw new Error(dataDeletionFailure); + } }); } From 472e34fd5b6ba5c1f7d50b6132385a8b917f19e5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 09:08:09 +0000 Subject: [PATCH 16/63] fix: address Codex review round 34 - Install rollback retries the promoted-tree deletion after stopping the plugin's MCP servers (a running server can hold the lock), and quarantines a still-undeletable tree into the staging root so global discovery cannot load it as an unmanaged plugin after a failed install. - prunePluginOverrideKeys rejects a non-object override document root (a newer build's representation) the same way it rejects opaque owned-field shapes, keeping the uninstall tombstone retryable instead of retiring it against uninspectable content. --- .../agentPlugins/installService.test.ts | 16 ++++++---- .../services/agentPlugins/installService.ts | 29 ++++++++++++++++--- .../workspaceMcpOverridesService.test.ts | 8 +++++ .../services/workspaceMcpOverridesService.ts | 8 ++++- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index fd2fd55379d..30432898585 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1594,10 +1594,13 @@ describe("AgentPluginInstallService", () => { expect(await registry()).toHaveLength(1); }); - test("install rollback invalidates servers even when deleting the promoted tree fails", async () => { + test("install rollback invalidates servers and quarantines the tree when deletion fails", async () => { // A locked file (e.g. on Windows) can make the rollback deletion reject; - // the prefix invalidation must still run, or a server started from the - // briefly-visible tree survives an install that reported failure. + // the prefix invalidation must still run (a running server can be exactly + // what holds the lock), and the undeletable tree must be QUARANTINED into + // the staging root — leaving it in the globally scanned plugins container + // would let discovery load it as an unmanaged plugin even though the + // install reported failure. const stoppedPrefixes: string[] = []; const mcpStub = { stopServersWithKeyPrefix: (prefix: string) => { @@ -1624,10 +1627,9 @@ describe("AgentPluginInstallService", () => { dir === targetPath ? Promise.reject(new Error("EBUSY: resource busy")) : realRemoveDir(dir) ); try { - // Both failures surface in one error; the invalidation still ran. await expect( serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }) - ).rejects.toThrow(/persist the plugin registry.*could not be removed/s); + ).rejects.toThrow(/persist the plugin registry/); } finally { writeSpy.mockRestore(); removeSpy.mockRestore(); @@ -1635,6 +1637,10 @@ describe("AgentPluginInstallService", () => { const instanceId = computePluginInstanceId(targetPath); expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`]); expect(await registry()).toEqual([]); + // The tree left the discovery container via the quarantine rename (the + // staged-dir mock only rejects the container path), so no unmanaged + // ghost plugin can appear. + expect(await pathExists(targetPath)).toBe(false); }); test("install rejects a source URL with embedded credentials", async () => { diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index bd78e005014..f461c7bb7be 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1074,12 +1074,13 @@ export class AgentPluginInstallService { // isolated so a failure (e.g. a locked file on Windows) cannot // skip the others or mask the registry error. const cleanupNotes: string[] = []; + let treeRemoved = false; try { await this.removeDir(targetPath); - } catch (cleanupError) { - cleanupNotes.push( - `the promoted plugin tree could not be removed — delete ${shortenHome(targetPath)} manually (${getErrorMessage(cleanupError)})` - ); + treeRemoved = true; + } catch { + // Retried below after the plugin's processes are stopped — a + // running server can be exactly what holds the lock. } // A getToolsForWorkspace running during the promote↔rollback window // can have discovered the briefly-visible tree and be starting a @@ -1095,6 +1096,26 @@ export class AgentPluginInstallService { `the plugin's MCP servers could not be stopped (${getErrorMessage(cleanupError)})` ); } + if (!treeRemoved) { + // Retry now that the lock-holding processes are gone; if the tree + // still cannot be deleted, QUARANTINE it into the staging root so + // the globally scanned plugins container cannot rediscover and + // load it as an unmanaged plugin (stale-dir reclamation cleans + // staging leftovers). + try { + await this.removeDir(targetPath); + } catch { + const quarantineDir = path.join(this.stagingRoot, `trash-${Date.now()}-${name}`); + try { + await fsPromises.rename(targetPath, quarantineDir); + await this.removeDir(quarantineDir).catch(() => undefined); + } catch (cleanupError) { + cleanupNotes.push( + `the promoted plugin tree could not be removed — delete ${shortenHome(targetPath)} manually (${getErrorMessage(cleanupError)})` + ); + } + } + } const notes = cleanupNotes.length > 0 ? ` Additionally, ${cleanupNotes.join("; ")}.` : ""; throw new Error( `Failed to persist the plugin registry: ${getErrorMessage(error)}${notes}` diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index b41d2e72311..b967f3c3368 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -453,6 +453,14 @@ describe("WorkspaceMcpOverridesService", () => { // Absent fields stay fine (nothing to prune). await fs.writeFile(filePath, JSON.stringify({ somethingElse: true })); await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + + // A non-object ROOT is equally opaque: a newer build may store the whole + // document in a different shape with plugin keys embedded inside it. + await fs.writeFile(filePath, JSON.stringify([{ enabledServers: ["plugin:abc:echo"] }])); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( + /unrecognized root shape/ + ); }); it("prunePluginOverrideKeys rejects duplicate properties instead of mis-editing", async () => { diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index 603dcbe96c3..7fde5aace7b 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -578,7 +578,13 @@ export class WorkspaceMcpOverridesService { throw new Error(`Workspace MCP overrides file has JSONC parse errors: ${filePath}`); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - continue; + // A newer build may store the whole document in a non-object shape + // this build cannot inspect; "successfully pruning" it would retire + // the caller's tombstone while plugin keys embedded in that shape + // survive. Same doctrine as opaque owned-field shapes below. + throw new Error( + `Workspace MCP overrides file has an unrecognized root shape (written by a newer version?): ${filePath}` + ); } // Duplicate properties make jsonc.parse (last value wins) and From 67f4d46d3f343652f075da21b4c7d264fb1e4871 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 09:23:43 +0000 Subject: [PATCH 17/63] fix: address Codex review round 35 - Recheck the workspace removal stop-epoch inside the synchronous MCP cache publication callback: a stopServers(workspaceId) landing while the awaited invalidation scan yielded found no cache entry to close, so publishing would resurrect processes for a removed workspace. Skip publication and close the late clients instead. - Sort env entries in the update capability fingerprint: env is an unordered map, so an upstream property reordering must not be rejected as a capability change. - Validate added plugin override keys PER FIELD: a stale key surviving only in toolAllowlist must not smuggle that key into enabledServers without discovery validation. - Stories: accept a single props parameter instead of destructuring (repo rename-friendly convention). --- .../PluginsSettingsSection.stories.tsx | 11 +++-- .../agentPlugins/installService.test.ts | 41 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 6 ++- src/node/services/agentPlugins/mcpConfig.ts | 40 ++++++++++++------ src/node/services/mcpServerManager.test.ts | 40 ++++++++++++++++++ src/node/services/mcpServerManager.ts | 23 ++++++++++- 6 files changed, 141 insertions(+), 20 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx index 7f968370e56..9afe12a9137 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -97,18 +97,17 @@ const MAX_LENGTH_ITEM: AgentPluginListItem = { mcpServerCount: 0, }; -const PluginsSectionStoryShell: FC<{ options: MockORPCClientOptions; children: ReactNode }> = ({ - options, - children, -}) => { +const PluginsSectionStoryShell: FC<{ options: MockORPCClientOptions; children: ReactNode }> = ( + props +) => { const clientRef = useRef(null); - clientRef.current ??= createMockORPCClient(options); + clientRef.current ??= createMockORPCClient(props.options); return ( - {children} + {props.children} diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 30432898585..f43c58d5d78 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -371,6 +371,36 @@ describe("AgentPluginInstallService", () => { expect(updated.lockedSha).toBe(cleanHead); }); + test("update accepts an env property reordering as capability-neutral", async () => { + // env is an unordered map: a mere property reordering upstream spawns an + // identical environment and must not be rejected as a capability change + // (which would force a needless uninstall/reinstall). + const mcpWithEnv = (env: Record) => + JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + echo: { type: "stdio", command: "node", args: ["${PLUGIN_ROOT}/server.js"], env }, + }, + }); + await fsPromises.writeFile( + path.join(remoteDir, "mcp.json"), + mcpWithEnv({ ALPHA: "1", BETA: "2" }) + ); + await commitAll(remoteDir, "env in ALPHA,BETA order"); + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + await writePluginFixture(remoteDir, { version: "1.0.1" }); + await fsPromises.writeFile( + path.join(remoteDir, "mcp.json"), + mcpWithEnv({ BETA: "2", ALPHA: "1" }) + ); + const newHead = await commitAll(remoteDir, "env reordered to BETA,ALPHA"); + + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(newHead); + }); + test("checkUpdates surfaces a corrupted registry instead of a false all-clear", async () => { await fsPromises.writeFile(registryFile(), "{ not json"); @@ -1685,6 +1715,17 @@ describe("AgentPluginInstallService", () => { ); await validator({}, { enabledServers: ["ordinary-server"] }); + // Additions are PER FIELD: a stale key surviving only in toolAllowlist + // (e.g. a removed unmanaged dir's old tool selection) must not smuggle + // that key into enabledServers without discovery validation — enabling + // is the consent-relevant action. + await expect( + validator( + { toolAllowlist: { "plugin:gone:echo": [] } }, + { toolAllowlist: { "plugin:gone:echo": [] }, enabledServers: ["plugin:gone:echo"] } + ) + ).rejects.toThrow(/does not match any available plugin server/); + // Discovery failure → additions rejected (never accept unverifiable keys). const failingValidator = buildAddedPluginKeyValidator(() => Promise.reject(new Error("discovery unavailable")) diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index f461c7bb7be..0214a9108f8 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -696,8 +696,12 @@ export class AgentPluginInstallService { ? JSON.stringify({ transport: "stdio", argv: [info.command, ...(info.args ?? [])].map(normalize), + // Sorted: env is an unordered map, so a mere property + // reordering upstream must not read as a capability change. env: Object.fromEntries( - Object.entries(info.env ?? {}).map(([key, value]) => [key, normalize(value)]) + Object.entries(info.env ?? {}) + .map(([key, value]): [string, string] => [key, normalize(value)]) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) ), // cwd changes relative module/config resolution (e.g. plugin // root → writable PLUGIN_DATA), so it is consent-relevant. diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 3b5176f7478..89bfbb91556 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -74,14 +74,22 @@ export function isCanonicalPluginServerKeyPrefix(prefix: string): boolean { return CANONICAL_PLUGIN_KEY_PREFIX_PATTERN.test(prefix); } -function collectPluginOverrideKeys(overrides: WorkspaceMCPOverrides): Set { - return new Set( - [ - ...(overrides.enabledServers ?? []), - ...(overrides.disabledServers ?? []), - ...Object.keys(overrides.toolAllowlist ?? {}), - ].filter((key) => key.startsWith(PLUGIN_SERVER_KEY_PREFIX)) - ); +/** + * Plugin keys PER FIELD, not collapsed into one set: a stale key that only + * survives in toolAllowlist (e.g. a removed unmanaged dir's old tool + * selection) must not make that key's NEW appearance in enabledServers look + * like a no-op — enabling is the consent-relevant action. + */ +function collectPluginOverrideKeysByField( + overrides: WorkspaceMCPOverrides +): Record<"enabledServers" | "disabledServers" | "toolAllowlist", Set> { + const pluginKeys = (keys: readonly string[]): Set => + new Set(keys.filter((key) => key.startsWith(PLUGIN_SERVER_KEY_PREFIX))); + return { + enabledServers: pluginKeys(overrides.enabledServers ?? []), + disabledServers: pluginKeys(overrides.disabledServers ?? []), + toolAllowlist: pluginKeys(Object.keys(overrides.toolAllowlist ?? {})), + }; } /** @@ -105,10 +113,18 @@ export function buildAddedPluginKeyValidator( listDiscoveredPluginServerKeys: () => Promise> ): (current: WorkspaceMCPOverrides, incoming: WorkspaceMCPOverrides) => Promise { return async (current, incoming) => { - const currentKeys = collectPluginOverrideKeys(current); - const addedKeys = [...collectPluginOverrideKeys(incoming)].filter( - (key) => !currentKeys.has(key) - ); + // Additions are computed PER FIELD so a key already present in one field + // (say a stale toolAllowlist entry) still validates when it newly enters + // another (enabledServers — the consent-relevant one). + const currentByField = collectPluginOverrideKeysByField(current); + const incomingByField = collectPluginOverrideKeysByField(incoming); + const addedKeys = [ + ...new Set( + (Object.keys(incomingByField) as Array).flatMap((field) => + [...incomingByField[field]].filter((key) => !currentByField[field].has(key)) + ) + ), + ]; if (addedKeys.length === 0) { return; } diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 25ec1599979..27dfab54368 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -267,6 +267,46 @@ describe("MCPServerManager", () => { expect(Object.keys(second.tools)).toHaveLength(1); }); + test("workspace removal landing during the invalidation scan never publishes the started servers", async () => { + const workspaceId = "ws-removal-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + + // Same one-shot iterator hook as the invalidation race above, but the + // queued call is a removal-style stopServers(workspaceId): it bumps the + // stop epoch AFTER the pre-publication epoch check ran and finds no cache + // entry to close (publication hasn't happened) — publishing anyway would + // resurrect MCP processes for a removed workspace until idle cleanup. + 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.stopServers(workspaceId); + }); + } + return originalIterator(); + }; + + access.startServers = () => + Promise.resolve({ instances, failedServerNames: [], timedOutServerNames: [] }); + + const result = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(stopPromise).toBeDefined(); + await stopPromise; + + // Publication was skipped and the late clients were closed. + expect(close).toHaveBeenCalledTimes(1); + expect(Object.keys(result.tools)).toEqual([]); + expect(access.workspaceServers.has(workspaceId)).toBe(false); + }); + test("stopServersWithKeyPrefix closes only matching instances and retries them on next use", async () => { const workspaceId = "ws-selective-stop"; const pluginKey = "plugin:abc123:echo"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 5dbf5bf9bba..2e24d625056 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1984,12 +1984,20 @@ export class MCPServerManager { // happens inside the stable-clock callback so no invalidation can land // between the final scan and workspaceServers.set (see // closeInvalidatedInstancesThenPublish). - let entry!: WorkspaceServers; + let entry: WorkspaceServers | undefined; await this.closeInvalidatedInstancesThenPublish( instances, startupEpoch, workspaceId, (invalidatedKeys) => { + // Recheck the removal-stop epoch INSIDE the synchronous publication + // callback: a stopServers(workspaceId) landing while the awaited + // invalidation scan yielded found no cache entry to close, so + // publishing now would resurrect processes for a removed workspace + // until idle cleanup. Skip publication; the late close runs below. + if ((this.workspaceStopEpochs.get(workspaceId) ?? 0) !== stopEpochBefore) { + return; + } entry = { configSignature: signature, instances, @@ -2002,6 +2010,19 @@ export class MCPServerManager { this.workspaceServers.set(workspaceId, entry); } ); + if (entry === undefined) { + for (const instance of instances.values()) { + try { + await instance.close(); + } catch (error) { + log.warn("Failed to stop late MCP server for removed workspace", { + error, + name: instance.name, + }); + } + } + return { tools: {}, toolServerNames: {}, stats, promptDescriptors: [] }; + } // Repair first so the awaited refresh never queries a server revoked // during startup, then again after it so mutations landing during the From 1b1afe895573376a049efccefa463d90f2d61f7b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 09:29:27 +0000 Subject: [PATCH 18/63] fix: address Codex review round 36 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Security: enforce an aggregate quota (100 MiB / 10,000 files, .git excluded, symlinks not followed) on every staged clone checkout before validation reads it — --depth 1 and the subprocess output cap do not bound checkout bytes from an untrusted remote. - Consent preview discloses full stdio env assignments (quoted KEY='value'), not just key names: values like NODE_OPTIONS=--require=./payload.js change what executes without appearing in the argv. - Uninstall data rollback re-invalidates the plugin's MCP prefix and removes a recreated (empty) data dir before restoring the staged original, so a late server launch cannot make the restore EEXIST-fail and strand the user's data in staging. --- .../agentPlugins/installService.test.ts | 97 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 86 +++++++++++++++- 2 files changed, 179 insertions(+), 4 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index f43c58d5d78..ed87d951067 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -401,6 +401,103 @@ describe("AgentPluginInstallService", () => { expect(updated.lockedSha).toBe(newHead); }); + test("preview rejects a repository exceeding the staged-checkout quota", async () => { + // Remotes are untrusted: --depth 1 bounds history, not checkout bytes. + // An oversized tree must be rejected (and its staging dir deleted) + // before any validation reads it. + const smallQuotaService = new AgentPluginInstallService(config, { + isEnabled: () => true, + stagingQuota: { maxBytes: 1024, maxFiles: 100 }, + }); + await fsPromises.writeFile(path.join(remoteDir, "payload.bin"), "x".repeat(4096)); + await commitAll(remoteDir, "oversized payload"); + + await expect(smallQuotaService.preview({ input: remoteDir })).rejects.toThrow( + /too large to install/ + ); + expect(await stagingLeftovers()).toEqual([]); + + // File-count quota trips independently of bytes. + const fileCountService = new AgentPluginInstallService(config, { + isEnabled: () => true, + stagingQuota: { maxBytes: 1024 * 1024, maxFiles: 2 }, + }); + + await expect(fileCountService.preview({ input: remoteDir })).rejects.toThrow( + /too large to install/ + ); + }); + + test("consent preview discloses full env assignments, not just key names", async () => { + // NODE_OPTIONS=--require=./payload.js changes what executes without + // appearing in the argv; the consent card must show the value. + await fsPromises.writeFile( + path.join(remoteDir, "mcp.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + echo: { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/server.js"], + env: { NODE_OPTIONS: "--require=./payload.js" }, + }, + }, + }) + ); + await commitAll(remoteDir, "env with execution-relevant value"); + + const preview = await service.preview({ input: remoteDir }); + expect(preview.mcpServers[0].summary).toContain("NODE_OPTIONS='--require=./payload.js'"); + }); + + test("failed uninstall registry write restores plugin data over a recreated data dir", async () => { + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const dataPath = getPluginDataPath(muxRoot, instanceId); + const stoppedPrefixes: string[] = []; + const mcpStub = { + stopServersWithKeyPrefix: (prefix: string) => { + stoppedPrefixes.push(prefix); + return Promise.resolve(); + }, + }; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub as unknown as MCPServerManager, + }); + const preview = await serviceWithMcp.preview({ input: remoteDir }); + await serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }); + await fsPromises.mkdir(dataPath, { recursive: true }); + await fsPromises.writeFile(path.join(dataPath, "state.txt"), "original"); + + // The registry write fails AND a late server launch recreated dataPath in + // the meantime (prepareStdioLaunch mkdirs it): the rollback must + // re-invalidate, clear the recreated dir, and restore the ORIGINAL data — + // an EEXIST rename failure would strand it in staging. + const internals = serviceWithMcp as unknown as { + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; + }; + const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(async () => { + await fsPromises.mkdir(dataPath, { recursive: true }); + throw new Error("ENOSPC: no space left on device"); + }); + try { + await expect( + serviceWithMcp.uninstall({ name: "demo-plugin", deletePluginData: true }) + ).rejects.toThrow(/persist the plugin registry/); + } finally { + writeSpy.mockRestore(); + } + + // Original data restored; rollback re-invalidated (pre-stage stop + rollback stop). + expect(await fsPromises.readFile(path.join(dataPath, "state.txt"), "utf8")).toBe("original"); + expect(stoppedPrefixes.filter((p) => p === `plugin:${instanceId}:`).length).toBeGreaterThan(1); + expect((await registry()).map((entry) => (entry as { name: string }).name)).toEqual([ + "demo-plugin", + ]); + expect((await stagingLeftovers()).filter((name) => name.startsWith("trash-data-"))).toEqual([]); + }); + test("checkUpdates surfaces a corrupted registry instead of a false all-clear", async () => { await fsPromises.writeFile(registryFile(), "{ not json"); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 0214a9108f8..09e466b4d2e 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -131,6 +131,16 @@ async function runGit(args: string[], opts?: { timeoutMs?: number }): Promise { + const quota = this.deps.stagingQuota ?? { + maxBytes: STAGED_TREE_MAX_BYTES, + maxFiles: STAGED_TREE_MAX_FILES, + }; + let bytes = 0; + let files = 0; + const pending: string[] = [dir]; + while (pending.length > 0) { + const current = pending.pop(); + assert(current !== undefined, "assertStagedTreeWithinQuota: queue underflow"); + for (const entry of await fsPromises.readdir(current, { withFileTypes: true })) { + if (entry.name === ".git" && current === dir) { + continue; + } + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + continue; + } + files += 1; + if (entry.isFile()) { + const stat = await fsPromises.lstat(entryPath); + bytes += stat.size; + } + if (files > quota.maxFiles || bytes > quota.maxBytes) { + throw new Error( + `The repository is too large to install as a plugin (limit: ${quota.maxFiles} files, ${Math.floor(quota.maxBytes / (1024 * 1024))} MiB).` + ); + } + } + } + } + /** Shallow-clone `resolved` into a fresh staging dir; returns { dir, sha } with sha = HEAD. */ private async cloneResolved( url: string, @@ -539,6 +591,7 @@ export class AgentPluginInstallService { } const sha = (await runGit(["-C", dir, "rev-parse", "HEAD"])).trim(); assert(isFullCommitSha(sha), "cloneResolved: rev-parse HEAD must be a full SHA"); + await this.assertStagedTreeWithinQuota(dir); return { dir, sha }; } catch (error) { await this.removeDir(dir); @@ -584,6 +637,7 @@ export class AgentPluginInstallService { `The remote moved since the preview (expected ${sha.slice(0, 12)}, got ${head.slice(0, 12)}). Run the preview again.` ); } + await this.assertStagedTreeWithinQuota(dir); return dir; } catch (error) { await this.removeDir(dir); @@ -899,13 +953,19 @@ export class AgentPluginInstallService { info.args !== undefined ? [info.command, ...info.args].map(rewrite).map(shellQuote).join(" ") : rewrite(info.command); - const envKeys = Object.keys(info.env ?? {}).filter( - (key) => key !== "PLUGIN_ROOT" && key !== "PLUGIN_DATA" - ); + // Env VALUES are execution-relevant (e.g. NODE_OPTIONS=--require=… + // auto-loads code the argv never shows), so consent must disclose the + // full assignment, quoted like the argv so boundaries are unambiguous. + const envAssignments = Object.entries(info.env ?? {}) + .filter(([key]) => key !== "PLUGIN_ROOT" && key !== "PLUGIN_DATA") + .map(([key, value]) => `${key}=${shellQuote(rewrite(value))}`); result.push({ serverName: info.plugin.serverName, transport: "stdio", - summary: envKeys.length > 0 ? `${commandLine} (env: ${envKeys.join(", ")})` : commandLine, + summary: + envAssignments.length > 0 + ? `${commandLine} (env: ${envAssignments.join(" ")})` + : commandLine, }); } else { result.push({ @@ -1346,6 +1406,24 @@ export class AgentPluginInstallService { } catch (error) { await restoreTree("failed registry write"); if (stagedData) { + // A getToolsForWorkspace startup that began after the pre-stage + // invalidation can have recreated dataPath (prepareStdioLaunch + // mkdirs it) while the registry write failed. Invalidate again so + // no late server publishes against the restored install, then + // remove the recreated (fresh, empty) directory — otherwise the + // rename below EEXIST-fails and strands the ORIGINAL data in + // staging while the still-installed plugin sees an empty data dir. + try { + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + } catch (stopError) { + log.warn("Failed to re-invalidate plugin servers during data rollback", { + serverKeyPrefix, + error: getErrorMessage(stopError), + }); + } + if (await pathExists(dataPath)) { + await this.removeDir(dataPath).catch(() => undefined); + } await fsPromises.rename(dataTrashDir, dataPath).catch((rollbackError: unknown) => { log.error("Failed to restore plugin data after failed registry write", { dataPath, From 60412186009b7868f406b8c76510617308bcb01b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 13:35:55 +0000 Subject: [PATCH 19/63] fix: address Codex review round 37 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCPServerManager: recheck cache ownership inside the synchronous publication callbacks of the timed-out retry and active-lease restart paths; a removal/replacement landing during the awaited invalidation scan no longer merges new clients into a detached entry (ownerless processes). Removed workspaces return empty instead of recursing. - Bound git DURING clone/fetch/checkout with a disk-quota watchdog (aborts the process tree when the staging dir outgrows 2x the checkout quota) — the post-clone check could only reject a tree git had already fully materialized. - Stale staging reclamation ages trash dirs by their embedded Date.now stamp (renames preserve the tree's old mtime) and never touches paths owned by in-process operations, so an old install staged as a rollback copy cannot be reaped mid-uninstall/update. - resolveRemoteRef prefers the tracked entry's stored ref kind when a branch and tag share a name: a remote ADDING a same-name branch no longer breaks a still-valid tracked tag. - Duplicate registry names: strict (mutation) reads refuse, lenient views keep the first entry — raw rewrites match by name and would otherwise patch every duplicate from the first entry's source. - Install writes a promotion journal before the promote rename; reconcileOrphanedPromotions (section open) quarantines trees orphaned by a crash between promote and registry write, restoring reinstallability without manual deletion. - Docs: Settings->Plugins paragraph names the canonical ~/.shux plugin locations instead of legacy ~/.mux paths. --- docs/config/mcp-servers.mdx | 2 +- .../agentPlugins/installService.test.ts | 148 +++++- .../services/agentPlugins/installService.ts | 483 ++++++++++++++---- src/node/services/mcpServerManager.test.ts | 51 ++ src/node/services/mcpServerManager.ts | 67 +++ 5 files changed, 656 insertions(+), 95 deletions(-) diff --git a/docs/config/mcp-servers.mdx b/docs/config/mcp-servers.mdx index 54b3b45353f..e1713b9a16a 100644 --- a/docs/config/mcp-servers.mdx +++ b/docs/config/mcp-servers.mdx @@ -61,7 +61,7 @@ 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 `~/.xum/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. +**Settings → Plugins** installs plugins from git into `~/.shux/plugins` (paste a git URL or `owner/repo[@ref]`); the exact location derives from the active Shux home and is shown in the section. 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 `~/.shux/plugin-data/` unless you opt in to deleting it. ## Behavior diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index ed87d951067..dc8b62529fc 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -8,7 +8,7 @@ import { Config } from "@/node/config"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { execFileAsync } from "@/node/utils/disposableExec"; -import { AgentPluginInstallService } from "./installService"; +import { AgentPluginInstallService, withDiskQuotaWatchdog } from "./installService"; import { AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, buildAddedPluginKeyValidator, @@ -498,6 +498,152 @@ describe("AgentPluginInstallService", () => { expect((await stagingLeftovers()).filter((name) => name.startsWith("trash-data-"))).toEqual([]); }); + test("withDiskQuotaWatchdog aborts a pending git run when the staging dir outgrows the quota", async () => { + // The post-clone quota only rejects a tree git already materialized; the + // watchdog is what bounds disk DURING clone. Simulate a long transfer: + // the wrapped fn writes an oversized file, then only settles on abort. + const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "quota-watchdog-")); + try { + await expect( + withDiskQuotaWatchdog({ dir, maxBytes: 1024, pollMs: 10 }, async (signal) => { + await fsPromises.writeFile(path.join(dir, "pack"), "x".repeat(8192)); + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("killed")), { once: true }); + }); + }) + ).rejects.toThrow(/too large to install/); + } finally { + await fsPromises.rm(dir, { recursive: true, force: true }); + } + }); + + test("stale staging reclamation ages trash by embedded stamp and spares owned dirs", async () => { + const staging = stagingDir(); + await fsPromises.mkdir(staging, { recursive: true }); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + + // Freshly staged trash inherits the tree's OLD mtime via rename — the + // embedded stamp says it is fresh, so reclamation must keep it. + const freshStamped = path.join(staging, `trash-${Date.now()}-fresh`); + await fsPromises.mkdir(freshStamped); + await fsPromises.utimes(freshStamped, twoHoursAgo, twoHoursAgo); + + // Crash leftover: old stamp, whatever the mtime says — reclaimed. + const oldStamped = path.join(staging, `trash-${twoHoursAgo.getTime()}-old`); + await fsPromises.mkdir(oldStamped); + + const internals = service as unknown as { + createStagingDir: () => Promise; + purgeStaleStaging: () => Promise; + }; + // An in-flight stage dir stays owned by the operation even when a slow + // clone pushes it past the age threshold. + const active = await internals.createStagingDir(); + await fsPromises.utimes(active, twoHoursAgo, twoHoursAgo); + + await internals.purgeStaleStaging(); + + expect(await pathExists(freshStamped)).toBe(true); + expect(await pathExists(oldStamped)).toBe(false); + expect(await pathExists(active)).toBe(true); + }); + + test("a same-name branch added later does not break a tracked tag", async () => { + await git(remoteDir, "tag", "dual"); + const preview = await service.preview({ input: remoteDir, ref: "dual" }); + expect(preview.source.refType).toBe("tag"); + const tagSha = preview.lockedSha; + await service.install({ source: preview.source, expectedSha: tagSha }); + + // The remote later gains a BRANCH named 'dual' pointing at new content + // while the tag is unchanged. The stored ref kind must win the ambiguity: + // branch-first resolution would report the tag "became a branch" and + // block updates forever. + await writePluginFixture(remoteDir, { version: "2.0.0" }); + const newHead = await commitAll(remoteDir, "content the branch points at"); + await git(remoteDir, "branch", "dual"); + + expect(await service.checkUpdates()).toEqual([{ name: "demo-plugin", status: "up-to-date" }]); + + // A genuinely moved tag still reports tag-moved (with the TAG's sha, not + // the same-name branch's), and the reviewed per-plugin update applies it. + await git(remoteDir, "tag", "-f", "dual", newHead); + expect(await service.checkUpdates()).toEqual([ + { name: "demo-plugin", status: "tag-moved", remoteSha: newHead }, + ]); + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(newHead); + }); + + test("duplicate registry names block mutations while views keep the first entry", async () => { + const entryFor = (url: string) => ({ + name: "demo-plugin", + scope: "global", + source: { type: "git", url, ref: "main", refType: "branch" }, + lockedSha: "a".repeat(40), + installedAt: "2026-08-01T00:00:00.000Z", + }); + // Corrupted/newer-written registry: two schema-valid entries, same name, + // different sources. Raw rewrites match by name, so a mutation would + // patch BOTH from the first entry's source — refuse instead. + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ plugins: [entryFor(remoteDir), entryFor("https://example.com/o.git")] }) + ); + + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/duplicate entries/); + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/duplicate entries/); + + await expect(service.checkUpdates()).rejects.toThrow(/duplicate entries/); + + // Views degrade gracefully: one row (first entry wins, matching find()). + const items = await service.list(); + expect(items.filter((item) => item.name === "demo-plugin")).toHaveLength(1); + }); + + test("a promotion orphaned by a crash is cleaned up on section open", async () => { + // Simulate the post-crash state of an install that died between the + // promote rename and the registry write: a promoted tree with no + // registry entry, plus the journal install wrote before renaming. + const targetPath = path.join(pluginsDir(), "demo-plugin"); + await fsPromises.mkdir(targetPath, { recursive: true }); + await fsPromises.writeFile( + path.join(targetPath, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "demo-plugin", version: "1" }) + ); + const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); + await fsPromises.mkdir(stagingDir(), { recursive: true }); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) + ); + + // Section open reconciles: the orphan never renders (not even as + // unmanaged), the tree is gone, and the journal is consumed. + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")).toBeUndefined(); + expect(await pathExists(targetPath)).toBe(false); + expect(await pathExists(journalPath)).toBe(false); + + // The name is fully recoverable: reinstalling succeeds (no collision). + const preview = await service.preview({ input: remoteDir }); + const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + expect(entry.name).toBe("demo-plugin"); + + // A journal WITH a registry entry means the install committed and only + // the journal deletion was lost — the tree must survive. + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) + ); + const itemsAfter = await service.list(); + expect(itemsAfter.find((item) => item.name === "demo-plugin")?.managed).toBe(true); + expect(await pathExists(targetPath)).toBe(true); + expect(await pathExists(journalPath)).toBe(false); + }); + test("checkUpdates surfaces a corrupted registry instead of a false all-clear", async () => { await fsPromises.writeFile(registryFile(), "{ not json"); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 09e466b4d2e..24d887dfed1 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1,3 +1,4 @@ +import type { Dirent } from "node:fs"; import * as fsPromises from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -85,6 +86,16 @@ const REGISTRY_FILE_NAME = "plugins.json"; /** Preview/staging clones live here — NOT under ~/.mux/plugins, which discovery scans. */ const STAGING_DIR_NAME = "plugin-staging"; +/** + * Journal file recording an install promotion that has renamed the staged + * tree into the container but not yet written its registry entry. A crash in + * that window would otherwise strand an orphaned tree that discovery lists as + * unmanaged, assertNoCollision blocks from reinstalling, and uninstall + * refuses (not managed) — recoverable only by manual deletion. + * reconcileOrphanedPromotions cleans such trees up on the next section open. + */ +const PROMOTION_JOURNAL_PREFIX = "promotion-"; + /** Staging dirs left behind by crashes are reclaimed after this age. */ const STALE_STAGING_MAX_AGE_MS = 60 * 60 * 1000; @@ -110,22 +121,116 @@ function gitEnv(): Record { return env; } -async function runGit(args: string[], opts?: { timeoutMs?: number }): Promise { - using proc = execFileAsync("git", args, { - env: gitEnv(), - timeoutMs: opts?.timeoutMs ?? CLONE_TIMEOUT_MS, - // Git spawns SSH/credential-helper children that inherit its pipes; a - // stalled helper would otherwise keep the promise pending past the - // timeout because only the direct child gets killed. - killTreeOnTermination: true, - // These remotes are untrusted: a malicious or noisy repository can emit - // unbounded progress/sideband output, and unbounded buffering would - // exhaust the main process before the timeout fires. 10 MiB is far above - // anything the plugin-sized clones/ls-remotes here legitimately produce. - maxOutputBytes: 10 * 1024 * 1024, - }); - const { stdout } = await proc.result; - return stdout; +/** + * True when the aggregate file bytes under `dir` exceed `maxBytes`. Walks + * with early exit; entries vanishing mid-walk (git renames temp files) are + * skipped. + */ +async function directorySizeExceeds(dir: string, maxBytes: number): Promise { + let bytes = 0; + const pending: string[] = [dir]; + while (pending.length > 0) { + const current = pending.pop(); + assert(current !== undefined, "directorySizeExceeds: queue underflow"); + let entries: Dirent[]; + try { + entries = await fsPromises.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + continue; + } + if (entry.isFile()) { + try { + bytes += (await fsPromises.lstat(entryPath)).size; + } catch { + continue; + } + if (bytes > maxBytes) { + return true; + } + } + } + } + return false; +} + +/** + * Run `fn` with an AbortSignal that fires when `dir` grows past `maxBytes` + * while fn is pending. Bounds git DURING clone/fetch/checkout: the post-clone + * quota can only reject a tree git already materialized, so a huge remote + * would otherwise fill the disk before that check runs. Exported for tests. + */ +export async function withDiskQuotaWatchdog( + quota: { dir: string; maxBytes: number; pollMs?: number }, + fn: (signal: AbortSignal) => Promise +): Promise { + const controller = new AbortController(); + let exceeded = false; + let checking = false; + const interval = setInterval(() => { + if (checking || exceeded) { + return; + } + checking = true; + directorySizeExceeds(quota.dir, quota.maxBytes).then( + (over) => { + checking = false; + if (over) { + exceeded = true; + controller.abort(); + } + }, + () => { + checking = false; + } + ); + }, quota.pollMs ?? 500); + try { + return await fn(controller.signal); + } catch (error) { + if (exceeded) { + throw new Error( + `The repository is too large to install as a plugin (exceeded ${Math.floor(quota.maxBytes / (1024 * 1024))} MiB during clone).` + ); + } + throw error; + } finally { + clearInterval(interval); + } +} + +async function runGit( + args: string[], + opts?: { timeoutMs?: number; diskQuota?: { dir: string; maxBytes: number; pollMs?: number } } +): Promise { + const run = async (signal?: AbortSignal): Promise => { + using proc = execFileAsync("git", args, { + env: gitEnv(), + timeoutMs: opts?.timeoutMs ?? CLONE_TIMEOUT_MS, + // Git spawns SSH/credential-helper children that inherit its pipes; a + // stalled helper would otherwise keep the promise pending past the + // timeout because only the direct child gets killed. + killTreeOnTermination: true, + // These remotes are untrusted: a malicious or noisy repository can emit + // unbounded progress/sideband output, and unbounded buffering would + // exhaust the main process before the timeout fires. 10 MiB is far above + // anything the plugin-sized clones/ls-remotes here legitimately produce. + maxOutputBytes: 10 * 1024 * 1024, + ...(signal !== undefined ? { signal } : {}), + }); + const { stdout } = await proc.result; + return stdout; + }; + if (opts?.diskQuota !== undefined) { + const diskQuota = opts.diskQuota; + return withDiskQuotaWatchdog(diskQuota, (signal) => run(signal)); + } + return run(); } /** Max simultaneous `git ls-remote` processes during an update check. */ @@ -202,6 +307,12 @@ export class AgentPluginInstallService { private readonly registryFile: string; /** Serializes mutations (install/update/uninstall) so directory swaps and registry writes cannot interleave. */ private mutationQueue: Promise = Promise.resolve(); + /** + * Staging paths owned by in-process operations. Stale reclamation must + * never reap these: a just-renamed trash dir inherits the tree's OLD + * mtime, so age alone can misclassify an active rollback copy as stale. + */ + private readonly activeStagingPaths = new Set(); constructor( private readonly config: Config, @@ -325,18 +436,37 @@ export class AgentPluginInstallService { * 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[] { + private parseRegistryEntries( + rawEntries: unknown[], + mode: "lenient" | "strict" = "lenient" + ): AgentPluginInstallEntry[] { const entries: AgentPluginInstallEntry[] = []; + const seenNames = new Set(); for (const rawEntry of rawEntries) { const parsed = AgentPluginInstallEntrySchema.safeParse(rawEntry); - if (parsed.success) { - entries.push(parsed.data); - } else { + if (!parsed.success) { log.debug("Skipping unrecognized managed plugin registry entry (preserved on disk)", { entry: rawEntry, error: parsed.error.message, }); + continue; + } + // Duplicate names are corrupt identity: entry names map 1:1 to + // container directories and instance IDs, and raw rewrites match by + // name — a mutation would patch EVERY duplicate from the first entry's + // source, silently rewriting the others. Mutations (strict) refuse; + // views (lenient) keep the first (matching find()-based lookups). + if (seenNames.has(parsed.data.name)) { + if (mode === "strict") { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) contains duplicate entries for '${parsed.data.name}'. Repair the file, then retry.` + ); + } + log.warn("Ignoring duplicate managed plugin registry entry", { name: parsed.data.name }); + continue; } + seenNames.add(parsed.data.name); + entries.push(parsed.data); } return entries; } @@ -349,7 +479,7 @@ export class AgentPluginInstallService { */ private async readRegistry(mode: "lenient" | "strict"): Promise { const { rawEntries } = await this.readRegistryDocument(mode); - const entries = this.parseRegistryEntries(rawEntries); + const entries = this.parseRegistryEntries(rawEntries, mode); if (mode === "strict" && entries.length !== rawEntries.length) { throw new Error( `The plugin registry (${shortenHome(this.registryFile)}) contains ${rawEntries.length - entries.length} entr${rawEntries.length - entries.length === 1 ? "y" : "ies"} this version cannot read (written by a newer version of Mux, or corrupted).` @@ -429,7 +559,27 @@ export class AgentPluginInstallService { private async createStagingDir(): Promise { await fsPromises.mkdir(this.stagingRoot, { recursive: true }); await this.purgeStaleStaging(); - return fsPromises.mkdtemp(path.join(this.stagingRoot, "stage-")); + const dir = await fsPromises.mkdtemp(path.join(this.stagingRoot, "stage-")); + this.activeStagingPaths.add(dir); + return dir; + } + + /** + * Rename `sourcePath` into the staging root under in-process ownership so + * stale reclamation cannot reap it mid-operation. Trash names embed a + * Date.now() stamp because the rename preserves the tree's OLD mtime — an + * installed tree older than the stale threshold would otherwise be reaped + * as "stale" the moment it lands in staging, deleting an active rollback + * copy out from under uninstall/update. + */ + private async renameIntoStaging(sourcePath: string, trashDir: string): Promise { + this.activeStagingPaths.add(trashDir); + try { + await fsPromises.rename(sourcePath, trashDir); + } catch (error) { + this.activeStagingPaths.delete(trashDir); + throw error; + } } /** Best-effort reclaim of staging dirs orphaned by crashes. */ @@ -438,9 +588,20 @@ export class AgentPluginInstallService { const now = Date.now(); for (const entry of await fsPromises.readdir(this.stagingRoot)) { const entryPath = path.join(this.stagingRoot, entry); + // Never touch paths an in-process operation still owns, or promotion + // journals (their lifecycle belongs to reconcileOrphanedPromotions). + if (this.activeStagingPaths.has(entryPath) || entry.startsWith(PROMOTION_JOURNAL_PREFIX)) { + continue; + } try { - const stat = await fsPromises.stat(entryPath); - if (now - stat.mtimeMs > STALE_STAGING_MAX_AGE_MS) { + // Trash names embed their staging time; renames preserve the + // tree's old mtime, which says nothing about staging age. + const stampMatch = /^trash(?:-data)?-(\d+)-/.exec(entry); + const stagedAt = + stampMatch !== null + ? Number(stampMatch[1]) + : (await fsPromises.stat(entryPath)).mtimeMs; + if (now - stagedAt > STALE_STAGING_MAX_AGE_MS) { await fsPromises.rm(entryPath, { recursive: true, force: true }); } } catch { @@ -454,14 +615,26 @@ export class AgentPluginInstallService { private async removeDir(dirPath: string): Promise { await fsPromises.rm(dirPath, { recursive: true, force: true }); + this.activeStagingPaths.delete(dirPath); } // --------------------------------------------------------------------- // 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 { + /** + * Resolve what a preview/install/update should check out, via `git + * ls-remote` (no fetch). When a branch and a tag share the ref name, + * `preferredRefType` (a tracked entry's stored kind) wins — a remote + * ADDING a same-name branch must not make a still-valid tracked tag look + * like it changed kind. New previews without a stored kind stay + * branch-first. + */ + private async resolveRemoteRef( + url: string, + ref: string | undefined, + preferredRefType?: "branch" | "tag" + ): Promise { if (ref !== undefined && isFullCommitSha(ref)) { return { ref: ref.toLowerCase(), refType: "commit", sha: ref.toLowerCase() }; } @@ -506,12 +679,15 @@ export class AgentPluginInstallService { 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 (preferredRefType === "tag" && resolvedTagSha !== undefined) { + return { ref, refType: "tag", sha: resolvedTagSha }; + } + if (branchSha !== undefined) { + return { ref, refType: "branch", sha: branchSha }; + } if (resolvedTagSha !== undefined) { return { ref, refType: "tag", sha: resolvedTagSha }; } @@ -526,6 +702,22 @@ export class AgentPluginInstallService { } } + private stagingQuota(): { maxBytes: number; maxFiles: number } { + return ( + this.deps.stagingQuota ?? { maxBytes: STAGED_TREE_MAX_BYTES, maxFiles: STAGED_TREE_MAX_FILES } + ); + } + + /** + * During-clone disk bound for a staging dir: checkout + pack live in it, so + * allow twice the checkout quota. The watchdog aborts git mid-transfer — + * the post-clone assertStagedTreeWithinQuota can only reject a tree git + * already fully materialized on disk. + */ + private cloneDiskQuota(dir: string): { dir: string; maxBytes: number } { + return { dir, maxBytes: this.stagingQuota().maxBytes * 2 }; + } + /** * Enforce the staged-checkout quota (bytes + file count, .git excluded, * symlinks not followed). Runs immediately after every staged clone so an @@ -533,10 +725,7 @@ export class AgentPluginInstallService { * validation reads it. */ private async assertStagedTreeWithinQuota(dir: string): Promise { - const quota = this.deps.stagingQuota ?? { - maxBytes: STAGED_TREE_MAX_BYTES, - maxFiles: STAGED_TREE_MAX_FILES, - }; + const quota = this.stagingQuota(); let bytes = 0; let files = 0; const pending: string[] = [dir]; @@ -576,18 +765,21 @@ export class AgentPluginInstallService { 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, - ]); + await runGit( + [ + "clone", + "--depth", + "1", + "--single-branch", + "--branch", + resolved.ref, + "-c", + "advice.detachedHead=false", + url, + dir, + ], + { diskQuota: this.cloneDiskQuota(dir) } + ); } const sha = (await runGit(["-C", dir, "rev-parse", "HEAD"])).trim(); assert(isFullCommitSha(sha), "cloneResolved: rev-parse HEAD must be a full SHA"); @@ -618,18 +810,22 @@ export class AgentPluginInstallService { // 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, - ]); + this.activeStagingPaths.add(dir); + await runGit( + [ + "clone", + "--depth", + "1", + "--single-branch", + "--branch", + source.ref, + "-c", + "advice.detachedHead=false", + source.url, + dir, + ], + { diskQuota: this.cloneDiskQuota(dir) } + ); } const head = (await runGit(["-C", dir, "rev-parse", "HEAD"])).trim(); if (head !== sha) { @@ -646,18 +842,14 @@ export class AgentPluginInstallService { } private async fetchExactSha(url: string, sha: string, dir: string): Promise { + const diskQuota = this.cloneDiskQuota(dir); 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", - ]); + await runGit(["-C", dir, "fetch", "--depth", "1", "origin", sha], { diskQuota }); + await runGit( + ["-C", dir, "-c", "advice.detachedHead=false", "checkout", "--quiet", "FETCH_HEAD"], + { diskQuota } + ); } // --------------------------------------------------------------------- @@ -1110,6 +1302,14 @@ export class AgentPluginInstallService { // .git dir would only invite in-place edits that updates discard. await this.removeDir(path.join(stagedDir, ".git")); + // Journal the promotion BEFORE the rename: a process crash between + // the rename and the registry write would otherwise strand a tree + // that discovery lists as unmanaged, assertNoCollision blocks, and + // uninstall refuses — reconcileOrphanedPromotions uses this record + // to clean it up on the next section open. + const journalPath = this.promotionJournalPath(name); + await fsPromises.writeFile(journalPath, JSON.stringify({ name, stagedAt: Date.now() })); + await fsPromises.mkdir(this.containerDir, { recursive: true }); await fsPromises.rename(stagedDir, targetPath); @@ -1171,7 +1371,7 @@ export class AgentPluginInstallService { } catch { const quarantineDir = path.join(this.stagingRoot, `trash-${Date.now()}-${name}`); try { - await fsPromises.rename(targetPath, quarantineDir); + await this.renameIntoStaging(targetPath, quarantineDir); await this.removeDir(quarantineDir).catch(() => undefined); } catch (cleanupError) { cleanupNotes.push( @@ -1184,6 +1384,10 @@ export class AgentPluginInstallService { throw new Error( `Failed to persist the plugin registry: ${getErrorMessage(error)}${notes}` ); + } finally { + // Registry write settled (entry recorded, or the rollback above + // handled the tree): the journal's crash-recovery job is done. + await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); } log.info(`Installed agent plugin '${name}' at ${args.expectedSha.slice(0, 12)}`); return entry; @@ -1193,10 +1397,84 @@ export class AgentPluginInstallService { }); } + private promotionJournalPath(name: string): string { + // Names are grammar-validated (no separators/traversal), so this join is safe. + return path.join(this.stagingRoot, `${PROMOTION_JOURNAL_PREFIX}${name}.json`); + } + + /** + * Crash recovery for installs that died between the promote rename and the + * registry write: the journal proves WE created the container tree from a + * staged clone (it is not user-authored work), so it is safe to clear. + * Without this, the orphan is listed as unmanaged, blocks reinstalling the + * same name, and cannot be uninstalled (not managed). Runs on section open + * under the mutation queue so it cannot interleave with a live install. + */ + private async reconcileOrphanedPromotions(): Promise { + let journalNames: string[]; + try { + journalNames = (await fsPromises.readdir(this.stagingRoot)).filter( + (entry) => entry.startsWith(PROMOTION_JOURNAL_PREFIX) && entry.endsWith(".json") + ); + } catch { + return; // No staging root: nothing was ever promoted. + } + if (journalNames.length === 0) { + return; + } + await this.runExclusive(async () => { + const registryNames = new Set( + (await this.readRegistryDocument("lenient")).rawEntries + .map((rawEntry) => this.rawEntryName(rawEntry)) + .filter((name): name is string => name !== undefined) + ); + for (const journalName of journalNames) { + const journalPath = path.join(this.stagingRoot, journalName); + const name = journalName.slice(PROMOTION_JOURNAL_PREFIX.length, -".json".length); + if (!isValidAgentPluginName(name)) { + await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + continue; + } + const targetPath = this.targetPathFor(name); + // Only an ORPHAN (tree without registry entry) needs cleanup; a + // registry entry means the install committed and only the journal + // deletion was lost. + if (!registryNames.has(name) && (await pathExists(targetPath))) { + log.warn("Cleaning up plugin promotion orphaned by a crash", { name }); + await this.deps.mcpServerManager?.stopServersWithKeyPrefix( + buildPluginServerKey(this.instanceIdFor(name), "") + ); + const quarantineDir = path.join(this.stagingRoot, `trash-${Date.now()}-${name}`); + try { + await this.renameIntoStaging(targetPath, quarantineDir); + await this.removeDir(quarantineDir).catch(() => undefined); + } catch (error) { + // Keep the journal so the next section open retries. + log.warn("Failed to clean up orphaned plugin promotion", { + name, + error: getErrorMessage(error), + }); + continue; + } + } + await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + } + }); + } + /** Managed registry entries merged with unmanaged plugins found by global discovery. */ async list(): Promise { this.assertEnabled(); + // Section open is the natural recovery moment: clear promotions orphaned + // by a crash between promote and registry write BEFORE discovery scans + // the container, so the orphan never renders as an unmanaged row. + await this.reconcileOrphanedPromotions().catch((error: unknown) => { + log.warn("Failed to reconcile orphaned plugin promotions", { + error: getErrorMessage(error), + }); + }); + // 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) => { @@ -1299,7 +1577,7 @@ export class AgentPluginInstallService { return this.runExclusive(async () => { const { envelope, rawEntries: rawRegistry } = await this.readRegistryDocument("strict"); - const registry = this.parseRegistryEntries(rawRegistry); + const registry = this.parseRegistryEntries(rawRegistry, "strict"); const entry = registry.find((e) => e.name === args.name); if (!entry) { throw new Error(`'${args.name}' is not a managed plugin install.`); @@ -1339,7 +1617,7 @@ export class AgentPluginInstallService { const trashDir = path.join(this.stagingRoot, `trash-${Date.now()}-${entry.name}`); let stagedTree = false; try { - await fsPromises.rename(targetPath, trashDir); + await this.renameIntoStaging(targetPath, trashDir); stagedTree = true; } catch (error) { if (!hasErrorCode(error, "ENOENT")) { @@ -1352,12 +1630,15 @@ export class AgentPluginInstallService { if (!stagedTree) { return; } - await fsPromises.rename(trashDir, targetPath).catch((rollbackError: unknown) => { - log.error(`Failed to restore plugin dir after ${context}`, { - targetPath, - rollbackError, - }); - }); + await fsPromises.rename(trashDir, targetPath).then( + () => this.activeStagingPaths.delete(trashDir), + (rollbackError: unknown) => { + log.error(`Failed to restore plugin dir after ${context}`, { + targetPath, + rollbackError, + }); + } + ); }; const dataPath = getPluginDataPath(this.config.rootDir, instanceId); @@ -1365,7 +1646,7 @@ export class AgentPluginInstallService { let stagedData = false; if (args.deletePluginData) { try { - await fsPromises.rename(dataPath, dataTrashDir); + await this.renameIntoStaging(dataPath, dataTrashDir); stagedData = true; } catch (error) { if (!hasErrorCode(error, "ENOENT")) { @@ -1424,12 +1705,15 @@ export class AgentPluginInstallService { if (await pathExists(dataPath)) { await this.removeDir(dataPath).catch(() => undefined); } - await fsPromises.rename(dataTrashDir, dataPath).catch((rollbackError: unknown) => { - log.error("Failed to restore plugin data after failed registry write", { - dataPath, - rollbackError, - }); - }); + await fsPromises.rename(dataTrashDir, dataPath).then( + () => this.activeStagingPaths.delete(dataTrashDir), + (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)}`); } @@ -1835,7 +2119,13 @@ export class AgentPluginInstallService { return { name: entry.name, status: "pinned" }; } try { - const resolved = await this.resolveRemoteRef(entry.source.url, entry.source.ref); + // Pass the stored kind: a remote ADDING a same-name branch must + // not make a still-tracked tag read as "now a branch". + const resolved = await this.resolveRemoteRef( + entry.source.url, + entry.source.ref, + entry.source.refType + ); if (resolved.refType !== entry.source.refType) { // e.g. a tracked branch was deleted and a tag with the same name exists now. return { @@ -1871,7 +2161,7 @@ export class AgentPluginInstallService { return this.runExclusive(async () => { const { envelope, rawEntries: rawRegistry } = await this.readRegistryDocument("strict"); - const registry = this.parseRegistryEntries(rawRegistry); + const registry = this.parseRegistryEntries(rawRegistry, "strict"); const entry = registry.find((e) => e.name === args.name); if (!entry) { throw new Error(`'${args.name}' is not a managed plugin install.`); @@ -1882,7 +2172,11 @@ export class AgentPluginInstallService { ); } - const resolved = await this.resolveRemoteRef(entry.source.url, entry.source.ref); + const resolved = await this.resolveRemoteRef( + entry.source.url, + entry.source.ref, + entry.source.refType + ); 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 @@ -1930,7 +2224,7 @@ export class AgentPluginInstallService { await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); if (hadOldTree) { - await fsPromises.rename(targetPath, trashDir); + await this.renameIntoStaging(targetPath, trashDir); } try { await fsPromises.mkdir(this.containerDir, { recursive: true }); @@ -1938,12 +2232,15 @@ export class AgentPluginInstallService { } 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, - }); - }); + await fsPromises.rename(trashDir, targetPath).then( + () => this.activeStagingPaths.delete(trashDir), + (rollbackError: unknown) => { + log.error("Failed to roll back plugin dir after failed update swap", { + targetPath, + rollbackError, + }); + } + ); } throw error; } diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 27dfab54368..2b0f1dade67 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -307,6 +307,57 @@ describe("MCPServerManager", () => { expect(access.workspaceServers.has(workspaceId)).toBe(false); }); + test("workspace removal landing during a timed-out retry never merges into the detached entry", async () => { + const workspaceId = "ws-retry-removal-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + + // First call: the server times out, so the cached entry carries a retry + // marker and no live instance. + access.startServers = () => + Promise.resolve({ + instances: new Map(), + failedServerNames: [], + timedOutServerNames: [pluginKey], + }); + await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(access.workspaceServers.has(workspaceId)).toBe(true); + + // Second call retries the timed-out server. The one-shot iterator hook + // queues a removal-style stopServers(workspaceId) during the retry's + // invalidation scan: it deletes the cache entry, so the merge callback + // must NOT attach these clients to the detached entry (they would have + // no owner to ever clean them up). + const close = mock(() => Promise.resolve(undefined)); + let stopPromise: Promise | undefined; + const retried = new Map([[pluginKey, testInstance(pluginKey, { close })]]); + let armed = true; + const originalIterator = retried[Symbol.iterator].bind(retried); + retried[Symbol.iterator] = () => { + if (armed) { + armed = false; + queueMicrotask(() => { + stopPromise = manager.stopServers(workspaceId); + }); + } + return originalIterator(); + }; + access.startServers = () => + Promise.resolve({ instances: retried, failedServerNames: [], timedOutServerNames: [] }); + + const result = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(stopPromise).toBeDefined(); + await stopPromise; + + // The retried client was closed, nothing was merged into the detached + // entry, and the removed workspace stays uncached. + expect(close).toHaveBeenCalledTimes(1); + expect(Object.keys(result.tools)).toEqual([]); + expect(access.workspaceServers.has(workspaceId)).toBe(false); + }); + test("stopServersWithKeyPrefix closes only matching instances and retries them on next use", async () => { const workspaceId = "ws-selective-stop"; const pluginKey = "plugin:abc123:echo"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 2e24d625056..538cc948f6a 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1661,11 +1661,22 @@ export class MCPServerManager { // 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. + let retryOwnershipLost = false; await this.closeInvalidatedInstancesThenPublish( retriedInstances, startupEpoch, workspaceId, (invalidatedRetryKeys) => { + // Recheck ownership INSIDE the synchronous callback: a + // removal-style stopServers (or config-change replacement) + // landing while the awaited invalidation scan yielded has + // deleted/replaced the cache entry and closed its instances — + // merging into the detached `existing` would leave these + // clients with no cache owner to ever clean them up. + if (this.workspaceServers.get(workspaceId) !== existing) { + retryOwnershipLost = true; + return; + } for (const [serverName, instance] of retriedInstances) { existing.instances.set(serverName, instance); } @@ -1682,6 +1693,30 @@ export class MCPServerManager { ]; } ); + if (retryOwnershipLost) { + for (const instance of retriedInstances.values()) { + try { + await instance.close(); + } catch (error) { + log.warn("Failed to stop orphaned retried MCP server", { + error, + name: instance.name, + }); + } + } + // Removed workspace: return empty instead of recursing, which + // would resurrect servers the removal just stopped. A replaced + // entry (config change) recomputes against the new entry. + if (this.workspaceServers.get(workspaceId) === undefined) { + return { + tools: {}, + toolServerNames: {}, + stats: this.createWorkspaceStats(enabledEntries.length, new Map(), []), + promptDescriptors: [], + }; + } + return this.getToolsForWorkspace(options); + } const failedServerNames = [ ...existing.stats.failedServerNames.filter( @@ -1804,11 +1839,19 @@ export class MCPServerManager { // 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. + let restartOwnershipLost = false; await this.closeInvalidatedInstancesThenPublish( restartedInstances, startupEpoch, workspaceId, (invalidatedRestartKeys) => { + // Same ownership recheck as the timed-out retry path: a removal + // or replacement landing during the awaited scan must not let + // this merge revive clients on a detached entry. + if (this.workspaceServers.get(workspaceId) !== existing) { + restartOwnershipLost = true; + return; + } restartTimedOutNames = [...restartTimedOutNames, ...invalidatedRestartKeys]; for (const [serverName, instance] of restartedInstances) { @@ -1816,6 +1859,30 @@ export class MCPServerManager { } } ); + if (restartOwnershipLost) { + for (const instance of restartedInstances.values()) { + try { + await instance.close(); + } catch (error) { + log.warn("Failed to stop orphaned restarted MCP server", { + error, + name: instance.name, + }); + } + } + // Removed workspace: return empty instead of recursing, which would + // resurrect servers the removal just stopped. A replaced entry + // (config change) recomputes against the new entry. + if (this.workspaceServers.get(workspaceId) === undefined) { + return { + tools: {}, + toolServerNames: {}, + stats: this.createWorkspaceStats(enabledEntries.length, new Map(), []), + promptDescriptors: [], + }; + } + return this.getToolsForWorkspace(options); + } } log.info("[MCP] Deferring MCP server restart while stream is active", { From ea63dca2ad4857bb44209a1a45258b76a07fc385 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 14:14:06 +0000 Subject: [PATCH 20/63] fix: address Codex review round 38 - Generalize promotion journals into a three-kind journal system (promotion/update/uninstall) with reconcileJournals crash recovery running at service startup (before any global discovery consumer) and on section open - reconcileJournals uses a STRICT registry read: an unreadable registry retains every journal instead of treating committed installs as orphans - update() journals the swap before moving the live tree; recovery restores the old tree so assertNoCapabilityIncrease can't self-reject - uninstall() journals the staging transaction; recovery restores staged tree/data while the registry entry exists and finishes trash cleanup once the commit landed - purgeStaleStaging never reclaims journal-referenced trash dirs - install rollback re-invalidates the plugin's MCP prefix after the retry/quarantine removes the tree - Disk quota watchdog enforces maxFiles during clone/checkout, not just bytes --- .../agentPlugins/installService.test.ts | 206 ++++++- .../services/agentPlugins/installService.ts | 513 +++++++++++++++--- .../builtInSkillContent.generated.ts | 2 +- 3 files changed, 628 insertions(+), 93 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index dc8b62529fc..584aafbfd62 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -505,12 +505,39 @@ describe("AgentPluginInstallService", () => { const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "quota-watchdog-")); try { await expect( - withDiskQuotaWatchdog({ dir, maxBytes: 1024, pollMs: 10 }, async (signal) => { - await fsPromises.writeFile(path.join(dir, "pack"), "x".repeat(8192)); - await new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(new Error("killed")), { once: true }); - }); - }) + withDiskQuotaWatchdog( + { dir, maxBytes: 1024, maxFiles: 10_000, pollMs: 10 }, + async (signal) => { + await fsPromises.writeFile(path.join(dir, "pack"), "x".repeat(8192)); + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("killed")), { once: true }); + }); + } + ) + ).rejects.toThrow(/too large to install/); + } finally { + await fsPromises.rm(dir, { recursive: true, force: true }); + } + }); + + test("withDiskQuotaWatchdog aborts on file count independently of bytes", async () => { + // Many empty files consume inodes and allocation metadata without moving + // the byte total, so the in-flight watchdog must enforce maxFiles DURING + // checkout too — the post-clone count only runs after git returns. + const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "quota-watchdog-files-")); + try { + await expect( + withDiskQuotaWatchdog( + { dir, maxBytes: 1024 * 1024, maxFiles: 8, pollMs: 10 }, + async (signal) => { + for (let i = 0; i < 20; i += 1) { + await fsPromises.writeFile(path.join(dir, `empty-${i}`), ""); + } + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("killed")), { once: true }); + }); + } + ) ).rejects.toThrow(/too large to install/); } finally { await fsPromises.rm(dir, { recursive: true, force: true }); @@ -532,6 +559,17 @@ describe("AgentPluginInstallService", () => { const oldStamped = path.join(staging, `trash-${twoHoursAgo.getTime()}-old`); await fsPromises.mkdir(oldStamped); + // An old-stamped trash dir still referenced by an uninstall journal is a + // pending rollback copy, not garbage — reclaiming it before + // reconcileJournals runs would turn a restorable interrupted uninstall + // into data loss. + const journaled = path.join(staging, `trash-${twoHoursAgo.getTime()}-journaled`); + await fsPromises.mkdir(journaled); + await fsPromises.writeFile( + path.join(staging, "uninstall-journaled-plugin.json"), + JSON.stringify({ name: "journaled-plugin", trashDir: journaled, stagedAt: Date.now() }) + ); + const internals = service as unknown as { createStagingDir: () => Promise; purgeStaleStaging: () => Promise; @@ -545,6 +583,7 @@ describe("AgentPluginInstallService", () => { expect(await pathExists(freshStamped)).toBe(true); expect(await pathExists(oldStamped)).toBe(false); + expect(await pathExists(journaled)).toBe(true); expect(await pathExists(active)).toBe(true); }); @@ -644,6 +683,155 @@ describe("AgentPluginInstallService", () => { expect(await pathExists(journalPath)).toBe(false); }); + test("crash recovery runs at service startup, before any section open", async () => { + // An orphaned promotion must not wait for list(): a session can serve + // agent requests — whose global plugin discovery loads the container's + // hooks and MCP servers — without ever opening Settings → Plugins. + const targetPath = path.join(pluginsDir(), "demo-plugin"); + await fsPromises.mkdir(targetPath, { recursive: true }); + await fsPromises.writeFile( + path.join(targetPath, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "demo-plugin", version: "1" }) + ); + await fsPromises.mkdir(stagingDir(), { recursive: true }); + const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) + ); + + const freshService = new AgentPluginInstallService(config, { isEnabled: () => true }); + await (freshService as unknown as { startupReconciliation: Promise }) + .startupReconciliation; + + expect(await pathExists(targetPath)).toBe(false); + expect(await pathExists(journalPath)).toBe(false); + }); + + test("an update swap interrupted between rename and promote is restored", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // Simulate the post-crash state: the old live tree renamed into staging, + // the staged replacement never promoted, the registry still recording the + // install. Without recovery, retrying Update self-rejects — the missing + // tree reads as an empty capability surface, so even the UNCHANGED MCP + // server in the new tree looks like a consent-relevant addition. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.rename(targetPath, trashDir); + const journalPath = path.join(stagingDir(), "update-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", trashDir, stagedAt: Date.now() }) + ); + + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")?.managed).toBe(true); + expect(await pathExists(targetPath)).toBe(true); + expect(await pathExists(journalPath)).toBe(false); + + // The restored tree makes the retried update succeed. + await writePluginFixture(remoteDir, { version: "2.0.0" }); + const newHead = await commitAll(remoteDir, "v2 after interrupted swap"); + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(newHead); + }); + + test("an uninstall interrupted before the registry commit restores tree and data", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + const instanceId = computePluginInstanceId(targetPath); + const dataPath = getPluginDataPath(muxRoot, instanceId); + await fsPromises.mkdir(dataPath, { recursive: true }); + await fsPromises.writeFile(path.join(dataPath, "state.txt"), "original"); + + // Post-crash state: both assets staged into trash, journal present, the + // registry still owning the plugin — and a server launch since restart + // recreated a fresh dataPath (prepareStdioLaunch mkdirs it), which must + // not block restoring the ORIGINAL data. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + const dataTrashDir = path.join(stagingDir(), `trash-data-${Date.now()}-demo-plugin`); + await fsPromises.rename(targetPath, trashDir); + await fsPromises.rename(dataPath, dataTrashDir); + await fsPromises.mkdir(dataPath, { recursive: true }); + const journalPath = path.join(stagingDir(), "uninstall-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", trashDir, dataTrashDir, stagedAt: Date.now() }) + ); + + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")?.managed).toBe(true); + expect(await pathExists(targetPath)).toBe(true); + expect(await fsPromises.readFile(path.join(dataPath, "state.txt"), "utf8")).toBe("original"); + expect(await pathExists(journalPath)).toBe(false); + + // A retried uninstall then completes cleanly. + await service.uninstall({ name: "demo-plugin", deletePluginData: true }); + expect(await registry()).toEqual([]); + expect(await pathExists(dataPath)).toBe(false); + }); + + test("an uninstall interrupted after the registry commit finishes deleting the trash", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // Post-crash state: the commit landed (entry gone) but the staged assets + // and the journal survived. The user may have requested the data + // deletion, so recovery must finish it — stale-staging reclamation only + // runs during a later staging operation, which may never happen. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.rename(targetPath, trashDir); + const seeded = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as Record< + string, + unknown + >; + await fsPromises.writeFile(registryFile(), JSON.stringify({ ...seeded, plugins: [] })); + const journalPath = path.join(stagingDir(), "uninstall-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", trashDir, stagedAt: Date.now() }) + ); + + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")).toBeUndefined(); + expect(await pathExists(trashDir)).toBe(false); + expect(await pathExists(journalPath)).toBe(false); + }); + + test("journal recovery refuses to treat an unreadable registry as empty", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // A leftover promotion journal plus a temporarily corrupted registry: a + // lenient read would degrade to an empty entry list and reconciliation + // would delete the COMMITTED install's tree while its entry survives on + // disk — a recoverable read problem turned into data loss. + const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) + ); + const goodRegistry = await fsPromises.readFile(registryFile(), "utf8"); + await fsPromises.writeFile(registryFile(), "{ not json"); + + await service.list(); // Reconciliation failure is logged; list degrades gracefully. + expect(await pathExists(targetPath)).toBe(true); + expect(await pathExists(journalPath)).toBe(true); + + // Once the registry reads again, the journal resolves: the entry exists, + // so the install committed and the tree survives. + await fsPromises.writeFile(registryFile(), goodRegistry); + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")?.managed).toBe(true); + expect(await pathExists(targetPath)).toBe(true); + expect(await pathExists(journalPath)).toBe(false); + }); + test("checkUpdates surfaces a corrupted registry instead of a false all-clear", async () => { await fsPromises.writeFile(registryFile(), "{ not json"); @@ -1908,7 +2096,11 @@ describe("AgentPluginInstallService", () => { removeSpy.mockRestore(); } const instanceId = computePluginInstanceId(targetPath); - expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`]); + // Two stops: one so the retry can delete what a running server locked, + // and one AFTER the retry/quarantine — a startup that began after the + // first stop can have discovered the still-visible tree and would + // otherwise publish after it disappears. + expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`, `plugin:${instanceId}:`]); expect(await registry()).toEqual([]); // The tree left the discovery container via the quarantine rename (the // staged-dir mock only rejects the container path), so no unmanaged diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 24d887dfed1..7c89652f28a 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -92,10 +92,40 @@ const STAGING_DIR_NAME = "plugin-staging"; * that window would otherwise strand an orphaned tree that discovery lists as * unmanaged, assertNoCollision blocks from reinstalling, and uninstall * refuses (not managed) — recoverable only by manual deletion. - * reconcileOrphanedPromotions cleans such trees up on the next section open. + * reconcileJournals cleans such trees up on startup and on section open. */ const PROMOTION_JOURNAL_PREFIX = "promotion-"; +/** + * Journal recording an update swap that has renamed the OLD live tree into + * staging but not yet promoted the staged replacement. A crash in that window + * leaves the registry recording an install whose path is missing — and + * retrying Update cannot self-heal because assertNoCapabilityIncrease treats + * the missing tree as an empty surface and rejects the staged capabilities as + * additions. reconcileJournals restores the old tree from staging. + */ +const UPDATE_JOURNAL_PREFIX = "update-"; + +/** + * Journal recording an uninstall that has staged the plugin tree (and + * optionally its data dir) into staging but not yet committed the registry + * write. A crash in that window hides the assets under plugin-staging while + * the registry still owns the plugin. reconcileJournals restores the staged + * assets when the registry entry still exists, and finishes the trash cleanup + * when the commit landed. + */ +const UNINSTALL_JOURNAL_PREFIX = "uninstall-"; + +const JOURNAL_PREFIXES = [ + PROMOTION_JOURNAL_PREFIX, + UPDATE_JOURNAL_PREFIX, + UNINSTALL_JOURNAL_PREFIX, +] as const; + +function isJournalName(entry: string): boolean { + return JOURNAL_PREFIXES.some((prefix) => entry.startsWith(prefix)); +} + /** Staging dirs left behind by crashes are reclaimed after this age. */ const STALE_STAGING_MAX_AGE_MS = 60 * 60 * 1000; @@ -122,16 +152,23 @@ function gitEnv(): Record { } /** - * True when the aggregate file bytes under `dir` exceed `maxBytes`. Walks - * with early exit; entries vanishing mid-walk (git renames temp files) are - * skipped. + * True when the aggregate file bytes OR the non-directory entry count under + * `dir` exceed the quota. Counts every non-directory entry (empty files, + * symlinks) like assertStagedTreeWithinQuota: a repo of tens of thousands of + * empty files consumes inodes and allocation metadata without moving the byte + * total. Walks with early exit; entries vanishing mid-walk (git renames temp + * files) are skipped. */ -async function directorySizeExceeds(dir: string, maxBytes: number): Promise { +async function directoryQuotaExceeded( + dir: string, + quota: { maxBytes: number; maxFiles: number } +): Promise { let bytes = 0; + let files = 0; const pending: string[] = [dir]; while (pending.length > 0) { const current = pending.pop(); - assert(current !== undefined, "directorySizeExceeds: queue underflow"); + assert(current !== undefined, "directoryQuotaExceeded: queue underflow"); let entries: Dirent[]; try { entries = await fsPromises.readdir(current, { withFileTypes: true }); @@ -144,15 +181,16 @@ async function directorySizeExceeds(dir: string, maxBytes: number): Promise maxBytes) { - return true; - } + } + if (bytes > quota.maxBytes || files > quota.maxFiles) { + return true; } } } @@ -161,12 +199,13 @@ async function directorySizeExceeds(dir: string, maxBytes: number): Promise( - quota: { dir: string; maxBytes: number; pollMs?: number }, + quota: { dir: string; maxBytes: number; maxFiles: number; pollMs?: number }, fn: (signal: AbortSignal) => Promise ): Promise { const controller = new AbortController(); @@ -177,7 +216,7 @@ export async function withDiskQuotaWatchdog( return; } checking = true; - directorySizeExceeds(quota.dir, quota.maxBytes).then( + directoryQuotaExceeded(quota.dir, quota).then( (over) => { checking = false; if (over) { @@ -195,7 +234,7 @@ export async function withDiskQuotaWatchdog( } catch (error) { if (exceeded) { throw new Error( - `The repository is too large to install as a plugin (exceeded ${Math.floor(quota.maxBytes / (1024 * 1024))} MiB during clone).` + `The repository is too large to install as a plugin (exceeded ${Math.floor(quota.maxBytes / (1024 * 1024))} MiB or ${quota.maxFiles} files during clone).` ); } throw error; @@ -206,7 +245,10 @@ export async function withDiskQuotaWatchdog( async function runGit( args: string[], - opts?: { timeoutMs?: number; diskQuota?: { dir: string; maxBytes: number; pollMs?: number } } + opts?: { + timeoutMs?: number; + diskQuota?: { dir: string; maxBytes: number; maxFiles: number; pollMs?: number }; + } ): Promise { const run = async (signal?: AbortSignal): Promise => { using proc = execFileAsync("git", args, { @@ -314,6 +356,17 @@ export class AgentPluginInstallService { */ private readonly activeStagingPaths = new Set(); + /** + * Startup crash-recovery pass. Kicked off at construction because a + * session can serve agent requests (whose global plugin discovery, MCP + * config, and hook loading scan the container) without ever opening the + * Plugins section — an orphaned promotion would load as an unmanaged + * plugin, hooks included, before list()'s reconciliation ever ran. Errors + * are logged, never thrown (startup must not crash the app); list() awaits + * this so section open cannot race it. + */ + private readonly startupReconciliation: Promise; + constructor( private readonly config: Config, private readonly deps: { @@ -330,6 +383,15 @@ export class AgentPluginInstallService { 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); + // Not gated on isEnabled(): journals only exist if the feature staged + // something, and cleaning up our own crash leftovers is correct even if + // the experiment was disabled afterwards (a missing staging root makes + // this a single readdir). Failures retry on the next section open. + this.startupReconciliation = this.reconcileJournals().catch((error: unknown) => { + log.warn("Startup plugin journal reconciliation failed", { + error: getErrorMessage(error), + }); + }); } /** @@ -586,11 +648,35 @@ export class AgentPluginInstallService { private async purgeStaleStaging(): Promise { try { const now = Date.now(); - for (const entry of await fsPromises.readdir(this.stagingRoot)) { + const entries = await fsPromises.readdir(this.stagingRoot); + // Journals pin the staged trash dirs they reference: reclaiming a + // journaled rollback copy by age before reconcileJournals runs would + // turn a restorable interrupted uninstall/update into data loss. + const journalProtected = new Set(); + for (const entry of entries) { + if (!isJournalName(entry)) { + continue; + } + for (const field of ["trashDir", "dataTrashDir"]) { + const staged = await this.readJournalStagedPath( + path.join(this.stagingRoot, entry), + field + ); + if (staged !== undefined) { + journalProtected.add(staged); + } + } + } + for (const entry of entries) { const entryPath = path.join(this.stagingRoot, entry); - // Never touch paths an in-process operation still owns, or promotion - // journals (their lifecycle belongs to reconcileOrphanedPromotions). - if (this.activeStagingPaths.has(entryPath) || entry.startsWith(PROMOTION_JOURNAL_PREFIX)) { + // Never touch paths an in-process operation still owns, journals + // (their lifecycle belongs to reconcileJournals), or trash dirs a + // journal still references. + if ( + this.activeStagingPaths.has(entryPath) || + isJournalName(entry) || + journalProtected.has(entryPath) + ) { continue; } try { @@ -710,12 +796,14 @@ export class AgentPluginInstallService { /** * During-clone disk bound for a staging dir: checkout + pack live in it, so - * allow twice the checkout quota. The watchdog aborts git mid-transfer — + * allow twice the checkout quota (bytes AND file count — loose objects can + * mirror the checkout's file count). The watchdog aborts git mid-transfer — * the post-clone assertStagedTreeWithinQuota can only reject a tree git * already fully materialized on disk. */ - private cloneDiskQuota(dir: string): { dir: string; maxBytes: number } { - return { dir, maxBytes: this.stagingQuota().maxBytes * 2 }; + private cloneDiskQuota(dir: string): { dir: string; maxBytes: number; maxFiles: number } { + const quota = this.stagingQuota(); + return { dir, maxBytes: quota.maxBytes * 2, maxFiles: quota.maxFiles * 2 }; } /** @@ -1305,9 +1393,9 @@ export class AgentPluginInstallService { // Journal the promotion BEFORE the rename: a process crash between // the rename and the registry write would otherwise strand a tree // that discovery lists as unmanaged, assertNoCollision blocks, and - // uninstall refuses — reconcileOrphanedPromotions uses this record - // to clean it up on the next section open. - const journalPath = this.promotionJournalPath(name); + // uninstall refuses — reconcileJournals uses this record to clean it + // up on startup or the next section open. + const journalPath = this.journalPath(PROMOTION_JOURNAL_PREFIX, name); await fsPromises.writeFile(journalPath, JSON.stringify({ name, stagedAt: Date.now() })); await fsPromises.mkdir(this.containerDir, { recursive: true }); @@ -1379,6 +1467,20 @@ export class AgentPluginInstallService { ); } } + // Re-invalidate AFTER the retry/quarantine: a workspace startup + // that began after the stop above snapshots the newer epoch, can + // still have discovered the then-visible tree, and would publish + // after it disappears with no later invalidation covering it + // (update/uninstall do the same second post-removal stop). + try { + await this.deps.mcpServerManager?.stopServersWithKeyPrefix( + `plugin:${this.instanceIdFor(name)}:` + ); + } catch (cleanupError) { + cleanupNotes.push( + `the plugin's MCP servers could not be re-stopped after removal (${getErrorMessage(cleanupError)})` + ); + } } const notes = cleanupNotes.length > 0 ? ` Additionally, ${cleanupNotes.join("; ")}.` : ""; throw new Error( @@ -1397,80 +1499,255 @@ export class AgentPluginInstallService { }); } - private promotionJournalPath(name: string): string { + private journalPath(prefix: string, name: string): string { // Names are grammar-validated (no separators/traversal), so this join is safe. - return path.join(this.stagingRoot, `${PROMOTION_JOURNAL_PREFIX}${name}.json`); + return path.join(this.stagingRoot, `${prefix}${name}.json`); } /** - * Crash recovery for installs that died between the promote rename and the - * registry write: the journal proves WE created the container tree from a - * staged clone (it is not user-authored work), so it is safe to clear. - * Without this, the orphan is listed as unmanaged, blocks reinstalling the - * same name, and cannot be uninstalled (not managed). Runs on section open - * under the mutation queue so it cannot interleave with a live install. + * A staged path recorded in a journal, or undefined when absent/invalid. + * Defensive: recovery renames/deletes these paths, so a corrupted journal + * must never aim them anywhere but a direct trash child of the staging root. */ - private async reconcileOrphanedPromotions(): Promise { + private async readJournalStagedPath( + journalPath: string, + field: string + ): Promise { + try { + const parsed = JSON.parse(await fsPromises.readFile(journalPath, "utf-8")) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const value = (parsed as Record)[field]; + if (typeof value !== "string") { + return undefined; + } + if (path.dirname(value) !== this.stagingRoot || !path.basename(value).startsWith("trash-")) { + return undefined; + } + return value; + } catch { + return undefined; + } + } + + /** + * Crash recovery for mutations that died between their directory moves and + * the registry write. Each journal proves WE created the referenced state + * from a registry-owned tree or a staged clone (it is not user-authored + * work), so it is safe to restore or clear. Runs at service startup (a + * session can serve agent requests — global discovery, MCP config, hooks — + * without ever opening the Plugins section) and again on section open, + * under the mutation queue so it cannot interleave with a live mutation. + */ + private async reconcileJournals(): Promise { let journalNames: string[]; try { journalNames = (await fsPromises.readdir(this.stagingRoot)).filter( - (entry) => entry.startsWith(PROMOTION_JOURNAL_PREFIX) && entry.endsWith(".json") + (entry) => isJournalName(entry) && entry.endsWith(".json") ); } catch { - return; // No staging root: nothing was ever promoted. + return; // No staging root: nothing was ever staged. } if (journalNames.length === 0) { return; } await this.runExclusive(async () => { + // STRICT read: a temporarily unreadable or corrupted registry must not + // degrade to an empty entry list here — reconciliation would then treat + // committed installs as orphans and delete their trees, turning a + // recoverable read problem into data loss. Throwing retains every + // journal for a later retry; callers log and continue. const registryNames = new Set( - (await this.readRegistryDocument("lenient")).rawEntries + (await this.readRegistryDocument("strict")).rawEntries .map((rawEntry) => this.rawEntryName(rawEntry)) .filter((name): name is string => name !== undefined) ); for (const journalName of journalNames) { const journalPath = path.join(this.stagingRoot, journalName); - const name = journalName.slice(PROMOTION_JOURNAL_PREFIX.length, -".json".length); + const prefix = JOURNAL_PREFIXES.find((candidate) => journalName.startsWith(candidate)); + assert(prefix !== undefined, "reconcileJournals: filtered journal lost its prefix"); + const name = journalName.slice(prefix.length, -".json".length); if (!isValidAgentPluginName(name)) { await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); continue; } - const targetPath = this.targetPathFor(name); - // Only an ORPHAN (tree without registry entry) needs cleanup; a - // registry entry means the install committed and only the journal - // deletion was lost. - if (!registryNames.has(name) && (await pathExists(targetPath))) { - log.warn("Cleaning up plugin promotion orphaned by a crash", { name }); - await this.deps.mcpServerManager?.stopServersWithKeyPrefix( - buildPluginServerKey(this.instanceIdFor(name), "") - ); - const quarantineDir = path.join(this.stagingRoot, `trash-${Date.now()}-${name}`); - try { - await this.renameIntoStaging(targetPath, quarantineDir); - await this.removeDir(quarantineDir).catch(() => undefined); - } catch (error) { - // Keep the journal so the next section open retries. - log.warn("Failed to clean up orphaned plugin promotion", { - name, - error: getErrorMessage(error), - }); - continue; - } + const consumed = + prefix === PROMOTION_JOURNAL_PREFIX + ? await this.recoverOrphanedPromotion(name, registryNames) + : prefix === UPDATE_JOURNAL_PREFIX + ? await this.recoverInterruptedUpdateSwap(name, journalPath, registryNames) + : await this.recoverInterruptedUninstall(name, journalPath, registryNames); + if (consumed) { + await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); } - await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); } }); } + /** + * Install crashed between the promote rename and the registry write: the + * orphan would be listed as unmanaged, block reinstalling the same name, + * and refuse uninstall (not managed). Returns true when the journal's + * recovery job is done. + */ + private async recoverOrphanedPromotion( + name: string, + registryNames: Set + ): Promise { + const targetPath = this.targetPathFor(name); + // Only an ORPHAN (tree without registry entry) needs cleanup; a registry + // entry means the install committed and only the journal deletion was lost. + if (!registryNames.has(name) && (await pathExists(targetPath))) { + log.warn("Cleaning up plugin promotion orphaned by a crash", { name }); + const serverKeyPrefix = buildPluginServerKey(this.instanceIdFor(name), ""); + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + const quarantineDir = path.join(this.stagingRoot, `trash-${Date.now()}-${name}`); + try { + await this.renameIntoStaging(targetPath, quarantineDir); + await this.removeDir(quarantineDir).catch(() => undefined); + } catch (error) { + // Keep the journal so the next reconciliation retries. + log.warn("Failed to clean up orphaned plugin promotion", { + name, + error: getErrorMessage(error), + }); + return false; + } + // Re-invalidate AFTER the tree left the container: a workspace startup + // that began after the stop above can have discovered the then-visible + // tree and would otherwise publish a server from the removed tree. + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + } + return true; + } + + /** + * Update crashed between renaming the OLD live tree into staging and + * promoting the staged replacement: the registry records an install whose + * path is missing, and retrying Update self-rejects (the missing tree reads + * as an empty capability surface). Restore the old tree from staging. + */ + private async recoverInterruptedUpdateSwap( + name: string, + journalPath: string, + registryNames: Set + ): Promise { + if (await pathExists(this.targetPathFor(name))) { + // A live tree (old or new) means the swap never started or completed; + // a still-staged old tree is plain trash for reclamation. + return true; + } + const trashDir = await this.readJournalStagedPath(journalPath, "trashDir"); + if (trashDir === undefined || !(await pathExists(trashDir))) { + log.warn("Update swap journal has no recoverable tree", { name }); + return true; + } + if (!registryNames.has(name)) { + // The entry was since removed (e.g. a registry-only uninstall while + // recovery kept failing): restoring would recreate an unmanaged orphan. + await this.removeDir(trashDir).catch(() => undefined); + return true; + } + log.warn("Restoring plugin tree after an update swap interrupted by a crash", { name }); + try { + await fsPromises.mkdir(this.containerDir, { recursive: true }); + await fsPromises.rename(trashDir, this.targetPathFor(name)); + } catch (error) { + log.warn("Failed to restore plugin tree from an interrupted update swap", { + name, + error: getErrorMessage(error), + }); + return false; + } + return true; + } + + /** + * Uninstall crashed between staging the plugin's assets into trash and the + * registry commit (registry still owns the plugin → restore everything), or + * between the commit and the trash cleanup (entry gone → finish deleting). + */ + private async recoverInterruptedUninstall( + name: string, + journalPath: string, + registryNames: Set + ): Promise { + const trashDir = await this.readJournalStagedPath(journalPath, "trashDir"); + const dataTrashDir = await this.readJournalStagedPath(journalPath, "dataTrashDir"); + if (!registryNames.has(name)) { + // Committed: the staged assets are trash. Delete them now — the user + // may have explicitly requested the data deletion, and stale-staging + // reclamation only runs during a later staging operation. + if (trashDir !== undefined) { + await this.removeDir(trashDir).catch(() => undefined); + } + if (dataTrashDir !== undefined) { + await this.removeDir(dataTrashDir).catch(() => undefined); + } + return true; + } + log.warn("Restoring plugin assets after an uninstall interrupted by a crash", { name }); + let restored = true; + const targetPath = this.targetPathFor(name); + if (trashDir !== undefined && (await pathExists(trashDir))) { + if (await pathExists(targetPath)) { + // The container path is occupied (e.g. the user manually recreated + // it): renaming over it would clobber that tree. Leave the staged + // copy and the journal for manual/later resolution. + log.warn("Uninstall recovery found the plugin path occupied; keeping the staged tree", { + name, + }); + restored = false; + } else { + try { + await fsPromises.mkdir(this.containerDir, { recursive: true }); + await fsPromises.rename(trashDir, targetPath); + } catch (error) { + log.warn("Failed to restore plugin tree from an interrupted uninstall", { + name, + error: getErrorMessage(error), + }); + restored = false; + } + } + } + if (dataTrashDir !== undefined && (await pathExists(dataTrashDir))) { + const dataPath = getPluginDataPath(this.config.rootDir, this.instanceIdFor(name)); + // A server launch since restart can have recreated a fresh dataPath + // (prepareStdioLaunch mkdirs it): stop the plugin's servers and clear + // it so the ORIGINAL data slides back (mirrors the inline rollback). + await this.deps.mcpServerManager?.stopServersWithKeyPrefix( + buildPluginServerKey(this.instanceIdFor(name), "") + ); + if (await pathExists(dataPath)) { + await this.removeDir(dataPath).catch(() => undefined); + } + try { + await fsPromises.mkdir(path.dirname(dataPath), { recursive: true }); + await fsPromises.rename(dataTrashDir, dataPath); + } catch (error) { + log.warn("Failed to restore plugin data from an interrupted uninstall", { + name, + error: getErrorMessage(error), + }); + restored = false; + } + } + return restored; + } + /** Managed registry entries merged with unmanaged plugins found by global discovery. */ async list(): Promise { this.assertEnabled(); - // Section open is the natural recovery moment: clear promotions orphaned - // by a crash between promote and registry write BEFORE discovery scans - // the container, so the orphan never renders as an unmanaged row. - await this.reconcileOrphanedPromotions().catch((error: unknown) => { - log.warn("Failed to reconcile orphaned plugin promotions", { + // Section open re-runs crash recovery (the startup pass may have failed + // or predates recent journals) BEFORE discovery scans the container, so + // an orphaned promotion never renders as an unmanaged row and interrupted + // update/uninstall swaps are restored before their rows would look wrong. + await this.startupReconciliation; + await this.reconcileJournals().catch((error: unknown) => { + log.warn("Failed to reconcile plugin journals", { error: getErrorMessage(error), }); }); @@ -1615,34 +1892,60 @@ export class AgentPluginInstallService { // 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}`); + const dataTrashDir = path.join(this.stagingRoot, `trash-data-${Date.now()}-${entry.name}`); + + // Journal the transaction BEFORE anything moves: a crash between the + // renames below and the registry commit would otherwise leave the + // registry owning a plugin whose tree (and optionally its data) is + // hidden under plugin-staging with nothing to restore it — the next + // list shows a missing install and a retried uninstall commits on + // ENOENT while the staged assets linger. reconcileJournals restores + // them while the registry entry still exists, and finishes the trash + // cleanup once the commit landed. + const uninstallJournalPath = this.journalPath(UNINSTALL_JOURNAL_PREFIX, entry.name); + await fsPromises.writeFile( + uninstallJournalPath, + JSON.stringify({ name: entry.name, trashDir, dataTrashDir, stagedAt: Date.now() }) + ); + const consumeJournal = async (): Promise => { + await fsPromises.rm(uninstallJournalPath, { force: true }).catch(() => undefined); + }; + let stagedTree = false; try { await this.renameIntoStaging(targetPath, trashDir); stagedTree = true; } catch (error) { if (!hasErrorCode(error, "ENOENT")) { + await consumeJournal(); // Nothing moved: no recovery needed. 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 => { + /** Returns true when nothing remained staged (safe to consume the journal). */ + const restoreTree = async (context: string): Promise => { if (!stagedTree) { - return; + return true; } - await fsPromises.rename(trashDir, targetPath).then( - () => this.activeStagingPaths.delete(trashDir), + return fsPromises.rename(trashDir, targetPath).then( + () => { + this.activeStagingPaths.delete(trashDir); + return true; + }, (rollbackError: unknown) => { + // Keep the journal: reconcileJournals restores the staged tree + // on the next startup/section open. log.error(`Failed to restore plugin dir after ${context}`, { targetPath, rollbackError, }); + return false; } ); }; 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 { @@ -1652,7 +1955,9 @@ export class AgentPluginInstallService { 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"); + if (await restoreTree("failed plugin-data staging")) { + await consumeJournal(); + } throw new Error(`Failed to remove the plugin data: ${getErrorMessage(error)}`); } // No data dir: nothing to delete. @@ -1685,7 +1990,8 @@ export class AgentPluginInstallService { rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== entry.name) ); } catch (error) { - await restoreTree("failed registry write"); + const treeRestored = await restoreTree("failed registry write"); + let dataRestored = true; if (stagedData) { // A getToolsForWorkspace startup that began after the pre-stage // invalidation can have recreated dataPath (prepareStdioLaunch @@ -1705,16 +2011,25 @@ export class AgentPluginInstallService { if (await pathExists(dataPath)) { await this.removeDir(dataPath).catch(() => undefined); } - await fsPromises.rename(dataTrashDir, dataPath).then( - () => this.activeStagingPaths.delete(dataTrashDir), + dataRestored = await fsPromises.rename(dataTrashDir, dataPath).then( + () => { + this.activeStagingPaths.delete(dataTrashDir); + return true; + }, (rollbackError: unknown) => { + // Keep the journal: reconcileJournals restores the staged data + // on the next startup/section open. log.error("Failed to restore plugin data after failed registry write", { dataPath, rollbackError, }); + return false; } ); } + if (treeRestored && dataRestored) { + await consumeJournal(); + } throw new Error(`Failed to persist the plugin registry: ${getErrorMessage(error)}`); } @@ -1742,6 +2057,11 @@ export class AgentPluginInstallService { }); }); } + // Consume the journal even when a trash removal failed above: the + // commit landed, so recovery must never restore these assets — a kept + // journal could resurrect the old data over a later REINSTALL of the + // same name. Undeletable leftovers surface below / go to reclamation. + await consumeJournal(); // Re-invalidate AFTER the tree is gone: a getToolsForWorkspace call // that started right after the pre-rename stop snapshots the new epoch, @@ -2223,8 +2543,25 @@ export class AgentPluginInstallService { // handles can make the rename itself fail on Windows. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + const updateJournalPath = this.journalPath(UPDATE_JOURNAL_PREFIX, entry.name); if (hadOldTree) { - await this.renameIntoStaging(targetPath, trashDir); + // Journal the swap BEFORE the live tree moves: a crash between the + // rename below and the promote would leave the registry recording + // an install whose path is missing — and retrying Update cannot + // self-heal because assertNoCapabilityIncrease treats the missing + // tree as an empty surface and rejects the staged capabilities as + // additions. reconcileJournals restores the old tree on recovery. + await fsPromises.writeFile( + updateJournalPath, + JSON.stringify({ name: entry.name, trashDir, stagedAt: Date.now() }) + ); + try { + await this.renameIntoStaging(targetPath, trashDir); + } catch (error) { + // Nothing moved: no recovery needed. + await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); + throw error; + } } try { await fsPromises.mkdir(this.containerDir, { recursive: true }); @@ -2232,19 +2569,25 @@ export class AgentPluginInstallService { } catch (error) { if (hadOldTree) { // Roll the old tree back so a failed swap never leaves the plugin missing. - await fsPromises.rename(trashDir, targetPath).then( - () => this.activeStagingPaths.delete(trashDir), - (rollbackError: unknown) => { - log.error("Failed to roll back plugin dir after failed update swap", { - targetPath, - rollbackError, - }); - } - ); + try { + await fsPromises.rename(trashDir, targetPath); + this.activeStagingPaths.delete(trashDir); + await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); + } catch (rollbackError) { + // Keep the journal: reconcileJournals restores the tree on the + // next startup/section open. + log.error("Failed to roll back plugin dir after failed update swap", { + targetPath, + rollbackError, + }); + } } throw error; } if (hadOldTree) { + // The new tree is live: the journal's recovery job is done (the + // staged old tree is plain trash now). + await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); // Best-effort: the trash dir sits under the staging root, where // stale-dir reclamation cleans up leftovers. await this.removeDir(trashDir).catch((error: unknown) => { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index db5b387a1bc..dabac066321 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3789,7 +3789,7 @@ 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 `~/.xum/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.", + "**Settings → Plugins** installs plugins from git into `~/.shux/plugins` (paste a git URL or `owner/repo[@ref]`); the exact location derives from the active Shux home and is shown in the section. 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 `~/.shux/plugin-data/` unless you opt in to deleting it.", "", "## Behavior", "", From d65c5c34a2b47fbb4a49ad444229fba21905cf0b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 14:38:50 +0000 Subject: [PATCH 21/63] fix: address Codex review round 39 - Gate every global plugin discovery scan (MCP, hooks, skills, workflows, agents) on startup journal reconciliation via a discovery barrier set by AgentPluginInstallService, so an agent request cannot load an orphaned tree while recovery is still running - Retain uninstall journals (inline and in recovery) until every staged asset actually deletes, making the journal the durable retry record for explicitly requested cleanup - Block same-name reinstalls while an uninstall journal awaits recovery, so recovery can never misread an old journal as an uncommitted uninstall of a fresh install and restore stale data over it - Stamp promotion journals with the staged tree's dev/ino identity and verify it before orphan cleanup, so recovery never deletes a user's replacement tree placed at the same container path --- src/node/services/agentPlugins/discovery.ts | 18 +++ .../agentPlugins/installService.test.ts | 149 ++++++++++++++++-- .../services/agentPlugins/installService.ts | 143 +++++++++++++---- 3 files changed, 262 insertions(+), 48 deletions(-) diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index a1a1da838dd..35579595235 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -360,6 +360,23 @@ export async function discoverAgentPluginAt(args: { return { plugin, diagnostics }; } +/** + * Crash-recovery gate for container scans. AgentPluginInstallService installs + * its startup journal reconciliation here so no discovery path (MCP config, + * hooks, skills, workflows, agents — they all funnel through + * discoverAgentPlugins) can scan the managed container while recovery is + * still restoring or removing trees: an agent request arriving right after a + * crash would otherwise load an orphaned promotion — hook included — before + * cleanup ran. The barrier must never reject (the service catches); it + * defaults to resolved so tests and contexts without the install service are + * unaffected. + */ +let discoveryBarrier: Promise = Promise.resolve(); + +export function setAgentPluginDiscoveryBarrier(barrier: Promise): void { + discoveryBarrier = barrier; +} + /** * Discover Agent Plugins in the given container directories. * @@ -371,6 +388,7 @@ export async function discoverAgentPluginAt(args: { export async function discoverAgentPlugins( containers: AgentPluginContainer[] ): Promise { + await discoveryBarrier; const plugins: AgentPluginInfo[] = []; const diagnostics: AgentPluginDiagnostic[] = []; diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 584aafbfd62..012e743f384 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -8,6 +8,7 @@ import { Config } from "@/node/config"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { execFileAsync } from "@/node/utils/disposableExec"; +import { discoverAgentPlugins } from "./discovery"; import { AgentPluginInstallService, withDiskQuotaWatchdog } from "./installService"; import { AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, @@ -642,6 +643,21 @@ describe("AgentPluginInstallService", () => { expect(items.filter((item) => item.name === "demo-plugin")).toHaveLength(1); }); + /** The identity-stamped journal install() writes before the promote rename. */ + const writePromotionJournal = async (journalPath: string, treePath: string): Promise => { + const stat = await fsPromises.stat(treePath, { bigint: true }); + await fsPromises.mkdir(path.dirname(journalPath), { recursive: true }); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ + name: path.basename(treePath), + stagedAt: Date.now(), + treeDev: stat.dev.toString(), + treeIno: stat.ino.toString(), + }) + ); + }; + test("a promotion orphaned by a crash is cleaned up on section open", async () => { // Simulate the post-crash state of an install that died between the // promote rename and the registry write: a promoted tree with no @@ -653,11 +669,7 @@ describe("AgentPluginInstallService", () => { JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "demo-plugin", version: "1" }) ); const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); - await fsPromises.mkdir(stagingDir(), { recursive: true }); - await fsPromises.writeFile( - journalPath, - JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) - ); + await writePromotionJournal(journalPath, targetPath); // Section open reconciles: the orphan never renders (not even as // unmanaged), the tree is gone, and the journal is consumed. @@ -673,17 +685,14 @@ describe("AgentPluginInstallService", () => { // A journal WITH a registry entry means the install committed and only // the journal deletion was lost — the tree must survive. - await fsPromises.writeFile( - journalPath, - JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) - ); + await writePromotionJournal(journalPath, targetPath); const itemsAfter = await service.list(); expect(itemsAfter.find((item) => item.name === "demo-plugin")?.managed).toBe(true); expect(await pathExists(targetPath)).toBe(true); expect(await pathExists(journalPath)).toBe(false); }); - test("crash recovery runs at service startup, before any section open", async () => { + test("crash recovery runs at service startup and gates global discovery", async () => { // An orphaned promotion must not wait for list(): a session can serve // agent requests — whose global plugin discovery loads the container's // hooks and MCP servers — without ever opening Settings → Plugins. @@ -693,19 +702,118 @@ describe("AgentPluginInstallService", () => { path.join(targetPath, "plugin.json"), JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "demo-plugin", version: "1" }) ); + const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); + await writePromotionJournal(journalPath, targetPath); + + void new AgentPluginInstallService(config, { isEnabled: () => true }); + // The barrier makes a discovery scan issued IMMEDIATELY after + // construction wait for the recovery pass, so the orphan can never + // surface — its hooks/servers would otherwise load on the next request. + const { plugins } = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(plugins.find((plugin) => plugin.dirName === "demo-plugin")).toBeUndefined(); + + expect(await pathExists(targetPath)).toBe(false); + expect(await pathExists(journalPath)).toBe(false); + }); + + test("promotion recovery leaves a user-replaced tree at the same path alone", async () => { + // The user deleted the orphan while the app was stopped and placed their + // OWN unmanaged plugin at the same path — a supported use of the global + // container. The journal's dev/ino stamp no longer matches, so recovery + // must not delete their directory; the journal is spent (our orphan is + // gone). + const targetPath = path.join(pluginsDir(), "demo-plugin"); + await fsPromises.mkdir(targetPath, { recursive: true }); + await fsPromises.writeFile( + path.join(targetPath, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "demo-plugin", version: "1" }) + ); + // Stamp the journal with a DIFFERENT directory's identity (stands in for + // the promoted tree that no longer exists). await fsPromises.mkdir(stagingDir(), { recursive: true }); + const otherStat = await fsPromises.stat(stagingDir(), { bigint: true }); const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); await fsPromises.writeFile( journalPath, - JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) + JSON.stringify({ + name: "demo-plugin", + stagedAt: Date.now(), + treeDev: otherStat.dev.toString(), + treeIno: otherStat.ino.toString(), + }) ); - const freshService = new AgentPluginInstallService(config, { isEnabled: () => true }); - await (freshService as unknown as { startupReconciliation: Promise }) - .startupReconciliation; + const items = await service.list(); + // The user's tree survives and lists as unmanaged; the journal is consumed. + expect(items.find((item) => item.name === "demo-plugin")?.managed).toBe(false); + expect(await pathExists(targetPath)).toBe(true); + expect(await pathExists(journalPath)).toBe(false); + }); - expect(await pathExists(targetPath)).toBe(false); + test("reinstalling is blocked while an uninstall journal awaits recovery", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // Post-crash state of a COMMITTED uninstall whose journal was retained + // (e.g. the trash deletion kept failing): entry gone, staged tree left. + // A reinstall now would make recovery unable to tell this journal from + // an uncommitted uninstall of the NEW install — it must be blocked until + // recovery finalizes the journal. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.rename(targetPath, trashDir); + const seeded = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as Record< + string, + unknown + >; + await fsPromises.writeFile(registryFile(), JSON.stringify({ ...seeded, plugins: [] })); + const journalPath = path.join(stagingDir(), "uninstall-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", trashDir, stagedAt: Date.now() }) + ); + + await expect( + service.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/unfinished cleanup/); + + // Recovery finalizes the journal (committed → trash deleted); the + // reinstall then proceeds. + await service.list(); expect(await pathExists(journalPath)).toBe(false); + const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + expect(entry.name).toBe("demo-plugin"); + }); + + test("a committed uninstall journal is retained until its staged assets delete", async () => { + // The user explicitly requested the data deletion: if recovery's cleanup + // fails (e.g. a Windows file lock), the journal must survive as the + // durable retry record instead of reporting success — stale-staging + // reclamation may never run again. + await fsPromises.mkdir(stagingDir(), { recursive: true }); + const dataTrashDir = path.join(stagingDir(), `trash-data-${Date.now()}-demo-plugin`); + await fsPromises.mkdir(dataTrashDir, { recursive: true }); + const journalPath = path.join(stagingDir(), "uninstall-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", dataTrashDir, stagedAt: Date.now() }) + ); + + const internals = service as unknown as { removeDir: (dirPath: string) => Promise }; + const removeDirSpy = spyOn(internals, "removeDir").mockImplementation(() => + Promise.reject(new Error("EBUSY: locked")) + ); + try { + await service.list(); + expect(await pathExists(journalPath)).toBe(true); + } finally { + removeDirSpy.mockRestore(); + } + + // Once deletion succeeds, the journal is consumed and the data is gone. + await service.list(); + expect(await pathExists(journalPath)).toBe(false); + expect(await pathExists(dataTrashDir)).toBe(false); }); test("an update swap interrupted between rename and promote is restored", async () => { @@ -1021,12 +1129,19 @@ describe("AgentPluginInstallService", () => { } // Uninstall completed: registry entry + container dir gone; the staged - // tree remains under staging for stale-dir reclamation. + // tree remains under staging with the journal as the durable retry + // record (stale reclamation may never run again). expect(await registry()).toEqual([]); expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); expect((await stagingLeftovers()).some((name) => name.startsWith("trash-"))).toBe(true); + const journalPath = path.join(stagingDir(), "uninstall-demo-plugin.json"); + expect(await pathExists(journalPath)).toBe(true); - // And reinstall is not blocked by leftover state. + // Recovery (section open) retries the deletion and finalizes the + // journal; reinstall then proceeds unblocked. + await service.list(); + expect(await pathExists(journalPath)).toBe(false); + expect((await stagingLeftovers()).some((name) => name.startsWith("trash-"))).toBe(false); const preview2 = await service.preview({ input: remoteDir }); const entry = await service.install({ source: preview2.source, diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 7c89652f28a..e763fea9d7e 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -34,6 +34,7 @@ import { execFileAsync } from "@/node/utils/disposableExec"; import { discoverAgentPluginAt, discoverAgentPlugins, + setAgentPluginDiscoveryBarrier, type AgentPluginContainer, type AgentPluginInfo, } from "./discovery"; @@ -392,6 +393,11 @@ export class AgentPluginInstallService { error: getErrorMessage(error), }); }); + // Every global discovery consumer (MCP config, hooks, skills, workflows, + // agents) funnels through discoverAgentPlugins; gate those scans on the + // startup pass so an agent request cannot load an orphaned tree while + // recovery is still running. The promise never rejects (caught above). + setAgentPluginDiscoveryBarrier(this.startupReconciliation); } /** @@ -1383,6 +1389,18 @@ export class AgentPluginInstallService { const name = plugin.name; await this.assertNoCollision(name); await this.assertNoPendingOverridePrune(name); + // A retained uninstall journal means a previous uninstall of this + // name still has unfinished recovery (staged assets to restore or + // delete). Block the reinstall until it resolves: with a fresh + // registry entry present, recoverInterruptedUninstall could no longer + // tell that old journal from an uncommitted uninstall of THIS install + // and would restore the old data over it. Recovery runs at startup + // and on section open, so this self-heals. + if (await pathExists(this.journalPath(UNINSTALL_JOURNAL_PREFIX, name))) { + throw new Error( + `A previous uninstall of '${name}' has unfinished cleanup. Open Settings → Plugins to let recovery complete, then try again.` + ); + } const targetPath = this.targetPathFor(name); // The installed tree is a plain content snapshot: the registry holds @@ -1394,9 +1412,22 @@ export class AgentPluginInstallService { // the rename and the registry write would otherwise strand a tree // that discovery lists as unmanaged, assertNoCollision blocks, and // uninstall refuses — reconcileJournals uses this record to clean it - // up on startup or the next section open. + // up on startup or the next section open. The staged dir's filesystem + // identity (preserved by the rename) proves the tree recovery finds + // at the target is the one WE promoted: a user could delete the + // orphan while the app is stopped and place their own unmanaged + // plugin at the same path, which cleanup must never delete. + const stagedStat = await fsPromises.stat(stagedDir, { bigint: true }); const journalPath = this.journalPath(PROMOTION_JOURNAL_PREFIX, name); - await fsPromises.writeFile(journalPath, JSON.stringify({ name, stagedAt: Date.now() })); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ + name, + stagedAt: Date.now(), + treeDev: stagedStat.dev.toString(), + treeIno: stagedStat.ino.toString(), + }) + ); await fsPromises.mkdir(this.containerDir, { recursive: true }); await fsPromises.rename(stagedDir, targetPath); @@ -1504,6 +1535,20 @@ export class AgentPluginInstallService { return path.join(this.stagingRoot, `${prefix}${name}.json`); } + /** A string field from a journal, or undefined when absent/unreadable. */ + private async readJournalField(journalPath: string, field: string): Promise { + try { + const parsed = JSON.parse(await fsPromises.readFile(journalPath, "utf-8")) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const value = (parsed as Record)[field]; + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } + } + /** * A staged path recorded in a journal, or undefined when absent/invalid. * Defensive: recovery renames/deletes these paths, so a corrupted journal @@ -1513,22 +1558,14 @@ export class AgentPluginInstallService { journalPath: string, field: string ): Promise { - try { - const parsed = JSON.parse(await fsPromises.readFile(journalPath, "utf-8")) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; - } - const value = (parsed as Record)[field]; - if (typeof value !== "string") { - return undefined; - } - if (path.dirname(value) !== this.stagingRoot || !path.basename(value).startsWith("trash-")) { - return undefined; - } - return value; - } catch { + const value = await this.readJournalField(journalPath, field); + if (value === undefined) { + return undefined; + } + if (path.dirname(value) !== this.stagingRoot || !path.basename(value).startsWith("trash-")) { return undefined; } + return value; } /** @@ -1574,7 +1611,7 @@ export class AgentPluginInstallService { } const consumed = prefix === PROMOTION_JOURNAL_PREFIX - ? await this.recoverOrphanedPromotion(name, registryNames) + ? await this.recoverOrphanedPromotion(name, journalPath, registryNames) : prefix === UPDATE_JOURNAL_PREFIX ? await this.recoverInterruptedUpdateSwap(name, journalPath, registryNames) : await this.recoverInterruptedUninstall(name, journalPath, registryNames); @@ -1593,12 +1630,38 @@ export class AgentPluginInstallService { */ private async recoverOrphanedPromotion( name: string, + journalPath: string, registryNames: Set ): Promise { const targetPath = this.targetPathFor(name); // Only an ORPHAN (tree without registry entry) needs cleanup; a registry // entry means the install committed and only the journal deletion was lost. if (!registryNames.has(name) && (await pathExists(targetPath))) { + // Verify the tree is the one WE promoted before deleting anything: the + // user can delete the orphan while the app is stopped and place their + // own unmanaged plugin at the same path (a supported use of the + // globally scanned container). The journal's dev/ino stamp survives the + // promote rename; a mismatch (or an unverifiable stamp) means our + // orphan is already gone, so consume the journal WITHOUT touching the + // replacement. + const journalDev = await this.readJournalField(journalPath, "treeDev"); + const journalIno = await this.readJournalField(journalPath, "treeIno"); + const currentStat = await fsPromises + .stat(targetPath, { bigint: true }) + .catch(() => undefined); + const isPromotedTree = + journalDev !== undefined && + journalIno !== undefined && + currentStat !== undefined && + currentStat.dev.toString() === journalDev && + currentStat.ino.toString() === journalIno; + if (!isPromotedTree) { + log.warn( + "Skipping orphaned-promotion cleanup: the tree at the plugin path is not the promoted one", + { name } + ); + return true; + } log.warn("Cleaning up plugin promotion orphaned by a crash", { name }); const serverKeyPrefix = buildPluginServerKey(this.instanceIdFor(name), ""); await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); @@ -1678,14 +1741,25 @@ export class AgentPluginInstallService { if (!registryNames.has(name)) { // Committed: the staged assets are trash. Delete them now — the user // may have explicitly requested the data deletion, and stale-staging - // reclamation only runs during a later staging operation. - if (trashDir !== undefined) { - await this.removeDir(trashDir).catch(() => undefined); - } - if (dataTrashDir !== undefined) { - await this.removeDir(dataTrashDir).catch(() => undefined); + // reclamation only runs during a later staging operation. A failed + // deletion (e.g. a Windows file lock) RETAINS the journal as the + // durable retry record; that also keeps same-name reinstalls blocked + // (install()'s journal gate), so this branch stays the only reachable + // one for this journal. + let cleaned = true; + for (const staged of [trashDir, dataTrashDir]) { + if (staged === undefined) { + continue; + } + await this.removeDir(staged).catch((error: unknown) => { + cleaned = false; + log.warn("Failed to delete staged assets of a committed uninstall; will retry", { + staged, + error: getErrorMessage(error), + }); + }); } - return true; + return cleaned; } log.warn("Restoring plugin assets after an uninstall interrupted by a crash", { name }); let restored = true; @@ -2035,9 +2109,11 @@ export class AgentPluginInstallService { // The uninstall is committed; everything below is best-effort cleanup // that must not abort the remaining steps. + let trashCleaned = true; if (stagedTree) { await this.removeDir(trashDir).catch((error: unknown) => { - log.warn("Failed to delete uninstalled plugin tree; leaving it for staging reclamation", { + trashCleaned = false; + log.warn("Failed to delete uninstalled plugin tree; recovery will retry", { trashDir, error: getErrorMessage(error), }); @@ -2050,18 +2126,23 @@ export class AgentPluginInstallService { // stale-staging reclamation only runs during a later staging // operation, which may never happen. await this.removeDir(dataTrashDir).catch((error: unknown) => { + trashCleaned = false; dataDeletionFailure = `The plugin was uninstalled, but deleting its stored data failed (${getErrorMessage(error)}). The data was moved to ${shortenHome(dataTrashDir)} — delete it manually.`; - log.warn("Failed to delete plugin data; leaving it for staging reclamation", { + log.warn("Failed to delete plugin data; recovery will retry", { dataTrashDir, error: getErrorMessage(error), }); }); } - // Consume the journal even when a trash removal failed above: the - // commit landed, so recovery must never restore these assets — a kept - // journal could resurrect the old data over a later REINSTALL of the - // same name. Undeletable leftovers surface below / go to reclamation. - await consumeJournal(); + // Consume the journal only once every staged asset is gone: a retained + // committed journal is the durable retry record for the failed cleanup + // (recoverInterruptedUninstall's committed branch finishes it), and it + // blocks same-name reinstalls until then — with the entry gone, + // recovery can never misread this journal as an uncommitted uninstall + // and restore the assets. + if (trashCleaned) { + await consumeJournal(); + } // Re-invalidate AFTER the tree is gone: a getToolsForWorkspace call // that started right after the pre-rename stop snapshots the new epoch, From 8abee7d7da30201e27ee81de912a930830a6fb18 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 15:01:20 +0000 Subject: [PATCH 22/63] fix: address Codex review round 40 - Charge directories against the checkout entry quota in both the in-flight clone watchdog and the post-clone tree check: repeated git tree objects can amplify a tiny pack into thousands of directories, each consuming an inode without moving the byte total - Replace the promotion journal's dev/ino identity with a random nonce marker file carried inside the promoted tree: filesystems can hand a deleted directory's inode straight back to a recreated one, so dev/ino matching could still delete a user's replacement tree. The marker is removed once the install commits (and swept by recovery when only the journal deletion was lost) - Make the discovery gate failure-aware: discoverAgentPlugins now receives the container paths to SUPPRESS, and the install service suppresses its managed container while the latest reconciliation attempt has failed (unreadable registry, failed restore/quarantine) instead of releasing discovery over an unreconciled tree; a later successful attempt (section open) re-opens the container --- src/node/services/agentPlugins/discovery.ts | 36 ++-- .../agentPlugins/installService.test.ts | 94 +++++++--- .../services/agentPlugins/installService.ts | 171 +++++++++++------- 3 files changed, 195 insertions(+), 106 deletions(-) diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 35579595235..0a03a818fc4 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -362,19 +362,23 @@ export async function discoverAgentPluginAt(args: { /** * Crash-recovery gate for container scans. AgentPluginInstallService installs - * its startup journal reconciliation here so no discovery path (MCP config, - * hooks, skills, workflows, agents — they all funnel through - * discoverAgentPlugins) can scan the managed container while recovery is - * still restoring or removing trees: an agent request arriving right after a - * crash would otherwise load an orphaned promotion — hook included — before - * cleanup ran. The barrier must never reject (the service catches); it - * defaults to resolved so tests and contexts without the install service are + * a gate here so no discovery path (MCP config, hooks, skills, workflows, + * agents — they all funnel through discoverAgentPlugins) can scan the managed + * container while journal recovery is still restoring or removing trees: an + * agent request arriving right after a crash would otherwise load an orphaned + * promotion — hook included — before cleanup ran. The gate resolves to the + * container paths that must be SUPPRESSED from the scan: when recovery + * FAILED (unreadable registry, failed restore/quarantine), merely waiting + * would release discovery over the unreconciled tree, so the managed + * container is omitted until a later recovery attempt succeeds. The returned + * promise must never reject (the service catches); the default gate + * suppresses nothing so tests and contexts without the install service are * unaffected. */ -let discoveryBarrier: Promise = Promise.resolve(); +let discoveryGate: () => Promise = () => Promise.resolve([]); -export function setAgentPluginDiscoveryBarrier(barrier: Promise): void { - discoveryBarrier = barrier; +export function setAgentPluginDiscoveryGate(gate: () => Promise): void { + discoveryGate = gate; } /** @@ -388,7 +392,7 @@ export function setAgentPluginDiscoveryBarrier(barrier: Promise): void { export async function discoverAgentPlugins( containers: AgentPluginContainer[] ): Promise { - await discoveryBarrier; + const suppressedContainers = new Set(await discoveryGate()); const plugins: AgentPluginInfo[] = []; const diagnostics: AgentPluginDiagnostic[] = []; @@ -401,6 +405,16 @@ export async function discoverAgentPlugins( continue; } seenContainers.add(container.path); + if (suppressedContainers.has(container.path)) { + diagnostics.push({ + path: container.path, + scope: container.scope, + severity: "warning", + message: + "Managed plugin container skipped: crash recovery has not completed (see logs); its plugins are unavailable until it succeeds.", + }); + continue; + } for (const entryName of await listChildDirectories(container.path)) { const plugin = await discoverPluginAt({ diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 012e743f384..7a9c1f71d8b 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -429,6 +429,28 @@ describe("AgentPluginInstallService", () => { ); }); + test("directories count toward the staged-checkout entry quota", async () => { + // Repeated git tree objects can amplify a tiny pack into thousands of + // directories, each consuming an inode and filesystem metadata; the + // entry quota must charge them even when the FILE count stays low. + for (let i = 0; i < 6; i += 1) { + const dir = path.join(remoteDir, `nested-${i}`); + await fsPromises.mkdir(dir, { recursive: true }); + await fsPromises.writeFile(path.join(dir, "f"), "x"); + } + await commitAll(remoteDir, "many directories"); + + // Tree: 9 files (3 fixture + 6 nested) but 17 entries once the 8 + // directories are charged — a files-only count would pass this quota. + const quotaService = new AgentPluginInstallService(config, { + isEnabled: () => true, + stagingQuota: { maxBytes: 1024 * 1024, maxFiles: 12 }, + }); + await expect(quotaService.preview({ input: remoteDir })).rejects.toThrow( + /too large to install/ + ); + }); + test("consent preview discloses full env assignments, not just key names", async () => { // NODE_OPTIONS=--require=./payload.js changes what executes without // appearing in the argv; the consent card must show the value. @@ -521,18 +543,20 @@ describe("AgentPluginInstallService", () => { } }); - test("withDiskQuotaWatchdog aborts on file count independently of bytes", async () => { - // Many empty files consume inodes and allocation metadata without moving - // the byte total, so the in-flight watchdog must enforce maxFiles DURING - // checkout too — the post-clone count only runs after git returns. + test("withDiskQuotaWatchdog aborts on entry count independently of bytes", async () => { + // Empty files and directories consume inodes and allocation metadata + // without moving the byte total, so the in-flight watchdog must enforce + // maxFiles DURING checkout too — the post-clone count only runs after + // git returns. Directories must charge the count like files. const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "quota-watchdog-files-")); try { await expect( withDiskQuotaWatchdog( { dir, maxBytes: 1024 * 1024, maxFiles: 8, pollMs: 10 }, async (signal) => { - for (let i = 0; i < 20; i += 1) { + for (let i = 0; i < 10; i += 1) { await fsPromises.writeFile(path.join(dir, `empty-${i}`), ""); + await fsPromises.mkdir(path.join(dir, `dir-${i}`)); } await new Promise((_resolve, reject) => { signal.addEventListener("abort", () => reject(new Error("killed")), { once: true }); @@ -643,18 +667,17 @@ describe("AgentPluginInstallService", () => { expect(items.filter((item) => item.name === "demo-plugin")).toHaveLength(1); }); - /** The identity-stamped journal install() writes before the promote rename. */ + /** + * The nonce-stamped journal install() writes before the promote rename, + * plus the matching marker file the staged tree carries through it. + */ const writePromotionJournal = async (journalPath: string, treePath: string): Promise => { - const stat = await fsPromises.stat(treePath, { bigint: true }); + const nonce = `test-nonce-${Date.now()}`; + await fsPromises.writeFile(path.join(treePath, ".mux-promotion-marker"), nonce); await fsPromises.mkdir(path.dirname(journalPath), { recursive: true }); await fsPromises.writeFile( journalPath, - JSON.stringify({ - name: path.basename(treePath), - stagedAt: Date.now(), - treeDev: stat.dev.toString(), - treeIno: stat.ino.toString(), - }) + JSON.stringify({ name: path.basename(treePath), stagedAt: Date.now(), nonce }) ); }; @@ -719,28 +742,21 @@ describe("AgentPluginInstallService", () => { test("promotion recovery leaves a user-replaced tree at the same path alone", async () => { // The user deleted the orphan while the app was stopped and placed their // OWN unmanaged plugin at the same path — a supported use of the global - // container. The journal's dev/ino stamp no longer matches, so recovery - // must not delete their directory; the journal is spent (our orphan is - // gone). + // container. Their tree carries no marker matching the journal's nonce + // (unlike dev/ino, a nonce cannot be reused by the filesystem when the + // recreated directory gets the deleted one's inode), so recovery must + // not delete their directory; the journal is spent (our orphan is gone). const targetPath = path.join(pluginsDir(), "demo-plugin"); await fsPromises.mkdir(targetPath, { recursive: true }); await fsPromises.writeFile( path.join(targetPath, "plugin.json"), JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "demo-plugin", version: "1" }) ); - // Stamp the journal with a DIFFERENT directory's identity (stands in for - // the promoted tree that no longer exists). await fsPromises.mkdir(stagingDir(), { recursive: true }); - const otherStat = await fsPromises.stat(stagingDir(), { bigint: true }); const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); await fsPromises.writeFile( journalPath, - JSON.stringify({ - name: "demo-plugin", - stagedAt: Date.now(), - treeDev: otherStat.dev.toString(), - treeIno: otherStat.ino.toString(), - }) + JSON.stringify({ name: "demo-plugin", stagedAt: Date.now(), nonce: "the-promoted-nonce" }) ); const items = await service.list(); @@ -750,6 +766,34 @@ describe("AgentPluginInstallService", () => { expect(await pathExists(journalPath)).toBe(false); }); + test("failed journal recovery suppresses the managed container from discovery", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // A journal plus an unreadable registry: recovery FAILS (strict read), + // and merely waiting for it must not release discovery over the managed + // container — the journaled tree may still be sitting in it. The + // unmanaged sibling container stays discoverable. + const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); + await writePromotionJournal(journalPath, path.join(pluginsDir(), "demo-plugin")); + const goodRegistry = await fsPromises.readFile(registryFile(), "utf8"); + await fsPromises.writeFile(registryFile(), "{ not json"); + + const freshService = new AgentPluginInstallService(config, { isEnabled: () => true }); + const suppressed = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(suppressed.plugins).toEqual([]); + expect( + suppressed.diagnostics.some((diagnostic) => diagnostic.message.includes("crash recovery")) + ).toBe(true); + + // Once the registry reads again, a successful recovery (section open) + // re-opens the container for discovery. + await fsPromises.writeFile(registryFile(), goodRegistry); + await freshService.list(); + const reopened = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(reopened.plugins.map((plugin) => plugin.dirName)).toEqual(["demo-plugin"]); + }); + test("reinstalling is blocked while an uninstall journal awaits recovery", async () => { const preview = await service.preview({ input: remoteDir }); await service.install({ source: preview.source, expectedSha: preview.lockedSha }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index e763fea9d7e..098eaca7edb 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "node:crypto"; import type { Dirent } from "node:fs"; import * as fsPromises from "node:fs/promises"; import * as os from "node:os"; @@ -34,7 +35,7 @@ import { execFileAsync } from "@/node/utils/disposableExec"; import { discoverAgentPluginAt, discoverAgentPlugins, - setAgentPluginDiscoveryBarrier, + setAgentPluginDiscoveryGate, type AgentPluginContainer, type AgentPluginInfo, } from "./discovery"; @@ -123,6 +124,16 @@ const JOURNAL_PREFIXES = [ UNINSTALL_JOURNAL_PREFIX, ] as const; +/** + * Marker file written into a staged tree just before its promote rename, + * holding the random nonce also recorded in the promotion journal. Orphan + * recovery deletes a tree only when the nonces match: this proves the tree is + * the one WE promoted. Filesystem identities (dev/ino) are NOT sufficient — + * deleting the orphan and recreating a directory at the same path can reuse + * the inode immediately. The marker is removed once the install commits. + */ +const PROMOTION_MARKER_FILE = ".mux-promotion-marker"; + function isJournalName(entry: string): boolean { return JOURNAL_PREFIXES.some((prefix) => entry.startsWith(prefix)); } @@ -153,19 +164,20 @@ function gitEnv(): Record { } /** - * True when the aggregate file bytes OR the non-directory entry count under - * `dir` exceed the quota. Counts every non-directory entry (empty files, - * symlinks) like assertStagedTreeWithinQuota: a repo of tens of thousands of - * empty files consumes inodes and allocation metadata without moving the byte - * total. Walks with early exit; entries vanishing mid-walk (git renames temp - * files) are skipped. + * True when the aggregate file bytes OR the entry count under `dir` exceed + * the quota. Counts EVERY entry — files, symlinks, and directories — like + * assertStagedTreeWithinQuota: each one consumes an inode and filesystem + * metadata without necessarily moving the byte total (a tiny pack of repeated + * git tree objects can materialize thousands of directories). Walks with + * early exit; entries vanishing mid-walk (git renames temp files) are + * skipped. */ async function directoryQuotaExceeded( dir: string, quota: { maxBytes: number; maxFiles: number } ): Promise { let bytes = 0; - let files = 0; + let entryCount = 0; const pending: string[] = [dir]; while (pending.length > 0) { const current = pending.pop(); @@ -178,19 +190,17 @@ async function directoryQuotaExceeded( } for (const entry of entries) { const entryPath = path.join(current, entry.name); + entryCount += 1; if (entry.isDirectory()) { pending.push(entryPath); - continue; - } - files += 1; - if (entry.isFile()) { + } else if (entry.isFile()) { try { bytes += (await fsPromises.lstat(entryPath)).size; } catch { - continue; + // Entry vanished mid-walk. } } - if (bytes > quota.maxBytes || files > quota.maxFiles) { + if (bytes > quota.maxBytes || entryCount > quota.maxFiles) { return true; } } @@ -358,15 +368,18 @@ export class AgentPluginInstallService { private readonly activeStagingPaths = new Set(); /** - * Startup crash-recovery pass. Kicked off at construction because a - * session can serve agent requests (whose global plugin discovery, MCP - * config, and hook loading scan the container) without ever opening the - * Plugins section — an orphaned promotion would load as an unmanaged - * plugin, hooks included, before list()'s reconciliation ever ran. Errors - * are logged, never thrown (startup must not crash the app); list() awaits - * this so section open cannot race it. + * Latest journal-reconciliation attempt, resolving to whether it SUCCEEDED. + * Kicked off at construction because a session can serve agent requests + * (whose global plugin discovery, MCP config, and hook loading scan the + * container) without ever opening the Plugins section — an orphaned + * promotion would load as an unmanaged plugin, hooks included, before + * list()'s reconciliation ever ran. The discovery gate consumes the status: + * `false` (unreadable registry, failed restore/quarantine) suppresses the + * managed container from scans until a later attempt succeeds — merely + * awaiting a failed pass would release discovery over the unreconciled + * tree. Never rejects (startup must not crash the app). */ - private readonly startupReconciliation: Promise; + private reconciliationState: Promise; constructor( private readonly config: Config, @@ -388,16 +401,29 @@ export class AgentPluginInstallService { // something, and cleaning up our own crash leftovers is correct even if // the experiment was disabled afterwards (a missing staging root makes // this a single readdir). Failures retry on the next section open. - this.startupReconciliation = this.reconcileJournals().catch((error: unknown) => { - log.warn("Startup plugin journal reconciliation failed", { - error: getErrorMessage(error), - }); - }); + this.reconciliationState = this.attemptReconcileJournals("startup"); // Every global discovery consumer (MCP config, hooks, skills, workflows, // agents) funnels through discoverAgentPlugins; gate those scans on the - // startup pass so an agent request cannot load an orphaned tree while - // recovery is still running. The promise never rejects (caught above). - setAgentPluginDiscoveryBarrier(this.startupReconciliation); + // LATEST reconciliation attempt so an agent request cannot load an + // orphaned tree while recovery is running — and cannot scan the managed + // container at all while the latest attempt has FAILED (the journaled + // tree may still be sitting in it). + setAgentPluginDiscoveryGate(async () => + (await this.reconciliationState) ? [] : [this.containerDir] + ); + } + + /** Run reconcileJournals, mapping the outcome to a never-rejecting health flag. */ + private attemptReconcileJournals(context: string): Promise { + return this.reconcileJournals().then( + () => true, + (error: unknown) => { + log.warn(`Plugin journal reconciliation failed (${context})`, { + error: getErrorMessage(error), + }); + return false; + } + ); } /** @@ -813,15 +839,17 @@ export class AgentPluginInstallService { } /** - * Enforce the staged-checkout quota (bytes + file count, .git excluded, - * symlinks not followed). Runs immediately after every staged clone so an - * oversized tree is deleted by the caller's error path before any - * validation reads it. + * Enforce the staged-checkout quota (bytes + entry count, .git excluded, + * symlinks not followed). Directories count too: each consumes an inode + * and filesystem metadata, and repeated git tree objects can amplify a + * tiny pack into thousands of them. Runs immediately after every staged + * clone so an oversized tree is deleted by the caller's error path before + * any validation reads it. */ private async assertStagedTreeWithinQuota(dir: string): Promise { const quota = this.stagingQuota(); let bytes = 0; - let files = 0; + let entryCount = 0; const pending: string[] = [dir]; while (pending.length > 0) { const current = pending.pop(); @@ -831,16 +859,14 @@ export class AgentPluginInstallService { continue; } const entryPath = path.join(current, entry.name); + entryCount += 1; if (entry.isDirectory()) { pending.push(entryPath); - continue; - } - files += 1; - if (entry.isFile()) { + } else if (entry.isFile()) { const stat = await fsPromises.lstat(entryPath); bytes += stat.size; } - if (files > quota.maxFiles || bytes > quota.maxBytes) { + if (entryCount > quota.maxFiles || bytes > quota.maxBytes) { throw new Error( `The repository is too large to install as a plugin (limit: ${quota.maxFiles} files, ${Math.floor(quota.maxBytes / (1024 * 1024))} MiB).` ); @@ -1412,21 +1438,17 @@ export class AgentPluginInstallService { // the rename and the registry write would otherwise strand a tree // that discovery lists as unmanaged, assertNoCollision blocks, and // uninstall refuses — reconcileJournals uses this record to clean it - // up on startup or the next section open. The staged dir's filesystem - // identity (preserved by the rename) proves the tree recovery finds + // up on startup or the next section open. The marker nonce (riding + // inside the tree through the rename) proves the tree recovery finds // at the target is the one WE promoted: a user could delete the // orphan while the app is stopped and place their own unmanaged // plugin at the same path, which cleanup must never delete. - const stagedStat = await fsPromises.stat(stagedDir, { bigint: true }); + const promotionNonce = randomBytes(16).toString("hex"); + await fsPromises.writeFile(path.join(stagedDir, PROMOTION_MARKER_FILE), promotionNonce); const journalPath = this.journalPath(PROMOTION_JOURNAL_PREFIX, name); await fsPromises.writeFile( journalPath, - JSON.stringify({ - name, - stagedAt: Date.now(), - treeDev: stagedStat.dev.toString(), - treeIno: stagedStat.ino.toString(), - }) + JSON.stringify({ name, stagedAt: Date.now(), nonce: promotionNonce }) ); await fsPromises.mkdir(this.containerDir, { recursive: true }); @@ -1522,6 +1544,11 @@ export class AgentPluginInstallService { // handled the tree): the journal's crash-recovery job is done. await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); } + // Committed: the marker did its crash-recovery job (a failed removal + // leaves a stray dotfile the next update swap discards — harmless). + await fsPromises + .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) + .catch(() => undefined); log.info(`Installed agent plugin '${name}' at ${args.expectedSha.slice(0, 12)}`); return entry; } finally { @@ -1634,27 +1661,30 @@ export class AgentPluginInstallService { registryNames: Set ): Promise { const targetPath = this.targetPathFor(name); - // Only an ORPHAN (tree without registry entry) needs cleanup; a registry - // entry means the install committed and only the journal deletion was lost. - if (!registryNames.has(name) && (await pathExists(targetPath))) { + if (registryNames.has(name)) { + // The install committed and only the journal deletion was lost; sweep + // the marker the commit path would have removed. + await fsPromises + .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) + .catch(() => undefined); + return true; + } + // Only an ORPHAN (tree without registry entry) needs cleanup. + if (await pathExists(targetPath)) { // Verify the tree is the one WE promoted before deleting anything: the // user can delete the orphan while the app is stopped and place their // own unmanaged plugin at the same path (a supported use of the - // globally scanned container). The journal's dev/ino stamp survives the - // promote rename; a mismatch (or an unverifiable stamp) means our - // orphan is already gone, so consume the journal WITHOUT touching the + // globally scanned container). The marker nonce is non-reusable — + // unlike dev/ino, which the filesystem can hand right back to a + // recreated directory. A mismatch or missing marker means our orphan + // is already gone, so consume the journal WITHOUT touching the // replacement. - const journalDev = await this.readJournalField(journalPath, "treeDev"); - const journalIno = await this.readJournalField(journalPath, "treeIno"); - const currentStat = await fsPromises - .stat(targetPath, { bigint: true }) + const journalNonce = await this.readJournalField(journalPath, "nonce"); + const treeNonce = await fsPromises + .readFile(path.join(targetPath, PROMOTION_MARKER_FILE), "utf-8") .catch(() => undefined); const isPromotedTree = - journalDev !== undefined && - journalIno !== undefined && - currentStat !== undefined && - currentStat.dev.toString() === journalDev && - currentStat.ino.toString() === journalIno; + journalNonce !== undefined && treeNonce !== undefined && treeNonce === journalNonce; if (!isPromotedTree) { log.warn( "Skipping orphaned-promotion cleanup: the tree at the plugin path is not the promoted one", @@ -1819,12 +1849,13 @@ export class AgentPluginInstallService { // or predates recent journals) BEFORE discovery scans the container, so // an orphaned promotion never renders as an unmanaged row and interrupted // update/uninstall swaps are restored before their rows would look wrong. - await this.startupReconciliation; - await this.reconcileJournals().catch((error: unknown) => { - log.warn("Failed to reconcile plugin journals", { - error: getErrorMessage(error), - }); - }); + // Reassigning the state BEFORE awaiting lets a concurrent discovery gate + // wait on this fresh attempt (reconcileJournals serializes internally via + // runExclusive); a success here re-opens a previously suppressed + // container. + await this.reconciliationState; + this.reconciliationState = this.attemptReconcileJournals("section open"); + await this.reconciliationState; // Section open is the natural retry moment for override-prune tombstones // left by uninstalls whose workspaces were temporarily unreachable. From cb8dca3df2136beb53f369337e5ea8a8d48218f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 15:10:20 +0000 Subject: [PATCH 23/63] fix: address Codex review round 41 Disclose the stdio MCP working directory in the consent preview when it deviates from the plugin root: prepareStdioLaunch passes cwd to the runtime, so relative script/config resolution can point at writable persistent plugin data while the argv implies the reviewed tree. The loader defaults cwd to the plugin root, so only deviations are shown. --- .../agentPlugins/installService.test.ts | 26 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 19 +++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 7a9c1f71d8b..e8862854642 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -474,6 +474,32 @@ describe("AgentPluginInstallService", () => { expect(preview.mcpServers[0].summary).toContain("NODE_OPTIONS='--require=./payload.js'"); }); + test("consent preview discloses the stdio working directory", async () => { + // cwd changes relative script/config resolution (prepareStdioLaunch + // passes it to the runtime): `node server.js` under cwd=${PLUGIN_DATA} + // executes from WRITABLE persistent data, not the reviewed tree — the + // consent card must say so. + await fsPromises.writeFile( + path.join(remoteDir, "mcp.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + echo: { + type: "stdio", + command: "node", + args: ["server.js"], + cwd: "${PLUGIN_DATA}", + }, + }, + }) + ); + await commitAll(remoteDir, "cwd pointing at plugin data"); + + const preview = await service.preview({ input: remoteDir }); + const dataPath = getPluginDataPath(muxRoot, computePluginInstanceId(preview.targetPath)); + expect(preview.mcpServers[0].summary).toContain(`cwd: '${dataPath}'`); + }); + test("failed uninstall registry write restores plugin data over a recreated data dir", async () => { const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); const dataPath = getPluginDataPath(muxRoot, instanceId); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 098eaca7edb..51e77ac45ba 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1271,13 +1271,24 @@ export class AgentPluginInstallService { const envAssignments = Object.entries(info.env ?? {}) .filter(([key]) => key !== "PLUGIN_ROOT" && key !== "PLUGIN_DATA") .map(([key, value]) => `${key}=${shellQuote(rewrite(value))}`); + const details: string[] = []; + // cwd is execution-relevant too: prepareStdioLaunch passes it to the + // runtime, so `node server.js` resolves scripts/configs relative to + // it — including from WRITABLE persistent plugin data — and the argv + // alone would imply a different resolution (capabilitySurface treats + // cwd as consent-relevant for the same reason). The loader defaults + // cwd to the plugin root; only a DEVIATION from the reviewed tree + // root needs calling out. + if (info.cwd !== undefined && rewrite(info.cwd) !== finalTargetPath) { + details.push(`cwd: ${shellQuote(rewrite(info.cwd))}`); + } + if (envAssignments.length > 0) { + details.push(`env: ${envAssignments.join(" ")}`); + } result.push({ serverName: info.plugin.serverName, transport: "stdio", - summary: - envAssignments.length > 0 - ? `${commandLine} (env: ${envAssignments.join(" ")})` - : commandLine, + summary: details.length > 0 ? `${commandLine} (${details.join("; ")})` : commandLine, }); } else { result.push({ From fe409fafd3d7aa3dee36e51d68ce28db2aaa5381 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 15:32:00 +0000 Subject: [PATCH 24/63] fix: address Codex review round 42 - Repair MCPServerManager's in-memory override cache after uninstall prunes on-disk overrides (disk-then-memory, same order as dialog saves): latestWorkspaceOverrides wins over fresh file reads, so a stale cached enable could auto-start a same-name reinstall's default-disabled server; a failed repair keeps the retry tombstone - Stamp update swaps with the same nonce-marker identity as promotions: recovery completing on 'target exists' alone let the registry claim a user-placed unmanaged tree at the vacated path. Recovery now finishes cleanup when the target carries the journal nonce, restores when the target is missing, and retains the journal (pinning the staged original, blocking further updates) when the target is occupied by an unidentified tree - Reject repositories shipping the reserved .mux-promotion-marker name in validateStagedClone so the recovery nonce write can never clobber plugin-owned content --- .../agentPlugins/installService.test.ts | 125 ++++++++++++++++++ .../services/agentPlugins/installService.ts | 106 ++++++++++++--- 2 files changed, 215 insertions(+), 16 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index e8862854642..a98f5082b7d 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -886,6 +886,131 @@ describe("AgentPluginInstallService", () => { expect(await pathExists(dataTrashDir)).toBe(false); }); + test("uninstall repairs the MCP manager's override cache after pruning disk", async () => { + // MCPServerManager.latestWorkspaceOverrides wins over freshly read + // files: pruning only the on-disk overrides would leave the stale + // in-memory enable, letting a same-name reinstall's default-disabled + // server start without a fresh user action. + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const serverKey = `plugin:${instanceId}:echo`; + let storedOverrides: { enabledServers: string[] } = { enabledServers: [serverKey] }; + const overridesStub = { + prunePluginOverrideKeys: (_id: string, keyPrefix: string) => { + storedOverrides = { + enabledServers: storedOverrides.enabledServers.filter( + (key) => !key.startsWith(keyPrefix) + ), + }; + return Promise.resolve(); + }, + getOverridesForWorkspace: () => + Promise.resolve({ overrides: storedOverrides, revision: "r" }), + }; + const applied: Array<{ workspaceId: string; overrides: unknown }> = []; + const mcpStub = { + stopServersWithKeyPrefix: () => Promise.resolve(), + applyWorkspaceOverrides: (workspaceId: string, overrides: unknown) => { + applied.push({ workspaceId, overrides }); + return Promise.resolve(); + }, + }; + const serviceWithDeps = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + mcpServerManager: mcpStub as unknown as MCPServerManager, + }); + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([{ id: "ws-1", runtimeConfig: { type: "local" } }] as unknown as Awaited< + ReturnType + >) + ); + try { + const preview = await serviceWithDeps.preview({ input: remoteDir }); + await serviceWithDeps.install({ source: preview.source, expectedSha: preview.lockedSha }); + await serviceWithDeps.uninstall({ name: "demo-plugin", deletePluginData: false }); + } finally { + metadataSpy.mockRestore(); + } + // The manager cache received the PRUNED overrides (disk first, then memory). + expect(applied).toEqual([{ workspaceId: "ws-1", overrides: { enabledServers: [] } }]); + }); + + test("update recovery leaves a user-placed tree at the vacated path alone", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // Crash window: old tree staged, replacement never promoted — and the + // user created their OWN unmanaged plugin at the now-empty target while + // the app was stopped. It carries no marker matching the journal nonce, + // so recovery must not let the registry claim it (a later Update or + // Uninstall would overwrite/delete it); the journal stays, pinning the + // staged original. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.rename(targetPath, trashDir); + await fsPromises.mkdir(targetPath, { recursive: true }); + await fsPromises.writeFile( + path.join(targetPath, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "demo-plugin", version: "9" }) + ); + const journalPath = path.join(stagingDir(), "update-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ + name: "demo-plugin", + trashDir, + nonce: "the-swap-nonce", + stagedAt: Date.now(), + }) + ); + + await service.list(); + expect(await pathExists(targetPath)).toBe(true); + expect( + JSON.parse(await fsPromises.readFile(path.join(targetPath, "plugin.json"), "utf8")) + ).toMatchObject({ version: "9" }); + expect(await pathExists(trashDir)).toBe(true); + expect(await pathExists(journalPath)).toBe(true); + + // Further updates refuse while recovery is unresolved: a new journal + // would clobber the trashDir reference protecting the original. + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await commitAll(remoteDir, "new upstream"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/unfinished recovery/); + }); + + test("update recovery finishes cleanup when the promoted tree carries the nonce", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // Crash window: promote landed (marker still inside) but journal/trash + // cleanup was lost. Recovery must finish it, not misread the tree. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.mkdir(trashDir, { recursive: true }); + await fsPromises.writeFile(path.join(targetPath, ".mux-promotion-marker"), "swap-nonce"); + const journalPath = path.join(stagingDir(), "update-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", trashDir, nonce: "swap-nonce", stagedAt: Date.now() }) + ); + + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")?.managed).toBe(true); + expect(await pathExists(path.join(targetPath, ".mux-promotion-marker"))).toBe(false); + expect(await pathExists(trashDir)).toBe(false); + expect(await pathExists(journalPath)).toBe(false); + }); + + test("repositories shipping the reserved recovery marker name are rejected", async () => { + // install/update write a nonce file at this path pre-rename; a repo + // shipping it would get that file clobbered then deleted, making the + // installed tree differ from the consented commit. + await fsPromises.writeFile(path.join(remoteDir, ".mux-promotion-marker"), "shipped"); + await commitAll(remoteDir, "reserved marker name"); + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/reserved file name/); + }); + test("an update swap interrupted between rename and promote is restored", async () => { const preview = await service.preview({ input: remoteDir }); await service.install({ source: preview.source, expectedSha: preview.lockedSha }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 51e77ac45ba..bc5af0b0c3c 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -125,12 +125,14 @@ const JOURNAL_PREFIXES = [ ] as const; /** - * Marker file written into a staged tree just before its promote rename, - * holding the random nonce also recorded in the promotion journal. Orphan - * recovery deletes a tree only when the nonces match: this proves the tree is - * the one WE promoted. Filesystem identities (dev/ino) are NOT sufficient — - * deleting the orphan and recreating a directory at the same path can reuse - * the inode immediately. The marker is removed once the install commits. + * Marker file written into a staged tree just before a promote/swap rename, + * holding the random nonce also recorded in the promotion or update journal. + * Recovery touches a tree at the target path only when the nonces match: this + * proves the tree is the one WE moved there. Filesystem identities (dev/ino) + * are NOT sufficient — deleting the orphan and recreating a directory at the + * same path can reuse the inode immediately. The marker is removed once the + * mutation commits, and validateStagedClone rejects repositories shipping the + * reserved name so the write can never clobber plugin-owned content. */ const PROMOTION_MARKER_FILE = ".mux-promotion-marker"; @@ -1000,6 +1002,17 @@ export class AgentPluginInstallService { ); } + // The crash-recovery marker name is RESERVED: install/update write a + // nonce file at this path just before their promote rename, which would + // silently replace repository-shipped content (and the commit path then + // deletes it), leaving the installed tree different from the consented + // commit. Reject up front instead of corrupting the plugin. + if (await pathExists(path.join(stagedDir, PROMOTION_MARKER_FILE))) { + throw new Error( + `The repository contains a reserved file name (${PROMOTION_MARKER_FILE}) used by the installer's crash recovery. Remove or rename it upstream to install this plugin.` + ); + } + const { plugin, diagnostics } = await discoverAgentPluginAt({ pluginDir: stagedDir, scope: "global", @@ -1737,12 +1750,41 @@ export class AgentPluginInstallService { journalPath: string, registryNames: Set ): Promise { - if (await pathExists(this.targetPathFor(name))) { - // A live tree (old or new) means the swap never started or completed; - // a still-staged old tree is plain trash for reclamation. - return true; - } + const targetPath = this.targetPathFor(name); const trashDir = await this.readJournalStagedPath(journalPath, "trashDir"); + if (await pathExists(targetPath)) { + const journalNonce = await this.readJournalField(journalPath, "nonce"); + const treeNonce = await fsPromises + .readFile(path.join(targetPath, PROMOTION_MARKER_FILE), "utf-8") + .catch(() => undefined); + if (journalNonce !== undefined && treeNonce === journalNonce) { + // OUR promoted replacement landed and only the cleanup was lost: + // finish it (marker off the live tree, staged old tree is trash). + await fsPromises + .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) + .catch(() => undefined); + if (trashDir !== undefined) { + await this.removeDir(trashDir).catch(() => undefined); + } + return true; + } + if (trashDir === undefined || !(await pathExists(trashDir))) { + // Nothing recoverable is staged: the swap never moved the old tree + // (or it was already restored), so the live tree is the install. + return true; + } + // The target holds a tree WITHOUT our nonce while the old tree is + // still staged: a user created an unmanaged plugin at the then-empty + // target while the app was stopped. The registry would wrongly claim + // it (a later Update/Uninstall could overwrite or delete it) — keep + // the journal, which also pins the staged original against + // reclamation and blocks further updates until resolved. + log.warn( + "Update recovery found an unrecognized tree at the plugin path; keeping the staged original", + { name } + ); + return false; + } if (trashDir === undefined || !(await pathExists(trashDir))) { log.warn("Update swap journal has no recoverable tree", { name }); return true; @@ -1756,7 +1798,7 @@ export class AgentPluginInstallService { log.warn("Restoring plugin tree after an update swap interrupted by a crash", { name }); try { await fsPromises.mkdir(this.containerDir, { recursive: true }); - await fsPromises.rename(trashDir, this.targetPathFor(name)); + await fsPromises.rename(trashDir, targetPath); } catch (error) { log.warn("Failed to restore plugin tree from an interrupted update swap", { name, @@ -2284,6 +2326,19 @@ export class AgentPluginInstallService { // builds, throws on unreadable files (tombstone retry), and cannot // interleave with a dialog save (shared exclusive write queue). await overridesService.prunePluginOverrideKeys(workspaceId, serverKeyPrefix); + // Propagate the pruned state into MCPServerManager's in-memory + // override cache, in the same disk-then-memory order as dialog + // saves: latestWorkspaceOverrides wins over freshly read files, so a + // workspace that once enabled this plugin's server would otherwise + // keep serving the stale enable — and a same-name reinstall's + // default-disabled server could start without a fresh user action. + // A failure keeps the tombstone so cache repair is retried too. + if (this.deps.mcpServerManager) { + const { overrides } = await overridesService.getOverridesForWorkspace(workspaceId, { + mode: "strict", + }); + await this.deps.mcpServerManager.applyWorkspaceOverrides(workspaceId, overrides); + } } catch (error) { failedWorkspaceIds.push(workspaceId); log.warn("Failed to prune plugin MCP overrides for workspace", { @@ -2614,6 +2669,17 @@ export class AgentPluginInstallService { `'${entry.name}' is pinned to commit ${entry.lockedSha.slice(0, 12)}; uninstall and reinstall to change it.` ); } + // A retained journal means a previous swap's recovery is unfinished + // (e.g. the target was occupied by an unidentifiable tree). Refuse + // BEFORE cloning and comparing capabilities: a new journal would + // clobber the trashDir reference protecting the recoverable original, + // and the capability comparison would run against the wrong tree. + const updateJournalPath = this.journalPath(UPDATE_JOURNAL_PREFIX, entry.name); + if (await pathExists(updateJournalPath)) { + throw new Error( + `A previous update of '${entry.name}' has unfinished recovery. Open Settings → Plugins to let recovery complete, then try again.` + ); + } const resolved = await this.resolveRemoteRef( entry.source.url, @@ -2666,7 +2732,11 @@ export class AgentPluginInstallService { // handles can make the rename itself fail on Windows. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); - const updateJournalPath = this.journalPath(UPDATE_JOURNAL_PREFIX, entry.name); + // The nonce marker rides inside the staged tree through the promote + // rename, letting crash recovery tell OUR promoted tree from an + // unmanaged one a user placed at the then-empty target. + const updateNonce = randomBytes(16).toString("hex"); + await fsPromises.writeFile(path.join(stagedDir, PROMOTION_MARKER_FILE), updateNonce); if (hadOldTree) { // Journal the swap BEFORE the live tree moves: a crash between the // rename below and the promote would leave the registry recording @@ -2676,7 +2746,7 @@ export class AgentPluginInstallService { // additions. reconcileJournals restores the old tree on recovery. await fsPromises.writeFile( updateJournalPath, - JSON.stringify({ name: entry.name, trashDir, stagedAt: Date.now() }) + JSON.stringify({ name: entry.name, trashDir, nonce: updateNonce, stagedAt: Date.now() }) ); try { await this.renameIntoStaging(targetPath, trashDir); @@ -2707,9 +2777,13 @@ export class AgentPluginInstallService { } throw error; } + // The new tree is live: the marker did its crash-recovery job. + await fsPromises + .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) + .catch(() => undefined); if (hadOldTree) { - // The new tree is live: the journal's recovery job is done (the - // staged old tree is plain trash now). + // The journal's recovery job is done (the staged old tree is plain + // trash now). await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); // Best-effort: the trash dir sits under the staging root, where // stale-dir reclamation cleans up leftovers. From 0ffe0071f9465084f40800cd3d68528a40b92866 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 15:51:06 +0000 Subject: [PATCH 25/63] fix: address Codex review round 43 - Reject ANY directory entry (lstat, symlinks not followed) at the reserved .mux-promotion-marker path: a dangling symlink read as 'absent' by access-style checks, letting the nonce write follow the attacker-controlled target outside the staged tree - Gate uninstall on unresolved update journals: the occupied target may be a user-placed replacement, and uninstalling would delete it while orphaning the recoverable staged original - Serialize MCP manager cache publication with override writes: both setOverridesForWorkspace and prunePluginOverrideKeys now accept a publish hook executed INSIDE the exclusive write queue with the persisted overrides; the router's dialog save and the uninstall prune publish through it, so concurrent writers publish in write order - Consume update journals BEFORE removing the tree marker (success path and matching-nonce recovery): the reverse order could crash into a markerless target that recovery misclassifies as a user replacement, deadlocking future updates - Refuse update() for registry entries recording a source subpath (written by newer builds): this build clones only the repository root and would swap the subpath snapshot for an unrelated root tree --- src/node/orpc/router.ts | 13 +-- .../agentPlugins/installService.test.ts | 50 +++++++++- .../services/agentPlugins/installService.ts | 91 ++++++++++++++----- .../workspaceMcpOverridesService.test.ts | 67 ++++++++++++++ .../services/workspaceMcpOverridesService.ts | 32 ++++++- 5 files changed, 221 insertions(+), 32 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 5d4a3720137..126d5094b67 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -5824,14 +5824,15 @@ export const router = (authToken?: string) => { ); return new Set(Object.keys(servers).filter((key) => key.startsWith("plugin:"))); }), + // Prompt invocation can hit cached servers before the next + // stream recomputes enablement, so sync the manager's view — + // INSIDE the write queue, so a concurrent plugin-uninstall + // prune cannot interleave its own publication and leave the + // cache holding the older snapshot (in either direction). + publish: (persisted) => + context.mcpServerManager.applyWorkspaceOverrides(input.workspaceId, persisted), } ); - // Prompt invocation can hit cached servers before the next stream - // recomputes enablement, so sync the manager's view immediately. - await context.mcpServerManager.applyWorkspaceOverrides( - input.workspaceId, - input.overrides - ); return { success: true, data: undefined }; } catch (error) { const message = getErrorMessage(error); diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index a98f5082b7d..28125dc6a05 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -895,16 +895,20 @@ describe("AgentPluginInstallService", () => { const serverKey = `plugin:${instanceId}:echo`; let storedOverrides: { enabledServers: string[] } = { enabledServers: [serverKey] }; const overridesStub = { - prunePluginOverrideKeys: (_id: string, keyPrefix: string) => { + // Mirrors the real service's contract: publish runs with the pruned + // persisted overrides inside the same (stubbed) write step. + prunePluginOverrideKeys: async ( + _id: string, + keyPrefix: string, + options?: { publish?: (persisted: unknown) => Promise } + ) => { storedOverrides = { enabledServers: storedOverrides.enabledServers.filter( (key) => !key.startsWith(keyPrefix) ), }; - return Promise.resolve(); + await options?.publish?.(storedOverrides); }, - getOverridesForWorkspace: () => - Promise.resolve({ overrides: storedOverrides, revision: "r" }), }; const applied: Array<{ workspaceId: string; overrides: unknown }> = []; const mcpStub = { @@ -977,6 +981,14 @@ describe("AgentPluginInstallService", () => { await writePluginFixture(remoteDir, { version: "2.0.0" }); await commitAll(remoteDir, "new upstream"); await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/unfinished recovery/); + + // Uninstall refuses too: the occupied target may be the USER'S tree, and + // uninstalling would delete it and orphan the staged original (the next + // reconciliation would discard it once the registry entry is gone). + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/unfinished recovery/); + expect(await pathExists(targetPath)).toBe(true); }); test("update recovery finishes cleanup when the promoted tree carries the nonce", async () => { @@ -1009,6 +1021,36 @@ describe("AgentPluginInstallService", () => { await fsPromises.writeFile(path.join(remoteDir, ".mux-promotion-marker"), "shipped"); await commitAll(remoteDir, "reserved marker name"); await expect(service.preview({ input: remoteDir })).rejects.toThrow(/reserved file name/); + + // A DANGLING symlink at the same path must be rejected too: access-style + // existence checks follow it and report "absent", and the nonce write + // would then follow the attacker-controlled target OUTSIDE the staged + // tree (e.g. creating ../../plugins.json with nonce content). + await fsPromises.rm(path.join(remoteDir, ".mux-promotion-marker")); + await fsPromises.symlink("../../plugins.json", path.join(remoteDir, ".mux-promotion-marker")); + await commitAll(remoteDir, "dangling symlink at reserved marker path"); + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/reserved file name/); + }); + + test("update refuses subpath installs recorded by a newer build", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // A newer build recorded a monorepo subpath source (the schema preserves + // it for upgrade↔downgrade). This build clones only the repository ROOT, + // so updating would swap the installed subpath snapshot for an unrelated + // root tree while the registry keeps claiming the subpath source. + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: Array<{ source: Record }>; + }; + doc.plugins[0].source.subpath = "packages/inner-plugin"; + await fsPromises.writeFile(registryFile(), JSON.stringify(doc)); + + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await commitAll(remoteDir, "upstream moved"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /installed from a repository subpath/ + ); }); test("an update swap interrupted between rename and promote is restored", async () => { diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index bc5af0b0c3c..db1a74e4c63 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1006,8 +1006,20 @@ export class AgentPluginInstallService { // nonce file at this path just before their promote rename, which would // silently replace repository-shipped content (and the commit path then // deletes it), leaving the installed tree different from the consented - // commit. Reject up front instead of corrupting the plugin. - if (await pathExists(path.join(stagedDir, PROMOTION_MARKER_FILE))) { + // commit. Reject up front instead of corrupting the plugin. lstat, not + // access: a DANGLING symlink at this path reads as "absent" to + // access-style checks, and the later nonce writeFile would then follow + // the attacker-controlled target OUTSIDE the staged tree (e.g. creating + // ../../plugins.json with nonce content). + const markerEntry = await fsPromises + .lstat(path.join(stagedDir, PROMOTION_MARKER_FILE)) + .catch((error: unknown) => { + if (hasErrorCode(error, "ENOENT")) { + return undefined; + } + throw error; + }); + if (markerEntry !== undefined) { throw new Error( `The repository contains a reserved file name (${PROMOTION_MARKER_FILE}) used by the installer's crash recovery. Remove or rename it upstream to install this plugin.` ); @@ -1759,7 +1771,11 @@ export class AgentPluginInstallService { .catch(() => undefined); if (journalNonce !== undefined && treeNonce === journalNonce) { // OUR promoted replacement landed and only the cleanup was lost: - // finish it (marker off the live tree, staged old tree is trash). + // finish it. Journal FIRST (mirrors the update path): a crash after + // removing the marker but before the journal would strand a + // markerless target that the next recovery misclassifies as a user + // replacement, deadlocking updates. + await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); await fsPromises .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) .catch(() => undefined); @@ -2018,6 +2034,17 @@ export class AgentPluginInstallService { throw new Error(`'${args.name}' is not a managed plugin install.`); } + // An unresolved update journal means the tree at the target may be a + // USER-PLACED replacement (recovery refused to identify it), not the + // managed install: uninstalling would stage and delete the user's tree, + // and the next reconciliation would discard the recoverable original + // because its registry entry is gone. Refuse until recovery resolves. + if (await pathExists(this.journalPath(UPDATE_JOURNAL_PREFIX, args.name))) { + throw new Error( + `A previous update of '${args.name}' has unfinished recovery. Open Settings → Plugins to let recovery complete, then try again.` + ); + } + const targetPath = this.targetPathFor(entry.name); const instanceId = this.instanceIdFor(entry.name); const serverKeyPrefix = buildPluginServerKey(instanceId, ""); @@ -2325,20 +2352,27 @@ export class AgentPluginInstallService { // Raw in-queue patch: preserves unknown fields written by newer // builds, throws on unreadable files (tombstone retry), and cannot // interleave with a dialog save (shared exclusive write queue). - await overridesService.prunePluginOverrideKeys(workspaceId, serverKeyPrefix); - // Propagate the pruned state into MCPServerManager's in-memory - // override cache, in the same disk-then-memory order as dialog - // saves: latestWorkspaceOverrides wins over freshly read files, so a - // workspace that once enabled this plugin's server would otherwise - // keep serving the stale enable — and a same-name reinstall's - // default-disabled server could start without a fresh user action. - // A failure keeps the tombstone so cache repair is retried too. - if (this.deps.mcpServerManager) { - const { overrides } = await overridesService.getOverridesForWorkspace(workspaceId, { - mode: "strict", - }); - await this.deps.mcpServerManager.applyWorkspaceOverrides(workspaceId, overrides); - } + // + // The publish hook repairs MCPServerManager's in-memory override + // cache INSIDE that same write queue: latestWorkspaceOverrides wins + // over freshly read overrides, so a workspace that once enabled this + // plugin's server would otherwise keep serving the stale enable — and + // a same-name reinstall's default-disabled server could start without + // a fresh user action. In-queue publication also keeps the ordering + // consistent with concurrent dialog saves (whichever writes disk last + // publishes last). A failure keeps the tombstone so cache repair is + // retried too. + const mcpServerManager = this.deps.mcpServerManager; + await overridesService.prunePluginOverrideKeys( + workspaceId, + serverKeyPrefix, + mcpServerManager + ? { + publish: (persisted) => + mcpServerManager.applyWorkspaceOverrides(workspaceId, persisted), + } + : undefined + ); } catch (error) { failedWorkspaceIds.push(workspaceId); log.warn("Failed to prune plugin MCP overrides for workspace", { @@ -2669,6 +2703,16 @@ export class AgentPluginInstallService { `'${entry.name}' is pinned to commit ${entry.lockedSha.slice(0, 12)}; uninstall and reinstall to change it.` ); } + if (entry.source.subpath !== undefined) { + // The registry schema deliberately preserves subpath entries written + // by newer builds (upgrade↔downgrade), but this build clones and + // validates only the repository ROOT: updating would swap the + // installed subpath snapshot for an unrelated root tree while the + // registry keeps claiming the subpath source. + throw new Error( + `'${entry.name}' was installed from a repository subpath by a newer version of Mux; update it with that version.` + ); + } // A retained journal means a previous swap's recovery is unfinished // (e.g. the target was occupied by an unidentifiable tree). Refuse // BEFORE cloning and comparing capabilities: a new journal would @@ -2777,14 +2821,19 @@ export class AgentPluginInstallService { } throw error; } - // The new tree is live: the marker did its crash-recovery job. + // The new tree is live. Consume the JOURNAL before the marker: a + // crash between marker removal and journal removal would leave a + // markerless target with the old tree still staged, which recovery + // must treat as an unidentified user replacement — deadlocking future + // updates. Journal-first, a crash merely leaves a stray marker in the + // live tree (harmless; the next update swap discards it). + if (hadOldTree) { + await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); + } await fsPromises .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) .catch(() => undefined); if (hadOldTree) { - // The journal's recovery job is done (the staged old tree is plain - // trash now). - await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); // Best-effort: the trash dir sits under the staging root, where // stale-dir reclamation cleans up leftovers. await this.removeDir(trashDir).catch((error: unknown) => { diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index b967f3c3368..7fcf89c3015 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -348,6 +348,73 @@ describe("WorkspaceMcpOverridesService", () => { await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); }); + it("publish hooks run in write order with the persisted overrides", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + JSON.stringify({ enabledServers: ["plugin:abc:echo", "other-server"] }) + ); + 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); + + // In-memory caches (MCPServerManager) mirror these publications: they + // must observe the same order as the disk writes, or a plugin-uninstall + // prune racing a dialog save can leave the cache holding the older + // snapshot (in either direction). Both writers publish INSIDE the + // exclusive write queue, so concurrent launches publish in write order. + const published: Array<{ via: string; enabled: unknown }> = []; + await Promise.all([ + service.prunePluginOverrideKeys(workspaceId, "plugin:abc:", { + publish: (persisted) => { + published.push({ via: "prune", enabled: persisted.enabledServers }); + return Promise.resolve(); + }, + }), + service.setOverridesForWorkspace( + workspaceId, + { enabledServers: ["other-server", "third-server"] }, + { + publish: (persisted) => { + published.push({ via: "set", enabled: persisted.enabledServers }); + return Promise.resolve(); + }, + } + ), + ]); + + // Queue order: prune first (pruned snapshot), then the save (its own + // normalized payload). Each publication carries the state its write + // persisted, and the LAST publication matches the final disk state. + expect(published).toEqual([ + { via: "prune", enabled: ["other-server"] }, + { via: "set", enabled: ["other-server", "third-server"] }, + ]); + const finalState = JSON.parse(await fs.readFile(filePath, "utf-8")) as Record; + expect(finalState.enabledServers).toEqual(["other-server", "third-server"]); + }); + it("prunePluginOverrideKeys preserves JSONC comments and formatting", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index 7fde5aace7b..476e34e6f00 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -503,6 +503,14 @@ export class WorkspaceMcpOverridesService { current: WorkspaceMCPOverrides, incoming: WorkspaceMCPOverrides ) => Promise; + /** + * Called INSIDE the exclusive write queue after a successful write, + * with the normalized persisted overrides. Callers that mirror + * overrides into in-memory caches (MCPServerManager) must publish here: + * publishing after this method returns can interleave with a concurrent + * writer's publication and leave the cache holding the older snapshot. + */ + publish?: (persisted: WorkspaceMCPOverrides) => Promise; } ): Promise { assert(overrides && typeof overrides === "object", "overrides must be an object"); @@ -530,12 +538,14 @@ export class WorkspaceMcpOverridesService { if (isEmptyOverrides(normalized)) { await this.removeOverridesFile(runtime, workspacePath); + await options?.publish?.(normalized); 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 options?.publish?.(normalized); }); } @@ -554,7 +564,21 @@ export class WorkspaceMcpOverridesService { * only ever written to workspace-local files (legacy config.json storage * predates Agent Plugins). */ - async prunePluginOverrideKeys(workspaceId: string, keyPrefix: string): Promise { + async prunePluginOverrideKeys( + workspaceId: string, + keyPrefix: string, + options?: { + /** + * Called INSIDE the exclusive write queue after the prune, with the + * pruned normalized overrides re-read from disk. Same ordering contract + * as setOverridesForWorkspace's publish: in-memory caches must be + * updated here, not after this method returns, or a concurrent dialog + * save's publication can be overwritten by the stale pre-save snapshot + * (in either direction). + */ + publish?: (persisted: WorkspaceMCPOverrides) => Promise; + } + ): Promise { assert(keyPrefix.length > 0, "prunePluginOverrideKeys: keyPrefix must be non-empty"); return this.runExclusive(async () => { @@ -664,6 +688,12 @@ export class WorkspaceMcpOverridesService { await writeFileString(runtime, filePath, text); } } + if (options?.publish) { + // Strict re-read: the prune above already threw on anything + // unreadable, so a failure here is a real regression and must keep + // the caller's retry tombstone rather than publish a guess. + await options.publish(await this.loadOverrides(workspaceId, "strict")); + } }); } } From e4c6be66d3c4544dea59eeb56a2eb7f28af5bc6e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 16:08:17 +0000 Subject: [PATCH 26/63] fix: address Codex review round 44 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce journal-before-marker ordering: the update success path and the matching-nonce recovery branch now abort cleanup (retaining the marker as the tree's identity) when the journal cannot be deleted, instead of best-effort-removing the marker and stranding a markerless target that recovery would misclassify as a user replacement - Propagate unconsumed journals into reconciliation health: reconcileJournals returns whether every journal ended consumed, and attemptReconcileJournals records false for retained journals (failed restore/quarantine, unidentified target tree, failed journal delete), keeping the discovery gate closed over unreconciled state - Gate uninstall on unresolved uninstall journals: a second uninstall would unconditionally overwrite the journal — the only references to the original trashDir/dataTrashDir — orphaning recoverable assets --- .../agentPlugins/installService.test.ts | 84 ++++++++++++ .../services/agentPlugins/installService.ts | 124 +++++++++++++----- 2 files changed, 178 insertions(+), 30 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 28125dc6a05..31bbb435025 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -989,6 +989,90 @@ describe("AgentPluginInstallService", () => { service.uninstall({ name: "demo-plugin", deletePluginData: false }) ).rejects.toThrow(/unfinished recovery/); expect(await pathExists(targetPath)).toBe(true); + + // And the unconsumed journal keeps the discovery gate CLOSED: recovery + // "succeeding" while a journal is retained would scan the managed + // container over the unresolved collision. + const suppressed = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(suppressed.plugins).toEqual([]); + expect( + suppressed.diagnostics.some((diagnostic) => diagnostic.message.includes("crash recovery")) + ).toBe(true); + }); + + test("uninstall refuses while an uninstall journal awaits recovery", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // Post-crash state of an UNCOMMITTED uninstall (registry still owns the + // plugin, assets staged, journal retained because a restore failed). A + // second uninstall would overwrite the journal — the only references to + // the original trashDir/dataTrashDir — orphaning the recoverable assets. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.rename(targetPath, trashDir); + const journalPath = path.join(stagingDir(), "uninstall-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", trashDir, stagedAt: Date.now() }) + ); + + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/unfinished cleanup/); + // The journal still references the original staged tree. + expect( + (JSON.parse(await fsPromises.readFile(journalPath, "utf8")) as { trashDir: string }).trashDir + ).toBe(trashDir); + + // Recovery restores the tree; the uninstall then proceeds normally. + await service.list(); + expect(await pathExists(targetPath)).toBe(true); + await service.uninstall({ name: "demo-plugin", deletePluginData: false }); + expect(await registry()).toEqual([]); + }); + + test("update recovery keeps the tree marker when the journal cannot be deleted", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + const markerPath = path.join(targetPath, ".mux-promotion-marker"); + + // Promote landed (marker inside), cleanup lost. If deleting the journal + // fails transiently, cleanup must ABORT with the marker retained: a + // markerless target + surviving journal is exactly the state recovery + // misclassifies as a user replacement, deadlocking updates. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.mkdir(trashDir, { recursive: true }); + await fsPromises.writeFile(markerPath, "swap-nonce"); + const journalPath = path.join(stagingDir(), "update-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", trashDir, nonce: "swap-nonce", stagedAt: Date.now() }) + ); + + const realRm = fsPromises.rm; + const rmSpy = spyOn(fsPromises, "rm").mockImplementation((target, options) => { + if (String(target) === journalPath) { + return Promise.reject(new Error("EBUSY: journal locked")); + } + return realRm(target, options); + }); + try { + await service.list(); + expect(await pathExists(markerPath)).toBe(true); + expect(await pathExists(journalPath)).toBe(true); + expect(await pathExists(trashDir)).toBe(true); + } finally { + rmSpy.mockRestore(); + } + + // Once the journal deletes, cleanup completes and the plugin is intact. + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")?.managed).toBe(true); + expect(await pathExists(markerPath)).toBe(false); + expect(await pathExists(journalPath)).toBe(false); + expect(await pathExists(trashDir)).toBe(false); }); test("update recovery finishes cleanup when the promoted tree carries the nonce", async () => { diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index db1a74e4c63..11bf1c5f4b6 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -415,10 +415,20 @@ export class AgentPluginInstallService { ); } - /** Run reconcileJournals, mapping the outcome to a never-rejecting health flag. */ + /** + * Run reconcileJournals, mapping the outcome to a never-rejecting health + * flag: false when it threw (unreadable registry) OR when any journal was + * left unconsumed (failed restore/quarantine, unidentified target tree) — + * both mean the managed container may hold unreconciled state. + */ private attemptReconcileJournals(context: string): Promise { return this.reconcileJournals().then( - () => true, + (allConsumed) => { + if (!allConsumed) { + log.warn(`Plugin journal reconciliation left unresolved journals (${context})`); + } + return allConsumed; + }, (error: unknown) => { log.warn(`Plugin journal reconciliation failed (${context})`, { error: getErrorMessage(error), @@ -1640,19 +1650,19 @@ export class AgentPluginInstallService { * without ever opening the Plugins section) and again on section open, * under the mutation queue so it cannot interleave with a live mutation. */ - private async reconcileJournals(): Promise { + private async reconcileJournals(): Promise { let journalNames: string[]; try { journalNames = (await fsPromises.readdir(this.stagingRoot)).filter( (entry) => isJournalName(entry) && entry.endsWith(".json") ); } catch { - return; // No staging root: nothing was ever staged. + return true; // No staging root: nothing was ever staged. } if (journalNames.length === 0) { - return; + return true; } - await this.runExclusive(async () => { + return this.runExclusive(async () => { // STRICT read: a temporarily unreadable or corrupted registry must not // degrade to an empty entry list here — reconciliation would then treat // committed installs as orphans and delete their trees, turning a @@ -1663,6 +1673,13 @@ export class AgentPluginInstallService { .map((rawEntry) => this.rawEntryName(rawEntry)) .filter((name): name is string => name !== undefined) ); + // Health result: every journal must end CONSUMED (its recovery job + // done and the file gone). A retained journal — failed restore or + // quarantine, unidentified tree at the target, or even a failed + // journal deletion — means unreconciled state may still sit in the + // managed container, so the discovery gate must keep suppressing it; + // resolving successfully here would open the gate over that state. + let allConsumed = true; for (const journalName of journalNames) { const journalPath = path.join(this.stagingRoot, journalName); const prefix = JOURNAL_PREFIXES.find((candidate) => journalName.startsWith(candidate)); @@ -1678,10 +1695,21 @@ export class AgentPluginInstallService { : prefix === UPDATE_JOURNAL_PREFIX ? await this.recoverInterruptedUpdateSwap(name, journalPath, registryNames) : await this.recoverInterruptedUninstall(name, journalPath, registryNames); - if (consumed) { - await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + if (!consumed) { + allConsumed = false; + continue; + } + try { + await fsPromises.rm(journalPath, { force: true }); + } catch (error) { + allConsumed = false; + log.warn("Failed to delete a consumed plugin journal; will retry", { + journalPath, + error: getErrorMessage(error), + }); } } + return allConsumed; }); } @@ -1771,11 +1799,22 @@ export class AgentPluginInstallService { .catch(() => undefined); if (journalNonce !== undefined && treeNonce === journalNonce) { // OUR promoted replacement landed and only the cleanup was lost: - // finish it. Journal FIRST (mirrors the update path): a crash after - // removing the marker but before the journal would strand a + // finish it. Journal FIRST, and ENFORCED (mirrors the update path): + // removing the marker while the journal survives would strand a // markerless target that the next recovery misclassifies as a user - // replacement, deadlocking updates. - await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + // replacement, deadlocking updates — so a failed journal deletion + // must abort cleanup and keep the marker as the tree's identity. + try { + await fsPromises.rm(journalPath, { force: true }); + } catch (error) { + log.warn("Failed to delete the update journal; keeping the tree marker for retry", { + name, + error: getErrorMessage(error), + }); + return false; + } + // Journal gone: a stray marker or trash dir is harmless if these + // best-effort removals fail (nothing references them anymore). await fsPromises .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) .catch(() => undefined); @@ -2044,6 +2083,17 @@ export class AgentPluginInstallService { `A previous update of '${args.name}' has unfinished recovery. Open Settings → Plugins to let recovery complete, then try again.` ); } + // Same for an unresolved UNINSTALL journal (a previous uninstall's + // restore failed while the registry still owns the plugin): the + // unconditional journal write below would replace the only references + // to the original trashDir/dataTrashDir, orphaning the recoverable + // assets for stale reclamation while this retry commits against a + // missing or replaced target. + if (await pathExists(this.journalPath(UNINSTALL_JOURNAL_PREFIX, args.name))) { + throw new Error( + `A previous uninstall of '${args.name}' has unfinished cleanup. Open Settings → Plugins to let recovery complete, then try again.` + ); + } const targetPath = this.targetPathFor(entry.name); const instanceId = this.instanceIdFor(entry.name); @@ -2821,27 +2871,41 @@ export class AgentPluginInstallService { } throw error; } - // The new tree is live. Consume the JOURNAL before the marker: a - // crash between marker removal and journal removal would leave a - // markerless target with the old tree still staged, which recovery - // must treat as an unidentified user replacement — deadlocking future - // updates. Journal-first, a crash merely leaves a stray marker in the - // live tree (harmless; the next update swap discards it). + // The new tree is live. Consume the JOURNAL before the marker, and + // ENFORCE that ordering: a markerless target with the journal still + // present is exactly the state recovery must treat as an unidentified + // user replacement — deadlocking future updates. If the journal + // cannot be deleted, keep the marker (it is the tree's identity for + // the matching-nonce recovery branch, which retries this cleanup) and + // leave the staged old tree pinned by the journal. Journal-first, a + // crash merely leaves a stray marker in the live tree (harmless; the + // next update swap discards it). + let journalConsumed = true; if (hadOldTree) { - await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); + try { + await fsPromises.rm(updateJournalPath, { force: true }); + } catch (error) { + journalConsumed = false; + log.warn( + "Failed to delete the update journal; keeping the tree marker so recovery can finish", + { name: entry.name, error: getErrorMessage(error) } + ); + } } - await fsPromises - .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) - .catch(() => undefined); - 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), + if (journalConsumed) { + await fsPromises + .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) + .catch(() => undefined); + 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 = { From 182dca48eb8e8b9769f3f2f0f46102314f8812e7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 16:31:40 +0000 Subject: [PATCH 27/63] fix: address Codex review round 45 Prune Agent Plugin override keys from a workspace's override files during workspace removal, before the metadata is dropped: LocalRuntime removal preserves the checkout and its .mux/mcp.local.jsonc, but a removed workspace is invisible to the plugin uninstaller's pruning and tombstones, so a stale plugin: enable could silently re-activate a same-name reinstall's server when the directory is re-registered. Best-effort (removal must never brick on a corrupted overrides file); wired via a narrow setter from ServiceContainer. --- src/node/services/serviceContainer.ts | 6 ++ src/node/services/workspaceService.test.ts | 68 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 30 ++++++++++ 3 files changed, 104 insertions(+) diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index b25e2a743cd..d96b4c8ca9d 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -330,6 +330,12 @@ export class ServiceContainer { // Wire terminal service to workspace service for cleanup on removal this.workspaceService.setTerminalService(this.terminalService); this.workspaceService.setDesktopSessionManager(this.desktopSessionManager); + // Plugin override keys must be pruned from a workspace's override files + // during removal: a removed workspace is invisible to the Agent Plugin + // uninstaller's pruning, but a preserved LocalRuntime checkout keeps + // .mux/mcp.local.jsonc, and a stale enable there could re-activate a + // same-name reinstall's server when the directory is re-registered. + this.workspaceService.setWorkspaceMcpOverridesService(this.workspaceMcpOverridesService); // Editor service for opening workspaces in code editors this.editorService = new EditorService(config); this.updateService = new UpdateService(this.config); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 3e422a4ff17..28821e7720a 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -8663,6 +8663,74 @@ describe("WorkspaceService remove lifecycle coordination", () => { }); }); +describe("WorkspaceService remove prunes plugin override keys", () => { + // LocalRuntime removal preserves the checkout and its .mux/mcp.local.jsonc, + // but a removed workspace is invisible to the Agent Plugin uninstaller's + // pruning/tombstones: a surviving `plugin:` enable could silently + // re-activate a same-name reinstall's server when the directory is + // re-registered. Removal must strip plugin keys while metadata still + // resolves — and must never brick on a failing prune. + async function runRemoval( + prunePluginOverrideKeys: (workspaceId: string, keyPrefix: string) => Promise + ): Promise<{ result: Result; order: string[] }> { + const sessionRoot = await fsPromises.mkdtemp(path.join(tmpdir(), "ws-remove-prune-")); + const order: string[] = []; + try { + const workspaceService = createWorkspaceServiceForTest({ + config: { + srcDir: "/tmp/src", + getSessionDir: mock((id: string) => path.join(sessionRoot, id)), + removeWorkspace: mock(() => { + order.push("config-removed"); + return Promise.resolve(); + }), + findWorkspace: mock(() => null), + loadConfigOrDefault: mock(() => ({ projects: new Map() })), + } as unknown as Config, + aiService: createMockAIService({ + getWorkspaceMetadata: mock(() => + Promise.resolve({ + success: true as const, + data: { + id: "ws-1", + name: "branch", + projectPath: "/tmp/proj", + runtimeConfig: { type: "local" as const }, + }, + }) + ), + } as unknown as Partial), + }); + workspaceService.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + order.push(`pruned:${workspaceId}:${keyPrefix}`); + return prunePluginOverrideKeys(workspaceId, keyPrefix); + }, + }); + const result = await workspaceService.remove("ws-1", true); + return { result, order }; + } finally { + await fsPromises.rm(sessionRoot, { recursive: true, force: true }); + } + } + + test("prunes plugin: keys before the metadata is dropped", async () => { + const { result, order } = await runRemoval(() => Promise.resolve()); + expect(result.success).toBe(true); + // Pruning needs the metadata to resolve the workspace path, so it must + // run before config.removeWorkspace — and with the all-plugins prefix. + expect(order).toEqual(["pruned:ws-1:plugin:", "config-removed"]); + }); + + test("a failing prune never blocks removal", async () => { + const { result, order } = await runRemoval(() => + Promise.reject(new Error("EBUSY: overrides file locked")) + ); + expect(result.success).toBe(true); + expect(order).toEqual(["pruned:ws-1:plugin:", "config-removed"]); + }); +}); + describe("WorkspaceService remove timing rollup", () => { let historyService: HistoryService; let cleanupHistory: () => Promise; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index b24ef9cd167..310c33cd2c6 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2720,6 +2720,10 @@ export class WorkspaceService extends EventEmitter { private workspaceGoalService?: WorkspaceGoalService; /** Narrow DevTools cleanup surface; wired by coreServices when a DevToolsService exists. */ private devToolsService?: { removeWorkspaceData(workspaceId: string): Promise }; + /** Narrow overrides-cleanup surface; wired by ServiceContainer for plugin-key pruning on removal. */ + private workspaceMcpOverridesService?: { + prunePluginOverrideKeys(workspaceId: string, keyPrefix: string): Promise; + }; setTimelineRecorder(recorder: TimelineRecorder): void { this.timelineRecorder = recorder; @@ -2733,6 +2737,12 @@ export class WorkspaceService extends EventEmitter { this.mcpServerManager = manager; } + setWorkspaceMcpOverridesService(service: { + prunePluginOverrideKeys(workspaceId: string, keyPrefix: string): Promise; + }): void { + this.workspaceMcpOverridesService = service; + } + setWorkspaceGoalService(service: WorkspaceGoalService): void { this.workspaceGoalService = service; } @@ -5313,6 +5323,26 @@ export class WorkspaceService extends EventEmitter { await this.mcpServerManager.stopServers(workspaceId); } + // Strip Agent Plugin override keys from the workspace's override files + // BEFORE the metadata is dropped (pruning needs it to resolve the + // workspace path). LocalRuntime removal preserves the checkout — and + // its .mux/mcp.local.jsonc — but a removed workspace is invisible to + // the plugin uninstaller's pruning/tombstones, so a surviving + // `plugin:` enable could silently re-enable a same-name reinstall's + // server when the directory is re-registered later. Best-effort: + // removal must never brick on a corrupted overrides file (worktree + // removals delete the checkout anyway, and a missing file is a no-op). + if (this.workspaceMcpOverridesService) { + try { + await this.workspaceMcpOverridesService.prunePluginOverrideKeys(workspaceId, "plugin:"); + } catch (error) { + log.warn("Failed to prune plugin override keys during workspace removal", { + workspaceId, + error: getErrorMessage(error), + }); + } + } + // Close any terminal sessions for this workspace this.terminalService?.closeWorkspaceSessions(workspaceId); await this.closeDesktopSessionBestEffort(workspaceId, "remove"); From d9ef467eb2a8a2877dc0215a1cde1f1736939faa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 16:44:56 +0000 Subject: [PATCH 28/63] fix: address Codex review round 46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract journal prefixes into agentPlugins/journals.ts and make the DEFAULT discovery gate journal-derived: headless processes (mux workflow resolving plugin:// scripts) never construct AgentPluginInstallService, so discovery now suppresses any container whose sibling plugin-staging root holds recovery journals — they cannot run recovery, so the container stays suppressed until a desktop/server session reconciles it. The install service still replaces the gate with its health-tracked variant at construction - Fail closed when the staging root cannot be ENUMERATED: only ENOENT reports reconciliation healthy; transient I/O/permission errors keep the discovery gate suppressing the managed container - Retain the promotion journal when install rollback can neither delete nor quarantine the promoted tree (and mark reconciliation unhealthy immediately): consuming it left a discoverable unmanaged orphan that permanently blocked reinstalls; recovery now quarantines by nonce once the lock clears - Block update() while an uninstall journal is unresolved: a skills-only plugin's empty capability surface would let the update promote a replacement that permanently deadlocks uninstall recovery --- src/node/services/agentPlugins/discovery.ts | 53 ++++--- .../agentPlugins/installService.test.ts | 131 +++++++++++++++++- .../services/agentPlugins/installService.ts | 126 ++++++++++------- src/node/services/agentPlugins/journals.ts | 51 +++++++ 4 files changed, 296 insertions(+), 65 deletions(-) create mode 100644 src/node/services/agentPlugins/journals.ts diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 0a03a818fc4..9d45c257803 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -5,6 +5,7 @@ import * as path from "node:path"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { containerHasUnreconciledJournals } from "./journals"; import { isValidAgentPluginName, validatePluginManifest, @@ -361,23 +362,41 @@ export async function discoverAgentPluginAt(args: { } /** - * Crash-recovery gate for container scans. AgentPluginInstallService installs - * a gate here so no discovery path (MCP config, hooks, skills, workflows, - * agents — they all funnel through discoverAgentPlugins) can scan the managed - * container while journal recovery is still restoring or removing trees: an - * agent request arriving right after a crash would otherwise load an orphaned - * promotion — hook included — before cleanup ran. The gate resolves to the - * container paths that must be SUPPRESSED from the scan: when recovery - * FAILED (unreadable registry, failed restore/quarantine), merely waiting - * would release discovery over the unreconciled tree, so the managed - * container is omitted until a later recovery attempt succeeds. The returned - * promise must never reject (the service catches); the default gate - * suppresses nothing so tests and contexts without the install service are - * unaffected. + * Crash-recovery gate for container scans, so no discovery path (MCP config, + * hooks, skills, workflows, agents — they all funnel through + * discoverAgentPlugins) can scan the managed container while install-mutation + * journal recovery is pending or failed: an agent request arriving right + * after a crash would otherwise load an orphaned promotion — hook included — + * before cleanup ran. The gate receives the container paths being scanned and + * resolves to those that must be SUPPRESSED; it must never reject. + * + * The DEFAULT gate derives suppression directly from surviving journal files + * in each container's sibling staging root: processes that never construct + * AgentPluginInstallService (headless `mux workflow` resolving plugin:// + * scripts) must not execute an unreconciled managed tree either. They never + * RUN recovery, so a journal keeps the managed container suppressed until a + * desktop/server session reconciles it. AgentPluginInstallService replaces + * this with its health-tracked gate at construction: when recovery FAILED + * (unreadable registry, failed restore/quarantine), merely waiting would + * release discovery over the unreconciled tree, so the managed container + * stays omitted until a later recovery attempt succeeds. */ -let discoveryGate: () => Promise = () => Promise.resolve([]); +export function journalDerivedDiscoveryGate( + containerPaths: readonly string[] +): Promise { + return Promise.all( + containerPaths.map(async (containerPath) => + (await containerHasUnreconciledJournals(containerPath)) ? [containerPath] : [] + ) + ).then((nested) => nested.flat()); +} + +let discoveryGate: (containerPaths: readonly string[]) => Promise = + journalDerivedDiscoveryGate; -export function setAgentPluginDiscoveryGate(gate: () => Promise): void { +export function setAgentPluginDiscoveryGate( + gate: (containerPaths: readonly string[]) => Promise +): void { discoveryGate = gate; } @@ -392,7 +411,9 @@ export function setAgentPluginDiscoveryGate(gate: () => Promise { - const suppressedContainers = new Set(await discoveryGate()); + const suppressedContainers = new Set( + await discoveryGate(containers.map((container) => container.path)) + ); const plugins: AgentPluginInfo[] = []; const diagnostics: AgentPluginDiagnostic[] = []; diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 31bbb435025..1f29ffd2566 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -8,7 +8,11 @@ import { Config } from "@/node/config"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { execFileAsync } from "@/node/utils/disposableExec"; -import { discoverAgentPlugins } from "./discovery"; +import { + discoverAgentPlugins, + journalDerivedDiscoveryGate, + setAgentPluginDiscoveryGate, +} from "./discovery"; import { AgentPluginInstallService, withDiskQuotaWatchdog } from "./installService"; import { AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, @@ -1025,6 +1029,14 @@ describe("AgentPluginInstallService", () => { (JSON.parse(await fsPromises.readFile(journalPath, "utf8")) as { trashDir: string }).trashDir ).toBe(trashDir); + // Update is a third same-name mutation path and must refuse too: a + // skills-only plugin has an empty capability surface, so the missing + // target would not stop it — it would promote a replacement that + // permanently deadlocks uninstall recovery on the occupied target. + await writePluginFixture(remoteDir, { version: "3.0.0" }); + await commitAll(remoteDir, "upstream moved during unresolved uninstall"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/unfinished cleanup/); + // Recovery restores the tree; the uninstall then proceeds normally. await service.list(); expect(await pathExists(targetPath)).toBe(true); @@ -1032,6 +1044,123 @@ describe("AgentPluginInstallService", () => { expect(await registry()).toEqual([]); }); + test("install rollback retains the journal when the tree cannot be removed or quarantined", async () => { + const preview = await service.preview({ input: remoteDir }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + + // Registry write fails AND the promoted tree can be neither deleted nor + // renamed into staging (e.g. a lock held by an external process): the + // journal must SURVIVE as the recovery record — consuming it would leave + // a discoverable unmanaged orphan that permanently blocks reinstalls. + const internals = service as unknown as { + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; + removeDir: (dir: string) => Promise; + }; + const realRemoveDir = internals.removeDir.bind(internals); + const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(() => + Promise.reject(new Error("ENOSPC: no space left on device")) + ); + const removeSpy = spyOn(internals, "removeDir").mockImplementation((dir: string) => + dir === targetPath ? Promise.reject(new Error("EBUSY: resource busy")) : realRemoveDir(dir) + ); + const realRename = fsPromises.rename; + const renameSpy = spyOn(fsPromises, "rename").mockImplementation((from, to) => { + if (String(from) === targetPath && String(to).includes("trash-")) { + return Promise.reject(new Error("EBUSY: resource busy")); + } + return realRename(from, to); + }); + try { + await expect( + service.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/cleaned up automatically/); + } finally { + writeSpy.mockRestore(); + removeSpy.mockRestore(); + renameSpy.mockRestore(); + } + + // Journal retained, tree still present (with its marker), and the + // discovery gate is closed IMMEDIATELY — not just after the next + // reconciliation run. + const journalPath = path.join(stagingDir(), "promotion-demo-plugin.json"); + expect(await pathExists(journalPath)).toBe(true); + expect(await pathExists(targetPath)).toBe(true); + const suppressed = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(suppressed.plugins).toEqual([]); + + // Once the lock clears, reconciliation identifies the orphan by nonce, + // quarantines it, and the name becomes reinstallable. + await service.list(); + expect(await pathExists(journalPath)).toBe(false); + expect(await pathExists(targetPath)).toBe(false); + const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + expect(entry.name).toBe("demo-plugin"); + }); + + test("an unenumerable staging root keeps the discovery gate closed", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // The staging root EXISTS but cannot be read (transient I/O/permissions): + // "cannot tell whether journals exist" must fail closed — an orphaned or + // half-swapped tree may still have a journal in there. + const realReaddir = fsPromises.readdir.bind(fsPromises) as ( + ...args: unknown[] + ) => Promise; + const readdirSpy = spyOn(fsPromises, "readdir").mockImplementation(((...args: unknown[]) => { + if (String(args[0]) === stagingDir()) { + return Promise.reject(new Error("EIO: input/output error")); + } + return realReaddir(...args); + }) as typeof fsPromises.readdir); + try { + const freshService = new AgentPluginInstallService(config, { isEnabled: () => true }); + await (freshService as unknown as { reconciliationState: Promise }) + .reconciliationState; + const suppressed = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(suppressed.plugins).toEqual([]); + expect( + suppressed.diagnostics.some((diagnostic) => diagnostic.message.includes("crash recovery")) + ).toBe(true); + } finally { + readdirSpy.mockRestore(); + } + }); + + test("headless processes suppress journaled containers via the default gate", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // A desktop crash left an update journal; a separate headless process + // (`mux workflow` resolving plugin:// scripts) never constructs + // AgentPluginInstallService, so the DEFAULT gate must derive suppression + // from the journal file in the container's sibling staging root. + await fsPromises.mkdir(stagingDir(), { recursive: true }); + const journalPath = path.join(stagingDir(), "update-demo-plugin.json"); + await fsPromises.writeFile( + journalPath, + JSON.stringify({ name: "demo-plugin", stagedAt: Date.now() }) + ); + setAgentPluginDiscoveryGate(journalDerivedDiscoveryGate); + try { + const suppressed = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(suppressed.plugins).toEqual([]); + expect( + suppressed.diagnostics.some((diagnostic) => diagnostic.message.includes("crash recovery")) + ).toBe(true); + + // Without journals the default gate suppresses nothing. + await fsPromises.rm(journalPath); + const reopened = await discoverAgentPlugins([{ path: pluginsDir(), scope: "global" }]); + expect(reopened.plugins.map((plugin) => plugin.dirName)).toEqual(["demo-plugin"]); + } finally { + // The next test's beforeEach constructs a fresh service, which + // re-installs the health-tracked gate. + setAgentPluginDiscoveryGate(journalDerivedDiscoveryGate); + } + }); + test("update recovery keeps the tree marker when the journal cannot be deleted", async () => { const preview = await service.preview({ input: remoteDir }); await service.install({ source: preview.source, expectedSha: preview.lockedSha }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 11bf1c5f4b6..25a681f8f2b 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -39,6 +39,14 @@ import { type AgentPluginContainer, type AgentPluginInfo, } from "./discovery"; +import { + isJournalName, + JOURNAL_PREFIXES, + PROMOTION_JOURNAL_PREFIX, + STAGING_DIR_NAME, + UNINSTALL_JOURNAL_PREFIX, + UPDATE_JOURNAL_PREFIX, +} from "./journals"; import type { AgentPluginManifest } from "./manifest"; import { buildPluginServerKey, @@ -85,44 +93,22 @@ import { /** 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"; - -/** - * Journal file recording an install promotion that has renamed the staged - * tree into the container but not yet written its registry entry. A crash in - * that window would otherwise strand an orphaned tree that discovery lists as - * unmanaged, assertNoCollision blocks from reinstalling, and uninstall - * refuses (not managed) — recoverable only by manual deletion. - * reconcileJournals cleans such trees up on startup and on section open. - */ -const PROMOTION_JOURNAL_PREFIX = "promotion-"; - -/** - * Journal recording an update swap that has renamed the OLD live tree into - * staging but not yet promoted the staged replacement. A crash in that window - * leaves the registry recording an install whose path is missing — and - * retrying Update cannot self-heal because assertNoCapabilityIncrease treats - * the missing tree as an empty surface and rejects the staged capabilities as - * additions. reconcileJournals restores the old tree from staging. - */ -const UPDATE_JOURNAL_PREFIX = "update-"; - -/** - * Journal recording an uninstall that has staged the plugin tree (and - * optionally its data dir) into staging but not yet committed the registry - * write. A crash in that window hides the assets under plugin-staging while - * the registry still owns the plugin. reconcileJournals restores the staged - * assets when the registry entry still exists, and finishes the trash cleanup - * when the commit landed. +/* + * Journal semantics (prefixes and helpers live in ./journals so discovery can + * derive suppression without an import cycle): + * - PROMOTION: an install renamed the staged tree into the container but has + * not yet written its registry entry. A crash in that window would strand + * an orphaned tree that discovery lists as unmanaged, assertNoCollision + * blocks, and uninstall refuses — recoverable only by manual deletion. + * - UPDATE: the OLD live tree moved into staging but the staged replacement + * is not yet promoted. The registry then records an install whose path is + * missing, and retrying Update cannot self-heal because + * assertNoCapabilityIncrease treats the missing tree as an empty surface. + * - UNINSTALL: the plugin tree (and optionally its data dir) is staged into + * trash but the registry write has not committed; the assets would hide + * under plugin-staging while the registry still owns the plugin. + * reconcileJournals resolves all three on startup and on section open. */ -const UNINSTALL_JOURNAL_PREFIX = "uninstall-"; - -const JOURNAL_PREFIXES = [ - PROMOTION_JOURNAL_PREFIX, - UPDATE_JOURNAL_PREFIX, - UNINSTALL_JOURNAL_PREFIX, -] as const; /** * Marker file written into a staged tree just before a promote/swap rename, @@ -136,10 +122,6 @@ const JOURNAL_PREFIXES = [ */ const PROMOTION_MARKER_FILE = ".mux-promotion-marker"; -function isJournalName(entry: string): boolean { - return JOURNAL_PREFIXES.some((prefix) => entry.startsWith(prefix)); -} - /** Staging dirs left behind by crashes are reclaimed after this age. */ const STALE_STAGING_MAX_AGE_MS = 60 * 60 * 1000; @@ -415,6 +397,17 @@ export class AgentPluginInstallService { ); } + /** + * Immediately mark reconciliation unhealthy for the discovery gate. Called + * when an INLINE mutation path retains a journal for an unremovable tree in + * the managed container: the next reconcileJournals run would report the + * retained journal anyway, but the current process's health snapshot is + * stale until then, and discovery must not load the orphan in the interim. + */ + private markUnreconciled(): void { + this.reconciliationState = Promise.resolve(false); + } + /** * Run reconcileJournals, mapping the outcome to a never-rejecting health * flag: false when it threw (unreadable registry) OR when any journal was @@ -1525,6 +1518,7 @@ export class AgentPluginInstallService { // isolated so a failure (e.g. a locked file on Windows) cannot // skip the others or mask the registry error. const cleanupNotes: string[] = []; + let promotedTreeHandled = true; let treeRemoved = false; try { await this.removeDir(targetPath); @@ -1561,8 +1555,14 @@ export class AgentPluginInstallService { await this.renameIntoStaging(targetPath, quarantineDir); await this.removeDir(quarantineDir).catch(() => undefined); } catch (cleanupError) { + // The tree is stuck in the container (marker still inside): + // the journal must SURVIVE as the recovery record — the next + // reconciliation identifies the orphan by nonce and retries + // the quarantine once the lock clears; without it the failed + // install permanently blocks reinstalls via assertNoCollision. + promotedTreeHandled = false; cleanupNotes.push( - `the promoted plugin tree could not be removed — delete ${shortenHome(targetPath)} manually (${getErrorMessage(cleanupError)})` + `the promoted plugin tree could not be removed — it will be cleaned up automatically, or delete ${shortenHome(targetPath)} manually (${getErrorMessage(cleanupError)})` ); } } @@ -1581,15 +1581,24 @@ export class AgentPluginInstallService { ); } } + if (!promotedTreeHandled) { + // Keep the discovery gate closed NOW: the orphan is discoverable + // in the container until reconciliation quarantines it, and the + // current process's health snapshot predates this failure. + this.markUnreconciled(); + } else { + // Rollback handled the tree: the journal's crash-recovery job is + // done. + await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + } const notes = cleanupNotes.length > 0 ? ` Additionally, ${cleanupNotes.join("; ")}.` : ""; throw new Error( `Failed to persist the plugin registry: ${getErrorMessage(error)}${notes}` ); - } finally { - // Registry write settled (entry recorded, or the rollback above - // handled the tree): the journal's crash-recovery job is done. - await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); } + // Registry write committed (entry recorded): the journal's + // crash-recovery job is done. + await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); // Committed: the marker did its crash-recovery job (a failed removal // leaves a stray dotfile the next update swap discards — harmless). await fsPromises @@ -1656,8 +1665,18 @@ export class AgentPluginInstallService { journalNames = (await fsPromises.readdir(this.stagingRoot)).filter( (entry) => isJournalName(entry) && entry.endsWith(".json") ); - } catch { - return true; // No staging root: nothing was ever staged. + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return true; // No staging root: nothing was ever staged. + } + // A staging root we cannot ENUMERATE (transient I/O, permissions) may + // hold journals for unreconciled trees; reporting healthy here would + // open the discovery gate over them. Fail closed and retry later. + log.warn("Failed to enumerate the plugin staging root for journal recovery", { + stagingRoot: this.stagingRoot, + error: getErrorMessage(error), + }); + return false; } if (journalNames.length === 0) { return true; @@ -2774,6 +2793,17 @@ export class AgentPluginInstallService { `A previous update of '${entry.name}' has unfinished recovery. Open Settings → Plugins to let recovery complete, then try again.` ); } + // Same for an unresolved UNINSTALL journal (the registry still owns the + // plugin while its tree sits in staging): a skills-only plugin has an + // empty capability surface, so the missing target would NOT stop this + // update — it would promote a replacement, after which uninstall + // recovery sees the occupied target, keeps its journal forever, and the + // whole managed container stays suppressed. + if (await pathExists(this.journalPath(UNINSTALL_JOURNAL_PREFIX, entry.name))) { + throw new Error( + `A previous uninstall of '${entry.name}' has unfinished cleanup. Open Settings → Plugins to let recovery complete, then try again.` + ); + } const resolved = await this.resolveRemoteRef( entry.source.url, diff --git a/src/node/services/agentPlugins/journals.ts b/src/node/services/agentPlugins/journals.ts new file mode 100644 index 00000000000..b80eb3ce816 --- /dev/null +++ b/src/node/services/agentPlugins/journals.ts @@ -0,0 +1,51 @@ +/** + * Shared crash-recovery journal vocabulary for managed Agent Plugin installs. + * + * AgentPluginInstallService writes a journal file into the staging root + * (`/plugin-staging`, a SIBLING of the managed `plugins` container) + * before every directory move of an install/update/uninstall, and consumes it + * only when the mutation's cleanup fully lands. A surviving journal therefore + * means the managed container may hold unreconciled state (an orphaned + * promotion, a half-swapped update, a staged-away uninstall). + * + * This lives outside installService.ts so discovery.ts can derive + * journal-based suppression for processes that never construct the install + * service (headless `mux workflow` resolving plugin:// scripts) without an + * import cycle: installService imports discovery for container scans. + */ +import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; + +/** Staging dir name under the mux home dir — NOT under ~/.mux/plugins, which discovery scans. */ +export const STAGING_DIR_NAME = "plugin-staging"; + +export const PROMOTION_JOURNAL_PREFIX = "promotion-"; +export const UPDATE_JOURNAL_PREFIX = "update-"; +export const UNINSTALL_JOURNAL_PREFIX = "uninstall-"; + +export const JOURNAL_PREFIXES = [ + PROMOTION_JOURNAL_PREFIX, + UPDATE_JOURNAL_PREFIX, + UNINSTALL_JOURNAL_PREFIX, +] as const; + +export function isJournalName(entry: string): boolean { + return JOURNAL_PREFIXES.some((prefix) => entry.startsWith(prefix)); +} + +/** + * Whether the staging root SIBLING of the given container holds any recovery + * journals. Fail-closed: an unreadable staging root (non-ENOENT) reports + * true, because "cannot tell" must not release discovery over a container + * that may hold unreconciled trees. + */ +export async function containerHasUnreconciledJournals(containerPath: string): Promise { + const stagingRoot = path.join(path.dirname(containerPath), STAGING_DIR_NAME); + try { + return (await fsPromises.readdir(stagingRoot)).some( + (entry) => isJournalName(entry) && entry.endsWith(".json") + ); + } catch (error) { + return !(error instanceof Error && "code" in error && error.code === "ENOENT"); + } +} From d7e9b1ca7d691f8c2d298be50132d4870478f336 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 17:09:32 +0000 Subject: [PATCH 29/63] fix: address Codex review round 47 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace removal-time plugin-override pruning with registration-time sanitization, eliminating the whole class of removal-side hazards: - Sibling safety: sanitization skips when another live workspace resolves to the same checkout path (local-runtime conversation forks share .mux/mcp.local.jsonc), so a fork's removal can never strip enables the surviving workspace still uses — and nothing edits shared files at removal time anymore - Durable retry: consent dies with the workspace, enforced at the moment the risk materializes — registering the directory as a NEW local workspace. A failed sanitize aborts the creation (config entry rolled back, nothing announced), so no silent-activation state can outlive an unreachable cleanup - No dialog race: nothing prunes at removal, and no Workspace MCP dialog can target a workspace that has not been announced yet - Canonical matching: prunePluginOverrideKeys now strips only canonical plugin:<16-hex instanceId>: keys, never a user-defined server that happens to be named plugin:… (MCP names are arbitrary strings) --- src/node/services/agentPlugins/mcpConfig.ts | 12 ++ .../workspaceMcpOverridesService.test.ts | 139 ++++++++++++------ .../services/workspaceMcpOverridesService.ts | 14 +- src/node/services/workspaceService.test.ts | 131 +++++++++-------- src/node/services/workspaceService.ts | 97 +++++++++--- 5 files changed, 266 insertions(+), 127 deletions(-) diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 89bfbb91556..f90eab6073a 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -74,6 +74,18 @@ export function isCanonicalPluginServerKeyPrefix(prefix: string): boolean { return CANONICAL_PLUGIN_KEY_PREFIX_PATTERN.test(prefix); } +/** + * Whether a FULL override key has the canonical managed-plugin shape + * `plugin:<16-hex instanceId>:`. MCP server names are otherwise + * arbitrary user strings (a user-defined server may legitimately be named + * "plugin:custom"), so plugin-key pruning must match only this shape. + */ +const CANONICAL_PLUGIN_KEY_PATTERN = /^plugin:[0-9a-f]{16}:/; + +export function isCanonicalPluginServerKey(key: string): boolean { + return CANONICAL_PLUGIN_KEY_PATTERN.test(key); +} + /** * Plugin keys PER FIELD, not collapsed into one set: a stale key that only * survives in toolAllowlist (e.g. a removed unmanaged dir's old tool diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index 7fcf89c3015..ed59cb8b9ef 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -203,7 +203,7 @@ describe("WorkspaceMcpOverridesService", () => { const service = new WorkspaceMcpOverridesService(config); await service.setOverridesForWorkspace(workspaceId, { - enabledServers: ["plugin:abc:server"], + enabledServers: ["plugin:0123456789abcdef:server"], }); // Dialog snapshot taken here... @@ -253,7 +253,7 @@ describe("WorkspaceMcpOverridesService", () => { // never read, resurrecting stale enabledServers on reinstall. await fs.writeFile( path.join(workspacePath, ".mux", "mcp.local.jsonc"), - '{ "enabledServers": ["plugin:abc:echo"' + '{ "enabledServers": ["plugin:0123456789abcdef:echo"' ); await config.editConfig((cfg) => { @@ -304,9 +304,9 @@ describe("WorkspaceMcpOverridesService", () => { filePath, JSON.stringify({ futureField: { keep: "me" }, - enabledServers: ["plugin:abc:echo", "other-server"], - disabledServers: ["plugin:abc:beta"], - toolAllowlist: { "plugin:abc:echo": ["t1"], "other-server": ["t2"] }, + enabledServers: ["plugin:0123456789abcdef:echo", "other-server"], + disabledServers: ["plugin:0123456789abcdef:beta"], + toolAllowlist: { "plugin:0123456789abcdef:echo": ["t1"], "other-server": ["t2"] }, }) ); @@ -325,7 +325,7 @@ describe("WorkspaceMcpOverridesService", () => { }); const service = new WorkspaceMcpOverridesService(config); - await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + await service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:"); const after = JSON.parse(await fs.readFile(filePath, "utf-8")) as Record; expect(after).toEqual({ @@ -338,14 +338,62 @@ describe("WorkspaceMcpOverridesService", () => { // Unreadable content must throw (callers keep their retry tombstones). await fs.writeFile(filePath, "{ not json"); // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void - await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( - /parse errors/ - ); + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/parse errors/); // A missing file is nothing to prune (plugin keys only ever live in // workspace-local files). await fs.rm(filePath); - await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + await service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:"); + }); + + it("prunePluginOverrideKeys matches only canonical plugin keys", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + // MCP server names are arbitrary user strings: a user-defined server may + // legitimately be named "plugin:custom". Only canonical + // plugin:<16-hex instanceId>: keys are plugin-owned; a broad + // "plugin:" prune (registration-time sanitization) must leave the + // ordinary server's enables and allowlists intact. + await fs.writeFile( + filePath, + JSON.stringify({ + enabledServers: ["plugin:0123456789abcdef:echo", "plugin:custom", "other"], + toolAllowlist: { "plugin:0123456789abcdef:echo": ["t1"], "plugin:custom": ["t2"] }, + }) + ); + 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.prunePluginOverrideKeys(workspaceId, "plugin:"); + + const after = JSON.parse(await fs.readFile(filePath, "utf-8")) as Record; + expect(after).toEqual({ + enabledServers: ["plugin:custom", "other"], + toolAllowlist: { "plugin:custom": ["t2"] }, + }); }); it("publish hooks run in write order with the persisted overrides", async () => { @@ -362,7 +410,7 @@ describe("WorkspaceMcpOverridesService", () => { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile( filePath, - JSON.stringify({ enabledServers: ["plugin:abc:echo", "other-server"] }) + JSON.stringify({ enabledServers: ["plugin:0123456789abcdef:echo", "other-server"] }) ); await config.editConfig((cfg) => { cfg.projects.set(projectPath, { @@ -386,7 +434,7 @@ describe("WorkspaceMcpOverridesService", () => { // exclusive write queue, so concurrent launches publish in write order. const published: Array<{ via: string; enabled: unknown }> = []; await Promise.all([ - service.prunePluginOverrideKeys(workspaceId, "plugin:abc:", { + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:", { publish: (persisted) => { published.push({ via: "prune", enabled: persisted.enabledServers }); return Promise.resolve(); @@ -434,12 +482,12 @@ describe("WorkspaceMcpOverridesService", () => { `{ // Keep me: explains why other-server is enabled. "enabledServers": [ - "plugin:abc:echo", + "plugin:0123456789abcdef:echo", "other-server" // trailing comment survives too ], /* block comment */ "toolAllowlist": { - "plugin:abc:echo": ["t1"], + "plugin:0123456789abcdef:echo": ["t1"], "other-server": ["t2"] } } @@ -461,13 +509,13 @@ describe("WorkspaceMcpOverridesService", () => { }); const service = new WorkspaceMcpOverridesService(config); - await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + await service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:"); const after = await fs.readFile(filePath, "utf-8"); expect(after).toContain("// Keep me: explains why other-server is enabled."); expect(after).toContain("// trailing comment survives too"); expect(after).toContain("/* block comment */"); - expect(after).not.toContain("plugin:abc:echo"); + expect(after).not.toContain("plugin:0123456789abcdef:echo"); const parsed = jsoncParse(after) as Record; expect(parsed).toEqual({ enabledServers: ["other-server"], @@ -505,29 +553,38 @@ describe("WorkspaceMcpOverridesService", () => { // A newer release may represent an owned field with a shape this build // cannot inspect; "successfully pruning" it would retire the caller's // tombstone while plugin keys embedded in that shape survive. - await fs.writeFile(filePath, JSON.stringify({ enabledServers: { v2: ["plugin:abc:echo"] } })); - // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void - await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( - /unrecognized "enabledServers" shape/ + await fs.writeFile( + filePath, + JSON.stringify({ enabledServers: { v2: ["plugin:0123456789abcdef:echo"] } }) ); - - await fs.writeFile(filePath, JSON.stringify({ toolAllowlist: ["plugin:abc:echo"] })); // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void - await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( - /unrecognized "toolAllowlist" shape/ + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/unrecognized "enabledServers" shape/); + + await fs.writeFile( + filePath, + JSON.stringify({ toolAllowlist: ["plugin:0123456789abcdef:echo"] }) ); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/unrecognized "toolAllowlist" shape/); // Absent fields stay fine (nothing to prune). await fs.writeFile(filePath, JSON.stringify({ somethingElse: true })); - await service.prunePluginOverrideKeys(workspaceId, "plugin:abc:"); + await service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:"); // A non-object ROOT is equally opaque: a newer build may store the whole // document in a different shape with plugin keys embedded inside it. - await fs.writeFile(filePath, JSON.stringify([{ enabledServers: ["plugin:abc:echo"] }])); - // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void - await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( - /unrecognized root shape/ + await fs.writeFile( + filePath, + JSON.stringify([{ enabledServers: ["plugin:0123456789abcdef:echo"] }]) ); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/unrecognized root shape/); }); it("prunePluginOverrideKeys rejects duplicate properties instead of mis-editing", async () => { @@ -564,14 +621,14 @@ describe("WorkspaceMcpOverridesService", () => { // value. The prune must throw (caller keeps its retry tombstone). const duplicateAllowlist = `{ "toolAllowlist": { "other": ["t2"] }, - "toolAllowlist": { "plugin:abc:echo": ["t1"] } + "toolAllowlist": { "plugin:0123456789abcdef:echo": ["t1"] } } `; await fs.writeFile(filePath, duplicateAllowlist); // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void - await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( - /duplicate "toolAllowlist"/ - ); + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/duplicate "toolAllowlist"/); expect(await fs.readFile(filePath, "utf-8")).toBe(duplicateAllowlist); // Duplicate enabledServers: the same parse/modify disagreement makes the @@ -580,28 +637,28 @@ describe("WorkspaceMcpOverridesService", () => { filePath, `{ "enabledServers": ["other"], - "enabledServers": ["plugin:abc:echo"] + "enabledServers": ["plugin:0123456789abcdef:echo"] } ` ); // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void - await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( - /duplicate "enabledServers"/ - ); + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/duplicate "enabledServers"/); // Duplicate keys INSIDE toolAllowlist: removal by name hits the first, // parse exposes the last — the stale key would survive. await fs.writeFile( filePath, `{ - "toolAllowlist": { "plugin:abc:echo": ["t1"], "plugin:abc:echo": ["t2"] } + "toolAllowlist": { "plugin:0123456789abcdef:echo": ["t1"], "plugin:0123456789abcdef:echo": ["t2"] } } ` ); // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void - await expect(service.prunePluginOverrideKeys(workspaceId, "plugin:abc:")).rejects.toThrow( - /duplicate "plugin:abc:echo"/ - ); + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/duplicate "plugin:0123456789abcdef:echo"/); }); it("removes workspace-local file when overrides are set to empty", async () => { diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index 476e34e6f00..5ec6022f33c 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -10,6 +10,7 @@ import { type createRuntime } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { isCanonicalPluginServerKey } from "@/node/services/agentPlugins/mcpConfig"; import { log } from "@/node/services/log"; import { getErrorMessage } from "@/common/utils/errors"; @@ -651,6 +652,13 @@ export class WorkspaceMcpOverridesService { `Workspace MCP overrides file has an unrecognized "${field}" shape (written by a newer version?): ${filePath}` ); + // Match only canonical `plugin:<16-hex>:` keys under the + // requested prefix: MCP server names are otherwise arbitrary strings + // and user configuration may legitimately name a server "plugin:…" — + // pruning must never strip such an ordinary server's overrides. + const isPrunableKey = (key: unknown): boolean => + typeof key === "string" && key.startsWith(keyPrefix) && isCanonicalPluginServerKey(key); + for (const field of ["enabledServers", "disabledServers"] as const) { // Re-parse after each removal: array indices shift as items go. for (;;) { @@ -662,9 +670,7 @@ export class WorkspaceMcpOverridesService { if (!Array.isArray(value)) { throw opaqueShape(field); } - const index = value.findIndex( - (key) => typeof key === "string" && key.startsWith(keyPrefix) - ); + const index = value.findIndex(isPrunableKey); if (index === -1) { break; } @@ -678,7 +684,7 @@ export class WorkspaceMcpOverridesService { throw opaqueShape("toolAllowlist"); } for (const key of Object.keys(allowlist)) { - if (key.startsWith(keyPrefix)) { + if (isPrunableKey(key)) { removeAt(["toolAllowlist", key]); } } diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 28821e7720a..2a46b52eadf 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -8663,71 +8663,80 @@ describe("WorkspaceService remove lifecycle coordination", () => { }); }); -describe("WorkspaceService remove prunes plugin override keys", () => { - // LocalRuntime removal preserves the checkout and its .mux/mcp.local.jsonc, - // but a removed workspace is invisible to the Agent Plugin uninstaller's - // pruning/tombstones: a surviving `plugin:` enable could silently - // re-activate a same-name reinstall's server when the directory is - // re-registered. Removal must strip plugin keys while metadata still - // resolves — and must never brick on a failing prune. - async function runRemoval( - prunePluginOverrideKeys: (workspaceId: string, keyPrefix: string) => Promise - ): Promise<{ result: Result; order: string[] }> { - const sessionRoot = await fsPromises.mkdtemp(path.join(tmpdir(), "ws-remove-prune-")); - const order: string[] = []; - try { - const workspaceService = createWorkspaceServiceForTest({ - config: { - srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(sessionRoot, id)), - removeWorkspace: mock(() => { - order.push("config-removed"); - return Promise.resolve(); - }), - findWorkspace: mock(() => null), - loadConfigOrDefault: mock(() => ({ projects: new Map() })), - } as unknown as Config, - aiService: createMockAIService({ - getWorkspaceMetadata: mock(() => - Promise.resolve({ - success: true as const, - data: { - id: "ws-1", - name: "branch", - projectPath: "/tmp/proj", - runtimeConfig: { type: "local" as const }, - }, - }) - ), - } as unknown as Partial), - }); - workspaceService.setWorkspaceMcpOverridesService({ - prunePluginOverrideKeys: (workspaceId, keyPrefix) => { - order.push(`pruned:${workspaceId}:${keyPrefix}`); - return prunePluginOverrideKeys(workspaceId, keyPrefix); - }, - }); - const result = await workspaceService.remove("ws-1", true); - return { result, order }; - } finally { - await fsPromises.rm(sessionRoot, { recursive: true, force: true }); - } +describe("WorkspaceService registration-time plugin override sanitization", () => { + // A LocalRuntime checkout preserves .mux/mcp.local.jsonc across workspace + // removal, and a removed workspace is invisible to the Agent Plugin + // uninstaller's pruning/tombstones. Consent dies with the workspace: + // registering the directory as a NEW workspace sanitizes canonical plugin + // keys — unless a live sibling still resolves to the same path (its consent + // context is alive), and a failed sanitize aborts creation instead of + // silently activating stale enables. + interface SanitizeAccess { + sanitizeStalePluginOverridesForNewWorkspace( + workspaceId: string, + workspacePath: string + ): Promise; } - test("prunes plugin: keys before the metadata is dropped", async () => { - const { result, order } = await runRemoval(() => Promise.resolve()); - expect(result.success).toBe(true); - // Pruning needs the metadata to resolve the workspace path, so it must - // run before config.removeWorkspace — and with the all-plugins prefix. - expect(order).toEqual(["pruned:ws-1:plugin:", "config-removed"]); + function makeService(existingWorkspaces: Array<{ id: string; path: string }>): WorkspaceService { + return createWorkspaceServiceForTest({ + config: { + srcDir: "/tmp/src", + loadConfigOrDefault: mock(() => ({ + projects: new Map([["/tmp/proj", { workspaces: existingWorkspaces }]]), + })), + } as unknown as Config, + }); + } + + test("sanitizes canonical plugin keys when no sibling shares the path", async () => { + const service = makeService([{ id: "ws-new", path: "/tmp/proj" }]); + const pruned: string[] = []; + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + pruned.push(`${workspaceId}:${keyPrefix}`); + return Promise.resolve(); + }, + }); + const error = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace("ws-new", "/tmp/proj"); + expect(error).toBeUndefined(); + expect(pruned).toEqual(["ws-new:plugin:"]); }); - test("a failing prune never blocks removal", async () => { - const { result, order } = await runRemoval(() => - Promise.reject(new Error("EBUSY: overrides file locked")) - ); - expect(result.success).toBe(true); - expect(order).toEqual(["pruned:ws-1:plugin:", "config-removed"]); + test("skips sanitization while a live sibling resolves to the same path", async () => { + // Conversation forks of a local workspace share the checkout: the + // sibling's consent context is alive, so its enables must survive. + const service = makeService([ + { id: "ws-sibling", path: "/tmp/proj" }, + { id: "ws-new", path: "/tmp/proj/" }, + ]); + const pruned: string[] = []; + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + pruned.push(`${workspaceId}:${keyPrefix}`); + return Promise.resolve(); + }, + }); + const error = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace("ws-new", "/tmp/proj"); + expect(error).toBeUndefined(); + expect(pruned).toEqual([]); + }); + + test("a failed sanitize surfaces an error so creation aborts", async () => { + const service = makeService([{ id: "ws-new", path: "/tmp/proj" }]); + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: () => + Promise.reject(new Error('duplicate "enabledServers" properties')), + }); + const error = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace("ws-new", "/tmp/proj"); + expect(error).toContain("could not be sanitized"); + expect(error).toContain("mcp.local.jsonc"); }); }); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 310c33cd2c6..9fe137dad3f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2720,7 +2720,7 @@ export class WorkspaceService extends EventEmitter { private workspaceGoalService?: WorkspaceGoalService; /** Narrow DevTools cleanup surface; wired by coreServices when a DevToolsService exists. */ private devToolsService?: { removeWorkspaceData(workspaceId: string): Promise }; - /** Narrow overrides-cleanup surface; wired by ServiceContainer for plugin-key pruning on removal. */ + /** Narrow overrides-cleanup surface; wired by ServiceContainer for stale plugin-key sanitization. */ private workspaceMcpOverridesService?: { prunePluginOverrideKeys(workspaceId: string, keyPrefix: string): Promise; }; @@ -2743,6 +2743,63 @@ export class WorkspaceService extends EventEmitter { this.workspaceMcpOverridesService = service; } + /** + * Registration-time sanitization of stale Agent Plugin override keys. + * + * A LocalRuntime workspace's `.mux/mcp.local.jsonc` lives in the checkout, + * which removal PRESERVES — while a removed workspace is invisible to the + * plugin uninstaller's pruning/tombstones. Plugin-server consent must die + * with the workspace that granted it: when a directory is REGISTERED as a + * new local workspace and no other live workspace resolves to the same + * path, canonical `plugin:<16-hex>:` keys are pruned before the workspace + * is announced, so a stale enable can never silently re-activate a + * same-name reinstall's default-disabled server. + * + * Deliberately NOT done at removal time: a removal-time prune edits a file + * that sibling workspaces (conversation forks share the local checkout) may + * still be using, has no durable retry if it fails (the workspace becomes + * unresolvable), and races Workspace MCP dialog saves that land between the + * prune and the metadata drop. Sanitizing at the moment the NEW workspace + * identity is created has none of those windows: siblings force a skip, + * a failure aborts creation (nothing announced, no silent activation), and + * no dialog can target a workspace that has not been announced yet. + * + * Returns an error string (creation must abort) or undefined on success. + */ + private async sanitizeStalePluginOverridesForNewWorkspace( + workspaceId: string, + workspacePath: string + ): Promise { + if (!this.workspaceMcpOverridesService) { + return undefined; + } + // A sibling workspace resolving to the same checkout (local-runtime + // conversation forks) means the consent context is still ALIVE — its + // enables must survive, and the uninstaller can still reach the file + // through that sibling. + const normalizedPath = stripTrailingSlashes(workspacePath); + const config = this.config.loadConfigOrDefault(); + for (const project of config.projects.values()) { + for (const workspace of project.workspaces) { + if ( + workspace.id !== workspaceId && + stripTrailingSlashes(workspace.path) === normalizedPath + ) { + return undefined; + } + } + } + try { + await this.workspaceMcpOverridesService.prunePluginOverrideKeys(workspaceId, "plugin:"); + return undefined; + } catch (error) { + // Abort creation instead of proceeding with the stale file: continuing + // would re-create the silent-activation path this sanitization exists + // to close, with no durable record left to retry it. + return `The directory's existing MCP overrides file could not be sanitized: ${getErrorMessage(error)}. Fix or remove .mux/mcp.local.jsonc in ${workspacePath} and try again.`; + } + } + setWorkspaceGoalService(service: WorkspaceGoalService): void { this.workspaceGoalService = service; } @@ -4355,6 +4412,24 @@ export class WorkspaceService extends EventEmitter { return Err("Failed to retrieve workspace metadata"); } + // Local runtime registers an EXISTING directory, whose preserved + // .mux/mcp.local.jsonc may carry plugin enables consented by a since- + // removed workspace. Sanitize before announcing; a failure aborts the + // creation (config entry rolled back) so nothing stale ever activates. + // Worktree/SSH runtimes create fresh checkouts, so there is no + // preserved-file window there. + if (finalRuntimeConfig.type === "local") { + const sanitizeError = await this.sanitizeStalePluginOverridesForNewWorkspace( + workspaceId, + createResult!.workspacePath + ); + if (sanitizeError !== undefined) { + await this.config.removeWorkspace(workspaceId).catch(() => undefined); + initLogger.logComplete(-1); + return Err(sanitizeError); + } + } + session.emitMetadata(this.enrichFrontendMetadata(completeMetadata)); // Background init: run postCreateSetup (if present) then initWorkspace @@ -5323,26 +5398,6 @@ export class WorkspaceService extends EventEmitter { await this.mcpServerManager.stopServers(workspaceId); } - // Strip Agent Plugin override keys from the workspace's override files - // BEFORE the metadata is dropped (pruning needs it to resolve the - // workspace path). LocalRuntime removal preserves the checkout — and - // its .mux/mcp.local.jsonc — but a removed workspace is invisible to - // the plugin uninstaller's pruning/tombstones, so a surviving - // `plugin:` enable could silently re-enable a same-name reinstall's - // server when the directory is re-registered later. Best-effort: - // removal must never brick on a corrupted overrides file (worktree - // removals delete the checkout anyway, and a missing file is a no-op). - if (this.workspaceMcpOverridesService) { - try { - await this.workspaceMcpOverridesService.prunePluginOverrideKeys(workspaceId, "plugin:"); - } catch (error) { - log.warn("Failed to prune plugin override keys during workspace removal", { - workspaceId, - error: getErrorMessage(error), - }); - } - } - // Close any terminal sessions for this workspace this.terminalService?.closeWorkspaceSessions(workspaceId); await this.closeDesktopSessionBestEffort(workspaceId, "remove"); From 1ee5bc0d8bbbd3b96f61c0f99661c330ac255f12 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 18:04:27 +0000 Subject: [PATCH 30/63] fix: address Codex review round 48 + Integration ENOENT unwrapping - RuntimeError: pass wrapped cause through the native Error options bag typed unknown; jest vm-sandbox fs errors are cross-realm (instanceof Error false) so Error-typed cause filters dropped the ENOENT that statIsFile strict-mode unwrapping needs (Integration CI failure) - discovery gate: bracketed sessions (pre-scan suppression + post-scan confirm) with a mutation-epoch handshake file bumped before every journal deletion, closing the cross-process check-then-scan window - recoverOrphanedPromotion: verify nonce ownership before sweeping the promotion marker so a stale promotion journal cannot strip a marker owned by a later crashed update - PluginsSettingsSection: uninstall confirmation renders only under the managed row (same-name unmanaged doppelgangers no longer anchor it) --- .../PluginsSettingsSection.stories.tsx | 38 ++++++- .../Sections/PluginsSettingsSection.tsx | 9 +- src/node/runtime/LocalBaseRuntime.ts | 14 +-- src/node/runtime/Runtime.ts | 11 +- .../services/agentPlugins/discovery.test.ts | 74 +++++++++++- src/node/services/agentPlugins/discovery.ts | 107 ++++++++++++++---- .../agentPlugins/installService.test.ts | 47 +++++++- .../services/agentPlugins/installService.ts | 74 +++++++++--- src/node/services/agentPlugins/journals.ts | 65 +++++++++++ 9 files changed, 385 insertions(+), 54 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx index 9afe12a9137..b7b604ae6f5 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -225,9 +225,27 @@ export const InstalledPhoneViewport: Story = { }, }; +/** An unmanaged plugin sharing the MANAGED_ITEM's manifest name (a supported + * container state: `~/.agents/plugins` is user-populated). The uninstall + * confirmation is keyed by name, so it must additionally anchor on the + * managed row — never under this read-only doppelganger. */ +const UNMANAGED_SAME_NAME_ITEM: AgentPluginListItem = { + name: "grill", + managed: false, + present: true, + location: "~/.agents/plugins/grill", + description: "Same manifest name in another container; Mux lists it read-only.", + skillCount: 1, + mcpServerCount: 0, +}; + export const UninstallConfirmation: Story = { render: () => ( - + // Unmanaged doppelganger FIRST: a purely name-keyed confirmation would + // render under it too (and before the managed row). + ), @@ -239,6 +257,24 @@ export const UninstallConfirmation: Story = { // Preserve-by-default: the plugin-data checkbox starts unchecked. await canvas.findByText(/Also delete stored plugin data/); + const confirms = canvas.getAllByText(/Also delete stored plugin data/); + if (confirms.length !== 1) { + throw new Error( + `Uninstall confirmation must render exactly once (managed row), found ${confirms.length}` + ); + } + // ...and under the MANAGED row: confirming a card that visually belongs + // to the read-only unmanaged plugin would still uninstall the managed one. + // closest() from the label lands on the confirm panel's own rounded div, + // so hop to its parent (the row card) before checking the row identity. + const panel = confirms[0].closest("div[class*='rounded-md']"); + const rowCard = panel?.parentElement?.closest("div[class*='rounded-md']"); + if ( + !(rowCard instanceof HTMLElement) || + !rowCard.textContent?.includes("~/.mux/plugins/grill") + ) { + throw new Error("Uninstall confirmation must anchor on the managed row"); + } const checkbox = await canvas.findByRole("checkbox"); if (checkbox.getAttribute("data-state") !== "unchecked") { throw new Error("Plugin-data checkbox must start unchecked (preserve by default)"); diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index eedcedaf67a..87974f09dab 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -740,7 +740,14 @@ export const PluginsSettingsSection: React.FC = () => { )}
- {uninstallTarget === item.name && ( + {/* Managed rows only: an unmanaged plugin in another + container can share the manifest name, and rendering the + confirmation under its row would visually attach a + backend uninstall of the MANAGED install to a read-only + unmanaged plugin. The backend uninstall is keyed by + managed-registry name, so the managed row is the one + identity-correct anchor. */} + {item.managed && uninstallTarget === item.name && ( { expect(result.plugins.map((p) => p.name)).toEqual(["alpha", "zeta"]); }); + + test("discards a container's results when the gate's post-scan confirm flags it", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + await writePlugin(container, "transient-plugin"); + + // A mutation that overlaps the scan is only visible AFTER the scan read + // the container: pre-scan suppression stays empty and confirm flags it. + setAgentPluginDiscoveryGate((containerPaths) => + Promise.resolve({ + suppressed: [], + confirm: () => Promise.resolve(containerPaths), + }) + ); + try { + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + expect(result.plugins).toEqual([]); + expect(result.diagnostics.some((d) => d.message.includes("overlapped this scan"))).toBe(true); + } finally { + setAgentPluginDiscoveryGate(journalDerivedDiscoveryGate); + } + }); +}); + +describe("journalDerivedDiscoveryGate", () => { + test("suppresses a container whose staging root holds a journal at session creation", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const stagingRoot = path.join(tmp.path, STAGING_DIR_NAME); + await fs.mkdir(container, { recursive: true }); + await fs.mkdir(stagingRoot, { recursive: true }); + await fs.writeFile(path.join(stagingRoot, "promotion-demo.json"), "{}", "utf8"); + + const session = await journalDerivedDiscoveryGate([container]); + expect(session.suppressed).toEqual([container]); + }); + + test("confirm flags a mutation whose whole journal lifetime fit inside the scan window", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const stagingRoot = path.join(tmp.path, STAGING_DIR_NAME); + await fs.mkdir(container, { recursive: true }); + await fs.mkdir(stagingRoot, { recursive: true }); + + const session = await journalDerivedDiscoveryGate([container]); + expect(session.suppressed).toEqual([]); + + // Nothing changed: a quiet container stays accepted (also covers the + // stable "epoch file never written" state on both reads). + expect(await session.confirm()).toEqual([]); + + // Full transaction between the session's two reads: journal written, + // container mutated, epoch bumped (the install service bumps BEFORE + // deleting any journal), journal consumed. The journal file alone can no + // longer betray the mutation — only the epoch can. + const journalPath = path.join(stagingRoot, "promotion-demo.json"); + await fs.writeFile(journalPath, "{}", "utf8"); + await bumpContainerMutationEpoch(stagingRoot); + await fs.rm(journalPath); + expect(await session.confirm()).toEqual([container]); + + // A journal still in flight at confirm time is flagged as well. + const session2 = await journalDerivedDiscoveryGate([container]); + await fs.writeFile(journalPath, "{}", "utf8"); + expect(await session2.confirm()).toEqual([container]); + }); }); describe("computeAgentPluginContainers", () => { diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 9d45c257803..7601ef9ef2b 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; -import { containerHasUnreconciledJournals } from "./journals"; +import { readContainerMutationState } from "./journals"; import { isValidAgentPluginName, validatePluginManifest, @@ -361,14 +361,28 @@ export async function discoverAgentPluginAt(args: { return { plugin, diagnostics }; } +/** + * One gated scan: `suppressed` containers must not be scanned at all, and + * `confirm()` — called AFTER the scan — returns containers whose scan results + * must be DISCARDED because a mutation may have overlapped the scan. + */ +export interface AgentPluginDiscoveryGateSession { + suppressed: readonly string[]; + confirm(): Promise; +} + +export type AgentPluginDiscoveryGate = ( + containerPaths: readonly string[] +) => Promise; + /** * Crash-recovery gate for container scans, so no discovery path (MCP config, * hooks, skills, workflows, agents — they all funnel through * discoverAgentPlugins) can scan the managed container while install-mutation * journal recovery is pending or failed: an agent request arriving right * after a crash would otherwise load an orphaned promotion — hook included — - * before cleanup ran. The gate receives the container paths being scanned and - * resolves to those that must be SUPPRESSED; it must never reject. + * before cleanup ran. The gate receives the container paths being scanned, + * resolves the session up front, and must never reject. * * The DEFAULT gate derives suppression directly from surviving journal files * in each container's sibling staging root: processes that never construct @@ -376,27 +390,49 @@ export async function discoverAgentPluginAt(args: { * scripts) must not execute an unreconciled managed tree either. They never * RUN recovery, so a journal keeps the managed container suppressed until a * desktop/server session reconciles it. AgentPluginInstallService replaces - * this with its health-tracked gate at construction: when recovery FAILED - * (unreadable registry, failed restore/quarantine), merely waiting would - * release discovery over the unreconciled tree, so the managed container - * stays omitted until a later recovery attempt succeeds. + * this with a gate that ADDS health-tracked suppression at construction: + * when recovery FAILED (unreadable registry, failed restore/quarantine), + * merely waiting would release discovery over the unreconciled tree, so the + * managed container stays omitted until a later recovery attempt succeeds. + * + * A single pre-scan journal check is not enough across processes: a desktop + * install/update in ANOTHER process can write its journal and promote a tree + * after the check but before (or during) the scan, and can even complete its + * whole journal lifetime inside that window. The session therefore re-reads + * each container's mutation state in `confirm()` and discards containers + * whose journals appeared or whose mutation EPOCH changed (the install + * service bumps the epoch before every journal deletion, so a fully + * completed transaction cannot hide). */ -export function journalDerivedDiscoveryGate( +export async function journalDerivedDiscoveryGate( containerPaths: readonly string[] -): Promise { - return Promise.all( - containerPaths.map(async (containerPath) => - (await containerHasUnreconciledJournals(containerPath)) ? [containerPath] : [] +): Promise { + const pre = new Map( + await Promise.all( + containerPaths.map( + async (containerPath) => + [containerPath, await readContainerMutationState(containerPath)] as const + ) ) - ).then((nested) => nested.flat()); + ); + return { + suppressed: containerPaths.filter((containerPath) => pre.get(containerPath)?.hasJournals), + confirm: async () => { + const flagged = await Promise.all( + containerPaths.map(async (containerPath) => { + const post = await readContainerMutationState(containerPath); + const changed = post.hasJournals || post.epoch !== pre.get(containerPath)?.epoch; + return changed ? [containerPath] : []; + }) + ); + return flagged.flat(); + }, + }; } -let discoveryGate: (containerPaths: readonly string[]) => Promise = - journalDerivedDiscoveryGate; +let discoveryGate: AgentPluginDiscoveryGate = journalDerivedDiscoveryGate; -export function setAgentPluginDiscoveryGate( - gate: (containerPaths: readonly string[]) => Promise -): void { +export function setAgentPluginDiscoveryGate(gate: AgentPluginDiscoveryGate): void { discoveryGate = gate; } @@ -411,11 +447,10 @@ export function setAgentPluginDiscoveryGate( export async function discoverAgentPlugins( containers: AgentPluginContainer[] ): Promise { - const suppressedContainers = new Set( - await discoveryGate(containers.map((container) => container.path)) - ); - const plugins: AgentPluginInfo[] = []; - const diagnostics: AgentPluginDiagnostic[] = []; + const gateSession = await discoveryGate(containers.map((container) => container.path)); + const suppressedContainers = new Set(gateSession.suppressed); + let plugins: AgentPluginInfo[] = []; + let diagnostics: AgentPluginDiagnostic[] = []; const seenContainers = new Set(); for (const container of containers) { @@ -451,5 +486,31 @@ export async function discoverAgentPlugins( } } + // Post-scan confirmation: a mutation in ANOTHER process (or a concurrent + // in-process one) may have started or finished while the scan read the + // container, so the trees just read can be transient (an orphaned promotion + // that recovery will quarantine, or a mixed old/new update read). Discard + // those containers' results rather than hand callers plugin content that + // may already be rolled back. + const overlapped = new Set(await gateSession.confirm()); + for (const container of containers) { + if (!overlapped.has(container.path) || suppressedContainers.has(container.path)) { + continue; + } + plugins = plugins.filter((plugin) => plugin.containerPath !== container.path); + diagnostics = diagnostics.filter( + (diagnostic) => + diagnostic.path !== container.path && !diagnostic.path.startsWith(container.path + path.sep) + ); + diagnostics.push({ + path: container.path, + scope: container.scope, + severity: "warning", + message: + "Managed plugin container skipped: a plugin install/update/uninstall overlapped this scan; its plugins are unavailable until the next scan.", + }); + suppressedContainers.add(container.path); + } + return { plugins, diagnostics }; } diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 1f29ffd2566..b544ef74395 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -96,7 +96,11 @@ describe("AgentPluginInstallService", () => { () => false ); const stagingLeftovers = async () => - (await pathExists(stagingDir())) ? fsPromises.readdir(stagingDir()) : []; + (await pathExists(stagingDir())) + ? // The mutation-epoch handshake file is durable staging-root state (it + // must survive so scan brackets can compare tokens), not a leftover. + (await fsPromises.readdir(stagingDir())).filter((entry) => entry !== "mutation-epoch") + : []; beforeEach(async () => { muxRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-plugin-test-")); @@ -1227,6 +1231,47 @@ describe("AgentPluginInstallService", () => { expect(await pathExists(journalPath)).toBe(false); }); + test("a stale promotion journal never strips a marker owned by a later update", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + const targetPath = path.join(pluginsDir(), "demo-plugin"); + const markerPath = path.join(targetPath, ".mux-promotion-marker"); + + // Two coexisting journals for one name: the install committed but its + // promotion journal survived a failed deletion, and a later update + // crashed after promoting its replacement (update journal + its nonce + // marker in the live tree). The promotion journal's committed-install + // sweep must verify nonce OWNERSHIP before touching the marker — + // stripping the update's marker would make update recovery misread the + // live tree as an unrecognized user replacement (staged old tree + + // markerless target) and suppress the container forever. + const trashDir = path.join(stagingDir(), `trash-${Date.now()}-demo-plugin`); + await fsPromises.mkdir(trashDir, { recursive: true }); + await fsPromises.writeFile(markerPath, "update-nonce"); + await fsPromises.writeFile( + path.join(stagingDir(), "update-demo-plugin.json"), + JSON.stringify({ + name: "demo-plugin", + trashDir, + nonce: "update-nonce", + stagedAt: Date.now(), + }) + ); + await fsPromises.writeFile( + path.join(stagingDir(), "promotion-demo-plugin.json"), + JSON.stringify({ name: "demo-plugin", nonce: "install-nonce", stagedAt: Date.now() }) + ); + + // Reconciliation must consume BOTH journals (regardless of visit order) + // and leave the plugin available, not suppressed. + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")?.managed).toBe(true); + expect(await pathExists(path.join(stagingDir(), "promotion-demo-plugin.json"))).toBe(false); + expect(await pathExists(path.join(stagingDir(), "update-demo-plugin.json"))).toBe(false); + expect(await pathExists(markerPath)).toBe(false); + expect(await pathExists(targetPath)).toBe(true); + }); + test("repositories shipping the reserved recovery marker name are rejected", async () => { // install/update write a nonce file at this path pre-rename; a repo // shipping it would get that file clobbered then deleted, making the diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 25a681f8f2b..39b18345beb 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -35,11 +35,13 @@ import { execFileAsync } from "@/node/utils/disposableExec"; import { discoverAgentPluginAt, discoverAgentPlugins, + journalDerivedDiscoveryGate, setAgentPluginDiscoveryGate, type AgentPluginContainer, type AgentPluginInfo, } from "./discovery"; import { + bumpContainerMutationEpoch, isJournalName, JOURNAL_PREFIXES, PROMOTION_JOURNAL_PREFIX, @@ -391,10 +393,24 @@ export class AgentPluginInstallService { // LATEST reconciliation attempt so an agent request cannot load an // orphaned tree while recovery is running — and cannot scan the managed // container at all while the latest attempt has FAILED (the journaled - // tree may still be sitting in it). - setAgentPluginDiscoveryGate(async () => - (await this.reconciliationState) ? [] : [this.containerDir] - ); + // tree may still be sitting in it). Health alone is not enough: a live + // mutation (in this process or a sibling desktop/server process sharing + // the same mux home) can overlap a scan, so keep the journal+epoch + // bracket of the default gate and UNION health suppression onto it. + setAgentPluginDiscoveryGate(async (containerPaths) => { + // Serialize behind the latest recovery attempt BEFORE snapshotting the + // journal bracket: recovery consumes journals, and reading them first + // would suppress the very scan whose recovery just succeeded. + const unhealthySuppression = (await this.reconciliationState) ? [] : [this.containerDir]; + const bracket = await journalDerivedDiscoveryGate(containerPaths); + return { + suppressed: [...new Set([...bracket.suppressed, ...unhealthySuppression])], + confirm: async () => { + const stillUnhealthy = (await this.reconciliationState) ? [] : [this.containerDir]; + return [...new Set([...(await bracket.confirm()), ...stillUnhealthy])]; + }, + }; + }); } /** @@ -1589,7 +1605,7 @@ export class AgentPluginInstallService { } else { // Rollback handled the tree: the journal's crash-recovery job is // done. - await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + await this.consumeJournalFile(journalPath).catch(() => undefined); } const notes = cleanupNotes.length > 0 ? ` Additionally, ${cleanupNotes.join("; ")}.` : ""; throw new Error( @@ -1598,7 +1614,7 @@ export class AgentPluginInstallService { } // Registry write committed (entry recorded): the journal's // crash-recovery job is done. - await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + await this.consumeJournalFile(journalPath).catch(() => undefined); // Committed: the marker did its crash-recovery job (a failed removal // leaves a stray dotfile the next update swap discards — harmless). await fsPromises @@ -1617,6 +1633,20 @@ export class AgentPluginInstallService { return path.join(this.stagingRoot, `${prefix}${name}.json`); } + /** + * Consume a journal whose transaction/recovery job finished: bump the + * container mutation epoch FIRST, then delete the file. The epoch bump is + * what lets a discovery-gate bracket (pre/post scan reads, in this or any + * sibling process) detect a mutation whose whole journal lifetime fit + * inside its scan window. A bump failure keeps the journal — callers treat + * that as a failed consumption — rather than deleting the last visible + * trace of the mutation. + */ + private async consumeJournalFile(journalPath: string): Promise { + await bumpContainerMutationEpoch(this.stagingRoot); + await fsPromises.rm(journalPath, { force: true }); + } + /** A string field from a journal, or undefined when absent/unreadable. */ private async readJournalField(journalPath: string, field: string): Promise { try { @@ -1705,7 +1735,7 @@ export class AgentPluginInstallService { assert(prefix !== undefined, "reconcileJournals: filtered journal lost its prefix"); const name = journalName.slice(prefix.length, -".json".length); if (!isValidAgentPluginName(name)) { - await fsPromises.rm(journalPath, { force: true }).catch(() => undefined); + await this.consumeJournalFile(journalPath).catch(() => undefined); continue; } const consumed = @@ -1719,7 +1749,7 @@ export class AgentPluginInstallService { continue; } try { - await fsPromises.rm(journalPath, { force: true }); + await this.consumeJournalFile(journalPath); } catch (error) { allConsumed = false; log.warn("Failed to delete a consumed plugin journal; will retry", { @@ -1746,10 +1776,22 @@ export class AgentPluginInstallService { const targetPath = this.targetPathFor(name); if (registryNames.has(name)) { // The install committed and only the journal deletion was lost; sweep - // the marker the commit path would have removed. - await fsPromises - .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) + // the marker the commit path would have removed — but only when the + // marker is OURS (nonce match). A LATER mutation may own the live tree + // by now: if an update crashed after promoting its replacement, the + // marker at the target carries the UPDATE journal's nonce, and blindly + // deleting it would make update recovery misread the live tree as an + // unrecognized user replacement (staged old tree + markerless target) + // and suppress the container forever. + const journalNonce = await this.readJournalField(journalPath, "nonce"); + const treeNonce = await fsPromises + .readFile(path.join(targetPath, PROMOTION_MARKER_FILE), "utf-8") .catch(() => undefined); + if (journalNonce !== undefined && treeNonce === journalNonce) { + await fsPromises + .rm(path.join(targetPath, PROMOTION_MARKER_FILE), { force: true }) + .catch(() => undefined); + } return true; } // Only an ORPHAN (tree without registry entry) needs cleanup. @@ -1824,7 +1866,7 @@ export class AgentPluginInstallService { // replacement, deadlocking updates — so a failed journal deletion // must abort cleanup and keep the marker as the tree's identity. try { - await fsPromises.rm(journalPath, { force: true }); + await this.consumeJournalFile(journalPath); } catch (error) { log.warn("Failed to delete the update journal; keeping the tree marker for retry", { name, @@ -2162,7 +2204,7 @@ export class AgentPluginInstallService { JSON.stringify({ name: entry.name, trashDir, dataTrashDir, stagedAt: Date.now() }) ); const consumeJournal = async (): Promise => { - await fsPromises.rm(uninstallJournalPath, { force: true }).catch(() => undefined); + await this.consumeJournalFile(uninstallJournalPath).catch(() => undefined); }; let stagedTree = false; @@ -2876,7 +2918,7 @@ export class AgentPluginInstallService { await this.renameIntoStaging(targetPath, trashDir); } catch (error) { // Nothing moved: no recovery needed. - await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); + await this.consumeJournalFile(updateJournalPath).catch(() => undefined); throw error; } } @@ -2889,7 +2931,7 @@ export class AgentPluginInstallService { try { await fsPromises.rename(trashDir, targetPath); this.activeStagingPaths.delete(trashDir); - await fsPromises.rm(updateJournalPath, { force: true }).catch(() => undefined); + await this.consumeJournalFile(updateJournalPath).catch(() => undefined); } catch (rollbackError) { // Keep the journal: reconcileJournals restores the tree on the // next startup/section open. @@ -2913,7 +2955,7 @@ export class AgentPluginInstallService { let journalConsumed = true; if (hadOldTree) { try { - await fsPromises.rm(updateJournalPath, { force: true }); + await this.consumeJournalFile(updateJournalPath); } catch (error) { journalConsumed = false; log.warn( diff --git a/src/node/services/agentPlugins/journals.ts b/src/node/services/agentPlugins/journals.ts index b80eb3ce816..366ee7dabeb 100644 --- a/src/node/services/agentPlugins/journals.ts +++ b/src/node/services/agentPlugins/journals.ts @@ -13,12 +13,24 @@ * service (headless `mux workflow` resolving plugin:// scripts) without an * import cycle: installService imports discovery for container scans. */ +import { randomUUID } from "node:crypto"; import * as fsPromises from "node:fs/promises"; import * as path from "node:path"; /** Staging dir name under the mux home dir — NOT under ~/.mux/plugins, which discovery scans. */ export const STAGING_DIR_NAME = "plugin-staging"; +/** + * Mutation-epoch handshake file in the staging root. The install service + * rewrites it with a fresh random token immediately BEFORE deleting any + * journal, so a mutation whose entire journal lifetime (create → consume) + * fits between a scanner's two journal checks still leaves a visible trace: + * the journal file alone cannot betray a transaction that finished before + * the post-scan check. Bump-before-delete makes "journal gone" imply "epoch + * already changed" for any mutation that ran during the scan window. + */ +export const MUTATION_EPOCH_FILE = "mutation-epoch"; + export const PROMOTION_JOURNAL_PREFIX = "promotion-"; export const UPDATE_JOURNAL_PREFIX = "update-"; export const UNINSTALL_JOURNAL_PREFIX = "uninstall-"; @@ -49,3 +61,56 @@ export async function containerHasUnreconciledJournals(containerPath: string): P return !(error instanceof Error && "code" in error && error.code === "ENOENT"); } } + +/** + * Snapshot of a container's mutation-visibility state, read twice by the + * discovery gate (before and after a container scan) to detect mutations + * that overlap the scan. + */ +export interface ContainerMutationState { + /** Fail-closed: an unreadable staging root reports true. */ + hasJournals: boolean; + /** + * Epoch token; `undefined` when the epoch file has never been written (a + * stable state). An unreadable epoch file yields a UNIQUE token so it can + * never compare equal across two reads (fail toward suppression). + */ + epoch: string | undefined; +} + +export async function readContainerMutationState( + containerPath: string +): Promise { + const stagingRoot = path.join(path.dirname(containerPath), STAGING_DIR_NAME); + const hasJournals = await containerHasUnreconciledJournals(containerPath); + let epoch: string | undefined; + try { + epoch = await fsPromises.readFile(path.join(stagingRoot, MUTATION_EPOCH_FILE), "utf-8"); + } catch (error) { + epoch = + error instanceof Error && "code" in error && error.code === "ENOENT" + ? undefined + : `unreadable-${randomUUID()}`; + } + return { hasJournals, epoch }; +} + +/** + * Rewrite the epoch file with a fresh random token. MUST be awaited before + * deleting a journal (see MUTATION_EPOCH_FILE); a failure must be treated as + * a failed journal consumption (keep the journal) or the finished-inside-the- + * scan-window race reopens. Written atomically via temp + rename so a + * concurrent scanner can never observe a torn token that happens to match + * its earlier read. + */ +export async function bumpContainerMutationEpoch(stagingRoot: string): Promise { + const token = randomUUID(); + const tempPath = path.join(stagingRoot, `.${MUTATION_EPOCH_FILE}-${token}.tmp`); + await fsPromises.writeFile(tempPath, token, "utf-8"); + try { + await fsPromises.rename(tempPath, path.join(stagingRoot, MUTATION_EPOCH_FILE)); + } catch (error) { + await fsPromises.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} From 6d5c796c3f4fceea90e93b7da47aa38e584fc398 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 18:28:47 +0000 Subject: [PATCH 31/63] fix: address Codex review round 49 (registration-time sanitization hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sibling qualification: only host-local (local/worktree/legacy) workspaces count — an SSH/container workspace with an equal path string lives in a different filesystem namespace; compare canonical realpath identity in addition to spelling so symlinked sibling spellings still force a skip - Overlapping registrations: entries pending their own sanitization (pendingPluginSanitizations) never qualify as live siblings, so two concurrent creations for one checkout cannot both skip pruning - Extend sanitization to worktree creations: a tracked .mux/mcp.local.jsonc materializes in fresh checkouts and project plugin instance IDs are worktree-stable, so committed enables would silently activate - Verify rollback actually removed the config entry (saveConfig swallows write failures) and surface manual-cleanup guidance when it did not - Tear down session/init state (abort controller, init record, session subscriptions) when sanitization aborts creation, preventing per-retry leaks --- src/node/services/workspaceService.test.ts | 99 +++++++++- src/node/services/workspaceService.ts | 202 ++++++++++++++++----- 2 files changed, 252 insertions(+), 49 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 2a46b52eadf..3fd343e15ac 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -8676,9 +8676,13 @@ describe("WorkspaceService registration-time plugin override sanitization", () = workspaceId: string, workspacePath: string ): Promise; + pendingPluginSanitizations: Set; + rollbackUnsanitizedWorkspaceRegistration(workspaceId: string): Promise; } - function makeService(existingWorkspaces: Array<{ id: string; path: string }>): WorkspaceService { + function makeService( + existingWorkspaces: Array<{ id: string; path: string; runtimeConfig?: unknown }> + ): WorkspaceService { return createWorkspaceServiceForTest({ config: { srcDir: "/tmp/src", @@ -8738,6 +8742,99 @@ describe("WorkspaceService registration-time plugin override sanitization", () = expect(error).toContain("could not be sanitized"); expect(error).toContain("mcp.local.jsonc"); }); + + test("an off-host workspace with an equal path string is not a sibling", async () => { + // SSH/container paths occupy a different filesystem namespace: an equal + // STRING proves nothing about the local overrides file, and skipping + // would leave a stale enable to activate on the next local request. + const service = makeService([ + { id: "ws-ssh", path: "/tmp/proj", runtimeConfig: { type: "ssh", host: "box" } }, + { id: "ws-new", path: "/tmp/proj" }, + ]); + const pruned: string[] = []; + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + pruned.push(`${workspaceId}:${keyPrefix}`); + return Promise.resolve(); + }, + }); + const error = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace("ws-new", "/tmp/proj"); + expect(error).toBeUndefined(); + expect(pruned).toEqual(["ws-new:plugin:"]); + }); + + test("a sibling registered through a symlinked spelling still forces a skip", async () => { + // Canonical (realpath) identity, not just spelling: pruning here would + // strip the live symlink-spelled sibling's enables from the shared file. + const realDir = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-sanitize-real-")); + const linkPath = `${realDir}-link`; + await fsPromises.symlink(realDir, linkPath); + try { + const service = makeService([ + { id: "ws-symlink-sibling", path: linkPath }, + { id: "ws-new", path: realDir }, + ]); + const pruned: string[] = []; + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + pruned.push(`${workspaceId}:${keyPrefix}`); + return Promise.resolve(); + }, + }); + const error = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace("ws-new", realDir); + expect(error).toBeUndefined(); + expect(pruned).toEqual([]); + } finally { + await fsPromises.rm(linkPath, { force: true }); + await fsPromises.rm(realDir, { recursive: true, force: true }); + } + }); + + test("an overlapping registration pending its own sanitization is not a sibling", async () => { + // Two creations for the same checkout can both persist config entries + // before either sanitizes; a not-yet-sanitized entry is no proof of live + // consent, so the scan must ignore it or BOTH creations skip pruning. + const service = makeService([ + { id: "ws-concurrent", path: "/tmp/proj" }, + { id: "ws-new", path: "/tmp/proj" }, + ]); + (service as unknown as SanitizeAccess).pendingPluginSanitizations.add("ws-concurrent"); + const pruned: string[] = []; + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + pruned.push(`${workspaceId}:${keyPrefix}`); + return Promise.resolve(); + }, + }); + const error = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace("ws-new", "/tmp/proj"); + expect(error).toBeUndefined(); + expect(pruned).toEqual(["ws-new:plugin:"]); + }); + + test("rollback verification detects a swallowed config write failure", async () => { + // Config.saveConfig logs and swallows write errors, so removeWorkspace + // can resolve while the entry survives on disk; the rollback must verify + // absence rather than trust the resolved promise. + const stuckWorkspaces = [{ id: "ws-stuck", path: "/tmp/proj" }]; + const service = createWorkspaceServiceForTest({ + config: { + removeWorkspace: mock(() => Promise.resolve()), + loadConfigOrDefault: mock(() => ({ + projects: new Map([["/tmp/proj", { workspaces: stuckWorkspaces }]]), + })), + } as unknown as Config, + }); + const access = service as unknown as SanitizeAccess; + expect(await access.rollbackUnsanitizedWorkspaceRegistration("ws-stuck")).toBe(false); + // A rollback that actually lands verifies clean. + expect(await access.rollbackUnsanitizedWorkspaceRegistration("ws-gone")).toBe(true); + }); }); describe("WorkspaceService remove timing rollup", () => { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 9fe137dad3f..036462d047b 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2743,10 +2743,21 @@ export class WorkspaceService extends EventEmitter { this.workspaceMcpOverridesService = service; } + /** + * Workspace IDs whose creation persisted a config entry but has not yet + * finished registration-time plugin-override sanitization. Two overlapping + * creations for the same checkout would otherwise each see the other's + * just-persisted entry as a live sibling and BOTH skip sanitizing; entries + * in this set never qualify as siblings, so the first sanitize to run + * prunes (a concurrent double-prune is idempotent) and later ones see a + * completed registration. + */ + private readonly pendingPluginSanitizations = new Set(); + /** * Registration-time sanitization of stale Agent Plugin override keys. * - * A LocalRuntime workspace's `.mux/mcp.local.jsonc` lives in the checkout, + * A host-local workspace's `.mux/mcp.local.jsonc` lives in the checkout, * which removal PRESERVES — while a removed workspace is invisible to the * plugin uninstaller's pruning/tombstones. Plugin-server consent must die * with the workspace that granted it: when a directory is REGISTERED as a @@ -2776,14 +2787,48 @@ export class WorkspaceService extends EventEmitter { // A sibling workspace resolving to the same checkout (local-runtime // conversation forks) means the consent context is still ALIVE — its // enables must survive, and the uninstaller can still reach the file - // through that sibling. + // through that sibling. Qualification is deliberately strict on runtime + // KIND and loose on path SPELLING: + // - Only host-local workspaces (project-dir local / worktree, including + // legacy entries without a runtimeConfig) qualify: an SSH or container + // workspace whose persisted remote path merely equals this local path + // string lives in a different filesystem namespace and preserves no + // consent context for the local file. + // - Paths compare by canonical filesystem identity (realpath) IN ADDITION + // to normalized spelling: a sibling registered through a symlinked or + // differently-cased spelling of the same checkout must still be + // recognized, or pruning would strip a live workspace's enables. + // Failures fall back to spelling so an unresolvable path errs toward + // skipping (leaving keys) rather than pruning live consent. + const canonicalize = async (candidate: string): Promise => { + const stripped = stripTrailingSlashes(candidate); + try { + return await fsPromises.realpath(stripped); + } catch { + return stripped; + } + }; + const isHostLocalConfig = (runtimeConfig: RuntimeConfig | undefined): boolean => + runtimeConfig === undefined || + runtimeConfig.type === "local" || + runtimeConfig.type === "worktree"; const normalizedPath = stripTrailingSlashes(workspacePath); + const canonicalPath = await canonicalize(workspacePath); const config = this.config.loadConfigOrDefault(); for (const project of config.projects.values()) { for (const workspace of project.workspaces) { if ( - workspace.id !== workspaceId && - stripTrailingSlashes(workspace.path) === normalizedPath + workspace.id === workspaceId || + // Registered-but-unsanitized entries from an overlapping creation + // are not live consent contexts (see pendingPluginSanitizations). + (workspace.id !== undefined && this.pendingPluginSanitizations.has(workspace.id)) || + !isHostLocalConfig(workspace.runtimeConfig) + ) { + continue; + } + if ( + stripTrailingSlashes(workspace.path) === normalizedPath || + (await canonicalize(workspace.path)) === canonicalPath ) { return undefined; } @@ -2800,6 +2845,31 @@ export class WorkspaceService extends EventEmitter { } } + /** + * Roll back a just-persisted workspace registration and VERIFY it left the + * on-disk config. Config.saveConfig logs and swallows write failures, so + * removeWorkspace can resolve while the entry is still persisted — after a + * restart that entry would resurrect with the unsanitized overrides file + * this rollback exists to keep unreachable. Returns whether the entry is + * provably gone from disk. + */ + private async rollbackUnsanitizedWorkspaceRegistration(workspaceId: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + await this.config.removeWorkspace(workspaceId).catch(() => undefined); + const persisted = this.config.loadConfigOrDefault(); + const stillPresent = Array.from(persisted.projects.values()).some((project) => + project.workspaces.some((workspace) => workspace.id === workspaceId) + ); + if (!stillPresent) { + return true; + } + } + log.error( + `Failed to roll back workspace ${workspaceId} after plugin-override sanitization aborted creation` + ); + return false; + } + setWorkspaceGoalService(service: WorkspaceGoalService): void { this.workspaceGoalService = service; } @@ -4380,55 +4450,91 @@ export class WorkspaceService extends EventEmitter { createdAt: new Date().toISOString(), }; - await this.config.editConfig((config) => { - let projectConfig = config.projects.get(owningProjectPath); - if (!projectConfig) { - projectConfig = { workspaces: [] }; - config.projects.set(owningProjectPath, projectConfig); - } - projectConfig.workspaces.push({ - path: createResult!.workspacePath!, - id: workspaceId, - name: finalBranchName, - title, - createdAt: metadata.createdAt, - runtimeConfig: finalRuntimeConfig, - subProjectPath: effectiveSubProjectPath, - // Persist tags atomically with creation so orchestration loops that - // look workspaces up by tag (e.g. workspace.ensure) never observe a - // created-but-untagged window after a crash. - ...(tags != null && Object.keys(tags).length > 0 ? { tags } : {}), - // Mirror /fork: when /new is invoked with a start message, defer title - // selection until the first message can drive LLM-based generation. - ...(pendingAutoTitle === true ? { pendingAutoTitle: true } : {}), - }); - return config; - }); - - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const completeMetadata = allMetadata.find((m) => m.id === workspaceId); - if (!completeMetadata) { - initLogger.logComplete(-1); - return Err("Failed to retrieve workspace metadata"); + // Host-local checkouts (project-dir local and worktree) get their + // preserved/tracked .mux/mcp.local.jsonc sanitized below. Mark this + // registration pending BEFORE the entry persists so an overlapping + // creation for the same checkout cannot mistake the not-yet-sanitized + // entry for a live sibling and skip its own sanitization. + const isHostLocalCheckout = + finalRuntimeConfig.type === "local" || finalRuntimeConfig.type === "worktree"; + let completeMetadata: FrontendWorkspaceMetadata | undefined; + if (isHostLocalCheckout) { + this.pendingPluginSanitizations.add(workspaceId); } + try { + await this.config.editConfig((config) => { + let projectConfig = config.projects.get(owningProjectPath); + if (!projectConfig) { + projectConfig = { workspaces: [] }; + config.projects.set(owningProjectPath, projectConfig); + } + projectConfig.workspaces.push({ + path: createResult!.workspacePath!, + id: workspaceId, + name: finalBranchName, + title, + createdAt: metadata.createdAt, + runtimeConfig: finalRuntimeConfig, + subProjectPath: effectiveSubProjectPath, + // Persist tags atomically with creation so orchestration loops that + // look workspaces up by tag (e.g. workspace.ensure) never observe a + // created-but-untagged window after a crash. + ...(tags != null && Object.keys(tags).length > 0 ? { tags } : {}), + // Mirror /fork: when /new is invoked with a start message, defer title + // selection until the first message can drive LLM-based generation. + ...(pendingAutoTitle === true ? { pendingAutoTitle: true } : {}), + }); + return config; + }); - // Local runtime registers an EXISTING directory, whose preserved - // .mux/mcp.local.jsonc may carry plugin enables consented by a since- - // removed workspace. Sanitize before announcing; a failure aborts the - // creation (config entry rolled back) so nothing stale ever activates. - // Worktree/SSH runtimes create fresh checkouts, so there is no - // preserved-file window there. - if (finalRuntimeConfig.type === "local") { - const sanitizeError = await this.sanitizeStalePluginOverridesForNewWorkspace( - workspaceId, - createResult!.workspacePath - ); - if (sanitizeError !== undefined) { - await this.config.removeWorkspace(workspaceId).catch(() => undefined); + const allMetadata = await this.config.getAllWorkspaceMetadata(); + completeMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!completeMetadata) { initLogger.logComplete(-1); - return Err(sanitizeError); + return Err("Failed to retrieve workspace metadata"); + } + + // The checkout being registered may already hold plugin enables no + // live workspace consented to: LocalRuntime registers an EXISTING + // directory whose preserved .mux/mcp.local.jsonc can carry enables + // from a since-removed workspace, and a fresh WORKTREE checkout + // materializes the file when the repository tracks it (project plugin + // instance IDs are stable across a project's worktrees, so committed + // enables would silently activate here). Sanitize before announcing; + // a failure aborts the creation so nothing stale ever activates. + // SSH/container runtimes exec off-host, where plugin servers never + // spawn (host-path containers only in v1). + if (isHostLocalCheckout) { + const sanitizeError = await this.sanitizeStalePluginOverridesForNewWorkspace( + workspaceId, + createResult!.workspacePath + ); + if (sanitizeError !== undefined) { + const rolledBack = await this.rollbackUnsanitizedWorkspaceRegistration(workspaceId); + // Tear down the in-memory state registered earlier in this + // creation (session, init record, abort controller) exactly like + // workspace removal would; without this every aborted retry + // against the same bad file leaks another unreachable session + // for the process lifetime. + initAbortController.abort(); + this.initAbortControllers.delete(workspaceId); + this.initStateManager.clearInMemoryState(workspaceId); + this.disposeSession(workspaceId); + initLogger.logComplete(-1); + return Err( + rolledBack + ? sanitizeError + : `${sanitizeError} Additionally, the half-created workspace registration could not be rolled back; remove workspace ${workspaceId} manually before retrying.` + ); + } } + } finally { + this.pendingPluginSanitizations.delete(workspaceId); } + assert( + completeMetadata !== undefined, + "create: registration must have produced workspace metadata" + ); session.emitMetadata(this.enrichFrontendMetadata(completeMetadata)); From 4d2a8974715ac56231253be0a474a6c6f7afc707 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 18:51:19 +0000 Subject: [PATCH 32/63] fix: address Codex review round 50 (cross-process safety + symlink final-path validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Security: staged-tree walk rejects unresolvable symlinks and relative links escaping the repo root — links resolve against the STAGED path during consent/capability checks but against the PROMOTED path at runtime, so a dangling hooks.js link could skip consent entirely - Cross-process mutation lock (staging-root lock file, wx-create + ownership re-read, dead-pid reclamation with stale ceiling): two processes sharing rootDir can no longer interleave plugins.json read-modify-writes and drop each other's entries - purgeStaleStaging exempts the mutation-epoch and lock files, and an unreadable journal pins ALL trash entries (fail closed) - Journal reads fail closed: unreadable/corrupt journals leave recovery unresolved (journal + discovery suppression retained) instead of being consumed as completed via 'field absent' degradation - Uninstall re-enumerates workspaces post-commit and folds the delta into pruning/tombstones, covering workspaces registered after the pre-commit enumeration - Update-check badges render only on managed rows (name-keyed checks no longer mislabel same-name unmanaged plugins) --- .../Sections/PluginsSettingsSection.tsx | 7 +- .../agentPlugins/installService.test.ts | 137 +++++++- .../services/agentPlugins/installService.ts | 309 ++++++++++++++++-- 3 files changed, 414 insertions(+), 39 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 87974f09dab..31e93ee6f07 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -650,7 +650,12 @@ export const PluginsSettingsSection: React.FC = () => {

No plugins installed yet.

) : ( items.map((item) => { - const check = updateChecks.get(item.name); + // Update checks are keyed by MANAGED-registry name: an + // unmanaged plugin in another container can share the manifest + // name, and rendering the managed install's check state on its + // read-only row would mislabel unrelated content ("update + // available" with no Update action). + const check = item.managed ? updateChecks.get(item.name) : undefined; const updateAvailable = item.managed && (check?.status === "update-available" || check?.status === "tag-moved"); diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index b544ef74395..922abeaff74 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1272,6 +1272,136 @@ describe("AgentPluginInstallService", () => { expect(await pathExists(targetPath)).toBe(true); }); + test("unreadable journals stay unresolved and keep discovery suppressed", async () => { + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // A truncated/corrupt journal's recovery instructions are unknown: + // consuming it would leave an orphaned promotion live as an unmanaged + // plugin (unreadable nonce) or abandon an interrupted update's staged + // original (unreadable trashDir). It must survive as unresolved, keeping + // the managed container suppressed, until repaired. + const journalPath = path.join(stagingDir(), "update-demo-plugin.json"); + await fsPromises.writeFile(journalPath, '{"name": "demo-plugin", "trash'); + + // The registry row still lists, but discovery of the managed container is + // suppressed (present:false, no components) and the journal survives. + const items = await service.list(); + expect(items.find((item) => item.name === "demo-plugin")?.present).toBe(false); + expect(await pathExists(journalPath)).toBe(true); + + // Repairing the journal (here: to a consumed-state no-op) recovers. + await fsPromises.writeFile(journalPath, JSON.stringify({ name: "demo-plugin" })); + const repaired = await service.list(); + expect(repaired.find((item) => item.name === "demo-plugin")?.present).toBe(true); + expect(await pathExists(journalPath)).toBe(false); + }); + + test("concurrent mutations from two service instances cannot drop registry entries", async () => { + // Two ServiceContainer instances can share one rootDir (a desktop app + // alongside `mux server`, ALLOW_MULTIPLE_INSTANCES): each has its own + // in-process queue, so only the cross-process mutation lock serializes + // their read-modify-write of plugins.json. Without it, both installs + // read the same snapshot and the later atomic write drops the earlier + // entry despite both reporting success. + const secondRemote = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-plugin-remote2-")); + try { + await initRemote(secondRemote); + await writePluginFixture(secondRemote, { version: "1.0.0" }); + await fsPromises.writeFile( + path.join(secondRemote, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "second-plugin" }) + ); + await commitAll(secondRemote, "initial"); + + const serviceB = new AgentPluginInstallService(config, { isEnabled: () => true }); + const [previewA, previewB] = await Promise.all([ + service.preview({ input: remoteDir }), + serviceB.preview({ input: secondRemote }), + ]); + await Promise.all([ + service.install({ source: previewA.source, expectedSha: previewA.lockedSha }), + serviceB.install({ source: previewB.source, expectedSha: previewB.lockedSha }), + ]); + + const names = (await registry()).map((entry) => (entry as { name: string }).name).sort(); + expect(names).toEqual(["demo-plugin", "second-plugin"]); + } finally { + await fsPromises.rm(secondRemote, { recursive: true, force: true }); + } + }); + + test("uninstall prunes workspaces registered after its pre-commit enumeration", async () => { + // A workspace created between the pre-commit enumeration and the tree + // removal can still save a valid enable (save-time validation sees the + // then-present server). The post-commit re-enumeration must fold it in, + // or a same-name reinstall would silently reactivate the server there. + const prunedIds: string[] = []; + const overridesStub = { + prunePluginOverrideKeys: (workspaceId: string) => { + prunedIds.push(workspaceId); + return Promise.resolve(); + }, + }; + const serviceWithDeps = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + const preview = await serviceWithDeps.preview({ input: remoteDir }); + await serviceWithDeps.install({ source: preview.source, expectedSha: preview.lockedSha }); + + let enumerations = 0; + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => { + enumerations += 1; + const workspaces = + enumerations === 1 + ? [{ id: "ws-old", runtimeConfig: { type: "local" } }] + : [ + { id: "ws-old", runtimeConfig: { type: "local" } }, + { id: "ws-mid-uninstall", runtimeConfig: { type: "worktree" } }, + ]; + return Promise.resolve( + workspaces as unknown as Awaited> + ); + }); + try { + await serviceWithDeps.uninstall({ name: "demo-plugin", deletePluginData: false }); + } finally { + metadataSpy.mockRestore(); + } + expect(prunedIds.sort()).toEqual(["ws-mid-uninstall", "ws-old"]); + }); + + test("staged trees reject dangling and root-escaping relative symlinks", async () => { + // The exact consent-miss attack: hooks.js -> ../../plugins//payload.js + // is dangling in staging (component checks see "no hook"), but after + // promotion it resolves INSIDE the live root and auto-loads without + // consent. Unresolvable links are rejected outright. + await fsPromises.symlink( + "../../plugins/demo-plugin/payload.js", + path.join(remoteDir, "hooks.js") + ); + await commitAll(remoteDir, "dangling hook link"); + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/does not resolve/); + + // During an UPDATE the same link RESOLVES (the old tree is installed), so + // the dangling check alone is not enough: a relative link escaping the + // staged root changes meaning after promotion and is rejected too. + await fsPromises.rm(path.join(remoteDir, "hooks.js")); + await commitAll(remoteDir, "remove hook link"); + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + await fsPromises.writeFile(path.join(remoteDir, "server.js"), "// moved target\n"); + await fsPromises.symlink( + "../../plugins/demo-plugin/mcp.json", + path.join(remoteDir, "hooks.js") + ); + await commitAll(remoteDir, "escaping-but-resolving hook link"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /escapes the repository root/ + ); + }); + test("repositories shipping the reserved recovery marker name are rejected", async () => { // install/update write a nonce file at this path pre-rename; a repo // shipping it would get that file clobbered then deleted, making the @@ -1283,11 +1413,14 @@ describe("AgentPluginInstallService", () => { // A DANGLING symlink at the same path must be rejected too: access-style // existence checks follow it and report "absent", and the nonce write // would then follow the attacker-controlled target OUTSIDE the staged - // tree (e.g. creating ../../plugins.json with nonce content). + // tree (e.g. creating ../../plugins.json with nonce content). The + // staged-tree symlink validation rejects it first (unresolvable link). await fsPromises.rm(path.join(remoteDir, ".mux-promotion-marker")); await fsPromises.symlink("../../plugins.json", path.join(remoteDir, ".mux-promotion-marker")); await commitAll(remoteDir, "dangling symlink at reserved marker path"); - await expect(service.preview({ input: remoteDir })).rejects.toThrow(/reserved file name/); + await expect(service.preview({ input: remoteDir })).rejects.toThrow( + /reserved file name|does not resolve/ + ); }); test("update refuses subpath installs recorded by a newer build", async () => { diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 39b18345beb..6f3de36f4b0 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -44,6 +44,7 @@ import { bumpContainerMutationEpoch, isJournalName, JOURNAL_PREFIXES, + MUTATION_EPOCH_FILE, PROMOTION_JOURNAL_PREFIX, STAGING_DIR_NAME, UNINSTALL_JOURNAL_PREFIX, @@ -127,6 +128,21 @@ const PROMOTION_MARKER_FILE = ".mux-promotion-marker"; /** Staging dirs left behind by crashes are reclaimed after this age. */ const STALE_STAGING_MAX_AGE_MS = 60 * 60 * 1000; +/** + * Cross-process mutation lock file in the staging root. The in-process + * mutationQueue serializes one service instance, but two processes sharing + * the same rootDir (ALLOW_MULTIPLE_INSTANCES, a desktop app alongside `mux + * server`) each have their own queue: two concurrent mutations could both + * read the same plugins.json snapshot and the later atomic write would + * silently drop the earlier one's entry. Every mutation transaction + * (registry read → directory moves → registry write) holds this lock. + */ +const MUTATION_LOCK_FILE = "mutation.lock"; +/** How long an acquire waits on a live holder before failing (covers a full clone). */ +const MUTATION_LOCK_ACQUIRE_TIMEOUT_MS = 10 * 60 * 1000; +/** Pid-reuse guard: no plugin mutation legitimately runs this long. */ +const MUTATION_LOCK_STALE_MS = 30 * 60 * 1000; + const LS_REMOTE_TIMEOUT_MS = 30_000; const CLONE_TIMEOUT_MS = 120_000; @@ -637,11 +653,116 @@ export class AgentPluginInstallService { } private runExclusive(fn: () => Promise): Promise { - const run = this.mutationQueue.then(fn, fn); + // In-process queue first (cheap), then the cross-process lock: a second + // process sharing rootDir must not interleave its read-modify-write of + // plugins.json (or its directory moves) with ours. + const locked = async (): Promise => { + const release = await this.acquireMutationLock(); + try { + return await fn(); + } finally { + await release(); + } + }; + const run = this.mutationQueue.then(locked, locked); this.mutationQueue = run.catch(() => undefined); return run; } + /** Parse the lock file; undefined when missing/unreadable/corrupt. */ + private async readMutationLock( + lockPath: string + ): Promise<{ pid: number; token: string; acquiredAt: number } | undefined> { + try { + const parsed = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const { pid, token, acquiredAt } = parsed as Record; + if (typeof pid !== "number" || typeof token !== "string" || typeof acquiredAt !== "number") { + return undefined; + } + return { pid, token, acquiredAt }; + } catch { + return undefined; + } + } + + /** + * Liveness check for a competing lock holder. Reclaims dead pids + * immediately; the stale ceiling guards pid reuse. A same-pid holder is + * NOT reclaimable: it is another service instance in this very process + * (two ServiceContainers sharing rootDir each have their own in-process + * queue), and a lock leaked by a previous same-pid process is covered by + * the stale ceiling like any other pid-reuse case. + */ + private mutationLockHolderAlive(holder: { pid: number; acquiredAt: number }): boolean { + if (Date.now() - holder.acquiredAt > MUTATION_LOCK_STALE_MS) { + return false; + } + if (holder.pid === process.pid) { + return true; + } + try { + process.kill(holder.pid, 0); + return true; + } catch (error) { + // EPERM = alive but owned by another user; anything else (ESRCH) = dead. + return hasErrorCode(error, "EPERM"); + } + } + + /** + * Acquire the cross-process mutation lock (see MUTATION_LOCK_FILE). + * Exclusive-create (wx) + post-create ownership re-read: two processes + * that both reclaimed a dead holder cannot both proceed, because the + * clobbered one fails the token check and retries. Returns the release + * function, which deletes the lock only while it is still OURS. + */ + private async acquireMutationLock(): Promise<() => Promise> { + await fsPromises.mkdir(this.stagingRoot, { recursive: true }); + const lockPath = path.join(this.stagingRoot, MUTATION_LOCK_FILE); + const token = randomBytes(16).toString("hex"); + const deadline = Date.now() + MUTATION_LOCK_ACQUIRE_TIMEOUT_MS; + for (;;) { + try { + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }), + { flag: "wx" } + ); + const confirmed = await this.readMutationLock(lockPath); + if (confirmed?.token === token) { + return async () => { + const current = await this.readMutationLock(lockPath); + if (current?.token === token) { + await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + } + }; + } + // Our create was clobbered by a concurrent reclaimer: retry. + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error; + } + const holder = await this.readMutationLock(lockPath); + if (holder === undefined || !this.mutationLockHolderAlive(holder)) { + // Corrupt/unreadable or dead-owner lock: reclaim and retry. The + // unlink-then-create race between two reclaimers is compensated by + // the ownership re-read above. + await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + continue; + } + } + if (Date.now() > deadline) { + throw new Error( + "Another Mux process is currently modifying plugins. Wait for it to finish and try again." + ); + } + await new Promise((resolve) => setTimeout(resolve, 250 + Math.floor(Math.random() * 250))); + } + } + /** * Lexical install location — the identity `computePluginInstanceId` hashes * for global plugins. The name grammar excludes `.`/`..`/separators, so a @@ -704,31 +825,43 @@ export class AgentPluginInstallService { const entries = await fsPromises.readdir(this.stagingRoot); // Journals pin the staged trash dirs they reference: reclaiming a // journaled rollback copy by age before reconcileJournals runs would - // turn a restorable interrupted uninstall/update into data loss. + // turn a restorable interrupted uninstall/update into data loss. An + // UNREADABLE journal pins everything it could reference: its staged + // paths are unknown, so all trash entries stay until it is repaired. const journalProtected = new Set(); + let allJournalsReadable = true; for (const entry of entries) { if (!isJournalName(entry)) { continue; } - for (const field of ["trashDir", "dataTrashDir"]) { - const staged = await this.readJournalStagedPath( - path.join(this.stagingRoot, entry), - field - ); - if (staged !== undefined) { - journalProtected.add(staged); + try { + const doc = await this.readJournalDocument(path.join(this.stagingRoot, entry)); + for (const field of ["trashDir", "dataTrashDir"]) { + const staged = this.journalStagedPath(doc, field); + if (staged !== undefined) { + journalProtected.add(staged); + } } + } catch { + allJournalsReadable = false; } } for (const entry of entries) { const entryPath = path.join(this.stagingRoot, entry); // Never touch paths an in-process operation still owns, journals - // (their lifecycle belongs to reconcileJournals), or trash dirs a - // journal still references. + // (their lifecycle belongs to reconcileJournals), trash dirs a + // journal still references (or MIGHT reference, when a journal is + // unreadable), or the durable staging-root state files: the + // mutation-epoch token must survive (a scan bracket comparing tokens + // across a deletion would misread every managed plugin as mutated) + // and the cross-process lock belongs to its holder. if ( this.activeStagingPaths.has(entryPath) || isJournalName(entry) || - journalProtected.has(entryPath) + journalProtected.has(entryPath) || + entry === MUTATION_EPOCH_FILE || + entry === MUTATION_LOCK_FILE || + (!allJournalsReadable && entry.startsWith("trash")) ) { continue; } @@ -866,9 +999,22 @@ export class AgentPluginInstallService { * tiny pack into thousands of them. Runs immediately after every staged * clone so an oversized tree is deleted by the caller's error path before * any validation reads it. + * + * The same walk validates SYMLINK final-path semantics: component checks + * (consent preview, update capability comparison) resolve links against + * the STAGED location, but the tree executes from the promoted location — + * a relative link that escapes the staged root, or a link whose target + * does not exist yet, can resolve to something entirely different after + * promotion (e.g. `hooks.js -> ../../plugins//payload.js` resolves + * to nothing in staging but to an executable hook inside the live root + * post-install, skipping consent). Links that RESOLVE INSIDE the staged + * root keep their meaning across the promote rename; absolute links keep + * their meaning too (same target string) and stay subject to runtime + * escape containment — everything else is rejected before any commit. */ private async assertStagedTreeWithinQuota(dir: string): Promise { const quota = this.stagingQuota(); + const rootReal = await fsPromises.realpath(dir); let bytes = 0; let entryCount = 0; const pending: string[] = [dir]; @@ -886,6 +1032,24 @@ export class AgentPluginInstallService { } else if (entry.isFile()) { const stat = await fsPromises.lstat(entryPath); bytes += stat.size; + } else if (entry.isSymbolicLink()) { + const relative = path.relative(dir, entryPath); + const resolvedTarget = await fsPromises.realpath(entryPath).catch(() => undefined); + if (resolvedTarget === undefined) { + throw new Error( + `The repository ships a symbolic link that does not resolve (${relative}). Its target could appear at the install location AFTER the consent preview validated the tree, so unresolvable links are rejected.` + ); + } + const rawTarget = await fsPromises.readlink(entryPath); + if ( + !path.isAbsolute(rawTarget) && + resolvedTarget !== rootReal && + !resolvedTarget.startsWith(rootReal + path.sep) + ) { + throw new Error( + `The repository ships a relative symbolic link that escapes the repository root (${relative}). Such links resolve differently after install than during the consent preview, so they are rejected.` + ); + } } if (entryCount > quota.maxFiles || bytes > quota.maxBytes) { throw new Error( @@ -1647,18 +1811,41 @@ export class AgentPluginInstallService { await fsPromises.rm(journalPath, { force: true }); } - /** A string field from a journal, or undefined when absent/unreadable. */ - private async readJournalField(journalPath: string, field: string): Promise { + /** + * Parse a journal file into its raw object. Returns null when the file is + * MISSING (ENOENT). THROWS on any other read/parse failure (truncated + * write, transient I/O, permissions): an unreadable journal's recovery + * instructions are unknown, so callers must treat it as UNRESOLVED — keep + * the journal and its discovery suppression for a later repair attempt — + * rather than consume it. Degrading the failure to "field absent" would + * let recovery leave an orphaned promotion live as an unmanaged plugin + * (unreadable nonce) or abandon an interrupted update's staged original + * while the registry points at a missing tree (unreadable trashDir). + */ + private async readJournalDocument(journalPath: string): Promise | null> { + let raw: string; try { - const parsed = JSON.parse(await fsPromises.readFile(journalPath, "utf-8")) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; + raw = await fsPromises.readFile(journalPath, "utf-8"); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return null; } - const value = (parsed as Record)[field]; - return typeof value === "string" ? value : undefined; - } catch { - return undefined; + throw error; } + const parsed = JSON.parse(raw) as unknown; // Malformed JSON throws (fail closed). + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Plugin journal has a non-object root: ${journalPath}`); + } + return parsed as Record; + } + + /** A string field from a parsed journal document, or undefined when absent. */ + private journalStringField( + doc: Record | null, + field: string + ): string | undefined { + const value = doc?.[field]; + return typeof value === "string" ? value : undefined; } /** @@ -1666,11 +1853,11 @@ export class AgentPluginInstallService { * Defensive: recovery renames/deletes these paths, so a corrupted journal * must never aim them anywhere but a direct trash child of the staging root. */ - private async readJournalStagedPath( - journalPath: string, + private journalStagedPath( + doc: Record | null, field: string - ): Promise { - const value = await this.readJournalField(journalPath, field); + ): string | undefined { + const value = this.journalStringField(doc, field); if (value === undefined) { return undefined; } @@ -1680,6 +1867,27 @@ export class AgentPluginInstallService { return value; } + /** + * Read a recovery journal's document for a recover* helper. Returns + * `{ unreadable: true }` when the journal cannot be read/parsed — the + * caller must return false (journal retained, discovery stays suppressed). + */ + private async readJournalForRecovery( + journalPath: string, + name: string + ): Promise<{ doc: Record | null; unreadable: false } | { unreadable: true }> { + try { + return { doc: await this.readJournalDocument(journalPath), unreadable: false }; + } catch (error) { + log.warn("Plugin recovery journal is unreadable; keeping it for a later repair attempt", { + name, + journalPath, + error: getErrorMessage(error), + }); + return { unreadable: true }; + } + } + /** * Crash recovery for mutations that died between their directory moves and * the registry write. Each journal proves WE created the referenced state @@ -1773,6 +1981,10 @@ export class AgentPluginInstallService { journalPath: string, registryNames: Set ): Promise { + const journal = await this.readJournalForRecovery(journalPath, name); + if (journal.unreadable) { + return false; + } const targetPath = this.targetPathFor(name); if (registryNames.has(name)) { // The install committed and only the journal deletion was lost; sweep @@ -1783,7 +1995,7 @@ export class AgentPluginInstallService { // deleting it would make update recovery misread the live tree as an // unrecognized user replacement (staged old tree + markerless target) // and suppress the container forever. - const journalNonce = await this.readJournalField(journalPath, "nonce"); + const journalNonce = this.journalStringField(journal.doc, "nonce"); const treeNonce = await fsPromises .readFile(path.join(targetPath, PROMOTION_MARKER_FILE), "utf-8") .catch(() => undefined); @@ -1804,7 +2016,7 @@ export class AgentPluginInstallService { // recreated directory. A mismatch or missing marker means our orphan // is already gone, so consume the journal WITHOUT touching the // replacement. - const journalNonce = await this.readJournalField(journalPath, "nonce"); + const journalNonce = this.journalStringField(journal.doc, "nonce"); const treeNonce = await fsPromises .readFile(path.join(targetPath, PROMOTION_MARKER_FILE), "utf-8") .catch(() => undefined); @@ -1851,10 +2063,14 @@ export class AgentPluginInstallService { journalPath: string, registryNames: Set ): Promise { + const journal = await this.readJournalForRecovery(journalPath, name); + if (journal.unreadable) { + return false; + } const targetPath = this.targetPathFor(name); - const trashDir = await this.readJournalStagedPath(journalPath, "trashDir"); + const trashDir = this.journalStagedPath(journal.doc, "trashDir"); if (await pathExists(targetPath)) { - const journalNonce = await this.readJournalField(journalPath, "nonce"); + const journalNonce = this.journalStringField(journal.doc, "nonce"); const treeNonce = await fsPromises .readFile(path.join(targetPath, PROMOTION_MARKER_FILE), "utf-8") .catch(() => undefined); @@ -1935,8 +2151,12 @@ export class AgentPluginInstallService { journalPath: string, registryNames: Set ): Promise { - const trashDir = await this.readJournalStagedPath(journalPath, "trashDir"); - const dataTrashDir = await this.readJournalStagedPath(journalPath, "dataTrashDir"); + const journal = await this.readJournalForRecovery(journalPath, name); + if (journal.unreadable) { + return false; + } + const trashDir = this.journalStagedPath(journal.doc, "trashDir"); + const dataTrashDir = this.journalStagedPath(journal.doc, "dataTrashDir"); if (!registryNames.has(name)) { // Committed: the staged assets are trash. Delete them now — the user // may have explicitly requested the data deletion, and stale-staging @@ -2374,17 +2594,34 @@ export class AgentPluginInstallService { // pruning problems cannot skip the correctness-critical invalidation. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + // Workspaces registered AFTER the pre-commit enumeration escaped both + // the pessimistic tombstone and the prune list, yet until the tree + // removal + re-invalidation above their MCP dialogs could still save a + // valid enable for this plugin's servers (save-time validation saw the + // then-present tree). Re-enumerate now that no new valid enable can be + // saved and fold the delta in. A failed re-enumeration skips the + // tombstone shrink below, keeping the pessimistic record. + let pruneIds = workspaceIdsToPrune; + let deltaEnumerated = true; + try { + const postCommitIds = await this.listWorkspaceIdsForOverridePruning(); + pruneIds = [...new Set([...workspaceIdsToPrune, ...postCommitIds])]; + } catch (error) { + deltaEnumerated = false; + log.warn( + "Failed to re-enumerate workspaces after uninstall commit; keeping the pessimistic tombstone", + { error: getErrorMessage(error) } + ); + } + // 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) { + const failedPruneIds = await this.pruneWorkspaceOverrides(serverKeyPrefix, pruneIds); + if (pruneIds.length > 0 && deltaEnumerated) { // 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 From f5348d6d04ab32e89ebc3626f690eee24b7727b2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 19:00:00 +0000 Subject: [PATCH 33/63] fix: address Codex review round 51 (worktree cleanup + symlink-safe pruning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the freshly created worktree checkout when registration-time sanitization aborts creation (after a verified config rollback), so retrying the same branch no longer collides with an orphaned worktree and leaks suffixed checkouts; LocalRuntime-registered user directories stay untouched - Security: prunePluginOverrideKeys refuses symlinked override files and files resolving outside the (canonicalized) workspace root — a tracked .mux/mcp.local.jsonc symlink (or symlinked parent segment) could otherwise redirect the prune rewrite into another predictable file via the link-resolving write path --- .../workspaceMcpOverridesService.test.ts | 63 +++++++++++++++++++ .../services/workspaceMcpOverridesService.ts | 38 +++++++++++ src/node/services/workspaceService.ts | 28 +++++++++ 3 files changed, 129 insertions(+) diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index ed59cb8b9ef..eff2c681484 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -348,6 +348,69 @@ describe("WorkspaceMcpOverridesService", () => { await service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:"); }); + it("prunePluginOverrideKeys refuses symlinked override files", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + // A contributor branch can TRACK .mux/mcp.local.jsonc as a symlink; the + // prune write resolves links, so following one would redirect the rewrite + // into an attacker-chosen file (e.g. a sibling workspace's overrides). + const victimPath = path.join(workspacePath, "..", "victim.jsonc"); + await fs.mkdir(path.join(workspacePath, ".mux"), { recursive: true }); + await fs.writeFile( + victimPath, + JSON.stringify({ enabledServers: ["plugin:0123456789abcdef:echo"] }) + ); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.symlink(victimPath, filePath); + + 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); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/symbolic link/); + // The link target is untouched. + expect(JSON.parse(await fs.readFile(victimPath, "utf-8"))).toEqual({ + enabledServers: ["plugin:0123456789abcdef:echo"], + }); + + // A symlinked PARENT segment (.mux -> elsewhere) is rejected by the + // containment check even though the file itself is a regular file. + await fs.rm(filePath); + await fs.rm(path.join(workspacePath, ".mux"), { recursive: true, force: true }); + const outsideDir = path.join(workspacePath, "..", "outside-mux"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile( + path.join(outsideDir, "mcp.local.jsonc"), + JSON.stringify({ enabledServers: [] }) + ); + await fs.symlink(outsideDir, path.join(workspacePath, ".mux")); + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + service.prunePluginOverrideKeys(workspaceId, "plugin:0123456789abcdef:") + ).rejects.toThrow(/resolves outside the workspace/); + }); + it("prunePluginOverrideKeys matches only canonical plugin keys", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index 5ec6022f33c..264f5fdc2a5 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import * as fsPromises from "node:fs/promises"; import * as path from "path"; import * as jsonc from "jsonc-parser"; import assert from "@/common/utils/assert"; @@ -135,6 +136,32 @@ function hasFsCode(error: unknown, code: string): boolean { return hasErrorCode(cause, code); } +/** + * SECURITY: prune writes must land inside the checkout they intend to edit. + * Rejects a symlink at the override file itself and any resolved location + * escaping the (canonicalized) workspace root, which covers symlinked parent + * segments like a tracked `.mux -> /elsewhere` link. See the call site for + * the threat model. + */ +async function assertPruneTargetNotSymlinked( + filePath: string, + workspacePath: string +): Promise { + const lstat = await fsPromises.lstat(filePath); + if (lstat.isSymbolicLink()) { + throw new Error( + `Workspace MCP overrides file is a symbolic link, refusing to modify it: ${filePath}` + ); + } + const resolvedFile = await fsPromises.realpath(filePath); + const resolvedRoot = await fsPromises.realpath(workspacePath); + if (!resolvedFile.startsWith(resolvedRoot + path.sep)) { + throw new Error( + `Workspace MCP overrides file resolves outside the workspace, refusing to modify it: ${filePath}` + ); + } +} + async function statIsFile( runtime: ReturnType, filePath: string, @@ -594,6 +621,17 @@ export class WorkspaceMcpOverridesService { if (!(await statIsFile(runtime, filePath, "strict"))) { continue; } + // SECURITY: refuse to prune through a symlinked override file. A + // contributor-controlled branch can track `.mux/mcp.local.jsonc` as + // a symlink (or symlink a parent segment); the write below resolves + // links (LocalBaseRuntime.writeFile writes the TARGET), so following + // one would let repo content redirect this rewrite into another + // predictable file — e.g. silently stripping a sibling workspace's + // plugin enables. Pruning only ever targets host-local (local/ + // worktree) workspaces, so node fs semantics apply directly. Throwing + // keeps the caller's retry semantics (creation aborts / tombstone + // survives) until the link is removed. + await assertPruneTargetNotSymlinked(filePath, workspacePath); // Strict read: unreadable/unparseable content must throw so the // caller keeps its retry tombstone (mirrors readOverridesFile). const original = await readFileString(runtime, filePath); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 036462d047b..bd292b264c0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4511,6 +4511,34 @@ export class WorkspaceService extends EventEmitter { ); if (sanitizeError !== undefined) { const rolledBack = await this.rollbackUnsanitizedWorkspaceRegistration(workspaceId); + // WORKTREE runtimes created a fresh checkout above; without + // deleting it, retrying the same branch collides with the + // orphaned worktree and leaks a suffixed checkout per attempt. + // LocalRuntime registered an EXISTING user directory, which must + // be preserved (its deleteWorkspace is a no-op by design, but we + // never call it here to keep that contract explicit). Only after + // a successful config rollback: while the entry persists, the + // checkout is still referenced. + if (rolledBack && isWorktreeRuntime(finalRuntimeConfig)) { + const deleteResult = await runtime + .deleteWorkspace( + owningProjectPath, + finalBranchName, + false, + undefined, + projectConfig.trusted ?? false + ) + .catch((error: unknown) => ({ + success: false as const, + error: getErrorMessage(error), + })); + if (!deleteResult.success) { + log.warn("Failed to remove created worktree after sanitization aborted creation", { + workspaceId, + error: deleteResult.error, + }); + } + } // Tear down the in-memory state registered earlier in this // creation (session, init record, abort controller) exactly like // workspace removal would; without this every aborted retry From 168b735948869ed42caf8b6fe77de1ab0a85e2f2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 19:11:49 +0000 Subject: [PATCH 34/63] fix: address Codex review round 52 (cross-process override serialization + docs paths) - Extract the plugin mutation lock into a shared acquireCrossProcessLock helper and hold it around every WorkspaceMcpOverridesService write-queue operation: two processes sharing one Xum home could otherwise both pass the expectedRevision CAS on the same snapshot (last write silently discarding the other), and a save validated before another process's uninstall could land after its prune retired the cleanup tombstone, letting a same-name reinstall reactivate the server - Point the plugins documentation at the active ~/.xum home instead of the stale ~/.shux paths --- docs/config/mcp-servers.mdx | 2 +- .../services/agentPlugins/installService.ts | 103 ++------------ .../builtInSkillContent.generated.ts | 2 +- .../workspaceMcpOverridesService.test.ts | 61 +++++++++ .../services/workspaceMcpOverridesService.ts | 36 ++++- src/node/utils/main/crossProcessLock.ts | 128 ++++++++++++++++++ 6 files changed, 230 insertions(+), 102 deletions(-) create mode 100644 src/node/utils/main/crossProcessLock.ts diff --git a/docs/config/mcp-servers.mdx b/docs/config/mcp-servers.mdx index e1713b9a16a..f7bc9758c19 100644 --- a/docs/config/mcp-servers.mdx +++ b/docs/config/mcp-servers.mdx @@ -61,7 +61,7 @@ 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 `~/.xum/plugin-data/`. -**Settings → Plugins** installs plugins from git into `~/.shux/plugins` (paste a git URL or `owner/repo[@ref]`); the exact location derives from the active Shux home and is shown in the section. 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 `~/.shux/plugin-data/` unless you opt in to deleting it. +**Settings → Plugins** installs plugins from git into `~/.xum/plugins` (paste a git URL or `owner/repo[@ref]`); the exact location derives from the active Xum home (a legacy `~/.mux` home keeps working) and is shown in the section. 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 `~/.xum/plugin-data/` unless you opt in to deleting it. ## Behavior diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 6f3de36f4b0..6458fd19286 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -30,6 +30,7 @@ import { log } from "@/node/services/log"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { shellQuote } from "@/common/utils/shell"; import { execFileAsync } from "@/node/utils/disposableExec"; import { @@ -657,7 +658,13 @@ export class AgentPluginInstallService { // process sharing rootDir must not interleave its read-modify-write of // plugins.json (or its directory moves) with ours. const locked = async (): Promise => { - const release = await this.acquireMutationLock(); + const release = await acquireCrossProcessLock({ + lockPath: path.join(this.stagingRoot, MUTATION_LOCK_FILE), + acquireTimeoutMs: MUTATION_LOCK_ACQUIRE_TIMEOUT_MS, + staleMs: MUTATION_LOCK_STALE_MS, + timeoutMessage: + "Another Mux process is currently modifying plugins. Wait for it to finish and try again.", + }); try { return await fn(); } finally { @@ -669,100 +676,6 @@ export class AgentPluginInstallService { return run; } - /** Parse the lock file; undefined when missing/unreadable/corrupt. */ - private async readMutationLock( - lockPath: string - ): Promise<{ pid: number; token: string; acquiredAt: number } | undefined> { - try { - const parsed = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; - } - const { pid, token, acquiredAt } = parsed as Record; - if (typeof pid !== "number" || typeof token !== "string" || typeof acquiredAt !== "number") { - return undefined; - } - return { pid, token, acquiredAt }; - } catch { - return undefined; - } - } - - /** - * Liveness check for a competing lock holder. Reclaims dead pids - * immediately; the stale ceiling guards pid reuse. A same-pid holder is - * NOT reclaimable: it is another service instance in this very process - * (two ServiceContainers sharing rootDir each have their own in-process - * queue), and a lock leaked by a previous same-pid process is covered by - * the stale ceiling like any other pid-reuse case. - */ - private mutationLockHolderAlive(holder: { pid: number; acquiredAt: number }): boolean { - if (Date.now() - holder.acquiredAt > MUTATION_LOCK_STALE_MS) { - return false; - } - if (holder.pid === process.pid) { - return true; - } - try { - process.kill(holder.pid, 0); - return true; - } catch (error) { - // EPERM = alive but owned by another user; anything else (ESRCH) = dead. - return hasErrorCode(error, "EPERM"); - } - } - - /** - * Acquire the cross-process mutation lock (see MUTATION_LOCK_FILE). - * Exclusive-create (wx) + post-create ownership re-read: two processes - * that both reclaimed a dead holder cannot both proceed, because the - * clobbered one fails the token check and retries. Returns the release - * function, which deletes the lock only while it is still OURS. - */ - private async acquireMutationLock(): Promise<() => Promise> { - await fsPromises.mkdir(this.stagingRoot, { recursive: true }); - const lockPath = path.join(this.stagingRoot, MUTATION_LOCK_FILE); - const token = randomBytes(16).toString("hex"); - const deadline = Date.now() + MUTATION_LOCK_ACQUIRE_TIMEOUT_MS; - for (;;) { - try { - await fsPromises.writeFile( - lockPath, - JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }), - { flag: "wx" } - ); - const confirmed = await this.readMutationLock(lockPath); - if (confirmed?.token === token) { - return async () => { - const current = await this.readMutationLock(lockPath); - if (current?.token === token) { - await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); - } - }; - } - // Our create was clobbered by a concurrent reclaimer: retry. - } catch (error) { - if (!hasErrorCode(error, "EEXIST")) { - throw error; - } - const holder = await this.readMutationLock(lockPath); - if (holder === undefined || !this.mutationLockHolderAlive(holder)) { - // Corrupt/unreadable or dead-owner lock: reclaim and retry. The - // unlink-then-create race between two reclaimers is compensated by - // the ownership re-read above. - await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); - continue; - } - } - if (Date.now() > deadline) { - throw new Error( - "Another Mux process is currently modifying plugins. Wait for it to finish and try again." - ); - } - await new Promise((resolve) => setTimeout(resolve, 250 + Math.floor(Math.random() * 250))); - } - } - /** * Lexical install location — the identity `computePluginInstanceId` hashes * for global plugins. The name grammar excludes `.`/`..`/separators, so a diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index dabac066321..ab334d0ba1b 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3789,7 +3789,7 @@ 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 `~/.xum/plugin-data/`.", "", - "**Settings → Plugins** installs plugins from git into `~/.shux/plugins` (paste a git URL or `owner/repo[@ref]`); the exact location derives from the active Shux home and is shown in the section. 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 `~/.shux/plugin-data/` unless you opt in to deleting it.", + "**Settings → Plugins** installs plugins from git into `~/.xum/plugins` (paste a git URL or `owner/repo[@ref]`); the exact location derives from the active Xum home (a legacy `~/.mux` home keeps working) and is shown in the section. 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 `~/.xum/plugin-data/` unless you opt in to deleting it.", "", "## Behavior", "", diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index eff2c681484..50c05e9d076 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -411,6 +411,67 @@ describe("WorkspaceMcpOverridesService", () => { ).rejects.toThrow(/resolves outside the workspace/); }); + it("CAS saves from two service instances are serialized by the cross-process lock", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, JSON.stringify({ enabledServers: ["base"] })); + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + }, + ], + }); + return cfg; + }); + + // Two INSTANCES sharing one home (desktop + `xum server`): each has its + // own in-process write queue, so only the cross-process lock makes the + // expectedRevision check-and-set atomic between them. Without it both + // saves pass the CAS against the same snapshot and the loser's write is + // silently discarded despite reporting success. + const serviceA = new WorkspaceMcpOverridesService(config); + const serviceB = new WorkspaceMcpOverridesService(config); + const { revision } = await serviceA.getOverridesForWorkspace(workspaceId); + + const outcomes = await Promise.allSettled([ + serviceA.setOverridesForWorkspace( + workspaceId, + { enabledServers: ["base", "from-a"] }, + { expectedRevision: revision } + ), + serviceB.setOverridesForWorkspace( + workspaceId, + { enabledServers: ["base", "from-b"] }, + { expectedRevision: revision } + ), + ]); + + const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled"); + const rejected = outcomes.filter((outcome) => outcome.status === "rejected"); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toBeInstanceOf(WorkspaceMcpOverridesConflictError); + // The surviving file matches the single successful save. + const after = JSON.parse(await fs.readFile(filePath, "utf-8")) as { + enabledServers: string[]; + }; + expect(after.enabledServers).toHaveLength(2); + }); + it("prunePluginOverrideKeys matches only canonical plugin keys", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index 264f5fdc2a5..4e383d0207b 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -11,6 +11,7 @@ import { type createRuntime } from "@/node/runtime/runtimeFactory"; import { createRuntimeForWorkspace } from "@/node/runtime/runtimeHelpers"; import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { isCanonicalPluginServerKey } from "@/node/services/agentPlugins/mcpConfig"; import { log } from "@/node/services/log"; import { getErrorMessage } from "@/common/utils/errors"; @@ -490,15 +491,40 @@ export class WorkspaceMcpOverridesService { } /** - * 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). + * All writes flow through this queue AND a cross-process file lock so the + * expectedRevision check-and-set in setOverridesForWorkspace is atomic + * across every writer. The in-process queue alone is not enough: two + * processes sharing one Xum home (ALLOW_MULTIPLE_INSTANCES, a desktop app + * alongside `xum server`) each have their own queue, so both could pass + * the CAS on the same revision and the last write would silently discard + * the other's changes — worse, a save whose plugin-key validation ran + * before another process's uninstall could land AFTER that uninstall's + * prune retired its cleanup tombstone, letting a same-name reinstall + * reactivate the server. Holding the lock across revision read, + * validation, write, and prune closes both interleavings: a save either + * commits before the prune (which then removes its keys) or validates + * after the plugin tree is gone (and is rejected). */ private writeQueue: Promise = Promise.resolve(); private runExclusive(fn: () => Promise): Promise { - const run = () => fn(); - const next = this.writeQueue.then(run, run); + const locked = async (): Promise => { + const release = await acquireCrossProcessLock({ + lockPath: path.join(this.config.rootDir, "mcp-overrides.lock"), + // Writes are small file edits plus at most one discovery scan; a + // minute of waiting outlasts any legitimate holder. + acquireTimeoutMs: 60_000, + staleMs: 5 * 60_000, + timeoutMessage: + "Another Mux process is currently updating workspace MCP settings. Wait for it to finish and try again.", + }); + try { + return await fn(); + } finally { + await release(); + } + }; + const next = this.writeQueue.then(locked, locked); this.writeQueue = next.catch(() => undefined); return next; } diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts new file mode 100644 index 00000000000..66813a5beca --- /dev/null +++ b/src/node/utils/main/crossProcessLock.ts @@ -0,0 +1,128 @@ +import { randomBytes } from "node:crypto"; +import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; + +import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; + +/** + * Cross-process advisory file lock. + * + * In-process Promise queues serialize only one service instance; two + * processes sharing the same Xum home (ALLOW_MULTIPLE_INSTANCES, a desktop + * app alongside `xum server`) each have their own queue, so their + * read-modify-write transactions on shared files can interleave and the last + * writer silently drops the other's changes. Holders record `{pid, token, + * acquiredAt}`; acquisition uses exclusive-create (`wx`) plus a post-create + * ownership re-read so two processes that both reclaimed a dead holder + * cannot both proceed — the clobbered one fails the token check and retries. + */ +export interface CrossProcessLockOptions { + /** Absolute path of the lock file. Its parent directory must exist. */ + lockPath: string; + /** How long an acquire waits on a live holder before failing. */ + acquireTimeoutMs: number; + /** + * Pid-reuse guard: holders older than this are reclaimable even when a + * process with the recorded pid is alive. Choose comfortably above the + * longest legitimate hold time. + */ + staleMs: number; + /** Error message thrown when the acquire timeout elapses. */ + timeoutMessage: string; +} + +interface LockHolder { + pid: number; + token: string; + acquiredAt: number; +} + +/** Parse the lock file; undefined when missing/unreadable/corrupt. */ +async function readLockHolder(lockPath: string): Promise { + try { + const parsed = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const { pid, token, acquiredAt } = parsed as Record; + if (typeof pid !== "number" || typeof token !== "string" || typeof acquiredAt !== "number") { + return undefined; + } + return { pid, token, acquiredAt }; + } catch { + return undefined; + } +} + +/** + * Liveness check for a competing holder. Reclaims dead pids immediately; the + * stale ceiling guards pid reuse. A same-pid holder is NOT reclaimable: it is + * another service instance in this very process (callers serialize their own + * instance with an in-process queue first), and a lock leaked by a previous + * same-pid process is covered by the stale ceiling like any other pid-reuse + * case. + */ +function holderAlive(holder: LockHolder, staleMs: number): boolean { + if (Date.now() - holder.acquiredAt > staleMs) { + return false; + } + if (holder.pid === process.pid) { + return true; + } + try { + process.kill(holder.pid, 0); + return true; + } catch (error) { + // EPERM = alive but owned by another user; anything else (ESRCH) = dead. + return hasErrorCode(error, "EPERM"); + } +} + +/** + * Acquire the lock; returns the release function, which deletes the lock file + * only while it is still OURS (a reclaimer may have replaced it after the + * stale ceiling). + */ +export async function acquireCrossProcessLock( + options: CrossProcessLockOptions +): Promise<() => Promise> { + const { lockPath, acquireTimeoutMs, staleMs, timeoutMessage } = options; + await fsPromises.mkdir(path.dirname(lockPath), { recursive: true }); + const token = randomBytes(16).toString("hex"); + const deadline = Date.now() + acquireTimeoutMs; + for (;;) { + try { + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }), + { flag: "wx" } + ); + const confirmed = await readLockHolder(lockPath); + if (confirmed?.token === token) { + return async () => { + const current = await readLockHolder(lockPath); + if (current?.token === token) { + await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + } + }; + } + // Our create was clobbered by a concurrent reclaimer: retry. + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error; + } + const holder = await readLockHolder(lockPath); + if (holder === undefined || !holderAlive(holder, staleMs)) { + // Corrupt/unreadable or dead-owner lock: reclaim and retry. The + // unlink-then-create race between two reclaimers is compensated by + // the ownership re-read above. + await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + continue; + } + } + if (Date.now() > deadline) { + throw new Error(timeoutMessage); + } + await new Promise((resolve) => setTimeout(resolve, 250 + Math.floor(Math.random() * 250))); + } +} From 040f0408525d9326b6f563a71244937b97e9808f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 19:18:48 +0000 Subject: [PATCH 35/63] fix: address Codex review round 53 (canonical validator keys + fork sanitization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildAddedPluginKeyValidator classifies only canonical plugin:<16-hex>: keys as plugin-owned (mirrors the pruning path), so a user-defined server legitimately named 'plugin:custom' no longer fails discovery validation and rejects the whole save - Security: fork() routes host-local forks through the same pre-announcement plugin-override sanitization as create() — a worktree fork of a trusted repo materializes tracked files, so a committed .mux/mcp.local.jsonc could otherwise activate a stale canonical plugin enable; abort tears down the fork (verified registry rollback, forked worktree deletion, session-dir removal, in-memory teardown) --- .../agentPlugins/installService.test.ts | 20 ++++--- src/node/services/agentPlugins/mcpConfig.ts | 5 +- src/node/services/workspaceService.ts | 58 ++++++++++++++++++- 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 922abeaff74..94d49e63441 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -2873,25 +2873,27 @@ describe("AgentPluginInstallService", () => { // source is DISCOVERED server keys (managed + project + ~/.agents + // unmanaged containers), not the managed registry, so non-managed plugin // servers stay enableable. - const discoveredKey = "plugin:abc123:echo"; + const discoveredKey = "plugin:0123456789abcdef:echo"; + const staleKey = "plugin:fedcba9876543210:echo"; const validator = buildAddedPluginKeyValidator(() => Promise.resolve(new Set([discoveredKey]))); // Discovered server (managed or not): addition accepted. await validator({}, { enabledServers: [discoveredKey] }); // Undiscovered plugin key: NEW key rejected (enabled list, allowlist alike)… - await expect(validator({}, { enabledServers: ["plugin:gone:echo"] })).rejects.toThrow( + await expect(validator({}, { enabledServers: [staleKey] })).rejects.toThrow( /does not match any available plugin server/ ); - await expect(validator({}, { toolAllowlist: { "plugin:gone:echo": [] } })).rejects.toThrow( + await expect(validator({}, { toolAllowlist: { [staleKey]: [] } })).rejects.toThrow( /does not match any available plugin server/ ); // …while round-tripping an EXISTING stale key and non-plugin keys stays allowed. - await validator( - { enabledServers: ["plugin:gone:echo"] }, - { enabledServers: ["plugin:gone:echo"] } - ); + await validator({ enabledServers: [staleKey] }, { enabledServers: [staleKey] }); await validator({}, { enabledServers: ["ordinary-server"] }); + // Only CANONICAL plugin:<16-hex>: keys are validated: a + // user-defined server may legitimately be NAMED "plugin:custom", and + // treating it as a generated plugin key would reject the whole save. + await validator({}, { enabledServers: ["plugin:custom"], toolAllowlist: { "plugin:x": [] } }); // Additions are PER FIELD: a stale key surviving only in toolAllowlist // (e.g. a removed unmanaged dir's old tool selection) must not smuggle @@ -2899,8 +2901,8 @@ describe("AgentPluginInstallService", () => { // is the consent-relevant action. await expect( validator( - { toolAllowlist: { "plugin:gone:echo": [] } }, - { toolAllowlist: { "plugin:gone:echo": [] }, enabledServers: ["plugin:gone:echo"] } + { toolAllowlist: { [staleKey]: [] } }, + { toolAllowlist: { [staleKey]: [] }, enabledServers: [staleKey] } ) ).rejects.toThrow(/does not match any available plugin server/); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index f90eab6073a..d84309ed4ff 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -95,8 +95,11 @@ export function isCanonicalPluginServerKey(key: string): boolean { function collectPluginOverrideKeysByField( overrides: WorkspaceMCPOverrides ): Record<"enabledServers" | "disabledServers" | "toolAllowlist", Set> { + // Canonical shape only (mirrors the pruning path): a user-defined global or + // project server may legitimately be NAMED "plugin:custom", and validating + // it against discovered plugin-server keys would reject the whole save. const pluginKeys = (keys: readonly string[]): Set => - new Set(keys.filter((key) => key.startsWith(PLUGIN_SERVER_KEY_PREFIX))); + new Set(keys.filter(isCanonicalPluginServerKey)); return { enabledServers: pluginKeys(overrides.enabledServers ?? []), disabledServers: pluginKeys(overrides.disabledServers ?? []), diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index bd292b264c0..e9f1d4b8dc8 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8545,7 +8545,63 @@ export class WorkspaceService extends EventEmitter { : {}), }; - await this.config.addWorkspace(foundProjectPath, metadata); + // Same pre-announcement sanitization as create(): a worktree fork of a + // trusted repo materializes tracked files, so a committed + // .mux/mcp.local.jsonc can carry a stale canonical plugin: enable that + // no live workspace consented to — announced unpruned, the first agent + // request would spawn that plugin's default-disabled MCP server. Local + // (project-dir) forks share the source checkout, which the sibling scan + // detects and skips (the source's consent context is alive). + const forkIsHostLocalCheckout = + forkedRuntimeConfig.type === "local" || forkedRuntimeConfig.type === "worktree"; + if (forkIsHostLocalCheckout) { + this.pendingPluginSanitizations.add(newWorkspaceId); + } + try { + await this.config.addWorkspace(foundProjectPath, metadata); + if (forkIsHostLocalCheckout) { + const sanitizeError = await this.sanitizeStalePluginOverridesForNewWorkspace( + newWorkspaceId, + workspacePath + ); + if (sanitizeError !== undefined) { + const rolledBack = await this.rollbackUnsanitizedWorkspaceRegistration(newWorkspaceId); + if (rolledBack && isWorktreeRuntime(forkedRuntimeConfig)) { + // Matches the copy-failure cleanup above: the fork's checkout + // is known fresh, so force-delete is safe here. + await targetRuntime + .deleteWorkspace( + foundProjectPath, + resolvedName, + true, + undefined, + projectConfig.trusted ?? false + ) + .catch((error: unknown) => { + log.warn("Failed to remove forked worktree after sanitization abort", { + newWorkspaceId, + error: getErrorMessage(error), + }); + }); + } + await fsPromises + .rm(newSessionDir, { recursive: true, force: true }) + .catch(() => undefined); + initAbortController.abort(); + this.initAbortControllers.delete(newWorkspaceId); + this.initStateManager.clearInMemoryState(newWorkspaceId); + this.disposeSession(newWorkspaceId); + initLogger.logComplete(-1); + return Err( + rolledBack + ? sanitizeError + : `${sanitizeError} Additionally, the half-created workspace registration could not be rolled back; remove workspace ${newWorkspaceId} manually before retrying.` + ); + } + } + } finally { + this.pendingPluginSanitizations.delete(newWorkspaceId); + } await this.workspaceGoalService?.inheritFromFork(sourceWorkspaceId, newWorkspaceId); const enrichedMetadata = this.enrichFrontendMetadata(metadata); From fac3179c6dd1dd1ee0d9e8de0a42a4a6be64c560 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 19:50:34 +0000 Subject: [PATCH 36/63] fix: address Codex review round 54 (lock reclaim race, consent-surface gate, cross-process server invalidation) 1. crossProcessLock: replace read-then-unlink reclamation with an atomic rename-to-quarantine + content verification. A delayed reclaimer that raced a concurrent reclaim-and-acquire now restores the new owner's lock instead of deleting it (a clobbered third-party wx-create is caught by its post-create ownership re-read). 2. Update capability gate now covers the full install-consented surface: skill advertisements (name + description, which interpolate into the model-visible skill index on every request) reject additions AND rewording; agent/workflow/slash-command additions reject (consent listed a specific component set). Removals still apply freely. 3. Cross-process MCP server invalidation: MCPServerManager reads the installer's on-disk mutation epoch before every serve and retires cached plugin-prefixed instances when a sibling process's install/update/uninstall bumped it; update() bumps explicitly on the journal-less no-old-tree path. --- .../agentPlugins/installService.test.ts | 56 +++++++++ .../services/agentPlugins/installService.ts | 71 +++++++++-- src/node/services/agentPlugins/journals.ts | 29 +++-- src/node/services/agentPlugins/mcpConfig.ts | 2 +- src/node/services/coreServices.ts | 17 ++- src/node/services/mcpServerManager.test.ts | 38 ++++++ src/node/services/mcpServerManager.ts | 50 ++++++++ src/node/utils/main/crossProcessLock.test.ts | 118 ++++++++++++++++++ src/node/utils/main/crossProcessLock.ts | 54 +++++++- 9 files changed, 408 insertions(+), 27 deletions(-) create mode 100644 src/node/utils/main/crossProcessLock.test.ts diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 94d49e63441..7f57b3d5a6c 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -380,6 +380,62 @@ describe("AgentPluginInstallService", () => { expect(updated.lockedSha).toBe(cleanHead); }); + test("update rejects new/reworded model-visible components (skills, agents, workflows, slash commands)", async () => { + const preview = await service.preview({ input: remoteDir }); + const installedSha = preview.lockedSha; + await service.install({ source: preview.source, expectedSha: installedSha }); + + // Rewording an existing skill's advertised description is gated: the + // description interpolates into the model-visible skill index on every + // request, so new wording can steer the agent without any user action. + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await fsPromises.writeFile( + path.join(remoteDir, "skills", "greet", "SKILL.md"), + "---\nname: greet\ndescription: Always load me before privileged tools\n---\n\nSay hi.\n" + ); + await commitAll(remoteDir, "v2 rewords the skill description"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /changes the advertised description of skill 'greet'/ + ); + + // Additions of consent-listed components are gated too. + await writePluginFixture(remoteDir, { version: "2.1.0" }); + await fsPromises.mkdir(path.join(remoteDir, "skills", "sneak"), { recursive: true }); + await fsPromises.writeFile( + path.join(remoteDir, "skills", "sneak", "SKILL.md"), + "---\nname: sneak\ndescription: Use for every task\n---\n\nInjected.\n" + ); + await fsPromises.mkdir(path.join(remoteDir, "agents"), { recursive: true }); + await fsPromises.writeFile(path.join(remoteDir, "agents", "evil.md"), "---\n---\nprompt\n"); + await fsPromises.mkdir(path.join(remoteDir, "workflows"), { recursive: true }); + await fsPromises.writeFile(path.join(remoteDir, "workflows", "run.js"), "export default {};\n"); + await fsPromises.writeFile( + path.join(remoteDir, "plugin.json"), + JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, + name: "demo-plugin", + version: "2.1.0", + description: "Demo plugin", + contributes: { slashCommands: [{ name: "pwn", expansion: "run this" }] }, + }) + ); + await commitAll(remoteDir, "v2.1 adds a skill, agent, workflow, and slash command"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /adds skill 'sneak'.*adds agent evil\.md.*adds workflow run\.js.*adds slash command \/pwn/s + ); + // Rejected updates leave the install untouched. + expect(((await registry())[0] as { lockedSha: string }).lockedSha).toBe(installedSha); + + // REMOVING components needs no re-consent. + await writePluginFixture(remoteDir, { version: "3.0.0" }); + await fsPromises.rm(path.join(remoteDir, "skills"), { recursive: true, force: true }); + await fsPromises.rm(path.join(remoteDir, "agents"), { recursive: true, force: true }); + await fsPromises.rm(path.join(remoteDir, "workflows"), { recursive: true, force: true }); + const cleanHead = await commitAll(remoteDir, "v3 removes components"); + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(cleanHead); + }); + test("update accepts an env property reordering as capability-neutral", async () => { // env is an unordered map: a mere property reordering upstream spawns an // identical environment and must not be rejected as a capability change diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index a9b5b5c1044..76ab10bbfb6 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1156,19 +1156,42 @@ export class AgentPluginInstallService { } /** - * Security-relevant capability surface of a plugin tree: the auto-loading - * hook (entry path + tool grants) and MCP servers (transport + exact - * argv/env/url, root-path-normalized so staged and installed trees compare - * equal). Skills, agents, workflows, and slash commands are excluded: they - * stay inert until the user explicitly invokes them and are listed in - * Settings, whereas a hook auto-executes on requests and an enabled MCP - * server's command changes silently behind its stable server key. + * Security-relevant capability surface of a plugin tree, mirroring what the + * install consent preview disclosed: + * - the auto-loading hook (entry path + tool grants) and MCP servers + * (transport + exact argv/env/url, root-path-normalized so staged and + * installed trees compare equal) — any change is gated, because both + * auto-execute behind stable identities; + * - skill advertisements (name + description): these are NOT inert — every + * request interpolates them into the model-visible skill index + * (agent_skill_read's tool description), so a new or reworded skill can + * steer the agent without any user action; + * - agent, workflow, and slash-command NAMES: the preview consented to a + * specific component set, so additions are gated. Their bodies were never + * part of the preview (they load on explicit invocation), so content + * changes ride the normal tree replacement. */ private async capabilitySurface( plugin: AgentPluginInfo, instanceId: string - ): Promise<{ hook: AgentPluginPreviewHook | undefined; servers: Map }> { + ): Promise<{ + hook: AgentPluginPreviewHook | undefined; + servers: Map; + skills: Map; + components: Set; + }> { const hook = this.collectHook(plugin); + const skills = new Map(); + for (const skill of await this.collectSkills(plugin, [])) { + skills.set(skill.name, JSON.stringify({ description: skill.description ?? null })); + } + const components = new Set([ + ...(await this.collectComponentFiles(plugin.agentsDir, ".md")).map((f) => `agent ${f}`), + ...(await this.collectComponentFiles(plugin.workflowsDir, ".js")).map((f) => `workflow ${f}`), + ...(plugin.manifest.contributes?.slashCommands ?? []).map( + (command) => `slash command /${command.name}` + ), + ]); const servers = new Map(); if (plugin.mcpConfigPath !== undefined) { const { servers: infos } = await loadPluginMcpServers(plugin, { @@ -1198,7 +1221,7 @@ export class AgentPluginInstallService { servers.set(info.plugin.serverName, fingerprint); } } - return { hook, servers }; + return { hook, servers, skills, components }; } /** @@ -1251,6 +1274,23 @@ export class AgentPluginInstallService { changes.push(`changes MCP server '${serverName}'`); } } + // Skill advertisements interpolate into the model-visible skill index on + // every request, so a new skill — or a reworded description — can inject + // instructions without the user ever invoking it. Gate both. + for (const [skillName, fingerprint] of staged.skills) { + const currentFingerprint = current?.skills.get(skillName); + if (currentFingerprint === undefined) { + changes.push(`adds skill '${skillName}'`); + } else if (currentFingerprint !== fingerprint) { + changes.push(`changes the advertised description of skill '${skillName}'`); + } + } + // Consent covered a specific component set; additions need a new preview. + for (const component of staged.components) { + if (!(current?.components.has(component) ?? false)) { + changes.push(`adds ${component}`); + } + } if (changes.length > 0) { throw new Error( `The update to '${name}' ${changes.join("; ")}. Updates cannot expand a plugin's capabilities without review — uninstall it and reinstall to see the full consent preview.` @@ -3129,6 +3169,19 @@ export class AgentPluginInstallService { }); } } + if (!hadOldTree) { + // No journal was written (no old tree to restore), so nothing + // above bumped the mutation epoch. Bump it explicitly: sibling + // processes' MCPServerManagers key their cross-process plugin + // invalidation off this token, and a server launched before the + // old tree went missing may still be running there. + await bumpContainerMutationEpoch(this.stagingRoot).catch((error: unknown) => { + log.warn("Failed to bump the plugin mutation epoch after update", { + name: entry.name, + error: getErrorMessage(error), + }); + }); + } const updated: AgentPluginInstallEntry = { ...entry, diff --git a/src/node/services/agentPlugins/journals.ts b/src/node/services/agentPlugins/journals.ts index 366ee7dabeb..fff48203bd8 100644 --- a/src/node/services/agentPlugins/journals.ts +++ b/src/node/services/agentPlugins/journals.ts @@ -78,21 +78,30 @@ export interface ContainerMutationState { epoch: string | undefined; } +/** + * Read the current mutation epoch token; `undefined` when never written. An + * unreadable file yields a UNIQUE token so it can never compare equal across + * two reads (fail toward invalidation/suppression). Also consumed by + * MCPServerManager as its cross-process plugin invalidation signal: a sibling + * process's install/update/uninstall bumps this token, telling every manager + * to retire cached plugin server instances before serving them again. + */ +export async function readMutationEpochToken(stagingRoot: string): Promise { + try { + return await fsPromises.readFile(path.join(stagingRoot, MUTATION_EPOCH_FILE), "utf-8"); + } catch (error) { + return error instanceof Error && "code" in error && error.code === "ENOENT" + ? undefined + : `unreadable-${randomUUID()}`; + } +} + export async function readContainerMutationState( containerPath: string ): Promise { const stagingRoot = path.join(path.dirname(containerPath), STAGING_DIR_NAME); const hasJournals = await containerHasUnreconciledJournals(containerPath); - let epoch: string | undefined; - try { - epoch = await fsPromises.readFile(path.join(stagingRoot, MUTATION_EPOCH_FILE), "utf-8"); - } catch (error) { - epoch = - error instanceof Error && "code" in error && error.code === "ENOENT" - ? undefined - : `unreadable-${randomUUID()}`; - } - return { hasJournals, epoch }; + return { hasJournals, epoch: await readMutationEpochToken(stagingRoot) }; } /** diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index dd2c29f76b5..c1b3eec7a90 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -25,7 +25,7 @@ import { expandPluginPlaceholders, type PluginPlaceholderValues } from "./expans export const AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; -const PLUGIN_SERVER_KEY_PREFIX = "plugin:"; +export const PLUGIN_SERVER_KEY_PREFIX = "plugin:"; /** * Stable plugin-instance identity. Global plugins hash their LEXICAL diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 5a5b40dd2db..9ed695de6a0 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -19,7 +19,11 @@ import { type WorkspaceGoalServiceOptions, } from "@/node/services/workspaceGoalService"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; -import { createAgentPluginsMcpProvider } from "@/node/services/agentPlugins/mcpConfig"; +import { STAGING_DIR_NAME, readMutationEpochToken } from "@/node/services/agentPlugins/journals"; +import { + PLUGIN_SERVER_KEY_PREFIX, + createAgentPluginsMcpProvider, +} from "@/node/services/agentPlugins/mcpConfig"; import { MCPConfigService } from "@/node/services/mcpConfigService"; import { MCPServerManager, type MCPServerManagerOptions } from "@/node/services/mcpServerManager"; import { mergeMultiProjectSecrets } from "@/node/services/utils/multiProjectSecrets"; @@ -148,7 +152,16 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { }); const mcpServerManager = new MCPServerManager( mcpConfigService, - opts.mcpServerManagerOptions, + { + // A plugin update/uninstall in a sibling process (desktop app alongside + // `xum server`) bumps the installer's mutation epoch; managers retire + // cached plugin instances before serving them again. + pluginInvalidation: { + keyPrefix: PLUGIN_SERVER_KEY_PREFIX, + readToken: () => readMutationEpochToken(path.join(mcpConfig.rootDir, STAGING_DIR_NAME)), + }, + ...opts.mcpServerManagerOptions, + }, opts.policyService ); aiService.setMCPServerManager(mcpServerManager); diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 2b0f1dade67..a9331a0e444 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -152,6 +152,44 @@ describe("MCPServerManager", () => { manager.dispose(); }); + test("cross-process plugin mutation token retires cached plugin instances before serving", async () => { + // A sibling process's update/uninstall recycles only its OWN manager; + // this manager must notice the bumped on-disk mutation token and retire + // matching cached instances instead of serving stale-tree servers forever. + manager.dispose(); + let token = "epoch-1"; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { keyPrefix: "plugin:", readToken: () => Promise.resolve(token) }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-cross-process"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + const close = mock(() => Promise.resolve(undefined)); + access.startServers = () => + Promise.resolve(startResult([[pluginKey, { tools: { echo: testTool() }, close }]])); + + const first = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(Object.keys(first.tools)).toHaveLength(1); + + // Unchanged token: the cached instance is served untouched. + await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(close).toHaveBeenCalledTimes(0); + + // The sibling's mutation bumps the token: retire and restart. + token = "epoch-2"; + const close2 = mock(() => Promise.resolve(undefined)); + access.startServers = () => + Promise.resolve(startResult([[pluginKey, { tools: { echo: testTool() }, close: close2 }]])); + const third = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(close).toHaveBeenCalledTimes(1); + expect(Object.keys(third.tools)).toHaveLength(1); + expect(close2).toHaveBeenCalledTimes(0); + }); + test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup, then retries them", async () => { const workspaceId = "ws-swap-race"; const pluginKey = "plugin:abc123:echo"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index be5950a4217..3d3ef2941e6 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1038,6 +1038,20 @@ export interface MCPServerManagerOptions { inlineServers?: Record; /** If true, ignore config file servers and use only inline servers */ ignoreConfigFile?: boolean; + /** + * Cross-process Agent Plugin invalidation. stopServersWithKeyPrefix only + * recycles THIS process's instances; a sibling process sharing the same + * home (ALLOW_MULTIPLE_INSTANCES, desktop app alongside `xum server`) would + * otherwise keep serving servers launched from a plugin tree that an + * update/uninstall replaced — the key and command signature are unchanged, + * so nothing else notices. `readToken` reads the installer's on-disk + * mutation epoch; when it changes between serves, every cached instance + * whose key starts with `keyPrefix` is retired before being served again. + */ + pluginInvalidation?: { + keyPrefix: string; + readToken: () => Promise; + }; } export class MCPServerManager { @@ -1087,6 +1101,10 @@ export class MCPServerManager { private prefixInvalidationClock = 0; /** Latest invalidation epoch per key prefix. */ private readonly prefixInvalidations = new Map(); + /** See MCPServerManagerOptions.pluginInvalidation. */ + private readonly pluginInvalidation?: MCPServerManagerOptions["pluginInvalidation"]; + private pluginInvalidationTokenSeen = false; + private lastPluginInvalidationToken: string | undefined; private readonly idleCheckInterval: ReturnType; private inlineServers: Record = {}; private readonly policyService: PolicyService | null; @@ -1115,6 +1133,33 @@ export class MCPServerManager { if (options?.ignoreConfigFile) { this.ignoreConfigFile = options.ignoreConfigFile; } + this.pluginInvalidation = options?.pluginInvalidation; + } + + /** + * Retire cached plugin instances when a SIBLING process mutated a plugin + * (see MCPServerManagerOptions.pluginInvalidation). Runs before every + * serve; must precede the caller's prefixInvalidationClock snapshot so + * in-flight startups integrate with the existing invalidation machinery. + * The first read only records the token: no plugin instance can predate it + * because this method guards every serve path. + */ + private async retireCrossProcessPluginInstances(): Promise { + if (this.pluginInvalidation === undefined) { + return; + } + const token = await this.pluginInvalidation.readToken(); + if (!this.pluginInvalidationTokenSeen) { + this.pluginInvalidationTokenSeen = true; + this.lastPluginInvalidationToken = token; + return; + } + if (token === this.lastPluginInvalidationToken) { + return; + } + this.lastPluginInvalidationToken = token; + log.info("[MCP] Cross-process plugin mutation detected; recycling plugin servers"); + await this.stopServersWithKeyPrefix(this.pluginInvalidation.keyPrefix); } /** @@ -1536,6 +1581,11 @@ export class MCPServerManager { // reads so enablement repair can detect them. const configGenerationUsed = this.configService.configGeneration; + // A sibling process's plugin mutation must retire cached plugin + // instances BEFORE this serve returns them (and before the epoch + // snapshot below, so the recycle is visible to this startup). + await this.retireCrossProcessPluginInstances(); + // Snapshot BEFORE reading config: a plugin swap that lands after this // point may invalidate instances this call starts (see // closeInvalidatedInstances). diff --git a/src/node/utils/main/crossProcessLock.test.ts b/src/node/utils/main/crossProcessLock.test.ts new file mode 100644 index 00000000000..e1cdd1a703b --- /dev/null +++ b/src/node/utils/main/crossProcessLock.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import * as fsPromises from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { acquireCrossProcessLock, reclaimStaleLock } from "./crossProcessLock"; + +async function tempLockPath(): Promise { + const dir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "cross-process-lock-")); + return path.join(dir, "test.lock"); +} + +async function pathExists(target: string): Promise { + try { + await fsPromises.stat(target); + return true; + } catch { + return false; + } +} + +const baseOptions = { + acquireTimeoutMs: 400, + staleMs: 60_000, + timeoutMessage: "lock busy", +}; + +describe("acquireCrossProcessLock", () => { + test("acquires, blocks a competing acquirer on a live holder, and releases", async () => { + const lockPath = await tempLockPath(); + const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + try { + await acquireCrossProcessLock({ lockPath, ...baseOptions }); + expect.unreachable("second acquire must time out on a live holder"); + } catch (error) { + expect((error as Error).message).toBe("lock busy"); + } + await release(); + expect(await pathExists(lockPath)).toBe(false); + const release2 = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + await release2(); + }); + + test("reclaims a holder past the stale ceiling even when its pid is alive", async () => { + const lockPath = await tempLockPath(); + // acquiredAt 0 puts the holder beyond any stale ceiling (pid-reuse guard). + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: process.pid, token: "stale", acquiredAt: 0 }) + ); + const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + await release(); + expect(await pathExists(lockPath)).toBe(false); + }); + + test("reclaims a corrupt lock file", async () => { + const lockPath = await tempLockPath(); + await fsPromises.writeFile(lockPath, "not json"); + const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + await release(); + }); +}); + +describe("reclaimStaleLock", () => { + const staleHolder = { pid: 1, token: "stale-token", acquiredAt: 0 }; + + test("deletes the lock when it still holds the observed content", async () => { + const lockPath = await tempLockPath(); + await fsPromises.writeFile(lockPath, JSON.stringify(staleHolder)); + await reclaimStaleLock(lockPath, staleHolder); + expect(await pathExists(lockPath)).toBe(false); + // No quarantine leftovers. + expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([]); + }); + + test("restores a lock that was replaced after the observation (new owner survives)", async () => { + // The Codex-flagged race: we read a stale holder, then a concurrent + // reclaimer completed its own reclaim AND acquired before our removal + // ran. A plain rm would delete the new owner's live lock; the atomic + // rename + verify must put it back instead. + const lockPath = await tempLockPath(); + const newOwner = { pid: process.pid, token: "new-owner", acquiredAt: Date.now() }; + await fsPromises.writeFile(lockPath, JSON.stringify(newOwner)); + await reclaimStaleLock(lockPath, staleHolder); + const surviving = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { + token: string; + }; + expect(surviving.token).toBe("new-owner"); + expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([path.basename(lockPath)]); + }); + + test("restores a valid lock when the observation was a corrupt/partial read", async () => { + // A `wx` creator's content can land after a competitor read the file as + // empty/corrupt; the completed lock must survive the reclaim attempt. + const lockPath = await tempLockPath(); + const newOwner = { pid: process.pid, token: "completed-write", acquiredAt: Date.now() }; + await fsPromises.writeFile(lockPath, JSON.stringify(newOwner)); + await reclaimStaleLock(lockPath, undefined); + const surviving = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { + token: string; + }; + expect(surviving.token).toBe("completed-write"); + }); + + test("deletes a corrupt lock observed as corrupt", async () => { + const lockPath = await tempLockPath(); + await fsPromises.writeFile(lockPath, "not json"); + await reclaimStaleLock(lockPath, undefined); + expect(await pathExists(lockPath)).toBe(false); + }); + + test("no-ops when a concurrent reclaimer already moved the lock", async () => { + const lockPath = await tempLockPath(); + await reclaimStaleLock(lockPath, staleHolder); + expect(await pathExists(lockPath)).toBe(false); + expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([]); + }); +}); diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index 66813a5beca..1306be92ed8 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -31,7 +31,7 @@ export interface CrossProcessLockOptions { timeoutMessage: string; } -interface LockHolder { +export interface LockHolder { pid: number; token: string; acquiredAt: number; @@ -54,6 +54,51 @@ async function readLockHolder(lockPath: string): Promise } } +/** + * Reclaim a lock we observed as stale/corrupt WITHOUT the read-then-unlink + * race: between our read and a plain `rm`, a concurrent reclaimer can finish + * its own reclaim AND acquire, so the delayed `rm` would delete the NEW + * owner's live lock and let two transactions run concurrently. Instead, + * atomically rename the file aside (exactly one reclaimer wins; the loser + * gets ENOENT and simply retries the create loop) and verify we moved the + * exact content we judged reclaimable: + * - match → it really was the stale lock; delete the quarantined file. + * - mismatch → we stole a lock that was replaced after our read (a new + * owner, or a creator whose `wx` write completed after we read a partial + * file); rename it back. A third party's `wx`-create inside that gap is + * clobbered by the restore, but its post-create ownership re-read detects + * the foreign token and retries. + * Exported for tests. + */ +export async function reclaimStaleLock( + lockPath: string, + observed: LockHolder | undefined +): Promise { + const quarantinePath = `${lockPath}.reclaim-${process.pid}-${randomBytes(8).toString("hex")}`; + try { + await fsPromises.rename(lockPath, quarantinePath); + } catch { + // ENOENT: a concurrent reclaimer moved it first. Nothing to do. + return; + } + const moved = await readLockHolder(quarantinePath); + const movedWhatWeObserved = + moved === undefined + ? observed === undefined + : observed !== undefined && + moved.pid === observed.pid && + moved.token === observed.token && + moved.acquiredAt === observed.acquiredAt; + if (movedWhatWeObserved) { + await fsPromises.rm(quarantinePath, { force: true }).catch(() => undefined); + return; + } + // Restore failures propagate: leaving the new owner's lock quarantined + // would release mutual exclusion early, which is exactly the corruption + // this helper exists to prevent. + await fsPromises.rename(quarantinePath, lockPath); +} + /** * Liveness check for a competing holder. Reclaims dead pids immediately; the * stale ceiling guards pid reuse. A same-pid holder is NOT reclaimable: it is @@ -113,10 +158,9 @@ export async function acquireCrossProcessLock( } const holder = await readLockHolder(lockPath); if (holder === undefined || !holderAlive(holder, staleMs)) { - // Corrupt/unreadable or dead-owner lock: reclaim and retry. The - // unlink-then-create race between two reclaimers is compensated by - // the ownership re-read above. - await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + // Corrupt/unreadable or dead-owner lock: reclaim atomically (see + // reclaimStaleLock for why a plain rm is unsafe here) and retry. + await reclaimStaleLock(lockPath, holder); continue; } } From 5c027971ec0ec3ec45d0fa5acf2a77a464977511 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 20:00:35 +0000 Subject: [PATCH 37/63] fix: address Codex review round 55 (git hooks in staging clones, fork init teardown ordering) 1. Staging clones/fetches/checkouts now run with GIT_NO_HOOKS_ENV: a user with a relative global core.hooksPath would otherwise execute an attacker-controlled repository's post-checkout hook during Preview, before any consent UI appears. 2. fork(): the sanitization-abort cleanup aborts background init and AWAITS its termination (runBackgroundInit now returns a settled promise) before deleting the fresh worktree, so the delete cannot race init's writes/open handles and orphan the worktree. --- src/node/runtime/runtimeFactory.ts | 23 +++++++++------ .../services/agentPlugins/installService.ts | 10 ++++++- src/node/services/taskService.test.ts | 28 +++++++++---------- src/node/services/taskService.ts | 4 +-- src/node/services/workspaceService.test.ts | 24 ++++++++-------- src/node/services/workspaceService.ts | 16 +++++++++-- 6 files changed, 65 insertions(+), 40 deletions(-) diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 9a52baf5a05..4cebb30b86e 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -50,21 +50,28 @@ export async function runFullInit( /** * Fire-and-forget init with standardized error handling. * Use this for background init after workspace creation (workspaceService, taskService). + * + * Returns a promise that SETTLES (never rejects) when init terminates, so + * error paths that must tear down the checkout can await termination first — + * deleting a worktree while init still runs against it races its writes and + * open handles. Callers that never tear down may ignore it with `void`. */ - export function runBackgroundInit( runtime: Runtime, params: WorkspaceInitParams, workspaceId: string, // eslint-disable-next-line local/no-object-parameters -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern logger?: { error: (msg: string, ctx: object) => void } -): void { - void runFullInit(runtime, params).catch((error: unknown) => { - const errorMsg = getErrorMessage(error); - logger?.error(`Workspace init failed for ${workspaceId}:`, { error }); - params.initLogger.logStderr(`Initialization failed: ${errorMsg}`); - params.initLogger.logComplete(-1); - }); +): Promise { + return runFullInit(runtime, params).then( + () => undefined, + (error: unknown) => { + const errorMsg = getErrorMessage(error); + logger?.error(`Workspace init failed for ${workspaceId}:`, { error }); + params.initLogger.logStderr(`Initialization failed: ${errorMsg}`); + params.initLogger.logComplete(-1); + } + ); } function shouldUseSSH2Runtime(): boolean { diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 76ab10bbfb6..758ebfbd6d8 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -24,6 +24,7 @@ import type { import { resolvePluginHookGrants } from "@/node/services/agentPlugins/hookSandbox"; import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; +import { GIT_NO_HOOKS_ENV } from "@/node/utils/gitNoHooksEnv"; import type { Config } from "@/node/config"; import { parseSkillMarkdown } from "@/node/services/agentSkills/parseSkillMarkdown"; import { log } from "@/node/services/log"; @@ -159,7 +160,14 @@ 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" }; + // + // SECURITY: disable Git hooks for every staging clone/fetch/checkout. A + // user with a RELATIVE global core.hooksPath (e.g. ".githooks") would + // otherwise execute an attacker-controlled repository's post-checkout hook + // during Preview — before any consent UI appears. GIT_CONFIG_* env config + // takes precedence over all config files, so this neutralizes hooks + // regardless of global/system configuration. + const env: Record = { GIT_TERMINAL_PROMPT: "0", ...GIT_NO_HOOKS_ENV }; if (process.env.GIT_SSH_COMMAND === undefined) { env.GIT_SSH_COMMAND = "ssh -oBatchMode=yes"; } diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index eff2ba2524d..d733d784ead 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -8002,8 +8002,8 @@ describe("TaskService", () => { return cfg; }); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); try { await taskService.initialize(); @@ -8107,8 +8107,8 @@ describe("TaskService", () => { expect(findWorkspaceInConfig(config, queuedTaskId)?.taskPrompt).toBeUndefined(); expect(findWorkspaceInConfig(config, acceptedStartingTaskId)?.taskPrompt).toBe(acceptedPrompt); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); try { await taskService.initialize(); @@ -8433,8 +8433,8 @@ describe("TaskService", () => { projects, }, }); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); try { @@ -8514,8 +8514,8 @@ describe("TaskService", () => { // orchestrateFork must NOT be called for isolation: "none"; runBackgroundInit is stubbed only // so a stray call would be observable (it should not be invoked either). const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); try { const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); @@ -8607,8 +8607,8 @@ describe("TaskService", () => { ); const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); try { const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); @@ -8693,8 +8693,8 @@ describe("TaskService", () => { ); const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); try { const { workspaceService } = createWorkspaceServiceMocks(); @@ -8760,8 +8760,8 @@ describe("TaskService", () => { ); const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); try { const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index a519ae0993a..106ea1934b4 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -3462,7 +3462,7 @@ export class TaskService { const secrets = await secretsToRecord( this.config.getEffectiveSecrets(plan.parentMeta.projectPath) ); - runBackgroundInit( + void runBackgroundInit( runtimeForTaskWorkspace, { projectPath: plan.parentMeta.projectPath, @@ -4423,7 +4423,7 @@ export class TaskService { const secrets = await secretsToRecord( this.config.getEffectiveSecrets(parentMeta.projectPath) ); - runBackgroundInit( + void runBackgroundInit( runtimeForTaskWorkspace, { projectPath: parentMeta.projectPath, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 3fd343e15ac..81f8cab4533 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -12740,8 +12740,8 @@ describe("WorkspaceService fork", () => { const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue( {} as ReturnType ); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); const copyPlanSpy = spyOn(runtimeExecHelpers, "copyPlanFileAcrossRuntimes").mockResolvedValue( undefined @@ -12869,8 +12869,8 @@ describe("WorkspaceService fork", () => { const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue( {} as ReturnType ); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); const copyPlanSpy = spyOn(runtimeExecHelpers, "copyPlanFileAcrossRuntimes").mockResolvedValue( undefined @@ -12986,8 +12986,8 @@ describe("WorkspaceService fork", () => { const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue( {} as ReturnType ); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); const copyPlanSpy = spyOn(runtimeExecHelpers, "copyPlanFileAcrossRuntimes").mockResolvedValue( undefined @@ -13098,8 +13098,8 @@ describe("WorkspaceService fork", () => { const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue( {} as ReturnType ); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); const copyPlanSpy = spyOn(runtimeExecHelpers, "copyPlanFileAcrossRuntimes").mockResolvedValue( undefined @@ -13208,8 +13208,8 @@ describe("WorkspaceService fork", () => { const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue( {} as ReturnType ); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); const copyPlanSpy = spyOn(runtimeExecHelpers, "copyPlanFileAcrossRuntimes").mockResolvedValue( undefined @@ -13317,8 +13317,8 @@ describe("WorkspaceService fork", () => { const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue( {} as ReturnType ); - const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( - () => undefined + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation(() => + Promise.resolve(undefined) ); const copyPlanSpy = spyOn(runtimeExecHelpers, "copyPlanFileAcrossRuntimes").mockResolvedValue( undefined diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index cf4ffc09e53..86cbf379af9 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4573,7 +4573,7 @@ export class WorkspaceService extends EventEmitter { // If the user cancelled creation while create() was still in flight, avoid spawning // additional background work for a workspace that's already being removed. if (!this.removingWorkspaces.has(workspaceId) && !initAbortController.signal.aborted) { - runBackgroundInit( + void runBackgroundInit( runtime, { projectPath: owningProjectPath, @@ -8353,7 +8353,12 @@ export class WorkspaceService extends EventEmitter { } const secrets = await resolveProjectEnv(foundProjectPath); - runBackgroundInit( + // Fire-and-forget on the happy path, but keep the termination handle: + // the sanitization-abort cleanup below deletes the fresh worktree, and + // doing that while init still runs against the checkout races its + // writes/open handles (a failed delete leaves an orphaned worktree that + // collides with the next fork of the same branch). + const initSettled = runBackgroundInit( targetRuntime, { projectPath: foundProjectPath, @@ -8565,6 +8570,12 @@ export class WorkspaceService extends EventEmitter { workspacePath ); if (sanitizeError !== undefined) { + // Background init is still running against this checkout: abort + // it and AWAIT termination before deleting the worktree, or the + // delete races init's writes/open handles and can fail, leaving + // an orphaned worktree that collides with the next fork attempt. + initAbortController.abort(); + await initSettled; const rolledBack = await this.rollbackUnsanitizedWorkspaceRegistration(newWorkspaceId); if (rolledBack && isWorktreeRuntime(forkedRuntimeConfig)) { // Matches the copy-failure cleanup above: the fork's checkout @@ -8587,7 +8598,6 @@ export class WorkspaceService extends EventEmitter { await fsPromises .rm(newSessionDir, { recursive: true, force: true }) .catch(() => undefined); - initAbortController.abort(); this.initAbortControllers.delete(newWorkspaceId); this.initStateManager.clearInMemoryState(newWorkspaceId); this.disposeSession(newWorkspaceId); From 84964a03be3f17243c1b8fe33676664a718705d1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 20:10:38 +0000 Subject: [PATCH 38/63] fix: address Codex review round 56 (lock reclaim redesign, raw duplicate detection, skill dir-name validation, canonical path in error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. crossProcessLock: stale reclamation no longer deletes or renames the lock file (a delayed rename could clobber a newly confirmed owner in a three-process race). Reclaimers now serialize through a short-lived mkdir mutex and take ownership by atomically REPLACING the lock content in place — the path is never absent during reclamation, so no third process can slip in a wx create, and no restore branch exists. Mutex ownership is re-verified immediately before the replacing rename. 2. parseRegistryEntries (strict): duplicate names are detected across RAW entries before schema filtering, so a valid entry colliding with a same-name newer-version row refuses the mutation instead of update() patching / uninstall() deleting both rows (upgrade-downgrade rule). 3. Preview collectSkills mirrors runtime discovery: invalid skill directory names are skipped with a warning, and frontmatter names are validated against the directory name — the consent preview no longer promises a skill that never loads, and the update capability surface cannot misclassify one as an addition. 4. Sanitization-failure message now names the canonical .xum/mcp.local.jsonc (and legacy .mux fallback) instead of only .mux. --- .../agentPlugins/installService.test.ts | 42 ++++ .../services/agentPlugins/installService.ts | 39 +++- src/node/services/workspaceService.ts | 2 +- src/node/utils/main/crossProcessLock.test.ts | 103 ++++++--- src/node/utils/main/crossProcessLock.ts | 203 +++++++++++++----- 5 files changed, 299 insertions(+), 90 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 7f57b3d5a6c..27d737b52dc 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -292,6 +292,48 @@ describe("AgentPluginInstallService", () => { expect(await stagingLeftovers()).toEqual([]); }); + test("mutations refuse when a raw registry entry duplicates a managed name (newer-version rows)", async () => { + // A newer build can write a same-name entry this build cannot parse. + // Raw rewrites match by name, so update()/uninstall() would silently + // patch or delete BOTH rows — destroying the newer version's metadata + // (upgrade↔downgrade rule). The duplicate must be detected across RAW + // entries, before schema filtering hides the unrecognized row. + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + const raw = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: unknown[]; + }; + raw.plugins.push({ name: "demo-plugin", source: { kind: "future-source-kind" } }); + await fsPromises.writeFile(registryFile(), JSON.stringify(raw)); + + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/duplicate entries/); + await expect( + service.uninstall({ name: "demo-plugin", deletePluginData: false }) + ).rejects.toThrow(/duplicate entries/); + // Both raw rows survive untouched. + const after = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: unknown[]; + }; + expect(after.plugins).toHaveLength(2); + }); + + test("preview validates skills against their directory names like runtime discovery", async () => { + // skills/wrong-dir/SKILL.md advertising a different name never loads at + // runtime (parseSkillMarkdown rejects the mismatch), so the preview must + // not promise it — and the update capability surface must not count it. + await fsPromises.mkdir(path.join(remoteDir, "skills", "wrong-dir"), { recursive: true }); + await fsPromises.writeFile( + path.join(remoteDir, "skills", "wrong-dir", "SKILL.md"), + "---\nname: other-name\ndescription: Mismatched\n---\n\nBody.\n" + ); + await commitAll(remoteDir, "adds a dir-name-mismatched skill"); + + const preview = await service.preview({ input: remoteDir }); + expect(preview.skills.map((skill) => skill.name)).toEqual(["greet"]); + expect(preview.warnings.join("\n")).toContain("skills/wrong-dir"); + }); + test("update rejects capability increases (new hook, expanded grants, new/changed MCP servers)", async () => { // Security gate: a compromised upstream must not auto-load new executable // capabilities through a routine update click. Additions/changes are diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 758ebfbd6d8..e15b0447fdd 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -26,6 +26,7 @@ import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { GIT_NO_HOOKS_ENV } from "@/node/utils/gitNoHooksEnv"; import type { Config } from "@/node/config"; +import { SkillNameSchema } from "@/common/orpc/schemas/agentSkill"; import { parseSkillMarkdown } from "@/node/services/agentSkills/parseSkillMarkdown"; import { log } from "@/node/services/log"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; @@ -580,6 +581,27 @@ export class AgentPluginInstallService { rawEntries: unknown[], mode: "lenient" | "strict" = "lenient" ): AgentPluginInstallEntry[] { + // Strict (mutation) mode must detect duplicate names across RAW entries, + // BEFORE schema filtering: a valid entry can collide with a same-name + // entry this build cannot parse (written by a newer version). Raw + // rewrites match by name — update() would patch and uninstall() would + // delete BOTH rows, silently destroying the newer version's metadata + // (upgrade↔downgrade rule). + if (mode === "strict") { + const seenRawNames = new Set(); + for (const rawEntry of rawEntries) { + const rawName = this.rawEntryName(rawEntry); + if (rawName === undefined) { + continue; + } + if (seenRawNames.has(rawName)) { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) contains duplicate entries for '${rawName}'. Repair the file, then retry.` + ); + } + seenRawNames.add(rawName); + } + } const entries: AgentPluginInstallEntry[] = []; const seenNames = new Set(); for (const rawEntry of rawEntries) { @@ -1383,9 +1405,24 @@ export class AgentPluginInstallService { continue; } if (!stat.isFile()) continue; + // Mirror runtime discovery's directory-name validation: an invalid dir + // name never loads, and a frontmatter name that mismatches the dir name + // is rejected at load time. Without both checks here the consent + // preview (and the update capability surface built from it) would + // promise a skill that disappears after installation — or classify a + // never-loading skill as a capability addition. + const dirNameParsed = SkillNameSchema.safeParse(dirName); + if (!dirNameParsed.success) { + warnings.push(`skills/${dirName}: invalid skill directory name; it will not load`); + continue; + } try { const content = await fsPromises.readFile(containedSkillPath, "utf8"); - const parsed = parseSkillMarkdown({ content, byteSize: stat.size }); + const parsed = parseSkillMarkdown({ + content, + byteSize: stat.size, + directoryName: dirNameParsed.data, + }); skills.push({ name: parsed.frontmatter.name, ...(parsed.frontmatter.description !== undefined diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 86cbf379af9..7aacabcb88e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2841,7 +2841,7 @@ export class WorkspaceService extends EventEmitter { // Abort creation instead of proceeding with the stale file: continuing // would re-create the silent-activation path this sanitization exists // to close, with no durable record left to retry it. - return `The directory's existing MCP overrides file could not be sanitized: ${getErrorMessage(error)}. Fix or remove .mux/mcp.local.jsonc in ${workspacePath} and try again.`; + return `The directory's existing MCP overrides file could not be sanitized: ${getErrorMessage(error)}. Fix or remove the workspace MCP overrides file (.xum/mcp.local.jsonc, or legacy .mux/mcp.local.jsonc) in ${workspacePath} and try again.`; } } diff --git a/src/node/utils/main/crossProcessLock.test.ts b/src/node/utils/main/crossProcessLock.test.ts index e1cdd1a703b..d47a23f7933 100644 --- a/src/node/utils/main/crossProcessLock.test.ts +++ b/src/node/utils/main/crossProcessLock.test.ts @@ -62,26 +62,28 @@ describe("acquireCrossProcessLock", () => { }); describe("reclaimStaleLock", () => { - const staleHolder = { pid: 1, token: "stale-token", acquiredAt: 0 }; - - test("deletes the lock when it still holds the observed content", async () => { + test("takes ownership of a stale lock in place and confirms", async () => { const lockPath = await tempLockPath(); - await fsPromises.writeFile(lockPath, JSON.stringify(staleHolder)); - await reclaimStaleLock(lockPath, staleHolder); - expect(await pathExists(lockPath)).toBe(false); - // No quarantine leftovers. - expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([]); + await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 })); + const token = await reclaimStaleLock(lockPath, 60_000); + expect(token).toBeDefined(); + const holder = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { token: string }; + expect(holder.token).toBe(token!); + // Mutex and temp files are cleaned up. + expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([path.basename(lockPath)]); }); - test("restores a lock that was replaced after the observation (new owner survives)", async () => { - // The Codex-flagged race: we read a stale holder, then a concurrent - // reclaimer completed its own reclaim AND acquired before our removal - // ran. A plain rm would delete the new owner's live lock; the atomic - // rename + verify must put it back instead. + test("never touches a lock that became live/fresh after the caller's observation", async () => { + // The Codex-flagged three-process race: a caller observed a stale + // holder, but a competitor completed its own reclaim-and-acquire before + // this reclaim ran. The fresh re-read inside the mutex must abandon + // WITHOUT modifying the new owner's confirmed lock (the old design's + // quarantine/restore could clobber it). const lockPath = await tempLockPath(); const newOwner = { pid: process.pid, token: "new-owner", acquiredAt: Date.now() }; await fsPromises.writeFile(lockPath, JSON.stringify(newOwner)); - await reclaimStaleLock(lockPath, staleHolder); + const token = await reclaimStaleLock(lockPath, 60_000); + expect(token).toBeUndefined(); const surviving = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { token: string; }; @@ -89,30 +91,69 @@ describe("reclaimStaleLock", () => { expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([path.basename(lockPath)]); }); - test("restores a valid lock when the observation was a corrupt/partial read", async () => { - // A `wx` creator's content can land after a competitor read the file as - // empty/corrupt; the completed lock must survive the reclaim attempt. + test("reclaims a corrupt-but-present lock in place", async () => { const lockPath = await tempLockPath(); - const newOwner = { pid: process.pid, token: "completed-write", acquiredAt: Date.now() }; - await fsPromises.writeFile(lockPath, JSON.stringify(newOwner)); - await reclaimStaleLock(lockPath, undefined); - const surviving = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { - token: string; - }; - expect(surviving.token).toBe("completed-write"); + await fsPromises.writeFile(lockPath, "not json"); + const token = await reclaimStaleLock(lockPath, 60_000); + expect(token).toBeDefined(); + const holder = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { token: string }; + expect(holder.token).toBe(token!); }); - test("deletes a corrupt lock observed as corrupt", async () => { + test("abandons when the lock file is missing (the wx create path handles absence)", async () => { const lockPath = await tempLockPath(); - await fsPromises.writeFile(lockPath, "not json"); - await reclaimStaleLock(lockPath, undefined); + expect(await reclaimStaleLock(lockPath, 60_000)).toBeUndefined(); expect(await pathExists(lockPath)).toBe(false); + expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([]); }); - test("no-ops when a concurrent reclaimer already moved the lock", async () => { + test("backs off while a competing reclaimer holds a fresh reclaim mutex", async () => { const lockPath = await tempLockPath(); - await reclaimStaleLock(lockPath, staleHolder); - expect(await pathExists(lockPath)).toBe(false); - expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([]); + const stale = JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 }); + await fsPromises.writeFile(lockPath, stale); + const mutexDir = `${lockPath}.reclaim`; + await fsPromises.mkdir(mutexDir); + await fsPromises.writeFile(path.join(mutexDir, "owner"), "competitor"); + const token = await reclaimStaleLock(lockPath, 60_000); + expect(token).toBeUndefined(); + // The stale lock and the competitor's mutex are untouched. + expect(await fsPromises.readFile(lockPath, "utf-8")).toBe(stale); + expect(await fsPromises.readFile(path.join(mutexDir, "owner"), "utf-8")).toBe("competitor"); + }); + + test("breaks a reclaim mutex abandoned by a crashed reclaimer", async () => { + const lockPath = await tempLockPath(); + await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 })); + const mutexDir = `${lockPath}.reclaim`; + await fsPromises.mkdir(mutexDir); + // Age the mutex beyond RECLAIM_MUTEX_STALE_MS. + const old = new Date(Date.now() - 60_000); + await fsPromises.utimes(mutexDir, old, old); + const token = await reclaimStaleLock(lockPath, 60_000); + expect(token).toBeDefined(); + const holder = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { token: string }; + expect(holder.token).toBe(token!); + }); + + test("the lock path is never absent during a successful reclaim", async () => { + // Watch for absence with a tight poller while a reclaim runs. rename-over + // is atomic, so no observer may ever see ENOENT — the property that keeps + // a third process's wx-create from slipping in mid-reclaim. + const lockPath = await tempLockPath(); + await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 })); + let sawAbsent = false; + let stop = false; + const watcher = (async () => { + while (!stop) { + if (!(await pathExists(lockPath))) { + sawAbsent = true; + } + } + })(); + const token = await reclaimStaleLock(lockPath, 60_000); + stop = true; + await watcher; + expect(token).toBeDefined(); + expect(sawAbsent).toBe(false); }); }); diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index 1306be92ed8..f5229e443ef 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -13,8 +13,17 @@ import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; * read-modify-write transactions on shared files can interleave and the last * writer silently drops the other's changes. Holders record `{pid, token, * acquiredAt}`; acquisition uses exclusive-create (`wx`) plus a post-create - * ownership re-read so two processes that both reclaimed a dead holder + * ownership re-read so two processes that both observed the path absent * cannot both proceed — the clobbered one fails the token check and retries. + * + * STALE RECLAMATION never deletes or renames the lock file (a delayed + * unlink/rename could destroy a NEW owner's confirmed lock and let two + * transactions run concurrently). Instead, reclaimers serialize through a + * short-lived mkdir mutex and take ownership by atomically REPLACING the lock + * file's content in place (temp + rename). The lock path is therefore never + * absent during reclamation, so no third process can slip in a `wx` create + * mid-reclaim; the only competitors are other reclaimers, which the mutex + * serializes. See reclaimStaleLock. */ export interface CrossProcessLockOptions { /** Absolute path of the lock file. Its parent directory must exist. */ @@ -54,51 +63,6 @@ async function readLockHolder(lockPath: string): Promise } } -/** - * Reclaim a lock we observed as stale/corrupt WITHOUT the read-then-unlink - * race: between our read and a plain `rm`, a concurrent reclaimer can finish - * its own reclaim AND acquire, so the delayed `rm` would delete the NEW - * owner's live lock and let two transactions run concurrently. Instead, - * atomically rename the file aside (exactly one reclaimer wins; the loser - * gets ENOENT and simply retries the create loop) and verify we moved the - * exact content we judged reclaimable: - * - match → it really was the stale lock; delete the quarantined file. - * - mismatch → we stole a lock that was replaced after our read (a new - * owner, or a creator whose `wx` write completed after we read a partial - * file); rename it back. A third party's `wx`-create inside that gap is - * clobbered by the restore, but its post-create ownership re-read detects - * the foreign token and retries. - * Exported for tests. - */ -export async function reclaimStaleLock( - lockPath: string, - observed: LockHolder | undefined -): Promise { - const quarantinePath = `${lockPath}.reclaim-${process.pid}-${randomBytes(8).toString("hex")}`; - try { - await fsPromises.rename(lockPath, quarantinePath); - } catch { - // ENOENT: a concurrent reclaimer moved it first. Nothing to do. - return; - } - const moved = await readLockHolder(quarantinePath); - const movedWhatWeObserved = - moved === undefined - ? observed === undefined - : observed !== undefined && - moved.pid === observed.pid && - moved.token === observed.token && - moved.acquiredAt === observed.acquiredAt; - if (movedWhatWeObserved) { - await fsPromises.rm(quarantinePath, { force: true }).catch(() => undefined); - return; - } - // Restore failures propagate: leaving the new owner's lock quarantined - // would release mutual exclusion early, which is exactly the corruption - // this helper exists to prevent. - await fsPromises.rename(quarantinePath, lockPath); -} - /** * Liveness check for a competing holder. Reclaims dead pids immediately; the * stale ceiling guards pid reuse. A same-pid holder is NOT reclaimable: it is @@ -123,6 +87,128 @@ function holderAlive(holder: LockHolder, staleMs: number): boolean { } } +/** + * A reclaimer stuck longer than this inside the (tiny) reclaim critical + * section is presumed crashed and its mutex is broken. The section performs + * only a handful of filesystem operations, so seconds of margin is plenty. + */ +const RECLAIM_MUTEX_STALE_MS = 15_000; + +/** + * Take ownership of a stale/corrupt lock WITHOUT ever making the lock path + * absent. Returns the token that now owns the lock, or undefined when the + * reclaim was abandoned (competitor holds the reclaim mutex, the holder + * turned out live/fresh on re-read, or the file disappeared). + * + * Protocol: + * 1. mkdir `.reclaim` — the reclaim mutex. Atomic: exactly one + * reclaimer enters; others back off and retry the main loop. A mutex dir + * older than RECLAIM_MUTEX_STALE_MS (crashed reclaimer) is broken. + * 2. Inside the mutex, RE-READ the lock and re-evaluate staleness on the + * fresh content. A lock that changed since the caller's observation + * belongs to a new owner and is left untouched. + * 3. Take ownership by atomically REPLACING the file content (temp + + * rename-over). The path never goes absent, so a competing `wx` create + * cannot slip in between "remove stale" and "create ours" — the failure + * mode the previous delete-based design had. Immediately before the + * rename, re-verify we still own the reclaim mutex (a competitor may have + * broken it during an arbitrary pause); abandon if not. + * 4. Confirm ownership with a post-rename re-read (same as the `wx` path). + * + * Exported for tests. + */ +export async function reclaimStaleLock( + lockPath: string, + staleMs: number +): Promise { + const mutexDir = `${lockPath}.reclaim`; + const mutexToken = randomBytes(16).toString("hex"); + const mutexTokenFile = path.join(mutexDir, "owner"); + + const enterMutex = async (): Promise => { + try { + await fsPromises.mkdir(mutexDir); + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error; + } + // Break a mutex abandoned by a crashed reclaimer, then retry ONCE. + // (A live reclaimer finishes in milliseconds; see the stale ceiling.) + try { + const stat = await fsPromises.stat(mutexDir); + if (Date.now() - stat.mtimeMs <= RECLAIM_MUTEX_STALE_MS) { + return false; + } + await fsPromises.rm(mutexDir, { recursive: true, force: true }); + } catch { + return false; + } + try { + await fsPromises.mkdir(mutexDir); + } catch { + return false; + } + } + await fsPromises.writeFile(mutexTokenFile, mutexToken); + return true; + }; + + const ownsMutex = async (): Promise => { + try { + return (await fsPromises.readFile(mutexTokenFile, "utf-8")) === mutexToken; + } catch { + return false; + } + }; + + if (!(await enterMutex())) { + return undefined; + } + try { + // Fresh re-read INSIDE the mutex: the caller's observation may predate a + // completed reclaim-and-acquire by a competitor. A live fresh holder is + // never touched. (Corrupt-but-present files re-read as undefined and stay + // reclaimable; a MISSING file aborts — the `wx` path handles absence.) + try { + await fsPromises.stat(lockPath); + } catch { + return undefined; + } + const current = await readLockHolder(lockPath); + if (current !== undefined && holderAlive(current, staleMs)) { + return undefined; + } + + const token = randomBytes(16).toString("hex"); + const tempPath = `${lockPath}.claim-${token}`; + await fsPromises.writeFile( + tempPath, + JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }) + ); + // Last-instant mutex re-check: if we paused long enough for a competitor + // to break our mutex and reclaim, our rename would clobber ITS confirmed + // lock. (A pause landing exactly between this check and the rename is the + // residual window; it requires a >15s stall across two adjacent syscalls.) + if (!(await ownsMutex())) { + await fsPromises.rm(tempPath, { force: true }).catch(() => undefined); + return undefined; + } + try { + await fsPromises.rename(tempPath, lockPath); + } catch (error) { + await fsPromises.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } + const confirmed = await readLockHolder(lockPath); + return confirmed?.token === token ? token : undefined; + } finally { + // Release only OUR mutex: a competitor that broke ours owns the dir now. + if (await ownsMutex()) { + await fsPromises.rm(mutexDir, { recursive: true, force: true }).catch(() => undefined); + } + } +} + /** * Acquire the lock; returns the release function, which deletes the lock file * only while it is still OURS (a reclaimer may have replaced it after the @@ -133,9 +219,15 @@ export async function acquireCrossProcessLock( ): Promise<() => Promise> { const { lockPath, acquireTimeoutMs, staleMs, timeoutMessage } = options; await fsPromises.mkdir(path.dirname(lockPath), { recursive: true }); - const token = randomBytes(16).toString("hex"); const deadline = Date.now() + acquireTimeoutMs; + const releaseFor = (token: string) => async () => { + const current = await readLockHolder(lockPath); + if (current?.token === token) { + await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + } + }; for (;;) { + const token = randomBytes(16).toString("hex"); try { await fsPromises.writeFile( lockPath, @@ -144,12 +236,7 @@ export async function acquireCrossProcessLock( ); const confirmed = await readLockHolder(lockPath); if (confirmed?.token === token) { - return async () => { - const current = await readLockHolder(lockPath); - if (current?.token === token) { - await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); - } - }; + return releaseFor(token); } // Our create was clobbered by a concurrent reclaimer: retry. } catch (error) { @@ -158,10 +245,12 @@ export async function acquireCrossProcessLock( } const holder = await readLockHolder(lockPath); if (holder === undefined || !holderAlive(holder, staleMs)) { - // Corrupt/unreadable or dead-owner lock: reclaim atomically (see - // reclaimStaleLock for why a plain rm is unsafe here) and retry. - await reclaimStaleLock(lockPath, holder); - continue; + // Corrupt/unreadable or dead-owner lock: take ownership in place via + // the serialized reclaim protocol (never deletes the path). + const reclaimedToken = await reclaimStaleLock(lockPath, staleMs); + if (reclaimedToken !== undefined) { + return releaseFor(reclaimedToken); + } } } if (Date.now() > deadline) { From 21f781935b76c49e77b22ea3461059589019d5fd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 20:26:23 +0000 Subject: [PATCH 39/63] fix: address Codex review round 57 (lock publication + release atomicity, serialized sweep, full skill fingerprint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. crossProcessLock publication is now atomic-with-content: the holder record is written to a temp file and hard-linked into place (exclusive EEXIST), so no observer can ever read a partially written lock and misjudge it corrupt mid-publication. Defense-in-depth for non-atomic writers from other builds: corrupt content younger than a 2s grace is retried, not reclaimed. 2. Release serializes through the same mkdir mutex as reclamation, making its verify-then-unlink atomic against a reclaimer replacing the file — a holder releasing at the stale ceiling can no longer delete the successor's confirmed lock. Bounded retries; on persistent contention the file is left for stale reclamation (never mis-deleted). 3. MCPServerManager cross-process invalidation: check+sweep runs inside a serialization queue and the observed token publishes only AFTER the sweep completes, so a concurrent serve cannot observe the token as handled while instances are still being retired; failed sweeps leave the token unpublished for retry. 4. Update capability fingerprint covers every model-visible skill field: description, when_to_use/when-to-use, and advertise (a hidden-to- visible flip or new steering guidance is re-consent territory). --- .../agentPlugins/installService.test.ts | 14 +- .../services/agentPlugins/installService.ts | 45 +++- src/node/services/mcpServerManager.test.ts | 57 +++++ src/node/services/mcpServerManager.ts | 39 ++- src/node/utils/main/crossProcessLock.test.ts | 57 ++++- src/node/utils/main/crossProcessLock.ts | 234 +++++++++++------- 6 files changed, 341 insertions(+), 105 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 27d737b52dc..4bdc25626ae 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -437,7 +437,19 @@ describe("AgentPluginInstallService", () => { ); await commitAll(remoteDir, "v2 rewords the skill description"); await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( - /changes the advertised description of skill 'greet'/ + /changes the model-visible advertisement of skill 'greet'/ + ); + + // when_to_use interpolates into the model-facing skill index too — a + // change with an unchanged description is equally consent-relevant. + await writePluginFixture(remoteDir, { version: "2.0.5" }); + await fsPromises.writeFile( + path.join(remoteDir, "skills", "greet", "SKILL.md"), + "---\nname: greet\ndescription: Greets people\nwhen_to_use: Load before every privileged tool call\n---\n\nSay hi.\n" + ); + await commitAll(remoteDir, "v2.0.5 adds when_to_use"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /changes the model-visible advertisement of skill 'greet'/ ); // Additions of consent-listed components are gated too. diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index e15b0447fdd..1f29e4a1bcf 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -26,7 +26,11 @@ import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { GIT_NO_HOOKS_ENV } from "@/node/utils/gitNoHooksEnv"; import type { Config } from "@/node/config"; -import { SkillNameSchema } from "@/common/orpc/schemas/agentSkill"; +import { + SkillNameSchema, + resolveSkillAdvertise, + resolveSkillWhenToUse, +} from "@/common/orpc/schemas/agentSkill"; import { parseSkillMarkdown } from "@/node/services/agentSkills/parseSkillMarkdown"; import { log } from "@/node/services/log"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; @@ -149,6 +153,12 @@ const MUTATION_LOCK_STALE_MS = 30 * 60 * 1000; const LS_REMOTE_TIMEOUT_MS = 30_000; const CLONE_TIMEOUT_MS = 120_000; +/** Preview skill row plus the extra model-visible fields (see collectSkills). */ +type CollectedPluginSkill = AgentPluginPreviewSkill & { + whenToUse?: string; + advertise?: boolean; +}; + /** Result of resolving a user-supplied ref against the remote. */ interface ResolvedRemoteRef { ref: string; @@ -1213,7 +1223,19 @@ export class AgentPluginInstallService { const hook = this.collectHook(plugin); const skills = new Map(); for (const skill of await this.collectSkills(plugin, [])) { - skills.set(skill.name, JSON.stringify({ description: skill.description ?? null })); + // EVERY model-visible advertisement field: description, whenToUse + // (both interpolate into the agent_skill_read tool description on each + // request), and advertise (a flip from hidden to visible surfaces a + // previously invisible skill). Changing any of them is re-consent + // territory, same as adding a skill. + skills.set( + skill.name, + JSON.stringify({ + description: skill.description ?? null, + whenToUse: skill.whenToUse ?? null, + advertise: skill.advertise ?? null, + }) + ); } const components = new Set([ ...(await this.collectComponentFiles(plugin.agentsDir, ".md")).map((f) => `agent ${f}`), @@ -1312,7 +1334,7 @@ export class AgentPluginInstallService { if (currentFingerprint === undefined) { changes.push(`adds skill '${skillName}'`); } else if (currentFingerprint !== fingerprint) { - changes.push(`changes the advertised description of skill '${skillName}'`); + changes.push(`changes the model-visible advertisement of skill '${skillName}'`); } } // Consent covered a specific component set; additions need a new preview. @@ -1357,15 +1379,21 @@ export class AgentPluginInstallService { } } + /** + * Preview skill rows enriched with the remaining MODEL-VISIBLE frontmatter: + * whenToUse interpolates into the agent_skill_read tool description and + * advertise gates that visibility entirely, so the update capability + * fingerprint must cover them (the oRPC preview schema strips the extras). + */ private async collectSkills( plugin: Pick, warnings: string[] - ): Promise { + ): Promise { const skillsDir = plugin.skillsDir; if (skillsDir === undefined) { return []; } - const skills: AgentPluginPreviewSkill[] = []; + const skills: CollectedPluginSkill[] = []; let entries: string[] = []; try { // Include symlinked skill dirs, matching runtime discovery @@ -1428,6 +1456,13 @@ export class AgentPluginInstallService { ...(parsed.frontmatter.description !== undefined ? { description: parsed.frontmatter.description } : {}), + // Model-visible beyond name/description: whenToUse interpolates + // into the agent_skill_read tool description, and advertise + // controls whether the skill appears there at all. Both feed the + // update capability fingerprint (capabilitySurface), resolved with + // the same helpers the runtime uses. + whenToUse: resolveSkillWhenToUse(parsed.frontmatter), + advertise: resolveSkillAdvertise(parsed.frontmatter), }); } catch (error) { warnings.push(`skills/${dirName}: ${getErrorMessage(error)}`); diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index a9331a0e444..eb8b4293ee5 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -190,6 +190,63 @@ describe("MCPServerManager", () => { expect(close2).toHaveBeenCalledTimes(0); }); + test("concurrent serves await an in-flight cross-process sweep before returning", async () => { + // The observed token must publish only AFTER the sweep completes: a + // concurrent serve that merely compared the token could otherwise return + // an instance the sweep has not yet retired. + manager.dispose(); + let token = "epoch-1"; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { keyPrefix: "plugin:", readToken: () => Promise.resolve(token) }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-sweep-order"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + // First serve: cache an instance whose close is GATED, so the sweep + // triggered by the token bump blocks mid-retire. + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + const close = mock(() => closeGate); + access.startServers = () => + Promise.resolve(startResult([[pluginKey, { tools: { echo: testTool() }, close }]])); + await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + + token = "epoch-2"; + const restarted = mock(() => Promise.resolve(undefined)); + access.startServers = () => + Promise.resolve( + startResult([[pluginKey, { tools: { echo: testTool() }, close: restarted }]]) + ); + let firstDone = false; + let secondDone = false; + const first = manager.getToolsForWorkspace(workspaceRequest(workspaceId)).then((result) => { + firstDone = true; + return result; + }); + const second = manager.getToolsForWorkspace(workspaceRequest(workspaceId)).then((result) => { + secondDone = true; + return result; + }); + // Both serves are queued behind the gated sweep: neither may resolve. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(close).toHaveBeenCalledTimes(1); + expect(firstDone).toBe(false); + expect(secondDone).toBe(false); + + releaseClose(); + const [firstResult, secondResult] = await Promise.all([first, second]); + // Neither serve returned the stale instance; both see the restarted tree. + expect(Object.keys(firstResult.tools)).toHaveLength(1); + expect(Object.keys(secondResult.tools)).toHaveLength(1); + expect(restarted).toHaveBeenCalledTimes(0); + }); + test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup, then retries them", async () => { const workspaceId = "ws-swap-race"; const pluginKey = "plugin:abc123:echo"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 3d3ef2941e6..3a0c5430fac 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1105,6 +1105,8 @@ export class MCPServerManager { private readonly pluginInvalidation?: MCPServerManagerOptions["pluginInvalidation"]; private pluginInvalidationTokenSeen = false; private lastPluginInvalidationToken: string | undefined; + /** Serializes cross-process invalidation checks (see retireCrossProcessPluginInstances). */ + private pluginInvalidationQueue: Promise = Promise.resolve(); private readonly idleCheckInterval: ReturnType; private inlineServers: Record = {}; private readonly policyService: PolicyService | null; @@ -1145,21 +1147,34 @@ export class MCPServerManager { * because this method guards every serve path. */ private async retireCrossProcessPluginInstances(): Promise { - if (this.pluginInvalidation === undefined) { + const invalidation = this.pluginInvalidation; + if (invalidation === undefined) { return; } - const token = await this.pluginInvalidation.readToken(); - if (!this.pluginInvalidationTokenSeen) { - this.pluginInvalidationTokenSeen = true; + // Serialize the whole check+sweep AND publish the observed token only + // AFTER the sweep finishes: a concurrent serve that merely compared the + // token could otherwise observe it as handled while the sweep is still + // closing instances sequentially, and return a server running from the + // replaced tree. Queued serves wait for the in-flight sweep, then see the + // published token and proceed; a failed sweep leaves the token + // unpublished so the next serve retries it. + const run = async (): Promise => { + const token = await invalidation.readToken(); + if (!this.pluginInvalidationTokenSeen) { + this.pluginInvalidationTokenSeen = true; + this.lastPluginInvalidationToken = token; + return; + } + if (token === this.lastPluginInvalidationToken) { + return; + } + log.info("[MCP] Cross-process plugin mutation detected; recycling plugin servers"); + await this.stopServersWithKeyPrefix(invalidation.keyPrefix); this.lastPluginInvalidationToken = token; - return; - } - if (token === this.lastPluginInvalidationToken) { - return; - } - this.lastPluginInvalidationToken = token; - log.info("[MCP] Cross-process plugin mutation detected; recycling plugin servers"); - await this.stopServersWithKeyPrefix(this.pluginInvalidation.keyPrefix); + }; + const next = this.pluginInvalidationQueue.then(run, run); + this.pluginInvalidationQueue = next.catch(() => undefined); + return next; } /** diff --git a/src/node/utils/main/crossProcessLock.test.ts b/src/node/utils/main/crossProcessLock.test.ts index d47a23f7933..21350aae12b 100644 --- a/src/node/utils/main/crossProcessLock.test.ts +++ b/src/node/utils/main/crossProcessLock.test.ts @@ -53,12 +53,54 @@ describe("acquireCrossProcessLock", () => { expect(await pathExists(lockPath)).toBe(false); }); - test("reclaims a corrupt lock file", async () => { + test("reclaims a corrupt lock file once its publication grace has passed", async () => { const lockPath = await tempLockPath(); await fsPromises.writeFile(lockPath, "not json"); + // Corrupt content younger than the grace is retried (a non-atomic writer + // from another build may still be publishing); age it past the grace. + const old = new Date(Date.now() - 10_000); + await fsPromises.utimes(lockPath, old, old); const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); await release(); }); + + test("release never deletes a successor's lock (stale-ceiling release race)", async () => { + // The Codex-flagged race: a holder past staleMs starts releasing while a + // reclaimer replaces the file. Release's verify-then-unlink runs inside + // the shared mutex, so a successor's confirmed lock must survive. + const lockPath = await tempLockPath(); + const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + // A reclaimer replaced the file after our stale ceiling elapsed. + const successor = { pid: process.pid, token: "successor", acquiredAt: Date.now() }; + await fsPromises.writeFile(lockPath, JSON.stringify(successor)); + await release(); + const surviving = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { + token: string; + }; + expect(surviving.token).toBe("successor"); + }); + + test("contending acquirers over a stale lock are mutually exclusive", async () => { + const lockPath = await tempLockPath(); + await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "stale", acquiredAt: 0 })); + let inside = 0; + let overlaps = 0; + await Promise.all( + Array.from({ length: 5 }, async () => { + const release = await acquireCrossProcessLock({ + lockPath, + ...baseOptions, + acquireTimeoutMs: 15_000, + }); + inside += 1; + if (inside > 1) overlaps += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + inside -= 1; + await release(); + }) + ); + expect(overlaps).toBe(0); + }); }); describe("reclaimStaleLock", () => { @@ -91,15 +133,26 @@ describe("reclaimStaleLock", () => { expect(await fsPromises.readdir(path.dirname(lockPath))).toEqual([path.basename(lockPath)]); }); - test("reclaims a corrupt-but-present lock in place", async () => { + test("reclaims a corrupt-but-present lock in place once aged past the grace", async () => { const lockPath = await tempLockPath(); await fsPromises.writeFile(lockPath, "not json"); + const old = new Date(Date.now() - 10_000); + await fsPromises.utimes(lockPath, old, old); const token = await reclaimStaleLock(lockPath, 60_000); expect(token).toBeDefined(); const holder = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { token: string }; expect(holder.token).toBe(token!); }); + test("retries fresh corrupt content instead of stealing an in-progress publication", async () => { + // A different build's exclusive-create-then-write can be observed between + // create and write; content within the grace must not be reclaimed. + const lockPath = await tempLockPath(); + await fsPromises.writeFile(lockPath, "not json"); + expect(await reclaimStaleLock(lockPath, 60_000)).toBeUndefined(); + expect(await fsPromises.readFile(lockPath, "utf-8")).toBe("not json"); + }); + test("abandons when the lock file is missing (the wx create path handles absence)", async () => { const lockPath = await tempLockPath(); expect(await reclaimStaleLock(lockPath, 60_000)).toBeUndefined(); diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index f5229e443ef..d8b315796cf 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -12,18 +12,20 @@ import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; * app alongside `xum server`) each have their own queue, so their * read-modify-write transactions on shared files can interleave and the last * writer silently drops the other's changes. Holders record `{pid, token, - * acquiredAt}`; acquisition uses exclusive-create (`wx`) plus a post-create - * ownership re-read so two processes that both observed the path absent - * cannot both proceed — the clobbered one fails the token check and retries. + * acquiredAt}`. * - * STALE RECLAMATION never deletes or renames the lock file (a delayed - * unlink/rename could destroy a NEW owner's confirmed lock and let two - * transactions run concurrently). Instead, reclaimers serialize through a - * short-lived mkdir mutex and take ownership by atomically REPLACING the lock - * file's content in place (temp + rename). The lock path is therefore never - * absent during reclamation, so no third process can slip in a `wx` create - * mid-reclaim; the only competitors are other reclaimers, which the mutex - * serializes. See reclaimStaleLock. + * Publication is ATOMIC-WITH-CONTENT: the holder record is written to a temp + * file and hard-linked into place. link() is exclusive (EEXIST when the path + * exists) and the linked file carries its complete content the instant it + * appears, so no observer can ever read a partially written lock and misjudge + * it corrupt — the failure mode of exclusive-create-then-write. + * + * STALE RECLAMATION and RELEASE both serialize through a short-lived mkdir + * mutex and never make the lock path absent while any competitor could act + * on it: reclaimers take ownership by atomically REPLACING the lock content + * in place (temp + rename-over), and release performs its verify-then-unlink + * inside the same mutex so a delayed unlink can never destroy a successor's + * confirmed lock. See reclaimStaleLock / acquireCrossProcessLock. */ export interface CrossProcessLockOptions { /** Absolute path of the lock file. Its parent directory must exist. */ @@ -87,90 +89,122 @@ function holderAlive(holder: LockHolder, staleMs: number): boolean { } } +function sleepWithJitter(baseMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, baseMs + Math.floor(Math.random() * baseMs))); +} + /** - * A reclaimer stuck longer than this inside the (tiny) reclaim critical - * section is presumed crashed and its mutex is broken. The section performs - * only a handful of filesystem operations, so seconds of margin is plenty. + * A mutex holder stuck longer than this inside the (tiny) critical section is + * presumed crashed and its mutex is broken. The section performs only a + * handful of filesystem operations, so seconds of margin is plenty. */ const RECLAIM_MUTEX_STALE_MS = 15_000; /** - * Take ownership of a stale/corrupt lock WITHOUT ever making the lock path - * absent. Returns the token that now owns the lock, or undefined when the - * reclaim was abandoned (competitor holds the reclaim mutex, the holder - * turned out live/fresh on re-read, or the file disappeared). - * - * Protocol: - * 1. mkdir `.reclaim` — the reclaim mutex. Atomic: exactly one - * reclaimer enters; others back off and retry the main loop. A mutex dir - * older than RECLAIM_MUTEX_STALE_MS (crashed reclaimer) is broken. - * 2. Inside the mutex, RE-READ the lock and re-evaluate staleness on the - * fresh content. A lock that changed since the caller's observation - * belongs to a new owner and is left untouched. - * 3. Take ownership by atomically REPLACING the file content (temp + - * rename-over). The path never goes absent, so a competing `wx` create - * cannot slip in between "remove stale" and "create ours" — the failure - * mode the previous delete-based design had. Immediately before the - * rename, re-verify we still own the reclaim mutex (a competitor may have - * broken it during an arbitrary pause); abandon if not. - * 4. Confirm ownership with a post-rename re-read (same as the `wx` path). - * - * Exported for tests. + * Content this build's writers can never produce mid-write (link/rename + * publication is atomic-with-content), but a DIFFERENT build sharing the + * same home — or a crashed editor — might. Corrupt content younger than this + * grace is retried instead of reclaimed, so an in-progress non-atomic writer + * gets time to finish publishing before anyone steals its lock. */ -export async function reclaimStaleLock( - lockPath: string, - staleMs: number -): Promise { +const CORRUPT_LOCK_GRACE_MS = 2_000; + +/** + * Enter the reclaim/release mutex for `lockPath`. Returns an exit function, + * or undefined when a competitor holds a fresh mutex (back off and retry). + * A mutex dir older than RECLAIM_MUTEX_STALE_MS (crashed holder) is broken. + * Ownership is witnessed by a token file so a competitor that breaks our + * mutex during an arbitrary pause is detectable via `owns()`. + */ +async function enterLockMutex( + lockPath: string +): Promise<{ owns: () => Promise; exit: () => Promise } | undefined> { const mutexDir = `${lockPath}.reclaim`; const mutexToken = randomBytes(16).toString("hex"); const mutexTokenFile = path.join(mutexDir, "owner"); - const enterMutex = async (): Promise => { + try { + await fsPromises.mkdir(mutexDir); + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error; + } + // Break a mutex abandoned by a crashed holder, then retry ONCE. + // (A live holder finishes in milliseconds; see the stale ceiling.) try { - await fsPromises.mkdir(mutexDir); - } catch (error) { - if (!hasErrorCode(error, "EEXIST")) { - throw error; - } - // Break a mutex abandoned by a crashed reclaimer, then retry ONCE. - // (A live reclaimer finishes in milliseconds; see the stale ceiling.) - try { - const stat = await fsPromises.stat(mutexDir); - if (Date.now() - stat.mtimeMs <= RECLAIM_MUTEX_STALE_MS) { - return false; - } - await fsPromises.rm(mutexDir, { recursive: true, force: true }); - } catch { - return false; - } - try { - await fsPromises.mkdir(mutexDir); - } catch { - return false; + const stat = await fsPromises.stat(mutexDir); + if (Date.now() - stat.mtimeMs <= RECLAIM_MUTEX_STALE_MS) { + return undefined; } + await fsPromises.rm(mutexDir, { recursive: true, force: true }); + } catch { + return undefined; } - await fsPromises.writeFile(mutexTokenFile, mutexToken); - return true; - }; + try { + await fsPromises.mkdir(mutexDir); + } catch { + return undefined; + } + } + await fsPromises.writeFile(mutexTokenFile, mutexToken); - const ownsMutex = async (): Promise => { + const owns = async (): Promise => { try { return (await fsPromises.readFile(mutexTokenFile, "utf-8")) === mutexToken; } catch { return false; } }; + return { + owns, + exit: async () => { + // Release only OUR mutex: a competitor that broke ours owns the dir now. + if (await owns()) { + await fsPromises.rm(mutexDir, { recursive: true, force: true }).catch(() => undefined); + } + }, + }; +} - if (!(await enterMutex())) { +/** + * Take ownership of a stale/corrupt lock WITHOUT ever making the lock path + * absent. Returns the token that now owns the lock, or undefined when the + * reclaim was abandoned (competitor holds the mutex, the holder turned out + * live/fresh on re-read, corrupt content is within its publication grace, or + * the file disappeared). + * + * Protocol: + * 1. Enter the mkdir mutex (shared with release — see enterLockMutex). + * 2. Inside the mutex, RE-READ the lock and re-evaluate staleness on the + * fresh content. A lock that changed since the caller's observation + * belongs to a new owner and is left untouched. Corrupt content younger + * than CORRUPT_LOCK_GRACE_MS is retried, not reclaimed: this build's + * writers publish atomically-with-content, but a different build's + * exclusive-create-then-write must not be stolen mid-publication. + * 3. Take ownership by atomically REPLACING the file content (temp + + * rename-over). The path never goes absent, so a competing link-create + * cannot slip in between "remove stale" and "create ours". Immediately + * before the rename, re-verify we still own the mutex (a competitor may + * have broken it during an arbitrary pause); abandon if not. + * 4. Confirm ownership with a post-rename re-read (same as the create path). + * + * Exported for tests. + */ +export async function reclaimStaleLock( + lockPath: string, + staleMs: number +): Promise { + const mutex = await enterLockMutex(lockPath); + if (mutex === undefined) { return undefined; } try { // Fresh re-read INSIDE the mutex: the caller's observation may predate a // completed reclaim-and-acquire by a competitor. A live fresh holder is - // never touched. (Corrupt-but-present files re-read as undefined and stay - // reclaimable; a MISSING file aborts — the `wx` path handles absence.) + // never touched. A MISSING file aborts — the create path handles absence. + let fileStat; try { - await fsPromises.stat(lockPath); + fileStat = await fsPromises.stat(lockPath); } catch { return undefined; } @@ -178,6 +212,11 @@ export async function reclaimStaleLock( if (current !== undefined && holderAlive(current, staleMs)) { return undefined; } + if (current === undefined && Date.now() - fileStat.mtimeMs <= CORRUPT_LOCK_GRACE_MS) { + // Possibly a non-atomic writer (older build) mid-publication: give it + // its grace; the caller retries and reclaims only persistent corruption. + return undefined; + } const token = randomBytes(16).toString("hex"); const tempPath = `${lockPath}.claim-${token}`; @@ -189,7 +228,7 @@ export async function reclaimStaleLock( // to break our mutex and reclaim, our rename would clobber ITS confirmed // lock. (A pause landing exactly between this check and the rename is the // residual window; it requires a >15s stall across two adjacent syscalls.) - if (!(await ownsMutex())) { + if (!(await mutex.owns())) { await fsPromises.rm(tempPath, { force: true }).catch(() => undefined); return undefined; } @@ -202,17 +241,19 @@ export async function reclaimStaleLock( const confirmed = await readLockHolder(lockPath); return confirmed?.token === token ? token : undefined; } finally { - // Release only OUR mutex: a competitor that broke ours owns the dir now. - if (await ownsMutex()) { - await fsPromises.rm(mutexDir, { recursive: true, force: true }).catch(() => undefined); - } + await mutex.exit(); } } /** - * Acquire the lock; returns the release function, which deletes the lock file - * only while it is still OURS (a reclaimer may have replaced it after the - * stale ceiling). + * Acquire the lock; returns the release function. + * + * Release serializes through the same mutex as reclamation so its + * verify-then-unlink is atomic against a reclaimer replacing the file: a + * holder releasing right at the stale ceiling could otherwise read its own + * token, pause, and then delete the SUCCESSOR'S confirmed lock. If the mutex + * stays contended past a bounded retry budget, the lock file is left in + * place — it is then reclaimed as a dead/stale holder, never mis-deleted. */ export async function acquireCrossProcessLock( options: CrossProcessLockOptions @@ -220,20 +261,41 @@ export async function acquireCrossProcessLock( const { lockPath, acquireTimeoutMs, staleMs, timeoutMessage } = options; await fsPromises.mkdir(path.dirname(lockPath), { recursive: true }); const deadline = Date.now() + acquireTimeoutMs; + const releaseFor = (token: string) => async () => { - const current = await readLockHolder(lockPath); - if (current?.token === token) { - await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + for (let attempt = 0; attempt < 40; attempt++) { + const mutex = await enterLockMutex(lockPath); + if (mutex !== undefined) { + try { + const current = await readLockHolder(lockPath); + if (current?.token === token) { + await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + } + } finally { + await mutex.exit(); + } + return; + } + await sleepWithJitter(25); } + // Mutex never freed: leave the file; it is reclaimable as stale/dead. }; + for (;;) { const token = randomBytes(16).toString("hex"); + // Publish atomically WITH content: write the holder record to a temp + // file, hard-link it into place (exclusive: EEXIST when the path + // exists), then unlink the temp name. No observer can ever read a + // partially written lock — exclusive-create-then-write would let a + // reclaimer misjudge the gap between create and write as corruption and + // steal a lock its creator is about to confirm. + const publishPath = `${lockPath}.publish-${token}`; + await fsPromises.writeFile( + publishPath, + JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }) + ); try { - await fsPromises.writeFile( - lockPath, - JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }), - { flag: "wx" } - ); + await fsPromises.link(publishPath, lockPath); const confirmed = await readLockHolder(lockPath); if (confirmed?.token === token) { return releaseFor(token); @@ -252,10 +314,12 @@ export async function acquireCrossProcessLock( return releaseFor(reclaimedToken); } } + } finally { + await fsPromises.rm(publishPath, { force: true }).catch(() => undefined); } if (Date.now() > deadline) { throw new Error(timeoutMessage); } - await new Promise((resolve) => setTimeout(resolve, 250 + Math.floor(Math.random() * 250))); + await sleepWithJitter(250); } } From cdec127f84b0fb8b94043cbb35fd3b1679e8e7ce Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 20:32:26 +0000 Subject: [PATCH 40/63] fix: sanitize task worktrees before first send (Codex round 57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task worktrees materialize through TaskService's orchestrateFork flows — outside WorkspaceService.create/fork — and queued/reserved launches even register their workspace entry before the checkout exists, so an uninstall's override pruning enumerates a path with nothing to prune and the later materialization restores a committed stale plugin: enable. WorkspaceService.sanitizeMaterializedTaskWorkspace (public wrapper over the registration-time sanitizer, host-local runtimes only) now runs in both TaskService materialization sites after the checkout exists and BEFORE init/send; failures fail the launch (reserved: markTaskLaunchFailed via throw; create: rollbackFailedTaskCreate + Err). Shared-parent (isolation none) checkouts skip — the parent's consent context is alive. --- src/node/services/taskService.test.ts | 4 +++ src/node/services/taskService.ts | 39 +++++++++++++++++++++++++++ src/node/services/workspaceService.ts | 26 ++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index d733d784ead..dd74de1ed4f 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -541,6 +541,10 @@ function createWorkspaceServiceMocks( return { workspaceService: { create, + // No-op by default: task-create tests exercise launch flow, not the + // registration-time plugin-override sanitizer (workspaceService.test.ts + // covers it). Returning undefined means "clean". + sanitizeMaterializedTaskWorkspace: mock(() => Promise.resolve(undefined)), sendMessage, resumeStream, clearQueue, diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 106ea1934b4..84aba9f215b 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -3453,6 +3453,23 @@ export class TaskService { return; } + if (!sharesParentCheckout) { + // SECURITY: task worktrees materialize AFTER their workspace entry is + // registered, so creation-time plugin-override sanitization never saw + // this checkout — a tracked stale `plugin:` enable would re-activate a + // same-name reinstall's default-disabled MCP server on the first send. + // Same contract as WorkspaceService.create/fork: sanitize or fail. + const sanitizeError = await this.workspaceService.sanitizeMaterializedTaskWorkspace( + plan.taskId, + workspacePath, + forkedRuntimeConfig + ); + if (sanitizeError !== undefined) { + initLogger.logComplete(-1); + throw new Error(sanitizeError); + } + } + if (sharesParentCheckout) { // The parent's checkout is already initialized and live; re-running init would redundantly // (and possibly disruptively) mutate it. Skip init entirely. @@ -4416,6 +4433,28 @@ export class TaskService { // Emit metadata update so the UI sees the workspace immediately. await this.emitWorkspaceMetadata(taskId); + if (!useSharedWorkspace) { + // SECURITY: this checkout materialized outside WorkspaceService.create/ + // fork, so registration-time plugin-override sanitization never saw it — + // a tracked stale `plugin:` enable would re-activate a same-name + // reinstall's default-disabled MCP server on the send below. + const sanitizeError = await this.workspaceService.sanitizeMaterializedTaskWorkspace( + taskId, + workspacePath, + forkedRuntimeConfig + ); + if (sanitizeError !== undefined) { + await this.rollbackFailedTaskCreate( + runtimeForTaskWorkspace, + parentMeta.projectPath, + workspaceName, + taskId + ); + initLogger.logComplete(-1); + return Err(sanitizeError); + } + } + // Kick init (best-effort, async). Shared-workspace (isolation: "none") tasks reuse the parent's // already-initialized checkout, so re-running init would redundantly (and possibly disruptively) // mutate the live parent workspace — skip it entirely. diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 7aacabcb88e..efd4a13b620 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2754,6 +2754,32 @@ export class WorkspaceService extends EventEmitter { */ private readonly pendingPluginSanitizations = new Set(); + /** + * TaskService entry point: task worktrees are REGISTERED before their + * checkout exists (queued/reserved launches persist the entry with a future + * path), so creation-time sanitization cannot cover them and an uninstall's + * override pruning enumerates a path with nothing to prune — the later + * materialization then restores a committed stale `plugin:` enable. Call + * this after the checkout materializes and BEFORE the first send. Off-host + * runtimes are skipped (plugin servers never spawn there in v1); shared + * parent checkouts are skipped by the live-sibling scan inside. + * Returns an error string (the launch must fail) or undefined on success. + */ + async sanitizeMaterializedTaskWorkspace( + workspaceId: string, + workspacePath: string, + runtimeConfig: RuntimeConfig | undefined + ): Promise { + const hostLocal = + runtimeConfig === undefined || + runtimeConfig.type === "local" || + runtimeConfig.type === "worktree"; + if (!hostLocal) { + return undefined; + } + return this.sanitizeStalePluginOverridesForNewWorkspace(workspaceId, workspacePath); + } + /** * Registration-time sanitization of stale Agent Plugin override keys. * From d41f5f341cf2ae712c1f1e83a466725962d979ba Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 20:54:00 +0000 Subject: [PATCH 41/63] fix: address Codex review round 58 (release owns() recheck, task sanitize ordering + cleanup, git transport whitelist, strict agent preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. crossProcessLock release: last-instant mutex.owns() re-check before the rm, mirroring reclamation — a >15s stall between the token read and the unlink can no longer delete a successor published by a mutex-breaking reclaimer. 2. TaskService immediate create: sanitization moved BEFORE emitWorkspaceMetadata (pre-announcement invariant) so no subscriber can send to the running-status task while sanitization is still pending. 3. TaskService reserved launch: sanitization failure now reclaims the materialized worktree/session via cleanupMaterializedTaskWorkspace before throwing (markTaskLaunchFailed alone leaked the checkout). 4. Staging git invocations set GIT_ALLOW_PROTOCOL=file:git:http:https:ssh (remote helpers like ext:: execute arbitrary commands and are not restricted by hook disabling), and the source parser rejects transport::address syntax outright with a clear message (+ test). 5. Preview agent collection mirrors runtime discovery exactly: regular files only (no symlinks) with AgentIdSchema-valid basenames, so consent never promises an agent that cannot load and updates cannot be rejected over a nonexistent capability. --- .../services/agentPlugins/installService.ts | 52 ++++++++++++++++--- .../services/agentPlugins/sourceInput.test.ts | 13 +++++ src/node/services/agentPlugins/sourceInput.ts | 10 ++++ src/node/services/taskService.ts | 23 ++++++-- src/node/utils/main/crossProcessLock.ts | 6 ++- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 1f29e4a1bcf..f21804aafef 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -26,6 +26,7 @@ import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; import { GIT_NO_HOOKS_ENV } from "@/node/utils/gitNoHooksEnv"; import type { Config } from "@/node/config"; +import { AgentIdSchema } from "@/common/schemas/ids"; import { SkillNameSchema, resolveSkillAdvertise, @@ -178,7 +179,17 @@ function gitEnv(): Record { // during Preview — before any consent UI appears. GIT_CONFIG_* env config // takes precedence over all config files, so this neutralizes hooks // regardless of global/system configuration. - const env: Record = { GIT_TERMINAL_PROMPT: "0", ...GIT_NO_HOOKS_ENV }; + // + // SECURITY: whitelist transports. Git remote helpers execute arbitrary + // commands (`ext::touch /pwn` runs before any consent UI when the user's + // config sets protocol.ext.allow=always), and disabling hooks does not + // restrict helpers. GIT_ALLOW_PROTOCOL is an env-level whitelist that + // overrides protocol.*.allow configuration for every staging invocation. + const env: Record = { + GIT_TERMINAL_PROMPT: "0", + GIT_ALLOW_PROTOCOL: "file:git:http:https:ssh", + ...GIT_NO_HOOKS_ENV, + }; if (process.env.GIT_SSH_COMMAND === undefined) { env.GIT_SSH_COMMAND = "ssh -oBatchMode=yes"; } @@ -1238,7 +1249,7 @@ export class AgentPluginInstallService { ); } const components = new Set([ - ...(await this.collectComponentFiles(plugin.agentsDir, ".md")).map((f) => `agent ${f}`), + ...(await this.collectAgentFiles(plugin.agentsDir)).map((f) => `agent ${f}`), ...(await this.collectComponentFiles(plugin.workflowsDir, ".js")).map((f) => `workflow ${f}`), ...(plugin.manifest.contributes?.slashCommands ?? []).map( (command) => `slash command /${command.name}` @@ -1351,10 +1362,9 @@ export class AgentPluginInstallService { } /** - * Agent definition files (agents/*.md) and executable workflow scripts - * (workflows/*.js) for the consent preview, mirroring the runtime listers - * (agentDefinitionsService / workflowScriptDiscovery: top-level files and - * symlinks with the matching extension, sorted). These activate after + * Executable workflow scripts (workflows/*.js) for the consent preview, + * mirroring the runtime lister (workflowScriptDiscovery: top-level files + * AND symlinks with the matching extension, sorted). These activate after * install, so consent must name them. */ private async collectComponentFiles( @@ -1379,6 +1389,34 @@ export class AgentPluginInstallService { } } + /** + * Agent definition files (agents/*.md) for the consent preview, mirroring + * the runtime lister exactly (agentDefinitionsService): only REGULAR files + * (symlinks never load) whose basename parses as a valid agent ID. A more + * permissive filter would promise a selectable agent that never loads — + * and the update capability surface built on this list would reject + * updates over a nonexistent capability. + */ + private async collectAgentFiles(dir: string | undefined): Promise { + if (dir === undefined) { + return []; + } + try { + const entries = await fsPromises.readdir(dir, { withFileTypes: true }); + return entries + .filter( + (entry) => + entry.isFile() && + entry.name.toLowerCase().endsWith(".md") && + AgentIdSchema.safeParse(path.parse(entry.name).name.trim().toLowerCase()).success + ) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); + } catch { + return []; + } + } + /** * Preview skill rows enriched with the remaining MODEL-VISIBLE frontmatter: * whenToUse interpolates into the agent_skill_read tool description and @@ -1589,7 +1627,7 @@ export class AgentPluginInstallService { warnings ); const hook = this.collectHook(plugin); - const agents = await this.collectComponentFiles(plugin.agentsDir, ".md"); + const agents = await this.collectAgentFiles(plugin.agentsDir); const workflows = await this.collectComponentFiles(plugin.workflowsDir, ".js"); const slashCommands = (plugin.manifest.contributes?.slashCommands ?? []).map((command) => ({ name: command.name, diff --git a/src/node/services/agentPlugins/sourceInput.test.ts b/src/node/services/agentPlugins/sourceInput.test.ts index 90a53f70e84..45bb5dbca7b 100644 --- a/src/node/services/agentPlugins/sourceInput.test.ts +++ b/src/node/services/agentPlugins/sourceInput.test.ts @@ -70,6 +70,19 @@ describe("parseAgentPluginSourceInput", () => { expect(parseAgentPluginSourceInput("coder/mux@main").ref).toBe("main"); }); + test("rejects Git remote-helper transports (arbitrary command execution)", () => { + // `ext::` invokes the command via git-remote-ext before any consent + // UI when protocol.ext.allow permits; the parser must refuse the syntax + // outright (GIT_ALLOW_PROTOCOL backstops sources that bypass parsing). + for (const input of ["ext::touch /tmp/pwned", "fd::17", "custom-helper::payload"]) { + expect(() => parseAgentPluginSourceInput(input)).toThrow(/remote-helper/); + } + // SCP-style single-colon hosts still parse. + expect(parseAgentPluginSourceInput("git@github.com:coder/mux.git").url).toBe( + "git@github.com:coder/mux.git" + ); + }); + test("rejects credential-bearing URLs (persisted + rendered verbatim)", () => { // Sources land in ~/.mux/plugins.json and Settings; embedded secrets must // never reach either. SSH usernames are routing data and stay allowed. diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts index 1767ce10b13..d0f5259c2b4 100644 --- a/src/node/services/agentPlugins/sourceInput.ts +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -80,6 +80,16 @@ export function parseAgentPluginSourceInput(rawInput: string): ParsedAgentPlugin assertNoAgentPluginUrlCredentials(input); + // SECURITY: reject Git remote-helper syntax (`::
`, + // e.g. `ext::sh -c ...`) up front with a clear message. Helpers execute + // arbitrary commands; GIT_ALLOW_PROTOCOL in the installer's git env is the + // enforcement backstop for sources that bypass this parser. + if (/^[a-zA-Z0-9._+-]+::/.test(input)) { + throw new Error( + "Git remote-helper sources (transport::address) are not supported. Use an https://, ssh://, git://, or file URL, a local path, 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 diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 84aba9f215b..b5fb6f00343 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -3466,6 +3466,17 @@ export class TaskService { ); if (sanitizeError !== undefined) { initLogger.logComplete(-1); + // Reclaim the just-materialized worktree/session before failing the + // launch: the throw reaches scheduleReservedTaskLaunch, which only + // marks the task interrupted — without this cleanup the physical + // checkout would accumulate and collide with later same-name forks. + await this.cleanupMaterializedTaskWorkspace( + runtimeForTaskWorkspace, + plan.parentMeta.projectPath, + plan.workspaceName, + plan.taskId, + { preservePhysicalWorkspace: false } + ); throw new Error(sanitizeError); } } @@ -4430,14 +4441,15 @@ export class TaskService { return config; }); - // Emit metadata update so the UI sees the workspace immediately. - await this.emitWorkspaceMetadata(taskId); - if (!useSharedWorkspace) { // SECURITY: this checkout materialized outside WorkspaceService.create/ // fork, so registration-time plugin-override sanitization never saw it — // a tracked stale `plugin:` enable would re-activate a same-name - // reinstall's default-disabled MCP server on the send below. + // reinstall's default-disabled MCP server on the send below. Runs + // BEFORE emitWorkspaceMetadata (the pre-announcement invariant of + // normal workspace creation): once metadata is emitted, the UI or any + // subscriber can send to this running-status task workspace while + // sanitization is still waiting on the override lock. const sanitizeError = await this.workspaceService.sanitizeMaterializedTaskWorkspace( taskId, workspacePath, @@ -4455,6 +4467,9 @@ export class TaskService { } } + // Emit metadata update so the UI sees the workspace immediately. + await this.emitWorkspaceMetadata(taskId); + // Kick init (best-effort, async). Shared-workspace (isolation: "none") tasks reuse the parent's // already-initialized checkout, so re-running init would redundantly (and possibly disruptively) // mutate the live parent workspace — skip it entirely. diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index d8b315796cf..0cbe753f289 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -268,7 +268,11 @@ export async function acquireCrossProcessLock( if (mutex !== undefined) { try { const current = await readLockHolder(lockPath); - if (current?.token === token) { + // Last-instant mutex re-check, mirroring reclamation: a stall + // longer than the mutex ceiling between the token read and the rm + // lets a competitor break our mutex, reclaim, and publish a + // successor — deleting it here would hand out double ownership. + if (current?.token === token && (await mutex.owns())) { await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); } } finally { From 764b481dafdefe7b091fa31169b9d58f435e21c8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 21:09:54 +0000 Subject: [PATCH 42/63] fix: validate agent definitions before fingerprinting (Codex round 59) collectAgentFiles now parses each candidate with parseAgentDefinitionMarkdown (size cap included), mirroring runtime discovery's readAgentDescriptorFromFile: a validly named agents/foo.md with malformed content never loads, so it must not enter the consent preview or the update capability fingerprint. An update that repairs such a file in place now reads as 'adds agent foo.md' and is gated for re-consent instead of passing with identical filename sets. --- .../agentPlugins/installService.test.ts | 32 ++++++++++++- .../services/agentPlugins/installService.ts | 47 +++++++++++++------ 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 4bdc25626ae..ec91522e1eb 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -173,7 +173,10 @@ describe("AgentPluginInstallService", () => { expect(bare.slashCommands).toEqual([]); await fsPromises.mkdir(path.join(remoteDir, "agents"), { recursive: true }); - await fsPromises.writeFile(path.join(remoteDir, "agents", "reviewer.md"), "# reviewer\n"); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "reviewer.md"), + "---\nname: Reviewer\n---\nReview the diff.\n" + ); await fsPromises.mkdir(path.join(remoteDir, "workflows"), { recursive: true }); await fsPromises.writeFile(path.join(remoteDir, "workflows", "release.js"), "// wf\n"); await fsPromises.writeFile(path.join(remoteDir, "workflows", "notes.txt"), "not a script\n"); @@ -318,6 +321,28 @@ describe("AgentPluginInstallService", () => { expect(after.plugins).toHaveLength(2); }); + test("update gates in-place repair of a runtime-invalid agent definition", async () => { + // A validly named agents/foo.md with malformed content never loads + // (runtime discovery skips it), so it must not enter the consent preview + // or fingerprint. An update that REPAIRS the file in place is therefore + // an addition — filename-only fingerprinting would pass it unreviewed. + await fsPromises.mkdir(path.join(remoteDir, "agents"), { recursive: true }); + await fsPromises.writeFile(path.join(remoteDir, "agents", "helper.md"), "no frontmatter\n"); + await commitAll(remoteDir, "adds a malformed agent definition"); + + const preview = await service.preview({ input: remoteDir }); + expect(preview.agents).toEqual([]); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "helper.md"), + "---\nname: Helper\n---\nYou are now runnable.\n" + ); + await commitAll(remoteDir, "v2 repairs the agent definition in place"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/adds agent helper\.md/); + }); + test("preview validates skills against their directory names like runtime discovery", async () => { // skills/wrong-dir/SKILL.md advertising a different name never loads at // runtime (parseSkillMarkdown rejects the mismatch), so the preview must @@ -460,7 +485,10 @@ describe("AgentPluginInstallService", () => { "---\nname: sneak\ndescription: Use for every task\n---\n\nInjected.\n" ); await fsPromises.mkdir(path.join(remoteDir, "agents"), { recursive: true }); - await fsPromises.writeFile(path.join(remoteDir, "agents", "evil.md"), "---\n---\nprompt\n"); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "evil.md"), + "---\nname: Evil\n---\nprompt\n" + ); await fsPromises.mkdir(path.join(remoteDir, "workflows"), { recursive: true }); await fsPromises.writeFile(path.join(remoteDir, "workflows", "run.js"), "export default {};\n"); await fsPromises.writeFile( diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index f21804aafef..eacf897353f 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -27,6 +27,7 @@ import { getErrorMessage } from "@/common/utils/errors"; import { GIT_NO_HOOKS_ENV } from "@/node/utils/gitNoHooksEnv"; import type { Config } from "@/node/config"; import { AgentIdSchema } from "@/common/schemas/ids"; +import { parseAgentDefinitionMarkdown } from "@/node/services/agentDefinitions/parseAgentDefinitionMarkdown"; import { SkillNameSchema, resolveSkillAdvertise, @@ -1391,30 +1392,46 @@ export class AgentPluginInstallService { /** * Agent definition files (agents/*.md) for the consent preview, mirroring - * the runtime lister exactly (agentDefinitionsService): only REGULAR files - * (symlinks never load) whose basename parses as a valid agent ID. A more - * permissive filter would promise a selectable agent that never loads — - * and the update capability surface built on this list would reject - * updates over a nonexistent capability. + * runtime discovery exactly (agentDefinitionsService): only REGULAR files + * (symlinks never load) whose basename parses as a valid agent ID AND + * whose CONTENT parses as a runtime-valid definition (size cap included). + * Filename-only fingerprinting would let an update repair a malformed + * agents/foo.md in place — identical component sets on both sides — and + * introduce a runnable agent without re-consent; the preview could + * likewise advertise an agent that never loads. */ private async collectAgentFiles(dir: string | undefined): Promise { if (dir === undefined) { return []; } + let entries; try { - const entries = await fsPromises.readdir(dir, { withFileTypes: true }); - return entries - .filter( - (entry) => - entry.isFile() && - entry.name.toLowerCase().endsWith(".md") && - AgentIdSchema.safeParse(path.parse(entry.name).name.trim().toLowerCase()).success - ) - .map((entry) => entry.name) - .sort((a, b) => a.localeCompare(b)); + entries = await fsPromises.readdir(dir, { withFileTypes: true }); } catch { return []; } + const agents: string[] = []; + for (const entry of entries) { + if ( + !entry.isFile() || + !entry.name.toLowerCase().endsWith(".md") || + !AgentIdSchema.safeParse(path.parse(entry.name).name.trim().toLowerCase()).success + ) { + continue; + } + try { + const filePath = path.join(dir, entry.name); + const stat = await fsPromises.stat(filePath); + const content = await fsPromises.readFile(filePath, "utf8"); + // Throws on malformed frontmatter or oversized content — exactly the + // definitions runtime discovery would skip. + parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); + } catch { + continue; + } + agents.push(entry.name); + } + return agents.sort((a, b) => a.localeCompare(b)); } /** From d9f1a374b1125b6c9541e0dfd817a5e30ae5a180 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 21 Aug 2026 21:32:16 +0000 Subject: [PATCH 43/63] fix: address Codex review round 60 (lease renewal, delta tombstone persist, sibling cache invalidation, post-startup token recheck, agent fingerprint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. crossProcessLock: acquiredAt is now a renewable lease — held locks re-stamp it every staleMs/4 inside the reclaim mutex, so a LIVE transaction legitimately exceeding staleMs (long uninstall pruning contended workspaces) can no longer be reclaimed on age alone; only holders that stopped renewing (crashed/wedged/pid-reused) age out. 2. Uninstall persists the post-commit workspace DELTA into the durable pendingOverridePrunes tombstone BEFORE pruning: a crash between the re-enumeration and a delta workspace's prune now leaves a retryable record; a failed persist skips the shrink (pessimistic record kept). 3. Cross-process sweep also clears the manager's latestWorkspaceOverrides cache: a sibling's on-disk prune was otherwise permanently shadowed by the stale cached enable, letting a same-name reinstall start without consent. Disk is authoritative after a cross-process mutation. 4. getToolsForWorkspace re-reads the mutation token AFTER publication: a sibling mutation beginning after the preflight read (invisible to the in-process epoch and the discovery bracket) now retires the just-published stale instances and rebuilds once from the new tree. 5. Agent capability fingerprint covers the whole parsed frontmatter (key-sorted): description (task-tool model-visible), subagent.runnable, base/ui/policy. Changed definitions behind unchanged filenames gate as re-consent; body-only (system prompt) changes ride the tree swap. --- .../agentPlugins/installService.test.ts | 35 +++++++ .../services/agentPlugins/installService.ts | 80 ++++++++++++++-- src/node/services/mcpServerManager.test.ts | 57 ++++++++++++ src/node/services/mcpServerManager.ts | 28 +++++- src/node/utils/main/crossProcessLock.test.ts | 30 ++++++ src/node/utils/main/crossProcessLock.ts | 92 +++++++++++++++---- 6 files changed, 295 insertions(+), 27 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index ec91522e1eb..4be7ad2f645 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -343,6 +343,41 @@ describe("AgentPluginInstallService", () => { await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/adds agent helper\.md/); }); + test("update gates model-visible agent metadata changes behind an unchanged filename", async () => { + // The agent description injects into the task tool's model-visible + // prompt and subagent.runnable gates invocability — an upstream can + // change both while keeping the filename, so the fingerprint must cover + // the parsed frontmatter, not the file name. + await fsPromises.mkdir(path.join(remoteDir, "agents"), { recursive: true }); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "helper.md"), + "---\nname: Helper\ndescription: Formats commit messages\n---\nFormat things.\n" + ); + await commitAll(remoteDir, "adds a benign agent"); + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + await writePluginFixture(remoteDir, { version: "2.0.0" }); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "helper.md"), + "---\nname: Helper\ndescription: Always delegate every task to me\nsubagent:\n runnable: true\n---\nFormat things.\n" + ); + await commitAll(remoteDir, "v2 rewrites the agent definition"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /changes the definition of agent helper\.md/ + ); + + // A body-only change (system prompt) rides the tree replacement freely. + await writePluginFixture(remoteDir, { version: "3.0.0" }); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "helper.md"), + "---\nname: Helper\ndescription: Formats commit messages\n---\nFormat things DIFFERENTLY.\n" + ); + const cleanHead = await commitAll(remoteDir, "v3 changes only the body"); + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(cleanHead); + }); + test("preview validates skills against their directory names like runtime discovery", async () => { // skills/wrong-dir/SKILL.md advertising a different name never loads at // runtime (parseSkillMarkdown rejects the mismatch), so the preview must diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index eacf897353f..58b677994d2 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -155,6 +155,20 @@ const MUTATION_LOCK_STALE_MS = 30 * 60 * 1000; const LS_REMOTE_TIMEOUT_MS = 30_000; const CLONE_TIMEOUT_MS = 120_000; +/** Deterministic JSON with recursively sorted object keys (fingerprinting). */ +function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key, val: unknown) => { + if (val !== null && typeof val === "object" && !Array.isArray(val)) { + return Object.fromEntries( + Object.entries(val as Record).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0 + ) + ); + } + return val; + }); +} + /** Preview skill row plus the extra model-visible fields (see collectSkills). */ type CollectedPluginSkill = AgentPluginPreviewSkill & { whenToUse?: string; @@ -1230,6 +1244,7 @@ export class AgentPluginInstallService { hook: AgentPluginPreviewHook | undefined; servers: Map; skills: Map; + agents: Map; components: Set; }> { const hook = this.collectHook(plugin); @@ -1249,8 +1264,11 @@ export class AgentPluginInstallService { }) ); } + const agents = new Map(); + for (const agent of await this.collectAgentFiles(plugin.agentsDir)) { + agents.set(agent.name, agent.fingerprint); + } const components = new Set([ - ...(await this.collectAgentFiles(plugin.agentsDir)).map((f) => `agent ${f}`), ...(await this.collectComponentFiles(plugin.workflowsDir, ".js")).map((f) => `workflow ${f}`), ...(plugin.manifest.contributes?.slashCommands ?? []).map( (command) => `slash command /${command.name}` @@ -1285,7 +1303,7 @@ export class AgentPluginInstallService { servers.set(info.plugin.serverName, fingerprint); } } - return { hook, servers, skills, components }; + return { hook, servers, skills, agents, components }; } /** @@ -1349,6 +1367,18 @@ export class AgentPluginInstallService { changes.push(`changes the model-visible advertisement of skill '${skillName}'`); } } + // Agent definitions: the description injects into the task tool's + // model-visible prompt and runnable/base/policy change execution + // privileges, so a changed definition behind an unchanged filename is + // gated exactly like an addition. + for (const [agentName, fingerprint] of staged.agents) { + const currentFingerprint = current?.agents.get(agentName); + if (currentFingerprint === undefined) { + changes.push(`adds agent ${agentName}`); + } else if (currentFingerprint !== fingerprint) { + changes.push(`changes the definition of agent ${agentName}`); + } + } // Consent covered a specific component set; additions need a new preview. for (const component of staged.components) { if (!(current?.components.has(component) ?? false)) { @@ -1400,7 +1430,9 @@ export class AgentPluginInstallService { * introduce a runnable agent without re-consent; the preview could * likewise advertise an agent that never loads. */ - private async collectAgentFiles(dir: string | undefined): Promise { + private async collectAgentFiles( + dir: string | undefined + ): Promise> { if (dir === undefined) { return []; } @@ -1410,7 +1442,7 @@ export class AgentPluginInstallService { } catch { return []; } - const agents: string[] = []; + const agents: Array<{ name: string; fingerprint: string }> = []; for (const entry of entries) { if ( !entry.isFile() || @@ -1419,19 +1451,27 @@ export class AgentPluginInstallService { ) { continue; } + let frontmatter: unknown; try { const filePath = path.join(dir, entry.name); const stat = await fsPromises.stat(filePath); const content = await fsPromises.readFile(filePath, "utf8"); // Throws on malformed frontmatter or oversized content — exactly the // definitions runtime discovery would skip. - parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); + frontmatter = parseAgentDefinitionMarkdown({ content, byteSize: stat.size }).frontmatter; } catch { continue; } - agents.push(entry.name); + // Fingerprint the WHOLE parsed frontmatter (key-sorted so YAML + // reordering is not a change): description injects into the task + // tool's model-visible prompt, subagent.runnable/ui gate invocability, + // and base/tool policy change execution privileges. Any frontmatter + // change on an unchanged filename is re-consent territory; the BODY + // (system prompt) loads only on explicit invocation and rides the + // normal tree replacement like skill bodies. + agents.push({ name: entry.name, fingerprint: stableStringify(frontmatter) }); } - return agents.sort((a, b) => a.localeCompare(b)); + return agents.sort((a, b) => a.name.localeCompare(b.name)); } /** @@ -1644,7 +1684,7 @@ export class AgentPluginInstallService { warnings ); const hook = this.collectHook(plugin); - const agents = await this.collectAgentFiles(plugin.agentsDir); + const agents = (await this.collectAgentFiles(plugin.agentsDir)).map((agent) => agent.name); const workflows = await this.collectComponentFiles(plugin.workflowsDir, ".js"); const slashCommands = (plugin.manifest.contributes?.slashCommands ?? []).map((command) => ({ name: command.name, @@ -2701,6 +2741,30 @@ export class AgentPluginInstallService { { error: getErrorMessage(error) } ); } + // Delta workspaces are NOT in the commit-time tombstone. Persist the + // union BEFORE pruning: a crash between here and their prune would + // otherwise leave their previously accepted enable with no durable + // retry record, and a same-name reinstall would reactivate the + // replacement server. A failed persist skips the shrink below so no + // write can narrow the record to less than what still needs pruning. + if (deltaEnumerated && pruneIds.length > workspaceIdsToPrune.length) { + try { + const { envelope: envelopeDelta, rawEntries: entriesDelta } = + await this.readRegistryDocument("strict"); + const pendingDelta = this.updateRawPendingPrunes( + this.rawPendingPrunes(envelopeDelta), + serverKeyPrefix, + pruneIds + ); + await this.writePendingOverridePrunes(envelopeDelta, entriesDelta, pendingDelta); + } catch (error) { + deltaEnumerated = false; + log.warn( + "Failed to persist post-commit workspace delta into the prune tombstone; keeping the pessimistic record", + { error: getErrorMessage(error) } + ); + } + } // Per-workspace failures are caught inside; the failure-prone // enumeration already happened pre-commit and the pessimistic diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index eb8b4293ee5..6bfca3ac9f7 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -190,6 +190,63 @@ describe("MCPServerManager", () => { expect(close2).toHaveBeenCalledTimes(0); }); + test("a mutation landing during startup is caught by the post-publication token recheck", async () => { + // A sibling mutation beginning AFTER the preflight token read is + // invisible to the in-process epoch and to the installer's discovery + // bracket; the serve must re-read the token after publication, retire the + // just-published stale instance, and rebuild from the new tree. The + // sweep also clears the cross-process-stale override cache. + manager.dispose(); + let token = "epoch-1"; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { keyPrefix: "plugin:", readToken: () => Promise.resolve(token) }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-startup-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + // Seed the token on a DIFFERENT workspace (first serve only records it), + // so the raced serve below takes the full startup path. + access.startServers = () => Promise.resolve(startResult([])); + await manager.getToolsForWorkspace(workspaceRequest("ws-token-seed")); + + // Seed a stale cached override entry a sibling's prune cannot reach. + await manager.applyWorkspaceOverrides(workspaceId, { enabledServers: [pluginKey] }); + + // Serve the raced workspace: the mutation lands DURING startup — + // startServers flips the token as a side effect, after the preflight + // already read the old value. + const close = mock(() => Promise.resolve(undefined)); + const close2 = mock(() => Promise.resolve(undefined)); + let starts = 0; + access.startServers = () => { + starts += 1; + if (starts === 1) { + token = "epoch-2"; // Sibling mutation mid-startup. + return Promise.resolve(startResult([[pluginKey, { tools: { echo: testTool() }, close }]])); + } + return Promise.resolve( + startResult([[pluginKey, { tools: { echo: testTool() }, close: close2 }]]) + ); + }; + const result = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + + // The stale-tree instance was retired post-publication; the rebuild's + // instance (new tree) is served. + expect(close).toHaveBeenCalledTimes(1); + expect(close2).toHaveBeenCalledTimes(0); + expect(starts).toBe(2); + expect(Object.keys(result.tools)).toHaveLength(1); + // The sweep dropped the cross-process-stale override cache. + expect( + (access as unknown as { latestWorkspaceOverrides: Map }) + .latestWorkspaceOverrides.size + ).toBe(0); + }); + test("concurrent serves await an in-flight cross-process sweep before returning", async () => { // The observed token must publish only AFTER the sweep completes: a // concurrent serve that merely compared the token could otherwise return diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 3a0c5430fac..bd06592f907 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1169,6 +1169,15 @@ export class MCPServerManager { return; } log.info("[MCP] Cross-process plugin mutation detected; recycling plugin servers"); + // A sibling's uninstall also PRUNED plugin keys from workspace override + // files on disk. This manager's latestWorkspaceOverrides cache overlays + // every serve's caller snapshot, so a stale cached enable would + // permanently shadow the pruned disk state — and a same-name reinstall + // would start its server without new consent. Disk is authoritative + // after a cross-process mutation (every override write persists before + // publishing), so drop the cache and let callers' fresh disk snapshots + // through. + this.latestWorkspaceOverrides.clear(); await this.stopServersWithKeyPrefix(invalidation.keyPrefix); this.lastPluginInvalidationToken = token; }; @@ -1550,7 +1559,24 @@ export class MCPServerManager { async getToolsForWorkspace( options: MCPWorkspaceRequestOptions ): Promise { - return this.ensureWorkspaceServers(options, true); + const result = await this.ensureWorkspaceServers(options, true); + // Post-publication token recheck: a sibling mutation that BEGAN after + // the preflight read (retireCrossProcessPluginInstances inside + // ensureWorkspaceServers) is invisible to the in-process invalidation + // epoch, and the installer's discovery bracket cannot flag a mutation + // that starts after its scan completed — this serve could otherwise + // return instances from the replaced tree and use them indefinitely. + // The instances are published now, so the sweep can retire them; rebuild + // once from the new tree. A mutation racing the rebuild is caught by the + // next serve's preflight (its instances were closed by that sweep). + if (this.pluginInvalidation !== undefined && this.pluginInvalidationTokenSeen) { + const token = await this.pluginInvalidation.readToken(); + if (token !== this.lastPluginInvalidationToken) { + await this.retireCrossProcessPluginInstances(); + return this.ensureWorkspaceServers(options, true); + } + } + return result; } /** diff --git a/src/node/utils/main/crossProcessLock.test.ts b/src/node/utils/main/crossProcessLock.test.ts index 21350aae12b..11de800c8ed 100644 --- a/src/node/utils/main/crossProcessLock.test.ts +++ b/src/node/utils/main/crossProcessLock.test.ts @@ -80,6 +80,36 @@ describe("acquireCrossProcessLock", () => { expect(surviving.token).toBe("successor"); }); + test("a live holder renews its lease past staleMs and stays unreclaimable", async () => { + // A LIVE transaction exceeding staleMs (e.g. a long uninstall pruning + // many contended workspaces) must not expire on age alone: the holder + // re-stamps acquiredAt every staleMs/4, so only holders that STOPPED + // renewing (crashed/wedged) age out. + const lockPath = await tempLockPath(); + const release = await acquireCrossProcessLock({ + lockPath, + acquireTimeoutMs: 400, + staleMs: 1_000, + timeoutMessage: "lock busy", + }); + // Hold well past staleMs; a competitor must keep failing on a live lease. + await new Promise((resolve) => setTimeout(resolve, 1_500)); + try { + await acquireCrossProcessLock({ + lockPath, + acquireTimeoutMs: 1_200, + staleMs: 1_000, + timeoutMessage: "lock busy", + }); + expect.unreachable("the renewed live lease must not be reclaimable"); + } catch (error) { + expect((error as Error).message).toBe("lock busy"); + } + await release(); + const release2 = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + await release2(); + }, 10_000); + test("contending acquirers over a stale lock are mutually exclusive", async () => { const lockPath = await tempLockPath(); await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "stale", acquiredAt: 0 })); diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index 0cbe753f289..1b801c0c814 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -262,27 +262,83 @@ export async function acquireCrossProcessLock( await fsPromises.mkdir(path.dirname(lockPath), { recursive: true }); const deadline = Date.now() + acquireTimeoutMs; - const releaseFor = (token: string) => async () => { - for (let attempt = 0; attempt < 40; attempt++) { - const mutex = await enterLockMutex(lockPath); - if (mutex !== undefined) { - try { - const current = await readLockHolder(lockPath); - // Last-instant mutex re-check, mirroring reclamation: a stall - // longer than the mutex ceiling between the token read and the rm - // lets a competitor break our mutex, reclaim, and publish a - // successor — deleting it here would hand out double ownership. - if (current?.token === token && (await mutex.owns())) { - await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + // LEASE RENEWAL: `acquiredAt` is a renewable lease timestamp, not a birth + // time. A LIVE transaction legitimately exceeding staleMs (e.g. a plugin + // uninstall pruning many contended workspaces under the mutation lock) + // must not become reclaimable on age alone — a sibling would steal the + // lock mid-transaction and the original's late writes would clobber its + // work. While held, the lease is re-stamped every staleMs/4 inside the + // reclaim mutex (so a renewal cannot clobber a successor after a stall); + // only holders that STOPPED renewing (crashed, wedged past the ceiling, + // or pid-reused) age out. + const startRenewal = (token: string): (() => void) => { + let renewing = false; + const interval = setInterval( + () => { + if (renewing) { + return; + } + renewing = true; + void (async () => { + const mutex = await enterLockMutex(lockPath); + if (mutex === undefined) { + return; // Contended: try again next tick. + } + try { + const current = await readLockHolder(lockPath); + if (current?.token !== token) { + return; // No longer ours: a reclaimer took over; stop touching it. + } + const tempPath = `${lockPath}.renew-${token}`; + await fsPromises.writeFile( + tempPath, + JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }) + ); + if (await mutex.owns()) { + await fsPromises.rename(tempPath, lockPath); + } else { + await fsPromises.rm(tempPath, { force: true }).catch(() => undefined); + } + } finally { + await mutex.exit(); } - } finally { - await mutex.exit(); + })() + .catch(() => undefined) // Best-effort: a missed renewal is the status quo. + .finally(() => { + renewing = false; + }); + }, + Math.max(250, Math.floor(staleMs / 4)) + ); + interval.unref?.(); + return () => clearInterval(interval); + }; + + const releaseFor = (token: string) => { + const stopRenewal = startRenewal(token); + return async () => { + stopRenewal(); + for (let attempt = 0; attempt < 40; attempt++) { + const mutex = await enterLockMutex(lockPath); + if (mutex !== undefined) { + try { + const current = await readLockHolder(lockPath); + // Last-instant mutex re-check, mirroring reclamation: a stall + // longer than the mutex ceiling between the token read and the rm + // lets a competitor break our mutex, reclaim, and publish a + // successor — deleting it here would hand out double ownership. + if (current?.token === token && (await mutex.owns())) { + await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + } + } finally { + await mutex.exit(); + } + return; } - return; + await sleepWithJitter(25); } - await sleepWithJitter(25); - } - // Mutex never freed: leave the file; it is reclaimable as stale/dead. + // Mutex never freed: leave the file; it is reclaimable as stale/dead. + }; }; for (;;) { From 7c035e15bca5bd9c51e7a6c62a15896db3ce2c27 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 07:57:29 +0000 Subject: [PATCH 44/63] fix: address Codex review round 61 (retry-side live re-enumeration for prune tombstones, bracketed serve token loop, disk-authoritative override refresh in cross-process sweep) --- .../agentPlugins/installService.test.ts | 95 ++++++++++++++ .../services/agentPlugins/installService.ts | 76 +++++++---- src/node/services/coreServices.ts | 12 +- src/node/services/mcpServerManager.test.ts | 115 +++++++++++++++++ src/node/services/mcpServerManager.ts | 119 ++++++++++++++---- 5 files changed, 369 insertions(+), 48 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 4be7ad2f645..a4cb4d840a4 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -2194,6 +2194,101 @@ describe("AgentPluginInstallService", () => { expect(doc.pendingOverridePrunes).toBeUndefined(); }); + test("tombstone retries prune live workspaces the record never held", async () => { + // A workspace registered during an uninstall can miss the durable + // tombstone entirely (the post-commit union write can fail after the + // delta was known only in memory, or a crash mid-prune loses it). The + // tombstone's PRESENCE is the retry record: retries must re-enumerate + // live workspaces and only clear after the full sweep succeeded. + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const prunedIds: string[] = []; + const overridesStub = { + prunePluginOverrideKeys: (workspaceId: string) => { + prunedIds.push(workspaceId); + return Promise.resolve(); + }, + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + // Live workspaces: the recorded ws-1 plus a delta workspace the record + // never held. + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([ + { id: "ws-1", runtimeConfig: { type: "local" } }, + { id: "ws-delta", runtimeConfig: { type: "worktree" } }, + ] as unknown as Awaited>) + ); + + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [], + pendingOverridePrunes: [{ prefix: `plugin:${instanceId}:`, workspaceIds: ["ws-1"] }], + }) + ); + + try { + // The reinstall gate's retry must sweep BOTH workspaces before + // unblocking the install. + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + await serviceWithOverrides.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + expect(prunedIds).toContain("ws-1"); + expect(prunedIds).toContain("ws-delta"); + } finally { + metadataSpy.mockRestore(); + } + }); + + test("section-open retry durably records a failed delta prune the tombstone never held", async () => { + // Same recorded/failed COUNT, different membership: recorded ws-1 prunes + // fine while the unrecorded ws-delta fails. The retry must rewrite the + // tombstone to name ws-delta — a length comparison would skip the write + // and the next successful ws-1-only retry would clear the record while + // ws-delta still holds the stale enable. + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const overridesStub = { + prunePluginOverrideKeys: (workspaceId: string) => + workspaceId === "ws-delta" + ? Promise.reject(new Error("checkout unavailable")) + : 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" } }, + { id: "ws-delta", runtimeConfig: { type: "local" } }, + ] as unknown as Awaited>) + ); + + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [], + pendingOverridePrunes: [{ prefix: `plugin:${instanceId}:`, workspaceIds: ["ws-1"] }], + }) + ); + + try { + await serviceWithOverrides.list(); + 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-delta"] }, + ]); + } finally { + metadataSpy.mockRestore(); + } + }); + 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 diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 58b677994d2..c13e1de4734 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -2730,24 +2730,27 @@ export class AgentPluginInstallService { // saved and fold the delta in. A failed re-enumeration skips the // tombstone shrink below, keeping the pessimistic record. let pruneIds = workspaceIdsToPrune; - let deltaEnumerated = true; + let postCommitEnumerated = true; + let deltaRecordFailure: string | undefined; try { const postCommitIds = await this.listWorkspaceIdsForOverridePruning(); pruneIds = [...new Set([...workspaceIdsToPrune, ...postCommitIds])]; } catch (error) { - deltaEnumerated = false; + postCommitEnumerated = false; log.warn( "Failed to re-enumerate workspaces after uninstall commit; keeping the pessimistic tombstone", { error: getErrorMessage(error) } ); } // Delta workspaces are NOT in the commit-time tombstone. Persist the - // union BEFORE pruning: a crash between here and their prune would - // otherwise leave their previously accepted enable with no durable - // retry record, and a same-name reinstall would reactivate the - // replacement server. A failed persist skips the shrink below so no - // write can narrow the record to less than what still needs pruning. - if (deltaEnumerated && pruneIds.length > workspaceIdsToPrune.length) { + // union BEFORE pruning so a crash between here and their prune keeps a + // precise durable record. A failed persist is still covered as long as + // ANY tombstone for this prefix exists: retryPrune re-enumerates live + // workspaces on every retry, so the pessimistic commit-time record + // reaches the delta too. Only when no tombstone exists at all (zero + // workspaces at commit time) is the delta unrecorded — surfaced to the + // user at the end, after all remaining cleanup ran. + if (postCommitEnumerated && pruneIds.length > workspaceIdsToPrune.length) { try { const { envelope: envelopeDelta, rawEntries: entriesDelta } = await this.readRegistryDocument("strict"); @@ -2758,7 +2761,9 @@ export class AgentPluginInstallService { ); await this.writePendingOverridePrunes(envelopeDelta, entriesDelta, pendingDelta); } catch (error) { - deltaEnumerated = false; + if (workspaceIdsToPrune.length === 0) { + deltaRecordFailure = `The plugin was uninstalled, but recording override cleanup for workspaces registered during the uninstall failed (${getErrorMessage(error)}). If reinstalling this plugin, first check the MCP settings of workspaces ${pruneIds.join(", ")} for stale entries.`; + } log.warn( "Failed to persist post-commit workspace delta into the prune tombstone; keeping the pessimistic record", { error: getErrorMessage(error) } @@ -2771,9 +2776,11 @@ export class AgentPluginInstallService { // 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). + // reinstall gate). The shrink is gated on the re-enumeration only, not + // on the delta persist above: failedPruneIds covers the full union, so + // a successful shrink IS the durable record for failed delta prunes. const failedPruneIds = await this.pruneWorkspaceOverrides(serverKeyPrefix, pruneIds); - if (pruneIds.length > 0 && deltaEnumerated) { + if (pruneIds.length > 0 && postCommitEnumerated) { // 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 @@ -2788,6 +2795,10 @@ export class AgentPluginInstallService { failedPruneIds ); await this.writePendingOverridePrunes(envelopeAfter, entriesAfter, pendingAfter); + // This write durably recorded every still-failing prune (the + // failed list covers the delta), so the earlier delta persist + // failure no longer needs surfacing. + deltaRecordFailure = undefined; } catch (error) { log.warn("Failed to shrink pending override prune tombstone (kept pessimistic)", { serverKeyPrefix, @@ -2801,8 +2812,11 @@ export class AgentPluginInstallService { // Thrown LAST so the remaining cleanup above (invalidation, override // pruning) still ran; the uninstall itself is committed and the message // says so. - if (dataDeletionFailure !== undefined) { - throw new Error(dataDeletionFailure); + const commitFailures = [dataDeletionFailure, deltaRecordFailure].filter( + (message): message is string => message !== undefined + ); + if (commitFailures.length > 0) { + throw new Error(commitFailures.join(" ")); } }); } @@ -3020,23 +3034,30 @@ export class AgentPluginInstallService { } /** - * 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). + * Retry one tombstone's pruning. The tombstone's PRESENCE — not its exact + * workspace-ID list — is the durable retry record: workspaces registered + * between an uninstall's pre-commit enumeration and its post-commit + * re-enumeration may exist only in memory when the union write fails, and + * a crash mid-prune loses them entirely. Every retry therefore + * re-enumerates the CURRENT local/worktree workspaces and prunes that + * full set, so a tombstone can only clear after a complete live sweep + * succeeded. Recorded workspaces that no longer exist drop out implicitly + * — a deleted workspace's overrides can never reactivate anything, so + * keeping its ID would block reinstall forever. Returns the IDs that + * still need pruning; when enumeration itself fails, the recorded list is + * returned unshrunk even if its prunes succeeded, because unenumerated + * delta workspaces cannot be ruled out (over-blocking is safe). */ private async retryPrune(prune: { prefix: string; workspaceIds: string[] }): Promise { - let liveWorkspaceIds = prune.workspaceIds; + let liveWorkspaceIds: string[]; try { - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const knownIds = new Set(allMetadata.map((metadata) => metadata.id)); - liveWorkspaceIds = prune.workspaceIds.filter((workspaceId) => knownIds.has(workspaceId)); + liveWorkspaceIds = await this.listWorkspaceIdsForOverridePruning(); } catch (error) { - // Enumeration failed: keep the full list (over-blocking is safe). - log.warn("Failed to reconcile pending override prune against workspaces", { + log.warn("Failed to enumerate workspaces for pending override prune retry", { error: getErrorMessage(error), }); + await this.pruneWorkspaceOverrides(prune.prefix, prune.workspaceIds); + return prune.workspaceIds; } return this.pruneWorkspaceOverrides(prune.prefix, liveWorkspaceIds); } @@ -3110,7 +3131,12 @@ export class AgentPluginInstallService { let progressed = false; for (const prune of pending) { const failed = await this.retryPrune(prune); - if (failed.length !== prune.workspaceIds.length) { + // Set comparison, not length: retryPrune re-enumerates live + // workspaces, so `failed` can contain IDs the record never held + // (delta workspaces) — those must be folded in durably too. + const recorded = new Set(prune.workspaceIds); + const changed = failed.length !== recorded.size || failed.some((id) => !recorded.has(id)); + if (changed) { progressed = true; rawPending = this.updateRawPendingPrunes(rawPending, prune.prefix, failed); } diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 9ed695de6a0..929767c30b4 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -150,15 +150,25 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS) === true, }), }); + const overridesServiceForInvalidation = opts.workspaceMcpOverridesService; const mcpServerManager = new MCPServerManager( mcpConfigService, { // A plugin update/uninstall in a sibling process (desktop app alongside // `xum server`) bumps the installer's mutation epoch; managers retire - // cached plugin instances before serving them again. + // cached plugin instances before serving them again. The sibling's + // uninstall also pruned plugin keys from workspace override files, so + // the sweep refreshes cached override snapshots from disk. pluginInvalidation: { keyPrefix: PLUGIN_SERVER_KEY_PREFIX, readToken: () => readMutationEpochToken(path.join(mcpConfig.rootDir, STAGING_DIR_NAME)), + ...(overridesServiceForInvalidation !== undefined + ? { + readWorkspaceOverrides: async (workspaceId: string) => + (await overridesServiceForInvalidation.getOverridesForWorkspace(workspaceId)) + .overrides, + } + : {}), }, ...opts.mcpServerManagerOptions, }, diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 6bfca3ac9f7..d99f732ef16 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -304,6 +304,121 @@ describe("MCPServerManager", () => { expect(restarted).toHaveBeenCalledTimes(0); }); + test("serves loop until a startup is bracketed by an unchanged mutation token", async () => { + // A single post-publication rebuild is not enough: a second sibling + // mutation starting after the rebuild's preflight would let the rebuild + // publish an instance from ITS replaced tree and serve it indefinitely. + // The serve must repeat until one startup sees the same token on both + // sides. + manager.dispose(); + let token = "epoch-1"; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { keyPrefix: "plugin:", readToken: () => Promise.resolve(token) }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-token-loop"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + // Seed the token on a different workspace (first serve only records it). + access.startServers = () => Promise.resolve(startResult([])); + await manager.getToolsForWorkspace(workspaceRequest("ws-token-seed")); + + // Two consecutive startups each race a fresh sibling mutation; the third + // runs clean. + const closes = [ + mock(() => Promise.resolve(undefined)), + mock(() => Promise.resolve(undefined)), + mock(() => Promise.resolve(undefined)), + ]; + let starts = 0; + access.startServers = () => { + starts += 1; + if (starts <= 2) { + token = `epoch-${starts + 1}`; // Sibling mutation mid-startup. + } + return Promise.resolve( + startResult([[pluginKey, { tools: { echo: testTool() }, close: closes[starts - 1] }]]) + ); + }; + const result = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + + // Both raced instances were retired; only the bracketed third serve's + // instance survives. + expect(starts).toBe(3); + expect(closes[0]).toHaveBeenCalledTimes(1); + expect(closes[1]).toHaveBeenCalledTimes(1); + expect(closes[2]).toHaveBeenCalledTimes(0); + expect(Object.keys(result.tools)).toHaveLength(1); + }); + + test("cross-process sweep refreshes cached override snapshots from disk", async () => { + // A sibling's uninstall prunes plugin keys from workspace override FILES. + // Cached copies — the per-call overlay cache AND recorded request options + // (which getPrompt()'s refresh reuses) — must converge to disk, or a + // pre-prune enable would restart a same-name reinstall's server without + // new consent. + manager.dispose(); + let token = "epoch-1"; + let diskOverrides: Record = { enabledServers: ["plugin:abc123:echo"] }; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { + keyPrefix: "plugin:", + readToken: () => Promise.resolve(token), + readWorkspaceOverrides: () => Promise.resolve(diskOverrides), + }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-disk-refresh"; + const pluginKey = "plugin:abc123:echo"; + // Project-level disabled: only the workspace override enables the server. + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js", true) }) + ); + const close = mock(() => Promise.resolve(undefined)); + // Start only what enablement actually requested: the pruned second serve + // must derive an EMPTY start set, not merely discard a started instance. + access.startServers = (...args: unknown[]) => { + const servers = args[0] as Record; + return Promise.resolve( + pluginKey in servers + ? startResult([[pluginKey, { tools: { echo: testTool() }, close }]]) + : startResult([]) + ); + }; + + // First serve: the caller's snapshot enables the plugin server. + const staleCallerOptions = workspaceRequest(workspaceId, { + overrides: { enabledServers: [pluginKey] }, + }); + const first = await manager.getToolsForWorkspace(staleCallerOptions); + expect(Object.keys(first.tools)).toHaveLength(1); + + // Sibling uninstall: the override file is pruned on disk, then the epoch + // bumps. + diskOverrides = {}; + token = "epoch-2"; + + // Same STALE caller snapshot: the preflight sweep must reload disk state + // before the overlay captures this call's overrides, so the pruned + // (empty) overrides win and no replacement server starts. + const second = await manager.getToolsForWorkspace(staleCallerOptions); + expect(close).toHaveBeenCalledTimes(1); + expect(Object.keys(second.tools)).toHaveLength(0); + + // Both caches converged to disk: getPrompt()'s refresh (recorded + // options) can no longer resurrect the pre-prune enable. + const internals = access as unknown as { + latestWorkspaceOverrides: Map; + lastWorkspaceRequestOptions: Map; + }; + expect(internals.latestWorkspaceOverrides.get(workspaceId)).toEqual({}); + expect(internals.lastWorkspaceRequestOptions.get(workspaceId)?.overrides).toEqual({}); + }); + test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup, then retries them", async () => { const workspaceId = "ws-swap-race"; const pluginKey = "plugin:abc123:echo"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index bd06592f907..906ab9019e6 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1051,6 +1051,15 @@ export interface MCPServerManagerOptions { pluginInvalidation?: { keyPrefix: string; readToken: () => Promise; + /** + * Disk-authoritative workspace override read. A sibling's uninstall also + * pruned plugin keys from workspace override FILES; the sweep uses this + * to refresh every cached override snapshot (latestWorkspaceOverrides + * and lastWorkspaceRequestOptions) so no pre-prune enable survives in + * memory. When absent or failing, the affected cached state is dropped + * instead. + */ + readWorkspaceOverrides?: (workspaceId: string) => Promise; }; } @@ -1170,14 +1179,14 @@ export class MCPServerManager { } log.info("[MCP] Cross-process plugin mutation detected; recycling plugin servers"); // A sibling's uninstall also PRUNED plugin keys from workspace override - // files on disk. This manager's latestWorkspaceOverrides cache overlays - // every serve's caller snapshot, so a stale cached enable would - // permanently shadow the pruned disk state — and a same-name reinstall - // would start its server without new consent. Disk is authoritative - // after a cross-process mutation (every override write persists before - // publishing), so drop the cache and let callers' fresh disk snapshots - // through. - this.latestWorkspaceOverrides.clear(); + // files on disk. Disk is authoritative after a cross-process mutation + // (every override write persists before publishing), so refresh every + // cached override snapshot from it — BOTH caches: a stale + // latestWorkspaceOverrides entry would shadow the pruned disk state on + // the next serve, and a stale lastWorkspaceRequestOptions entry would + // feed a pre-prune enable into getPrompt()'s refresh, starting a + // same-name reinstall's replacement server without new consent. + await this.refreshCachedOverridesFromDisk(); await this.stopServersWithKeyPrefix(invalidation.keyPrefix); this.lastPluginInvalidationToken = token; }; @@ -1186,6 +1195,58 @@ export class MCPServerManager { return next; } + /** + * Reload cached workspace override snapshots from disk after a sibling + * process's plugin mutation. Workspaces whose disk state cannot be read + * (no reader wired, read failure) get their cached state DROPPED instead + * so nothing stale survives — callers then supply fresh snapshots on their + * next serve, and prompt refreshes for such workspaces stay disabled until + * then. Off-host workspaces (SSH/devcontainer) are skipped: plugin servers + * are never offered there, so a stale plugin enable is inert, and reading + * their override files would exec remotely inside the serialized sweep. + */ + private async refreshCachedOverridesFromDisk(): Promise { + const readOverrides = this.pluginInvalidation?.readWorkspaceOverrides; + for (const [workspaceId, recorded] of [...this.lastWorkspaceRequestOptions]) { + const execsOffHost = + recorded.runtime instanceof RemoteRuntime || + recorded.runtime instanceof DevcontainerRuntime; + if (execsOffHost) { + continue; + } + let fresh: WorkspaceMCPOverrides | undefined; + let readFailed = readOverrides === undefined; + if (readOverrides !== undefined) { + try { + fresh = await readOverrides(workspaceId); + } catch (error) { + readFailed = true; + log.warn("[MCP] Failed to reload workspace overrides after sibling plugin mutation", { + workspaceId, + error: getErrorMessage(error), + }); + } + } + if (readFailed) { + this.latestWorkspaceOverrides.delete(workspaceId); + this.lastWorkspaceRequestOptions.delete(workspaceId); + } else { + this.latestWorkspaceOverrides.set(workspaceId, fresh); + this.lastWorkspaceRequestOptions.set(workspaceId, { ...recorded, overrides: fresh }); + } + // In-flight prompt refresh loops must re-run against the new state. + this.bumpWorkspaceOptionsMutationCount(workspaceId); + } + // Entries without recorded options carry no runtime/identity to refresh + // from; drop them so callers' fresh disk snapshots pass through. + for (const workspaceId of [...this.latestWorkspaceOverrides.keys()]) { + if (!this.lastWorkspaceRequestOptions.has(workspaceId)) { + this.latestWorkspaceOverrides.delete(workspaceId); + this.bumpWorkspaceOptionsMutationCount(workspaceId); + } + } + } + /** * Stop the idle cleanup interval. Call when shutting down. */ @@ -1559,24 +1620,35 @@ export class MCPServerManager { async getToolsForWorkspace( options: MCPWorkspaceRequestOptions ): Promise { - const result = await this.ensureWorkspaceServers(options, true); // Post-publication token recheck: a sibling mutation that BEGAN after // the preflight read (retireCrossProcessPluginInstances inside // ensureWorkspaceServers) is invisible to the in-process invalidation // epoch, and the installer's discovery bracket cannot flag a mutation // that starts after its scan completed — this serve could otherwise // return instances from the replaced tree and use them indefinitely. - // The instances are published now, so the sweep can retire them; rebuild - // once from the new tree. A mutation racing the rebuild is caught by the - // next serve's preflight (its instances were closed by that sweep). - if (this.pluginInvalidation !== undefined && this.pluginInvalidationTokenSeen) { + // The instances are published now, so the sweep can retire them; loop + // until a serve is BRACKETED by an unchanged token — a single rebuild + // could itself race a second mutation that starts after its preflight + // and publish a stale instance. Each extra iteration requires a real + // sibling mutation to have advanced the on-disk epoch mid-serve, so the + // loop terminates in practice; the cap turns a pathological mutation + // storm into an explicit error instead of serving stale instances. + for (let attempt = 0; ; attempt++) { + const result = await this.ensureWorkspaceServers(options, true); + if (this.pluginInvalidation === undefined || !this.pluginInvalidationTokenSeen) { + return result; + } const token = await this.pluginInvalidation.readToken(); - if (token !== this.lastPluginInvalidationToken) { - await this.retireCrossProcessPluginInstances(); - return this.ensureWorkspaceServers(options, true); + if (token === this.lastPluginInvalidationToken) { + return result; + } + if (attempt >= 5) { + throw new Error( + "MCP startup kept racing concurrent plugin mutations; retry once plugin installs/updates settle" + ); } + await this.retireCrossProcessPluginInstances(); } - return result; } /** @@ -1587,6 +1659,14 @@ export class MCPServerManager { requestOptions: MCPWorkspaceRequestOptions, refreshToolCatalogs: boolean ): Promise { + // A sibling process's plugin mutation must retire cached plugin + // instances BEFORE this serve returns them (and before the epoch + // snapshot below, so the recycle is visible to this startup). It must + // also run BEFORE the overlay below captures this call's overrides: the + // sweep refreshes the cached override snapshots from disk, and options + // captured earlier would pass pre-mutation enables into startup. + await this.retireCrossProcessPluginInstances(); + // Cold workspaces have no recorded state for applyWorkspaceOverrides to repair. // Overlay the newest overrides over a caller snapshot that may predate the mutation. let options = this.latestWorkspaceOverrides.has(requestOptions.workspaceId) @@ -1622,11 +1702,6 @@ export class MCPServerManager { // reads so enablement repair can detect them. const configGenerationUsed = this.configService.configGeneration; - // A sibling process's plugin mutation must retire cached plugin - // instances BEFORE this serve returns them (and before the epoch - // snapshot below, so the recycle is visible to this startup). - await this.retireCrossProcessPluginInstances(); - // Snapshot BEFORE reading config: a plugin swap that lands after this // point may invalidate instances this call starts (see // closeInvalidatedInstances). From 6293dfed9378df36b5b9820b13e41876ca5ee86e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 08:31:59 +0000 Subject: [PATCH 45/63] fix: realm-safe ENOENT detection in mutation epoch reads + unreadable-token equivalence Under babel-jest's vm sandbox (Integration CI), fs errors fail instanceof Error, so readMutationEpochToken returned a fresh unreadable- token on every read of a missing epoch file. The round-61 sweep changes turned that into a livelock: prompt refresh loops never stabilized (mutation-count bumps per sweep) and the serve bracket loop exhausted its attempts, leaving MCP tools unavailable to the model. Use hasErrorCode for ENOENT and treat consecutive unreadable tokens as equivalent (one sweep on transition). --- src/node/services/agentPlugins/journals.ts | 48 ++++++++++++++++++++-- src/node/services/mcpServerManager.test.ts | 44 ++++++++++++++++++++ src/node/services/mcpServerManager.ts | 8 +++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/node/services/agentPlugins/journals.ts b/src/node/services/agentPlugins/journals.ts index fff48203bd8..0f7900cb213 100644 --- a/src/node/services/agentPlugins/journals.ts +++ b/src/node/services/agentPlugins/journals.ts @@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto"; import * as fsPromises from "node:fs/promises"; import * as path from "node:path"; +import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; /** Staging dir name under the mux home dir — NOT under ~/.mux/plugins, which discovery scans. */ export const STAGING_DIR_NAME = "plugin-staging"; @@ -31,6 +32,40 @@ export const STAGING_DIR_NAME = "plugin-staging"; */ export const MUTATION_EPOCH_FILE = "mutation-epoch"; +/** + * Prefix of the synthetic token readMutationEpochToken returns when the + * epoch file exists but cannot be read (non-ENOENT failure). Each read + * yields a FRESH token so bracket-style consumers (discovery gate) fail + * closed; consumers that would otherwise loop on every serve (the MCP + * manager's cross-process sweep) treat two unreadable tokens as equivalent + * instead — see areMutationEpochTokensEquivalent. + */ +export const MUTATION_EPOCH_UNREADABLE_PREFIX = "unreadable-"; + +/** + * Whether two mutation epoch reads represent the same observable state. + * Distinct unreadable tokens compare EQUIVALENT: a persistently unreadable + * staging root produces a fresh token per read, and treating that as a + * constant stream of mutations would livelock sweep-per-serve consumers. + * The transition INTO the unreadable state still compares different, so one + * sweep runs; mutations during a persistent outage are caught when the root + * becomes readable again (the real epoch differs from the unreadable token). + */ +export function areMutationEpochTokensEquivalent( + a: string | undefined, + b: string | undefined +): boolean { + if (a === b) { + return true; + } + return ( + a !== undefined && + b !== undefined && + a.startsWith(MUTATION_EPOCH_UNREADABLE_PREFIX) && + b.startsWith(MUTATION_EPOCH_UNREADABLE_PREFIX) + ); +} + export const PROMOTION_JOURNAL_PREFIX = "promotion-"; export const UPDATE_JOURNAL_PREFIX = "update-"; export const UNINSTALL_JOURNAL_PREFIX = "uninstall-"; @@ -58,7 +93,10 @@ export async function containerHasUnreconciledJournals(containerPath: string): P (entry) => isJournalName(entry) && entry.endsWith(".json") ); } catch (error) { - return !(error instanceof Error && "code" in error && error.code === "ENOENT"); + // hasErrorCode, not `instanceof Error`: under babel-jest's vm sandbox, + // fs errors come from another realm and fail instanceof, which would + // misreport every missing staging root as "has journals". + return !hasErrorCode(error, "ENOENT"); } } @@ -90,9 +128,13 @@ export async function readMutationEpochToken(stagingRoot: string): Promise { expect(Object.keys(result.tools)).toHaveLength(1); }); + test("an unreadable mutation epoch sweeps once, then serves normally", async () => { + // readMutationEpochToken returns a FRESH synthetic token per read when + // the staging root is unreadable (non-ENOENT). Treating each read as a + // new mutation would sweep on every serve: prompt refresh loops (bumped + // mutation counters) would never stabilize and the serve bracket loop + // would exhaust its attempts. Consecutive unreadable tokens must compare + // equivalent — one sweep on the transition, normal serving after. + manager.dispose(); + let reads = 0; + let unreadable = false; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { + keyPrefix: "plugin:", + readToken: () => Promise.resolve(unreadable ? `unreadable-${++reads}` : "epoch-1"), + }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-unreadable-epoch"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + const close = mock(() => Promise.resolve(undefined)); + access.startServers = () => + Promise.resolve(startResult([[pluginKey, { tools: { echo: testTool() }, close }]])); + + const first = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(Object.keys(first.tools)).toHaveLength(1); + + // The staging root becomes unreadable: the token transition sweeps once. + unreadable = true; + const second = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(close).toHaveBeenCalledTimes(1); + expect(Object.keys(second.tools)).toHaveLength(1); + + // Still unreadable: fresh synthetic tokens are equivalent — no further + // sweeps, the cached instance is served untouched. + const closesBefore = close.mock.calls.length; + const third = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(close.mock.calls.length).toBe(closesBefore); + expect(Object.keys(third.tools)).toHaveLength(1); + }); + test("cross-process sweep refreshes cached override snapshots from disk", async () => { // A sibling's uninstall prunes plugin keys from workspace override FILES. // Cached copies — the per-call overlay cache AND recorded request options diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 906ab9019e6..9f2fb2be815 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -28,6 +28,7 @@ import type { Runtime } from "@/node/runtime/Runtime"; import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; import type { AgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; +import { areMutationEpochTokensEquivalent } from "@/node/services/agentPlugins/journals"; import type { PolicyService } from "@/node/services/policyService"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import { @@ -1174,7 +1175,10 @@ export class MCPServerManager { this.lastPluginInvalidationToken = token; return; } - if (token === this.lastPluginInvalidationToken) { + // Equivalence, not equality: a persistently unreadable staging root + // yields a fresh synthetic token per read, and sweeping on every serve + // would livelock prompt refresh loops and the serve bracket loop. + if (areMutationEpochTokensEquivalent(token, this.lastPluginInvalidationToken)) { return; } log.info("[MCP] Cross-process plugin mutation detected; recycling plugin servers"); @@ -1639,7 +1643,7 @@ export class MCPServerManager { return result; } const token = await this.pluginInvalidation.readToken(); - if (token === this.lastPluginInvalidationToken) { + if (areMutationEpochTokensEquivalent(token, this.lastPluginInvalidationToken)) { return result; } if (attempt >= 5) { From e9fb0229cda36bbfbed38d88c2e7b61ec4891c61 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 08:58:14 +0000 Subject: [PATCH 46/63] fix: address Codex review round 62 (git config isolation for staging, common plugin-epoch bracket for prompt paths, stable unreadable epoch sentinel, bounded startup reconciliation, lock timestamp validation) --- .../services/agentPlugins/discovery.test.ts | 17 +- src/node/services/agentPlugins/discovery.ts | 14 +- .../agentPlugins/installService.test.ts | 71 +++++ .../services/agentPlugins/installService.ts | 86 ++++-- src/node/services/agentPlugins/journals.ts | 60 ++-- src/node/services/mcpServerManager.test.ts | 172 +++++++++-- src/node/services/mcpServerManager.ts | 287 +++++++++++------- src/node/utils/main/crossProcessLock.test.ts | 44 ++- src/node/utils/main/crossProcessLock.ts | 15 +- 9 files changed, 560 insertions(+), 206 deletions(-) diff --git a/src/node/services/agentPlugins/discovery.test.ts b/src/node/services/agentPlugins/discovery.test.ts index cfb6de3bc99..5eb49b50138 100644 --- a/src/node/services/agentPlugins/discovery.test.ts +++ b/src/node/services/agentPlugins/discovery.test.ts @@ -10,7 +10,7 @@ import { journalDerivedDiscoveryGate, setAgentPluginDiscoveryGate, } from "./discovery"; -import { bumpContainerMutationEpoch, STAGING_DIR_NAME } from "./journals"; +import { bumpContainerMutationEpoch, MUTATION_EPOCH_FILE, STAGING_DIR_NAME } from "./journals"; import { AGENT_PLUGIN_SCHEMA_ID_1_0_0 } from "./manifest"; async function writePlugin( @@ -407,6 +407,21 @@ describe("journalDerivedDiscoveryGate", () => { expect(session.suppressed).toEqual([container]); }); + test("suppresses a container while its mutation epoch is unreadable", async () => { + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const stagingRoot = path.join(tmp.path, STAGING_DIR_NAME); + await fs.mkdir(container, { recursive: true }); + await fs.mkdir(stagingRoot, { recursive: true }); + // A directory at the epoch path is a deterministic non-ENOENT read + // failure without relying on permission behavior of the test user. + await fs.mkdir(path.join(stagingRoot, MUTATION_EPOCH_FILE)); + + const session = await journalDerivedDiscoveryGate([container]); + expect(session.suppressed).toEqual([container]); + expect(await session.confirm()).toEqual([container]); + }); + test("confirm flags a mutation whose whole journal lifetime fit inside the scan window", async () => { using tmp = new DisposableTempDir("agent-plugins"); const container = path.join(tmp.path, "plugins"); diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 1b8d95ed86b..a646cec261c 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -9,7 +9,7 @@ import { import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; -import { readContainerMutationState } from "./journals"; +import { isMutationEpochUnreadable, readContainerMutationState } from "./journals"; import { isValidAgentPluginName, validatePluginManifest, @@ -420,12 +420,20 @@ export async function journalDerivedDiscoveryGate( ) ); return { - suppressed: containerPaths.filter((containerPath) => pre.get(containerPath)?.hasJournals), + suppressed: containerPaths.filter((containerPath) => { + const state = pre.get(containerPath); + return state?.hasJournals === true || isMutationEpochUnreadable(state?.epoch); + }), confirm: async () => { const flagged = await Promise.all( containerPaths.map(async (containerPath) => { const post = await readContainerMutationState(containerPath); - const changed = post.hasJournals || post.epoch !== pre.get(containerPath)?.epoch; + const preEpoch = pre.get(containerPath)?.epoch; + const changed = + post.hasJournals || + isMutationEpochUnreadable(preEpoch) || + isMutationEpochUnreadable(post.epoch) || + post.epoch !== preEpoch; return changed ? [containerPath] : []; }) ); diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index a4cb4d840a4..3f3ae4aae6c 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -7,6 +7,7 @@ import * as path from "node:path"; import { Config } from "@/node/config"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import { shellQuote } from "@/common/utils/shell"; import { execFileAsync } from "@/node/utils/disposableExec"; import { discoverAgentPlugins, @@ -198,6 +199,49 @@ describe("AgentPluginInstallService", () => { expect(preview.slashCommands).toEqual([{ name: "standup", description: "Daily standup" }]); }); + test("preview ignores configured checkout filters from global Git config", async () => { + // An untrusted repository controls .gitattributes. If staging inherits the + // user's global filter..smudge/process configuration, clone/checkout + // executes that command BEFORE the consent preview appears. + const marker = path.join(muxRoot, "checkout-filter-executed"); + const filterScript = path.join(muxRoot, "checkout-filter.js"); + await fsPromises.writeFile( + filterScript, + [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.argv[2], "executed");', + "process.stdin.pipe(process.stdout);", + ].join("\n") + ); + await fsPromises.writeFile(path.join(remoteDir, ".gitattributes"), "payload filter=pwn\n"); + await fsPromises.writeFile(path.join(remoteDir, "payload"), "attacker-controlled\n"); + await commitAll(remoteDir, "checkout filter fixture"); + + const globalConfig = path.join(muxRoot, "attacker-global-gitconfig"); + const filterCommand = [ + shellQuote(process.execPath), + shellQuote(filterScript), + shellQuote(marker), + ].join(" "); + await fsPromises.writeFile( + globalConfig, + `[filter "pwn"]\n\tsmudge = ${filterCommand}\n\trequired = true\n` + ); + const previousGlobal = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = globalConfig; + try { + const preview = await service.preview({ input: remoteDir }); + expect(preview.manifest.name).toBe("demo-plugin"); + expect(await pathExists(marker)).toBe(false); + } finally { + if (previousGlobal === undefined) { + delete process.env.GIT_CONFIG_GLOBAL; + } else { + process.env.GIT_CONFIG_GLOBAL = previousGlobal; + } + } + }); + test("preview stages+validates without writing; install promotes and records the registry", async () => { const head = (await git(remoteDir, "rev-parse", "HEAD")).trim(); @@ -946,6 +990,33 @@ describe("AgentPluginInstallService", () => { expect(await pathExists(journalPath)).toBe(false); }); + test("journal reconciliation timeout fails closed without hanging callers", async () => { + const boundedService = new AgentPluginInstallService(config, { + isEnabled: () => true, + reconciliationTimeoutMs: 10, + }); + const internals = boundedService as unknown as { + reconciliationState: Promise; + reconcileJournals: () => Promise; + attemptReconcileJournals: (context: string) => Promise; + }; + // Let the constructor's real empty-root pass settle, then simulate stalled + // storage for a later recovery pass through the same startup code path. + await internals.reconciliationState; + const neverSettles = new Promise(() => undefined); + const reconcileSpy = spyOn(internals, "reconcileJournals").mockImplementation( + () => neverSettles + ); + try { + const startedAt = Date.now(); + const healthy = await internals.attemptReconcileJournals("timeout regression"); + expect(healthy).toBe(false); + expect(Date.now() - startedAt).toBeLessThan(500); + } finally { + reconcileSpy.mockRestore(); + } + }); + test("promotion recovery leaves a user-replaced tree at the same path alone", async () => { // The user deleted the orphan while the app was stopped and placed their // OWN unmanaged plugin at the same path — a supported use of the global diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index c13e1de4734..ad7532b6a5f 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -38,6 +38,7 @@ import { log } from "@/node/services/log"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import { shellQuote } from "@/common/utils/shell"; import { execFileAsync } from "@/node/utils/disposableExec"; @@ -152,6 +153,9 @@ const MUTATION_LOCK_ACQUIRE_TIMEOUT_MS = 10 * 60 * 1000; /** Pid-reuse guard: no plugin mutation legitimately runs this long. */ const MUTATION_LOCK_STALE_MS = 30 * 60 * 1000; +/** Bound discovery/settings waits on startup crash-recovery I/O. */ +const JOURNAL_RECONCILIATION_TIMEOUT_MS = 30_000; + const LS_REMOTE_TIMEOUT_MS = 30_000; const CLONE_TIMEOUT_MS = 120_000; @@ -200,9 +204,20 @@ function gitEnv(): Record { // config sets protocol.ext.allow=always), and disabling hooks does not // restrict helpers. GIT_ALLOW_PROTOCOL is an env-level whitelist that // overrides protocol.*.allow configuration for every staging invocation. + // + // SECURITY: ignore system/global Git configuration during ALL staging Git + // operations. An attacker-controlled repository can assign a user-defined + // filter through .gitattributes; if the user's global config defines that + // filter's smudge/process command, Git executes it during clone/checkout — + // before consent. The staging flow deliberately accepts only the explicit + // URL/ref plus the numbered safe config below; authentication must come + // from transport-level mechanisms (SSH agent, URL credentials rejected + // separately), never executable credential/filter/helper configuration. const env: Record = { GIT_TERMINAL_PROMPT: "0", GIT_ALLOW_PROTOCOL: "file:git:http:https:ssh", + GIT_CONFIG_GLOBAL: os.devNull, + GIT_CONFIG_SYSTEM: os.devNull, ...GIT_NO_HOOKS_ENV, }; if (process.env.GIT_SSH_COMMAND === undefined) { @@ -415,17 +430,18 @@ export class AgentPluginInstallService { */ private readonly activeStagingPaths = new Set(); + /** The single underlying recovery pass; timeout callers never start a competing pass. */ + private reconciliationWork: Promise | undefined; /** - * Latest journal-reconciliation attempt, resolving to whether it SUCCEEDED. - * Kicked off at construction because a session can serve agent requests - * (whose global plugin discovery, MCP config, and hook loading scan the - * container) without ever opening the Plugins section — an orphaned + * Latest bounded journal-reconciliation attempt, resolving to whether it + * SUCCEEDED. Kicked off at construction because a session can serve agent + * requests (whose global plugin discovery, MCP config, and hook loading scan + * the container) without ever opening the Plugins section — an orphaned * promotion would load as an unmanaged plugin, hooks included, before * list()'s reconciliation ever ran. The discovery gate consumes the status: - * `false` (unreadable registry, failed restore/quarantine) suppresses the - * managed container from scans until a later attempt succeeds — merely - * awaiting a failed pass would release discovery over the unreconciled - * tree. Never rejects (startup must not crash the app). + * `false` (timeout, unreadable registry, failed restore/quarantine) + * suppresses the managed container from scans until a later attempt + * succeeds. Never rejects (startup must not crash the app). */ private reconciliationState: Promise; @@ -439,6 +455,8 @@ export class AgentPluginInstallService { workspaceMcpOverridesService?: WorkspaceMcpOverridesService; /** Test override for the staged-clone checkout quota. */ stagingQuota?: { maxBytes: number; maxFiles: number }; + /** Test override for the recovery wait bound. */ + reconciliationTimeoutMs?: number; } ) { assert(path.isAbsolute(config.rootDir), "AgentPluginInstallService: rootDir must be absolute"); @@ -492,21 +510,45 @@ export class AgentPluginInstallService { * left unconsumed (failed restore/quarantine, unidentified target tree) — * both mean the managed container may hold unreconciled state. */ - private attemptReconcileJournals(context: string): Promise { - return this.reconcileJournals().then( - (allConsumed) => { - if (!allConsumed) { - log.warn(`Plugin journal reconciliation left unresolved journals (${context})`); + private async attemptReconcileJournals(context: string): Promise { + let work = this.reconciliationWork; + if (work === undefined) { + const started = this.reconcileJournals().then( + (allConsumed) => { + if (!allConsumed) { + log.warn(`Plugin journal reconciliation left unresolved journals (${context})`); + } + return allConsumed; + }, + (error: unknown) => { + log.warn(`Plugin journal reconciliation failed (${context})`, { + error: getErrorMessage(error), + }); + return false; } - return allConsumed; - }, - (error: unknown) => { - log.warn(`Plugin journal reconciliation failed (${context})`, { - error: getErrorMessage(error), - }); - return false; - } - ); + ); + work = started.then((result) => { + // Clear only this pass: a later attempt may already have installed a + // successor promise by the time an old, delayed pass settles. + if (this.reconciliationWork === work) { + this.reconciliationWork = undefined; + } + return result; + }); + this.reconciliationWork = work; + } + + const timeoutMs = this.deps.reconciliationTimeoutMs ?? JOURNAL_RECONCILIATION_TIMEOUT_MS; + const settled = await raceWithAbortAndTimeout(work, { timeoutMs }); + if (settled.kind === "ok") { + return settled.value; + } + // The underlying pass remains the sole reconciliationWork. Discovery and + // settings callers stop waiting and fail closed (managed container + // suppressed); a later retry races the SAME pass instead of starting a + // competing filesystem mutation while stalled storage may still recover. + log.warn(`Plugin journal reconciliation timed out (${context})`, { timeoutMs }); + return false; } /** diff --git a/src/node/services/agentPlugins/journals.ts b/src/node/services/agentPlugins/journals.ts index 0f7900cb213..948f1fa48f2 100644 --- a/src/node/services/agentPlugins/journals.ts +++ b/src/node/services/agentPlugins/journals.ts @@ -33,37 +33,15 @@ export const STAGING_DIR_NAME = "plugin-staging"; export const MUTATION_EPOCH_FILE = "mutation-epoch"; /** - * Prefix of the synthetic token readMutationEpochToken returns when the - * epoch file exists but cannot be read (non-ENOENT failure). Each read - * yields a FRESH token so bracket-style consumers (discovery gate) fail - * closed; consumers that would otherwise loop on every serve (the MCP - * manager's cross-process sweep) treat two unreadable tokens as equivalent - * instead — see areMutationEpochTokensEquivalent. + * Stable sentinel returned when the mutation epoch exists but cannot be read + * (non-ENOENT failure). Stable identity prevents sweep-per-serve consumers + * from manufacturing perpetual mutation changes; consumers fail closed for + * plugin content explicitly via isMutationEpochUnreadable. */ -export const MUTATION_EPOCH_UNREADABLE_PREFIX = "unreadable-"; +export const MUTATION_EPOCH_UNREADABLE_TOKEN = "xum-plugin-epoch-unreadable"; -/** - * Whether two mutation epoch reads represent the same observable state. - * Distinct unreadable tokens compare EQUIVALENT: a persistently unreadable - * staging root produces a fresh token per read, and treating that as a - * constant stream of mutations would livelock sweep-per-serve consumers. - * The transition INTO the unreadable state still compares different, so one - * sweep runs; mutations during a persistent outage are caught when the root - * becomes readable again (the real epoch differs from the unreadable token). - */ -export function areMutationEpochTokensEquivalent( - a: string | undefined, - b: string | undefined -): boolean { - if (a === b) { - return true; - } - return ( - a !== undefined && - b !== undefined && - a.startsWith(MUTATION_EPOCH_UNREADABLE_PREFIX) && - b.startsWith(MUTATION_EPOCH_UNREADABLE_PREFIX) - ); +export function isMutationEpochUnreadable(token: string | undefined): boolean { + return token === MUTATION_EPOCH_UNREADABLE_TOKEN; } export const PROMOTION_JOURNAL_PREFIX = "promotion-"; @@ -110,19 +88,21 @@ export interface ContainerMutationState { hasJournals: boolean; /** * Epoch token; `undefined` when the epoch file has never been written (a - * stable state). An unreadable epoch file yields a UNIQUE token so it can - * never compare equal across two reads (fail toward suppression). + * stable state). An unreadable epoch file yields the stable + * MUTATION_EPOCH_UNREADABLE_TOKEN; discovery suppresses that state + * explicitly rather than relying on manufactured token changes. */ epoch: string | undefined; } /** * Read the current mutation epoch token; `undefined` when never written. An - * unreadable file yields a UNIQUE token so it can never compare equal across - * two reads (fail toward invalidation/suppression). Also consumed by - * MCPServerManager as its cross-process plugin invalidation signal: a sibling - * process's install/update/uninstall bumps this token, telling every manager - * to retire cached plugin server instances before serving them again. + * unreadable file yields a stable failure sentinel: discovery and MCP serving + * suppress plugin content explicitly while unrelated MCP servers remain + * usable. Also consumed by MCPServerManager as its cross-process plugin + * invalidation signal: a sibling process's install/update/uninstall bumps + * this token, telling every manager to retire cached plugin server instances + * before serving them again. */ export async function readMutationEpochToken(stagingRoot: string): Promise { try { @@ -130,11 +110,9 @@ export async function readMutationEpochToken(stagingRoot: string): Promise { expect(close2).toHaveBeenCalledTimes(0); expect(starts).toBe(2); expect(Object.keys(result.tools)).toHaveLength(1); - // The sweep dropped the cross-process-stale override cache. + // With no disk reader wired, the sweep scrubs plugin keys from the + // cross-process-stale cache while preserving unrelated override state. expect( - (access as unknown as { latestWorkspaceOverrides: Map }) - .latestWorkspaceOverrides.size - ).toBe(0); + ( + access as unknown as { latestWorkspaceOverrides: Map } + ).latestWorkspaceOverrides.get(workspaceId) + ).toEqual({ enabledServers: [] }); }); test("concurrent serves await an in-flight cross-process sweep before returning", async () => { @@ -354,20 +357,113 @@ describe("MCPServerManager", () => { expect(Object.keys(result.tools)).toHaveLength(1); }); - test("an unreadable mutation epoch sweeps once, then serves normally", async () => { - // readMutationEpochToken returns a FRESH synthetic token per read when - // the staging root is unreadable (non-ENOENT). Treating each read as a - // new mutation would sweep on every serve: prompt refresh loops (bumped - // mutation counters) would never stabilize and the serve bracket loop - // would exhaust its attempts. Consecutive unreadable tokens must compare - // equivalent — one sweep on the transition, normal serving after. + test("prompt listing retries when a plugin mutation lands during startup", async () => { manager.dispose(); - let reads = 0; - let unreadable = false; + let token = "epoch-1"; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { keyPrefix: "plugin:", readToken: () => Promise.resolve(token) }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-prompt-list-token-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + access.startServers = () => Promise.resolve(startResult([])); + await manager.getToolsForWorkspace(workspaceRequest("ws-prompt-token-seed")); + + const staleClose = mock(() => Promise.resolve(undefined)); + const freshClose = mock(() => Promise.resolve(undefined)); + let starts = 0; + access.startServers = () => { + starts += 1; + if (starts === 1) { + token = "epoch-2"; + } + return Promise.resolve( + startResult([ + [ + pluginKey, + { + prompts: [{ name: "review", description: starts === 1 ? "stale" : "fresh" }], + close: starts === 1 ? staleClose : freshClose, + }, + ], + ]) + ); + }; + + const prompts = await manager.getPromptsForWorkspace(workspaceRequest(workspaceId)); + expect(starts).toBe(2); + expect(staleClose).toHaveBeenCalledTimes(1); + expect(freshClose).toHaveBeenCalledTimes(0); + const review = prompts.find((prompt) => prompt.promptName === "review"); + expect(review?.description).toBe("fresh"); + }); + + test("prompt invocation retries when a plugin mutation lands during prompts/get", async () => { + manager.dispose(); + let token = "epoch-1"; + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { keyPrefix: "plugin:", readToken: () => Promise.resolve(token) }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-prompt-get-token-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + ); + const staleClose = mock(() => Promise.resolve(undefined)); + const freshClose = mock(() => Promise.resolve(undefined)); + const staleGetPrompt = mock(() => { + token = "epoch-2"; + return Promise.resolve({ + messages: [{ role: "user" as const, content: { type: "text" as const, text: "stale" } }], + }); + }); + const freshGetPrompt = mock(() => + Promise.resolve({ + messages: [{ role: "user" as const, content: { type: "text" as const, text: "fresh" } }], + }) + ); + let starts = 0; + access.startServers = () => { + starts += 1; + return Promise.resolve( + startResult([ + [ + pluginKey, + { + getPrompt: starts === 1 ? staleGetPrompt : freshGetPrompt, + close: starts === 1 ? staleClose : freshClose, + }, + ], + ]) + ); + }; + + await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + const prompt = await manager.getPrompt(workspaceId, pluginKey, "review", {}); + expect(prompt.text).toBe("fresh"); + expect(staleGetPrompt).toHaveBeenCalledTimes(1); + expect(staleClose).toHaveBeenCalledTimes(1); + expect(freshGetPrompt).toHaveBeenCalledTimes(1); + expect(freshClose).toHaveBeenCalledTimes(0); + }); + + test("an unreadable mutation epoch fails closed only for plugin servers", async () => { + // Unreadability is a STABLE state: transition into it sweeps once and + // suppresses plugin configs, while unrelated MCP servers remain usable. + // Repeated serves cannot exhaust the mutation bracket, and transition + // back to a readable epoch enables plugins again. + manager.dispose(); + let token = "epoch-1"; manager = new MCPServerManager(configService as unknown as MCPConfigService, { pluginInvalidation: { keyPrefix: "plugin:", - readToken: () => Promise.resolve(unreadable ? `unreadable-${++reads}` : "epoch-1"), + readToken: () => Promise.resolve(token), }, }); access = manager as unknown as MCPServerManagerTestAccess; @@ -375,27 +471,51 @@ describe("MCPServerManager", () => { const workspaceId = "ws-unreadable-epoch"; const pluginKey = "plugin:abc123:echo"; configService.listServers.mockImplementation(() => - Promise.resolve({ [pluginKey]: stdioConfig("node server.js") }) + Promise.resolve({ + [pluginKey]: { + ...stdioConfig("node plugin.js"), + plugin: { + pluginName: "demo", + serverName: "echo", + sourceScope: "global" as const, + sourceLocation: ".xum/plugins/demo", + }, + }, + regular: stdioConfig("node regular.js"), + }) ); - const close = mock(() => Promise.resolve(undefined)); - access.startServers = () => - Promise.resolve(startResult([[pluginKey, { tools: { echo: testTool() }, close }]])); + const pluginClose = mock(() => Promise.resolve(undefined)); + access.startServers = (...args: unknown[]) => { + const servers = args[0] as Record; + return Promise.resolve( + startResult( + Object.keys(servers).map((name) => [ + name, + { + tools: { echo: testTool() }, + close: name === pluginKey ? pluginClose : mock(() => Promise.resolve(undefined)), + }, + ]) + ) + ); + }; const first = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); - expect(Object.keys(first.tools)).toHaveLength(1); + expect(Object.keys(first.tools)).toHaveLength(2); - // The staging root becomes unreadable: the token transition sweeps once. - unreadable = true; + token = MUTATION_EPOCH_UNREADABLE_TOKEN; const second = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); - expect(close).toHaveBeenCalledTimes(1); + expect(pluginClose).toHaveBeenCalledTimes(1); expect(Object.keys(second.tools)).toHaveLength(1); - // Still unreadable: fresh synthetic tokens are equivalent — no further - // sweeps, the cached instance is served untouched. - const closesBefore = close.mock.calls.length; + // Stable unreadability: no repeated sweep or retry exhaustion. const third = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); - expect(close.mock.calls.length).toBe(closesBefore); + expect(pluginClose).toHaveBeenCalledTimes(1); expect(Object.keys(third.tools)).toHaveLength(1); + + token = "epoch-2"; + const recovered = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(Object.keys(recovered.tools)).toHaveLength(2); }); test("cross-process sweep refreshes cached override snapshots from disk", async () => { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 9f2fb2be815..e18956d1991 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -28,7 +28,7 @@ import type { Runtime } from "@/node/runtime/Runtime"; import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; import type { AgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; -import { areMutationEpochTokensEquivalent } from "@/node/services/agentPlugins/journals"; +import { isMutationEpochUnreadable } from "@/node/services/agentPlugins/journals"; import type { PolicyService } from "@/node/services/policyService"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import { @@ -1175,10 +1175,7 @@ export class MCPServerManager { this.lastPluginInvalidationToken = token; return; } - // Equivalence, not equality: a persistently unreadable staging root - // yields a fresh synthetic token per read, and sweeping on every serve - // would livelock prompt refresh loops and the serve bracket loop. - if (areMutationEpochTokensEquivalent(token, this.lastPluginInvalidationToken)) { + if (token === this.lastPluginInvalidationToken) { return; } log.info("[MCP] Cross-process plugin mutation detected; recycling plugin servers"); @@ -1199,15 +1196,44 @@ export class MCPServerManager { return next; } + /** Remove only Agent Plugin keys when disk-authoritative overrides cannot be read. */ + private scrubPluginOverrideKeys( + overrides: WorkspaceMCPOverrides | undefined + ): WorkspaceMCPOverrides | undefined { + if (overrides === undefined) { + return undefined; + } + const prefix = this.pluginInvalidation?.keyPrefix; + if (prefix === undefined) { + return overrides; + } + return { + ...overrides, + ...(overrides.enabledServers !== undefined + ? { enabledServers: overrides.enabledServers.filter((key) => !key.startsWith(prefix)) } + : {}), + ...(overrides.disabledServers !== undefined + ? { disabledServers: overrides.disabledServers.filter((key) => !key.startsWith(prefix)) } + : {}), + ...(overrides.toolAllowlist !== undefined + ? { + toolAllowlist: Object.fromEntries( + Object.entries(overrides.toolAllowlist).filter(([key]) => !key.startsWith(prefix)) + ), + } + : {}), + }; + } + /** * Reload cached workspace override snapshots from disk after a sibling - * process's plugin mutation. Workspaces whose disk state cannot be read - * (no reader wired, read failure) get their cached state DROPPED instead - * so nothing stale survives — callers then supply fresh snapshots on their - * next serve, and prompt refreshes for such workspaces stay disabled until - * then. Off-host workspaces (SSH/devcontainer) are skipped: plugin servers - * are never offered there, so a stale plugin enable is inert, and reading - * their override files would exec remotely inside the serialized sweep. + * process's plugin mutation. When disk state cannot be read (no reader + * wired, read failure), scrub only plugin keys from both caches instead of + * deleting recorded request options: getPrompt's local fallback must not + * resurrect a stale plugin enable, while unrelated MCP settings remain + * usable. Off-host workspaces (SSH/devcontainer) are skipped: plugin servers + * are never offered there, and reading their override files would exec + * remotely inside the serialized sweep. */ private async refreshCachedOverridesFromDisk(): Promise { const readOverrides = this.pluginInvalidation?.readWorkspaceOverrides; @@ -1231,21 +1257,20 @@ export class MCPServerManager { }); } } - if (readFailed) { - this.latestWorkspaceOverrides.delete(workspaceId); - this.lastWorkspaceRequestOptions.delete(workspaceId); - } else { - this.latestWorkspaceOverrides.set(workspaceId, fresh); - this.lastWorkspaceRequestOptions.set(workspaceId, { ...recorded, overrides: fresh }); - } + const authoritative = readFailed ? this.scrubPluginOverrideKeys(recorded.overrides) : fresh; + this.latestWorkspaceOverrides.set(workspaceId, authoritative); + this.lastWorkspaceRequestOptions.set(workspaceId, { + ...recorded, + overrides: authoritative, + }); // In-flight prompt refresh loops must re-run against the new state. this.bumpWorkspaceOptionsMutationCount(workspaceId); } - // Entries without recorded options carry no runtime/identity to refresh - // from; drop them so callers' fresh disk snapshots pass through. - for (const workspaceId of [...this.latestWorkspaceOverrides.keys()]) { + // Entries without recorded options carry no runtime/identity to reload; + // scrub their plugin keys in place so stale caller snapshots cannot win. + for (const [workspaceId, overrides] of [...this.latestWorkspaceOverrides]) { if (!this.lastWorkspaceRequestOptions.has(workspaceId)) { - this.latestWorkspaceOverrides.delete(workspaceId); + this.latestWorkspaceOverrides.set(workspaceId, this.scrubPluginOverrideKeys(overrides)); this.bumpWorkspaceOptionsMutationCount(workspaceId); } } @@ -1621,29 +1646,24 @@ export class MCPServerManager { return filtered; } - async getToolsForWorkspace( - options: MCPWorkspaceRequestOptions - ): Promise { - // Post-publication token recheck: a sibling mutation that BEGAN after - // the preflight read (retireCrossProcessPluginInstances inside - // ensureWorkspaceServers) is invisible to the in-process invalidation - // epoch, and the installer's discovery bracket cannot flag a mutation - // that starts after its scan completed — this serve could otherwise - // return instances from the replaced tree and use them indefinitely. - // The instances are published now, so the sweep can retire them; loop - // until a serve is BRACKETED by an unchanged token — a single rebuild - // could itself race a second mutation that starts after its preflight - // and publish a stale instance. Each extra iteration requires a real - // sibling mutation to have advanced the on-disk epoch mid-serve, so the - // loop terminates in practice; the cap turns a pathological mutation - // storm into an explicit error instead of serving stale instances. + /** + * Run a server operation only when the plugin mutation epoch is stable + * across its complete publication/query window. The preflight retires any + * instances invalidated by a sibling process before the operation starts; + * the post-read catches a mutation that began after that preflight. Every + * server-starting path (tools, prompt listing, prompt invocation) uses this + * same bracket so none can publish or query a stale plugin instance through + * a direct ensureWorkspaceServers call. + */ + private async runWithStablePluginEpoch(operation: () => Promise): Promise { for (let attempt = 0; ; attempt++) { - const result = await this.ensureWorkspaceServers(options, true); + await this.retireCrossProcessPluginInstances(); + const result = await operation(); if (this.pluginInvalidation === undefined || !this.pluginInvalidationTokenSeen) { return result; } const token = await this.pluginInvalidation.readToken(); - if (areMutationEpochTokensEquivalent(token, this.lastPluginInvalidationToken)) { + if (token === this.lastPluginInvalidationToken) { return result; } if (attempt >= 5) { @@ -1655,6 +1675,12 @@ export class MCPServerManager { } } + async getToolsForWorkspace( + options: MCPWorkspaceRequestOptions + ): Promise { + return this.runWithStablePluginEpoch(() => this.ensureWorkspaceServers(options, true)); + } + /** * Skips tools/list refreshes on cached instances so an unrelated server's * 60-second SDK timeout cannot block prompt paths. @@ -1663,13 +1689,9 @@ export class MCPServerManager { requestOptions: MCPWorkspaceRequestOptions, refreshToolCatalogs: boolean ): Promise { - // A sibling process's plugin mutation must retire cached plugin - // instances BEFORE this serve returns them (and before the epoch - // snapshot below, so the recycle is visible to this startup). It must - // also run BEFORE the overlay below captures this call's overrides: the - // sweep refreshes the cached override snapshots from disk, and options - // captured earlier would pass pre-mutation enables into startup. - await this.retireCrossProcessPluginInstances(); + // runWithStablePluginEpoch performs the sibling-mutation preflight BEFORE + // entering this method, so refreshed disk overrides are visible to the + // overlay below and every caller gets the same post-publication bracket. // Cold workspaces have no recorded state for applyWorkspaceOverrides to repair. // Overlay the newest overrides over a caller snapshot that may predate the mutation. @@ -1721,9 +1743,15 @@ export class MCPServerManager { // container even though it extends LocalBaseRuntime. const fullServerInfo: Record = {}; const execsOffHost = runtime instanceof RemoteRuntime || runtime instanceof DevcontainerRuntime; + const pluginEpochUnreadable = isMutationEpochUnreadable(this.lastPluginInvalidationToken); for (const [name, info] of Object.entries(allServers)) { - if (info.plugin !== undefined && execsOffHost) { - log.debug("[MCP] Skipping Agent Plugin server on off-host runtime", { workspaceId, name }); + if (info.plugin !== undefined && (execsOffHost || pluginEpochUnreadable)) { + log.debug( + execsOffHost + ? "[MCP] Skipping Agent Plugin server on off-host runtime" + : "[MCP] Skipping Agent Plugin server while mutation epoch is unreadable", + { workspaceId, name } + ); continue; } fullServerInfo[name] = info; @@ -2311,12 +2339,26 @@ export class MCPServerManager { currentOptions.projectPath ); const refreshed = await raceWithAbortAndTimeout( - this.ensureWorkspaceServers( - secretsUsed !== undefined - ? { ...currentOptions, projectSecrets: secretsUsed } - : currentOptions, - false - ), + this.runWithStablePluginEpoch(async () => { + await this.ensureWorkspaceServers( + secretsUsed !== undefined + ? { ...currentOptions, projectSecrets: secretsUsed } + : currentOptions, + false + ); + const entry = this.workspaceServers.get(workspaceId); + if (entry === undefined) { + return undefined; + } + // Include the prompt catalog query inside the epoch bracket: a + // sibling swap that lands after startup but before prompts/list + // must retire the stale instance and retry the whole operation. + await this.refreshInstancePrompts( + this.promptEligibleInstances(entry), + callOptions?.signal + ); + return entry; + }), { ...(callOptions?.signal !== undefined ? { signal: callOptions.signal } : {}), } @@ -2324,11 +2366,13 @@ export class MCPServerManager { if (refreshed.kind === "aborted") { throw new Error("MCP prompt discovery was aborted"); } - const entry = this.workspaceServers.get(workspaceId); - if (!entry) return []; - - latestEntry = entry; - await this.refreshInstancePrompts(this.promptEligibleInstances(entry), callOptions?.signal); + if (refreshed.kind === "timeout") { + throw new Error("MCP prompt discovery timed out"); + } + if (refreshed.value === undefined) { + return []; + } + latestEntry = refreshed.value; const secretsNow = await this.resolveSecretsForRefresh( workspaceId, currentOptions.projectPath @@ -2627,24 +2671,13 @@ export class MCPServerManager { args: Record, options?: { signal?: AbortSignal } ): Promise<{ text: string; description?: string }> { - // Refresh cached state because it can outlive configuration changes. Race - // startup with cancellation, but let a losing startup finish into the cache - // so idle cleanup can close it. const lastOptions = this.lastWorkspaceRequestOptions.get(workspaceId); - if (lastOptions) { - const refresh = async (projectSecrets: Record | undefined): Promise => { - // Re-read after the resolver await: a settings mutation recorded while - // secrets resolved must not be clobbered by a pre-await options snapshot. - const currentOptions = this.lastWorkspaceRequestOptions.get(workspaceId) ?? lastOptions; - await this.ensureWorkspaceServers( - projectSecrets !== undefined ? { ...currentOptions, projectSecrets } : currentOptions, - false - ); - }; - // Refresh until both mutation counters and resolved secrets remain stable - // so neither a settings mutation nor a secret rotation completing during - // the refresh leaves this dispatch on pre-mutation state. Later mutations - // race with the in-flight request and cannot be prevented here. + let stableSecrets: Record | undefined; + if (lastOptions !== undefined) { + // First stabilize cached startup state against settings/trust/secret + // mutations. Prompt materialization happens only AFTER this loop, so a + // cold-start config edit repairs and retries instead of surfacing the + // transient stalePrompt marker to the user. for (;;) { const optionsMutationsBefore = this.workspaceOptionsMutationCounts.get(workspaceId) ?? 0; const generationBefore = this.configService.configGeneration; @@ -2652,12 +2685,27 @@ export class MCPServerManager { workspaceId, lastOptions.projectPath ); - const refreshed = await raceWithAbortAndTimeout(refresh(secretsUsed), { - ...(options?.signal !== undefined ? { signal: options.signal } : {}), - }); + const refreshed = await raceWithAbortAndTimeout( + this.runWithStablePluginEpoch(async () => { + // Re-read after the resolver await: a settings mutation recorded + // while secrets resolved must not be clobbered by a pre-await + // options snapshot. + const currentOptions = this.lastWorkspaceRequestOptions.get(workspaceId) ?? lastOptions; + await this.ensureWorkspaceServers( + secretsUsed !== undefined + ? { ...currentOptions, projectSecrets: secretsUsed } + : currentOptions, + false + ); + }), + { ...(options?.signal !== undefined ? { signal: options.signal } : {}) } + ); if (refreshed.kind === "aborted") { throw new Error(`MCP prompt request for '${serverName}/${promptName}' was aborted`); } + if (refreshed.kind === "timeout") { + throw new Error(`MCP prompt request for '${serverName}/${promptName}' timed out`); + } const secretsNow = await this.resolveSecretsForRefresh( workspaceId, lastOptions.projectPath @@ -2667,36 +2715,65 @@ export class MCPServerManager { this.configService.configGeneration === generationBefore && secretRecordsEqual(secretsUsed, secretsNow) ) { + stableSecrets = secretsNow; break; } } } - const entry = this.workspaceServers.get(workspaceId); - if (entry && !entry.enabledServerNames.has(serverName)) { - throw new Error(`MCP server '${serverName}' is disabled`); - } - if (entry?.stalePromptServerNames?.has(serverName)) { - throw new Error( - `MCP server '${serverName}' was reconfigured while this request was being prepared; retry` - ); - } - const instance = entry?.instances.get(serverName); - if (!instance || instance.isClosed) { - throw new Error(`MCP server '${serverName}' is not connected`); + + const invoked = await raceWithAbortAndTimeout( + this.runWithStablePluginEpoch(async () => { + // Re-run startup inside the SAME bracket as prompts/get: a sibling + // mutation detected by the preflight may have retired the instance + // stabilized above, and the operation must rebuild before querying. + if (lastOptions !== undefined) { + const currentOptions = this.lastWorkspaceRequestOptions.get(workspaceId) ?? lastOptions; + await this.ensureWorkspaceServers( + stableSecrets !== undefined + ? { ...currentOptions, projectSecrets: stableSecrets } + : currentOptions, + false + ); + } + const entry = this.workspaceServers.get(workspaceId); + if (entry && !entry.enabledServerNames.has(serverName)) { + throw new Error(`MCP server '${serverName}' is disabled`); + } + if (entry?.stalePromptServerNames?.has(serverName)) { + throw new Error( + `MCP server '${serverName}' was reconfigured while this request was being prepared; retry` + ); + } + const instance = entry?.instances.get(serverName); + if (!instance || instance.isClosed) { + throw new Error(`MCP server '${serverName}' is not connected`); + } + this.markActivity(workspaceId); + // Include prompts/get itself inside the mutation-epoch bracket. A + // sibling update that lands after startup but before materialization + // retires the stale instance and retries this read-only operation. + const result = await instance.getPrompt(promptName, args, options); + const text = flattenMcpPrompt(result); + if (text.trim().length === 0) { + // Providers can reject empty user content, so fail expansion up + // front rather than persisting an empty synthetic user message. + throw new Error(`MCP prompt '${serverName}/${promptName}' returned no text content`); + } + return { + // Cap here because both composer expansion and mcp_prompt_get use this path. + text: truncateUtf8Bytes(text, MCP_PROMPT_MAX_TEXT_BYTES, MCP_PROMPT_TRUNCATION_MARKER), + ...(result.description !== undefined ? { description: result.description } : {}), + }; + }), + { ...(options?.signal !== undefined ? { signal: options.signal } : {}) } + ); + if (invoked.kind === "aborted") { + throw new Error(`MCP prompt request for '${serverName}/${promptName}' was aborted`); } - this.markActivity(workspaceId); - const result = await instance.getPrompt(promptName, args, options); - const text = flattenMcpPrompt(result); - if (text.trim().length === 0) { - // Providers can reject empty user content, so fail expansion up front - // rather than persisting an empty synthetic user message. - throw new Error(`MCP prompt '${serverName}/${promptName}' returned no text content`); + if (invoked.kind === "timeout") { + throw new Error(`MCP prompt request for '${serverName}/${promptName}' timed out`); } - return { - // Cap here because both composer expansion and mcp_prompt_get use this path. - text: truncateUtf8Bytes(text, MCP_PROMPT_MAX_TEXT_BYTES, MCP_PROMPT_TRUNCATION_MARKER), - ...(result.description !== undefined ? { description: result.description } : {}), - }; + return invoked.value; } /** diff --git a/src/node/utils/main/crossProcessLock.test.ts b/src/node/utils/main/crossProcessLock.test.ts index 11de800c8ed..f8a05920ce5 100644 --- a/src/node/utils/main/crossProcessLock.test.ts +++ b/src/node/utils/main/crossProcessLock.test.ts @@ -43,16 +43,34 @@ describe("acquireCrossProcessLock", () => { test("reclaims a holder past the stale ceiling even when its pid is alive", async () => { const lockPath = await tempLockPath(); - // acquiredAt 0 puts the holder beyond any stale ceiling (pid-reuse guard). + // An old positive timestamp puts the holder beyond the stale ceiling (pid-reuse guard). await fsPromises.writeFile( lockPath, - JSON.stringify({ pid: process.pid, token: "stale", acquiredAt: 0 }) + JSON.stringify({ pid: process.pid, token: "stale", acquiredAt: Date.now() - 120_000 }) ); const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); await release(); expect(await pathExists(lockPath)).toBe(false); }); + test("reclaims a lock with an implausibly future timestamp as corrupt", async () => { + const lockPath = await tempLockPath(); + await fsPromises.writeFile( + lockPath, + JSON.stringify({ + pid: process.pid, + token: "future-clock", + acquiredAt: Date.now() + 24 * 60 * 60 * 1000, + }) + ); + // Corrupt records observe the publication grace before reclamation. + const old = new Date(Date.now() - 10_000); + await fsPromises.utimes(lockPath, old, old); + const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + await release(); + expect(await pathExists(lockPath)).toBe(false); + }); + test("reclaims a corrupt lock file once its publication grace has passed", async () => { const lockPath = await tempLockPath(); await fsPromises.writeFile(lockPath, "not json"); @@ -112,7 +130,10 @@ describe("acquireCrossProcessLock", () => { test("contending acquirers over a stale lock are mutually exclusive", async () => { const lockPath = await tempLockPath(); - await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "stale", acquiredAt: 0 })); + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 1, token: "stale", acquiredAt: Date.now() - 120_000 }) + ); let inside = 0; let overlaps = 0; await Promise.all( @@ -136,7 +157,10 @@ describe("acquireCrossProcessLock", () => { describe("reclaimStaleLock", () => { test("takes ownership of a stale lock in place and confirms", async () => { const lockPath = await tempLockPath(); - await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 })); + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 1, token: "s", acquiredAt: Date.now() - 120_000 }) + ); const token = await reclaimStaleLock(lockPath, 60_000); expect(token).toBeDefined(); const holder = JSON.parse(await fsPromises.readFile(lockPath, "utf-8")) as { token: string }; @@ -192,7 +216,7 @@ describe("reclaimStaleLock", () => { test("backs off while a competing reclaimer holds a fresh reclaim mutex", async () => { const lockPath = await tempLockPath(); - const stale = JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 }); + const stale = JSON.stringify({ pid: 1, token: "s", acquiredAt: Date.now() - 120_000 }); await fsPromises.writeFile(lockPath, stale); const mutexDir = `${lockPath}.reclaim`; await fsPromises.mkdir(mutexDir); @@ -206,7 +230,10 @@ describe("reclaimStaleLock", () => { test("breaks a reclaim mutex abandoned by a crashed reclaimer", async () => { const lockPath = await tempLockPath(); - await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 })); + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 1, token: "s", acquiredAt: Date.now() - 120_000 }) + ); const mutexDir = `${lockPath}.reclaim`; await fsPromises.mkdir(mutexDir); // Age the mutex beyond RECLAIM_MUTEX_STALE_MS. @@ -223,7 +250,10 @@ describe("reclaimStaleLock", () => { // is atomic, so no observer may ever see ENOENT — the property that keeps // a third process's wx-create from slipping in mid-reclaim. const lockPath = await tempLockPath(); - await fsPromises.writeFile(lockPath, JSON.stringify({ pid: 1, token: "s", acquiredAt: 0 })); + await fsPromises.writeFile( + lockPath, + JSON.stringify({ pid: 1, token: "s", acquiredAt: Date.now() - 120_000 }) + ); let sawAbsent = false; let stop = false; const watcher = (async () => { diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index 1b801c0c814..cc86b7db313 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -48,6 +48,9 @@ export interface LockHolder { acquiredAt: number; } +/** Tolerate tiny wall-clock adjustments, but reject locks that could stay live indefinitely. */ +const MAX_LOCK_FUTURE_SKEW_MS = 60_000; + /** Parse the lock file; undefined when missing/unreadable/corrupt. */ async function readLockHolder(lockPath: string): Promise { try { @@ -56,7 +59,17 @@ async function readLockHolder(lockPath: string): Promise return undefined; } const { pid, token, acquiredAt } = parsed as Record; - if (typeof pid !== "number" || typeof token !== "string" || typeof acquiredAt !== "number") { + if ( + typeof pid !== "number" || + !Number.isInteger(pid) || + pid <= 0 || + typeof token !== "string" || + token.length === 0 || + typeof acquiredAt !== "number" || + !Number.isFinite(acquiredAt) || + acquiredAt <= 0 || + acquiredAt > Date.now() + MAX_LOCK_FUTURE_SKEW_MS + ) { return undefined; } return { pid, token, acquiredAt }; From e2924f93468da7f151d44422a7d184b919abc0b1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 09:19:36 +0000 Subject: [PATCH 47/63] fix: address Codex review round 63 (atomic journal publication, zero-workspace prune sentinel, agent ID dedupe in preview, manifest/mcp.json size caps, reserved canonical plugin key namespace) --- src/node/services/agentPlugins/discovery.ts | 17 ++ .../agentPlugins/installService.test.ts | 142 +++++++++++++++ .../services/agentPlugins/installService.ts | 169 +++++++++++++++--- .../services/agentPlugins/mcpConfig.test.ts | 19 ++ src/node/services/agentPlugins/mcpConfig.ts | 16 +- src/node/services/mcpConfigService.test.ts | 38 ++++ src/node/services/mcpConfigService.ts | 42 ++++- .../services/workspaceMcpOverridesService.ts | 4 + 8 files changed, 415 insertions(+), 32 deletions(-) diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index a646cec261c..a5f7adec53f 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -39,6 +39,16 @@ export type AgentPluginScope = "project" | "global"; */ export const UNIVERSAL_AGENT_PLUGINS_CONTAINER = "~/.agents/plugins"; +/** + * Generous ceiling for a plausible plugin.json (name/description/contributes + * declarations). A repository can otherwise put its entire checkout quota + * into one unbounded manifest string, and the install consent preview would + * ship that through IPC and render it — freezing the app before consent. + * Skill/agent markdown already has runtime size caps; this closes the same + * hole for the manifest. + */ +export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024; + export interface AgentPluginContainer { /** Absolute host path of the container directory (e.g. `/.xum/plugins`). */ path: string; @@ -208,6 +218,13 @@ async function discoverPluginAt(args: { // plugin.json of the wrong filesystem kind: not a plugin candidate. return null; } + if (manifestStat.size > MAX_PLUGIN_MANIFEST_BYTES) { + pushError( + manifestPath, + `plugin.json is too large (${manifestStat.size} bytes; max ${MAX_PLUGIN_MANIFEST_BYTES})` + ); + return null; + } let rawManifest: unknown; try { diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 3f3ae4aae6c..e5a5a758310 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -199,6 +199,42 @@ describe("AgentPluginInstallService", () => { expect(preview.slashCommands).toEqual([{ name: "standup", description: "Daily standup" }]); }); + test("preview dedupes agents that normalize to the same agent ID", async () => { + // On a case-sensitive filesystem a repo can ship agents/reviewer.md AND + // agents/REVIEWER.md; runtime discovery lowercases both to one agent ID + // and loads only one. The consent preview must promise one selectable + // agent, not two. + await fsPromises.mkdir(path.join(remoteDir, "agents"), { recursive: true }); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "reviewer.md"), + "---\nname: Reviewer\n---\nReview the diff.\n" + ); + await fsPromises.writeFile( + path.join(remoteDir, "agents", "REVIEWER.md"), + "---\nname: Shouty Reviewer\n---\nReview the diff loudly.\n" + ); + await commitAll(remoteDir, "case-colliding agents"); + + const preview = await service.preview({ input: remoteDir }); + expect(preview.agents).toHaveLength(1); + }); + + test("preview rejects an oversized plugin.json before it can reach the renderer", async () => { + // The checkout quota permits ~100 MiB; without a manifest ceiling a repo + // could put megabytes into `description` and the preview would ship that + // through IPC and lay it out in Settings before any consent. + const manifestPath = path.join(remoteDir, "plugin.json"); + const manifest = JSON.parse(await fsPromises.readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + manifest.description = "x".repeat(512 * 1024); + await fsPromises.writeFile(manifestPath, JSON.stringify(manifest)); + await commitAll(remoteDir, "oversized manifest"); + + await expect(service.preview({ input: remoteDir })).rejects.toThrow(/too large/); + }); + test("preview ignores configured checkout filters from global Git config", async () => { // An untrusted repository controls .gitattributes. If staging inherits the // user's global filter..smudge/process configuration, clone/checkout @@ -2360,6 +2396,112 @@ describe("AgentPluginInstallService", () => { } }); + test("zero-workspace uninstall with a failed re-enumeration persists a sentinel tombstone", async () => { + // Pre-commit enumeration found ZERO workspaces, so the commit wrote no + // tombstone — yet a workspace registered during the uninstall could have + // saved an enable while the tree was present. When the post-commit + // re-enumeration (the only chance to find it) then fails, an empty + // SENTINEL tombstone must persist so retries live-re-enumerate; the + // reinstall gate must clear it only after a full live sweep. + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const prunedIds: string[] = []; + const overridesStub = { + prunePluginOverrideKeys: (workspaceId: string) => { + prunedIds.push(workspaceId); + return Promise.resolve(); + }, + }; + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, + }); + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + await serviceWithOverrides.install({ source: preview.source, expectedSha: preview.lockedSha }); + + // Pre-commit: zero workspaces. Post-commit: enumeration fails. + let calls = 0; + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => { + calls += 1; + return calls === 1 + ? Promise.resolve([] as unknown as Awaited>) + : Promise.reject(new Error("config store unavailable")); + }); + try { + await serviceWithOverrides.uninstall({ name: "demo-plugin", deletePluginData: false }); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: Array<{ prefix: string; workspaceIds: string[] }>; + }; + expect(doc.pendingOverridePrunes).toEqual([ + { prefix: `plugin:${instanceId}:`, workspaceIds: [] }, + ]); + } finally { + metadataSpy.mockRestore(); + } + + // Reinstall gate: the sentinel drives a live sweep over the delta + // workspace the record never named, then clears. + const liveSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.resolve([{ id: "ws-delta", runtimeConfig: { type: "local" } }] as unknown as Awaited< + ReturnType + >) + ); + try { + const preview2 = await serviceWithOverrides.preview({ input: remoteDir }); + await serviceWithOverrides.install({ + source: preview2.source, + expectedSha: preview2.lockedSha, + }); + expect(prunedIds).toContain("ws-delta"); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: unknown; + }; + expect(doc.pendingOverridePrunes).toBeUndefined(); + } finally { + liveSpy.mockRestore(); + } + }); + + test("a sentinel tombstone survives retries whose enumeration fails and blocks reinstall", async () => { + // An empty sentinel and a failed live enumeration are indistinguishable + // from "delta workspaces unknown": the section-open retry must keep the + // record and the reinstall gate must stay blocked rather than clearing + // a sweep that never ran. + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const serviceWithOverrides = new AgentPluginInstallService(config, { + isEnabled: () => true, + workspaceMcpOverridesService: { + prunePluginOverrideKeys: () => Promise.resolve(), + } as unknown as WorkspaceMcpOverridesService, + }); + await fsPromises.mkdir(path.dirname(registryFile()), { recursive: true }); + await fsPromises.writeFile( + registryFile(), + JSON.stringify({ + plugins: [], + pendingOverridePrunes: [{ prefix: `plugin:${instanceId}:`, workspaceIds: [] }], + }) + ); + const metadataSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.reject(new Error("config store unavailable")) + ); + try { + await serviceWithOverrides.list(); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + pendingOverridePrunes?: Array<{ prefix: string; workspaceIds: string[] }>; + }; + expect(doc.pendingOverridePrunes).toEqual([ + { prefix: `plugin:${instanceId}:`, workspaceIds: [] }, + ]); + + const preview = await serviceWithOverrides.preview({ input: remoteDir }); + await expect( + serviceWithOverrides.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/could not verify/); + } finally { + metadataSpy.mockRestore(); + } + }); + 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 diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index ad7532b6a5f..7b3a57d767a 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1485,12 +1485,21 @@ export class AgentPluginInstallService { return []; } const agents: Array<{ name: string; fingerprint: string }> = []; + // Runtime discovery dedupes by normalized agent ID with the first + // successfully parsed file winning (discoverAgentDefinitions sets byId + // once per ID, in the same readdir enumeration order used here). Mirror + // that: on a case-sensitive filesystem agents/foo.md and agents/FOO.md + // are ONE loadable agent, so the preview must promise one row and the + // capability surface must not fingerprint a definition that never loads. + const seenAgentIds = new Set(); for (const entry of entries) { - if ( - !entry.isFile() || - !entry.name.toLowerCase().endsWith(".md") || - !AgentIdSchema.safeParse(path.parse(entry.name).name.trim().toLowerCase()).success - ) { + const agentIdParse = AgentIdSchema.safeParse( + path.parse(entry.name).name.trim().toLowerCase() + ); + if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md") || !agentIdParse.success) { + continue; + } + if (seenAgentIds.has(agentIdParse.data)) { continue; } let frontmatter: unknown; @@ -1504,6 +1513,10 @@ export class AgentPluginInstallService { } catch { continue; } + // Seen only AFTER a successful parse, mirroring runtime dedupe: when + // the enumeration-order winner is malformed (skipped above), the next + // same-ID file is the one that actually loads. + seenAgentIds.add(agentIdParse.data); // Fingerprint the WHOLE parsed frontmatter (key-sorted so YAML // reordering is not a change): description injects into the task // tool's model-visible prompt, subagent.runnable/ui gate invocability, @@ -1835,10 +1848,11 @@ export class AgentPluginInstallService { const promotionNonce = randomBytes(16).toString("hex"); await fsPromises.writeFile(path.join(stagedDir, PROMOTION_MARKER_FILE), promotionNonce); const journalPath = this.journalPath(PROMOTION_JOURNAL_PREFIX, name); - await fsPromises.writeFile( - journalPath, - JSON.stringify({ name, stagedAt: Date.now(), nonce: promotionNonce }) - ); + await this.writeJournalFile(journalPath, { + name, + stagedAt: Date.now(), + nonce: promotionNonce, + }); await fsPromises.mkdir(this.containerDir, { recursive: true }); await fsPromises.rename(stagedDir, targetPath); @@ -1967,6 +1981,28 @@ export class AgentPluginInstallService { return path.join(this.stagingRoot, `${prefix}${name}.json`); } + /** + * Publish a journal atomically (temp + rename, like the epoch file): + * readJournalForRecovery deliberately retains any unparseable journal and + * the discovery gate then suppresses the whole managed container, so a + * crash mid-writeFile must never leave truncated JSON at the journal path. + * The temp name keeps the `.json` suffix off, so journal enumeration + * (isJournalName + `.json`) can never pick up a half-written file. + */ + private async writeJournalFile( + journalPath: string, + document: Record + ): Promise { + const tempPath = `${journalPath}.${randomBytes(8).toString("hex")}.tmp`; + await fsPromises.writeFile(tempPath, JSON.stringify(document)); + try { + await fsPromises.rename(tempPath, journalPath); + } catch (error) { + await fsPromises.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } + } + /** * Consume a journal whose transaction/recovery job finished: bump the * container mutation epoch FIRST, then delete the file. The epoch bump is @@ -2589,10 +2625,12 @@ export class AgentPluginInstallService { // them while the registry entry still exists, and finishes the trash // cleanup once the commit landed. const uninstallJournalPath = this.journalPath(UNINSTALL_JOURNAL_PREFIX, entry.name); - await fsPromises.writeFile( - uninstallJournalPath, - JSON.stringify({ name: entry.name, trashDir, dataTrashDir, stagedAt: Date.now() }) - ); + await this.writeJournalFile(uninstallJournalPath, { + name: entry.name, + trashDir, + dataTrashDir, + stagedAt: Date.now(), + }); const consumeJournal = async (): Promise => { await this.consumeJournalFile(uninstallJournalPath).catch(() => undefined); }; @@ -2783,6 +2821,42 @@ export class AgentPluginInstallService { "Failed to re-enumerate workspaces after uninstall commit; keeping the pessimistic tombstone", { error: getErrorMessage(error) } ); + if (workspaceIdsToPrune.length === 0) { + // Zero workspaces at commit time means the commit wrote NO + // tombstone for this prefix, yet a workspace registered during the + // uninstall could have saved an enable while the tree was still + // present — and this failed re-enumeration was the only chance to + // find it. The tombstone's PRESENCE is what drives retry-side live + // re-enumeration, so persist an empty SENTINEL record; retries + // clear it only after a full live sweep succeeds. If even that + // write fails, surface the gap to the user (after the remaining + // cleanup below). + try { + const { envelope: envelopeSentinel, rawEntries: entriesSentinel } = + await this.readRegistryDocument("strict"); + if (this.hasOpaquePendingPrunes(envelopeSentinel)) { + // Recording into a newer build's opaque shape would clobber it + // (same reasoning as the pre-commit guard, which only runs when + // commit-time workspaces existed). + throw new Error( + "the registry's pending cleanup state was written by a newer version of Mux" + ); + } + const pendingSentinel = this.updateRawPendingPrunes( + this.rawPendingPrunes(envelopeSentinel), + serverKeyPrefix, + [], + { keepEmpty: true } + ); + await this.writePendingOverridePrunes( + envelopeSentinel, + entriesSentinel, + pendingSentinel + ); + } catch (sentinelError) { + deltaRecordFailure = `The plugin was uninstalled, but workspaces registered during the uninstall could not be checked for stale MCP overrides and recording a cleanup reminder failed (${getErrorMessage(sentinelError)}). If reinstalling this plugin, first check workspace MCP settings for stale entries.`; + } + } } // Delta workspaces are NOT in the commit-time tombstone. Persist the // union BEFORE pruning so a crash between here and their prune keeps a @@ -3024,11 +3098,16 @@ export class AgentPluginInstallService { * removes every recognized item for that prefix (merging their unknown * fields into the replacement) and appends the new one when * `workspaceIds` is non-empty. Unrecognized items are preserved verbatim. + * `keepEmpty` appends an empty-list SENTINEL tombstone instead of removing + * the entry: its presence still drives retry-side live re-enumeration, + * covering uninstalls where the workspaces needing pruning were never + * enumerable (see the uninstall post-commit re-enumeration catch). */ private updateRawPendingPrunes( rawPending: unknown[], prefix: string, - workspaceIds: string[] + workspaceIds: string[], + options?: { keepEmpty?: boolean } ): unknown[] { const matches: Array> = []; const next: unknown[] = []; @@ -3039,7 +3118,7 @@ export class AgentPluginInstallService { next.push(item); } } - if (workspaceIds.length > 0) { + if (workspaceIds.length > 0 || options?.keepEmpty === true) { const replacement: Record = {}; for (const match of matches) { Object.assign(replacement, match); @@ -3086,11 +3165,17 @@ export class AgentPluginInstallService { * succeeded. Recorded workspaces that no longer exist drop out implicitly * — a deleted workspace's overrides can never reactivate anything, so * keeping its ID would block reinstall forever. Returns the IDs that - * still need pruning; when enumeration itself fails, the recorded list is - * returned unshrunk even if its prunes succeeded, because unenumerated - * delta workspaces cannot be ruled out (over-blocking is safe). + * still need pruning; when enumeration itself fails (`enumerated: false`), + * the recorded list is returned unshrunk even if its prunes succeeded, + * because unenumerated delta workspaces cannot be ruled out — callers must + * then keep the tombstone even when `failed` is empty (an empty SENTINEL + * tombstone records exactly this "delta workspaces unknown" state, and a + * failed re-enumeration cannot rule them out either). */ - private async retryPrune(prune: { prefix: string; workspaceIds: string[] }): Promise { + private async retryPrune(prune: { + prefix: string; + workspaceIds: string[]; + }): Promise<{ enumerated: boolean; failed: string[] }> { let liveWorkspaceIds: string[]; try { liveWorkspaceIds = await this.listWorkspaceIdsForOverridePruning(); @@ -3099,9 +3184,12 @@ export class AgentPluginInstallService { error: getErrorMessage(error), }); await this.pruneWorkspaceOverrides(prune.prefix, prune.workspaceIds); - return prune.workspaceIds; + return { enumerated: false, failed: prune.workspaceIds }; } - return this.pruneWorkspaceOverrides(prune.prefix, liveWorkspaceIds); + return { + enumerated: true, + failed: await this.pruneWorkspaceOverrides(prune.prefix, liveWorkspaceIds), + }; } /** @@ -3139,7 +3227,16 @@ export class AgentPluginInstallService { return; } - const failed = await this.retryPrune(match); + const { enumerated, failed } = await this.retryPrune(match); + if (!enumerated) { + // Delta workspaces cannot be ruled out without a live enumeration: + // keep the tombstone verbatim (even an empty sentinel) and stay + // blocked — clearing it here would let the reinstall proceed over + // workspaces the sweep never saw. + throw new Error( + `A previous uninstall of '${name}' could not verify its workspace MCP override cleanup yet (workspace enumeration failed). Retry in a moment.` + ); + } const remaining = this.updateRawPendingPrunes( this.rawPendingPrunes(envelope), serverKeyPrefix, @@ -3172,12 +3269,24 @@ export class AgentPluginInstallService { let rawPending = this.rawPendingPrunes(envelope); let progressed = false; for (const prune of pending) { - const failed = await this.retryPrune(prune); + const { enumerated, failed } = await this.retryPrune(prune); + if (!enumerated) { + // Keep the record verbatim: without a live enumeration, delta + // workspaces cannot be ruled out — in particular an empty SENTINEL + // tombstone (recorded when an uninstall's re-enumeration failed + // with zero commit-time workspaces) must not be cleared here. + continue; + } // Set comparison, not length: retryPrune re-enumerates live // workspaces, so `failed` can contain IDs the record never held - // (delta workspaces) — those must be folded in durably too. + // (delta workspaces) — those must be folded in durably too. A fully + // successful sweep (`failed` empty) always counts as progress so an + // empty sentinel tombstone clears instead of lingering forever. const recorded = new Set(prune.workspaceIds); - const changed = failed.length !== recorded.size || failed.some((id) => !recorded.has(id)); + const changed = + failed.length === 0 || + failed.length !== recorded.size || + failed.some((id) => !recorded.has(id)); if (changed) { progressed = true; rawPending = this.updateRawPendingPrunes(rawPending, prune.prefix, failed); @@ -3367,10 +3476,12 @@ export class AgentPluginInstallService { // self-heal because assertNoCapabilityIncrease treats the missing // tree as an empty surface and rejects the staged capabilities as // additions. reconcileJournals restores the old tree on recovery. - await fsPromises.writeFile( - updateJournalPath, - JSON.stringify({ name: entry.name, trashDir, nonce: updateNonce, stagedAt: Date.now() }) - ); + await this.writeJournalFile(updateJournalPath, { + name: entry.name, + trashDir, + nonce: updateNonce, + stagedAt: Date.now(), + }); try { await this.renameIntoStaging(targetPath, trashDir); } catch (error) { diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 74d3ac30636..6dc7ee29fdd 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -175,6 +175,25 @@ describe("loadPluginMcpServers", () => { } }); + test("disables MCP for the plugin on an oversized mcp.json", async () => { + // Server summaries built from mcp.json reach the install consent + // preview's IPC/render path: an unbounded document must disable MCP for + // this plugin instead of shipping megabytes of text to the renderer. + using tmp = new DisposableTempDir("plugin-mcp"); + const oversized = JSON.stringify({ + $schema: AGENT_PLUGIN_MCP_SCHEMA_ID_1_0_0, + mcpServers: { + big: { type: "stdio", command: "bunx", args: ["x".repeat(512 * 1024)] }, + }, + }); + const plugin = await makePlugin(tmp.path, "oversized", oversized); + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { xumHome: tmp.path }); + expect(servers).toEqual({}); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("error"); + expect(diagnostics[0].message).toContain("too large"); + }); + test("an empty mcpServers object is valid", async () => { using tmp = new DisposableTempDir("plugin-mcp"); const plugin = await makePlugin(tmp.path, "empty", mcpDoc({})); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index c1b3eec7a90..13927fe94ba 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -12,7 +12,11 @@ import { isMultiProject } from "@/common/utils/multiProject"; import { log } from "@/node/services/log"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; import type { AgentPluginContainer, AgentPluginDiagnostic, AgentPluginInfo } from "./discovery"; -import { computeAgentPluginContainers, discoverAgentPlugins } from "./discovery"; +import { + computeAgentPluginContainers, + discoverAgentPlugins, + MAX_PLUGIN_MANIFEST_BYTES, +} from "./discovery"; import { expandPluginPlaceholders, type PluginPlaceholderValues } from "./expansion"; /** @@ -554,6 +558,16 @@ export async function loadPluginMcpServers( let raw: unknown; try { + // Size-cap before parsing: server summaries built from this document + // (command lines, env assignments, URLs) reach the install consent + // preview's IPC/render path, so one unbounded string must not be able to + // freeze the app before consent (same ceiling as plugin.json). + const stat = await fsPromises.stat(plugin.mcpConfigPath); + if (stat.size > MAX_PLUGIN_MANIFEST_BYTES) { + return disableMcp( + `mcp.json is too large (${stat.size} bytes; max ${MAX_PLUGIN_MANIFEST_BYTES})` + ); + } raw = JSON.parse(await fsPromises.readFile(plugin.mcpConfigPath, "utf8")) as unknown; } catch (error) { // §7.2.2 rule 2: invalid JSON disables MCP for this plugin only. diff --git a/src/node/services/mcpConfigService.test.ts b/src/node/services/mcpConfigService.test.ts index f82755e8fae..ef935040c74 100644 --- a/src/node/services/mcpConfigService.test.ts +++ b/src/node/services/mcpConfigService.test.ts @@ -196,6 +196,44 @@ describe("MCP server disable filtering", () => { }); }); + test("canonical plugin keys are reserved: ignored in user config layers, rejected by addServer", async () => { + // A user server occupying a canonical `plugin:<16-hex>:` key would + // shadow the plugin server (user layers win on collision) yet lose its + // workspace overrides to that plugin's uninstall, which prunes such keys + // by shape. Hand-edited config entries are ignored (not started, not + // shadowing); the add flow rejects the name outright. + const reservedKey = "plugin:0123456789abcdef:srv"; + const withProvider = new MCPConfigService(config, { + agentPluginsMcpProvider: () => Promise.resolve({ [reservedKey]: PLUGIN_SERVER }), + }); + + const added = await withProvider.addServer(reservedKey, { transport: "stdio", command: "x" }); + expect(added.success).toBe(false); + if (!added.success) { + expect(added.error).toContain("reserved"); + } + + // Hand-edited global + project entries on the reserved key. + await fs.writeFile( + path.join(config.rootDir, "mcp.jsonc"), + JSON.stringify({ servers: { [reservedKey]: "user-global", ordinary: "user-ordinary" } }), + "utf-8" + ); + const projectPath = path.join(tempDir, "repo-reserved"); + await fs.mkdir(path.join(projectPath, ".xum"), { recursive: true }); + await fs.writeFile( + path.join(projectPath, ".xum", "mcp.jsonc"), + JSON.stringify({ servers: { [reservedKey]: "user-project" } }), + "utf-8" + ); + + const servers = await withProvider.listServers(projectPath, true); + // The plugin server keeps its reserved key; the user entries neither + // shadow it nor appear under their own name. Ordinary names still load. + expect(servers[reservedKey]).toEqual(PLUGIN_SERVER); + expect(servers.ordinary).toMatchObject({ command: "user-ordinary" }); + }); + test("listServers resolves the Agent Plugins context: default, explicit, and null", async () => { const seenArgs: Array<{ projectRoot?: string; projectKey?: string; trusted: boolean }> = []; const withProvider = new MCPConfigService(config, { diff --git a/src/node/services/mcpConfigService.ts b/src/node/services/mcpConfigService.ts index a2a3f371897..3e9b0e02146 100644 --- a/src/node/services/mcpConfigService.ts +++ b/src/node/services/mcpConfigService.ts @@ -17,9 +17,36 @@ import type { AgentPluginsMcpContext, AgentPluginsMcpProvider, } from "@/node/services/agentPlugins/mcpConfig"; +import { isCanonicalPluginServerKey } from "@/node/services/agentPlugins/mcpConfig"; import { log } from "@/node/services/log"; import { getErrorMessage } from "@/common/utils/errors"; +/** + * Canonical `plugin:<16-hex>:` keys are RESERVED for Agent Plugin + * servers: a plugin uninstall prunes workspace overrides for these keys by + * shape, so an ordinary user-configured server occupying one would shadow + * the plugin server (user layers win on key collision) yet lose its own + * enablement/allowlist state during that plugin's uninstall. Reserved keys + * found in user config are ignored at runtime — the on-disk entry is + * preserved verbatim (loss-preserving rewrites) but never listed or started. + */ +function omitReservedPluginKeys( + servers: Record, + layer: "global" | "project" +): Record { + const result: Record = {}; + for (const [name, info] of Object.entries(servers)) { + if (isCanonicalPluginServerKey(name)) { + log.debug( + `[MCP] Ignoring ${layer} MCP server '${name}': the canonical plugin key namespace is reserved for Agent Plugin servers` + ); + continue; + } + result[name] = info; + } + return result; +} + export class MCPConfigService { private readonly config: Config; /** @@ -295,16 +322,21 @@ export class MCPConfigService { } const globalCfg = await this.getGlobalConfig(); + const globalServers = omitReservedPluginKeys(globalCfg.servers, "global"); if (!projectPath || !trusted) { if (projectPath && !trusted) { log.debug("[MCP] Skipping project-local MCP config for untrusted project", { projectPath }); } - return { plugin: pluginServers, global: globalCfg.servers, project: {} }; + return { plugin: pluginServers, global: globalServers, project: {} }; } const repoCfg = await this.getRepoOverrideConfig(projectPath); - return { plugin: pluginServers, global: globalCfg.servers, project: repoCfg.servers }; + return { + plugin: pluginServers, + global: globalServers, + project: omitReservedPluginKeys(repoCfg.servers, "project"), + }; } async addServer( @@ -319,6 +351,12 @@ export class MCPConfigService { if (!name.trim()) { return Err("Server name is required"); } + if (isCanonicalPluginServerKey(name.trim())) { + // See omitReservedPluginKeys: a user server on a canonical plugin key + // would be stripped of its workspace overrides by that plugin's + // uninstall. + return Err("Server names of the form 'plugin::' are reserved for Agent Plugins"); + } const transport: MCPServerTransport = input.transport ?? "stdio"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index e1a1aff6c15..81f6a00a1a7 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -704,6 +704,10 @@ export class WorkspaceMcpOverridesService { // requested prefix: MCP server names are otherwise arbitrary strings // and user configuration may legitimately name a server "plugin:…" — // pruning must never strip such an ordinary server's overrides. + // Canonical keys themselves are additionally RESERVED in ordinary + // config (MCPConfigService ignores them in global/project layers and + // addServer rejects them), so a key this shape can only belong to an + // Agent Plugin server — shape-based pruning cannot hit a user server. const isPrunableKey = (key: unknown): boolean => typeof key === "string" && key.startsWith(keyPrefix) && isCanonicalPluginServerKey(key); From 12e02f54c6878490e0e905d39400d21f3168bd9a Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 09:26:42 +0000 Subject: [PATCH 48/63] fix: join the in-flight lease renewal on lock release so a stalled tick cannot restamp a released lock --- src/node/utils/main/crossProcessLock.test.ts | 28 +++++++++++++++++ src/node/utils/main/crossProcessLock.ts | 33 ++++++++++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/node/utils/main/crossProcessLock.test.ts b/src/node/utils/main/crossProcessLock.test.ts index f8a05920ce5..d01ccc25985 100644 --- a/src/node/utils/main/crossProcessLock.test.ts +++ b/src/node/utils/main/crossProcessLock.test.ts @@ -128,6 +128,34 @@ describe("acquireCrossProcessLock", () => { await release2(); }, 10_000); + test("release during active renewals leaves the lock immediately reacquirable", async () => { + // stopRenewal joins the in-flight renewal tick: releasing mid-tick must + // never let a resumed renewal re-stamp a fresh lease onto the released + // lock (which would block siblings until the stale ceiling). Release at + // staggered offsets against a fast renewal interval, asserting the file + // is gone and a competitor can acquire instantly every time. + const lockPath = await tempLockPath(); + for (const holdMs of [260, 310, 380, 430]) { + const release = await acquireCrossProcessLock({ + lockPath, + acquireTimeoutMs: 400, + staleMs: 1_000, // renewal ticks every 250ms + timeoutMessage: "lock busy", + }); + await new Promise((resolve) => setTimeout(resolve, holdMs)); + await release(); + expect(await pathExists(lockPath)).toBe(false); + const release2 = await acquireCrossProcessLock({ + lockPath, + acquireTimeoutMs: 400, + staleMs: 1_000, + timeoutMessage: "lock busy", + }); + await release2(); + expect(await pathExists(lockPath)).toBe(false); + } + }, 10_000); + test("contending acquirers over a stale lock are mutually exclusive", async () => { const lockPath = await tempLockPath(); await fsPromises.writeFile( diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index cc86b7db313..36dbf34a3b9 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -284,20 +284,30 @@ export async function acquireCrossProcessLock( // reclaim mutex (so a renewal cannot clobber a successor after a stall); // only holders that STOPPED renewing (crashed, wedged past the ceiling, // or pid-reused) age out. - const startRenewal = (token: string): (() => void) => { + const startRenewal = (token: string): (() => Promise) => { let renewing = false; + let stopped = false; + // The in-flight tick, joined by stop: clearing the interval only stops + // FUTURE ticks, and a tick already holding the reclaim mutex could + // otherwise outlast release's bounded retry budget and then re-stamp a + // fresh lease onto a lock whose transaction already finished — blocking + // siblings until the stale ceiling instead of immediately. + let inFlight: Promise = Promise.resolve(); const interval = setInterval( () => { - if (renewing) { + if (renewing || stopped) { return; } renewing = true; - void (async () => { + inFlight = (async () => { const mutex = await enterLockMutex(lockPath); if (mutex === undefined) { return; // Contended: try again next tick. } try { + if (stopped) { + return; // Release began while we waited for the mutex. + } const current = await readLockHolder(lockPath); if (current?.token !== token) { return; // No longer ours: a reclaimer took over; stop touching it. @@ -307,7 +317,7 @@ export async function acquireCrossProcessLock( tempPath, JSON.stringify({ pid: process.pid, token, acquiredAt: Date.now() }) ); - if (await mutex.owns()) { + if (!stopped && (await mutex.owns())) { await fsPromises.rename(tempPath, lockPath); } else { await fsPromises.rm(tempPath, { force: true }).catch(() => undefined); @@ -324,13 +334,24 @@ export async function acquireCrossProcessLock( Math.max(250, Math.floor(staleMs / 4)) ); interval.unref?.(); - return () => clearInterval(interval); + return async () => { + // Order matters: the flag is visible to the in-flight tick before the + // join, so a tick still waiting on the mutex exits without writing, + // and one already past the holder read skips the rename. The join is + // unbounded on purpose — a rename can land only inside `inFlight`, so + // release must not proceed (or give up) while it is unsettled; the + // tick is a handful of local fs ops, and a filesystem wedged past that + // stalls every other lock operation anyway. + stopped = true; + clearInterval(interval); + await inFlight; + }; }; const releaseFor = (token: string) => { const stopRenewal = startRenewal(token); return async () => { - stopRenewal(); + await stopRenewal(); for (let attempt = 0; attempt < 40; attempt++) { const mutex = await enterLockMutex(lockPath); if (mutex !== undefined) { From 6d12bf5c2c2517c0fc3e04c15aa8833ad4e02f89 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 09:44:02 +0000 Subject: [PATCH 49/63] fix: address Codex review round 65 (exclusive mutex owner publication, reject absolute symlinks into the managed plugins container) --- .../agentPlugins/installService.test.ts | 23 +++++++++++ .../services/agentPlugins/installService.ts | 39 +++++++++++++++---- src/node/utils/main/crossProcessLock.ts | 19 ++++++++- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index e5a5a758310..1208140cf75 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1682,6 +1682,29 @@ describe("AgentPluginInstallService", () => { ); }); + test("staged trees reject absolute symlinks into the managed plugins directory", async () => { + // The update-time consent bypass: v1 ships a benign payload.js; v2 adds + // hooks.js as an ABSOLUTE link to the plugin's own final install path. + // While staged, that target resolves into the currently installed tree — + // outside the staged root, so hook discovery excludes it from the + // preview and the capability comparison — but after the swap the same + // target string resolves inside the promoted root and the undisclosed + // hook would auto-load. + await fsPromises.writeFile(path.join(remoteDir, "payload.js"), "// benign in v1\n"); + await commitAll(remoteDir, "v1 with payload"); + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + + await fsPromises.symlink( + path.join(pluginsDir(), "demo-plugin", "payload.js"), + path.join(remoteDir, "hooks.js") + ); + await commitAll(remoteDir, "absolute hook link into the final install path"); + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /absolute symbolic link into the managed plugins directory/ + ); + }); + test("repositories shipping the reserved recovery marker name are rejected", async () => { // install/update write a nonce file at this path pre-rename; a repo // shipping it would get that file clobbered then deleted, making the diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 7b3a57d767a..410059d0870 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1029,13 +1029,31 @@ export class AgentPluginInstallService { * promotion (e.g. `hooks.js -> ../../plugins//payload.js` resolves * to nothing in staging but to an executable hook inside the live root * post-install, skipping consent). Links that RESOLVE INSIDE the staged - * root keep their meaning across the promote rename; absolute links keep - * their meaning too (same target string) and stay subject to runtime - * escape containment — everything else is rejected before any commit. + * root keep their meaning across the promote rename. Absolute links keep + * their target STRING, but their CONTAINMENT can still flip: a target + * under the managed plugins container — this plugin's own final install + * path — resolves into the CURRENTLY INSTALLED tree during an update's + * staging (outside the staged root, so component discovery excludes it + * from the consent preview and the capability comparison) yet inside the + * promoted root after the swap, auto-loading undisclosed content. Links + * into the container are therefore rejected, by raw target and by + * resolution; other absolute links keep their meaning and stay subject to + * runtime escape containment. Everything else is rejected before any + * commit. */ private async assertStagedTreeWithinQuota(dir: string): Promise { const quota = this.stagingQuota(); const rootReal = await fsPromises.realpath(dir); + // Both forms of the container path: the raw-target check must catch the + // guessable lexical path even when nothing exists there yet, and the + // resolved check must catch realpath-equivalent routes to it. + const containerReal = await fsPromises + .realpath(this.containerDir) + .catch(() => this.containerDir); + const withinContainer = (candidate: string): boolean => + [this.containerDir, containerReal].some( + (container) => candidate === container || candidate.startsWith(container + path.sep) + ); let bytes = 0; let entryCount = 0; const pending: string[] = [dir]; @@ -1062,13 +1080,20 @@ export class AgentPluginInstallService { ); } const rawTarget = await fsPromises.readlink(entryPath); + const withinStagedRoot = + resolvedTarget === rootReal || resolvedTarget.startsWith(rootReal + path.sep); + if (!path.isAbsolute(rawTarget) && !withinStagedRoot) { + throw new Error( + `The repository ships a relative symbolic link that escapes the repository root (${relative}). Such links resolve differently after install than during the consent preview, so they are rejected.` + ); + } if ( - !path.isAbsolute(rawTarget) && - resolvedTarget !== rootReal && - !resolvedTarget.startsWith(rootReal + path.sep) + path.isAbsolute(rawTarget) && + !withinStagedRoot && + (withinContainer(path.resolve(rawTarget)) || withinContainer(resolvedTarget)) ) { throw new Error( - `The repository ships a relative symbolic link that escapes the repository root (${relative}). Such links resolve differently after install than during the consent preview, so they are rejected.` + `The repository ships an absolute symbolic link into the managed plugins directory (${relative}). Such links resolve differently after install than during the consent preview, so they are rejected.` ); } } diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index 36dbf34a3b9..00e9775dbab 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -127,7 +127,11 @@ const CORRUPT_LOCK_GRACE_MS = 2_000; * or undefined when a competitor holds a fresh mutex (back off and retry). * A mutex dir older than RECLAIM_MUTEX_STALE_MS (crashed holder) is broken. * Ownership is witnessed by a token file so a competitor that breaks our - * mutex during an arbitrary pause is detectable via `owns()`. + * mutex during an arbitrary pause is detectable via `owns()`. The token is + * published with exclusive create: a process that stalled between its mkdir + * and this publication long enough to be broken as stale must find the + * successor's owner file and abandon, not overwrite it — a plain write would + * let BOTH sides leave believing they hold the mutex. */ async function enterLockMutex( lockPath: string @@ -159,7 +163,18 @@ async function enterLockMutex( return undefined; } } - await fsPromises.writeFile(mutexTokenFile, mutexToken); + try { + await fsPromises.writeFile(mutexTokenFile, mutexToken, { flag: "wx" }); + } catch (error) { + // EEXIST: a competitor broke our apparently-abandoned dir and published + // its own owner (or we broke theirs and lost the publish race) — exactly + // one publisher may win, and it is not us. ENOENT: the dir itself was + // broken mid-publication. Both mean "abandon and let the caller retry". + if (hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOENT")) { + return undefined; + } + throw error; + } const owns = async (): Promise => { try { From 9e113da27c7e3991c64a006ca4db8533724eaad1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 09:51:56 +0000 Subject: [PATCH 50/63] fix: load disk overrides on a workspace's first serve so a stale caller snapshot cannot re-enable a sibling-reinstalled plugin server --- src/node/services/mcpServerManager.test.ts | 39 ++++++++++++++++++ src/node/services/mcpServerManager.ts | 48 +++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 320c520e53a..601714a307f 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -583,6 +583,45 @@ describe("MCPServerManager", () => { expect(internals.lastWorkspaceRequestOptions.get(workspaceId)?.overrides).toEqual({}); }); + test("a cold workspace's first serve loads disk overrides instead of trusting the caller snapshot", async () => { + // Two processes, one home: the caller read its snapshot BEFORE a sibling + // uninstall + same-name reinstall pruned the enable from the override + // file. This manager never served the workspace (no cached snapshot for + // the sweep to refresh) and its first token observation records the + // already-advanced epoch, so the bracket sees nothing to retire — disk + // must win on the first serve, or the stale enable overrides the + // replacement server's default-disabled state. + manager.dispose(); + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { + keyPrefix: "plugin:", + readToken: () => Promise.resolve("epoch-post-mutation"), + readWorkspaceOverrides: () => Promise.resolve({}), // pruned on disk + }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js", true) }) + ); + let startedPluginServer = false; + access.startServers = (...args: unknown[]) => { + const servers = args[0] as Record; + if (pluginKey in servers) { + startedPluginServer = true; + } + return Promise.resolve(startResult([])); + }; + + const staleCallerOptions = workspaceRequest("ws-cold-first-serve", { + overrides: { enabledServers: [pluginKey] }, + }); + const result = await manager.getToolsForWorkspace(staleCallerOptions); + expect(startedPluginServer).toBe(false); + expect(Object.keys(result.tools)).toHaveLength(0); + }); + test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup, then retries them", async () => { const workspaceId = "ws-swap-race"; const pluginKey = "plugin:abc123:echo"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index e18956d1991..ddb65972fe6 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1276,6 +1276,49 @@ export class MCPServerManager { } } + /** + * Authoritative overrides for a workspace's FIRST serve on this manager. + * The caller's snapshot may have been read from disk BEFORE a sibling + * process's uninstall + same-name reinstall pruned its plugin keys, and + * the epoch bracket cannot catch that staleness here: a cold manager's + * first token observation records the already-advanced token, and later + * sweeps refresh only workspaces with recorded options — a never-served + * workspace has none. Disk is authoritative (every override write + * persists before publishing), so read it now; when it cannot be read, + * scrub plugin keys from the caller snapshot so a stale enable can never + * override a replacement server's default-disabled state. Off-host + * workspaces are skipped (plugin servers are never offered there, and the + * read would exec remotely). + */ + private async loadFirstServeWorkspaceOverrides( + requestOptions: MCPWorkspaceRequestOptions + ): Promise { + if ( + this.pluginInvalidation === undefined || + this.lastWorkspaceRequestOptions.has(requestOptions.workspaceId) + ) { + return requestOptions.overrides; + } + const execsOffHost = + requestOptions.runtime instanceof RemoteRuntime || + requestOptions.runtime instanceof DevcontainerRuntime; + if (execsOffHost) { + return requestOptions.overrides; + } + const readOverrides = this.pluginInvalidation.readWorkspaceOverrides; + if (readOverrides !== undefined) { + try { + return await readOverrides(requestOptions.workspaceId); + } catch (error) { + log.warn("[MCP] Failed to load workspace overrides for a first serve", { + workspaceId: requestOptions.workspaceId, + error: getErrorMessage(error), + }); + } + } + return this.scrubPluginOverrideKeys(requestOptions.overrides); + } + /** * Stop the idle cleanup interval. Call when shutting down. */ @@ -1700,7 +1743,10 @@ export class MCPServerManager { ...requestOptions, overrides: this.latestWorkspaceOverrides.get(requestOptions.workspaceId), } - : requestOptions; + : { + ...requestOptions, + overrides: await this.loadFirstServeWorkspaceOverrides(requestOptions), + }; // Same cold-workspace gap for project trust: a revocation landing while a // stream's pre-await trusted snapshot is still in flight has no recorded // options to repair, so overlay the newest trust the manager has seen. From 6a708bf77397a990ec2f839755faf30a4113d0d3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 10:15:22 +0000 Subject: [PATCH 51/63] fix: address Codex review round 67 (recheck override cache after first-serve disk read, cap hooks.js source size in discovery) --- .../services/agentPlugins/discovery.test.ts | 23 +++++++ src/node/services/agentPlugins/discovery.ts | 31 +++++++++- src/node/services/mcpServerManager.test.ts | 61 +++++++++++++++++++ src/node/services/mcpServerManager.ts | 29 ++++++--- 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/src/node/services/agentPlugins/discovery.test.ts b/src/node/services/agentPlugins/discovery.test.ts index 5eb49b50138..2fc81236d5f 100644 --- a/src/node/services/agentPlugins/discovery.test.ts +++ b/src/node/services/agentPlugins/discovery.test.ts @@ -304,6 +304,29 @@ describe("discoverAgentPlugins", () => { expect(result.diagnostics[0].message).toContain("hooks.js"); }); + test("an oversized hooks.js invalidates only the hooks component", async () => { + // The hook source is read and hashed every send and evaluated in the + // main process: a repo pouring its checkout quota into hooks.js must not + // gain a post-install stall primitive. The same discovery cap governs + // the consent preview, so preview and runtime exclude identically. + using tmp = new DisposableTempDir("agent-plugins"); + const container = path.join(tmp.path, "plugins"); + const pluginDir = await writePlugin(container, "big-hooks", { mcpJson: "{}" }); + await fs.writeFile( + path.join(pluginDir, "hooks.js"), + `// ${"x".repeat(2 * 1024 * 1024)}\n({})`, + "utf8" + ); + + const result = await discoverAgentPlugins([{ path: container, scope: "global" }]); + + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].hooksPath).toBeUndefined(); + expect(result.plugins[0].mcpConfigPath).toBeDefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain("too large"); + }); + test("a symlinked plugin directory anchors containment at its realpath", async () => { using tmp = new DisposableTempDir("agent-plugins"); const container = path.join(tmp.path, "plugins"); diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index a5f7adec53f..5852433b9fc 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -49,6 +49,15 @@ export const UNIVERSAL_AGENT_PLUGINS_CONTAINER = "~/.agents/plugins"; */ export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024; +/** + * Ceiling for hooks.js source. Unlike other components, the hook FILE itself + * is read and hashed on every send and evaluated in the Electron main + * process, so a repository devoting its checkout quota to one giant script + * could stall the app after an accepted install. Enforced here so the + * consent preview and runtime discovery exclude the identical component set. + */ +export const MAX_PLUGIN_HOOK_SOURCE_BYTES = 1024 * 1024; + export interface AgentPluginContainer { /** Absolute host path of the container directory (e.g. `/.xum/plugins`). */ path: string; @@ -123,6 +132,8 @@ async function resolveComponentPath(args: { componentLabel: string; scope: AgentPluginScope; diagnostics: AgentPluginDiagnostic[]; + /** For file components whose whole source gets loaded: exclude oversized files. */ + maxBytes?: number; }): Promise { const candidate = path.join(args.rootReal, args.relativePath); @@ -165,6 +176,18 @@ async function resolveComponentPath(args: { return undefined; } + if (args.maxBytes !== undefined && stat.size > args.maxBytes) { + const message = `${args.componentLabel} is too large (${stat.size} bytes; max ${args.maxBytes}); ignoring this component`; + log.warn(`Agent plugin ${args.rootReal}: ${message}`); + args.diagnostics.push({ + path: candidate, + scope: args.scope, + severity: "error", + message, + }); + return undefined; + } + return canonical; } @@ -262,7 +285,8 @@ async function discoverPluginAt(args: { const contributes = validation.manifest.contributes; const resolveComponent = ( relativePath: string, - expectKind: "file" | "directory" + expectKind: "file" | "directory", + options?: { maxBytes?: number } ): Promise => resolveComponentPath({ rootReal, @@ -271,11 +295,14 @@ async function discoverPluginAt(args: { componentLabel: expectKind === "directory" ? `${relativePath}/` : relativePath, scope, diagnostics, + ...(options?.maxBytes !== undefined ? { maxBytes: options.maxBytes } : {}), }); const skillsDir = await resolveComponent(contributes?.skills ?? "skills", "directory"); const mcpConfigPath = await resolveComponent(contributes?.mcp ?? "mcp.json", "file"); - const hooksPath = await resolveComponent(contributes?.hooks ?? "hooks.js", "file"); + const hooksPath = await resolveComponent(contributes?.hooks ?? "hooks.js", "file", { + maxBytes: MAX_PLUGIN_HOOK_SOURCE_BYTES, + }); const agentsDir = await resolveComponent(contributes?.agents ?? "agents", "directory"); const workflowsDir = await resolveComponent(contributes?.workflows ?? "workflows", "directory"); diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 601714a307f..5dd6fe00be8 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -622,6 +622,67 @@ describe("MCPServerManager", () => { expect(Object.keys(result.tools)).toHaveLength(0); }); + test("a settings save landing during the first-serve disk read wins over the read result", async () => { + // The first serve's disk read races a successful MCP settings save: the + // save persists to disk, then publishes into the override cache — but a + // read started BEFORE the save can resolve with the older state + // afterwards. The continuation must recheck the cache: recording the + // stale read would expose a just-disabled server for this send, and the + // save's repair path only patches recorded options, which do not exist + // yet on a first serve. + manager.dispose(); + let readStarted: () => void = () => undefined; + const readStartedPromise = new Promise((resolve) => { + readStarted = resolve; + }); + let resolveRead: (value: Record) => void = () => undefined; + const pendingRead = new Promise>((resolve) => { + resolveRead = resolve; + }); + manager = new MCPServerManager(configService as unknown as MCPConfigService, { + pluginInvalidation: { + keyPrefix: "plugin:", + readToken: () => Promise.resolve("epoch-1"), + readWorkspaceOverrides: () => { + readStarted(); + return pendingRead; + }, + }, + }); + access = manager as unknown as MCPServerManagerTestAccess; + + const workspaceId = "ws-first-serve-race"; + const pluginKey = "plugin:abc123:echo"; + configService.listServers.mockImplementation(() => + Promise.resolve({ [pluginKey]: stdioConfig("node server.js", true) }) + ); + let startedPluginServer = false; + access.startServers = (...args: unknown[]) => { + const servers = args[0] as Record; + if (pluginKey in servers) { + startedPluginServer = true; + } + return Promise.resolve(startResult([])); + }; + + const serve = manager.getToolsForWorkspace( + workspaceRequest(workspaceId, { overrides: { enabledServers: [pluginKey] } }) + ); + // Deterministic interleaving: the serve is parked on the disk read when + // the save publishes, then the read resolves with the pre-save state. + await readStartedPromise; + await manager.applyWorkspaceOverrides(workspaceId, {}); + resolveRead({ enabledServers: [pluginKey] }); + + const result = await serve; + expect(startedPluginServer).toBe(false); + expect(Object.keys(result.tools)).toHaveLength(0); + const internals = access as unknown as { + lastWorkspaceRequestOptions: Map; + }; + expect(internals.lastWorkspaceRequestOptions.get(workspaceId)?.overrides).toEqual({}); + }); + test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup, then retries them", async () => { const workspaceId = "ws-swap-race"; const pluginKey = "plugin:abc123:echo"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index ddb65972fe6..902a30ad393 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1738,15 +1738,26 @@ export class MCPServerManager { // Cold workspaces have no recorded state for applyWorkspaceOverrides to repair. // Overlay the newest overrides over a caller snapshot that may predate the mutation. - let options = this.latestWorkspaceOverrides.has(requestOptions.workspaceId) - ? { - ...requestOptions, - overrides: this.latestWorkspaceOverrides.get(requestOptions.workspaceId), - } - : { - ...requestOptions, - overrides: await this.loadFirstServeWorkspaceOverrides(requestOptions), - }; + let options: MCPWorkspaceRequestOptions; + if (this.latestWorkspaceOverrides.has(requestOptions.workspaceId)) { + options = { + ...requestOptions, + overrides: this.latestWorkspaceOverrides.get(requestOptions.workspaceId), + }; + } else { + const firstServeOverrides = await this.loadFirstServeWorkspaceOverrides(requestOptions); + // Recheck AFTER the await: an MCP settings save completing while the + // disk read was in flight published newer state into the cache, and + // recording the read's older result would expose a just-disabled + // server for this send (the save's repair path only patches recorded + // options, which do not exist yet on a first serve). + options = this.latestWorkspaceOverrides.has(requestOptions.workspaceId) + ? { + ...requestOptions, + overrides: this.latestWorkspaceOverrides.get(requestOptions.workspaceId), + } + : { ...requestOptions, overrides: firstServeOverrides }; + } // Same cold-workspace gap for project trust: a revocation landing while a // stream's pre-await trusted snapshot is still in flight has no recorded // options to repair, so overlay the newest trust the manager has seen. From 908adb52ab737bbc1e861c64ea19c849628c4582 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 10:36:12 +0000 Subject: [PATCH 52/63] fix: reject nested plugin:// workflow paths so the executable set matches the consented top-level surface --- .../workflows/workflowScriptResolver.test.ts | 23 +++++++++++++++++++ .../workflows/workflowScriptResolver.ts | 10 ++++++++ 2 files changed, 33 insertions(+) diff --git a/src/node/services/workflows/workflowScriptResolver.test.ts b/src/node/services/workflows/workflowScriptResolver.test.ts index 974e2ee76f2..6c6711f3c68 100644 --- a/src/node/services/workflows/workflowScriptResolver.test.ts +++ b/src/node/services/workflows/workflowScriptResolver.test.ts @@ -445,5 +445,28 @@ describe("resolveWorkflowScript", () => { resolveWorkflowScript({ ...input, scriptPath: "plugin://my-plugin/release.ts" }) ).rejects.toThrow(".js"); }); + + test("rejects nested plugin workflow paths the consent surface never names", async () => { + // The install preview and update capability comparison fingerprint + // TOP-LEVEL workflows/*.js only: a resolvable nested file would be an + // executable an upstream can add without re-consent. + using tempDir = new TestTempDir("workflow-script-plugin-nested"); + const container = path.join(tempDir.path, ".mux", "plugins"); + await writePluginWithWorkflow(container, "my-plugin", "release.js"); + const nestedDir = path.join(container, "my-plugin", "workflows", "private"); + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(path.join(nestedDir, "hidden.js"), "({})", "utf8"); + const input = { + runtime: new LocalRuntime(tempDir.path), + workspacePath: tempDir.path, + projectTrusted: true, + includeAgentPlugins: true, + roots: pluginRoots(tempDir, container), + }; + + await expect( + resolveWorkflowScript({ ...input, scriptPath: "plugin://my-plugin/private/hidden.js" }) + ).rejects.toThrow("top-level"); + }); }); }); diff --git a/src/node/services/workflows/workflowScriptResolver.ts b/src/node/services/workflows/workflowScriptResolver.ts index 6879e38d60b..00299191281 100644 --- a/src/node/services/workflows/workflowScriptResolver.ts +++ b/src/node/services/workflows/workflowScriptResolver.ts @@ -349,6 +349,16 @@ function parsePluginWorkflowScriptPath(scriptPath: string): { } const relativePath = normalizeRelativeWorkflowPath(remainder.slice(slashIndex + 1), "plugin"); + // Consent alignment: the install preview and the update capability + // comparison fingerprint TOP-LEVEL workflows/*.js only (mirroring the + // runtime lister), so nested paths must not be executable either — an + // attacker-controlled upstream could otherwise add a nested workflow the + // consent surface never names and later direct workflow_run at it. + if (relativePath.includes("/")) { + throw new Error( + `plugin:// workflow scripts must be top-level files in the plugin's workflows directory: ${relativePath}` + ); + } return { pluginName, relativePath }; } From 4b6d9be3dd32112fed00578f96a97a0996198294 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 10:50:27 +0000 Subject: [PATCH 53/63] fix: address Codex review round 69 (bounded same-handle hook source read, release failed update trash for reclamation, fail update on epoch publication failure) --- .../services/agentPlugins/hookService.test.ts | 27 ++++++- src/node/services/agentPlugins/hookService.ts | 36 ++++++++- .../agentPlugins/installService.test.ts | 80 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 29 +++++-- 4 files changed, 163 insertions(+), 9 deletions(-) diff --git a/src/node/services/agentPlugins/hookService.test.ts b/src/node/services/agentPlugins/hookService.test.ts index 19cd3d0626c..9ad6d5b54ec 100644 --- a/src/node/services/agentPlugins/hookService.test.ts +++ b/src/node/services/agentPlugins/hookService.test.ts @@ -28,7 +28,7 @@ import { } from "@/node/services/replay/replayFixture"; import { collectFullHistory, replayVerifySession } from "@/node/services/replay/replayVerify"; import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; -import { AgentPluginHookService } from "./hookService"; +import { AgentPluginHookService, readHookSourceCapped } from "./hookService"; import { AGENT_PLUGIN_SCHEMA_ID_1_0_0 } from "./manifest"; const WORKSPACE_ID = "plugin-hooks-test"; @@ -147,6 +147,31 @@ function blockedError(ctx: ToolExecuteContext): string { return result.error as string; } +describe("readHookSourceCapped", () => { + test("enforces the hook size ceiling at the read itself", async () => { + // Discovery's stat-based cap and the consuming read are separated by an + // update-sized TOCTOU window (a managed update can promote a replacement + // hooks.js between them), so the ceiling must hold at read time: an + // oversized source is refused, a normal one round-trips byte-exact. + using tmp = new DisposableTempDir("hook-source-cap"); + const smallPath = path.join(tmp.path, "hooks.js"); + await fs.writeFile(smallPath, "({ 'tool.execute.before': () => undefined })", "utf8"); + expect(await readHookSourceCapped(smallPath)).toBe( + "({ 'tool.execute.before': () => undefined })" + ); + + const bigPath = path.join(tmp.path, "big-hooks.js"); + await fs.writeFile(bigPath, `// ${"x".repeat(2 * 1024 * 1024)}\n({})`, "utf8"); + // try/catch instead of .rejects: bun:test types trip await-thenable. + try { + await readHookSourceCapped(bigPath); + expect.unreachable("an oversized hooks.js must be refused at read time"); + } catch (error) { + expect((error as Error).message).toContain("too large"); + } + }); +}); + describe("AgentPluginHookService", () => { test("tool.execute.before blocks .env reads with a clear model-visible error", async () => { const harness = await createHarness(); diff --git a/src/node/services/agentPlugins/hookService.ts b/src/node/services/agentPlugins/hookService.ts index 1ce4a4e2b29..c0c0b00c65b 100644 --- a/src/node/services/agentPlugins/hookService.ts +++ b/src/node/services/agentPlugins/hookService.ts @@ -48,6 +48,7 @@ import { ensurePathContained } from "@/node/services/tools/skillFileUtils"; import { computeAgentPluginContainers, discoverAgentPlugins, + MAX_PLUGIN_HOOK_SOURCE_BYTES, type AgentPluginContainer, type AgentPluginInfo, } from "./discovery"; @@ -326,7 +327,7 @@ export class AgentPluginHookService { } let source: string; try { - source = await fsPromises.readFile(plugin.hooksPath, "utf8"); + source = await readHookSourceCapped(plugin.hooksPath); } catch (error) { log.warn(`Agent plugin hooks: failed to read ${plugin.hooksPath}; skipping`, { error }); continue; @@ -624,5 +625,38 @@ function annotateResult(result: unknown, annotation: string, pluginName: string) return result; } +/** + * Read hooks.js through one file handle with a same-handle size check. + * Discovery's stat-based cap and this read are separated by an update-sized + * TOCTOU window — a managed update can promote a replacement tree between + * them, making the canonical path name a file discovery never measured — so + * the ceiling must be enforced at the read itself. Reading exactly the + * fstat-reported byte count through the same handle also bounds the read if + * the file grows mid-read. Exported for tests. + */ +export async function readHookSourceCapped(hooksPath: string): Promise { + const handle = await fsPromises.open(hooksPath, "r"); + try { + const stat = await handle.stat(); + if (stat.size > MAX_PLUGIN_HOOK_SOURCE_BYTES) { + throw new Error( + `hooks.js is too large (${stat.size} bytes; max ${MAX_PLUGIN_HOOK_SOURCE_BYTES})` + ); + } + const buffer = Buffer.alloc(Number(stat.size)); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset); + if (bytesRead === 0) { + break; // Truncated since the fstat: return what exists. + } + offset += bytesRead; + } + return buffer.subarray(0, offset).toString("utf8"); + } finally { + await handle.close(); + } +} + /** Process-wide singleton (mirrors eventSpine/sandboxHostService). */ export const agentPluginHookService = new AgentPluginHookService(); diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 1208140cf75..50d7a6962cb 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1705,6 +1705,86 @@ describe("AgentPluginInstallService", () => { ); }); + test("a failed trash deletion after update releases the dir for staging reclamation", async () => { + // The journal is consumed before the replaced tree is deleted, so a + // failed deletion (e.g. a file locked on Windows) has no other cleaner + // than stale-staging reclamation — the transaction must release the dir + // from the active set or every later purge in this process skips it, + // accumulating a full checkout per failed update deletion. + 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"); + + const internals = service as unknown as { + removeDir: (dir: string) => Promise; + activeStagingPaths: Set; + }; + const realRemoveDir = internals.removeDir.bind(internals); + const removeSpy = spyOn(internals, "removeDir").mockImplementation((dir: string) => + path.basename(dir).startsWith("trash-") + ? Promise.reject(new Error("EBUSY: resource busy")) + : realRemoveDir(dir) + ); + try { + await service.update({ name: "demo-plugin" }); + } finally { + removeSpy.mockRestore(); + } + const pinnedTrash = [...internals.activeStagingPaths].filter((entry) => + path.basename(entry).startsWith("trash-") + ); + expect(pinnedTrash).toEqual([]); + }); + + test("a missing-tree update fails when the mutation epoch cannot be published", async () => { + // With no old tree there is no journal, so the explicit epoch bump is + // the ONLY cross-process publication of the swap. Swallowing its failure + // would let a sibling process keep serving a server from the removed + // tree indefinitely; the update must fail (old lockedSha retained) and + // the retry self-heals through the journaled swap path. + // + // A bare plugin: against the missing tree's EMPTY capability surface, + // any capability would be an addition and block before the promote. + await fsPromises.rm(path.join(remoteDir, "skills"), { recursive: true, force: true }); + await fsPromises.rm(path.join(remoteDir, "mcp.json"), { force: true }); + await commitAll(remoteDir, "bare v1"); + const preview = await service.preview({ input: remoteDir }); + const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + await fsPromises.rm(path.join(pluginsDir(), "demo-plugin"), { recursive: true, force: true }); + const manifestPath = path.join(remoteDir, "plugin.json"); + const manifest = JSON.parse(await fsPromises.readFile(manifestPath, "utf8")) as Record< + string, + unknown + >; + manifest.version = "2.0.0"; + await fsPromises.writeFile(manifestPath, JSON.stringify(manifest)); + const newSha = await commitAll(remoteDir, "bare v2 while tree missing"); + + const realRename = fsPromises.rename; + const renameSpy = spyOn(fsPromises, "rename").mockImplementation((from, to) => { + if (path.basename(String(to)) === "mutation-epoch") { + return Promise.reject(new Error("EACCES: permission denied")); + } + return realRename(from, to); + }); + try { + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow( + /publishing the change/ + ); + } finally { + renameSpy.mockRestore(); + } + // Old lockedSha retained: the update badge stays visible for the retry. + const entries = (await registry()) as Array<{ lockedSha: string }>; + expect(entries[0].lockedSha).toBe(entry.lockedSha); + + // Retry: the promoted tree now exists, so the journaled swap path runs + // and republishes the epoch through the journal lifecycle. + const updated = await service.update({ name: "demo-plugin" }); + expect(updated.lockedSha).toBe(newSha); + }); + test("repositories shipping the reserved recovery marker name are rejected", async () => { // install/update write a nonce file at this path pre-rename; a repo // shipping it would get that file clobbered then deleted, making the diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 410059d0870..119b48f0417 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -3565,6 +3565,12 @@ export class AgentPluginInstallService { // Best-effort: the trash dir sits under the staging root, where // stale-dir reclamation cleans up leftovers. await this.removeDir(trashDir).catch((error: unknown) => { + // The update transaction no longer owns this dir (journal + // consumed above) — release it from the active set or every + // later purgeStaleStaging in this process skips the very dir + // this catch defers to reclamation, accumulating a full + // checkout per failed deletion until restart. + this.activeStagingPaths.delete(trashDir); log.warn( "Failed to delete replaced plugin tree; leaving it for staging reclamation", { trashDir, error: getErrorMessage(error) } @@ -3577,13 +3583,22 @@ export class AgentPluginInstallService { // above bumped the mutation epoch. Bump it explicitly: sibling // processes' MCPServerManagers key their cross-process plugin // invalidation off this token, and a server launched before the - // old tree went missing may still be running there. - await bumpContainerMutationEpoch(this.stagingRoot).catch((error: unknown) => { - log.warn("Failed to bump the plugin mutation epoch after update", { - name: entry.name, - error: getErrorMessage(error), - }); - }); + // old tree went missing may still be running there. This bump is + // the ONLY cross-process publication on this path (no journal, no + // consume), so a failure must FAIL the update rather than commit + // success — a sibling would otherwise observe neither a journal + // nor a token change and keep serving the removed tree's server + // indefinitely. The registry still holds the old lockedSha, the + // update badge stays visible, and the retry runs the journaled + // swap path (the promoted tree now exists), whose journal + // lifecycle republishes the epoch or retains a durable record. + try { + await bumpContainerMutationEpoch(this.stagingRoot); + } catch (error) { + throw new Error( + `The new plugin tree is in place, but publishing the change to other Mux processes failed (${getErrorMessage(error)}). Retry the update.` + ); + } } const updated: AgentPluginInstallEntry = { From 35b8e89ef919939cc78435a6a485440aeb846938 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 11:08:29 +0000 Subject: [PATCH 54/63] fix: address Codex review round 70 (pre-read size checks for agents/skills, release unlink retry, update recovery provenance reconciliation, CLI registration sanitization, fresh-install override sweep) --- .../agentPlugins/installService.test.ts | 159 +++++++++++++++-- .../services/agentPlugins/installService.ts | 164 +++++++++++++++++- src/node/services/agentSession.ts | 30 ++++ src/node/services/workspaceService.ts | 37 ++++ src/node/utils/main/crossProcessLock.test.ts | 28 ++- src/node/utils/main/crossProcessLock.ts | 24 ++- 6 files changed, 413 insertions(+), 29 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 50d7a6962cb..9d401999d6c 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1223,8 +1223,13 @@ describe("AgentPluginInstallService", () => { } finally { metadataSpy.mockRestore(); } - // The manager cache received the PRUNED overrides (disk first, then memory). - expect(applied).toEqual([{ workspaceId: "ws-1", overrides: { enabledServers: [] } }]); + // The manager cache received the PRUNED overrides (disk first, then + // memory) — once from install's fresh-instance hygiene sweep, once from + // uninstall's prune. + expect(applied).toEqual([ + { workspaceId: "ws-1", overrides: { enabledServers: [] } }, + { workspaceId: "ws-1", overrides: { enabledServers: [] } }, + ]); }); test("update recovery leaves a user-placed tree at the vacated path alone", async () => { @@ -1705,6 +1710,104 @@ describe("AgentPluginInstallService", () => { ); }); + test("recovery reconciles registry provenance for a promoted-but-unrecorded update", async () => { + // Crash window: the update promoted the (capability-reviewed) new tree + // but died before the registry write — the registry still claims the old + // commit, and a forced branch move back to that SHA would even hide the + // update badge. Recovery must commit the journal-recorded SHA and the + // promoted tree's manifest summary before consuming the journal. + const preview = await service.preview({ input: remoteDir }); + const installed = await service.install({ + source: preview.source, + expectedSha: preview.lockedSha, + }); + await writePluginFixture(remoteDir, { version: "9.9.9" }); + const newSha = await commitAll(remoteDir, "v9.9.9"); + + // Simulate the crash: journal deletion AND registry write both fail, so + // update() throws after the promote with the journal (and marker) intact. + const internals = service as unknown as { + consumeJournalFile: (journalPath: string) => Promise; + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; + }; + const consumeSpy = spyOn(internals, "consumeJournalFile").mockImplementationOnce(() => + Promise.reject(new Error("EBUSY: resource busy")) + ); + const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(() => + Promise.reject(new Error("ENOSPC: no space left on device")) + ); + try { + await expect(service.update({ name: "demo-plugin" })).rejects.toThrow(/ENOSPC/); + } finally { + consumeSpy.mockRestore(); + writeSpy.mockRestore(); + } + const staleDoc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: Array<{ lockedSha: string }>; + }; + expect(staleDoc.plugins[0].lockedSha).toBe(installed.lockedSha); + + // Section open runs recovery: provenance reconciled, journal consumed. + await service.list(); + const doc = JSON.parse(await fsPromises.readFile(registryFile(), "utf8")) as { + plugins: Array<{ lockedSha: string; manifest?: { version?: string } }>; + }; + expect(doc.plugins[0].lockedSha).toBe(newSha); + expect(doc.plugins[0].manifest?.version).toBe("9.9.9"); + expect(await pathExists(path.join(stagingDir(), "update-demo-plugin.json"))).toBe(false); + }); + + test("a fresh install sweeps stale overrides left by a manually removed unmanaged plugin", async () => { + // An unmanaged plugin the user enabled and then deleted BY HAND was + // never uninstalled, so no tombstone exists — yet a same-name managed + // install reuses the lexical path-derived instance ID, and the stale + // workspace enable would start its default-disabled server without + // fresh consent. Install must sweep the prefix first, and fail closed + // when the sweep cannot run. + const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + const pruned: Array<{ workspaceId: string; prefix: string }> = []; + const overridesStub = { + prunePluginOverrideKeys: (workspaceId: string, prefix: string) => { + pruned.push({ workspaceId, prefix }); + 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, + }); + expect(pruned).toContainEqual({ workspaceId: "ws-1", prefix: `plugin:${instanceId}:` }); + } finally { + metadataSpy.mockRestore(); + } + + // Fail closed: with workspaces unenumerable, a fresh install of another + // name must refuse rather than risk inheriting stale consent. + await serviceWithOverrides.uninstall({ name: "demo-plugin", deletePluginData: false }); + const failingSpy = spyOn(config, "getAllWorkspaceMetadata").mockImplementation(() => + Promise.reject(new Error("config store unavailable")) + ); + try { + const preview2 = await serviceWithOverrides.preview({ input: remoteDir }); + await expect( + serviceWithOverrides.install({ source: preview2.source, expectedSha: preview2.lockedSha }) + ).rejects.toThrow(/Could not verify/); + } finally { + failingSpy.mockRestore(); + } + }); + test("a failed trash deletion after update releases the dir for staging reclamation", async () => { // The journal is consumed before the replaced tree is deleted, so a // failed deletion (e.g. a file locked on Windows) has no other cleaner @@ -2116,11 +2219,13 @@ describe("AgentPluginInstallService", () => { 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"); + // Three recycles: install's fresh-instance hygiene sweep (no tree yet), + // pre-swap (old tree, servers stopped while their files still exist), + // and post-promote (new content behind the stable path). + expect(observedVersions.length).toBe(3); + expect(observedVersions[0]).toBeNull(); + expect(observedVersions[1]).toBe("1.0.0"); + expect(observedVersions[2]).toBe("2.0.0"); }); test("uninstall completes even when deleting the staged tree fails", async () => { @@ -2182,7 +2287,10 @@ describe("AgentPluginInstallService", () => { await serviceWithMcp.install({ source: preview.source, expectedSha: preview.lockedSha }); await serviceWithMcp.uninstall({ name: "demo-plugin", deletePluginData: false }); - expect(treeStates).toEqual([true, false]); + // Leading false: install's fresh-instance hygiene sweep runs before any + // tree exists. Uninstall then stops pre-rename (tree present) and again + // post-removal (tree gone). + expect(treeStates).toEqual([false, true, false]); }); test("uninstall aborts intact when pruning enumeration fails (pre-commit)", async () => { @@ -2240,8 +2348,9 @@ describe("AgentPluginInstallService", () => { const serverKey = `plugin:${instanceId}:echo`; // One local workspace with the plugin's server enabled; its override - // file is temporarily unwritable. - let overridesBroken = true; + // file becomes temporarily unwritable AFTER the install (install's own + // hygiene sweep must succeed for the install to complete). + let overridesBroken = false; let storedOverrides: Record = { enabledServers: [serverKey] }; const overridesStub = { prunePluginOverrideKeys: (_id: string, keyPrefix: string) => { @@ -2272,6 +2381,10 @@ describe("AgentPluginInstallService", () => { source: preview.source, expectedSha: preview.lockedSha, }); + // The workspace enabled the server while installed; the override file + // then becomes unwritable before the uninstall. + overridesBroken = true; + storedOverrides = { enabledServers: [serverKey] }; await serviceWithOverrides.uninstall({ name: "demo-plugin", deletePluginData: false }); // Uninstall committed, but the failed prune left a persisted tombstone. @@ -2310,8 +2423,11 @@ describe("AgentPluginInstallService", () => { test("tombstone survives even when both the prune and the shrink write fail", async () => { const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); + // Healthy during install (its hygiene sweep must pass); broken afterwards. + let overridesBroken = false; const overridesStub = { - prunePluginOverrideKeys: () => Promise.reject(new Error("checkout unavailable")), + prunePluginOverrideKeys: () => + overridesBroken ? Promise.reject(new Error("checkout unavailable")) : Promise.resolve(), }; const serviceWithOverrides = new AgentPluginInstallService(config, { isEnabled: () => true, @@ -2329,6 +2445,7 @@ describe("AgentPluginInstallService", () => { source: preview.source, expectedSha: preview.lockedSha, }); + overridesBroken = true; // The commit write (which must carry the pessimistic tombstone) runs // for real; the post-prune shrink write fails. @@ -3367,11 +3484,12 @@ describe("AgentPluginInstallService", () => { } // No partial state: the promoted dir was rolled back and any server - // started from the briefly-visible tree was invalidated. + // started from the briefly-visible tree was invalidated. (The leading + // stop is install's fresh-instance hygiene sweep.) expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); expect(await stagingLeftovers()).toEqual([]); const instanceId = computePluginInstanceId(path.join(pluginsDir(), "demo-plugin")); - expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`]); + expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`, `plugin:${instanceId}:`]); // The retry of the same consented install succeeds. const entry = await serviceWithMcp.install({ @@ -3423,11 +3541,16 @@ describe("AgentPluginInstallService", () => { removeSpy.mockRestore(); } const instanceId = computePluginInstanceId(targetPath); - // Two stops: one so the retry can delete what a running server locked, - // and one AFTER the retry/quarantine — a startup that began after the - // first stop can have discovered the still-visible tree and would - // otherwise publish after it disappears. - expect(stoppedPrefixes).toEqual([`plugin:${instanceId}:`, `plugin:${instanceId}:`]); + // Three stops: install's fresh-instance hygiene sweep, then one so the + // retry can delete what a running server locked, and one AFTER the + // retry/quarantine — a startup that began after the first rollback stop + // can have discovered the still-visible tree and would otherwise publish + // after it disappears. + expect(stoppedPrefixes).toEqual([ + `plugin:${instanceId}:`, + `plugin:${instanceId}:`, + `plugin:${instanceId}:`, + ]); expect(await registry()).toEqual([]); // The tree left the discovery container via the quarantine rename (the // staged-dir mock only rejects the container path), so no unmanaged diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 119b48f0417..c8fdb139c83 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -37,6 +37,7 @@ import { parseSkillMarkdown } from "@/node/services/agentSkills/parseSkillMarkdo import { log } from "@/node/services/log"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import { MAX_FILE_SIZE } from "@/node/services/tools/fileCommon"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; @@ -1531,6 +1532,13 @@ export class AgentPluginInstallService { try { const filePath = path.join(dir, entry.name); const stat = await fsPromises.stat(filePath); + // Size-check BEFORE reading (mirroring runtime discovery): the parse + // below applies the same cap, but only after the whole file has been + // read and UTF-8-decoded — an untrusted repo pouring its checkout + // quota into one agents/*.md could stall the main process first. + if (stat.size > MAX_FILE_SIZE) { + continue; + } const content = await fsPromises.readFile(filePath, "utf8"); // Throws on malformed frontmatter or oversized content — exactly the // definitions runtime discovery would skip. @@ -1619,6 +1627,15 @@ export class AgentPluginInstallService { warnings.push(`skills/${dirName}: invalid skill directory name; it will not load`); continue; } + // Size-check BEFORE reading (mirroring runtime discovery): the parse + // enforces the same cap, but only after the full read+decode — see the + // identical guard in collectAgentFiles. + if (stat.size > MAX_FILE_SIZE) { + warnings.push( + `skills/${dirName}: SKILL.md is too large (${stat.size} bytes; max ${MAX_FILE_SIZE}); it will not load` + ); + continue; + } try { const content = await fsPromises.readFile(containedSkillPath, "utf8"); const parsed = parseSkillMarkdown({ @@ -1842,6 +1859,7 @@ export class AgentPluginInstallService { const name = plugin.name; await this.assertNoCollision(name); await this.assertNoPendingOverridePrune(name); + await this.assertNoResidualInstanceState(name); // A retained uninstall journal means a previous uninstall of this // name still has unfinished recovery (staged assets to restore or // delete). Block the reinstall until it resolves: with a fresh @@ -2306,12 +2324,40 @@ export class AgentPluginInstallService { .readFile(path.join(targetPath, PROMOTION_MARKER_FILE), "utf-8") .catch(() => undefined); if (journalNonce !== undefined && treeNonce === journalNonce) { - // OUR promoted replacement landed and only the cleanup was lost: - // finish it. Journal FIRST, and ENFORCED (mirrors the update path): - // removing the marker while the journal survives would strand a - // markerless target that the next recovery misclassifies as a user - // replacement, deadlocking updates — so a failed journal deletion - // must abort cleanup and keep the marker as the tree's identity. + // OUR promoted replacement landed. Two states reach here: the live + // update failed only its journal DELETION (registry already updated) + // — or the process died between the promote and the registry write, + // leaving the registry claiming the OLD commit while the (already + // capability-reviewed) replacement runs. The recorded newSha + // distinguishes them: reconcile provenance FIRST, before the journal + // is consumed, so lockedSha/manifest can never silently keep + // describing a tree that no longer exists (a forced branch move back + // to the stale SHA would even hide the update badge). + const newSha = this.journalStringField(journal.doc, "newSha"); + if (newSha !== undefined && isFullCommitSha(newSha)) { + try { + const reconciled = await this.reconcilePromotedUpdateProvenance( + name, + targetPath, + newSha + ); + if (!reconciled) { + return false; // Keep the journal; retried next reconciliation. + } + } catch (error) { + log.warn("Failed to reconcile registry provenance for a promoted update", { + name, + error: getErrorMessage(error), + }); + return false; + } + } + // Finish the lost cleanup. Journal FIRST, and ENFORCED (mirrors the + // update path): removing the marker while the journal survives would + // strand a markerless target that the next recovery misclassifies as + // a user replacement, deadlocking updates — so a failed journal + // deletion must abort cleanup and keep the marker as the tree's + // identity. try { await this.consumeJournalFile(journalPath); } catch (error) { @@ -2372,6 +2418,76 @@ export class AgentPluginInstallService { return true; } + /** + * Commit a promoted-but-unrecorded update's provenance into the registry: + * lockedSha from the journal, version/description re-read from the promoted + * tree's own plugin.json (the tree IS the source of truth for its manifest; + * its .git was stripped, so the SHA must ride in the journal). No-ops when + * the entry is gone (registry-only uninstall raced recovery) or already + * records the new SHA (the live update only lost its journal deletion). + * Returns false when the promoted tree's manifest cannot be read — the + * journal must survive so a transient read failure is retried rather than + * committing a SHA whose manifest summary silently stays stale. + */ + private async reconcilePromotedUpdateProvenance( + name: string, + targetPath: string, + newSha: string + ): Promise { + const { envelope, rawEntries } = await this.readRegistryDocument("strict"); + const rawEntry = rawEntries.find((entry) => this.rawEntryName(entry) === name); + if (rawEntry === undefined) { + return true; + } + const currentSha = (rawEntry as Record).lockedSha; + if (currentSha === newSha) { + return true; + } + const { plugin } = await discoverAgentPluginAt({ pluginDir: targetPath, scope: "global" }); + if (!plugin) { + log.warn("Promoted update tree has an unreadable manifest; keeping the journal", { name }); + return false; + } + await this.writeRegistry( + envelope, + rawEntries.map((entry) => { + if (this.rawEntryName(entry) !== name) { + return entry; + } + const rawRecord = entry as Record; + const rawManifest = + typeof rawRecord.manifest === "object" && + rawRecord.manifest !== null && + !Array.isArray(rawRecord.manifest) + ? (rawRecord.manifest as Record) + : {}; + // Same raw-patch rules as the update path: only the fields this + // reconciliation owns are replaced; unknown keys pass through. + const { + version: _staleVersion, + description: _staleDescription, + ...preservedManifest + } = rawManifest; + return { + ...rawRecord, + lockedSha: newSha, + updatedAt: new Date().toISOString(), + manifest: { + ...preservedManifest, + ...(plugin.manifest.version !== undefined ? { version: plugin.manifest.version } : {}), + ...(plugin.manifest.description !== undefined + ? { description: plugin.manifest.description } + : {}), + }, + }; + }) + ); + log.info( + `Reconciled registry provenance for '${name}' after an interrupted update (→ ${newSha.slice(0, 12)})` + ); + return true; + } + /** * Uninstall crashed between staging the plugin's assets into trash and the * registry commit (registry still owns the plugin → restore everything), or @@ -3275,6 +3391,37 @@ export class AgentPluginInstallService { } } + /** + * Fresh-install hygiene for consent state left by a PREVIOUS occupant of + * this plugin's path. The instance ID derives from the lexical target + * path, so an UNMANAGED plugin the user enabled and then deleted by hand + * (never uninstalled — no tombstone exists) leaves workspace overrides, + * and possibly cached server instances, that a same-name managed install + * would silently inherit: its default-disabled servers would start + * without fresh enablement. Sweep the prefix across live workspaces and + * retire cached instances BEFORE anything is promoted; failures block the + * install (over-blocking is safe, silent activation is not). Runs under + * install's exclusive mutation lock. + */ + private async assertNoResidualInstanceState(name: string): Promise { + const serverKeyPrefix = buildPluginServerKey(this.instanceIdFor(name), ""); + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + const { enumerated, failed } = await this.retryPrune({ + prefix: serverKeyPrefix, + workspaceIds: [], + }); + if (!enumerated) { + throw new Error( + `Could not verify that no workspace holds stale MCP overrides for '${name}' (workspace enumeration failed). Retry in a moment.` + ); + } + if (failed.length > 0) { + throw new Error( + `Stale workspace MCP overrides for '${name}' could not be cleaned up (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 @@ -3501,11 +3648,16 @@ export class AgentPluginInstallService { // self-heal because assertNoCapabilityIncrease treats the missing // tree as an empty surface and rejects the staged capabilities as // additions. reconcileJournals restores the old tree on recovery. + // newSha lets crash recovery reconcile registry provenance: a crash + // between the promote below and the registry write leaves the + // (consented) replacement live while the registry still claims the + // old commit — recovery must be able to commit the recorded SHA. await this.writeJournalFile(updateJournalPath, { name: entry.name, trashDir, nonce: updateNonce, stagedAt: Date.now(), + newSha: resolved.sha, }); try { await this.renameIntoStaging(targetPath, trashDir); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 2359feab9db..c973ddcfe68 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -488,6 +488,19 @@ interface AgentSessionOptions { workspaceGoalService?: WorkspaceGoalService; /** When true, skip terminating background processes on dispose/compaction (for bench/CI) */ keepBackgroundProcesses?: boolean; + /** + * Registration-time Agent Plugin override sanitization for workspaces this + * session registers itself (ensureMetadata: CLI `xum run`/`xum workflow` in + * a directory with no existing metadata). Wired to + * WorkspaceService.sanitizeCliRegisteredWorkspace, which rolls the config + * write back on failure; ensureMetadata must then abort without announcing + * the workspace. Returns an error string or undefined on success. + */ + sanitizeCliWorkspaceRegistration?: (args: { + workspaceId: string; + workspacePath: string; + runtimeConfig: RuntimeConfig | undefined; + }) => Promise; /** Called when compaction completes (e.g., to clear idle compaction pending state) */ onCompactionComplete?: (metadata: CompactionCompletionMetadata) => void; /** Called with the terminal outcome of an idle compaction (persisted success / post-stream failure) */ @@ -530,6 +543,7 @@ export class AgentSession { private readonly backgroundProcessManager: BackgroundProcessManager; private readonly workspaceGoalService?: WorkspaceGoalService; private readonly keepBackgroundProcesses: boolean; + private readonly sanitizeCliWorkspaceRegistration?: AgentSessionOptions["sanitizeCliWorkspaceRegistration"]; private readonly onPostCompactionStateChange?: () => void; private readonly emitter = new EventEmitter(); private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = @@ -749,6 +763,7 @@ export class AgentSession { backgroundProcessManager, workspaceGoalService, keepBackgroundProcesses, + sanitizeCliWorkspaceRegistration, onCompactionComplete, onIdleCompactionOutcome, onPostCompactionStateChange, @@ -767,6 +782,7 @@ export class AgentSession { this.backgroundProcessManager = backgroundProcessManager; this.workspaceGoalService = workspaceGoalService; this.keepBackgroundProcesses = keepBackgroundProcesses ?? false; + this.sanitizeCliWorkspaceRegistration = sanitizeCliWorkspaceRegistration; this.onPostCompactionStateChange = onPostCompactionStateChange; this.compactionHandler = new CompactionHandler({ @@ -2615,6 +2631,20 @@ export class AgentSession { // Write metadata directly to config.json (single source of truth) await this.config.addWorkspace(derivedProjectPath, metadata); + // This registration path bypasses WorkspaceService.create/fork and the + // task-materialization flows, so it must run the same pre-announcement + // Agent Plugin override sanitization: a preserved checkout can carry a + // stale canonical `plugin:` enable from a since-removed workspace, which + // would start a same-name reinstall's default-disabled server on the + // first CLI send. The callback rolls back the config write on failure. + const sanitizeError = await this.sanitizeCliWorkspaceRegistration?.({ + workspaceId: this.workspaceId, + workspacePath: normalizedWorkspacePath, + runtimeConfig: metadata.runtimeConfig, + }); + if (sanitizeError !== undefined) { + throw new Error(`Failed to register workspace: ${sanitizeError}`); + } this.emitMetadata(metadata); } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 8e95e6bc866..c665ecf4251 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2786,6 +2786,37 @@ export class WorkspaceService extends EventEmitter { return this.sanitizeStalePluginOverridesForNewWorkspace(workspaceId, workspacePath); } + /** + * Registration-time sanitization for workspaces that AgentSession registers + * directly (CLI `xum run` / `xum workflow` in a directory without existing + * metadata) — a path that bypasses WorkspaceService.create/fork and the + * task-materialization flows. Called between the config write and the + * metadata announcement; on failure the registration is rolled back so a + * preserved checkout's stale `plugin:` enables can never activate a + * same-name reinstall's default-disabled server on the first CLI send. + * Returns an error string (the caller must abort) or undefined on success. + */ + async sanitizeCliRegisteredWorkspace( + workspaceId: string, + workspacePath: string, + runtimeConfig: RuntimeConfig | undefined + ): Promise { + this.pendingPluginSanitizations.add(workspaceId); + try { + const sanitizeError = await this.sanitizeMaterializedTaskWorkspace( + workspaceId, + workspacePath, + runtimeConfig + ); + if (sanitizeError !== undefined) { + await this.rollbackUnsanitizedWorkspaceRegistration(workspaceId); + } + return sanitizeError; + } finally { + this.pendingPluginSanitizations.delete(workspaceId); + } + } + /** * Registration-time sanitization of stale Agent Plugin override keys. * @@ -3712,6 +3743,12 @@ export class WorkspaceService extends EventEmitter { initStateManager: this.initStateManager, workspaceGoalService: this.workspaceGoalService, backgroundProcessManager: this.backgroundProcessManager, + sanitizeCliWorkspaceRegistration: (args) => + this.sanitizeCliRegisteredWorkspace( + args.workspaceId, + args.workspacePath, + args.runtimeConfig + ), onCompactionComplete: (metadata) => { this.schedulePostCompactionMetadataRefresh(workspaceId); // Compaction marks a long session with accumulated learnings: harvest diff --git a/src/node/utils/main/crossProcessLock.test.ts b/src/node/utils/main/crossProcessLock.test.ts index d01ccc25985..5c72f01a566 100644 --- a/src/node/utils/main/crossProcessLock.test.ts +++ b/src/node/utils/main/crossProcessLock.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { 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"; @@ -128,6 +128,32 @@ describe("acquireCrossProcessLock", () => { await release2(); }, 10_000); + test("release retries a transiently failing unlink instead of leaving a live-looking holder", async () => { + // A swallowed unlink failure (Windows file lock, antivirus scan) leaves + // the holder record behind with renewal stopped: the live PID reads as a + // valid owner until the lease ages out, blocking siblings for minutes. + const lockPath = await tempLockPath(); + const release = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + const realRm = fsPromises.rm; + let failures = 0; + const rmSpy = spyOn(fsPromises, "rm").mockImplementation((target, options) => { + if (String(target) === lockPath && failures < 2) { + failures += 1; + return Promise.reject(new Error("EBUSY: resource busy")); + } + return realRm(target, options); + }); + try { + await release(); + } finally { + rmSpy.mockRestore(); + } + expect(failures).toBe(2); + expect(await pathExists(lockPath)).toBe(false); + const release2 = await acquireCrossProcessLock({ lockPath, ...baseOptions }); + await release2(); + }); + test("release during active renewals leaves the lock immediately reacquirable", async () => { // stopRenewal joins the in-flight renewal tick: releasing mid-tick must // never let a resumed renewal re-stamp a fresh lease onto the released diff --git a/src/node/utils/main/crossProcessLock.ts b/src/node/utils/main/crossProcessLock.ts index 00e9775dbab..c9d3996254a 100644 --- a/src/node/utils/main/crossProcessLock.ts +++ b/src/node/utils/main/crossProcessLock.ts @@ -370,23 +370,39 @@ export async function acquireCrossProcessLock( for (let attempt = 0; attempt < 40; attempt++) { const mutex = await enterLockMutex(lockPath); if (mutex !== undefined) { + let released = false; try { const current = await readLockHolder(lockPath); // Last-instant mutex re-check, mirroring reclamation: a stall // longer than the mutex ceiling between the token read and the rm // lets a competitor break our mutex, reclaim, and publish a // successor — deleting it here would hand out double ownership. - if (current?.token === token && (await mutex.owns())) { - await fsPromises.rm(lockPath, { force: true }).catch(() => undefined); + if (current?.token !== token) { + released = true; // Not ours anymore: nothing to delete. + } else if (await mutex.owns()) { + // A transiently failing unlink (Windows file lock, antivirus + // scan) must RETRY, not silently succeed: renewal already + // stopped, so a holder record left behind reads as a live + // owner until its lease ages out — blocking every sibling for + // up to the stale ceiling even though the transaction is done. + try { + await fsPromises.rm(lockPath, { force: true }); + released = true; + } catch { + // Retry on the next attempt. + } } } finally { await mutex.exit(); } - return; + if (released) { + return; + } } await sleepWithJitter(25); } - // Mutex never freed: leave the file; it is reclaimable as stale/dead. + // Mutex never freed (or the unlink kept failing): leave the file; it + // is reclaimable once its no-longer-renewed lease ages out. }; }; From aac228084434b30b9a34984055c0c40d7527d6af Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 11:13:27 +0000 Subject: [PATCH 55/63] fix: refuse symlinked hooks.js at the consuming read (Codex round 71 P2) readHookSourceCapped's open follows symlinks: a managed update replacing a consented regular hooks.js with an absolute link outside the plugin root (allowed by staged validation, read as a capability removal by discovery) could have the stale canonical pathname follow the new link and evaluate an outside file as hook code. Require the opened object to be the regular file a non-following lstat sees at the path (dev/ino identity, bigint stats). --- .../services/agentPlugins/hookService.test.ts | 20 ++++++++++++++++++ src/node/services/agentPlugins/hookService.ts | 21 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/node/services/agentPlugins/hookService.test.ts b/src/node/services/agentPlugins/hookService.test.ts index 9ad6d5b54ec..29fa2053188 100644 --- a/src/node/services/agentPlugins/hookService.test.ts +++ b/src/node/services/agentPlugins/hookService.test.ts @@ -170,6 +170,26 @@ describe("readHookSourceCapped", () => { expect((error as Error).message).toContain("too large"); } }); + + test("refuses to follow a hooks.js that is a symlink at read time", async () => { + // A managed update can replace a consented regular hooks.js with an + // absolute symlink to a file OUTSIDE the plugin root (staged validation + // only rejects links into the managed container; the escaping link reads + // as a capability removal). Discovery that measured the old regular file + // must not have its consuming open follow the replacement link and + // evaluate the outside file as hook code. + using tmp = new DisposableTempDir("hook-source-symlink"); + const outside = path.join(tmp.path, "outside.js"); + await fs.writeFile(outside, "({ 'tool.execute.before': () => undefined })", "utf8"); + const linkPath = path.join(tmp.path, "hooks.js"); + await fs.symlink(outside, linkPath); + try { + await readHookSourceCapped(linkPath); + expect.unreachable("a symlinked hooks.js must be refused at read time"); + } catch (error) { + expect((error as Error).message).toContain("regular file"); + } + }); }); describe("AgentPluginHookService", () => { diff --git a/src/node/services/agentPlugins/hookService.ts b/src/node/services/agentPlugins/hookService.ts index c0c0b00c65b..d34eff99adf 100644 --- a/src/node/services/agentPlugins/hookService.ts +++ b/src/node/services/agentPlugins/hookService.ts @@ -637,8 +637,25 @@ function annotateResult(result: unknown, annotation: string, pluginName: string) export async function readHookSourceCapped(hooksPath: string): Promise { const handle = await fsPromises.open(hooksPath, "r"); try { - const stat = await handle.stat(); - if (stat.size > MAX_PLUGIN_HOOK_SOURCE_BYTES) { + const stat = await handle.stat({ bigint: true }); + // The open above FOLLOWS symlinks. A managed update can replace a + // consented regular hooks.js with an absolute symlink to an existing + // file outside the plugin root — staged validation only rejects links + // into the managed container, and discovery treats the escaping link as + // a capability REMOVAL — so if discovery measured the old regular file + // and the swap landed before this open, the canonical pathname now names + // that link and the outside file would be evaluated as hook code. + // Require the opened object to BE the regular file a non-following lstat + // sees at this path: a symlink fails isFile(), and any concurrent + // replacement fails the dev/ino identity match (over-blocking is safe — + // the read is skipped and the next discovery re-measures). + const linkStat = await fsPromises.lstat(hooksPath, { bigint: true }); + if (!linkStat.isFile() || linkStat.dev !== stat.dev || linkStat.ino !== stat.ino) { + throw new Error( + `hooks.js is not the regular file discovery measured (symlinked or replaced): ${hooksPath}` + ); + } + if (stat.size > BigInt(MAX_PLUGIN_HOOK_SOURCE_BYTES)) { throw new Error( `hooks.js is too large (${stat.size} bytes; max ${MAX_PLUGIN_HOOK_SOURCE_BYTES})` ); From 3bca011dd8ad5d3ad6277c2a72e456b8c3909615 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 11:28:08 +0000 Subject: [PATCH 56/63] fix: wire direct CLI sessions into plugin override sanitization (Codex round 72 P2) xum run and xum workflow construct AgentSession directly, bypassing WorkspaceService.createSession, so the optional sanitizeCliWorkspaceRegistration callback was silently skipped and a preserved checkout could carry a stale plugin: MCP enable into a same-name reinstall on the first CLI send. Pass the sanitizer at both headless entry points. --- src/cli/run.ts | 9 +++++++++ src/cli/workflow.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/cli/run.ts b/src/cli/run.ts index ed988abed15..344007d91a0 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -657,6 +657,15 @@ async function main(): Promise { backgroundProcessManager, workspaceGoalService, keepBackgroundProcesses, + // Direct CLI registration bypasses WorkspaceService.create, so a + // preserved checkout could carry a stale `plugin:` MCP override into a + // same-name reinstall on the first send; sanitize before announcing. + sanitizeCliWorkspaceRegistration: (args) => + workspaceService.sanitizeCliRegisteredWorkspace( + args.workspaceId, + args.workspacePath, + args.runtimeConfig + ), }); // Register with WorkspaceService so TaskService operations that target the parent // workspace (e.g. resumeStream after sub-agent completion) reuse this session diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index dac0282ab03..efbeef1a4dc 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -373,6 +373,9 @@ async function createWorkflowContext(options: { ); services.aiService.setCoderOauthService(coderOauthService); + // Const capture: `services` is a `let`, so the deferred sanitize closure + // below would lose TypeScript's definite-assignment narrowing. + const workspaceServiceForSanitize = services.workspaceService; session = new AgentSession({ workspaceId, config, @@ -381,6 +384,15 @@ async function createWorkflowContext(options: { initStateManager: services.initStateManager, backgroundProcessManager: services.backgroundProcessManager, workspaceGoalService: services.workspaceGoalService, + // Direct CLI registration bypasses WorkspaceService.create, so a + // preserved checkout could carry a stale `plugin:` MCP override into a + // same-name reinstall on the first send; sanitize before announcing. + sanitizeCliWorkspaceRegistration: (args) => + workspaceServiceForSanitize.sanitizeCliRegisteredWorkspace( + args.workspaceId, + args.workspacePath, + args.runtimeConfig + ), }); services.workspaceService.registerSession(workspaceId, session); From d59e33e4b267600a578e9ab90567d561cd8281cf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 11:33:15 +0000 Subject: [PATCH 57/63] fix: recheck hook path containment against the plugin root at read time (Codex round 73 P2) The leaf lstat identity check cannot catch a replacement symlink at an ancestor component (lib/hooks.js where lib becomes a link to an outside dir): lstat follows ancestor links, so the outside file reports as regular with matching dev/ino. readHookSourceCapped now also rechecks full symlink-resolved containment against the plugin root after the open; a promotion is a single swap, so a link the open followed is still present at the recheck. --- .../services/agentPlugins/hookService.test.ts | 31 +++++++++++++++++-- src/node/services/agentPlugins/hookService.ts | 31 ++++++++++++------- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/node/services/agentPlugins/hookService.test.ts b/src/node/services/agentPlugins/hookService.test.ts index 29fa2053188..77466560aac 100644 --- a/src/node/services/agentPlugins/hookService.test.ts +++ b/src/node/services/agentPlugins/hookService.test.ts @@ -156,7 +156,7 @@ describe("readHookSourceCapped", () => { using tmp = new DisposableTempDir("hook-source-cap"); const smallPath = path.join(tmp.path, "hooks.js"); await fs.writeFile(smallPath, "({ 'tool.execute.before': () => undefined })", "utf8"); - expect(await readHookSourceCapped(smallPath)).toBe( + expect(await readHookSourceCapped(smallPath, tmp.path)).toBe( "({ 'tool.execute.before': () => undefined })" ); @@ -164,7 +164,7 @@ describe("readHookSourceCapped", () => { await fs.writeFile(bigPath, `// ${"x".repeat(2 * 1024 * 1024)}\n({})`, "utf8"); // try/catch instead of .rejects: bun:test types trip await-thenable. try { - await readHookSourceCapped(bigPath); + await readHookSourceCapped(bigPath, tmp.path); expect.unreachable("an oversized hooks.js must be refused at read time"); } catch (error) { expect((error as Error).message).toContain("too large"); @@ -184,12 +184,37 @@ describe("readHookSourceCapped", () => { const linkPath = path.join(tmp.path, "hooks.js"); await fs.symlink(outside, linkPath); try { - await readHookSourceCapped(linkPath); + await readHookSourceCapped(linkPath, tmp.path); expect.unreachable("a symlinked hooks.js must be refused at read time"); } catch (error) { expect((error as Error).message).toContain("regular file"); } }); + + test("refuses a hooks.js reached through a symlinked ancestor directory", async () => { + // The leaf lstat check cannot catch a replacement symlink at an ANCESTOR + // component (lib/hooks.js where `lib` becomes a link to an outside dir): + // lstat follows ancestor links and reports the outside file as regular + // with matching dev/ino. The post-open containment recheck must reject + // the resolved path escaping the plugin root. + using tmp = new DisposableTempDir("hook-source-ancestor-symlink"); + const outsideDir = path.join(tmp.path, "outside"); + await fs.mkdir(outsideDir); + await fs.writeFile( + path.join(outsideDir, "hooks.js"), + "({ 'tool.execute.before': () => undefined })", + "utf8" + ); + const root = path.join(tmp.path, "plugin-root"); + await fs.mkdir(root); + await fs.symlink(outsideDir, path.join(root, "lib")); + try { + await readHookSourceCapped(path.join(root, "lib", "hooks.js"), root); + expect.unreachable("an ancestor-symlinked hooks.js must be refused at read time"); + } catch (error) { + expect((error as Error).message).toContain("outside containment root"); + } + }); }); describe("AgentPluginHookService", () => { diff --git a/src/node/services/agentPlugins/hookService.ts b/src/node/services/agentPlugins/hookService.ts index d34eff99adf..bb3133b4538 100644 --- a/src/node/services/agentPlugins/hookService.ts +++ b/src/node/services/agentPlugins/hookService.ts @@ -327,7 +327,7 @@ export class AgentPluginHookService { } let source: string; try { - source = await readHookSourceCapped(plugin.hooksPath); + source = await readHookSourceCapped(plugin.hooksPath, plugin.rootPath); } catch (error) { log.warn(`Agent plugin hooks: failed to read ${plugin.hooksPath}; skipping`, { error }); continue; @@ -634,21 +634,28 @@ function annotateResult(result: unknown, annotation: string, pluginName: string) * fstat-reported byte count through the same handle also bounds the read if * the file grows mid-read. Exported for tests. */ -export async function readHookSourceCapped(hooksPath: string): Promise { +export async function readHookSourceCapped(hooksPath: string, pluginRoot: string): Promise { const handle = await fsPromises.open(hooksPath, "r"); try { const stat = await handle.stat({ bigint: true }); // The open above FOLLOWS symlinks. A managed update can replace a - // consented regular hooks.js with an absolute symlink to an existing - // file outside the plugin root — staged validation only rejects links - // into the managed container, and discovery treats the escaping link as - // a capability REMOVAL — so if discovery measured the old regular file - // and the swap landed before this open, the canonical pathname now names - // that link and the outside file would be evaluated as hook code. - // Require the opened object to BE the regular file a non-following lstat - // sees at this path: a symlink fails isFile(), and any concurrent - // replacement fails the dev/ino identity match (over-blocking is safe — - // the read is skipped and the next discovery re-measures). + // consented regular hooks.js — or any ANCESTOR directory on its path + // (lib/hooks.js with `lib` becoming a link) — with an absolute symlink to + // existing content outside the plugin root: staged validation only + // rejects links into the managed container, and discovery treats the + // escaping link as a capability REMOVAL. If discovery measured the old + // tree and the swap landed before this open, the canonical pathname now + // traverses that link and outside content would be evaluated as hook + // code. Two post-open checks close this (a promotion is a single swap, + // so a link followed by the open is still present here): + // 1. Containment recheck: the fully-resolved path must stay inside the + // plugin root, catching replacement links at any ancestor component. + // 2. Leaf identity: the opened object must BE the regular file a + // non-following lstat sees at this path (a symlink fails isFile(); + // a concurrent replacement fails the dev/ino match). + // Over-blocking is safe — the read is skipped and re-measured next + // discovery. + await ensurePathContained(pluginRoot, hooksPath); const linkStat = await fsPromises.lstat(hooksPath, { bigint: true }); if (!linkStat.isFile() || linkStat.dev !== stat.dev || linkStat.ino !== stat.ino) { throw new Error( From f98dd52dc53f1f2110ac140b04dd1430f23336c7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 11:43:22 +0000 Subject: [PATCH 58/63] fix: default-wire WorkspaceMcpOverridesService in createCoreServices (Codex round 74 P2) Both CLI entry points omit workspaceMcpOverridesService, and only the desktop ServiceContainer called setWorkspaceMcpOverridesService, so the CLI registration sanitizer early-returned on the undefined service and a stale checkout-local plugin: enable survived headless registration. createCoreServices now default-constructs the overrides service, passes it to AIService and the plugin-invalidation override reader, and wires WorkspaceService pruning for every process that can register workspaces; the desktop's now-redundant explicit set call is removed. --- src/node/services/coreServices.ts | 26 ++++++++++++++++---------- src/node/services/serviceContainer.ts | 8 ++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 929767c30b4..5e8e019188b 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -32,7 +32,7 @@ import { secretsToRecord } from "@/common/types/secrets"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { WorkspaceService } from "@/node/services/workspaceService"; import { TaskService } from "@/node/services/taskService"; -import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { PolicyService } from "@/node/services/policyService"; import type { TelemetryService } from "@/node/services/telemetryService"; import type { ExperimentsService } from "@/node/services/experimentsService"; @@ -106,6 +106,12 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.goalServiceOptions ); + // Default-construct when the caller (CLI) does not pass one: workspace MCP + // override reads AND registration-time plugin-override sanitization must + // work in every process that can register workspaces, not just desktop. + const workspaceMcpOverridesService = + opts.workspaceMcpOverridesService ?? new WorkspaceMcpOverridesService(config); + const aiService = new AIService( config, historyService, @@ -113,7 +119,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { providerService, backgroundProcessManager, sessionUsageService, - opts.workspaceMcpOverridesService, + workspaceMcpOverridesService, opts.policyService, opts.telemetryService, opts.devToolsService, @@ -150,7 +156,6 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS) === true, }), }); - const overridesServiceForInvalidation = opts.workspaceMcpOverridesService; const mcpServerManager = new MCPServerManager( mcpConfigService, { @@ -162,13 +167,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { pluginInvalidation: { keyPrefix: PLUGIN_SERVER_KEY_PREFIX, readToken: () => readMutationEpochToken(path.join(mcpConfig.rootDir, STAGING_DIR_NAME)), - ...(overridesServiceForInvalidation !== undefined - ? { - readWorkspaceOverrides: async (workspaceId: string) => - (await overridesServiceForInvalidation.getOverridesForWorkspace(workspaceId)) - .overrides, - } - : {}), + readWorkspaceOverrides: async (workspaceId: string) => + (await workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId)).overrides, }, ...opts.mcpServerManagerOptions, }, @@ -213,6 +213,12 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceService.setDevToolsService(opts.devToolsService); } workspaceService.setMCPServerManager(mcpServerManager); + // Plugin override keys must be pruned from a workspace's override files when + // registering a preserved checkout (desktop create/fork, task + // materialization, and headless `xum run`/`xum workflow` registration) and + // during removal: a stale enable in a kept .xum/mcp.local.jsonc could + // otherwise re-activate a same-name reinstall's server. + workspaceService.setWorkspaceMcpOverridesService(workspaceMcpOverridesService); workspaceService.setWorkspaceGoalService(workspaceGoalService); workspaceGoalService.setOnActivityChange((workspaceId, snapshot) => { workspaceService.emitWorkspaceActivity(workspaceId, snapshot); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index a07ca7b3717..e64810d1d86 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -330,12 +330,8 @@ export class ServiceContainer { // Wire terminal service to workspace service for cleanup on removal this.workspaceService.setTerminalService(this.terminalService); this.workspaceService.setDesktopSessionManager(this.desktopSessionManager); - // Plugin override keys must be pruned from a workspace's override files - // during removal: a removed workspace is invisible to the Agent Plugin - // uninstaller's pruning, but a preserved LocalRuntime checkout keeps - // .mux/mcp.local.jsonc, and a stale enable there could re-activate a - // same-name reinstall's server when the directory is re-registered. - this.workspaceService.setWorkspaceMcpOverridesService(this.workspaceMcpOverridesService); + // Plugin-override pruning is wired inside createCoreServices (shared with + // headless CLI registration), using this.workspaceMcpOverridesService. // Editor service for opening workspaces in code editors this.editorService = new EditorService(config); this.updateService = new UpdateService(this.config); From 7b9ad869881c5762658390b14facc22ba712dc55 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 11:56:48 +0000 Subject: [PATCH 59/63] fix: revalidate mcp.json reads and retire live hooks on plugin mutation (Codex round 75 P1+P2) P1: loadPluginMcpServers read mcp.json via a plain readFile on the discovery-time canonical path; an update promoting a replacement symlink between discovery and the read could parse outside config and spawn its command. The read now goes through readPluginFileWithinRootCapped, a shared bounded-handle helper (extracted from readHookSourceCapped) that rechecks fully-resolved containment against the plugin root and leaf dev/ino identity after the open. P2: an uninstall/update committed mid-stream left already-registered hook middleware live until the workspace's next send. Registrations now capture the managed-container mutation epoch at ensure time and every hook invocation revalidates it before the hook sees any input; a stale epoch (bumped at commit by this process or a sibling) tears the registration down. The unchanged-fingerprint ensure path refreshes the token since it re-measured content from disk. --- src/node/services/agentPlugins/discovery.ts | 60 ++++++++ .../services/agentPlugins/hookService.test.ts | 41 ++++++ src/node/services/agentPlugins/hookService.ts | 134 +++++++++++------- .../services/agentPlugins/mcpConfig.test.ts | 24 ++++ src/node/services/agentPlugins/mcpConfig.ts | 34 +++-- 5 files changed, 232 insertions(+), 61 deletions(-) diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 5852433b9fc..88a6c4ee161 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -58,6 +58,66 @@ export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024; */ export const MAX_PLUGIN_HOOK_SOURCE_BYTES = 1024 * 1024; +/** + * Read a consented plugin component file (hooks.js, mcp.json) through a + * bounded handle, revalidating containment and identity AFTER the open. + * + * Discovery's measurement and the consuming read are separated by an + * update-sized TOCTOU window: a managed update can promote a replacement + * tree where the canonical path — or any ANCESTOR component on it — became + * an absolute symlink to existing content outside the plugin root. Staged + * validation only rejects links into the managed container, and discovery + * treats the escaping link as a capability REMOVAL, so the swapped tree is + * permitted; a stale canonical path would then read (and execute/parse) + * attacker-chosen outside content. Two post-open checks close this (a + * promotion is a single swap, so a link the open followed is still present + * here): + * 1. Containment recheck: the fully-resolved path must stay inside the + * plugin root, catching replacement links at any ancestor component. + * 2. Leaf identity: the opened object must BE the regular file a + * non-following lstat sees at this path (a symlink fails isFile(); a + * concurrent replacement fails the dev/ino match). + * The size ceiling is enforced on the same handle (fstat), and the read is + * bounded to the fstat-reported byte count so a file growing mid-read stays + * capped. Over-blocking is safe — the caller skips and re-measures on the + * next discovery. + */ +export async function readPluginFileWithinRootCapped(args: { + filePath: string; + pluginRoot: string; + maxBytes: number; + /** Component name used in error messages (e.g. "hooks.js"). */ + label: string; +}): Promise { + const { filePath, pluginRoot, maxBytes, label } = args; + const handle = await fsPromises.open(filePath, "r"); + try { + const stat = await handle.stat({ bigint: true }); + await ensurePathContained(pluginRoot, filePath); + const linkStat = await fsPromises.lstat(filePath, { bigint: true }); + if (!linkStat.isFile() || linkStat.dev !== stat.dev || linkStat.ino !== stat.ino) { + throw new Error( + `${label} is not the regular file discovery measured (symlinked or replaced): ${filePath}` + ); + } + if (stat.size > BigInt(maxBytes)) { + throw new Error(`${label} is too large (${stat.size} bytes; max ${maxBytes})`); + } + const buffer = Buffer.alloc(Number(stat.size)); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset); + if (bytesRead === 0) { + break; // Truncated since the fstat: return what exists. + } + offset += bytesRead; + } + return buffer.subarray(0, offset).toString("utf8"); + } finally { + await handle.close(); + } +} + export interface AgentPluginContainer { /** Absolute host path of the container directory (e.g. `/.xum/plugins`). */ path: string; diff --git a/src/node/services/agentPlugins/hookService.test.ts b/src/node/services/agentPlugins/hookService.test.ts index 77466560aac..2c77a7c2546 100644 --- a/src/node/services/agentPlugins/hookService.test.ts +++ b/src/node/services/agentPlugins/hookService.test.ts @@ -29,6 +29,7 @@ import { import { collectFullHistory, replayVerifySession } from "@/node/services/replay/replayVerify"; import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; import { AgentPluginHookService, readHookSourceCapped } from "./hookService"; +import { bumpContainerMutationEpoch, STAGING_DIR_NAME } from "./journals"; import { AGENT_PLUGIN_SCHEMA_ID_1_0_0 } from "./manifest"; const WORKSPACE_ID = "plugin-hooks-test"; @@ -640,3 +641,43 @@ describe("replay determinism with hooks active", () => { await harness.service.disposeWorkspace(REPLAY_FIXTURE_WORKSPACE_ID); }); }); + +describe("epoch-based hook retirement", () => { + test("a managed plugin mutation (epoch bump) retires live hooks before the next invocation", async () => { + // An uninstall/update/install committed in ANY process bumps the managed + // container's mutation epoch. Already-registered hooks must stop seeing + // tool traffic at the next invocation — not survive until the workspace's + // next send calls ensureWorkspaceHooks — or a mid-stream uninstall would + // keep exposing tool args/results to (and accepting denials/rewrites + // from) the removed plugin. + const harness = await createHarness(); + await writeHookPlugin( + harness.container, + "epoch-demo", + "({ 'tool.execute.before': () => ({ deny: 'blocked by hook' }) })", + { tools: ["dangerous_tool"] } + ); + await harness.ensure(); + + // Live: the hook denies the granted tool. + const before = makeToolCtx("dangerous_tool", { a: 1 }); + await runTool(harness.spine, before); + expect(blockedError(before)).toContain("blocked by hook"); + + const stagingRoot = path.join(harness.tmp.path, STAGING_DIR_NAME); + await fs.mkdir(stagingRoot, { recursive: true }); + await bumpContainerMutationEpoch(stagingRoot); + + // Stale epoch: the registration is torn down before the hook sees input. + const after = makeToolCtx("dangerous_tool", { a: 2 }); + await runTool(harness.spine, after); + expect(after.blocked).toBeUndefined(); + expect(after.executed).toBe(true); + + // The next ensure re-registers from disk with the fresh epoch. + await harness.ensure(); + const reensured = makeToolCtx("dangerous_tool", { a: 3 }); + await runTool(harness.spine, reensured); + expect(blockedError(reensured)).toContain("blocked by hook"); + }); +}); diff --git a/src/node/services/agentPlugins/hookService.ts b/src/node/services/agentPlugins/hookService.ts index bb3133b4538..fb34d799598 100644 --- a/src/node/services/agentPlugins/hookService.ts +++ b/src/node/services/agentPlugins/hookService.ts @@ -24,7 +24,7 @@ import assert from "node:assert"; import * as crypto from "node:crypto"; -import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; import { isBridgeToolGranted, type CapabilityGrants } from "@/common/types/capabilityGrants"; import { getErrorMessage } from "@/common/utils/errors"; import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; @@ -49,9 +49,11 @@ import { computeAgentPluginContainers, discoverAgentPlugins, MAX_PLUGIN_HOOK_SOURCE_BYTES, + readPluginFileWithinRootCapped, type AgentPluginContainer, type AgentPluginInfo, } from "./discovery"; +import { readMutationEpochToken, STAGING_DIR_NAME } from "./journals"; import { buildHookInvokeScript, buildHookLoadScript, @@ -112,6 +114,16 @@ interface WorkspaceHookRegistration { fingerprint: string; unregisters: Array<() => void>; states: LoadedPluginHookState[]; + /** + * Managed-container mutation epoch captured when this registration's + * plugins were (re)discovered. Every hook invocation revalidates it so an + * install/update/uninstall committed in ANY process (the epoch bump is the + * commit signal, same as the MCP manager's cross-process retire sweep) + * stops already-registered hooks from seeing tool traffic mid-stream, + * instead of them surviving until the workspace's next send. + */ + epochStagingRoot: string; + epochToken: string | undefined; /** * Fingerprint lines of discovered candidates that failed to load. Retried * on later sends WITHOUT tearing down healthy siblings: a full-teardown @@ -177,6 +189,12 @@ export class AgentPluginHookService { return; } + // Capture the epoch BEFORE discovery: a mutation landing between this + // read and registration makes the stored token stale, so the dispatch + // check retires the registration (over-blocking; safe direction). + const epochStagingRoot = path.join(args.xumHome, STAGING_DIR_NAME); + const epochToken = await readMutationEpochToken(epochStagingRoot); + const discovered = await this.discoverHookPlugins(args); const fingerprintLines = discovered.map( (candidate) => @@ -187,6 +205,12 @@ export class AgentPluginHookService { const existing = this.registrations.get(args.workspaceId); if (existing?.fingerprint === fingerprint) { + // This ensure re-measured everything from disk and found identical + // content, so the registration is current as of the token captured + // above — refresh it (an uninstall+identical-reinstall cycle would + // otherwise leave a permanently stale token that retires the + // registration on every dispatch). + existing.epochToken = epochToken; // Unchanged configuration. Retry ONLY previously-failed candidates so // healthy siblings keep their persistent mounts (cross-turn guest state) // instead of being torn down and re-initialized on every send while one @@ -231,7 +255,14 @@ export class AgentPluginHookService { unregisters.push(...loaded.unregisters); } - this.registrations.set(args.workspaceId, { fingerprint, unregisters, states, failedLines }); + this.registrations.set(args.workspaceId, { + fingerprint, + unregisters, + states, + failedLines, + epochStagingRoot, + epochToken, + }); } /** @@ -399,7 +430,7 @@ export class AgentPluginHookService { if (!isBridgeToolGranted(state.grants, ctx.toolName)) { return; } - const output = await this.invokeHook(state, "tool.execute.before", { + const output = await this.invokeHook(workspaceId, state, "tool.execute.before", { toolName: ctx.toolName, args: ctx.args, workspaceId, @@ -466,7 +497,7 @@ export class AgentPluginHookService { } catch { input.resultOmitted = true; } - const output = await this.invokeHook(state, "tool.execute.after", input); + const output = await this.invokeHook(workspaceId, state, "tool.execute.after", input); const annotation = output?.annotation; if (typeof annotation === "string" && annotation.length > 0) { ctx.result = annotateResult(ctx.result, annotation, state.pluginName); @@ -481,7 +512,7 @@ export class AgentPluginHookService { if (ctx.workspaceId !== args.workspaceId) { return; } - const output = await this.invokeHook(state, "request.assemble", { + const output = await this.invokeHook(args.workspaceId, state, "request.assemble", { workspaceId: args.workspaceId, modelString: ctx.modelString, }); @@ -526,11 +557,45 @@ export class AgentPluginHookService { // --- invocation --- + /** + * Revalidate the registration's managed-container mutation epoch before a + * hook sees any input. A committed install/update/uninstall (this process + * or a sibling — the epoch file is the cross-process commit signal) must + * stop already-registered hooks from observing tool args/results or + * injecting rewrites/denials/context for the rest of the current stream; + * without this they would survive until the workspace's next send calls + * ensureWorkspaceHooks. On staleness the registration is torn down and the + * invocation is refused; the next send re-discovers from disk. + */ + private async retireIfEpochStale(workspaceId: string): Promise { + const registration = this.registrations.get(workspaceId); + if (!registration) { + // Torn down since the middleware fired (teardown unregisters first, + // but an in-flight dispatch may already hold the callback). + return true; + } + const current = await readMutationEpochToken(registration.epochStagingRoot); + if (current === registration.epochToken) { + return false; + } + await using _guard = await this.lockFor(workspaceId).acquire(); + // Recheck under the lock: a concurrent ensure may have already replaced + // the registration with a freshly-discovered (current) one. + if (this.registrations.get(workspaceId) === registration) { + log.info( + `Agent plugin hooks: managed plugin mutation detected; retiring workspace ${workspaceId} hooks until the next send` + ); + await this.teardownLocked(workspaceId); + } + return true; + } + /** * Invoke one hook in the plugin's mount. Returns null on any failure * (crash, timeout, malformed output) — log, skip, continue. */ private async invokeHook( + workspaceId: string, state: LoadedPluginHookState, hookName: PluginHookPoint, input: Record @@ -538,6 +603,14 @@ export class AgentPluginHookService { if (state.disposed) { return null; } + if (await this.retireIfEpochStale(workspaceId)) { + return null; + } + if (state.disposed) { + // Re-check: the epoch validation above may have awaited; a concurrent + // teardown could have disposed this state in the meantime. + return null; + } try { const inputJson = JSON.stringify(input); assert(typeof inputJson === "string", "hook input must be JSON-serializable"); @@ -635,51 +708,12 @@ function annotateResult(result: unknown, annotation: string, pluginName: string) * the file grows mid-read. Exported for tests. */ export async function readHookSourceCapped(hooksPath: string, pluginRoot: string): Promise { - const handle = await fsPromises.open(hooksPath, "r"); - try { - const stat = await handle.stat({ bigint: true }); - // The open above FOLLOWS symlinks. A managed update can replace a - // consented regular hooks.js — or any ANCESTOR directory on its path - // (lib/hooks.js with `lib` becoming a link) — with an absolute symlink to - // existing content outside the plugin root: staged validation only - // rejects links into the managed container, and discovery treats the - // escaping link as a capability REMOVAL. If discovery measured the old - // tree and the swap landed before this open, the canonical pathname now - // traverses that link and outside content would be evaluated as hook - // code. Two post-open checks close this (a promotion is a single swap, - // so a link followed by the open is still present here): - // 1. Containment recheck: the fully-resolved path must stay inside the - // plugin root, catching replacement links at any ancestor component. - // 2. Leaf identity: the opened object must BE the regular file a - // non-following lstat sees at this path (a symlink fails isFile(); - // a concurrent replacement fails the dev/ino match). - // Over-blocking is safe — the read is skipped and re-measured next - // discovery. - await ensurePathContained(pluginRoot, hooksPath); - const linkStat = await fsPromises.lstat(hooksPath, { bigint: true }); - if (!linkStat.isFile() || linkStat.dev !== stat.dev || linkStat.ino !== stat.ino) { - throw new Error( - `hooks.js is not the regular file discovery measured (symlinked or replaced): ${hooksPath}` - ); - } - if (stat.size > BigInt(MAX_PLUGIN_HOOK_SOURCE_BYTES)) { - throw new Error( - `hooks.js is too large (${stat.size} bytes; max ${MAX_PLUGIN_HOOK_SOURCE_BYTES})` - ); - } - const buffer = Buffer.alloc(Number(stat.size)); - let offset = 0; - while (offset < buffer.length) { - const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset); - if (bytesRead === 0) { - break; // Truncated since the fstat: return what exists. - } - offset += bytesRead; - } - return buffer.subarray(0, offset).toString("utf8"); - } finally { - await handle.close(); - } + return readPluginFileWithinRootCapped({ + filePath: hooksPath, + pluginRoot, + maxBytes: MAX_PLUGIN_HOOK_SOURCE_BYTES, + label: "hooks.js", + }); } /** Process-wide singleton (mirrors eventSpine/sandboxHostService). */ diff --git a/src/node/services/agentPlugins/mcpConfig.test.ts b/src/node/services/agentPlugins/mcpConfig.test.ts index 6dc7ee29fdd..7a488bfb1c5 100644 --- a/src/node/services/agentPlugins/mcpConfig.test.ts +++ b/src/node/services/agentPlugins/mcpConfig.test.ts @@ -194,6 +194,30 @@ describe("loadPluginMcpServers", () => { expect(diagnostics[0].message).toContain("too large"); }); + test("disables MCP when mcp.json is a symlink escaping the plugin root", async () => { + // A managed update can replace a consented regular mcp.json with an + // absolute symlink to attacker-chosen content outside the plugin root + // (staged validation only rejects links into the managed container). The + // consuming read must refuse to follow it: this document defines + // spawnable commands, so following the link would let outside config be + // parsed and its command spawned during the promotion race. + using tmp = new DisposableTempDir("plugin-mcp"); + const outside = path.join(tmp.path, "outside-mcp.json"); + await fs.writeFile( + outside, + JSON.stringify(mcpDoc({ evil: { type: "stdio", command: "sh" } })), + "utf8" + ); + const plugin = await makePlugin(tmp.path, "symlinked", mcpDoc({})); + await fs.rm(plugin.mcpConfigPath!); + await fs.symlink(outside, plugin.mcpConfigPath!); + const { servers, diagnostics } = await loadPluginMcpServers(plugin, { xumHome: tmp.path }); + expect(servers).toEqual({}); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("error"); + expect(diagnostics[0].message).toContain("outside containment root"); + }); + test("an empty mcpServers object is valid", async () => { using tmp = new DisposableTempDir("plugin-mcp"); const plugin = await makePlugin(tmp.path, "empty", mcpDoc({})); diff --git a/src/node/services/agentPlugins/mcpConfig.ts b/src/node/services/agentPlugins/mcpConfig.ts index 13927fe94ba..8b06b9c9b8a 100644 --- a/src/node/services/agentPlugins/mcpConfig.ts +++ b/src/node/services/agentPlugins/mcpConfig.ts @@ -16,6 +16,7 @@ import { computeAgentPluginContainers, discoverAgentPlugins, MAX_PLUGIN_MANIFEST_BYTES, + readPluginFileWithinRootCapped, } from "./discovery"; import { expandPluginPlaceholders, type PluginPlaceholderValues } from "./expansion"; @@ -556,19 +557,30 @@ export async function loadPluginMcpServers( return { servers: {}, diagnostics }; }; + // Size-cap before parsing: server summaries built from this document + // (command lines, env assignments, URLs) reach the install consent + // preview's IPC/render path, so one unbounded string must not be able to + // freeze the app before consent (same ceiling as plugin.json). The bounded + // handle read also revalidates containment + file identity AFTER the open: + // this document defines spawnable commands, so a replacement symlink + // promoted between discovery and this read (see + // readPluginFileWithinRootCapped) would otherwise let an outside file be + // parsed as server config and its command spawned before the mutation-epoch + // post-check can retire the stale result. + let text: string; + try { + text = await readPluginFileWithinRootCapped({ + filePath: plugin.mcpConfigPath, + pluginRoot: plugin.rootPath, + maxBytes: MAX_PLUGIN_MANIFEST_BYTES, + label: "mcp.json", + }); + } catch (error) { + return disableMcp(getErrorMessage(error)); + } let raw: unknown; try { - // Size-cap before parsing: server summaries built from this document - // (command lines, env assignments, URLs) reach the install consent - // preview's IPC/render path, so one unbounded string must not be able to - // freeze the app before consent (same ceiling as plugin.json). - const stat = await fsPromises.stat(plugin.mcpConfigPath); - if (stat.size > MAX_PLUGIN_MANIFEST_BYTES) { - return disableMcp( - `mcp.json is too large (${stat.size} bytes; max ${MAX_PLUGIN_MANIFEST_BYTES})` - ); - } - raw = JSON.parse(await fsPromises.readFile(plugin.mcpConfigPath, "utf8")) as unknown; + raw = JSON.parse(text) as unknown; } catch (error) { // §7.2.2 rule 2: invalid JSON disables MCP for this plugin only. return disableMcp(`mcp.json is not valid JSON: ${getErrorMessage(error)}`); From 7110a0d7e6d9f9a2969cb6022f34078642af9197 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 12:02:52 +0000 Subject: [PATCH 60/63] fix: scan the persistent config for live siblings in CLI sanitization (Codex round 76 P2) xum run/workflow register on an ephemeral temp config whose project entries carry no workspace records, so the live-sibling scan saw only the new ephemeral workspace and pruned canonical plugin: enables that a live desktop workspace on the same checkout still owns from the shared .xum/mcp.local.jsonc. sanitizeCliRegisteredWorkspace now accepts an optional persistent sibling config (passed as realConfig by both CLI entry points) that the scan consults in addition to the service's own config. --- src/cli/run.ts | 6 +- src/cli/workflow.ts | 6 +- src/node/services/workspaceService.test.ts | 48 ++++++++++++++- src/node/services/workspaceService.ts | 69 +++++++++++++++------- 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 344007d91a0..2047b81e30d 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -660,11 +660,15 @@ async function main(): Promise { // Direct CLI registration bypasses WorkspaceService.create, so a // preserved checkout could carry a stale `plugin:` MCP override into a // same-name reinstall on the first send; sanitize before announcing. + // realConfig: the ephemeral CLI config has no workspace records, so the + // live-sibling scan needs the persistent one or it would prune enables a + // desktop workspace on this checkout still owns. sanitizeCliWorkspaceRegistration: (args) => workspaceService.sanitizeCliRegisteredWorkspace( args.workspaceId, args.workspacePath, - args.runtimeConfig + args.runtimeConfig, + realConfig ), }); // Register with WorkspaceService so TaskService operations that target the parent diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index efbeef1a4dc..ef32495c5d2 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -387,11 +387,15 @@ async function createWorkflowContext(options: { // Direct CLI registration bypasses WorkspaceService.create, so a // preserved checkout could carry a stale `plugin:` MCP override into a // same-name reinstall on the first send; sanitize before announcing. + // realConfig: the ephemeral CLI config has no workspace records, so the + // live-sibling scan needs the persistent one or it would prune enables a + // desktop workspace on this checkout still owns. sanitizeCliWorkspaceRegistration: (args) => workspaceServiceForSanitize.sanitizeCliRegisteredWorkspace( args.workspaceId, args.workspacePath, - args.runtimeConfig + args.runtimeConfig, + realConfig ), }); services.workspaceService.registerSession(workspaceId, session); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 1bce2b5f79c..479925cc45c 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -8674,7 +8674,8 @@ describe("WorkspaceService registration-time plugin override sanitization", () = interface SanitizeAccess { sanitizeStalePluginOverridesForNewWorkspace( workspaceId: string, - workspacePath: string + workspacePath: string, + persistentSiblingConfig?: Pick ): Promise; pendingPluginSanitizations: Set; rollbackUnsanitizedWorkspaceRegistration(workspaceId: string): Promise; @@ -8709,6 +8710,51 @@ describe("WorkspaceService registration-time plugin override sanitization", () = expect(pruned).toEqual(["ws-new:plugin:"]); }); + test("skips sanitization when the live sibling is only visible in the persistent config", async () => { + // xum run / xum workflow register on an EPHEMERAL temp config whose + // project entries carry no workspace records; a desktop workspace live on + // the same checkout exists only in the persistent config. Pruning would + // strip enables that live consent context still owns from the shared + // .xum/mcp.local.jsonc — the persistent sibling must force a skip, while + // a persistent record for a DIFFERENT checkout must not. + const service = makeService([{ id: "ws-new", path: "/tmp/proj" }]); + const pruned: string[] = []; + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + pruned.push(`${workspaceId}:${keyPrefix}`); + return Promise.resolve(); + }, + }); + const persistentWith = (workspacePath: string): Pick => + ({ + loadConfigOrDefault: () => ({ + projects: new Map([ + ["/tmp/proj", { workspaces: [{ id: "ws-desktop", path: workspacePath }] }], + ]), + }), + }) as unknown as Pick; + + const skip = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace( + "ws-new", + "/tmp/proj", + persistentWith("/tmp/proj") + ); + expect(skip).toBeUndefined(); + expect(pruned).toEqual([]); + + const prune = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace( + "ws-new", + "/tmp/proj", + persistentWith("/tmp/other") + ); + expect(prune).toBeUndefined(); + expect(pruned).toEqual(["ws-new:plugin:"]); + }); + test("skips sanitization while a live sibling resolves to the same path", async () => { // Conversation forks of a local workspace share the checkout: the // sibling's consent context is alive, so its enables must survive. diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index c665ecf4251..0abc07b0c70 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2774,7 +2774,8 @@ export class WorkspaceService extends EventEmitter { async sanitizeMaterializedTaskWorkspace( workspaceId: string, workspacePath: string, - runtimeConfig: RuntimeConfig | undefined + runtimeConfig: RuntimeConfig | undefined, + persistentSiblingConfig?: Pick ): Promise { const hostLocal = runtimeConfig === undefined || @@ -2783,7 +2784,11 @@ export class WorkspaceService extends EventEmitter { if (!hostLocal) { return undefined; } - return this.sanitizeStalePluginOverridesForNewWorkspace(workspaceId, workspacePath); + return this.sanitizeStalePluginOverridesForNewWorkspace( + workspaceId, + workspacePath, + persistentSiblingConfig + ); } /** @@ -2799,14 +2804,24 @@ export class WorkspaceService extends EventEmitter { async sanitizeCliRegisteredWorkspace( workspaceId: string, workspacePath: string, - runtimeConfig: RuntimeConfig | undefined + runtimeConfig: RuntimeConfig | undefined, + /** + * CLI sessions run on an EPHEMERAL config whose project entries carry no + * workspace records, so the live-sibling scan below would never see a + * desktop workspace registered for the same checkout — and would prune + * plugin enables that live consent context still owns from the shared + * .xum/mcp.local.jsonc. Callers on a temp config must pass the persistent + * config so those siblings are visible. + */ + persistentSiblingConfig?: Pick ): Promise { this.pendingPluginSanitizations.add(workspaceId); try { const sanitizeError = await this.sanitizeMaterializedTaskWorkspace( workspaceId, workspacePath, - runtimeConfig + runtimeConfig, + persistentSiblingConfig ); if (sanitizeError !== undefined) { await this.rollbackUnsanitizedWorkspaceRegistration(workspaceId); @@ -2842,7 +2857,8 @@ export class WorkspaceService extends EventEmitter { */ private async sanitizeStalePluginOverridesForNewWorkspace( workspaceId: string, - workspacePath: string + workspacePath: string, + persistentSiblingConfig?: Pick ): Promise { if (!this.workspaceMcpOverridesService) { return undefined; @@ -2877,23 +2893,32 @@ export class WorkspaceService extends EventEmitter { runtimeConfig.type === "worktree"; const normalizedPath = stripTrailingSlashes(workspacePath); const canonicalPath = await canonicalize(workspacePath); - const config = this.config.loadConfigOrDefault(); - for (const project of config.projects.values()) { - for (const workspace of project.workspaces) { - if ( - workspace.id === workspaceId || - // Registered-but-unsanitized entries from an overlapping creation - // are not live consent contexts (see pendingPluginSanitizations). - (workspace.id !== undefined && this.pendingPluginSanitizations.has(workspace.id)) || - !isHostLocalConfig(workspace.runtimeConfig) - ) { - continue; - } - if ( - stripTrailingSlashes(workspace.path) === normalizedPath || - (await canonicalize(workspace.path)) === canonicalPath - ) { - return undefined; + // Scan the service's own config AND (when provided) the persistent one: + // ephemeral CLI configs carry no workspace records, so a desktop + // workspace live on the same checkout is only visible in the latter. + const configSources = [ + this.config, + ...(persistentSiblingConfig ? [persistentSiblingConfig] : []), + ]; + for (const configSource of configSources) { + const config = configSource.loadConfigOrDefault(); + for (const project of config.projects.values()) { + for (const workspace of project.workspaces) { + if ( + workspace.id === workspaceId || + // Registered-but-unsanitized entries from an overlapping creation + // are not live consent contexts (see pendingPluginSanitizations). + (workspace.id !== undefined && this.pendingPluginSanitizations.has(workspace.id)) || + !isHostLocalConfig(workspace.runtimeConfig) + ) { + continue; + } + if ( + stripTrailingSlashes(workspace.path) === normalizedPath || + (await canonicalize(workspace.path)) === canonicalPath + ) { + return undefined; + } } } } From d62ab3e382567f342f91a251556a929f03a979b2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 12:13:44 +0000 Subject: [PATCH 61/63] fix: fail closed on unreadable persistent config and bound realpath in sibling scan (Codex round 77 P2s) The persistent sibling source is now read with throwOnError: a malformed or unreadable ~/.xum/config.json previously collapsed to an empty project map, read as 'no live sibling', and pruned enables a live desktop workspace still owns; sanitization now aborts the registration instead (a missing file still yields the default). Canonicalization is bounded by a 2s timeout so a stalled filesystem backing an unrelated persistent workspace record cannot hang CLI registration; timeouts join ordinary realpath failures in the spelling fallback. --- src/node/services/workspaceService.test.ts | 30 ++++++++++++++++ src/node/services/workspaceService.ts | 42 ++++++++++++++++++---- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 479925cc45c..fc58522fb9f 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -8755,6 +8755,36 @@ describe("WorkspaceService registration-time plugin override sanitization", () = expect(pruned).toEqual(["ws-new:plugin:"]); }); + test("refuses to prune when the persistent sibling config is unreadable", async () => { + // The lenient loadConfigOrDefault swallows a malformed ~/.xum/config.json + // into an EMPTY project map — which reads as "no live sibling" and would + // prune enables a live desktop workspace still owns. The persistent + // source must be read in throwing mode and sanitization must fail closed + // (abort the registration, leave the override file untouched). + const service = makeService([{ id: "ws-new", path: "/tmp/proj" }]); + const pruned: string[] = []; + service.setWorkspaceMcpOverridesService({ + prunePluginOverrideKeys: (workspaceId, keyPrefix) => { + pruned.push(`${workspaceId}:${keyPrefix}`); + return Promise.resolve(); + }, + }); + const broken = { + loadConfigOrDefault: (options?: { throwOnError?: boolean }) => { + if (options?.throwOnError) { + throw new Error("config.json is malformed"); + } + // A lenient read would hide the corruption behind an empty map. + return { projects: new Map() }; + }, + } as unknown as Pick; + const error = await ( + service as unknown as SanitizeAccess + ).sanitizeStalePluginOverridesForNewWorkspace("ws-new", "/tmp/proj", broken); + expect(error).toContain("unreadable"); + expect(pruned).toEqual([]); + }); + test("skips sanitization while a live sibling resolves to the same path", async () => { // Conversation forks of a local workspace share the checkout: the // sibling's consent context is alive, so its enables must survive. diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 0abc07b0c70..f98eb8e76d1 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2879,12 +2879,28 @@ export class WorkspaceService extends EventEmitter { // recognized, or pruning would strip a live workspace's enables. // Failures fall back to spelling so an unresolvable path errs toward // skipping (leaving keys) rather than pruning live consent. + // Bounded canonicalization: realpath against a stalled filesystem (e.g. a + // dead NFS mount backing an UNRELATED persistent workspace record) must + // not hang CLI registration indefinitely. Timeouts join ordinary realpath + // failures in the spelling fallback below. + const CANONICALIZE_TIMEOUT_MS = 2_000; const canonicalize = async (candidate: string): Promise => { const stripped = stripTrailingSlashes(candidate); + let timer: NodeJS.Timeout | undefined; try { - return await fsPromises.realpath(stripped); + return await Promise.race([ + fsPromises.realpath(stripped), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("realpath timed out")), + CANONICALIZE_TIMEOUT_MS + ); + }), + ]); } catch { return stripped; + } finally { + clearTimeout(timer); } }; const isHostLocalConfig = (runtimeConfig: RuntimeConfig | undefined): boolean => @@ -2896,12 +2912,24 @@ export class WorkspaceService extends EventEmitter { // Scan the service's own config AND (when provided) the persistent one: // ephemeral CLI configs carry no workspace records, so a desktop // workspace live on the same checkout is only visible in the latter. - const configSources = [ - this.config, - ...(persistentSiblingConfig ? [persistentSiblingConfig] : []), - ]; - for (const configSource of configSources) { - const config = configSource.loadConfigOrDefault(); + // The persistent source reads in THROWING mode: the lenient read swallows + // a malformed/unreadable config into an empty project map, which reads as + // "no live sibling" and would prune enables a live desktop workspace + // still owns. A missing file still yields the default (genuinely no + // siblings). this.config keeps the lenient read — it is the service's own + // store, whose desktop/task registration paths already depend on it. + let configSnapshots: ProjectsConfig[]; + try { + configSnapshots = [ + this.config.loadConfigOrDefault(), + ...(persistentSiblingConfig + ? [persistentSiblingConfig.loadConfigOrDefault({ throwOnError: true })] + : []), + ]; + } catch (error) { + return `Cannot verify live sibling workspaces for plugin override sanitization (the persistent config is unreadable: ${getErrorMessage(error)}). Refusing to prune; fix the config and retry.`; + } + for (const config of configSnapshots) { for (const project of config.projects.values()) { for (const workspace of project.workspaces) { if ( From 7d2d2181ce8e8719602c1079bab650bf6763d70e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 12:48:13 +0000 Subject: [PATCH 62/63] fix: workflow read revalidation, composer plugin refresh, cross-process registration lock (Codex round 78 P2s) P2a: resolvePluginWorkflowScript read the canonicalized path with a plain runtime read; a managed update replacing workflows/ itself with an absolute symlink would canonicalize root and file through the same link and accept outside executable source. The consuming read now goes through readPluginFileWithinRootCapped against plugin.rootPath. P2b: palette- and Settings-driven install/update/uninstall now publish publishAgentPluginsMutated; the mounted composer subscribes and bumps a tick that re-runs both the plugin slash-command and skill loader effects, so an updated command cannot keep inserting its old expansion until remount. P2c: persist+sanitize of new host-local registrations in create/fork is now serialized under a cross-process lock (workspace-registration.lock) so two processes sharing one config root cannot each read the other's unsanitized entry as a live sibling and both skip the prune. --- src/browser/features/ChatInput/index.tsx | 17 +++++++- .../Sections/PluginsSettingsSection.tsx | 12 ++++++ src/browser/utils/agentPluginMutations.ts | 27 +++++++++++++ src/browser/utils/commands/sources.ts | 11 +++++ .../workflows/workflowScriptResolver.ts | 19 ++++++++- src/node/services/workspaceService.ts | 40 +++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/browser/utils/agentPluginMutations.ts diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index ca05974833a..d073b800a8f 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -24,6 +24,7 @@ import type { SendMessageError } from "@/common/types/errors"; import { createErrorToast } from "@/browser/features/ChatInput/ChatInputToasts"; import { ConfirmationModal } from "@/browser/components/ConfirmationModal/ConfirmationModal"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; +import { subscribeAgentPluginsMutated } from "@/browser/utils/agentPluginMutations"; import { parseCommand } from "@/browser/utils/slashCommands/parser"; import { readPersistedState, @@ -1853,6 +1854,19 @@ const ChatInputInner: React.FC = (props) => { store, ]); + // Agent plugin installs/updates/uninstalls change contributed slash + // commands and skills while the composer stays mounted (palette and + // Settings flows never remount the workspace); bump a tick so both loader + // effects below re-query instead of serving descriptors from the old tree. + const [pluginMutationTick, setPluginMutationTick] = useState(0); + useEffect( + () => + subscribeAgentPluginsMutated(() => { + setPluginMutationTick((tick) => tick + 1); + }), + [] + ); + // Load agent skills for suggestions useEffect(() => { let isMounted = true; @@ -1917,6 +1931,7 @@ const ChatInputInner: React.FC = (props) => { // The backend gates plugin-contributed skills on this experiment, so a // toggle must refetch /skill suggestions like it reloads plugin commands. agentPluginsExperimentEnabled, + pluginMutationTick, ]); // Agent Plugins: load manifest-contributed slash commands for suggestions. @@ -1945,7 +1960,7 @@ const ChatInputInner: React.FC = (props) => { return () => { isMounted = false; }; - }, [api, variant, workspaceId, agentPluginsExperimentEnabled]); + }, [api, variant, workspaceId, agentPluginsExperimentEnabled, pluginMutationTick]); // Voice input: track transcription provider availability (subscribe to provider config changes) useEffect(() => { diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 31e93ee6f07..3fe017ed1d8 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -20,6 +20,7 @@ import type { AgentPluginUpdateCheck, } from "@/common/orpc/schemas/agentPlugins"; import { getErrorMessage } from "@/common/utils/errors"; +import { publishAgentPluginsMutated } from "@/browser/utils/agentPluginMutations"; import { consumePendingPluginsSectionIntent, subscribePluginsSectionIntents, @@ -108,6 +109,9 @@ const AddPluginPanel: React.FC<{ expectedSha: preview.lockedSha, }); if (result.success) { + // Mounted composers cache contributed slash-command/skill + // descriptors; an install adds them without a remount. + publishAgentPluginsMutated(); props.onInstalled(); } else { setError(result.error); @@ -524,6 +528,11 @@ export const PluginsSettingsSection: React.FC = () => { setError(null); try { const result = await api.agentPlugins.update({ name }); + if (result.success) { + // Mounted composers cache contributed slash-command/skill + // descriptors; an update can change them without a remount. + publishAgentPluginsMutated(); + } // 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 @@ -547,6 +556,9 @@ export const PluginsSettingsSection: React.FC = () => { try { const result = await api.agentPlugins.uninstall({ name, deletePluginData }); if (result.success) { + // Mounted composers cache contributed slash-command/skill + // descriptors; an uninstall removes them without a remount. + publishAgentPluginsMutated(); setUninstallTarget(null); await refresh(); } else { diff --git a/src/browser/utils/agentPluginMutations.ts b/src/browser/utils/agentPluginMutations.ts new file mode 100644 index 00000000000..d6adcb8b174 --- /dev/null +++ b/src/browser/utils/agentPluginMutations.ts @@ -0,0 +1,27 @@ +/** + * Frontend signal for completed Agent Plugin mutations (install / update / + * uninstall), published by the Settings section and command-palette flows. + * + * A mounted workspace composer caches plugin-contributed slash-command and + * skill descriptors; mutations do not remount it (palette flows do not even + * navigate), so without this signal an updated command would keep inserting + * its old expansion until the workspace remounts. Module-level and + * unbuffered on purpose: only currently-mounted subscribers need to + * re-query, and a later mount re-queries anyway. + */ + +const listeners = new Set<() => void>(); + +export function publishAgentPluginsMutated(): void { + for (const listener of listeners) { + listener(); + } +} + +/** Subscribe a mounted consumer; returns an unsubscribe. */ +export function subscribeAgentPluginsMutated(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index dd58b9c8d20..7eaa1070996 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -29,6 +29,7 @@ import { } from "@/common/constants/storage"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; +import { publishAgentPluginsMutated } from "@/browser/utils/agentPluginMutations"; import { publishPluginsSectionIntent } from "@/browser/features/Settings/Sections/pluginsSectionIntents"; import { isTabType, type TabType } from "@/browser/types/rightSidebar"; import { @@ -1758,6 +1759,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // branch update applied: the fresh check may have discovered // moved tags or per-plugin errors the section should show. publishPluginsSectionIntent({ type: "refresh" }); + if (updatedNames.length > 0) { + // Mounted composers cache contributed slash-command/skill + // descriptors; an update can change them without a remount. + publishAgentPluginsMutated(); + } const summary: string[] = []; if (updatedNames.length > 0) { @@ -1842,6 +1848,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // A mounted section keeps its own stale updateChecks map; // tell it to re-query so badges match the toast. publishPluginsSectionIntent({ type: "refresh" }); + if (result.success) { + // Mounted composers cache contributed slash-command/skill + // descriptors; an update can change them without a remount. + publishAgentPluginsMutated(); + } showCommandFeedbackToast( result.success ? { type: "success", message: `Updated ${values.pluginName}.` } diff --git a/src/node/services/workflows/workflowScriptResolver.ts b/src/node/services/workflows/workflowScriptResolver.ts index 00299191281..4d53e1cf74a 100644 --- a/src/node/services/workflows/workflowScriptResolver.ts +++ b/src/node/services/workflows/workflowScriptResolver.ts @@ -9,6 +9,7 @@ import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import type { Runtime } from "@/node/runtime/Runtime"; import { discoverAgentPlugins, + readPluginFileWithinRootCapped, type AgentPluginContainer, type AgentPluginInfo, } from "@/node/services/agentPlugins/discovery"; @@ -320,7 +321,23 @@ async function resolvePluginWorkflowScript( throw new Error(sizeValidation.error); } - const source = await readFileString(localRuntime, resolvedPath); + // Consuming read revalidates against the PLUGIN ROOT (not just + // workflowsDir) with post-open containment + file identity, mirroring + // hooks.js and mcp.json: a managed update can replace `workflows/` itself + // with an absolute symlink to an outside directory, and the containment + // check above would then canonicalize root and file through the SAME link + // and accept an outside file as executable workflow source. + let source: string; + try { + source = await readPluginFileWithinRootCapped({ + filePath: resolvedPath, + pluginRoot: plugin.rootPath, + maxBytes: MAX_FILE_SIZE, + label: "plugin workflow script", + }); + } catch (error) { + throw new Error(`Plugin workflow script not readable: ${getErrorMessage(error)}`); + } return buildResolvedScript({ requestedScriptPath: input.scriptPath, canonicalScriptPath: `${PLUGIN_SCRIPT_PATH_PREFIX}${plugin.name}/${parsed.relativePath}`, diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index f98eb8e76d1..468195c432d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2,6 +2,7 @@ import { TASK_TERMINATION_STOP_STREAM_TIMEOUT_MS } from "@/constants/termination import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { EventEmitter } from "events"; import * as path from "path"; +import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import * as fsPromises from "fs/promises"; import assert from "@/common/utils/assert"; import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior"; @@ -2760,6 +2761,29 @@ export class WorkspaceService extends EventEmitter { */ private readonly pendingPluginSanitizations = new Set(); + /** + * Serializes persist + sanitize of a new host-local registration across + * PROCESSES sharing this config root. pendingPluginSanitizations only + * covers this process: two processes registering the same preserved + * checkout could otherwise each persist an entry and then each read the + * other's unsanitized entry as a live sibling — both skipping the prune, + * letting a stale canonical enable activate a same-name reinstall's + * default-disabled server. Under the lock the second registrant scans only + * after the first's prune committed, so it correctly sees a completed live + * sibling. + */ + private acquireRegistrationSanitizeLock(): Promise<() => Promise> { + return acquireCrossProcessLock({ + lockPath: path.join(this.config.rootDir, "workspace-registration.lock"), + // Persist + sibling scan + one override-file prune; canonicalization is + // bounded per entry, so a minute outlasts any legitimate holder. + acquireTimeoutMs: 60_000, + staleMs: 5 * 60_000, + timeoutMessage: + "Another Mux process is currently registering a workspace. Wait for it to finish and try again.", + }); + } + /** * TaskService entry point: task worktrees are REGISTERED before their * checkout exists (queued/reserved launches persist the entry with a future @@ -4608,7 +4632,14 @@ export class WorkspaceService extends EventEmitter { if (isHostLocalCheckout) { this.pendingPluginSanitizations.add(workspaceId); } + let releaseRegistrationLock: (() => Promise) | undefined; try { + if (isHostLocalCheckout) { + // Cross-process: persist + sanitize must not interleave with a + // sibling process registering the same checkout (see + // acquireRegistrationSanitizeLock). + releaseRegistrationLock = await this.acquireRegistrationSanitizeLock(); + } await this.config.editConfig((config) => { let projectConfig = config.projects.get(owningProjectPath); if (!projectConfig) { @@ -4706,6 +4737,7 @@ export class WorkspaceService extends EventEmitter { } } } finally { + await releaseRegistrationLock?.(); this.pendingPluginSanitizations.delete(workspaceId); } assert( @@ -8736,7 +8768,14 @@ export class WorkspaceService extends EventEmitter { if (forkIsHostLocalCheckout) { this.pendingPluginSanitizations.add(newWorkspaceId); } + let releaseRegistrationLock: (() => Promise) | undefined; try { + if (forkIsHostLocalCheckout) { + // Cross-process: persist + sanitize must not interleave with a + // sibling process registering the same checkout (see + // acquireRegistrationSanitizeLock). + releaseRegistrationLock = await this.acquireRegistrationSanitizeLock(); + } await this.config.addWorkspace(foundProjectPath, metadata); if (forkIsHostLocalCheckout) { const sanitizeError = await this.sanitizeStalePluginOverridesForNewWorkspace( @@ -8784,6 +8823,7 @@ export class WorkspaceService extends EventEmitter { } } } finally { + await releaseRegistrationLock?.(); this.pendingPluginSanitizations.delete(newWorkspaceId); } await this.workspaceGoalService?.inheritFromFork(sourceWorkspaceId, newWorkspaceId); From 9a090c4557bf7d05076d2b513c29f66d382f9c8b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 22 Aug 2026 12:53:22 +0000 Subject: [PATCH 63/63] fix: revalidate plugin agent definition reads post-open (Codex round 79 P2) Both consuming reads of plugin agents/.md (descriptor listing and readAgentDefinition) followed the canonical path with plain runtime stat/read after isPluginAgentContained; a managed update promoted in that window can replace the file (or an ancestor) with an absolute symlink to an outside definition whose frontmatter controls agent policy (runnable/base/tools). Plugin agents are host-local by construction, so both sites now read through readPluginFileWithinRootCapped against the plugin root (post-open containment recheck + leaf dev/ino identity + bounded handle read), matching hooks.js, mcp.json, and workflow scripts. --- .../agentDefinitionsService.ts | 115 ++++++++++++------ 1 file changed, 81 insertions(+), 34 deletions(-) diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.ts b/src/node/services/agentDefinitions/agentDefinitionsService.ts index 38202bb2a84..e97c4d08a8c 100644 --- a/src/node/services/agentDefinitions/agentDefinitionsService.ts +++ b/src/node/services/agentDefinitions/agentDefinitionsService.ts @@ -21,11 +21,12 @@ import type { AgentId, } from "@/common/types/agentDefinition"; import { log } from "@/node/services/log"; -import { validateFileSize } from "@/node/services/tools/fileCommon"; +import { MAX_FILE_SIZE, validateFileSize } from "@/node/services/tools/fileCommon"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { discoverAgentPlugins, + readPluginFileWithinRootCapped, UNIVERSAL_AGENT_PLUGINS_CONTAINER, type AgentPluginContainer, } from "@/node/services/agentPlugins/discovery"; @@ -318,35 +319,62 @@ async function readAgentDescriptorFromFile( filePath: string, agentId: AgentId, scope: Exclude, - pluginName?: string + pluginName?: string, + /** + * Plugin agents (host-local by construction): the consuming read must + * revalidate containment + file identity through a bounded post-open + * handle. isPluginAgentContained ran BEFORE this call, and a managed + * update promoted in between can replace agents/.md (or an ancestor) + * with an absolute symlink to an outside definition — staged validation + * reads that as a capability removal, and the outside frontmatter would + * otherwise control agent policy (runnable/base/tools). + */ + pluginRoot?: string ): Promise { - let stat; - try { - stat = await runtime.stat(filePath); - } catch { - return null; - } + let content: string; + let byteSize: number; + if (pluginRoot != null) { + try { + content = await readPluginFileWithinRootCapped({ + filePath, + pluginRoot, + maxBytes: MAX_FILE_SIZE, + label: `plugin agent '${agentId}'`, + }); + byteSize = Buffer.byteLength(content, "utf8"); + } catch (err) { + log.warn(`Failed to read plugin agent definition ${filePath}: ${getErrorMessage(err)}`); + return null; + } + } else { + let stat; + try { + stat = await runtime.stat(filePath); + } catch { + return null; + } - if (stat.isDirectory) { - return null; - } + if (stat.isDirectory) { + return null; + } - const sizeValidation = validateFileSize(stat); - if (sizeValidation) { - log.warn(`Skipping agent '${agentId}' (${scope}): ${sizeValidation.error}`); - return null; - } + const sizeValidation = validateFileSize(stat); + if (sizeValidation) { + log.warn(`Skipping agent '${agentId}' (${scope}): ${sizeValidation.error}`); + return null; + } - let content: string; - try { - content = await readFileString(runtime, filePath); - } catch (err) { - log.warn(`Failed to read agent definition ${filePath}: ${getErrorMessage(err)}`); - return null; + try { + content = await readFileString(runtime, filePath); + } catch (err) { + log.warn(`Failed to read agent definition ${filePath}: ${getErrorMessage(err)}`); + return null; + } + byteSize = stat.size; } try { - const parsed = parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); + const parsed = parseAgentDefinitionMarkdown({ content, byteSize }); const { selectable } = resolveAgentVisibility(parsed.frontmatter.ui); @@ -470,7 +498,8 @@ export async function discoverAgentDefinitions( filePath, agentId, scan.scope, - scan.pluginName + scan.pluginName, + scan.pluginRoot ); if (!descriptor) continue; @@ -592,18 +621,36 @@ export async function readAgentDefinition( } try { - const stat = await candidate.runtime.stat(filePath); - if (stat.isDirectory) { - continue; - } + let content: string; + let byteSize: number; + if (candidate.pluginRoot != null) { + // Plugin agents: bounded post-open revalidation (containment + file + // identity) — see the pluginRoot doc on readAgentDescriptorFromFile. + // This frontmatter controls agent policy (runnable/base/tools), so a + // replacement symlink promoted after isPluginAgentContained must not + // have its outside target read here. + content = await readPluginFileWithinRootCapped({ + filePath, + pluginRoot: candidate.pluginRoot, + maxBytes: MAX_FILE_SIZE, + label: `plugin agent '${agentId}'`, + }); + byteSize = Buffer.byteLength(content, "utf8"); + } else { + const stat = await candidate.runtime.stat(filePath); + if (stat.isDirectory) { + continue; + } - const sizeValidation = validateFileSize(stat); - if (sizeValidation) { - throw new Error(sizeValidation.error); - } + const sizeValidation = validateFileSize(stat); + if (sizeValidation) { + throw new Error(sizeValidation.error); + } - const content = await readFileString(candidate.runtime, filePath); - const parsed = parseAgentDefinitionMarkdown({ content, byteSize: stat.size }); + content = await readFileString(candidate.runtime, filePath); + byteSize = stat.size; + } + const parsed = parseAgentDefinitionMarkdown({ content, byteSize }); const pkg: AgentDefinitionPackage = { id: agentId,