From 94b126db405fdcf1f84afab244dd32c6303a12e1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 17:57:54 +0000 Subject: [PATCH 01/24] feat: add managed Agent Plugin install registry schema (plugins section) New AgentPluginInstallEntry schema persisted as a 'plugins' section in ~/.mux/config.json via Config's atomic writes. source.ref is the tracking channel; lockedSha is what runs. Invalid entries are dropped lenient-on-read (discovery stays the source of truth for what loads). --- .../config/schemas/agentPluginInstalls.ts | 73 +++++++++++++++++++ src/common/config/schemas/appConfigOnDisk.ts | 14 ++++ src/common/types/project.ts | 7 ++ src/node/config.test.ts | 57 +++++++++++++++ src/node/config.ts | 34 +++++++++ 5 files changed, 185 insertions(+) create mode 100644 src/common/config/schemas/agentPluginInstalls.ts diff --git a/src/common/config/schemas/agentPluginInstalls.ts b/src/common/config/schemas/agentPluginInstalls.ts new file mode 100644 index 0000000000..f95e32b826 --- /dev/null +++ b/src/common/config/schemas/agentPluginInstalls.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +/** + * Managed Agent Plugin install registry — the `plugins` section of + * `~/.mux/config.json`. + * + * 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`. */ + name: z.string().min(1), + /** v1 installs are global-only; the installer never writes into project checkouts. */ + scope: z.literal("global"), + source: AgentPluginInstallSourceSchema, + /** Commit SHA of the tree installed on disk (what actually runs). */ + lockedSha: z.string().min(1), + /** ISO-8601 install timestamp. */ + installedAt: z.string().min(1), + /** ISO-8601 timestamp of the most recent applied update. */ + updatedAt: z.string().optional(), + /** Cached manifest metadata so the list UI works offline / when the dir is missing. */ + manifest: z + .object({ + version: z.string().optional(), + description: z.string().optional(), + }) + .optional(), + /** Reserved: per-plugin opt-in auto-update. Unused in v1 — updates are badge + manual. */ + autoUpdate: z.boolean().optional(), +}); + +export const AgentPluginInstallsSchema = z.array(AgentPluginInstallEntrySchema); + +export type AgentPluginGitSource = z.infer; +export type AgentPluginInstallSource = z.infer; +export type AgentPluginInstallEntry = z.infer; diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index a3b237fef8..5a172131f6 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -8,6 +8,7 @@ import { CODER_ARCHIVE_BEHAVIORS } from "../coderArchiveBehavior"; import { WORKTREE_ARCHIVE_BEHAVIORS } from "../worktreeArchiveBehavior"; import { UserPreferencesSchema } from "./userPreferences"; import { TaskSettingsSchema } from "./taskSettings"; +import { AgentPluginInstallsSchema } from "./agentPluginInstalls"; import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat"; import { DEFAULT_GOAL_DEFAULTS } from "@/constants/goals"; @@ -17,6 +18,17 @@ export { UserPreferencesSchema } from "./userPreferences"; export type { UserPreferences } from "./userPreferences"; export { TaskSettingsSchema } from "./taskSettings"; export type { TaskSettings } from "./taskSettings"; +export { + AgentPluginGitSourceSchema, + AgentPluginInstallEntrySchema, + AgentPluginInstallSourceSchema, + AgentPluginInstallsSchema, +} from "./agentPluginInstalls"; +export type { + AgentPluginGitSource, + AgentPluginInstallEntry, + AgentPluginInstallSource, +} from "./agentPluginInstalls"; export const AgentAiDefaultsEntrySchema = z.object({ modelString: z.string().optional(), @@ -155,6 +167,8 @@ export const AppConfigOnDiskSchema = z runtimeEnablement: RuntimeEnablementOverridesSchema.optional(), defaultRuntime: RuntimeEnablementIdSchema.optional(), onePasswordAccountName: z.string().optional(), + /** Managed Agent Plugin installs (agent-plugins experiment). */ + plugins: AgentPluginInstallsSchema.optional(), }) .passthrough(); diff --git a/src/common/types/project.ts b/src/common/types/project.ts index d8d6019140..42e8013185 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -6,6 +6,7 @@ import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior"; import type { + AgentPluginInstallEntry, AppConfigMigrations, ModelFallbacks, UpdateChannel, @@ -203,4 +204,10 @@ export interface ProjectsConfig { /** Optional 1Password account name used for desktop SDK account selection. */ onePasswordAccountName?: string; + + /** + * Managed Agent Plugin installs (agent-plugins experiment). + * See src/common/config/schemas/agentPluginInstalls.ts for semantics. + */ + plugins?: AgentPluginInstallEntry[]; } diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 00f3b11332..38440f28e4 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -148,6 +148,63 @@ describe("Config", () => { }); }); + describe("managed plugin registry (plugins section)", () => { + const entry = { + name: "demo-plugin", + scope: "global" as const, + source: { + type: "git" as const, + url: "https://github.com/foo/demo-plugin.git", + ref: "main", + refType: "branch" as const, + }, + lockedSha: "a".repeat(40), + installedAt: "2026-08-08T00:00:00.000Z", + }; + + it("round-trips entries through editConfig and a fresh Config instance", async () => { + await config.editConfig((cfg) => { + cfg.plugins = [entry]; + return cfg; + }); + + const reloaded = new Config(tempDir).loadConfigOrDefault(); + expect(reloaded.plugins).toEqual([entry]); + }); + + it("drops invalid registry entries on load instead of failing (self-heal)", async () => { + await config.editConfig((cfg) => { + cfg.plugins = [entry]; + return cfg; + }); + + const configFile = path.join(tempDir, "config.json"); + const onDisk = JSON.parse(fs.readFileSync(configFile, "utf-8")) as { + plugins: unknown[]; + }; + onDisk.plugins.push({ name: "broken", source: { type: "zip" } }); + fs.writeFileSync(configFile, JSON.stringify(onDisk)); + + const reloaded = new Config(tempDir).loadConfigOrDefault(); + expect(reloaded.plugins).toEqual([entry]); + }); + + it("preserves the plugins section across unrelated config edits", async () => { + await config.editConfig((cfg) => { + cfg.plugins = [entry]; + return cfg; + }); + await config.editConfig((cfg) => { + cfg.defaultModel = "openai:gpt-4o"; + return cfg; + }); + + const reloaded = new Config(tempDir).loadConfigOrDefault(); + expect(reloaded.plugins).toEqual([entry]); + expect(reloaded.defaultModel).toBe("openai:gpt-4o"); + }); + }); + describe("workspace tags", () => { it("persists programmatic tags through save/load and metadata mapping", async () => { await config.editConfig((cfg) => { diff --git a/src/node/config.ts b/src/node/config.ts index fa2e3d33c0..4d772adf79 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -19,12 +19,14 @@ import type { UpdateChannel, } from "@/common/types/project"; import type { + AgentPluginInstallEntry, AppConfigMigrations, AppConfigOnDisk, BaseProviderConfig as ProviderConfig, ModelFallbacks, ProvidersConfig as CanonicalProvidersConfig, } from "@/common/config/schemas"; +import { AgentPluginInstallEntrySchema } from "@/common/config/schemas"; import { DEFAULT_MODEL_FALLBACKS, sanitizeModelFallbacks } from "@/common/utils/ai/modelFallbacks"; import { DEFAULT_TASK_SETTINGS, @@ -449,6 +451,32 @@ function normalizeConfigMigrations(value: unknown): AppConfigMigrations { return migrations; } +/** + * Lenient-on-read normalization for the managed Agent Plugin install registry: + * invalid entries are dropped with a warning instead of failing config load + * (self-healing — discovery remains the source of truth for what loads; the + * registry only annotates managed installs). + */ +function normalizeAgentPluginInstalls(value: unknown): AgentPluginInstallEntry[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const entries: AgentPluginInstallEntry[] = []; + for (const raw of value) { + const parsed = AgentPluginInstallEntrySchema.safeParse(raw); + if (parsed.success) { + entries.push(parsed.data); + } else { + log.warn("Dropping invalid managed plugin registry entry from config.json", { + entry: raw, + error: parsed.error.message, + }); + } + } + return entries.length > 0 ? entries : undefined; +} + function extractAgentDefaultsFromLegacySubagents( legacySubagentAiDefaults: SubagentAiDefaultsConfig ): Record { @@ -1205,6 +1233,7 @@ export class Config { defaultRuntime, runtimeEnablement, onePasswordAccountName: parseOptionalNonEmptyString(parsed.onePasswordAccountName), + plugins: normalizeAgentPluginInstalls(parsed.plugins), }; } } catch (error) { @@ -1496,6 +1525,11 @@ export class Config { data.onePasswordAccountName = onePasswordAccountName; } + const plugins = normalizeAgentPluginInstalls(config.plugins); + if (plugins !== undefined) { + data.plugins = plugins; + } + await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { log.error("Error saving config:", error); From d24dc83cc376b25c0f508e4ca2e63be8fcce09f0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 18:15:28 +0000 Subject: [PATCH 02/24] feat: AgentPluginInstallService + plugins oRPC namespace - discoverAgentPluginAt: public single-root discovery wrapper so the installer validates staged clones with the exact runtime validation - extract normalizeRepoUrlForClone into src/node/utils/gitUrls.ts (shared with the project clone flow) - sourceInput grammar: owner/repo[/subpath][@ref] shorthand + URL passthrough - AgentPluginInstallService: stateless preview (temp shallow clone, consent payload with skills + MCP command lines), exact-SHA install with rollback, list (managed + unmanaged + missing), uninstall (override pruning, optional plugin-data purge), ls-remote update checks, swap-based update - MCPServerManager.stopServersWithKeyPrefix: explicit plugin-server recycle - plugins.* oRPC endpoints gated on the agent-plugins experiment --- src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/agentPlugins.ts | 88 ++ src/common/orpc/schemas/api.ts | 56 + src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 75 ++ src/node/services/agentPlugins/discovery.ts | 28 + .../agentPlugins/installService.test.ts | 322 ++++++ .../services/agentPlugins/installService.ts | 954 ++++++++++++++++++ .../services/agentPlugins/sourceInput.test.ts | 91 ++ src/node/services/agentPlugins/sourceInput.ts | 96 ++ src/node/services/mcpServerManager.ts | 23 + src/node/services/projectService.ts | 47 +- src/node/services/serviceContainer.ts | 14 + src/node/utils/gitUrls.ts | 52 + 14 files changed, 1803 insertions(+), 46 deletions(-) create mode 100644 src/common/orpc/schemas/agentPlugins.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/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 279160fcb6..ff94748b2e 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -311,6 +311,7 @@ export { desktop, general, menu, + agentPlugins, agentSkills, agents, workflows, diff --git a/src/common/orpc/schemas/agentPlugins.ts b/src/common/orpc/schemas/agentPlugins.ts new file mode 100644 index 0000000000..707b9f2451 --- /dev/null +++ b/src/common/orpc/schemas/agentPlugins.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +import { + AgentPluginGitSourceSchema, + AgentPluginInstallEntrySchema, +} from "@/common/config/schemas/agentPluginInstalls"; + +/** + * oRPC shapes for the managed Agent Plugin installer (agent-plugins + * experiment). Registry entry + source schemas are shared with the on-disk + * config schema (single source of truth). + */ + +export { AgentPluginGitSourceSchema, AgentPluginInstallEntrySchema }; + +export const AgentPluginPreviewSkillSchema = z.object({ + name: z.string(), + description: z.string().optional(), +}); + +export const AgentPluginPreviewMcpServerSchema = z.object({ + serverName: z.string(), + transport: z.enum(["stdio", "http", "sse"]), + /** Human-readable command line (stdio) or URL (remote) shown in the consent preview. */ + summary: z.string(), +}); + +/** Manifest metadata surfaced in the consent preview (UI-safe projection of plugin.json). */ +export const AgentPluginManifestSummarySchema = z.object({ + name: z.string(), + version: z.string().optional(), + description: z.string().optional(), + authorName: z.string().optional(), + homepage: z.string().optional(), + repository: z.string().optional(), + license: z.string().optional(), +}); + +/** + * Everything a user consents to before anything is written: the resolved + * source + SHA, the manifest, every skill, and every MCP server command line. + */ +export const AgentPluginInstallPreviewSchema = z.object({ + source: AgentPluginGitSourceSchema, + /** Commit SHA the preview was computed from; install verifies it gets the same tree. */ + lockedSha: z.string(), + manifest: AgentPluginManifestSummarySchema, + skills: z.array(AgentPluginPreviewSkillSchema), + mcpServers: z.array(AgentPluginPreviewMcpServerSchema), + /** Manifest warnings + component diagnostics from validating the staged clone. */ + warnings: z.array(z.string()), + /** Final install directory (~/.mux/plugins/). */ + targetPath: z.string(), +}); + +export const AgentPluginListItemSchema = z.object({ + name: z.string(), + /** True when a registry entry exists; unmanaged dirs found by discovery are read-only. */ + managed: z.boolean(), + /** False for managed entries whose directory vanished (registry self-heal display). */ + present: z.boolean(), + /** Display location, e.g. "~/.mux/plugins/demo". */ + location: z.string(), + version: z.string().optional(), + description: z.string().optional(), + source: AgentPluginGitSourceSchema.optional(), + lockedSha: z.string().optional(), + installedAt: z.string().optional(), + updatedAt: z.string().optional(), + skillCount: z.number().int().nonnegative(), + mcpServerCount: z.number().int().nonnegative(), +}); + +export const AgentPluginUpdateCheckSchema = z.object({ + name: z.string(), + status: z.enum(["up-to-date", "update-available", "tag-moved", "pinned", "error"]), + /** Remote tip SHA for update-available / tag-moved. */ + remoteSha: z.string().optional(), + /** Error detail when status is "error". */ + message: z.string().optional(), +}); + +export type AgentPluginPreviewSkill = z.infer; +export type AgentPluginPreviewMcpServer = z.infer; +export type AgentPluginManifestSummary = z.infer; +export type AgentPluginInstallPreview = z.infer; +export type AgentPluginListItem = z.infer; +export type AgentPluginUpdateCheck = z.infer; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index fd85f7957c..cbc4b7d6c8 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -117,6 +117,13 @@ import { MCPTestResultSchema, WorkspaceMCPOverridesSchema, } from "./mcp"; +import { + AgentPluginGitSourceSchema, + AgentPluginInstallEntrySchema, + AgentPluginInstallPreviewSchema, + AgentPluginListItemSchema, + AgentPluginUpdateCheckSchema, +} from "./agentPlugins"; import { PolicyGetResponseSchema } from "./policy"; import { AgentAiDefaultsSchema, @@ -916,6 +923,55 @@ export const mcp = { }, }; +/** + * Managed Agent Plugin installs (agent-plugins experiment; global scope only). + * + * Human-driven surfaces only (Settings + palette) — there is deliberately no + * agent-facing installer tool in v1. All endpoints return Result values; the + * backend service gates on the experiment flag. + */ +export const agentPlugins = { + /** Temp shallow clone + validation of the staged tree; writes nothing permanent. */ + preview: { + input: z.object({ + input: z.string(), + ref: z.string().nullish(), + subpath: z.string().nullish(), + }), + output: ResultSchema(AgentPluginInstallPreviewSchema, z.string()), + }, + /** Fetch the consented SHA, promote into ~/.mux/plugins, write the registry entry. */ + install: { + input: z.object({ + source: AgentPluginGitSourceSchema, + /** SHA from the preview the user consented to. */ + expectedSha: z.string(), + }), + output: ResultSchema(AgentPluginInstallEntrySchema, z.string()), + }, + list: { + input: z.void(), + output: ResultSchema(z.array(AgentPluginListItemSchema), z.string()), + }, + uninstall: { + input: z.object({ + name: z.string(), + /** Also delete ~/.mux/plugin-data/ (default off — preserve data). */ + deletePluginData: z.boolean(), + }), + output: ResultSchema(z.void(), z.string()), + }, + /** git ls-remote per managed entry vs lockedSha; no fetch, no timers. */ + checkUpdates: { + input: z.void(), + output: ResultSchema(z.array(AgentPluginUpdateCheckSchema), z.string()), + }, + update: { + input: z.object({ name: z.string() }), + output: ResultSchema(AgentPluginInstallEntrySchema, z.string()), + }, +}; + /** * Secrets store. * diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index d865094651..431f4b9cfd 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -26,6 +26,7 @@ import type { MemoryConsolidationService } from "@/node/services/memoryConsolida import type { MemoryMetaService } from "@/node/services/memoryMeta"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; +import type { AgentPluginInstallService } from "@/node/services/agentPlugins/installService"; import type { TelemetryService } from "@/node/services/telemetryService"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { TimelineService } from "@/node/services/timelineService"; @@ -72,6 +73,7 @@ export interface ORPCContext { mcpOauthService: McpOauthService; workspaceMcpOverridesService: WorkspaceMcpOverridesService; mcpServerManager: MCPServerManager; + agentPluginInstallService: AgentPluginInstallService; sessionTimingService: SessionTimingService; timelineService: TimelineService; telemetryService: TelemetryService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index a5234dffc0..91a2a15c94 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3134,6 +3134,81 @@ export const router = (authToken?: string) => { return result; }), }, + // Managed Agent Plugin installs (agent-plugins experiment). The service + // gates every method on the experiment flag and throws user-facing + // errors; handlers translate them into Result values. + agentPlugins: { + preview: t + .input(schemas.agentPlugins.preview.input) + .output(schemas.agentPlugins.preview.output) + .handler(async ({ context, input }) => { + try { + const data = await context.agentPluginInstallService.preview({ + input: input.input, + ref: input.ref ?? undefined, + subpath: input.subpath ?? undefined, + }); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + install: t + .input(schemas.agentPlugins.install.input) + .output(schemas.agentPlugins.install.output) + .handler(async ({ context, input }) => { + try { + const data = await context.agentPluginInstallService.install(input); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + list: t + .input(schemas.agentPlugins.list.input) + .output(schemas.agentPlugins.list.output) + .handler(async ({ context }) => { + try { + const data = await context.agentPluginInstallService.list(); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + uninstall: t + .input(schemas.agentPlugins.uninstall.input) + .output(schemas.agentPlugins.uninstall.output) + .handler(async ({ context, input }) => { + try { + await context.agentPluginInstallService.uninstall(input); + return { success: true, data: undefined }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + checkUpdates: t + .input(schemas.agentPlugins.checkUpdates.input) + .output(schemas.agentPlugins.checkUpdates.output) + .handler(async ({ context }) => { + try { + const data = await context.agentPluginInstallService.checkUpdates(); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + update: t + .input(schemas.agentPlugins.update.input) + .output(schemas.agentPlugins.update.output) + .handler(async ({ context, input }) => { + try { + const data = await context.agentPluginInstallService.update(input); + return { success: true, data }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }), + }, mcpOauth: { startDesktopFlow: t .input(schemas.mcpOauth.startDesktopFlow.input) diff --git a/src/node/services/agentPlugins/discovery.ts b/src/node/services/agentPlugins/discovery.ts index 0031838d60..f0d920899c 100644 --- a/src/node/services/agentPlugins/discovery.ts +++ b/src/node/services/agentPlugins/discovery.ts @@ -246,6 +246,34 @@ async function discoverPluginAt(args: { }; } +/** + * Discover a single Agent Plugin at an arbitrary root directory. + * + * Public wrapper around the per-entry discovery used by container scans, so + * callers (e.g. the install service validating a staged temp clone) can run + * the exact same manifest + component validation against a directory that is + * not (yet) inside a configured container. Returns `plugin: null` when the + * directory is not a valid plugin; diagnostics carry the reasons. + */ +export async function discoverAgentPluginAt(args: { + pluginDir: string; + scope: AgentPluginScope; +}): Promise<{ plugin: AgentPluginInfo | null; diagnostics: AgentPluginDiagnostic[] }> { + if (!path.isAbsolute(args.pluginDir)) { + throw new Error(`discoverAgentPluginAt: pluginDir must be absolute: ${args.pluginDir}`); + } + + const diagnostics: AgentPluginDiagnostic[] = []; + const plugin = await discoverPluginAt({ + pluginDir: args.pluginDir, + containerPath: path.dirname(args.pluginDir), + dirName: path.basename(args.pluginDir), + scope: args.scope, + diagnostics, + }); + return { plugin, diagnostics }; +} + /** * Discover Agent Plugins in the given container directories. * diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts new file mode 100644 index 0000000000..09d284a045 --- /dev/null +++ b/src/node/services/agentPlugins/installService.test.ts @@ -0,0 +1,322 @@ +/* eslint-disable @typescript-eslint/await-thenable -- bun:test types `await expect(...).rejects.toThrow()` as void */ +import { afterEach, beforeEach, 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 { Config } from "@/node/config"; +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 registry = () => config.loadConfigOrDefault().plugins ?? []; + 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("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(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(registry()).toHaveLength(1); + expect(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 config.editConfig((cfg) => { + delete cfg.plugins; + return cfg; + }); + 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(registry()[0].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(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("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(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("list surfaces unmanaged plugin dirs read-only and missing managed installs", async () => { + // Unmanaged: a directory dropped into the container by hand. + const unmanagedDir = path.join(pluginsDir(), "handmade"); + await fsPromises.mkdir(unmanagedDir, { recursive: true }); + await fsPromises.writeFile( + path.join(unmanagedDir, "plugin.json"), + JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_ID_1_0_0, name: "handmade" }) + ); + + // Missing managed install: registry entry without a directory. + const preview = await service.preview({ input: remoteDir }); + await service.install({ source: preview.source, expectedSha: preview.lockedSha }); + await fsPromises.rm(path.join(pluginsDir(), "demo-plugin"), { recursive: true, force: true }); + + const items = await service.list(); + expect(items).toHaveLength(2); + const managed = items.find((item) => item.name === "demo-plugin"); + expect(managed).toMatchObject({ managed: true, present: false, version: "1.0.0" }); + const unmanaged = items.find((item) => item.name === "handmade"); + expect(unmanaged).toMatchObject({ managed: false, present: true }); + }); +}); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts new file mode 100644 index 0000000000..ef1bf62638 --- /dev/null +++ b/src/node/services/agentPlugins/installService.ts @@ -0,0 +1,954 @@ +import * as fsPromises from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + AgentPluginGitSource, + AgentPluginInstallEntry, +} from "@/common/config/schemas/agentPluginInstalls"; +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 type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +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/config.json. + * + * 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. + */ + +/** 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; + /** 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); + } + + 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. */ + private targetPathFor(name: string): string { + assert(name.length > 0 && !name.includes("/") && !name.includes("\\"), "invalid plugin name"); + return path.join(this.containerDir, name); + } + + 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}.`); + } + 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( + skillsDir: string | undefined, + warnings: string[] + ): Promise { + if (skillsDir === undefined) { + return []; + } + const skills: AgentPluginPreviewSkill[] = []; + let entries: string[] = []; + try { + entries = (await fsPromises.readdir(skillsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .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"); + // Missing SKILL.md → not a skill dir; skip silently like runtime discovery. + let stat; + try { + stat = await fsPromises.stat(skillPath); + } catch { + continue; + } + if (!stat.isFile()) continue; + try { + const content = await fsPromises.readFile(skillPath, "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.skillsDir, 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 { + const registry = this.config.loadConfigOrDefault().plugins ?? []; + if (registry.some((entry) => entry.name === 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); + 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 { + await this.config.editConfig((cfg) => { + cfg.plugins = [...(cfg.plugins ?? []).filter((e) => e.name !== name), entry]; + return cfg; + }); + } 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 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(); + + const registry = this.config.loadConfigOrDefault().plugins ?? []; + 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.skillsDir, 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 }); + } + } + + items.push({ + name: plugin.name, + managed: entry !== undefined, + present: true, + location: shortenHome(path.join(plugin.containerPath, plugin.dirName)), + ...(plugin.manifest.version !== undefined ? { version: plugin.manifest.version } : {}), + ...(plugin.manifest.description !== undefined + ? { description: plugin.manifest.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 registry = this.config.loadConfigOrDefault().plugins ?? []; + 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, ""); + + // Stop running servers before deleting the tree out from under them. + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + + await this.removeDir(targetPath); + await this.config.editConfig((cfg) => { + cfg.plugins = (cfg.plugins ?? []).filter((e) => e.name !== entry.name); + if (cfg.plugins.length === 0) { + delete cfg.plugins; + } + return cfg; + }); + + await this.pruneWorkspaceOverrides(serverKeyPrefix); + + if (args.deletePluginData) { + await this.removeDir(getPluginDataPath(this.config.rootDir, instanceId)); + } + log.info(`Uninstalled agent plugin '${entry.name}'`); + }); + } + + /** + * Remove `plugin::*` keys from every local workspace's MCP + * overrides. Best-effort per workspace: a missing checkout must not block + * uninstall. Remote runtimes are skipped — they never see plugin servers + * (resolveAgentPluginsMcpContext returns null off-host). + */ + private async pruneWorkspaceOverrides(serverKeyPrefix: string): Promise { + const overridesService = this.deps.workspaceMcpOverridesService; + if (!overridesService) { + return; + } + const allMetadata = await this.config.getAllWorkspaceMetadata(); + for (const metadata of allMetadata) { + const runtimeType = metadata.runtimeConfig.type; + if (runtimeType !== "local" && runtimeType !== "worktree") { + continue; + } + try { + const overrides = await overridesService.getOverridesForWorkspace(metadata.id); + 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) { + continue; + } + await overridesService.setOverridesForWorkspace(metadata.id, { + ...(enabledServers !== undefined ? { enabledServers } : {}), + ...(disabledServers !== undefined ? { disabledServers } : {}), + ...(toolAllowlist !== undefined ? { toolAllowlist } : {}), + }); + } catch (error) { + log.warn("Failed to prune plugin MCP overrides for workspace", { + workspaceId: metadata.id, + 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 = this.config.loadConfigOrDefault().plugins ?? []; + 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 registry = this.config.loadConfigOrDefault().plugins ?? []; + 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.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 trashDir = path.join(this.stagingRoot, `trash-${Date.now()}-${entry.name}`); + const hadOldTree = await pathExists(targetPath); + 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) { + await this.removeDir(trashDir); + } + + 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 } + : {}), + }, + }; + await this.config.editConfig((cfg) => { + cfg.plugins = (cfg.plugins ?? []).map((e) => (e.name === entry.name ? updated : e)); + return cfg; + }); + + // Content changed behind a stable path (and possibly an unchanged + // stdio command line) — the signature check cannot see it, so recycle + // explicitly. Servers restart on next use, default-disabled state and + // workspace overrides are untouched (identity is the lexical path). + await this.deps.mcpServerManager?.stopServersWithKeyPrefix( + buildPluginServerKey(this.instanceIdFor(entry.name), "") + ); + + 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/sourceInput.test.ts b/src/node/services/agentPlugins/sourceInput.test.ts new file mode 100644 index 0000000000..ec7b3c1c6a --- /dev/null +++ b/src/node/services/agentPlugins/sourceInput.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +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("rejects unusable inputs with actionable messages", () => { + expect(() => parseAgentPluginSourceInput("")).toThrow(/git URL or owner\/repo/); + expect(() => parseAgentPluginSourceInput("just-a-name")).toThrow(/not a git URL/); + expect(() => parseAgentPluginSourceInput("./relative/path")).toThrow(/relative path/); + expect(() => parseAgentPluginSourceInput("coder/mux@")).toThrow(/must not be empty/); + expect(() => parseAgentPluginSourceInput("-bad/owner")).toThrow(/not a valid owner\/repo/); + }); +}); + +describe("isFullCommitSha", () => { + test("accepts only full 40-hex SHAs", () => { + expect(isFullCommitSha("a".repeat(40))).toBe(true); + expect(isFullCommitSha("A1B2C3D4E5".repeat(4))).toBe(true); + expect(isFullCommitSha("a".repeat(39))).toBe(false); + expect(isFullCommitSha("a".repeat(41))).toBe(false); + expect(isFullCommitSha("main")).toBe(false); + }); +}); diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts new file mode 100644 index 0000000000..4b9a5beaaa --- /dev/null +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -0,0 +1,96 @@ +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)) { + // normalizeRepoUrlForClone strips query strings/fragments from URL-like inputs. + return { url: normalizeRepoUrlForClone(input) }; + } + + if (input.startsWith(".")) { + throw new Error( + `'${input}' looks like a relative path. Use an absolute path, a git URL, or owner/repo shorthand.` + ); + } + + // Shorthand: owner/repo[/sub/path][@ref]. Split the ref at the first `@` — + // GitHub owner/repo segments cannot contain `@`. + const atIndex = input.indexOf("@"); + const pathPart = atIndex === -1 ? input : input.slice(0, atIndex); + const refPart = atIndex === -1 ? undefined : input.slice(atIndex + 1); + + if (refPart?.length === 0) { + throw new Error("Ref after '@' must not be empty (use owner/repo@branch, @tag, or @sha)."); + } + + const segments = pathPart.split("/"); + if (segments.length < 2 || segments.some((segment) => segment.length === 0)) { + throw new Error( + `'${input}' is not a git URL or owner/repo shorthand. Examples: coder/mux, coder/mux@main, https://github.com/coder/mux.git` + ); + } + + const ownerRepo = `${segments[0]}/${segments[1]}`; + if (!GITHUB_SHORTHAND_PATTERN.test(ownerRepo)) { + throw new Error(`'${ownerRepo}' is not a valid owner/repo shorthand.`); + } + + const subpath = segments.slice(2).join("/"); + return { + url: normalizeRepoUrlForClone(ownerRepo), + ...(refPart !== undefined ? { ref: refPart } : {}), + ...(subpath.length > 0 ? { subpath } : {}), + }; +} diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 21dbeac22a..549cefc31c 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1409,6 +1409,29 @@ 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"); + const workspaceIds: string[] = []; + for (const [workspaceId, entry] of this.workspaceServers) { + for (const serverKey of entry.instances.keys()) { + if (serverKey.startsWith(prefix)) { + workspaceIds.push(workspaceId); + break; + } + } + } + await Promise.all(workspaceIds.map((workspaceId) => this.stopServers(workspaceId))); + } + async stopServers(workspaceId: string): Promise { const entry = this.workspaceServers.get(workspaceId); if (!entry) return; diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 85a3eaae8e..66200fbeeb 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -15,6 +15,7 @@ import type { Secret } from "@/common/types/secrets"; import type { Stats } from "fs"; import * as fsPromises from "fs/promises"; import { execFileAsync, killProcessTree } from "@/node/utils/disposableExec"; +import { normalizeRepoUrlForClone } from "@/node/utils/gitUrls"; import { buildFileCompletionsIndex, EMPTY_FILE_COMPLETIONS_INDEX, @@ -226,52 +227,6 @@ function deriveRepoFolderName(repoUrl: string): string { return safeFolderName; } -const GITHUB_SHORTHAND_PATTERN = /^[a-zA-Z0-9][\w-]*\/[a-zA-Z0-9][\w.-]*$/; - -function hasLikelySshCredentials(): boolean { - const sshAgentSocket = process.env.SSH_AUTH_SOCK; - // Be conservative: only prefer git@github.com shorthand when the session has an active - // SSH agent. The mere presence of local key files does not imply GitHub SSH access. - return typeof sshAgentSocket === "string" && sshAgentSocket.trim().length > 0; -} - -/** - * Normalize a repo URL so git clone receives a valid remote. - * Expands "owner/repo" shorthand to either SSH or HTTPS based on likely local credentials. - * All other inputs (HTTPS URLs, SSH URLs, SCP-style, etc.) pass through unchanged. - */ -function normalizeRepoUrlForClone(repoUrl: string): string { - const trimmedRepoUrl = repoUrl.trim(); - const shorthandCandidate = trimmedRepoUrl.replace(/[\\/]+$/, ""); - - // owner/repo shorthand: exactly two non-empty segments separated by a single slash, - // where the first segment looks like a GitHub username (letters, digits, hyphens). - // Excludes local paths like ../repo, ./foo, foo/bar/baz, and absolute paths. - // Note: bare `foo/bar` style local relative paths are intentionally treated as GitHub - // shorthand here because this function is only called from the Clone dialog, which is - // specifically for remote repos. Users cloning local repos should use the "Local folder" tab. - if (GITHUB_SHORTHAND_PATTERN.test(shorthandCandidate)) { - // Strip existing .git suffix before appending to avoid double .git (e.g. owner/repo.git → owner/repo.git.git) - const withoutGitSuffix = shorthandCandidate.replace(/\.git$/i, ""); - - // Prefer SSH for shorthand only when the current session has an active SSH agent. - // This avoids assuming GitHub access from unrelated key files on disk. - if (hasLikelySshCredentials()) { - return `git@github.com:${withoutGitSuffix}.git`; - } - - return `https://github.com/${withoutGitSuffix}.git`; - } - - // Strip query strings and fragments only from URL-like inputs (protocol:// or git@), - // not from local paths where # and ? may be valid filename characters. - if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmedRepoUrl) || trimmedRepoUrl.startsWith("git@")) { - return trimmedRepoUrl.replace(/[?#].*$/, ""); - } - - return trimmedRepoUrl; -} - function parseScpStyleSshUrl(url: string): { host: string } | undefined { const trimmedUrl = url.trim(); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 2320641e8b..79b7d04665 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -47,6 +47,8 @@ import { } from "@/node/services/analytics/analyticsService"; import { ExperimentsService } from "@/node/services/experimentsService"; import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import { AgentPluginInstallService } from "@/node/services/agentPlugins/installService"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { McpOauthService } from "@/node/services/mcpOauthService"; import { HeartbeatService } from "@/node/services/heartbeatService"; import { AgentStatusService } from "@/node/services/agentStatusService"; @@ -119,6 +121,7 @@ export class ServiceContainer { public readonly voiceService: VoiceService; public readonly mcpOauthService: McpOauthService; public readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService; + public readonly agentPluginInstallService: AgentPluginInstallService; public readonly telemetryService: TelemetryService; public readonly sessionTimingService: SessionTimingService; public readonly timelineService: TimelineService; @@ -232,6 +235,16 @@ export class ServiceContainer { this.extensionMetadata = core.extensionMetadata; this.backgroundProcessManager = core.backgroundProcessManager; + // Managed Agent Plugin installer (agent-plugins experiment). Gated on the + // backend ExperimentsService exactly like the plugin MCP provider; the + // MCP manager dependency lets update/uninstall recycle running plugin + // servers whose content changed behind an unchanged command line. + this.agentPluginInstallService = new AgentPluginInstallService(config, { + isEnabled: () => this.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS), + mcpServerManager: this.mcpServerManager, + workspaceMcpOverridesService: this.workspaceMcpOverridesService, + }); + this.projectService = new ProjectService(config, this.sshPromptService); this.projectService.setWorkspaceService(this.workspaceService); this.desktopSessionManager = new DesktopSessionManager({ @@ -615,6 +628,7 @@ export class ServiceContainer { mcpOauthService: this.mcpOauthService, workspaceMcpOverridesService: this.workspaceMcpOverridesService, mcpServerManager: this.mcpServerManager, + agentPluginInstallService: this.agentPluginInstallService, sessionTimingService: this.sessionTimingService, timelineService: this.timelineService, telemetryService: this.telemetryService, diff --git a/src/node/utils/gitUrls.ts b/src/node/utils/gitUrls.ts new file mode 100644 index 0000000000..9b8f001aff --- /dev/null +++ b/src/node/utils/gitUrls.ts @@ -0,0 +1,52 @@ +/** + * Git remote URL helpers shared by the project clone flow and the Agent + * Plugin installer. + */ + +/** + * `owner/repo` GitHub shorthand: exactly two non-empty segments separated by a + * single slash, where the first segment looks like a GitHub username. + */ +export const GITHUB_SHORTHAND_PATTERN = /^[a-zA-Z0-9][\w-]*\/[a-zA-Z0-9][\w.-]*$/; + +function hasLikelySshCredentials(): boolean { + const sshAgentSocket = process.env.SSH_AUTH_SOCK; + // Be conservative: only prefer git@github.com shorthand when the session has an active + // SSH agent. The mere presence of local key files does not imply GitHub SSH access. + return typeof sshAgentSocket === "string" && sshAgentSocket.trim().length > 0; +} + +/** + * Normalize a repo URL so git clone receives a valid remote. + * Expands "owner/repo" shorthand to either SSH or HTTPS based on likely local credentials. + * All other inputs (HTTPS URLs, SSH URLs, SCP-style, etc.) pass through unchanged. + */ +export function normalizeRepoUrlForClone(repoUrl: string): string { + const trimmedRepoUrl = repoUrl.trim(); + const shorthandCandidate = trimmedRepoUrl.replace(/[\\/]+$/, ""); + + // owner/repo shorthand: excludes local paths like ../repo, ./foo, foo/bar/baz, and + // absolute paths. Note: bare `foo/bar` style local relative paths are intentionally + // treated as GitHub shorthand here because callers (Clone dialog, plugin installer) + // are specifically for remote repos. + if (GITHUB_SHORTHAND_PATTERN.test(shorthandCandidate)) { + // Strip existing .git suffix before appending to avoid double .git (e.g. owner/repo.git → owner/repo.git.git) + const withoutGitSuffix = shorthandCandidate.replace(/\.git$/i, ""); + + // Prefer SSH for shorthand only when the current session has an active SSH agent. + // This avoids assuming GitHub access from unrelated key files on disk. + if (hasLikelySshCredentials()) { + return `git@github.com:${withoutGitSuffix}.git`; + } + + return `https://github.com/${withoutGitSuffix}.git`; + } + + // Strip query strings and fragments only from URL-like inputs (protocol:// or git@), + // not from local paths where # and ? may be valid filename characters. + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmedRepoUrl) || trimmedRepoUrl.startsWith("git@")) { + return trimmedRepoUrl.replace(/[?#].*$/, ""); + } + + return trimmedRepoUrl; +} From 4f015c0edaad67d981f122bcef057154c22c17c3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 18:22:56 +0000 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20Settings=20=E2=86=92=20Plugins=20?= =?UTF-8?q?section=20+=20palette=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PluginsSettingsSection: managed/unmanaged/missing list with update badges, Check for updates (on section open + explicit button only), two-phase Add flow (source input → consent preview listing every skill and MCP command line → install), inline uninstall confirm with unchecked-by-default 'also delete stored plugin data' checkbox - gated on the agent-plugins experiment (section, redirect, palette entry) - mock oRPC client support + Storybook stories with play assertions --- src/browser/App.tsx | 2 + .../PluginsSettingsSection.stories.tsx | 234 +++++++ .../Sections/PluginsSettingsSection.tsx | 591 ++++++++++++++++++ .../features/Settings/SettingsPage.test.tsx | 29 +- .../features/Settings/SettingsPage.tsx | 34 +- src/browser/stories/mocks/orpc.ts | 36 ++ src/browser/utils/commands/sources.test.ts | 1 + src/browser/utils/commands/sources.ts | 14 + 8 files changed, 929 insertions(+), 12 deletions(-) create mode 100644 src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx create mode 100644 src/browser/features/Settings/Sections/PluginsSettingsSection.tsx diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 8b1903912b..bed5d4ac9e 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -216,6 +216,7 @@ function AppInner() { const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false); const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); + const agentPluginsEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_PLUGINS); // Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally; // collapse remains a separate toggle and the drag handle is hidden in mobile-touch overlay mode. @@ -992,6 +993,7 @@ function AppInner() { onStartWorkspaceCreation: openNewWorkspaceFromPalette, onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette, multiProjectWorkspacesEnabled, + agentPluginsEnabled, onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette, getBranchesForProject, onSelectWorkspace: selectWorkspaceFromPalette, diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx new file mode 100644 index 0000000000..92ce15e910 --- /dev/null +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -0,0 +1,234 @@ +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, +}; + +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/ }); + }, +}; + +export const UninstallConfirmation: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const uninstallButton = await canvas.findByRole("button", { name: /Uninstall grill/ }); + await userEvent.click(uninstallButton); + + // Preserve-by-default: the plugin-data checkbox starts unchecked. + await canvas.findByText(/Also delete stored plugin data/); + const checkbox = await canvas.findByRole("checkbox"); + if (checkbox.getAttribute("data-state") !== "unchecked") { + throw new Error("Plugin-data checkbox must start unchecked (preserve by default)"); + } + }, +}; + +export const AddPluginConsentPreview: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(await canvas.findByRole("button", { name: /Add plugin/ })); + await userEvent.type(await canvas.findByLabelText(/Git URL or owner\/repo/), "example/grill"); + await userEvent.click(await canvas.findByRole("button", { name: /Preview/ })); + + // Consent card: manifest + every skill + every MCP command line before install. + await canvas.findByText("Skills (2)"); + await canvas.findByText("grill-lite"); + await canvas.findByText("MCP servers (1)"); + await canvas.findByText(/server\.js --db/); + await canvas.findByText(/Unknown top-level field 'hooks' ignored/); + await canvas.findByRole("button", { name: /Install/ }); + }, +}; diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx new file mode 100644 index 0000000000..df0628e904 --- /dev/null +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -0,0 +1,591 @@ +import React, { useCallback, useEffect, 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"; + +/** + * Settings → Plugins (agent-plugins experiment; global scope only). + * + * Managed installs come from the `plugins` registry in ~/.mux/config.json; + * 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 = useCallback(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); + } + }, [api, input, ref, busy]); + + const handleInstall = useCallback(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); + } + }, [api, preview, busy, props]); + + 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); + const [error, setError] = useState(null); + const [updateChecks, setUpdateChecks] = useState>( + () => new Map() + ); + const [checkingUpdates, setCheckingUpdates] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [uninstallTarget, setUninstallTarget] = useState(null); + /** Name of the plugin with an update/uninstall in flight. */ + const [busyPlugin, setBusyPlugin] = useState(null); + + const refresh = useCallback(async () => { + if (!api) return; + try { + const result = await api.agentPlugins.list(); + if (result.success) { + setItems(result.data); + setError(null); + } else { + setItems([]); + setError(result.error); + } + } catch (err) { + setItems([]); + setError(getErrorMessage(err)); + } + }, [api]); + + const checkForUpdates = useCallback(async () => { + if (!api) return; + setCheckingUpdates(true); + try { + const result = await api.agentPlugins.checkUpdates(); + if (result.success) { + setUpdateChecks(new Map(result.data.map((check) => [check.name, check]))); + } else { + setError(result.error); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setCheckingUpdates(false); + } + }, [api]); + + // Approved update policy: passive check on section open + explicit button only. + useEffect(() => { + void refresh(); + void checkForUpdates(); + }, [refresh, checkForUpdates]); + + const handleUpdate = useCallback( + async (name: string) => { + if (!api || busyPlugin !== null) return; + setBusyPlugin(name); + setError(null); + try { + const result = await api.agentPlugins.update({ name }); + if (!result.success) { + setError(result.error); + } + await refresh(); + await checkForUpdates(); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusyPlugin(null); + } + }, + [api, busyPlugin, refresh, checkForUpdates] + ); + + const handleUninstall = useCallback( + 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) { + setError(result.error); + } + setUninstallTarget(null); + await refresh(); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusyPlugin(null); + } + }, + [api, busyPlugin, refresh] + ); + + 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} +
+ )} + +
+ {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 ( +
+
+
+
+ {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}

+ )} +

+ {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/SettingsPage.test.tsx b/src/browser/features/Settings/SettingsPage.test.tsx index 621313f524..ddae8a803c 100644 --- a/src/browser/features/Settings/SettingsPage.test.tsx +++ b/src/browser/features/Settings/SettingsPage.test.tsx @@ -4,7 +4,7 @@ import { getSettingsSectionRedirect, getSettingsSections } from "./SettingsPage" describe("SettingsPage", () => { test("keeps Goals and Heartbeat out of settings navigation", () => { - const labels = getSettingsSections(true, true).map((section) => section.label); + const labels = getSettingsSections(true, true, true).map((section) => section.label); expect(labels).not.toContain("Goals"); expect(labels).not.toContain("Heartbeat"); @@ -12,23 +12,38 @@ describe("SettingsPage", () => { }); test("normalizes stale Goals and Heartbeat routes to Experiments with replace navigation", () => { - expect(getSettingsSectionRedirect("goals", true, true)).toEqual({ + expect(getSettingsSectionRedirect("goals", true, true, true)).toEqual({ section: "experiments", replace: true, }); - expect(getSettingsSectionRedirect("heartbeat", true, true)).toEqual({ + expect(getSettingsSectionRedirect("heartbeat", true, true, true)).toEqual({ section: "experiments", replace: true, }); }); test("shows the Memory section only while the memory experiment is enabled", () => { - expect(getSettingsSections(false, true).map((section) => section.id)).toContain("memory"); - expect(getSettingsSections(false, false).map((section) => section.id)).not.toContain("memory"); + expect(getSettingsSections(false, true, false).map((section) => section.id)).toContain("memory"); + expect(getSettingsSections(false, false, false).map((section) => section.id)).not.toContain("memory"); }); test("redirects the memory route away while the memory experiment is disabled", () => { - expect(getSettingsSectionRedirect("memory", false, false)).toEqual({ section: "general" }); - expect(getSettingsSectionRedirect("memory", false, true)).toBeNull(); + expect(getSettingsSectionRedirect("memory", false, false, false)).toEqual({ section: "general" }); + expect(getSettingsSectionRedirect("memory", false, true, false)).toBeNull(); + }); + + test("shows the Plugins section next to MCP only while agent-plugins is enabled", () => { + const ids = getSettingsSections(false, false, true).map((section) => section.id); + expect(ids.indexOf("plugins")).toBe(ids.indexOf("mcp") + 1); + expect(getSettingsSections(false, false, false).map((section) => section.id)).not.toContain( + "plugins" + ); + }); + + test("redirects the plugins route away while agent-plugins is disabled", () => { + expect(getSettingsSectionRedirect("plugins", false, false, false)).toEqual({ + section: "general", + }); + expect(getSettingsSectionRedirect("plugins", false, false, true)).toBeNull(); }); }); diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index e51f292602..ab8f8f6d8b 100644 --- a/src/browser/features/Settings/SettingsPage.tsx +++ b/src/browser/features/Settings/SettingsPage.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { ArrowLeft, + Blocks, Brain, Menu, Settings, @@ -30,6 +31,7 @@ import { GovernorSection } from "./Sections/GovernorSection"; import { MemorySection } from "./Sections/MemorySection"; import { Button } from "@/browser/components/Button/Button"; import { MCPSettingsSection } from "./Sections/MCPSettingsSection"; +import { PluginsSettingsSection } from "./Sections/PluginsSettingsSection"; import { SecretsSection } from "./Sections/SecretsSection"; import { LayoutsSection } from "./Sections/LayoutsSection"; import { RuntimesSection } from "./Sections/RuntimesSection"; @@ -123,9 +125,20 @@ interface SettingsSectionRedirect { export function getSettingsSections( governorEnabled: boolean, - memoryEnabled: boolean + memoryEnabled: boolean, + agentPluginsEnabled: boolean ): SettingsSection[] { const sections = [...BASE_SECTIONS]; + if (agentPluginsEnabled) { + // Next to MCP: plugins contribute skills + MCP servers. + const mcpIndex = sections.findIndex((section) => section.id === "mcp"); + sections.splice(mcpIndex + 1, 0, { + id: "plugins", + label: "Plugins", + icon: , + component: PluginsSettingsSection, + }); + } if (memoryEnabled) { sections.push({ id: "memory", @@ -148,7 +161,8 @@ export function getSettingsSections( export function getSettingsSectionRedirect( activeSection: string, governorEnabled: boolean, - memoryEnabled: boolean + memoryEnabled: boolean, + agentPluginsEnabled: boolean ): SettingsSectionRedirect | null { if (LEGACY_EXPERIMENT_SETTINGS_SECTION_IDS.has(activeSection)) { return { section: "experiments", replace: true }; @@ -162,6 +176,10 @@ export function getSettingsSectionRedirect( return { section: BASE_SECTIONS[0]?.id ?? "general" }; } + if (!agentPluginsEnabled && activeSection === "plugins") { + return { section: BASE_SECTIONS[0]?.id ?? "general" }; + } + return null; } @@ -175,10 +193,16 @@ export function SettingsPage(props: SettingsPageProps) { const onboardingPause = useOnboardingPause(); const governorEnabled = useExperimentValue(EXPERIMENT_IDS.MUX_GOVERNOR); const memoryEnabled = useExperimentValue(EXPERIMENT_IDS.MEMORY); + const agentPluginsEnabled = useExperimentValue(EXPERIMENT_IDS.AGENT_PLUGINS); // Keep routing on a valid section when experiment-owned settings move or disappear. useEffect(() => { - const redirect = getSettingsSectionRedirect(activeSection, governorEnabled, memoryEnabled); + const redirect = getSettingsSectionRedirect( + activeSection, + governorEnabled, + memoryEnabled, + agentPluginsEnabled + ); if (!redirect) { return; } @@ -189,7 +213,7 @@ export function SettingsPage(props: SettingsPageProps) { } setActiveSection(redirect.section); - }, [activeSection, setActiveSection, governorEnabled, memoryEnabled]); + }, [activeSection, setActiveSection, governorEnabled, memoryEnabled, agentPluginsEnabled]); // Close settings on Escape. Uses bubble phase so inner surfaces (Select dropdowns, // Popover, Dialog) that call stopPropagation/preventDefault on Escape get first @@ -208,7 +232,7 @@ export function SettingsPage(props: SettingsPageProps) { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [close]); - const sections = getSettingsSections(governorEnabled, memoryEnabled); + const sections = getSettingsSections(governorEnabled, memoryEnabled, agentPluginsEnabled); const currentSection = sections.find((section) => section.id === activeSection) ?? sections[0]; const SectionComponent = currentSection.component; diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 922c35b474..33bb284ace 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -39,6 +39,11 @@ import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; import type { NameGenerationError } from "@/common/types/errors"; import type { Secret } from "@/common/types/secrets"; import type { MCPHttpServerInfo, MCPServerInfo } from "@/common/types/mcp"; +import type { + AgentPluginInstallPreview, + AgentPluginListItem, + AgentPluginUpdateCheck, +} from "@/common/orpc/schemas/agentPlugins"; import type { MCPOAuthAuthStatus } from "@/common/types/mcpOauth"; import type { ChatStats } from "@/common/types/chatStats"; import { @@ -119,6 +124,13 @@ type ProjectRemoveError = z.infer; export interface MockORPCClientOptions { /** Layout presets config for Settings → Layouts stories */ layoutPresets?: LayoutPresetsConfig; + /** Agent Plugin installer mock data (Settings → Plugins). */ + agentPlugins?: { + items?: AgentPluginListItem[]; + updateChecks?: AgentPluginUpdateCheck[]; + /** Returned by agentPlugins.preview; omit to make preview fail. */ + preview?: AgentPluginInstallPreview; + }; projects?: Map; workspaces?: FrontendWorkspaceMetadata[]; /** Pre-seeded multi-project git status rows keyed by workspace ID. */ @@ -370,6 +382,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl projectSecrets = new Map(), terminalSessions: initialTerminalSessions = [], globalMcpServers = {}, + agentPlugins: agentPluginsMock, mcpServers = new Map(), mcpOverrides = new Map(), mcpTestResults = new Map(), @@ -1083,6 +1096,29 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl return Promise.resolve({ success: true, data: undefined }); }, }, + agentPlugins: { + list: () => Promise.resolve({ success: true, data: agentPluginsMock?.items ?? [] }), + checkUpdates: () => + Promise.resolve({ success: true, data: agentPluginsMock?.updateChecks ?? [] }), + preview: () => + agentPluginsMock?.preview + ? Promise.resolve({ success: true, data: agentPluginsMock.preview }) + : Promise.resolve({ success: false, error: "No preview configured in this story" }), + install: (input: { source: AgentPluginInstallPreview["source"]; expectedSha: string }) => + Promise.resolve({ + success: true, + data: { + name: agentPluginsMock?.preview?.manifest.name ?? "plugin", + scope: "global" as const, + source: input.source, + lockedSha: input.expectedSha, + installedAt: new Date().toISOString(), + }, + }), + uninstall: () => Promise.resolve({ success: true, data: undefined }), + update: (input: { name: string }) => + Promise.resolve({ success: false, error: `No update mock for '${input.name}'` }), + }, mcp: { list: (input?: { projectPath?: string }) => { const projectPath = typeof input?.projectPath === "string" ? input.projectPath.trim() : ""; diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 0ff2b9e5fb..cc6d3f3427 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -54,6 +54,7 @@ const mk = (over: Partial[0]> = {}) => { onStartScratchCreation: () => undefined, onStartMultiProjectWorkspaceCreation: () => undefined, multiProjectWorkspacesEnabled: true, + agentPluginsEnabled: false, onArchiveMergedWorkspacesInProject: () => Promise.resolve(), onSelectWorkspace: () => undefined, onRemoveWorkspace: () => Promise.resolve({ success: true }), diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index bf743aee8d..4bc78b147e 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -112,6 +112,8 @@ export interface BuildSourcesParams { onStartWorkspaceCreation: (projectPath: string) => void; onStartMultiProjectWorkspaceCreation: () => void; multiProjectWorkspacesEnabled: boolean; + /** agent-plugins experiment: gates the Settings → Plugins palette entry. */ + agentPluginsEnabled: boolean; onArchiveMergedWorkspacesInProject: (projectPath: string) => Promise; getBranchesForProject: (projectPath: string) => Promise; onSelectWorkspace: (sel: { @@ -1568,6 +1570,18 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi keywords: ["model", "custom", "add"], run: () => openSettings("models"), }, + ...(p.agentPluginsEnabled + ? [ + { + id: CommandIds.settingsOpenSection("plugins"), + title: "Settings: Plugins", + subtitle: "Install and manage Agent Plugins", + section: section.settings, + keywords: ["plugin", "install", "agent", "skill", "mcp", "update"], + run: () => openSettings("plugins"), + }, + ] + : []), ]); } From 3b9d7245cec65713a9d2b8ddfc1b3f61077c6c4c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 18:30:17 +0000 Subject: [PATCH 04/24] =?UTF-8?q?docs:=20describe=20Settings=20=E2=86=92?= =?UTF-8?q?=20Plugins=20install/update/uninstall=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix lint (unsafe any in registry normalization) and prettier. --- docs/agents/agent-skills.mdx | 2 ++ docs/config/mcp-servers.mdx | 2 ++ src/browser/features/Settings/SettingsPage.test.tsx | 12 +++++++++--- src/node/config.ts | 2 +- .../agentSkills/builtInSkillContent.generated.ts | 4 ++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 0c9c4c6a0c..b586a874e4 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -64,6 +64,8 @@ Enable the **Agent Plugins** experiment (Settings → Experiments) to also disco Plugin skills have the lowest precedence within their scope and are read-only. A broken plugin (or a broken skill inside one) never affects other plugins or skills. Plugins can also ship MCP servers; see [MCP servers](/config/mcp-servers#agent-plugins-servers-experiment). +Global plugins can be installed from git via **Settings → Plugins** (paste a git URL or `owner/repo[@ref]`); the install preview lists every skill the plugin would contribute before anything is written. + ## Skill layout A skill is a directory named after the skill: diff --git a/docs/config/mcp-servers.mdx b/docs/config/mcp-servers.mdx index d8657b16c6..e91e6bcd93 100644 --- a/docs/config/mcp-servers.mdx +++ b/docs/config/mcp-servers.mdx @@ -61,6 +61,8 @@ With the **Agent Plugins** experiment enabled (Settings → Experiments), MCP se Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`. +**Settings → Plugins** installs plugins from git into `~/.mux/plugins` (paste a git URL or `owner/repo[@ref]`). Before anything is written, a consent preview lists the plugin's manifest, every skill, and every MCP server command line. Installs are pinned to the resolved commit; update checks compare the tracked branch or tag against the pinned commit and never auto-apply. Applying an update replaces the plugin directory wholesale — local edits to a managed plugin directory are discarded — and restarts that plugin's running MCP servers. Uninstalling removes the directory, the registry entry, and the plugin's per-workspace server overrides, but keeps `~/.mux/plugin-data/` unless you opt in to deleting it. + ## Behavior - **Hot reload** — Config changes apply on your next message (no restart needed) diff --git a/src/browser/features/Settings/SettingsPage.test.tsx b/src/browser/features/Settings/SettingsPage.test.tsx index ddae8a803c..ebf1461617 100644 --- a/src/browser/features/Settings/SettingsPage.test.tsx +++ b/src/browser/features/Settings/SettingsPage.test.tsx @@ -23,12 +23,18 @@ describe("SettingsPage", () => { }); test("shows the Memory section only while the memory experiment is enabled", () => { - expect(getSettingsSections(false, true, false).map((section) => section.id)).toContain("memory"); - expect(getSettingsSections(false, 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, false)).toEqual({ section: "general" }); + expect(getSettingsSectionRedirect("memory", false, false, false)).toEqual({ + section: "general", + }); expect(getSettingsSectionRedirect("memory", false, true, false)).toBeNull(); }); diff --git a/src/node/config.ts b/src/node/config.ts index 4d772adf79..f59784e47a 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -463,7 +463,7 @@ function normalizeAgentPluginInstalls(value: unknown): AgentPluginInstallEntry[] } const entries: AgentPluginInstallEntry[] = []; - for (const raw of value) { + for (const raw of value as unknown[]) { const parsed = AgentPluginInstallEntrySchema.safeParse(raw); if (parsed.success) { entries.push(parsed.data); diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index ddef86597f..a72ee0c3c9 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1327,6 +1327,8 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Plugin skills have the lowest precedence within their scope and are read-only. A broken plugin (or a broken skill inside one) never affects other plugins or skills. Plugins can also ship MCP servers; see [MCP servers](/config/mcp-servers#agent-plugins-servers-experiment).", "", + "Global plugins can be installed from git via **Settings → Plugins** (paste a git URL or `owner/repo[@ref]`); the install preview lists every skill the plugin would contribute before anything is written.", + "", "## Skill layout", "", "A skill is a directory named after the skill:", @@ -3163,6 +3165,8 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Stdio plugin servers launch with the spec's `PLUGIN_ROOT` and `PLUGIN_DATA` environment variables; per-plugin data directories live under `~/.mux/plugin-data/`.", "", + "**Settings → Plugins** installs plugins from git into `~/.mux/plugins` (paste a git URL or `owner/repo[@ref]`). Before anything is written, a consent preview lists the plugin's manifest, every skill, and every MCP server command line. Installs are pinned to the resolved commit; update checks compare the tracked branch or tag against the pinned commit and never auto-apply. Applying an update replaces the plugin directory wholesale — local edits to a managed plugin directory are discarded — and restarts that plugin's running MCP servers. Uninstalling removes the directory, the registry entry, and the plugin's per-workspace server overrides, but keeps `~/.mux/plugin-data/` unless you opt in to deleting it.", + "", "## Behavior", "", "- **Hot reload** — Config changes apply on your next message (no restart needed)", From b9c9a1062afabcfddf86a8fd36789d8e6030e8cf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 18:53:31 +0000 Subject: [PATCH 05/24] fix: address Codex review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Registry moved to standalone ~/.mux/plugins.json: older builds rebuild config.json from known fields on save (passthrough only affects schema validation), so a downgrade would drop an embedded registry; owning the file also lets writes THROW so install rolls back the promoted dir and uninstall/update surface persistence failures instead of silently succeeding (Q2's own contingency: 'migration to a separate file') - Traversal safety: plugin-name grammar (shared via src/common/utils/agentPluginName.ts) enforced in the registry schema and asserted in targetPathFor, so a malformed entry named '.'/'..' can never resolve outside the container that uninstall deletes recursively - Fallback clone: reset the staging dir before the branch-clone fallback (fetchExactSha leaves an initialized repo; git clone refuses non-empty) - Keyboard rule: palette commands Install Agent Plugin… (opens the section with the add panel expanded), Check for Plugin Updates (toast + navigate), Update All Plugins (applies update-available; moved tags stay manual) - New tests: registry survives config.json rewrites, traversal names dropped, registry-write failure rollback, SHA-fetch-refused fallback (file:// remote with uploadpack.allowAnySHA1InWant=false) --- .../Sections/PluginsSettingsSection.tsx | 5 +- .../Sections/pluginsSectionIntents.ts | 21 +++ src/browser/utils/commandIds.ts | 5 + src/browser/utils/commands/sources.ts | 84 ++++++++++ .../config/schemas/agentPluginInstalls.ts | 26 ++- src/common/config/schemas/appConfigOnDisk.ts | 6 +- src/common/types/project.ts | 7 - src/common/utils/agentPluginName.ts | 18 +++ src/node/config.test.ts | 57 ------- src/node/config.ts | 34 ---- .../agentPlugins/installService.test.ts | 95 +++++++++-- .../services/agentPlugins/installService.ts | 149 ++++++++++++++---- src/node/services/agentPlugins/manifest.ts | 10 +- 13 files changed, 365 insertions(+), 152 deletions(-) create mode 100644 src/browser/features/Settings/Sections/pluginsSectionIntents.ts create mode 100644 src/common/utils/agentPluginName.ts diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index df0628e904..fd48275c0c 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 { consumeAddPluginPanelRequest } from "./pluginsSectionIntents"; /** * Settings → Plugins (agent-plugins experiment; global scope only). @@ -332,7 +333,9 @@ export const PluginsSettingsSection: React.FC = () => { () => new Map() ); const [checkingUpdates, setCheckingUpdates] = useState(false); - const [addOpen, setAddOpen] = useState(false); + // The palette's "Install Agent Plugin…" opens this section with the add + // panel already expanded (keyboard rule: operations need a keyboard path). + const [addOpen, setAddOpen] = useState(() => consumeAddPluginPanelRequest()); const [uninstallTarget, setUninstallTarget] = useState(null); /** Name of the plugin with an update/uninstall in flight. */ const [busyPlugin, setBusyPlugin] = useState(null); diff --git a/src/browser/features/Settings/Sections/pluginsSectionIntents.ts b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts new file mode 100644 index 0000000000..65bf5a6bf6 --- /dev/null +++ b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts @@ -0,0 +1,21 @@ +/** + * One-shot navigation intents for the Settings → Plugins section. + * + * The command palette's "Install Agent Plugin…" runs before the section + * mounts, so it records an intent here and PluginsSettingsSection consumes it + * in its state initializer. Module-level (not persisted) on purpose: the + * intent is meaningful only for the navigation that just happened. + */ + +let addPluginPanelRequested = false; + +export function requestAddPluginPanel(): void { + addPluginPanelRequested = true; +} + +/** Returns whether the add panel was requested, clearing the intent. */ +export function consumeAddPluginPanelRequest(): boolean { + const requested = addPluginPanelRequested; + addPluginPanelRequested = false; + return requested; +} diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 359815fd66..ec84071fa7 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -92,6 +92,11 @@ export const CommandIds = { settingsOpen: () => "settings:open" as const, settingsOpenSection: (section: string) => `settings:open:${section}` as const, + // Agent Plugin commands (agent-plugins experiment) + pluginsInstall: () => "plugins:install" as const, + 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.ts b/src/browser/utils/commands/sources.ts index 4bc78b147e..e85675c8da 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -25,6 +25,7 @@ import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; +import { requestAddPluginPanel } from "@/browser/features/Settings/Sections/pluginsSectionIntents"; import { isTabType, type TabType } from "@/browser/types/rightSidebar"; import { getOrderedBaseTabIds, @@ -1580,6 +1581,89 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi 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. + requestAddPluginPanel(); + 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" + ); + showCommandFeedbackToast({ + type: "success", + message: + updatable.length === 0 + ? "All plugins are up to date." + : `Updates available: ${updatable.map((check) => check.name).join(", ")}`, + }); + if (updatable.length > 0) { + 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: tags are supposed to be immutable, so + // a moved tag warrants the section's per-plugin warning, not a + // bulk apply. + const updatable = checks.data.filter( + (check) => check.status === "update-available" + ); + if (updatable.length === 0) { + showCommandFeedbackToast({ + type: "success", + message: "All plugins are up to date.", + }); + return; + } + const failures: string[] = []; + for (const check of updatable) { + const result = await api.agentPlugins.update({ name: check.name }); + if (!result.success) { + failures.push(`${check.name}: ${result.error}`); + } + } + if (failures.length > 0) { + showCommandFeedbackToast({ type: "error", message: failures.join("; ") }); + } else { + showCommandFeedbackToast({ + type: "success", + message: `Updated ${updatable.map((check) => check.name).join(", ")}`, + }); + } + }, + }, ] : []), ]); diff --git a/src/common/config/schemas/agentPluginInstalls.ts b/src/common/config/schemas/agentPluginInstalls.ts index f95e32b826..9102584886 100644 --- a/src/common/config/schemas/agentPluginInstalls.ts +++ b/src/common/config/schemas/agentPluginInstalls.ts @@ -1,8 +1,17 @@ import { z } from "zod"; +import { + AGENT_PLUGIN_NAME_MAX_LENGTH, + AGENT_PLUGIN_NAME_PATTERN, +} from "@/common/utils/agentPluginName"; + /** - * Managed Agent Plugin install registry — the `plugins` section of - * `~/.mux/config.json`. + * Managed Agent Plugin install registry — persisted as `~/.mux/plugins.json`. + * + * 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. * * Semantics (mirroring lazy.nvim / Claude Code): `source.ref` is the tracking * channel and `lockedSha` is what is actually on disk and runs. Install @@ -44,8 +53,12 @@ export const AgentPluginInstallSourceSchema = z.discriminatedUnion("type", [ ]); export const AgentPluginInstallEntrySchema = z.object({ - /** plugin.json `name`; also the directory name under `~/.mux/plugins`. */ - name: z.string().min(1), + /** + * 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, @@ -68,6 +81,11 @@ export const AgentPluginInstallEntrySchema = z.object({ export const AgentPluginInstallsSchema = z.array(AgentPluginInstallEntrySchema); +/** On-disk shape of `~/.mux/plugins.json` (object wrapper leaves room for future fields). */ +export const AgentPluginRegistryFileSchema = z.object({ + plugins: AgentPluginInstallsSchema, +}); + 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 5a172131f6..2ded17462a 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -8,7 +8,6 @@ import { CODER_ARCHIVE_BEHAVIORS } from "../coderArchiveBehavior"; import { WORKTREE_ARCHIVE_BEHAVIORS } from "../worktreeArchiveBehavior"; import { UserPreferencesSchema } from "./userPreferences"; import { TaskSettingsSchema } from "./taskSettings"; -import { AgentPluginInstallsSchema } from "./agentPluginInstalls"; import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat"; import { DEFAULT_GOAL_DEFAULTS } from "@/constants/goals"; @@ -18,11 +17,14 @@ 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, + AgentPluginRegistryFileSchema, } from "./agentPluginInstalls"; export type { AgentPluginGitSource, @@ -167,8 +169,6 @@ export const AppConfigOnDiskSchema = z runtimeEnablement: RuntimeEnablementOverridesSchema.optional(), defaultRuntime: RuntimeEnablementIdSchema.optional(), onePasswordAccountName: z.string().optional(), - /** Managed Agent Plugin installs (agent-plugins experiment). */ - plugins: AgentPluginInstallsSchema.optional(), }) .passthrough(); diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 42e8013185..d8d6019140 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -6,7 +6,6 @@ import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior"; import type { - AgentPluginInstallEntry, AppConfigMigrations, ModelFallbacks, UpdateChannel, @@ -204,10 +203,4 @@ export interface ProjectsConfig { /** Optional 1Password account name used for desktop SDK account selection. */ onePasswordAccountName?: string; - - /** - * Managed Agent Plugin installs (agent-plugins experiment). - * See src/common/config/schemas/agentPluginInstalls.ts for semantics. - */ - plugins?: AgentPluginInstallEntry[]; } diff --git a/src/common/utils/agentPluginName.ts b/src/common/utils/agentPluginName.ts new file mode 100644 index 0000000000..31271a45b2 --- /dev/null +++ b/src/common/utils/agentPluginName.ts @@ -0,0 +1,18 @@ +/** + * Agent Plugins 1.0.0 plugin-name grammar (§5, canonical plugin.schema.json). + * + * Lives in src/common so both the node-side manifest validator and the shared + * registry schema (src/common/config/schemas/agentPluginInstalls.ts) enforce + * the same rule. Registry names double as directory names under + * `~/.mux/plugins`, so this validation is also a filesystem-safety gate: + * the pattern excludes path separators, `.`/`..`, and `..` runs. + */ + +// Canonical name pattern from plugin.schema.json (JS supports the lookahead). +export const AGENT_PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +export const AGENT_PLUGIN_NAME_MAX_LENGTH = 64; + +/** True when `name` satisfies the §5 plugin-name grammar. */ +export function isValidAgentPluginName(name: string): boolean { + return name.length <= AGENT_PLUGIN_NAME_MAX_LENGTH && AGENT_PLUGIN_NAME_PATTERN.test(name); +} diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 38440f28e4..00f3b11332 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -148,63 +148,6 @@ describe("Config", () => { }); }); - describe("managed plugin registry (plugins section)", () => { - const entry = { - name: "demo-plugin", - scope: "global" as const, - source: { - type: "git" as const, - url: "https://github.com/foo/demo-plugin.git", - ref: "main", - refType: "branch" as const, - }, - lockedSha: "a".repeat(40), - installedAt: "2026-08-08T00:00:00.000Z", - }; - - it("round-trips entries through editConfig and a fresh Config instance", async () => { - await config.editConfig((cfg) => { - cfg.plugins = [entry]; - return cfg; - }); - - const reloaded = new Config(tempDir).loadConfigOrDefault(); - expect(reloaded.plugins).toEqual([entry]); - }); - - it("drops invalid registry entries on load instead of failing (self-heal)", async () => { - await config.editConfig((cfg) => { - cfg.plugins = [entry]; - return cfg; - }); - - const configFile = path.join(tempDir, "config.json"); - const onDisk = JSON.parse(fs.readFileSync(configFile, "utf-8")) as { - plugins: unknown[]; - }; - onDisk.plugins.push({ name: "broken", source: { type: "zip" } }); - fs.writeFileSync(configFile, JSON.stringify(onDisk)); - - const reloaded = new Config(tempDir).loadConfigOrDefault(); - expect(reloaded.plugins).toEqual([entry]); - }); - - it("preserves the plugins section across unrelated config edits", async () => { - await config.editConfig((cfg) => { - cfg.plugins = [entry]; - return cfg; - }); - await config.editConfig((cfg) => { - cfg.defaultModel = "openai:gpt-4o"; - return cfg; - }); - - const reloaded = new Config(tempDir).loadConfigOrDefault(); - expect(reloaded.plugins).toEqual([entry]); - expect(reloaded.defaultModel).toBe("openai:gpt-4o"); - }); - }); - describe("workspace tags", () => { it("persists programmatic tags through save/load and metadata mapping", async () => { await config.editConfig((cfg) => { diff --git a/src/node/config.ts b/src/node/config.ts index f59784e47a..fa2e3d33c0 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -19,14 +19,12 @@ import type { UpdateChannel, } from "@/common/types/project"; import type { - AgentPluginInstallEntry, AppConfigMigrations, AppConfigOnDisk, BaseProviderConfig as ProviderConfig, ModelFallbacks, ProvidersConfig as CanonicalProvidersConfig, } from "@/common/config/schemas"; -import { AgentPluginInstallEntrySchema } from "@/common/config/schemas"; import { DEFAULT_MODEL_FALLBACKS, sanitizeModelFallbacks } from "@/common/utils/ai/modelFallbacks"; import { DEFAULT_TASK_SETTINGS, @@ -451,32 +449,6 @@ function normalizeConfigMigrations(value: unknown): AppConfigMigrations { return migrations; } -/** - * Lenient-on-read normalization for the managed Agent Plugin install registry: - * invalid entries are dropped with a warning instead of failing config load - * (self-healing — discovery remains the source of truth for what loads; the - * registry only annotates managed installs). - */ -function normalizeAgentPluginInstalls(value: unknown): AgentPluginInstallEntry[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - - const entries: AgentPluginInstallEntry[] = []; - for (const raw of value as unknown[]) { - const parsed = AgentPluginInstallEntrySchema.safeParse(raw); - if (parsed.success) { - entries.push(parsed.data); - } else { - log.warn("Dropping invalid managed plugin registry entry from config.json", { - entry: raw, - error: parsed.error.message, - }); - } - } - return entries.length > 0 ? entries : undefined; -} - function extractAgentDefaultsFromLegacySubagents( legacySubagentAiDefaults: SubagentAiDefaultsConfig ): Record { @@ -1233,7 +1205,6 @@ export class Config { defaultRuntime, runtimeEnablement, onePasswordAccountName: parseOptionalNonEmptyString(parsed.onePasswordAccountName), - plugins: normalizeAgentPluginInstalls(parsed.plugins), }; } } catch (error) { @@ -1525,11 +1496,6 @@ export class Config { data.onePasswordAccountName = onePasswordAccountName; } - const plugins = normalizeAgentPluginInstalls(config.plugins); - if (plugins !== undefined) { - data.plugins = plugins; - } - await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { log.error("Error saving config:", error); diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 09d284a045..c66c57d184 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -73,7 +73,15 @@ describe("AgentPluginInstallService", () => { const pluginsDir = () => path.join(muxRoot, "plugins"); const stagingDir = () => path.join(muxRoot, "plugin-staging"); - const registry = () => config.loadConfigOrDefault().plugins ?? []; + 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, @@ -121,7 +129,7 @@ describe("AgentPluginInstallService", () => { // Cancelling after preview = nothing written anywhere. expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); - expect(registry()).toEqual([]); + expect(await registry()).toEqual([]); expect(await stagingLeftovers()).toEqual([]); const entry = await service.install({ source: preview.source, expectedSha: preview.lockedSha }); @@ -132,8 +140,12 @@ describe("AgentPluginInstallService", () => { 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(registry()).toHaveLength(1); - expect(registry()[0]).toMatchObject({ name: "demo-plugin", lockedSha: head, scope: "global" }); + 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(); @@ -159,10 +171,7 @@ describe("AgentPluginInstallService", () => { ).rejects.toThrow(/already installed/); // Unmanaged directory at the target path (registry entry removed, dir kept). - await config.editConfig((cfg) => { - delete cfg.plugins; - return cfg; - }); + await fsPromises.rm(registryFile(), { force: true }); await expect(service.preview({ input: remoteDir })).rejects.toThrow(/already exists/); }); @@ -188,7 +197,7 @@ describe("AgentPluginInstallService", () => { expect(updated.updatedAt).toBeDefined(); expect(updated.manifest?.version).toBe("2.0.0"); expect(await pathExists(path.join(installedDir, "local-edit.txt"))).toBe(false); - expect(registry()[0].lockedSha).toBe(newHead); + expect(((await registry())[0] as { lockedSha: string }).lockedSha).toBe(newHead); expect(await stagingLeftovers()).toEqual([]); }); @@ -209,7 +218,7 @@ describe("AgentPluginInstallService", () => { expect(checks[0].status).toBe("tag-moved"); await service.uninstall({ name: "demo-plugin", deletePluginData: false }); - expect(registry()).toEqual([]); + expect(await registry()).toEqual([]); expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); // Full-SHA install pins hard: no update checks apply. @@ -271,7 +280,7 @@ describe("AgentPluginInstallService", () => { // Nothing was written by any of the failures above. expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); - expect(registry()).toEqual([]); + expect(await registry()).toEqual([]); expect(await stagingLeftovers()).toEqual([]); // Disabled experiment gates every method. @@ -298,6 +307,70 @@ describe("AgentPluginInstallService", () => { expect(installedManifest.version).toBe("1.0.0"); }); + 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("install rolls back the promoted dir when the registry write fails", async () => { + const preview = await service.preview({ input: remoteDir }); + + // Occupy the registry path with a directory so the atomic write's rename fails. + await fsPromises.mkdir(registryFile(), { recursive: true }); + await expect( + service.install({ source: preview.source, expectedSha: preview.lockedSha }) + ).rejects.toThrow(/persist the plugin registry/); + + // No partial state: the promoted dir was rolled back. + expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); + expect(await stagingLeftovers()).toEqual([]); + + // Clearing the obstruction lets the same consented install succeed. + await fsPromises.rmdir(registryFile()); + 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"); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index ef1bf62638..c615946bee 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -2,10 +2,14 @@ import * as fsPromises from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import type { - AgentPluginGitSource, - AgentPluginInstallEntry, +import writeFileAtomic from "write-file-atomic"; + +import { + AgentPluginRegistryFileSchema, + type AgentPluginGitSource, + type AgentPluginInstallEntry, } from "@/common/config/schemas/agentPluginInstalls"; +import { isValidAgentPluginName } from "@/common/utils/agentPluginName"; import type { AgentPluginInstallPreview, AgentPluginListItem, @@ -44,7 +48,13 @@ import { isFullCommitSha, parseAgentPluginSourceInput } from "./sourceInput"; * 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/config.json. + * ({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). @@ -60,6 +70,9 @@ import { isFullCommitSha, parseAgentPluginSourceInput } from "./sourceInput"; * 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"; @@ -129,6 +142,7 @@ function manifestSummary(manifest: AgentPluginManifest): AgentPluginManifestSumm 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(); @@ -145,6 +159,77 @@ export class AgentPluginInstallService { 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) + // --------------------------------------------------------------------- + + /** + * Lenient-on-read: a missing/corrupt file or invalid entries degrade to + * "unmanaged dirs" rather than an error (discovery stays the source of + * truth for what loads; the registry only annotates). Name validation in + * the schema doubles as a filesystem-safety gate — a traversal name like + * `..` must never reach targetPathFor. + */ + private async readRegistry(): Promise { + let raw: string; + try { + raw = await fsPromises.readFile(this.registryFile, "utf8"); + } catch { + return []; + } + + let parsedJson: unknown; + try { + parsedJson = JSON.parse(raw); + } catch (error) { + log.warn("Ignoring unparseable plugin registry file", { + file: this.registryFile, + error: getErrorMessage(error), + }); + return []; + } + + const wrapper = AgentPluginRegistryFileSchema.safeParse(parsedJson); + if (wrapper.success) { + return wrapper.data.plugins; + } + + // Salvage valid entries individually so one bad entry cannot hide the rest. + const rawEntries = + typeof parsedJson === "object" && + parsedJson !== null && + Array.isArray((parsedJson as { plugins?: unknown }).plugins) + ? (parsedJson as { plugins: unknown[] }).plugins + : []; + const entries: AgentPluginInstallEntry[] = []; + for (const rawEntry of rawEntries) { + const parsed = AgentPluginRegistryFileSchema.shape.plugins.element.safeParse(rawEntry); + if (parsed.success) { + entries.push(parsed.data); + } else { + log.warn("Dropping invalid managed plugin registry entry", { + entry: rawEntry, + error: parsed.error.message, + }); + } + } + return entries; + } + + /** + * 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. + */ + private async writeRegistry(entries: AgentPluginInstallEntry[]): Promise { + await writeFileAtomic( + this.registryFile, + JSON.stringify({ plugins: entries }, null, 2), + "utf-8" + ); } private assertEnabled(): void { @@ -159,10 +244,20 @@ export class AgentPluginInstallService { return run; } - /** Lexical install location — the identity `computePluginInstanceId` hashes for global plugins. */ + /** + * 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(name.length > 0 && !name.includes("/") && !name.includes("\\"), "invalid plugin name"); - return path.join(this.containerDir, name); + 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 { @@ -325,6 +420,10 @@ export class AgentPluginInstallService { 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", @@ -571,7 +670,7 @@ export class AgentPluginInstallService { } private async assertNoCollision(name: string): Promise { - const registry = this.config.loadConfigOrDefault().plugins ?? []; + const registry = await this.readRegistry(); if (registry.some((entry) => entry.name === name)) { throw new Error(`A managed plugin named '${name}' is already installed. Uninstall it first.`); } @@ -624,15 +723,13 @@ export class AgentPluginInstallService { }, }; try { - await this.config.editConfig((cfg) => { - cfg.plugins = [...(cfg.plugins ?? []).filter((e) => e.name !== name), entry]; - return cfg; - }); + const registry = await this.readRegistry(); + await this.writeRegistry([...registry.filter((e) => e.name !== 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 error; + 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; @@ -646,7 +743,7 @@ export class AgentPluginInstallService { async list(): Promise { this.assertEnabled(); - const registry = this.config.loadConfigOrDefault().plugins ?? []; + const registry = await this.readRegistry(); const containers: AgentPluginContainer[] = [ { path: this.containerDir, scope: "global" }, { path: path.join(os.homedir(), ".agents", "plugins"), scope: "global" }, @@ -734,7 +831,7 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const registry = this.config.loadConfigOrDefault().plugins ?? []; + const registry = await this.readRegistry(); const entry = registry.find((e) => e.name === args.name); if (!entry) { throw new Error(`'${args.name}' is not a managed plugin install.`); @@ -747,14 +844,10 @@ export class AgentPluginInstallService { // Stop running servers before deleting the tree out from under them. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + // Registry first: if the write fails, the plugin stays fully installed + // and the API reports the error (no half-removed state). + await this.writeRegistry(registry.filter((e) => e.name !== entry.name)); await this.removeDir(targetPath); - await this.config.editConfig((cfg) => { - cfg.plugins = (cfg.plugins ?? []).filter((e) => e.name !== entry.name); - if (cfg.plugins.length === 0) { - delete cfg.plugins; - } - return cfg; - }); await this.pruneWorkspaceOverrides(serverKeyPrefix); @@ -823,7 +916,7 @@ export class AgentPluginInstallService { async checkUpdates(): Promise { this.assertEnabled(); - const registry = this.config.loadConfigOrDefault().plugins ?? []; + const registry = await this.readRegistry(); return Promise.all( registry.map(async (entry): Promise => { if (entry.source.refType === "commit") { @@ -865,7 +958,7 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const registry = this.config.loadConfigOrDefault().plugins ?? []; + const registry = await this.readRegistry(); const entry = registry.find((e) => e.name === args.name); if (!entry) { throw new Error(`'${args.name}' is not a managed plugin install.`); @@ -929,10 +1022,10 @@ export class AgentPluginInstallService { : {}), }, }; - await this.config.editConfig((cfg) => { - cfg.plugins = (cfg.plugins ?? []).map((e) => (e.name === entry.name ? updated : e)); - return cfg; - }); + // 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. + await this.writeRegistry(registry.map((e) => (e.name === entry.name ? updated : e))); // Content changed behind a stable path (and possibly an unchanged // stdio command line) — the signature check cannot see it, so recycle diff --git a/src/node/services/agentPlugins/manifest.ts b/src/node/services/agentPlugins/manifest.ts index a42e09f195..0ca3a2f786 100644 --- a/src/node/services/agentPlugins/manifest.ts +++ b/src/node/services/agentPlugins/manifest.ts @@ -21,14 +21,10 @@ export const AGENT_PLUGIN_SCHEMA_ID_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; -// Canonical name pattern from plugin.schema.json (JS supports the lookahead). -const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; -const PLUGIN_NAME_MAX_LENGTH = 64; +// Name grammar shared with the install registry schema (see the module's doc comment). +import { isValidAgentPluginName } from "@/common/utils/agentPluginName"; -/** True when `name` satisfies the §5 plugin-name grammar. */ -export function isValidAgentPluginName(name: string): boolean { - return name.length <= PLUGIN_NAME_MAX_LENGTH && PLUGIN_NAME_PATTERN.test(name); -} +export { isValidAgentPluginName }; export interface AgentPluginAuthor { name?: string; From edcdfa05fe447de9e1c20da44d3c3db67218eb63 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 19:05:19 +0000 Subject: [PATCH 06/24] fix: address Codex review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uninstall: stage the tree out (rename to staging) BEFORE the registry write; a locked/undeletable tree now fails cleanly with the install fully intact, and a failed registry write renames the tree back - palette 'Install Agent Plugin…': mounted sections subscribe to the intent so invoking the command while Settings → Plugins is already open expands the add panel (initializer covers the fresh-mount path) - section: mutation errors are re-asserted after refresh (refresh success cleared them); failed uninstall keeps the confirmation open - palette check/update-all: per-plugin status:'error' entries no longer read as 'all up to date' — surface which plugins failed and navigate --- .../Sections/PluginsSettingsSection.tsx | 33 ++++++++++++--- .../Sections/pluginsSectionIntents.ts | 22 ++++++++-- src/browser/utils/commands/sources.ts | 42 ++++++++++++++----- .../agentPlugins/installService.test.ts | 25 +++++++++++ .../services/agentPlugins/installService.ts | 40 ++++++++++++++++-- 5 files changed, 137 insertions(+), 25 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index fd48275c0c..4483b62863 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -20,7 +20,10 @@ import type { AgentPluginUpdateCheck, } from "@/common/orpc/schemas/agentPlugins"; import { getErrorMessage } from "@/common/utils/errors"; -import { consumeAddPluginPanelRequest } from "./pluginsSectionIntents"; +import { + consumeAddPluginPanelRequest, + subscribeAddPluginPanelRequests, +} from "./pluginsSectionIntents"; /** * Settings → Plugins (agent-plugins experiment; global scope only). @@ -335,7 +338,17 @@ export const PluginsSettingsSection: React.FC = () => { const [checkingUpdates, setCheckingUpdates] = useState(false); // The palette's "Install Agent Plugin…" opens this section with the add // panel already expanded (keyboard rule: operations need a keyboard path). + // The initializer covers palette → fresh mount; the subscription covers + // invoking the command while this section is already on screen (same-route + // navigation preserves the mounted component, so no re-init happens). const [addOpen, setAddOpen] = useState(() => consumeAddPluginPanelRequest()); + useEffect(() => { + return subscribeAddPluginPanelRequests(() => { + if (consumeAddPluginPanelRequest()) { + setAddOpen(true); + } + }); + }, []); const [uninstallTarget, setUninstallTarget] = useState(null); /** Name of the plugin with an update/uninstall in flight. */ const [busyPlugin, setBusyPlugin] = useState(null); @@ -387,11 +400,15 @@ export const PluginsSettingsSection: React.FC = () => { 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); } - await refresh(); - await checkForUpdates(); } catch (err) { setError(getErrorMessage(err)); } finally { @@ -408,11 +425,15 @@ export const PluginsSettingsSection: React.FC = () => { setError(null); try { const result = await api.agentPlugins.uninstall({ name, deletePluginData }); - if (!result.success) { + 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); } - setUninstallTarget(null); - await refresh(); } catch (err) { setError(getErrorMessage(err)); } finally { diff --git a/src/browser/features/Settings/Sections/pluginsSectionIntents.ts b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts index 65bf5a6bf6..1c65d651f8 100644 --- a/src/browser/features/Settings/Sections/pluginsSectionIntents.ts +++ b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts @@ -1,16 +1,22 @@ /** * One-shot navigation intents for the Settings → Plugins section. * - * The command palette's "Install Agent Plugin…" runs before the section - * mounts, so it records an intent here and PluginsSettingsSection consumes it - * in its state initializer. Module-level (not persisted) on purpose: the - * intent is meaningful only for the navigation that just happened. + * The command palette's "Install Agent Plugin…" may run before the section + * mounts (intent consumed in the state initializer) or while it is already + * mounted (navigating to the same route preserves the component, so mounted + * sections subscribe and are notified immediately). Module-level (not + * persisted) on purpose: the intent is meaningful only for the invocation + * that just happened. */ let addPluginPanelRequested = false; +const listeners = new Set<() => void>(); export function requestAddPluginPanel(): void { addPluginPanelRequested = true; + for (const listener of listeners) { + listener(); + } } /** Returns whether the add panel was requested, clearing the intent. */ @@ -19,3 +25,11 @@ export function consumeAddPluginPanelRequest(): boolean { addPluginPanelRequested = false; return requested; } + +/** Notifies an already-mounted section of new requests; returns an unsubscribe. */ +export function subscribeAddPluginPanelRequests(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 e85675c8da..3a8ed9ccf3 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1608,15 +1608,26 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const updatable = result.data.filter( (check) => check.status === "update-available" || check.status === "tag-moved" ); - showCommandFeedbackToast({ - type: "success", - message: - updatable.length === 0 - ? "All plugins are up to date." - : `Updates available: ${updatable.map((check) => check.name).join(", ")}`, - }); + // Per-plugin failures ride inside a successful result; an + // unreachable remote is an unknown state, not "up to date". + const failed = result.data.filter((check) => check.status === "error"); if (updatable.length > 0) { + showCommandFeedbackToast({ + type: "success", + message: `Updates available: ${updatable.map((check) => check.name).join(", ")}`, + }); openSettings("plugins"); + } else if (failed.length > 0) { + showCommandFeedbackToast({ + type: "error", + message: `Update check failed for ${failed.map((check) => check.name).join(", ")}`, + }); + openSettings("plugins"); + } else { + showCommandFeedbackToast({ + type: "success", + message: "All plugins are up to date.", + }); } }, }, @@ -1641,10 +1652,19 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi (check) => check.status === "update-available" ); if (updatable.length === 0) { - showCommandFeedbackToast({ - type: "success", - message: "All plugins are up to date.", - }); + const failed = checks.data.filter((check) => check.status === "error"); + if (failed.length > 0) { + // An unreachable remote is an unknown state, not "up to date". + showCommandFeedbackToast({ + type: "error", + message: `Update check failed for ${failed.map((check) => check.name).join(", ")}`, + }); + } else { + showCommandFeedbackToast({ + type: "success", + message: "All plugins are up to date.", + }); + } return; } const failures: string[] = []; diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index c66c57d184..59a4216b8b 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -336,6 +336,31 @@ describe("AgentPluginInstallService", () => { ); }); + 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 }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index c615946bee..7121d516a9 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -25,6 +25,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 { hasErrorCode } from "@/node/services/tools/skillFileUtils"; import { execFileAsync } from "@/node/utils/disposableExec"; import { discoverAgentPluginAt, @@ -844,10 +845,41 @@ export class AgentPluginInstallService { // Stop running servers before deleting the tree out from under them. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); - // Registry first: if the write fails, the plugin stays fully installed - // and the API reports the error (no half-removed state). - await this.writeRegistry(registry.filter((e) => e.name !== entry.name)); - await this.removeDir(targetPath); + // Stage the tree out of the container BEFORE touching the registry so + // either 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 the tree back. Deleting the staged tree is + // best-effort — it sits under the staging root, where stale-dir + // reclamation cleans up leftovers. + 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. + } + + try { + await this.writeRegistry(registry.filter((e) => e.name !== entry.name)); + } catch (error) { + if (stagedTree) { + await fsPromises.rename(trashDir, targetPath).catch((rollbackError: unknown) => { + log.error("Failed to restore plugin dir after failed registry write", { + targetPath, + rollbackError, + }); + }); + } + throw new Error(`Failed to persist the plugin registry: ${getErrorMessage(error)}`); + } + if (stagedTree) { + await this.removeDir(trashDir); + } await this.pruneWorkspaceOverrides(serverKeyPrefix); From a6503d2fe48ccd00d2613af722dc9e27ae3c5e45 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 19:19:01 +0000 Subject: [PATCH 07/24] fix: address Codex review round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - palette 'Uninstall Agent Plugin…' with a managed-plugin select prompt; routes through the section's confirmation flow (plugin-data checkbox, destructive button) — the palette never uninstalls directly - pluginsSectionIntents generalized to a typed intent bus (open-add-panel / confirm-uninstall / refresh); mounted sections subscribe, unmounted sections consume the buffered intent on mount - 'Update All Plugins' publishes a refresh intent so an already-mounted section re-queries instead of showing stale versions/badges - sourceInput expands ~/-relative local paths (git is spawned without a shell, so ~ never expands on its own) --- .../Sections/PluginsSettingsSection.tsx | 47 +++++++++------ .../Sections/pluginsSectionIntents.ts | 57 ++++++++++++------- src/browser/utils/commandIds.ts | 1 + src/browser/utils/commands/sources.ts | 52 +++++++++++++++-- .../services/agentPlugins/sourceInput.test.ts | 8 +++ src/node/services/agentPlugins/sourceInput.ts | 8 +++ 6 files changed, 133 insertions(+), 40 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 4483b62863..3dc0eacd57 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -21,8 +21,9 @@ import type { } from "@/common/orpc/schemas/agentPlugins"; import { getErrorMessage } from "@/common/utils/errors"; import { - consumeAddPluginPanelRequest, - subscribeAddPluginPanelRequests, + consumePendingPluginsSectionIntent, + subscribePluginsSectionIntents, + type PluginsSectionIntent, } from "./pluginsSectionIntents"; /** @@ -336,20 +337,16 @@ export const PluginsSettingsSection: React.FC = () => { () => new Map() ); const [checkingUpdates, setCheckingUpdates] = useState(false); - // The palette's "Install Agent Plugin…" opens this section with the add - // panel already expanded (keyboard rule: operations need a keyboard path). - // The initializer covers palette → fresh mount; the subscription covers - // invoking the command while this section is already on screen (same-route - // navigation preserves the mounted component, so no re-init happens). - const [addOpen, setAddOpen] = useState(() => consumeAddPluginPanelRequest()); - useEffect(() => { - return subscribeAddPluginPanelRequests(() => { - if (consumeAddPluginPanelRequest()) { - setAddOpen(true); - } - }); - }, []); - const [uninstallTarget, setUninstallTarget] = 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 + // (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); @@ -393,6 +390,24 @@ export const PluginsSettingsSection: React.FC = () => { void checkForUpdates(); }, [refresh, checkForUpdates]); + // 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; + } + }); + }, [refresh, checkForUpdates]); + const handleUpdate = useCallback( async (name: string) => { if (!api || busyPlugin !== null) return; diff --git a/src/browser/features/Settings/Sections/pluginsSectionIntents.ts b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts index 1c65d651f8..e156c41ec7 100644 --- a/src/browser/features/Settings/Sections/pluginsSectionIntents.ts +++ b/src/browser/features/Settings/Sections/pluginsSectionIntents.ts @@ -1,33 +1,50 @@ /** - * One-shot navigation intents for the Settings → Plugins section. + * Intents for the Settings → Plugins section, published by command-palette + * actions that run outside the section's React tree. * - * The command palette's "Install Agent Plugin…" may run before the section - * mounts (intent consumed in the state initializer) or while it is already - * mounted (navigating to the same route preserves the component, so mounted - * sections subscribe and are notified immediately). Module-level (not - * persisted) on purpose: the intent is meaningful only for the invocation - * that just happened. + * 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. */ -let addPluginPanelRequested = false; -const listeners = new Set<() => void>(); +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 requestAddPluginPanel(): void { - addPluginPanelRequested = true; - for (const listener of listeners) { - listener(); +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; } -/** Returns whether the add panel was requested, clearing the intent. */ -export function consumeAddPluginPanelRequest(): boolean { - const requested = addPluginPanelRequested; - addPluginPanelRequested = false; - return requested; +/** Consume the buffered intent (mount path); returns null when none is pending. */ +export function consumePendingPluginsSectionIntent(): PluginsSectionIntent | null { + const intent = pendingIntent; + pendingIntent = null; + return intent; } -/** Notifies an already-mounted section of new requests; returns an unsubscribe. */ -export function subscribeAddPluginPanelRequests(listener: () => void): () => void { +/** 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/utils/commandIds.ts b/src/browser/utils/commandIds.ts index ec84071fa7..fafbb57703 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -94,6 +94,7 @@ export const CommandIds = { // 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, diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 3a8ed9ccf3..8e7b9297db 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -25,7 +25,7 @@ import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; -import { requestAddPluginPanel } from "@/browser/features/Settings/Sections/pluginsSectionIntents"; +import { publishPluginsSectionIntent } from "@/browser/features/Settings/Sections/pluginsSectionIntents"; import { isTabType, type TabType } from "@/browser/types/rightSidebar"; import { getOrderedBaseTabIds, @@ -1572,7 +1572,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi run: () => openSettings("models"), }, ...(p.agentPluginsEnabled - ? [ + ? ([ { id: CommandIds.settingsOpenSection("plugins"), title: "Settings: Plugins", @@ -1589,10 +1589,51 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi keywords: ["plugin", "install", "add", "git", "clone"], run: () => { // Open the section with the add-plugin form already expanded. - requestAddPluginPanel(); + 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", @@ -1674,6 +1715,9 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi failures.push(`${check.name}: ${result.error}`); } } + // A mounted section only re-queries from its own handlers, so + // tell it the backend state changed under it. + publishPluginsSectionIntent({ type: "refresh" }); if (failures.length > 0) { showCommandFeedbackToast({ type: "error", message: failures.join("; ") }); } else { @@ -1684,7 +1728,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi } }, }, - ] + ] satisfies CommandAction[]) : []), ]); } diff --git a/src/node/services/agentPlugins/sourceInput.test.ts b/src/node/services/agentPlugins/sourceInput.test.ts index ec7b3c1c6a..eb06241a46 100644 --- a/src/node/services/agentPlugins/sourceInput.test.ts +++ b/src/node/services/agentPlugins/sourceInput.test.ts @@ -1,4 +1,6 @@ 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"; @@ -71,6 +73,12 @@ describe("parseAgentPluginSourceInput", () => { 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") + ); + }); + 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/); diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts index 4b9a5beaaa..5d9a896412 100644 --- a/src/node/services/agentPlugins/sourceInput.ts +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -1,3 +1,6 @@ +import * as os from "node:os"; +import * as path from "node:path"; + import { GITHUB_SHORTHAND_PATTERN, normalizeRepoUrlForClone } from "@/node/utils/gitUrls"; /** @@ -55,6 +58,11 @@ export function parseAgentPluginSourceInput(rawInput: string): ParsedAgentPlugin } if (isUrlLike(input)) { + // Git is spawned without a shell, so `~` never expands on its own — + // resolve home-relative local paths here. + if (input === "~" || input.startsWith("~/")) { + return { url: path.join(os.homedir(), input.slice(1)) }; + } // normalizeRepoUrlForClone strips query strings/fragments from URL-like inputs. return { url: normalizeRepoUrlForClone(input) }; } From bc68e771d7b75e31c63fcc6634f43b0cf82ff4ef Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 21:59:04 +0000 Subject: [PATCH 08/24] fix: address Codex review round 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update: stop the plugin's MCP servers BEFORE the old tree is renamed (live servers can lose files mid-swap on POSIX; open handles can fail the rename on Windows), and recycle again post-promote so content changed behind an unchanged command line still restarts — regression test snapshots the installed tree version at each recycle (pre-swap sees v1, post sees v2) - uninstall: best-effort trash deletion (catch + log + leave for staging reclamation) so an undeletable staged tree cannot abort override pruning; update's replaced-tree deletion gets the same treatment — regression test forces EBUSY and verifies uninstall completes and reinstall works - palette Update All / Check for Updates: check failures stay in the final summary even when other updates succeeded (mixed results toast as errors) - section: update-check errors live in separate state from list/mutation errors, so the concurrent mount refresh can never clear an unreachable- remote warning (rendered as its own banner) - sourceInput: expand ~\-style Windows home paths, not just ~/ --- .../Sections/PluginsSettingsSection.tsx | 16 +++- src/browser/utils/commands/sources.ts | 75 ++++++++++++------- .../agentPlugins/installService.test.ts | 70 ++++++++++++++++- .../services/agentPlugins/installService.ts | 41 +++++++--- .../services/agentPlugins/sourceInput.test.ts | 6 ++ src/node/services/agentPlugins/sourceInput.ts | 10 ++- 6 files changed, 176 insertions(+), 42 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 3dc0eacd57..b396099b7f 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -332,7 +332,12 @@ const UninstallConfirm: React.FC<{ 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() ); @@ -374,11 +379,12 @@ export const PluginsSettingsSection: React.FC = () => { const result = await api.agentPlugins.checkUpdates(); if (result.success) { setUpdateChecks(new Map(result.data.map((check) => [check.name, check]))); + setUpdateCheckError(null); } else { - setError(result.error); + setUpdateCheckError(result.error); } } catch (err) { - setError(getErrorMessage(err)); + setUpdateCheckError(getErrorMessage(err)); } finally { setCheckingUpdates(false); } @@ -514,6 +520,12 @@ export const PluginsSettingsSection: React.FC = () => { {error} )} + {updateCheckError && ( +
+ + Update check failed: {updateCheckError} +
+ )}
{items === null ? ( diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 8e7b9297db..1e725ac5ca 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1650,26 +1650,32 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi (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". + // 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) { - showCommandFeedbackToast({ - type: "success", - message: `Updates available: ${updatable.map((check) => check.name).join(", ")}`, - }); - openSettings("plugins"); - } else if (failed.length > 0) { - showCommandFeedbackToast({ - type: "error", - message: `Update check failed for ${failed.map((check) => check.name).join(", ")}`, - }); - openSettings("plugins"); - } else { + 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(", ")}` + ); + } + 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"); }, }, { @@ -1692,13 +1698,16 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const updatable = checks.data.filter( (check) => check.status === "update-available" ); + // 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); if (updatable.length === 0) { - const failed = checks.data.filter((check) => check.status === "error"); - if (failed.length > 0) { - // An unreachable remote is an unknown state, not "up to date". + if (checkFailures.length > 0) { showCommandFeedbackToast({ type: "error", - message: `Update check failed for ${failed.map((check) => check.name).join(", ")}`, + message: `Update check failed for ${checkFailures.join(", ")}`, }); } else { showCommandFeedbackToast({ @@ -1708,24 +1717,36 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi } return; } - const failures: string[] = []; + const updateFailures: string[] = []; + const updatedNames: string[] = []; for (const check of updatable) { const result = await api.agentPlugins.update({ name: check.name }); - if (!result.success) { - failures.push(`${check.name}: ${result.error}`); + 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 backend state changed under it. publishPluginsSectionIntent({ type: "refresh" }); - if (failures.length > 0) { - showCommandFeedbackToast({ type: "error", message: failures.join("; ") }); - } else { - showCommandFeedbackToast({ - type: "success", - message: `Updated ${updatable.map((check) => check.name).join(", ")}`, - }); + + const summary: string[] = []; + if (updatedNames.length > 0) { + summary.push(`Updated ${updatedNames.join(", ")}`); + } + if (updateFailures.length > 0) { + summary.push(`Update failed — ${updateFailures.join("; ")}`); } + if (checkFailures.length > 0) { + summary.push(`Update check failed for ${checkFailures.join(", ")}`); + } + showCommandFeedbackToast({ + // Any failure taints the toast: a partial success must not + // read as a verified all-clear. + type: updateFailures.length > 0 || checkFailures.length > 0 ? "error" : "success", + message: summary.join(". "), + }); }, }, ] satisfies CommandAction[]) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 59a4216b8b..ceb5a68299 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/await-thenable -- bun:test types `await expect(...).rejects.toThrow()` as void */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +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 { execFileAsync } from "@/node/utils/disposableExec"; import { AgentPluginInstallService } from "./installService"; import { @@ -229,6 +230,73 @@ describe("AgentPluginInstallService", () => { 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 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 }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 7121d516a9..e3c5e5da5b 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -878,7 +878,16 @@ export class AgentPluginInstallService { throw new Error(`Failed to persist the plugin registry: ${getErrorMessage(error)}`); } if (stagedTree) { - await this.removeDir(trashDir); + // Best-effort, as documented above: a locked staged tree must not + // abort the remaining uninstall steps (override pruning below keeps + // reinstall from silently re-enabling servers via the same instance + // ID). Leftovers are reclaimed by purgeStaleStaging. + await this.removeDir(trashDir).catch((error: unknown) => { + log.warn("Failed to delete uninstalled plugin tree; leaving it for staging reclamation", { + trashDir, + error: getErrorMessage(error), + }); + }); } await this.pruneWorkspaceOverrides(serverKeyPrefix); @@ -1019,8 +1028,15 @@ export class AgentPluginInstallService { 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); } @@ -1040,7 +1056,14 @@ export class AgentPluginInstallService { throw error; } if (hadOldTree) { - await this.removeDir(trashDir); + // 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 = { @@ -1059,13 +1082,13 @@ export class AgentPluginInstallService { // retrying the update self-heals the mismatch. await this.writeRegistry(registry.map((e) => (e.name === entry.name ? updated : e))); - // Content changed behind a stable path (and possibly an unchanged - // stdio command line) — the signature check cannot see it, so recycle - // explicitly. Servers restart on next use, default-disabled state and - // workspace overrides are untouched (identity is the lexical path). - await this.deps.mcpServerManager?.stopServersWithKeyPrefix( - buildPluginServerKey(this.instanceIdFor(entry.name), "") - ); + // Recycle again post-promote: content changed behind a stable path + // (and possibly an unchanged stdio command line), which the config + // signature cannot see — and a concurrent stream may have relaunched + // servers against the old tree between the pre-swap stop and now. + // 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)}` diff --git a/src/node/services/agentPlugins/sourceInput.test.ts b/src/node/services/agentPlugins/sourceInput.test.ts index eb06241a46..b68e12e2f2 100644 --- a/src/node/services/agentPlugins/sourceInput.test.ts +++ b/src/node/services/agentPlugins/sourceInput.test.ts @@ -77,6 +77,12 @@ describe("parseAgentPluginSourceInput", () => { 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", () => { diff --git a/src/node/services/agentPlugins/sourceInput.ts b/src/node/services/agentPlugins/sourceInput.ts index 5d9a896412..6a93932214 100644 --- a/src/node/services/agentPlugins/sourceInput.ts +++ b/src/node/services/agentPlugins/sourceInput.ts @@ -59,9 +59,13 @@ export function parseAgentPluginSourceInput(rawInput: string): ParsedAgentPlugin if (isUrlLike(input)) { // Git is spawned without a shell, so `~` never expands on its own — - // resolve home-relative local paths here. - if (input === "~" || input.startsWith("~/")) { - return { url: path.join(os.homedir(), input.slice(1)) }; + // resolve home-relative local paths here (both separator styles, so a + // Windows-native `~\plugins\demo` doesn't hand git a literal tilde). + if (input === "~") { + return { url: os.homedir() }; + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return { url: path.join(os.homedir(), input.slice(2)) }; } // normalizeRepoUrlForClone strips query strings/fragments from URL-like inputs. return { url: normalizeRepoUrlForClone(input) }; From 5a2bd105f4204c4da993dd2fc4c3eb343bb1c792 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 22:16:10 +0000 Subject: [PATCH 09/24] fix: address Codex review round 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 MCP startup race: stopServersWithKeyPrefix records an epoch-stamped prefix invalidation; getToolsForWorkspace snapshots the clock before reading config and closes matching instances at every publish point (fresh start, timed-out retry, mid-stream restart) instead of publishing them — an update/uninstall swap during an in-flight startup can no longer leave an old-tree server running. Race test gates startServers, swaps mid-flight, and asserts the instance is closed, not published - registry rewrites are raw-preserving: mutations operate on the raw entry list (per-element validation on read, matched by name on write), so entries/fields from newer builds survive install/update/uninstall on this build; lifecycle test seeds an archive-source entry + unknown field - managed list rows keep registry identity (update/uninstall look up by it); manifest-name drift is surfaced in the description instead of breaking repair from Settings - update revalidates the resolved ref kind before cloning (deleted branch replaced by same-name tag → clear error, registry untouched) - palette Check for Plugin Updates publishes a refresh intent so a mounted section's badges match the toast - pinned phone-viewport story variant (Pixel matrix + mobile1 global) for the narrow-width row layout --- .../PluginsSettingsSection.stories.tsx | 41 +++++++ src/browser/utils/commands/sources.ts | 3 + .../config/schemas/agentPluginInstalls.ts | 13 +- src/common/config/schemas/appConfigOnDisk.ts | 1 - .../agentPlugins/installService.test.ts | 68 +++++++++++ .../services/agentPlugins/installService.ts | 114 +++++++++++++----- src/node/services/mcpServerManager.test.ts | 51 ++++++++ src/node/services/mcpServerManager.ts | 70 +++++++++++ 8 files changed, 320 insertions(+), 41 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx index 92ce15e910..f5a2774aca 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -154,6 +154,47 @@ export const InstalledWithUpdateStates: Story = { }, }; +/** + * 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: () => ( + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("grill"); + await canvas.findByText("update available"); + await canvas.findByRole("button", { name: /Update/ }); + }, +}; + export const UninstallConfirmation: Story = { render: () => ( diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 1e725ac5ca..1b39f19787 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1664,6 +1664,9 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi `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", diff --git a/src/common/config/schemas/agentPluginInstalls.ts b/src/common/config/schemas/agentPluginInstalls.ts index 9102584886..5c754cc9ad 100644 --- a/src/common/config/schemas/agentPluginInstalls.ts +++ b/src/common/config/schemas/agentPluginInstalls.ts @@ -6,12 +6,16 @@ import { } from "@/common/utils/agentPluginName"; /** - * Managed Agent Plugin install registry — persisted as `~/.mux/plugins.json`. + * 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. + * 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 @@ -81,11 +85,6 @@ export const AgentPluginInstallEntrySchema = z.object({ export const AgentPluginInstallsSchema = z.array(AgentPluginInstallEntrySchema); -/** On-disk shape of `~/.mux/plugins.json` (object wrapper leaves room for future fields). */ -export const AgentPluginRegistryFileSchema = z.object({ - plugins: AgentPluginInstallsSchema, -}); - 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 2ded17462a..e8442c51a1 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -24,7 +24,6 @@ export { AgentPluginInstallEntrySchema, AgentPluginInstallSourceSchema, AgentPluginInstallsSchema, - AgentPluginRegistryFileSchema, } from "./agentPluginInstalls"; export type { AgentPluginGitSource, diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index ceb5a68299..8bd4946acb 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -375,6 +375,74 @@ describe("AgentPluginInstallService", () => { 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("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("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 }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index e3c5e5da5b..2cbc0e0183 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import writeFileAtomic from "write-file-atomic"; import { - AgentPluginRegistryFileSchema, + AgentPluginInstallEntrySchema, type AgentPluginGitSource, type AgentPluginInstallEntry, } from "@/common/config/schemas/agentPluginInstalls"; @@ -168,13 +168,13 @@ export class AgentPluginInstallService { // --------------------------------------------------------------------- /** - * Lenient-on-read: a missing/corrupt file or invalid entries degrade to - * "unmanaged dirs" rather than an error (discovery stays the source of - * truth for what loads; the registry only annotates). Name validation in - * the schema doubles as a filesystem-safety gate — a traversal name like - * `..` must never reach targetPathFor. + * Raw `plugins` array exactly as stored on disk. Mutations operate on this + * raw list (matching entries by their `name` property) and write it back + * verbatim, so entries/fields written by newer builds — future source + * kinds, extra registry fields — survive an install/update/uninstall on + * this build (upgrade↔downgrade stays lossless). */ - private async readRegistry(): Promise { + private async readRegistryRaw(): Promise { let raw: string; try { raw = await fsPromises.readFile(this.registryFile, "utf8"); @@ -193,25 +193,28 @@ export class AgentPluginInstallService { return []; } - const wrapper = AgentPluginRegistryFileSchema.safeParse(parsedJson); - if (wrapper.success) { - return wrapper.data.plugins; - } - - // Salvage valid entries individually so one bad entry cannot hide the rest. - const rawEntries = - typeof parsedJson === "object" && + return typeof parsedJson === "object" && parsedJson !== null && Array.isArray((parsedJson as { plugins?: unknown }).plugins) - ? (parsedJson as { plugins: unknown[] }).plugins - : []; + ? (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 = AgentPluginRegistryFileSchema.shape.plugins.element.safeParse(rawEntry); + const parsed = AgentPluginInstallEntrySchema.safeParse(rawEntry); if (parsed.success) { entries.push(parsed.data); } else { - log.warn("Dropping invalid managed plugin registry entry", { + log.debug("Skipping unrecognized managed plugin registry entry (preserved on disk)", { entry: rawEntry, error: parsed.error.message, }); @@ -220,15 +223,29 @@ export class AgentPluginInstallService { return entries; } + private async readRegistry(): Promise { + return this.parseRegistryEntries(await this.readRegistryRaw()); + } + + /** `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. + * reporting success with an unpersisted registry. Takes the RAW entry list + * so unrecognized entries/fields are written back verbatim. */ - private async writeRegistry(entries: AgentPluginInstallEntry[]): Promise { + private async writeRegistry(rawEntries: unknown[]): Promise { await writeFileAtomic( this.registryFile, - JSON.stringify({ plugins: entries }, null, 2), + JSON.stringify({ plugins: rawEntries }, null, 2), "utf-8" ); } @@ -724,8 +741,11 @@ export class AgentPluginInstallService { }, }; try { - const registry = await this.readRegistry(); - await this.writeRegistry([...registry.filter((e) => e.name !== name), entry]); + const rawRegistry = await this.readRegistryRaw(); + await this.writeRegistry([ + ...rawRegistry.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. @@ -777,15 +797,22 @@ export class AgentPluginInstallService { } } + // 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: plugin.name, + 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 } : {}), - ...(plugin.manifest.description !== undefined - ? { description: plugin.manifest.description } - : {}), + ...(description !== undefined ? { description } : {}), ...(entry !== undefined ? { source: entry.source, @@ -832,7 +859,8 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const registry = await this.readRegistry(); + const rawRegistry = await this.readRegistryRaw(); + 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.`); @@ -865,7 +893,9 @@ export class AgentPluginInstallService { } try { - await this.writeRegistry(registry.filter((e) => e.name !== entry.name)); + await this.writeRegistry( + rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== entry.name) + ); } catch (error) { if (stagedTree) { await fsPromises.rename(trashDir, targetPath).catch((rollbackError: unknown) => { @@ -999,7 +1029,8 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const registry = await this.readRegistry(); + const rawRegistry = await this.readRegistryRaw(); + 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.`); @@ -1011,6 +1042,16 @@ export class AgentPluginInstallService { } 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. } @@ -1079,8 +1120,15 @@ export class AgentPluginInstallService { }; // 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. - await this.writeRegistry(registry.map((e) => (e.name === entry.name ? updated : e))); + // retrying the update self-heals the mismatch. Merging over the raw + // entry preserves fields a newer build may have added to it. + await this.writeRegistry( + rawRegistry.map((rawEntry) => + this.rawEntryName(rawEntry) === entry.name + ? { ...(rawEntry as Record), ...updated } + : rawEntry + ) + ); // Recycle again post-promote: content changed behind a stable path // (and possibly an unchanged stdio command line), which the config diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 5f69af06ba..59178d5c63 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -121,6 +121,57 @@ describe("MCPServerManager", () => { manager.dispose(); }); + test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup", 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; + }; + expect(entry.instances.size).toBe(0); + + // Startups that BEGIN after the invalidation are unaffected. + const close2 = mock(() => Promise.resolve(undefined)); + access.startServers = () => Promise.resolve(startResult([[pluginKey, { close: close2 }]])); + access.workspaceServers.delete(workspaceId); + const second = await manager.getToolsForWorkspace(workspaceRequest(workspaceId)); + expect(close2).toHaveBeenCalledTimes(0); + expect(Object.keys(second.tools).length).toBeGreaterThanOrEqual(0); + const secondEntry = access.workspaceServers.get(workspaceId) as { + instances: Map; + }; + expect(secondEntry.instances.size).toBe(1); + }); + 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 549cefc31c..d4544b9e0a 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -719,6 +719,16 @@ export interface MCPServerManagerOptions { export class MCPServerManager { private readonly workspaceServers = new Map(); private readonly workspaceLeases = new Map(); + /** + * Monotonic clock for key-prefix invalidations (stopServersWithKeyPrefix). + * getToolsForWorkspace snapshots it before reading config; any prefix + * invalidated after that snapshot marks the startup's matching instances + * stale, because they may have launched from a plugin tree that was + * swapped/deleted mid-startup. + */ + private prefixInvalidationClock = 0; + /** Latest invalidation epoch per key prefix. */ + private readonly prefixInvalidations = new Map(); private readonly idleCheckInterval: ReturnType; private inlineServers: Record = {}; private readonly policyService: PolicyService | null; @@ -1053,6 +1063,11 @@ export class MCPServerManager { agentPlugins, } = options; + // Snapshot BEFORE reading config: a plugin swap that lands after this + // point may invalidate instances this call starts (see + // closeInvalidatedInstances). + const startupEpoch = this.prefixInvalidationClock; + // Fetch full server info for project-level allowlists and server filtering const allServers = await this.getAllServers(projectPath, trusted, agentPlugins); @@ -1211,6 +1226,9 @@ export class MCPServerManager { return this.getToolsForWorkspace(options); } + // Drop retried instances whose plugin tree was swapped mid-startup. + await this.closeInvalidatedInstances(retriedInstances, startupEpoch, workspaceId); + for (const [serverName, instance] of retriedInstances) { existing.instances.set(serverName, instance); } @@ -1318,6 +1336,9 @@ export class MCPServerManager { restartFailedNames = failedNames; restartTimedOutNames = timedOutNames; + // Drop restarted instances whose plugin tree was swapped mid-startup. + await this.closeInvalidatedInstances(restartedInstances, startupEpoch, workspaceId); + for (const [serverName, instance] of restartedInstances) { existing.instances.set(serverName, instance); } @@ -1391,6 +1412,11 @@ export class MCPServerManager { () => this.markActivity(workspaceId) ); + // A plugin update/uninstall can swap the tree while startServers was + // running; its stopServersWithKeyPrefix scan cannot see instances that + // are not published yet, so close them here instead of publishing. + await this.closeInvalidatedInstances(instances, startupEpoch, workspaceId); + const allFailedNames = [...restartFailedNames, ...startFailedNames]; const stats = this.createWorkspaceStats(enabledEntries.length, instances, allFailedNames); @@ -1420,6 +1446,13 @@ export class MCPServerManager { */ 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); + const workspaceIds: string[] = []; for (const [workspaceId, entry] of this.workspaceServers) { for (const serverKey of entry.instances.keys()) { @@ -1432,6 +1465,43 @@ export class MCPServerManager { await Promise.all(workspaceIds.map((workspaceId) => this.stopServers(workspaceId))); } + /** + * 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; dropping them means the next + * MCP use restarts them from the current tree. + */ + private async closeInvalidatedInstances( + instances: Map, + startedAtEpoch: number, + workspaceId: string + ): Promise { + 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); + 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 }); + } + } + } + async stopServers(workspaceId: string): Promise { const entry = this.workspaceServers.get(workspaceId); if (!entry) return; From 0443e1af3e69959aa6dde22b1d714fbbd19394c9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 23:12:10 +0000 Subject: [PATCH 10/24] fix: address Codex review round 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: instances removed by mid-startup invalidation are queued in the timed-out retry list at every publish point — the entry is published under the unchanged full config signature, so without a retry marker the cached path would serve the reduced map and the updated plugin's tools would stay unavailable indefinitely; the race test now asserts the subsequent getToolsForWorkspace restarts the server from the new tree - P2: stopServersWithKeyPrefix closes ONLY matching instances, preserving the rest of the workspace cache (an unrelated healthy client is no longer torn down under a live lease/mid tool call); removed keys go through the same retry markers; test covers two servers + a held lease --- src/node/services/mcpServerManager.test.ts | 66 +++++++++++++-- src/node/services/mcpServerManager.ts | 93 ++++++++++++++++++---- 2 files changed, 138 insertions(+), 21 deletions(-) diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index 59178d5c63..bc8fa10eba 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -121,7 +121,7 @@ describe("MCPServerManager", () => { manager.dispose(); }); - test("stopServersWithKeyPrefix invalidates instances published by an in-flight startup", async () => { + 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(() => @@ -156,20 +156,76 @@ describe("MCPServerManager", () => { expect(Object.keys(result.tools)).toEqual([]); const entry = access.workspaceServers.get(workspaceId) as { instances: Map; + timedOutServerNames: string[]; }; expect(entry.instances.size).toBe(0); - // Startups that BEGIN after the invalidation are unaffected. + // 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, { close: close2 }]])); - access.workspaceServers.delete(workspaceId); + 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).length).toBeGreaterThanOrEqual(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("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", () => { diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index d4544b9e0a..6a50093e21 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1226,8 +1226,15 @@ export class MCPServerManager { return this.getToolsForWorkspace(options); } - // Drop retried instances whose plugin tree was swapped mid-startup. - await this.closeInvalidatedInstances(retriedInstances, startupEpoch, workspaceId); + // 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). + const invalidatedRetryKeys = await this.closeInvalidatedInstances( + retriedInstances, + startupEpoch, + workspaceId + ); for (const [serverName, instance] of retriedInstances) { existing.instances.set(serverName, instance); @@ -1241,6 +1248,7 @@ export class MCPServerManager { !existing.instances.has(serverName) ), ...retryTimedOutNames, + ...invalidatedRetryKeys, ]; const failedServerNames = [ @@ -1336,8 +1344,15 @@ export class MCPServerManager { restartFailedNames = failedNames; restartTimedOutNames = timedOutNames; - // Drop restarted instances whose plugin tree was swapped mid-startup. - await this.closeInvalidatedInstances(restartedInstances, startupEpoch, workspaceId); + // 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. + const invalidatedRestartKeys = await this.closeInvalidatedInstances( + restartedInstances, + startupEpoch, + workspaceId + ); + restartTimedOutNames = [...restartTimedOutNames, ...invalidatedRestartKeys]; for (const [serverName, instance] of restartedInstances) { existing.instances.set(serverName, instance); @@ -1414,8 +1429,15 @@ export class MCPServerManager { // 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. - await this.closeInvalidatedInstances(instances, startupEpoch, workspaceId); + // 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. + const invalidatedKeys = await this.closeInvalidatedInstances( + instances, + startupEpoch, + workspaceId + ); const allFailedNames = [...restartFailedNames, ...startFailedNames]; const stats = this.createWorkspaceStats(enabledEntries.length, instances, allFailedNames); @@ -1424,7 +1446,7 @@ export class MCPServerManager { configSignature: signature, instances, stats, - timedOutServerNames: startTimedOutNames, + timedOutServerNames: [...startTimedOutNames, ...invalidatedKeys], retryingTimedOutServerNames: new Set(), lastActivity: Date.now(), }); @@ -1453,30 +1475,67 @@ export class MCPServerManager { // instead of publishing them. this.prefixInvalidations.set(prefix, ++this.prefixInvalidationClock); - const workspaceIds: string[] = []; + // 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) { - for (const serverKey of entry.instances.keys()) { - if (serverKey.startsWith(prefix)) { - workspaceIds.push(workspaceId); - break; + 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); + } } - await Promise.all(workspaceIds.map((workspaceId) => this.stopServers(workspaceId))); } /** * 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; dropping them means the next - * MCP use restarts them from the current tree. + * 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 { + ): Promise { + const removedKeys: string[] = []; for (const [serverKey, instance] of [...instances]) { let invalidated = false; for (const [prefix, epoch] of this.prefixInvalidations) { @@ -1490,6 +1549,7 @@ export class MCPServerManager { } instances.delete(serverKey); + removedKeys.push(serverKey); log.info("[MCP] Closing instance invalidated during startup (plugin tree swapped)", { workspaceId, serverKey, @@ -1500,6 +1560,7 @@ export class MCPServerManager { log.warn("Failed to close invalidated MCP server instance", { error, serverKey }); } } + return removedKeys; } async stopServers(workspaceId: string): Promise { From dc141c4c7ea3274ee2343f7982f641ee6f757538 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 23:27:40 +0000 Subject: [PATCH 11/24] fix: address Codex review round 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 uninstall re-invalidation: issue a second stopServersWithKeyPrefix AFTER the tree+registry removal (mirroring update's post-promote stop) so a startup that snapshotted the new epoch but discovered the plugin before the rename cannot keep a server running from the removed tree — test asserts the recycle pair observes tree-present then tree-gone - P1 remove manual useCallback: PluginsSettingsSection handlers are plain functions (React Compiler repo); mount/subscription effects key on [api] with documented eslint-disable per repo precedent - P2 nested raw preservation: update patches only owned fields (lockedSha, updatedAt, manifest version/description) into the RAW entry instead of spreading the Zod-parsed entry — unknown metadata inside source/manifest survives downgrade round-trips (tested) - P2 stale update-check responses: generation counter; only the latest check commits updateChecks/checkingUpdates state - P2 plugin-data staging: when deletion is requested, the data dir is staged out BEFORE the registry commit; failure aborts the uninstall with the row intact so the cleanup can be retried (tested), rollback restores both tree and data on registry-write failure --- .../Sections/PluginsSettingsSection.tsx | 129 ++++++++++-------- .../agentPlugins/installService.test.ts | 90 ++++++++++++ .../services/agentPlugins/installService.ts | 118 ++++++++++++---- 3 files changed, 253 insertions(+), 84 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index b396099b7f..24c457ed23 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { ArrowDownToLine, ArrowLeft, @@ -29,7 +29,7 @@ import { /** * Settings → Plugins (agent-plugins experiment; global scope only). * - * Managed installs come from the `plugins` registry in ~/.mux/config.json; + * 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. @@ -77,7 +77,7 @@ const AddPluginPanel: React.FC<{ const [error, setError] = useState(null); const [preview, setPreview] = useState(null); - const handlePreview = useCallback(async () => { + const handlePreview = async () => { if (!api || input.trim().length === 0 || busy) return; setBusy(true); setError(null); @@ -96,9 +96,9 @@ const AddPluginPanel: React.FC<{ } finally { setBusy(false); } - }, [api, input, ref, busy]); + }; - const handleInstall = useCallback(async () => { + const handleInstall = async () => { if (!api || !preview || busy) return; setBusy(true); setError(null); @@ -117,7 +117,7 @@ const AddPluginPanel: React.FC<{ } finally { setBusy(false); } - }, [api, preview, busy, props]); + }; return (
@@ -354,8 +354,10 @@ export const PluginsSettingsSection: React.FC = () => { ); /** Name of the plugin with an update/uninstall in flight. */ const [busyPlugin, setBusyPlugin] = useState(null); + /** Monotonic id of the latest update check; stale responses must not commit state. */ + const checkGenerationRef = useRef(0); - const refresh = useCallback(async () => { + const refresh = async () => { if (!api) return; try { const result = await api.agentPlugins.list(); @@ -370,13 +372,20 @@ export const PluginsSettingsSection: React.FC = () => { setItems([]); setError(getErrorMessage(err)); } - }, [api]); + }; - const checkForUpdates = useCallback(async () => { + 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); @@ -384,17 +393,22 @@ export const PluginsSettingsSection: React.FC = () => { setUpdateCheckError(result.error); } } catch (err) { - setUpdateCheckError(getErrorMessage(err)); + if (generation === checkGenerationRef.current) { + setUpdateCheckError(getErrorMessage(err)); + } } finally { - setCheckingUpdates(false); + if (generation === checkGenerationRef.current) { + setCheckingUpdates(false); + } } - }, [api]); + }; // Approved update policy: passive check on section open + explicit button only. useEffect(() => { void refresh(); void checkForUpdates(); - }, [refresh, 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(() => { @@ -412,57 +426,52 @@ export const PluginsSettingsSection: React.FC = () => { break; } }); - }, [refresh, checkForUpdates]); + // 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 = useCallback( - 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 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); } - }, - [api, busyPlugin, refresh, checkForUpdates] - ); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusyPlugin(null); + } + }; - const handleUninstall = useCallback( - 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); + 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); } - }, - [api, busyPlugin, refresh] - ); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setBusyPlugin(null); + } + }; return (
diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 8bd4946acb..28855bc540 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -297,6 +297,61 @@ describe("AgentPluginInstallService", () => { 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 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 }); @@ -402,6 +457,41 @@ describe("AgentPluginInstallService", () => { 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 }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 2cbc0e0183..eddcb31058 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -873,12 +873,14 @@ export class AgentPluginInstallService { // Stop running servers before deleting the tree out from under them. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); - // Stage the tree out of the container BEFORE touching the registry so - // either 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 the tree back. Deleting the staged tree is - // best-effort — it sits under the staging root, where stale-dir - // reclamation cleans up leftovers. + // 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; @@ -892,26 +894,56 @@ export class AgentPluginInstallService { // 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. + } + } + try { await this.writeRegistry( rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== entry.name) ); } catch (error) { - if (stagedTree) { - await fsPromises.rename(trashDir, targetPath).catch((rollbackError: unknown) => { - log.error("Failed to restore plugin dir after failed registry write", { - targetPath, + 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) { - // Best-effort, as documented above: a locked staged tree must not - // abort the remaining uninstall steps (override pruning below keeps - // reinstall from silently re-enabling servers via the same instance - // ID). Leftovers are reclaimed by purgeStaleStaging. await this.removeDir(trashDir).catch((error: unknown) => { log.warn("Failed to delete uninstalled plugin tree; leaving it for staging reclamation", { trashDir, @@ -919,12 +951,24 @@ export class AgentPluginInstallService { }); }); } + 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), + }); + }); + } await this.pruneWorkspaceOverrides(serverKeyPrefix); - if (args.deletePluginData) { - await this.removeDir(getPluginDataPath(this.config.rootDir, instanceId)); - } + // 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. + await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + log.info(`Uninstalled agent plugin '${entry.name}'`); }); } @@ -1120,14 +1164,40 @@ export class AgentPluginInstallService { }; // 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. Merging over the raw - // entry preserves fields a newer build may have added to it. + // 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). await this.writeRegistry( - rawRegistry.map((rawEntry) => - this.rawEntryName(rawEntry) === entry.name - ? { ...(rawEntry as Record), ...updated } - : rawEntry - ) + 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 }, + }; + }) ); // Recycle again post-promote: content changed behind a stable path From b6daf6aec0cdf7909a8203b1421e15bac6e490bf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 23:37:18 +0000 Subject: [PATCH 12/24] fix: address Codex review round 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - corrupted plugins.json: reads stay lenient (section renders, dirs show unmanaged) but mutations read strict and refuse with a repair message — treating unparseable content as an empty registry would let the next install rewrite the file with one entry and permanently orphan every previously managed install (test: list lenient, preview/uninstall/update refuse, file never rewritten) - update recycles MCP servers in a finally around the registry write: the tree already swapped, so a failed write must not skip the post-promote invalidation that catches servers launched from the replaced tree (test: ENOSPC write failure → both recycles ran, stale lockedSha keeps the badge, retry self-heals) --- .../agentPlugins/installService.test.ts | 82 +++++++++++++ .../services/agentPlugins/installService.ts | 108 ++++++++++-------- 2 files changed, 145 insertions(+), 45 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 28855bc540..a3b1c92a0b 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -514,6 +514,88 @@ describe("AgentPluginInstallService", () => { 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"); + }); + + 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: (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" }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index eddcb31058..1363ffcb83 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -173,8 +173,14 @@ export class AgentPluginInstallService { * verbatim, so entries/fields written by newer builds — future source * kinds, extra registry fields — survive an install/update/uninstall on * this build (upgrade↔downgrade stays lossless). + * + * A missing file is an empty registry; UNPARSEABLE content is not. Reads + * ("lenient") degrade it to [] 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 readRegistryRaw(): Promise { + private async readRegistryRaw(mode: "lenient" | "strict"): Promise { let raw: string; try { raw = await fsPromises.readFile(this.registryFile, "utf8"); @@ -186,6 +192,11 @@ export class AgentPluginInstallService { try { parsedJson = JSON.parse(raw); } catch (error) { + if (mode === "strict") { + throw new Error( + `The plugin registry (${shortenHome(this.registryFile)}) is corrupted and cannot be parsed: ${getErrorMessage(error)}. Repair or remove the file, then retry.` + ); + } log.warn("Ignoring unparseable plugin registry file", { file: this.registryFile, error: getErrorMessage(error), @@ -223,8 +234,8 @@ export class AgentPluginInstallService { return entries; } - private async readRegistry(): Promise { - return this.parseRegistryEntries(await this.readRegistryRaw()); + private async readRegistry(mode: "lenient" | "strict"): Promise { + return this.parseRegistryEntries(await this.readRegistryRaw(mode)); } /** `name` of a raw registry entry, for identity matching during raw rewrites. */ @@ -688,7 +699,9 @@ export class AgentPluginInstallService { } private async assertNoCollision(name: string): Promise { - const registry = await this.readRegistry(); + // Strict: a corrupted registry must fail installs up front (with the + // repair message) instead of letting a later strict read fail mid-flow. + const registry = await this.readRegistry("strict"); if (registry.some((entry) => entry.name === name)) { throw new Error(`A managed plugin named '${name}' is already installed. Uninstall it first.`); } @@ -741,7 +754,7 @@ export class AgentPluginInstallService { }, }; try { - const rawRegistry = await this.readRegistryRaw(); + const rawRegistry = await this.readRegistryRaw("strict"); await this.writeRegistry([ ...rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== name), entry, @@ -764,7 +777,7 @@ export class AgentPluginInstallService { async list(): Promise { this.assertEnabled(); - const registry = await this.readRegistry(); + const registry = await this.readRegistry("lenient"); const containers: AgentPluginContainer[] = [ { path: this.containerDir, scope: "global" }, { path: path.join(os.homedir(), ".agents", "plugins"), scope: "global" }, @@ -859,7 +872,7 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const rawRegistry = await this.readRegistryRaw(); + const rawRegistry = await this.readRegistryRaw("strict"); const registry = this.parseRegistryEntries(rawRegistry); const entry = registry.find((e) => e.name === args.name); if (!entry) { @@ -1031,7 +1044,7 @@ export class AgentPluginInstallService { async checkUpdates(): Promise { this.assertEnabled(); - const registry = await this.readRegistry(); + const registry = await this.readRegistry("lenient"); return Promise.all( registry.map(async (entry): Promise => { if (entry.source.refType === "commit") { @@ -1073,7 +1086,7 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const rawRegistry = await this.readRegistryRaw(); + const rawRegistry = await this.readRegistryRaw("strict"); const registry = this.parseRegistryEntries(rawRegistry); const entry = registry.find((e) => e.name === args.name); if (!entry) { @@ -1171,42 +1184,47 @@ export class AgentPluginInstallService { // 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). - await this.writeRegistry( - 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 }, - }; - }) - ); - - // Recycle again post-promote: content changed behind a stable path - // (and possibly an unchanged stdio command line), which the config - // signature cannot see — and a concurrent stream may have relaunched - // servers against the old tree between the pre-swap stop and now. - // Servers restart on next use; default-disabled state and workspace - // overrides are untouched (identity is the lexical path). - await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + try { + await this.writeRegistry( + 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)}` From f1fd47e8cd671cccd5f5fde0abc1747377896111 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 23:47:56 +0000 Subject: [PATCH 13/24] fix: address Codex review round 9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - strict registry reads also reject structurally invalid envelopes ({}, {plugins: null}, bare arrays) — parseable corruption must not let a mutation rewrite plugins.json down to a single entry; lenient reads still degrade to unmanaged rows (tested for uninstall against all three shapes, file bytes never rewritten) - mutations preserve the full registry envelope: reads return {envelope, rawEntries} and writes replace only the plugins property, so top-level metadata from newer builds (registry version, migration state) survives install/update/uninstall on this build (tested) --- .../agentPlugins/installService.test.ts | 37 +++++++- .../services/agentPlugins/installService.ts | 94 +++++++++++++------ 2 files changed, 99 insertions(+), 32 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index a3b1c92a0b..b32b711d76 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -553,6 +553,41 @@ describe("AgentPluginInstallService", () => { // 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("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 () => { @@ -575,7 +610,7 @@ describe("AgentPluginInstallService", () => { stops = 0; const internals = serviceWithMcp as unknown as { - writeRegistry: (entries: unknown[]) => Promise; + writeRegistry: (envelope: Record, entries: unknown[]) => Promise; }; const writeSpy = spyOn(internals, "writeRegistry").mockImplementationOnce(() => Promise.reject(new Error("ENOSPC: no space left on device")) diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 1363ffcb83..d6719d683e 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -168,24 +168,38 @@ export class AgentPluginInstallService { // --------------------------------------------------------------------- /** - * Raw `plugins` array exactly as stored on disk. Mutations operate on this - * raw list (matching entries by their `name` property) and write it back - * verbatim, so entries/fields written by newer builds — future source - * kinds, extra registry fields — survive an install/update/uninstall on - * this build (upgrade↔downgrade stays lossless). + * 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; UNPARSEABLE content is not. Reads - * ("lenient") degrade it to [] 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. + * 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 readRegistryRaw(mode: "lenient" | "strict"): Promise { + 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 { - return []; + return { envelope: {}, rawEntries: [] }; } let parsedJson: unknown; @@ -193,22 +207,34 @@ export class AgentPluginInstallService { parsedJson = JSON.parse(raw); } catch (error) { if (mode === "strict") { - throw new Error( - `The plugin registry (${shortenHome(this.registryFile)}) is corrupted and cannot be parsed: ${getErrorMessage(error)}. Repair or remove the file, then retry.` - ); + corrupted(`it cannot be parsed (${getErrorMessage(error)})`); } log.warn("Ignoring unparseable plugin registry file", { file: this.registryFile, error: getErrorMessage(error), }); - return []; + 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 typeof parsedJson === "object" && - parsedJson !== null && - Array.isArray((parsedJson as { plugins?: unknown }).plugins) - ? (parsedJson as { plugins: unknown[] }).plugins - : []; + return { + envelope: parsedJson as Record, + rawEntries: (parsedJson as { plugins: unknown[] }).plugins, + }; } /** @@ -235,7 +261,7 @@ export class AgentPluginInstallService { } private async readRegistry(mode: "lenient" | "strict"): Promise { - return this.parseRegistryEntries(await this.readRegistryRaw(mode)); + return this.parseRegistryEntries((await this.readRegistryDocument(mode)).rawEntries); } /** `name` of a raw registry entry, for identity matching during raw rewrites. */ @@ -250,13 +276,17 @@ export class AgentPluginInstallService { /** * 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 entry list - * so unrecognized entries/fields are written back verbatim. + * 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(rawEntries: unknown[]): Promise { + private async writeRegistry( + envelope: Record, + rawEntries: unknown[] + ): Promise { await writeFileAtomic( this.registryFile, - JSON.stringify({ plugins: rawEntries }, null, 2), + JSON.stringify({ ...envelope, plugins: rawEntries }, null, 2), "utf-8" ); } @@ -754,9 +784,9 @@ export class AgentPluginInstallService { }, }; try { - const rawRegistry = await this.readRegistryRaw("strict"); - await this.writeRegistry([ - ...rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== name), + const { envelope, rawEntries } = await this.readRegistryDocument("strict"); + await this.writeRegistry(envelope, [ + ...rawEntries.filter((rawEntry) => this.rawEntryName(rawEntry) !== name), entry, ]); } catch (error) { @@ -872,7 +902,7 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const rawRegistry = await this.readRegistryRaw("strict"); + const { envelope, rawEntries: rawRegistry } = await this.readRegistryDocument("strict"); const registry = this.parseRegistryEntries(rawRegistry); const entry = registry.find((e) => e.name === args.name); if (!entry) { @@ -939,6 +969,7 @@ export class AgentPluginInstallService { try { await this.writeRegistry( + envelope, rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== entry.name) ); } catch (error) { @@ -1086,7 +1117,7 @@ export class AgentPluginInstallService { this.assertEnabled(); return this.runExclusive(async () => { - const rawRegistry = await this.readRegistryRaw("strict"); + const { envelope, rawEntries: rawRegistry } = await this.readRegistryDocument("strict"); const registry = this.parseRegistryEntries(rawRegistry); const entry = registry.find((e) => e.name === args.name); if (!entry) { @@ -1186,6 +1217,7 @@ export class AgentPluginInstallService { // 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; From b6965bc29869301ae037abff7b708bf61e8ada24 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 8 Aug 2026 23:56:19 +0000 Subject: [PATCH 14/24] fix: address Codex review round 10 - collision checks compare RAW entry names: an entry this build cannot parse (newer build's source kind) still owns its name, so install cannot filter it out and replace it during a downgrade (tested) - only ENOENT means empty registry: other read failures (e.g. mode-000 file) block mutations with a repair message instead of letting the atomic write replace the unreadable file and erase its entries; lenient reads still degrade to unmanaged rows (tested) - install rollback test now injects the write failure via spy (the old dir-at-registry-path trick trips the strict read first) --- .../agentPlugins/installService.test.ts | 68 +++++++++++++++++-- .../services/agentPlugins/installService.ts | 23 ++++++- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index b32b711d76..9dde2d8406 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -566,6 +566,53 @@ describe("AgentPluginInstallService", () => { } }); + 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( @@ -707,18 +754,25 @@ describe("AgentPluginInstallService", () => { test("install rolls back the promoted dir when the registry write fails", async () => { const preview = await service.preview({ input: remoteDir }); - // Occupy the registry path with a directory so the atomic write's rename fails. - await fsPromises.mkdir(registryFile(), { recursive: true }); - await expect( - service.install({ source: preview.source, expectedSha: preview.lockedSha }) - ).rejects.toThrow(/persist the plugin registry/); + 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([]); - // Clearing the obstruction lets the same consented install succeed. - await fsPromises.rmdir(registryFile()); + // 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); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index d6719d683e..78e38ff6a5 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -198,7 +198,21 @@ export class AgentPluginInstallService { let raw: string; try { raw = await fsPromises.readFile(this.registryFile, "utf8"); - } catch { + } 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: [] }; } @@ -731,8 +745,11 @@ export class AgentPluginInstallService { 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. - const registry = await this.readRegistry("strict"); - if (registry.some((entry) => entry.name === name)) { + // 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))) { From e576538423b690b8f1e43e0901003767b49b5883 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 00:07:31 +0000 Subject: [PATCH 15/24] fix: address Codex review round 11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update All: moved tags join the final summary ('Tag moved for X — review in Settings → Plugins') and taint the toast; tag-moved-only results no longer report 'All plugins are up to date' - Update All: the refresh intent publishes before any early return, so a mounted section picks up newly discovered moved tags / check errors even when no branch update applied; tag-moved / check-failure outcomes also navigate to the section --- src/browser/utils/commands/sources.ts | 53 ++++++++++++++++----------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 1b39f19787..df72c3dfa1 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -1695,31 +1695,22 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi showCommandFeedbackToast({ type: "error", message: checks.error }); return; } - // Moved tags are excluded: tags are supposed to be immutable, so - // a moved tag warrants the section's per-plugin warning, not a - // bulk apply. + // 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); - if (updatable.length === 0) { - if (checkFailures.length > 0) { - showCommandFeedbackToast({ - type: "error", - message: `Update check failed for ${checkFailures.join(", ")}`, - }); - } else { - showCommandFeedbackToast({ - type: "success", - message: "All plugins are up to date.", - }); - } - return; - } + const updateFailures: string[] = []; const updatedNames: string[] = []; for (const check of updatable) { @@ -1731,7 +1722,9 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi } } // A mounted section only re-queries from its own handlers, so - // tell it the backend state changed under it. + // 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[] = []; @@ -1741,15 +1734,33 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi 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({ - // Any failure taints the toast: a partial success must not - // read as a verified all-clear. - type: updateFailures.length > 0 || checkFailures.length > 0 ? "error" : "success", + // 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[]) From 8581764548cc19ada08838257545ef8353da154b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 00:15:35 +0000 Subject: [PATCH 16/24] fix: address Codex review round 12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uninstall runs the post-commit MCP invalidation BEFORE override pruning: pruneWorkspaceOverrides can throw from getAllWorkspaceMetadata (outside its per-workspace catch), and a pruning failure must not skip the invalidation that catches servers started from the removed tree mid-uninstall (test: metadata enumeration rejects → both stops ran, uninstall committed) --- .../agentPlugins/installService.test.ts | 45 +++++++++++++++++++ .../services/agentPlugins/installService.ts | 8 ++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 9dde2d8406..c14e45921c 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -321,6 +321,51 @@ describe("AgentPluginInstallService", () => { expect(treeStates).toEqual([true, false]); }); + test("uninstall's post-commit invalidation is not skipped by a pruning failure", async () => { + let stops = 0; + const mcpStub = { + stopServersWithKeyPrefix: () => { + stops += 1; + return Promise.resolve(); + }, + } as unknown as MCPServerManager; + // An overrides service forces pruneWorkspaceOverrides to enumerate + // workspace metadata; make that enumeration throw (outside the + // per-workspace catch). + const overridesStub = { + getOverridesForWorkspace: () => Promise.resolve({}), + setOverridesForWorkspace: () => Promise.resolve(), + }; + const serviceWithMcp = new AgentPluginInstallService(config, { + isEnabled: () => true, + mcpServerManager: mcpStub, + workspaceMcpOverridesService: + overridesStub as unknown as import("@/node/services/workspaceMcpOverridesService").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(); + } + + // Both invalidations ran despite the pruning failure — no server from the + // removed tree can be retained. + expect(stops).toBe(2); + // The uninstall itself was committed before pruning. + expect(await registry()).toEqual([]); + expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); + }); + 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 }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 78e38ff6a5..54a7b9b24b 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1021,15 +1021,17 @@ export class AgentPluginInstallService { }); } - await this.pruneWorkspaceOverrides(serverKeyPrefix); - // 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. + // running from the removed tree. This runs BEFORE override pruning so + // a pruning failure (e.g. metadata enumeration throwing) cannot skip + // the correctness-critical invalidation. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); + await this.pruneWorkspaceOverrides(serverKeyPrefix); + log.info(`Uninstalled agent plugin '${entry.name}'`); }); } From 4b195526aec8a491fca39a3ce0e41d1f96364e70 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 00:18:37 +0000 Subject: [PATCH 17/24] fix: use a static type import in the pruning-failure test (lint) --- src/node/services/agentPlugins/installService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index c14e45921c..ebb918f9e6 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -6,6 +6,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 { execFileAsync } from "@/node/utils/disposableExec"; import { AgentPluginInstallService } from "./installService"; import { @@ -339,8 +340,7 @@ describe("AgentPluginInstallService", () => { const serviceWithMcp = new AgentPluginInstallService(config, { isEnabled: () => true, mcpServerManager: mcpStub, - workspaceMcpOverridesService: - overridesStub as unknown as import("@/node/services/workspaceMcpOverridesService").WorkspaceMcpOverridesService, + workspaceMcpOverridesService: overridesStub as unknown as WorkspaceMcpOverridesService, }); const preview = await serviceWithMcp.preview({ input: remoteDir }); From a22a9e0cc36d2799af5f19b7ba74a06883d9f63c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 00:25:06 +0000 Subject: [PATCH 18/24] fix: address Codex review round 13 - refresh() gets the same request-generation guard as checkForUpdates: an older overlapping list response can no longer resurrect removed rows or stale versions over a newer refresh - uninstall enumerates its override-pruning targets (workspace metadata) BEFORE committing anything: enumeration is the only pruning step that can fail wholesale, and post-commit it would strand stale enabled-server overrides with no Settings row to retry from (reinstall reuses the same instance ID and would silently re-enable servers); per-workspace pruning stays best-effort post-commit (test: enumeration failure aborts fully intact with zero stops, retry completes with both invalidations) --- .../Sections/PluginsSettingsSection.tsx | 16 ++++- .../agentPlugins/installService.test.ts | 23 +++++--- .../services/agentPlugins/installService.ts | 58 ++++++++++++++----- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index 24c457ed23..e4362e0e9c 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -354,13 +354,21 @@ export const PluginsSettingsSection: React.FC = () => { ); /** Name of the plugin with an update/uninstall in flight. */ const [busyPlugin, setBusyPlugin] = useState(null); - /** Monotonic id of the latest update check; stale responses must not commit state. */ + /** 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); @@ -369,8 +377,10 @@ export const PluginsSettingsSection: React.FC = () => { setError(result.error); } } catch (err) { - setItems([]); - setError(getErrorMessage(err)); + if (generation === listGenerationRef.current) { + setItems([]); + setError(getErrorMessage(err)); + } } }; diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index ebb918f9e6..ee5054ff0e 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -322,7 +322,7 @@ describe("AgentPluginInstallService", () => { expect(treeStates).toEqual([true, false]); }); - test("uninstall's post-commit invalidation is not skipped by a pruning failure", async () => { + test("uninstall aborts intact when pruning enumeration fails (pre-commit)", async () => { let stops = 0; const mcpStub = { stopServersWithKeyPrefix: () => { @@ -330,9 +330,12 @@ describe("AgentPluginInstallService", () => { return Promise.resolve(); }, } as unknown as MCPServerManager; - // An overrides service forces pruneWorkspaceOverrides to enumerate - // workspace metadata; make that enumeration throw (outside the - // per-workspace catch). + // 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({}), setOverridesForWorkspace: () => Promise.resolve(), @@ -358,12 +361,16 @@ describe("AgentPluginInstallService", () => { metadataSpy.mockRestore(); } - // Both invalidations ran despite the pruning failure — no server from the - // removed tree can be retained. + // 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); - // The uninstall itself was committed before pruning. expect(await registry()).toEqual([]); - expect(await pathExists(path.join(pluginsDir(), "demo-plugin"))).toBe(false); }); test("uninstall stages plugin-data before committing when deletion is requested", async () => { diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 54a7b9b24b..42cb0b8c1e 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -930,6 +930,11 @@ export class AgentPluginInstallService { 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); @@ -1026,35 +1031,56 @@ export class AgentPluginInstallService { // 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 - // a pruning failure (e.g. metadata enumeration throwing) cannot skip - // the correctness-critical invalidation. + // pruning problems cannot skip the correctness-critical invalidation. await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); - await this.pruneWorkspaceOverrides(serverKeyPrefix); + // Per-workspace failures are caught inside; the failure-prone + // enumeration already happened pre-commit. + await this.pruneWorkspaceOverrides(serverKeyPrefix, workspaceIdsToPrune); log.info(`Uninstalled agent plugin '${entry.name}'`); }); } /** - * Remove `plugin::*` keys from every local workspace's MCP - * overrides. Best-effort per workspace: a missing checkout must not block - * uninstall. Remote runtimes are skipped — they never see plugin servers + * 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 pruneWorkspaceOverrides(serverKeyPrefix: string): Promise { + 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. + */ + private async pruneWorkspaceOverrides( + serverKeyPrefix: string, + workspaceIds: string[] + ): Promise { const overridesService = this.deps.workspaceMcpOverridesService; if (!overridesService) { return; } - const allMetadata = await this.config.getAllWorkspaceMetadata(); - for (const metadata of allMetadata) { - const runtimeType = metadata.runtimeConfig.type; - if (runtimeType !== "local" && runtimeType !== "worktree") { - continue; - } + for (const workspaceId of workspaceIds) { try { - const overrides = await overridesService.getOverridesForWorkspace(metadata.id); + const overrides = 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)); @@ -1072,14 +1098,14 @@ export class AgentPluginInstallService { if (!changed) { continue; } - await overridesService.setOverridesForWorkspace(metadata.id, { + await overridesService.setOverridesForWorkspace(workspaceId, { ...(enabledServers !== undefined ? { enabledServers } : {}), ...(disabledServers !== undefined ? { disabledServers } : {}), ...(toolAllowlist !== undefined ? { toolAllowlist } : {}), }); } catch (error) { log.warn("Failed to prune plugin MCP overrides for workspace", { - workspaceId: metadata.id, + workspaceId, error: getErrorMessage(error), }); } From 09fbbc29e63c9084c0d58d622433968002de6d33 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 00:36:37 +0000 Subject: [PATCH 19/24] fix: address Codex review round 14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit failed per-workspace override prunes persist a retryable tombstone (pendingOverridePrunes in the plugins.json envelope): retried on section open (list), and a reinstall of the same name is hard-gated on the pending prune for its instance-ID prefix — retry-then-refuse, so a stale enabledServers key can never silently re-enable a reinstalled plugin's server. Test: unavailable checkout → uninstall commits with tombstone, reinstall refuses; checkout recovers → override pruned, tombstone cleared, reinstall succeeds --- .../agentPlugins/installService.test.ts | 72 +++++++++ .../services/agentPlugins/installService.ts | 152 +++++++++++++++++- 2 files changed, 219 insertions(+), 5 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index ee5054ff0e..0bb0e19bf4 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -373,6 +373,78 @@ describe("AgentPluginInstallService", () => { 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(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("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 }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 42cb0b8c1e..921f860351 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -777,6 +777,7 @@ export class AgentPluginInstallService { 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 @@ -824,6 +825,12 @@ export class AgentPluginInstallService { 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" }, @@ -1035,8 +1042,30 @@ export class AgentPluginInstallService { await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); // Per-workspace failures are caught inside; the failure-prone - // enumeration already happened pre-commit. - await this.pruneWorkspaceOverrides(serverKeyPrefix, workspaceIdsToPrune); + // enumeration already happened pre-commit. Any workspace whose prune + // failed gets a persisted tombstone (retried on section open, and a + // reinstall of this name is gated on it) so the stale override can + // never silently re-enable a reinstalled server. + const failedPruneIds = await this.pruneWorkspaceOverrides( + serverKeyPrefix, + workspaceIdsToPrune + ); + if (failedPruneIds.length > 0) { + const { envelope, rawEntries } = await this.readRegistryDocument("lenient"); + const pending = this.parsePendingOverridePrunes(envelope).filter( + (prune) => prune.prefix !== serverKeyPrefix + ); + pending.push({ prefix: serverKeyPrefix, workspaceIds: failedPruneIds }); + await this.writePendingOverridePrunes(envelope, rawEntries, pending).catch( + (error: unknown) => { + log.error("Failed to persist pending override prune tombstone", { + serverKeyPrefix, + failedPruneIds, + error: getErrorMessage(error), + }); + } + ); + } log.info(`Uninstalled agent plugin '${entry.name}'`); }); @@ -1068,16 +1097,20 @@ export class AgentPluginInstallService { /** * Remove `plugin::*` keys from the given workspaces' MCP * overrides. Best-effort per workspace: a missing checkout must not block - * uninstall. + * 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 { + ): Promise { const overridesService = this.deps.workspaceMcpOverridesService; if (!overridesService) { - return; + return []; } + const failedWorkspaceIds: string[] = []; for (const workspaceId of workspaceIds) { try { const overrides = await overridesService.getOverridesForWorkspace(workspaceId); @@ -1104,12 +1137,121 @@ export class AgentPluginInstallService { ...(toolAllowlist !== undefined ? { toolAllowlist } : {}), }); } 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. + */ + private parsePendingOverridePrunes( + envelope: Record + ): Array<{ prefix: string; workspaceIds: string[] }> { + const raw = envelope.pendingOverridePrunes; + if (!Array.isArray(raw)) { + return []; + } + const pending: Array<{ prefix: string; workspaceIds: string[] }> = []; + for (const item of raw) { + if (typeof item !== "object" || item === null) continue; + const prefix = (item as { prefix?: unknown }).prefix; + const workspaceIds = (item as { workspaceIds?: unknown }).workspaceIds; + if ( + typeof prefix === "string" && + prefix.length > 0 && + Array.isArray(workspaceIds) && + workspaceIds.every((id): id is string => typeof id === "string") + ) { + pending.push({ prefix, workspaceIds }); + } + } + return pending; + } + + /** Persist the tombstone list into the envelope (removing the key when empty). */ + private async writePendingOverridePrunes( + envelope: Record, + rawEntries: unknown[], + pending: Array<{ prefix: string; workspaceIds: string[] }> + ): Promise { + const nextEnvelope = { ...envelope }; + if (pending.length > 0) { + nextEnvelope.pendingOverridePrunes = pending; + } else { + delete nextEnvelope.pendingOverridePrunes; + } + await this.writeRegistry(nextEnvelope, rawEntries); + } + + /** + * 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. + */ + 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.pruneWorkspaceOverrides(match.prefix, match.workspaceIds); + const remaining = pending + .filter((prune) => prune.prefix !== serverKeyPrefix) + .concat(failed.length > 0 ? [{ prefix: match.prefix, workspaceIds: 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. + */ + private async retryPendingOverridePrunes(): Promise { + const { envelope, rawEntries } = await this.readRegistryDocument("lenient"); + const pending = this.parsePendingOverridePrunes(envelope); + if (pending.length === 0) { + return; + } + + const remaining: Array<{ prefix: string; workspaceIds: string[] }> = []; + for (const prune of pending) { + const failed = await this.pruneWorkspaceOverrides(prune.prefix, prune.workspaceIds); + if (failed.length > 0) { + remaining.push({ prefix: prune.prefix, workspaceIds: failed }); + } + } + + const before = pending.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); + const after = remaining.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); + if (after !== before) { + await this.writePendingOverridePrunes(envelope, rawEntries, remaining).catch( + (error: unknown) => { + log.warn("Failed to persist pending override prune progress", { + error: getErrorMessage(error), + }); + } + ); + } } /** From d005e8c877552b76ea39a79fd19949b0307e0ead Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 00:47:34 +0000 Subject: [PATCH 20/24] fix: address Codex review round 15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: retryPendingOverridePrunes runs its read-modify-write under the exclusive mutation queue — a section-open retry can no longer clobber a concurrent install/update/uninstall with its stale registry snapshot (race test: gated prune during list + concurrent install → entry survives, tombstone cleared) - P2: the uninstall COMMIT write carries a pessimistic tombstone for every workspace to prune; the post-prune write only shrinks it (best-effort), so losing that write leaves the safe over-broad record instead of no record (test: prune + shrink-write both fail → tombstone durable, reinstall gated) - P2: tombstone retries reconcile workspace IDs against current metadata and drop deleted workspaces — a permanently-missing checkout can no longer block reinstall forever (test: dead-ID tombstone → reinstall succeeds, tombstone retired) --- .../agentPlugins/installService.test.ts | 161 ++++++++++++++++++ .../services/agentPlugins/installService.ts | 122 +++++++++---- 2 files changed, 247 insertions(+), 36 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 0bb0e19bf4..cead7a8ef0 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -445,6 +445,167 @@ describe("AgentPluginInstallService", () => { } }); + 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 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 {}; + }, + 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 }); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 921f860351..67ef1615f7 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -996,9 +996,27 @@ export class AgentPluginInstallService { } } + // 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.parsePendingOverridePrunes(envelope).filter( + (prune) => prune.prefix !== serverKeyPrefix + ); + if (workspaceIdsToPrune.length > 0) { + pendingForCommit.push({ prefix: serverKeyPrefix, workspaceIds: workspaceIdsToPrune }); + } + if (pendingForCommit.length > 0) { + commitEnvelope.pendingOverridePrunes = pendingForCommit; + } else { + delete commitEnvelope.pendingOverridePrunes; + } try { await this.writeRegistry( - envelope, + commitEnvelope, rawRegistry.filter((rawEntry) => this.rawEntryName(rawEntry) !== entry.name) ); } catch (error) { @@ -1042,23 +1060,27 @@ export class AgentPluginInstallService { await this.deps.mcpServerManager?.stopServersWithKeyPrefix(serverKeyPrefix); // Per-workspace failures are caught inside; the failure-prone - // enumeration already happened pre-commit. Any workspace whose prune - // failed gets a persisted tombstone (retried on section open, and a - // reinstall of this name is gated on it) so the stale override can - // never silently re-enable a reinstalled server. + // 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 (failedPruneIds.length > 0) { - const { envelope, rawEntries } = await this.readRegistryDocument("lenient"); - const pending = this.parsePendingOverridePrunes(envelope).filter( + if (workspaceIdsToPrune.length > 0) { + const { envelope: envelopeAfter, rawEntries: entriesAfter } = + await this.readRegistryDocument("lenient"); + const pendingAfter = this.parsePendingOverridePrunes(envelopeAfter).filter( (prune) => prune.prefix !== serverKeyPrefix ); - pending.push({ prefix: serverKeyPrefix, workspaceIds: failedPruneIds }); - await this.writePendingOverridePrunes(envelope, rawEntries, pending).catch( + if (failedPruneIds.length > 0) { + pendingAfter.push({ prefix: serverKeyPrefix, workspaceIds: failedPruneIds }); + } + await this.writePendingOverridePrunes(envelopeAfter, entriesAfter, pendingAfter).catch( (error: unknown) => { - log.error("Failed to persist pending override prune tombstone", { + log.warn("Failed to shrink pending override prune tombstone (kept pessimistic)", { serverKeyPrefix, failedPruneIds, error: getErrorMessage(error), @@ -1194,11 +1216,34 @@ export class AgentPluginInstallService { 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. + * 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), ""); @@ -1209,7 +1254,7 @@ export class AgentPluginInstallService { return; } - const failed = await this.pruneWorkspaceOverrides(match.prefix, match.workspaceIds); + const failed = await this.retryPrune(match); const remaining = pending .filter((prune) => prune.prefix !== serverKeyPrefix) .concat(failed.length > 0 ? [{ prefix: match.prefix, workspaceIds: failed }] : []); @@ -1224,34 +1269,39 @@ export class AgentPluginInstallService { /** * 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 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 { - const { envelope, rawEntries } = await this.readRegistryDocument("lenient"); - const pending = this.parsePendingOverridePrunes(envelope); - if (pending.length === 0) { - return; - } - - const remaining: Array<{ prefix: string; workspaceIds: string[] }> = []; - for (const prune of pending) { - const failed = await this.pruneWorkspaceOverrides(prune.prefix, prune.workspaceIds); - if (failed.length > 0) { - remaining.push({ prefix: prune.prefix, workspaceIds: failed }); + return this.runExclusive(async () => { + const { envelope, rawEntries } = await this.readRegistryDocument("lenient"); + const pending = this.parsePendingOverridePrunes(envelope); + if (pending.length === 0) { + return; } - } - const before = pending.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); - const after = remaining.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); - if (after !== before) { - await this.writePendingOverridePrunes(envelope, rawEntries, remaining).catch( - (error: unknown) => { - log.warn("Failed to persist pending override prune progress", { - error: getErrorMessage(error), - }); + const remaining: Array<{ prefix: string; workspaceIds: string[] }> = []; + for (const prune of pending) { + const failed = await this.retryPrune(prune); + if (failed.length > 0) { + remaining.push({ prefix: prune.prefix, workspaceIds: failed }); } - ); - } + } + + const before = pending.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); + const after = remaining.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); + if (after !== before) { + await this.writePendingOverridePrunes(envelope, rawEntries, remaining).catch( + (error: unknown) => { + log.warn("Failed to persist pending override prune progress", { + error: getErrorMessage(error), + }); + } + ); + } + }); } /** From 28d8bc6d0e75db148a4c51a0d1a311e55f9c25e4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 00:59:10 +0000 Subject: [PATCH 21/24] fix: address Codex review round 16 - WorkspaceMcpOverridesService.removeOverridesFile checks the rm exit code and throws: setOverridesForWorkspace must reject when clearing overrides fails, or the tombstone machinery would classify the prune as successful while the stale enabledServers key survives - tombstone rewrites are raw-preserving: unrecognized tombstone variants pass through verbatim and recognized items keep unknown fields when their workspaceIds shrink (test: future variant + extra field survive a full uninstall cycle) - the post-uninstall tombstone shrink re-reads the registry STRICT inside a try/catch: a lenient read degrading transient corruption to an empty document would have rewritten plugins.json with an empty plugin list; on failure the pessimistic commit-write tombstone simply stays --- .../agentPlugins/installService.test.ts | 31 ++++ .../services/agentPlugins/installService.ts | 150 +++++++++++------- .../services/workspaceMcpOverridesService.ts | 14 +- 3 files changed, 140 insertions(+), 55 deletions(-) diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index cead7a8ef0..769c9c6088 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -543,6 +543,37 @@ describe("AgentPluginInstallService", () => { 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 diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 67ef1615f7..8927fb51d0 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -1003,12 +1003,11 @@ export class AgentPluginInstallService { // silently losing the record (stale enabledServers reactivating a // reinstalled server) is not. const commitEnvelope = { ...envelope }; - const pendingForCommit = this.parsePendingOverridePrunes(envelope).filter( - (prune) => prune.prefix !== serverKeyPrefix + const pendingForCommit = this.updateRawPendingPrunes( + this.rawPendingPrunes(envelope), + serverKeyPrefix, + workspaceIdsToPrune ); - if (workspaceIdsToPrune.length > 0) { - pendingForCommit.push({ prefix: serverKeyPrefix, workspaceIds: workspaceIdsToPrune }); - } if (pendingForCommit.length > 0) { commitEnvelope.pendingOverridePrunes = pendingForCommit; } else { @@ -1070,23 +1069,27 @@ export class AgentPluginInstallService { workspaceIdsToPrune ); if (workspaceIdsToPrune.length > 0) { - const { envelope: envelopeAfter, rawEntries: entriesAfter } = - await this.readRegistryDocument("lenient"); - const pendingAfter = this.parsePendingOverridePrunes(envelopeAfter).filter( - (prune) => prune.prefix !== serverKeyPrefix - ); - if (failedPruneIds.length > 0) { - pendingAfter.push({ prefix: serverKeyPrefix, workspaceIds: failedPruneIds }); + // 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), + }); } - await this.writePendingOverridePrunes(envelopeAfter, entriesAfter, pendingAfter).catch( - (error: unknown) => { - log.warn("Failed to shrink pending override prune tombstone (kept pessimistic)", { - serverKeyPrefix, - failedPruneIds, - error: getErrorMessage(error), - }); - } - ); } log.info(`Uninstalled agent plugin '${entry.name}'`); @@ -1176,40 +1179,79 @@ export class AgentPluginInstallService { * 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[] }> { - const raw = envelope.pendingOverridePrunes; - if (!Array.isArray(raw)) { - return []; - } - const pending: Array<{ prefix: string; workspaceIds: string[] }> = []; - for (const item of raw) { - if (typeof item !== "object" || item === null) continue; - const prefix = (item as { prefix?: unknown }).prefix; - const workspaceIds = (item as { workspaceIds?: unknown }).workspaceIds; - if ( - typeof prefix === "string" && - prefix.length > 0 && - Array.isArray(workspaceIds) && - workspaceIds.every((id): id is string => typeof id === "string") - ) { - pending.push({ prefix, workspaceIds }); - } + 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 pending; + return next; } - /** Persist the tombstone list into the envelope (removing the key when empty). */ + /** Persist the raw tombstone list into the envelope (removing the key when empty). */ private async writePendingOverridePrunes( envelope: Record, rawEntries: unknown[], - pending: Array<{ prefix: string; workspaceIds: string[] }> + rawPending: unknown[] ): Promise { const nextEnvelope = { ...envelope }; - if (pending.length > 0) { - nextEnvelope.pendingOverridePrunes = pending; + if (rawPending.length > 0) { + nextEnvelope.pendingOverridePrunes = rawPending; } else { delete nextEnvelope.pendingOverridePrunes; } @@ -1255,9 +1297,11 @@ export class AgentPluginInstallService { } const failed = await this.retryPrune(match); - const remaining = pending - .filter((prune) => prune.prefix !== serverKeyPrefix) - .concat(failed.length > 0 ? [{ prefix: match.prefix, workspaceIds: failed }] : []); + const remaining = this.updateRawPendingPrunes( + this.rawPendingPrunes(envelope), + serverKeyPrefix, + failed + ); await this.writePendingOverridePrunes(envelope, rawEntries, remaining); if (failed.length > 0) { throw new Error( @@ -1282,18 +1326,18 @@ export class AgentPluginInstallService { return; } - const remaining: Array<{ prefix: string; workspaceIds: string[] }> = []; + let rawPending = this.rawPendingPrunes(envelope); + let progressed = false; for (const prune of pending) { const failed = await this.retryPrune(prune); - if (failed.length > 0) { - remaining.push({ prefix: prune.prefix, workspaceIds: failed }); + if (failed.length !== prune.workspaceIds.length) { + progressed = true; + rawPending = this.updateRawPendingPrunes(rawPending, prune.prefix, failed); } } - const before = pending.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); - const after = remaining.reduce((sum, prune) => sum + prune.workspaceIds.length, 0); - if (after !== before) { - await this.writePendingOverridePrunes(envelope, rawEntries, remaining).catch( + if (progressed) { + await this.writePendingOverridePrunes(envelope, rawEntries, rawPending).catch( (error: unknown) => { log.warn("Failed to persist pending override prune progress", { error: getErrorMessage(error), diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index de5df5a7d9..cb62f815aa 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -314,8 +314,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 +328,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}`}` + ); + } } /** From c62f9345a3d24671730fb0b5e91b82f3fe219415 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 01:12:54 +0000 Subject: [PATCH 22/24] fix: address Codex review round 17 - Consent preview now resolves symlinked skill dirs with allowMissing containment (matching runtime assertSkillDirValid), so escaping symlinks surface a warning instead of hiding behind ENOENT while in-root symlinked skills are disclosed. - Plugin location/source lines wrap with break-all so max-length separator-free names cannot overflow the card at phone widths; pinned phone story covers a 64-char name with a scroll-width assertion. --- .../PluginsSettingsSection.stories.tsx | 35 +++++++++++++++++- .../Sections/PluginsSettingsSection.tsx | 12 ++++-- .../agentPlugins/installService.test.ts | 20 ++++++++++ .../services/agentPlugins/installService.ts | 37 +++++++++++++++---- 4 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx index f5a2774aca..731ad09719 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.stories.tsx @@ -77,6 +77,26 @@ const MISSING_ITEM: AgentPluginListItem = { 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, @@ -173,7 +193,7 @@ export const InstalledPhoneViewport: Story = { - + {/* 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 }) => { @@ -192,6 +216,13 @@ export const InstalledPhoneViewport: Story = { 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"); + } }, }; diff --git a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx index e4362e0e9c..c96fa0b22f 100644 --- a/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx +++ b/src/browser/features/Settings/Sections/PluginsSettingsSection.tsx @@ -570,7 +570,10 @@ export const PluginsSettingsSection: React.FC = () => {
- {item.name} + {/* break-all: names can be 64 separator-free chars. */} + + {item.name} + {item.version && ( v{item.version} )} @@ -586,13 +589,16 @@ export const PluginsSettingsSection: React.FC = () => { {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)}` : ""}

diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index 769c9c6088..e836a3642d 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -108,6 +108,26 @@ describe("AgentPluginInstallService", () => { 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(); diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 8927fb51d0..929d81bbf0 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -25,7 +25,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 { hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; import { execFileAsync } from "@/node/utils/disposableExec"; import { discoverAgentPluginAt, @@ -580,17 +580,21 @@ export class AgentPluginInstallService { } private async collectSkills( - skillsDir: string | undefined, + 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()) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); } catch { @@ -598,16 +602,33 @@ export class AgentPluginInstallService { } for (const dirName of entries) { const skillPath = path.join(skillsDir, dirName, "SKILL.md"); - // Missing SKILL.md → not a skill dir; skip silently like runtime discovery. + // 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(skillPath); + stat = await fsPromises.stat(containedSkillPath); } catch { continue; } if (!stat.isFile()) continue; try { - const content = await fsPromises.readFile(skillPath, "utf8"); + const content = await fsPromises.readFile(containedSkillPath, "utf8"); const parsed = parseSkillMarkdown({ content, byteSize: stat.size }); skills.push({ name: parsed.frontmatter.name, @@ -708,7 +729,7 @@ export class AgentPluginInstallService { const targetPath = this.targetPathFor(plugin.name); await this.assertNoCollision(plugin.name); - const skills = await this.collectSkills(plugin.skillsDir, warnings); + const skills = await this.collectSkills(plugin, warnings); const mcpServers = await this.collectMcpServers( plugin, targetPath, @@ -850,7 +871,7 @@ export class AgentPluginInstallService { } const warnings: string[] = []; - const skillCount = (await this.collectSkills(plugin.skillsDir, warnings)).length; + const skillCount = (await this.collectSkills(plugin, warnings)).length; let mcpServerCount = 0; if (plugin.mcpConfigPath !== undefined) { try { From a7e59c1bfd5b7f5c7f036761159eb9c097a5d8bd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 01:36:51 +0000 Subject: [PATCH 23/24] fix: address Codex review round 18 Stale Workspace MCP dialog snapshots could restore plugin:: override keys that an uninstall had just pruned, silently re-enabling a reinstalled plugin's MCP server. - workspace.mcp.get now returns { overrides, revision } where revision is a content hash of the normalized overrides. - workspace.mcp.set requires expectedRevision and rejects with a conflict error when the stored overrides changed since that read; saves are serialized through a write queue so check-and-set is atomic. - WorkspaceMCPModal passes the loaded snapshot's revision on save, so a stale save surfaces 'settings changed while this dialog was open' instead of clobbering the prune. - The uninstaller's override prune passes expectedRevision too and re-reads + re-filters on conflict (bounded retries), so a concurrent dialog save cannot interleave with the prune's read-modify-write. --- .../WorkspaceMCPModal/WorkspaceMCPModal.tsx | 17 +++- src/browser/stories/mocks/orpc.ts | 11 ++- src/common/orpc/schemas/api.ts | 13 ++- src/node/orpc/router.ts | 11 ++- .../agentPlugins/installService.test.ts | 64 ++++++++++++- .../services/agentPlugins/installService.ts | 67 +++++++++----- src/node/services/aiService.ts | 5 +- .../workspaceMcpOverridesService.test.ts | 73 ++++++++++++++- .../services/workspaceMcpOverridesService.ts | 92 ++++++++++++++++--- 9 files changed, 295 insertions(+), 58 deletions(-) diff --git a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx index 2f9cc04b00..8bb8da6e79 100644 --- a/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx +++ b/src/browser/components/WorkspaceMCPModal/WorkspaceMCPModal.tsx @@ -34,6 +34,10 @@ export const WorkspaceMCPModal: React.FC = ({ // State for project servers and workspace overrides const [servers, setServers] = useState>({}); const [overrides, setOverrides] = useState({}); + // Revision of the loaded overrides snapshot. Saves pass it back so the + // backend can reject stale snapshots (e.g. after a plugin uninstall pruned + // this workspace's plugin: keys while the dialog was open). + const [overridesRevision, setOverridesRevision] = useState(null); const [loadingTools, setLoadingTools] = useState>({}); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -66,7 +70,8 @@ export const WorkspaceMCPModal: React.FC = ({ api.workspace.mcp.get({ workspaceId }), ]); setServers(projectServers ?? {}); - setOverrides(workspaceOverrides ?? {}); + setOverrides(workspaceOverrides.overrides ?? {}); + setOverridesRevision(workspaceOverrides.revision); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load MCP configuration"); } finally { @@ -235,11 +240,15 @@ export const WorkspaceMCPModal: React.FC = ({ // Save overrides const handleSave = useCallback(async () => { - if (!api) return; + if (!api || overridesRevision === null) return; setSaving(true); setError(null); try { - const result = await api.workspace.mcp.set({ workspaceId, overrides }); + const result = await api.workspace.mcp.set({ + workspaceId, + overrides, + expectedRevision: overridesRevision, + }); if (!result.success) { setError(result.error); } else { @@ -250,7 +259,7 @@ export const WorkspaceMCPModal: React.FC = ({ } finally { setSaving(false); } - }, [api, workspaceId, overrides, onOpenChange]); + }, [api, workspaceId, overrides, overridesRevision, onOpenChange]); const serverEntries = Object.entries(servers); diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 33bb284ace..4107421f9a 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -1754,8 +1754,15 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, mcp: { get: (input: { workspaceId: string }) => - Promise.resolve(mcpOverrides.get(input.workspaceId) ?? {}), - set: (input: { workspaceId: string; overrides: MockMcpOverrides }) => { + Promise.resolve({ + overrides: mcpOverrides.get(input.workspaceId) ?? {}, + revision: "mock-revision", + }), + set: (input: { + workspaceId: string; + overrides: MockMcpOverrides; + expectedRevision: string; + }) => { mcpOverrides.set(input.workspaceId, input.overrides); return Promise.resolve({ success: true, data: undefined }); }, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index cbc4b7d6c8..c3494f9046 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1834,12 +1834,23 @@ export const workspace = { mcp: { get: { input: z.object({ workspaceId: z.string() }), - output: WorkspaceMCPOverridesSchema, + output: z.object({ + overrides: WorkspaceMCPOverridesSchema, + /** Opaque token for optimistic-concurrency saves (set.expectedRevision). */ + revision: z.string(), + }), }, set: { input: z.object({ workspaceId: z.string(), overrides: WorkspaceMCPOverridesSchema, + /** + * Revision returned by get. The save is rejected if the stored + * overrides changed since then, so a stale dialog snapshot cannot + * silently restore entries removed by a concurrent writer (e.g. an + * Agent Plugin uninstall pruning its `plugin:` keys). + */ + expectedRevision: z.string(), }), output: ResultSchema(z.void(), z.string()), }, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 91a2a15c94..9bb2f27433 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -5607,7 +5607,7 @@ export const router = (authToken?: string) => { policy.mcp.allowUserDefined.remote === false; if (mcpDisabledByPolicy) { - return {}; + return { overrides: {}, revision: "mcp-disabled-by-policy" }; } try { @@ -5615,8 +5615,10 @@ export const router = (authToken?: string) => { input.workspaceId ); } catch { - // Defensive: overrides must never brick workspace UI. - return {}; + // Defensive: overrides must never brick workspace UI. The + // sentinel revision never matches a real one, so a save from + // this unknown state is rejected instead of clobbering data. + return { overrides: {}, revision: "unavailable" }; } }), set: t @@ -5626,7 +5628,8 @@ export const router = (authToken?: string) => { try { await context.workspaceMcpOverridesService.setOverridesForWorkspace( input.workspaceId, - input.overrides + input.overrides, + { expectedRevision: input.expectedRevision } ); return { success: true, data: undefined }; } catch (error) { diff --git a/src/node/services/agentPlugins/installService.test.ts b/src/node/services/agentPlugins/installService.test.ts index e836a3642d..9823996dd2 100644 --- a/src/node/services/agentPlugins/installService.test.ts +++ b/src/node/services/agentPlugins/installService.test.ts @@ -6,7 +6,10 @@ 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 { + WorkspaceMcpOverridesConflictError, + type WorkspaceMcpOverridesService, +} from "@/node/services/workspaceMcpOverridesService"; import { execFileAsync } from "@/node/utils/disposableExec"; import { AgentPluginInstallService } from "./installService"; import { @@ -357,7 +360,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({}), + getOverridesForWorkspace: () => Promise.resolve({ overrides: {}, revision: "r0" }), setOverridesForWorkspace: () => Promise.resolve(), }; const serviceWithMcp = new AgentPluginInstallService(config, { @@ -406,7 +409,10 @@ describe("AgentPluginInstallService", () => { if (overridesBroken) { return Promise.reject(new Error("checkout unavailable")); } - return Promise.resolve(storedOverrides); + return Promise.resolve({ + overrides: storedOverrides, + revision: JSON.stringify(storedOverrides), + }); }, setOverridesForWorkspace: (_id: string, overrides: Record) => { storedOverrides = overrides; @@ -465,6 +471,56 @@ 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 = { @@ -605,7 +661,7 @@ describe("AgentPluginInstallService", () => { const overridesStub = { getOverridesForWorkspace: async () => { await pruneGate; - return {}; + return { overrides: {}, revision: "r0" }; }, setOverridesForWorkspace: () => Promise.resolve(), }; diff --git a/src/node/services/agentPlugins/installService.ts b/src/node/services/agentPlugins/installService.ts index 929d81bbf0..c956fdc79e 100644 --- a/src/node/services/agentPlugins/installService.ts +++ b/src/node/services/agentPlugins/installService.ts @@ -24,7 +24,10 @@ 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 type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import { + WorkspaceMcpOverridesConflictError, + type WorkspaceMcpOverridesService, +} from "@/node/services/workspaceMcpOverridesService"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; import { execFileAsync } from "@/node/utils/disposableExec"; import { @@ -1156,32 +1159,50 @@ 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 { - const overrides = 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) { - continue; + 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; + } } - await overridesService.setOverridesForWorkspace(workspaceId, { - ...(enabledServers !== undefined ? { enabledServers } : {}), - ...(disabledServers !== undefined ? { disabledServers } : {}), - ...(toolAllowlist !== undefined ? { toolAllowlist } : {}), - }); } catch (error) { failedWorkspaceIds.push(workspaceId); log.warn("Failed to prune plugin MCP overrides for workspace", { diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index af3bc54158..9452fba051 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1568,8 +1568,9 @@ export class AIService extends EventEmitter { let mcpOverrides: WorkspaceMCPOverrides | undefined; const loadWorkspaceMcpOverridesStartedAt = Date.now(); try { - mcpOverrides = - await this.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId); + mcpOverrides = ( + await this.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId) + ).overrides; } catch (error) { log.warn("[MCP] Failed to load workspace MCP overrides; continuing without overrides", { workspaceId, diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index 1a2b5be719..2dbfb5d230 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -5,7 +5,10 @@ import * as path from "path"; import { Config } from "@/node/config"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { execBuffered } from "@/node/utils/runtime/helpers"; -import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; +import { + WorkspaceMcpOverridesConflictError, + WorkspaceMcpOverridesService, +} from "./workspaceMcpOverridesService"; function getWorkspacePath(args: { srcDir: string; @@ -64,7 +67,7 @@ describe("WorkspaceMcpOverridesService", () => { }); const service = new WorkspaceMcpOverridesService(config); - const overrides = await service.getOverridesForWorkspace(workspaceId); + const { overrides } = await service.getOverridesForWorkspace(workspaceId); expect(overrides).toEqual({}); expect(await pathExists(path.join(workspacePath, ".mux", "mcp.local.jsonc"))).toBe(false); @@ -165,12 +168,74 @@ describe("WorkspaceMcpOverridesService", () => { expect(await pathExists(filePath)).toBe(true); const roundTrip = await service.getOverridesForWorkspace(workspaceId); - expect(roundTrip).toEqual({ + expect(roundTrip.overrides).toEqual({ disabledServers: ["server-a"], toolAllowlist: { "server-b": ["tool1"] }, }); }); + it("rejects saves with a stale revision instead of clobbering newer overrides", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + await fs.mkdir(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + }, + ], + }); + return cfg; + }); + + const service = new WorkspaceMcpOverridesService(config); + await service.setOverridesForWorkspace(workspaceId, { + enabledServers: ["plugin:abc:server"], + }); + + // Dialog snapshot taken here... + const snapshot = await service.getOverridesForWorkspace(workspaceId); + + // ...then a concurrent writer (e.g. plugin uninstall prune) removes the key. + await service.setOverridesForWorkspace( + workspaceId, + {}, + { expectedRevision: snapshot.revision } + ); + + // Replaying the stale snapshot must fail, not restore the pruned key. + // eslint-disable-next-line @typescript-eslint/await-thenable -- bun-types mistype .rejects.toThrow as void + await expect( + service.setOverridesForWorkspace(workspaceId, snapshot.overrides, { + expectedRevision: snapshot.revision, + }) + ).rejects.toThrow(WorkspaceMcpOverridesConflictError); + + const current = await service.getOverridesForWorkspace(workspaceId); + expect(current.overrides).toEqual({}); + + // A save with the CURRENT revision goes through. + await service.setOverridesForWorkspace( + workspaceId, + { disabledServers: ["other"] }, + { expectedRevision: current.revision } + ); + const after = await service.getOverridesForWorkspace(workspaceId); + expect(after.overrides).toEqual({ disabledServers: ["other"] }); + }); + it("removes workspace-local file when overrides are set to empty", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; @@ -241,7 +306,7 @@ describe("WorkspaceMcpOverridesService", () => { }); const service = new WorkspaceMcpOverridesService(config); - const overrides = await service.getOverridesForWorkspace(workspaceId); + const { overrides } = await service.getOverridesForWorkspace(workspaceId); expect(overrides).toEqual({ disabledServers: ["server-a"], diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index cb62f815aa..408972581e 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import * as path from "path"; import * as jsonc from "jsonc-parser"; import assert from "@/common/utils/assert"; @@ -93,6 +94,28 @@ function normalizeWorkspaceMcpOverrides(raw: unknown): WorkspaceMCPOverrides { return normalized; } +/** + * Opaque revision token for optimistic-concurrency saves. Derived from the + * normalized overrides content, so any successful write (including the Agent + * Plugin uninstaller pruning `plugin:` keys) changes the revision and stale + * snapshots held by an open Workspace MCP dialog are rejected instead of + * silently restoring removed entries. + */ +function computeOverridesRevision(overrides: WorkspaceMCPOverrides): string { + return createHash("sha256").update(JSON.stringify(overrides)).digest("hex").slice(0, 16); +} + +/** Thrown when a save's expectedRevision no longer matches the stored overrides. */ +export class WorkspaceMcpOverridesConflictError extends Error { + constructor() { + super( + "Workspace MCP settings changed while this dialog was open. " + + "Close and reopen it to load the latest values, then reapply your changes." + ); + this.name = "WorkspaceMcpOverridesConflictError"; + } +} + function isEmptyOverrides(overrides: WorkspaceMCPOverrides): boolean { return ( (!overrides.disabledServers || overrides.disabledServers.length === 0) && @@ -340,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, @@ -392,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); - await this.ensureOverridesDir(runtime, workspacePath, metadata.runtimeConfig); - await writeFileString(runtime, jsoncPath, JSON.stringify(normalized, null, 2) + "\n"); - await this.ensureOverridesGitignored(runtime, workspacePath, metadata.runtimeConfig); + 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); + }); } } From 9e9c94e2b8c69458d9782501aa307135cb966901 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 9 Aug 2026 02:20:44 +0000 Subject: [PATCH 24/24] fix: address Codex review round 19 (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An await separated the final prefix-invalidation epoch scan from cache publication. A stopServersWithKeyPrefix continuation scheduled into that microtask yield records its epoch AFTER the scan checked it and scans the published map BEFORE workspaceServers.set runs — both mechanisms miss, and a server started from a removed/replaced plugin tree stays alive without a retry marker. closeInvalidatedInstancesThenPublish now re-scans until the invalidation clock is stable across a full scan and invokes the publish callback synchronously in the same continuation as the final clock check. Any invalidation landing after that check runs its own published-map scan strictly after publication (single-threaded), so it sees the entry and closes matching instances. Applied at all three publication sites (miss path, cached retry path, deferred restart path). Regression test interleaves stopServersWithKeyPrefix into the exact yield window via a one-shot instances-map iterator hook that queues a microtask; it fails on the previous code (stale instance published, close never called) and passes now. --- src/node/services/mcpServerManager.test.ts | 53 ++++++++ src/node/services/mcpServerManager.ts | 133 ++++++++++++++------- 2 files changed, 145 insertions(+), 41 deletions(-) diff --git a/src/node/services/mcpServerManager.test.ts b/src/node/services/mcpServerManager.test.ts index bc8fa10eba..2c51aaa25f 100644 --- a/src/node/services/mcpServerManager.test.ts +++ b/src/node/services/mcpServerManager.test.ts @@ -183,6 +183,59 @@ describe("MCPServerManager", () => { 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"; diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 6a50093e21..db7b65382c 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -1229,27 +1229,30 @@ export class MCPServerManager { // 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). - const invalidatedRetryKeys = await this.closeInvalidatedInstances( + // 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 - ); - - for (const [serverName, instance] of retriedInstances) { - existing.instances.set(serverName, instance); - } + 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, - ...invalidatedRetryKeys, - ]; + existing.timedOutServerNames = [ + ...existing.timedOutServerNames.filter( + (serverName) => + enabledServerNames.has(serverName) && + !retryingServerNames.has(serverName) && + !existing.instances.has(serverName) + ), + ...retryTimedOutNames, + ...invalidatedRetryKeys, + ]; + } + ); const failedServerNames = [ ...existing.stats.failedServerNames.filter( @@ -1346,17 +1349,21 @@ export class MCPServerManager { // 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. - const invalidatedRestartKeys = await this.closeInvalidatedInstances( + // 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 - ); - restartTimedOutNames = [...restartTimedOutNames, ...invalidatedRestartKeys]; + workspaceId, + (invalidatedRestartKeys) => { + restartTimedOutNames = [...restartTimedOutNames, ...invalidatedRestartKeys]; - for (const [serverName, instance] of restartedInstances) { - existing.instances.set(serverName, instance); - } + for (const [serverName, instance] of restartedInstances) { + existing.instances.set(serverName, instance); + } + } + ); } log.info("[MCP] Deferring MCP server restart while stream is active", { @@ -1432,25 +1439,29 @@ export class MCPServerManager { // 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. - const invalidatedKeys = await this.closeInvalidatedInstances( + // cached path would serve the reduced map indefinitely. Publication + // happens inside the stable-clock callback so no invalidation can land + // between the final scan and workspaceServers.set (see + // closeInvalidatedInstancesThenPublish). + const allFailedNames = [...restartFailedNames, ...startFailedNames]; + let stats!: MCPWorkspaceStats; + await this.closeInvalidatedInstancesThenPublish( instances, startupEpoch, - workspaceId + workspaceId, + (invalidatedKeys) => { + stats = this.createWorkspaceStats(enabledEntries.length, instances, allFailedNames); + this.workspaceServers.set(workspaceId, { + configSignature: signature, + instances, + stats, + timedOutServerNames: [...startTimedOutNames, ...invalidatedKeys], + retryingTimedOutServerNames: new Set(), + lastActivity: Date.now(), + }); + } ); - const allFailedNames = [...restartFailedNames, ...startFailedNames]; - const stats = this.createWorkspaceStats(enabledEntries.length, instances, allFailedNames); - - this.workspaceServers.set(workspaceId, { - configSignature: signature, - instances, - stats, - timedOutServerNames: [...startTimedOutNames, ...invalidatedKeys], - retryingTimedOutServerNames: new Set(), - lastActivity: Date.now(), - }); - return { tools: this.collectTools(instances, fullServerInfo, overrides), stats, @@ -1563,6 +1574,46 @@ export class MCPServerManager { return removedKeys; } + /** + * Scan for invalidated instances until the invalidation clock is stable + * across a full scan, then invoke `publish` SYNCHRONOUSLY in the same + * continuation as the final clock check. + * + * Why the loop + sync callback: closeInvalidatedInstances is awaited, so + * there is a microtask yield between its final scan and any code that runs + * after it. A stopServersWithKeyPrefix continuation scheduled into that + * yield records its epoch AFTER the scan checked it and scans the published + * map BEFORE the caller publishes these instances — both mechanisms miss, + * and a server started from a removed/replaced plugin tree would stay + * alive. Re-checking the clock in the caller's continuation and publishing + * synchronously (no await between check and publish) closes the window: + * any invalidation that lands after the check runs its own scan strictly + * after publication, so it sees the published entry and closes matches. + * + * `publish` MUST NOT await; it receives every key closed across all scans + * and must queue them for retry (see closeInvalidatedInstances docs). + */ + private async closeInvalidatedInstancesThenPublish( + instances: Map, + startedAtEpoch: number, + workspaceId: string, + publish: (invalidatedKeys: string[]) => void + ): Promise { + const invalidatedKeys: string[] = []; + for (;;) { + const clockBeforeScan = this.prefixInvalidationClock; + invalidatedKeys.push( + ...(await this.closeInvalidatedInstances(instances, startedAtEpoch, workspaceId)) + ); + // Terminates: the clock only advances on stopServersWithKeyPrefix + // calls, which are finite user-driven plugin update/uninstall events. + if (this.prefixInvalidationClock === clockBeforeScan) { + publish(invalidatedKeys); + return; + } + } + } + async stopServers(workspaceId: string): Promise { const entry = this.workspaceServers.get(workspaceId); if (!entry) return;