diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 5e7ab056cf..154f37774d 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -616,9 +616,10 @@ the fallback file untouched. Existing REA environment overrides are preserved when refreshing its command. A malformed or unreadable fallback file fails setup instead of being silently suppressed; repair or remove it and rerun. Uninstall removes an REA-owned `rea` entry from both the native table and the fallback, backing up -each changed file, so an emptied native table cannot load a leftover fallback -registration; other fallback servers, settings and a foreign `rea` entry are -kept. ZCode disables a +each changed file. If removing the last native server would activate a retained +fallback server, uninstall refuses before changing either file; review the +fallback configuration before retrying. Other fallback servers, settings and a +foreign `rea` entry are kept. ZCode disables a server with `enable: false` on its entry; doctor reports such a registration as stale, and rerunning setup removes the flag to repair it. Native configuration uses strict JSON; setup refuses comments, trailing commas and a BOM rather than diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 0befd7f2dc..a20047110b 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -541,6 +541,50 @@ export const systemUninstallHost = ( ); if (!removal.ok) return item(client.name, "failed", removal.error.detail); + // ZCode switches the entire server table to its shared fallback once + // the last native server disappears. Inspect the table it would load, + // not just the REA row: unrelated servers may otherwise become active. + if ( + client.format === "zcode" && + removal.value.removeNative && + Object.keys(read.parsed.servers).length === 1 && + client.zcodeFallbackPath !== undefined + ) { + const fallbackClient: UninstallClient = { + ...client, + configPath: client.zcodeFallbackPath, + configPaths: [client.zcodeFallbackPath], + format: "zcode_fallback", + }; + const fallback = await readClientConfiguration( + fallbackClient, + fileSystem, + resources, + true, + ); + if (fallback.kind === "item") { + if (fallback.item.status !== "skipped") return fallback.item; + } else { + const fallbackRemoval = clientRegistrationRemoval( + fallback.parsed, + fallback.original, + fallbackClient, + ); + if (!fallbackRemoval.ok) + return item(client.name, "failed", fallbackRemoval.error.detail); + const remaining = Object.keys(fallback.parsed.servers).filter( + (name) => + name !== PRODUCT_IDENTITY.mcpServerKey || + !fallbackRemoval.value.removeNative, + ); + if (remaining.length > 0) + return item( + client.name, + "failed", + "Removing REA's last native ZCode server would activate retained fallback servers; no change was made. Review the fallback configuration, then rerun uninstall.", + ); + } + } if ( !removal.value.removeNative && removal.value.legacyPath === undefined diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts index 36443d7c7b..dbaf81143b 100644 --- a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -1,7 +1,7 @@ import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -319,7 +319,6 @@ describe("ZCode fallback uninstall", () => { ); const original = await writeFallback(home, { rea: owned, - other: { command: "other-server" }, }); const result = await runUninstall(false, systemUninstallHost(home)); @@ -333,13 +332,72 @@ describe("ZCode fallback uninstall", () => { const fallback = JSON.parse(await readFile(fallbackPath(home), "utf8")); expect(fallback).toEqual({ ui: { keep: true }, - mcpServers: { other: { command: "other-server" } }, + mcpServers: {}, }); expect(await readFile(`${fallbackPath(home)}.rea.backup`, "utf8")).toBe( original, ); }); + it.each([ + { rea: { command: "/usr/bin/false" } }, + { rea: owned, other: { command: "other-server" } }, + ])("refuses to activate retained fallback servers: %j", async (servers) => { + const { home, client } = await zcodeClient(); + const native = JSON.stringify({ mcp: { servers: { rea: owned } } }); + await writeFile(client.configPath, native); + const fallback = await writeFallback(home, servers); + const cursor = supportedClients(home).find(({ name }) => name === "cursor"); + if (cursor === undefined) throw new Error("missing Cursor client"); + await mkdir(dirname(cursor.configPath), { recursive: true }); + const otherClient = JSON.stringify({ mcpServers: { rea: owned } }); + await writeFile(cursor.configPath, otherClient); + const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.status).toBe("failed"); + expect(await readFile(client.configPath, "utf8")).toBe(native); + expect(await readFile(fallbackPath(home), "utf8")).toBe(fallback); + expect(await readFile(cursor.configPath, "utf8")).toBe(otherClient); + await expect( + readFile(`${client.configPath}.rea.backup`), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + readFile(`${cursor.configPath}.rea.backup`), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("refuses last-native removal when the fallback cannot be interpreted", async () => { + const { home, client } = await zcodeClient(); + const native = JSON.stringify({ mcp: { servers: { rea: owned } } }); + await writeFile(client.configPath, native); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile(fallbackPath(home), "{invalid"); + const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.status).toBe("failed"); + expect(await readFile(client.configPath, "utf8")).toBe(native); + expect(await readFile(fallbackPath(home), "utf8")).toBe("{invalid"); + }); + + it("removes the owned server when another native server keeps the fallback inactive", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify({ + mcp: { servers: { rea: owned, other: { command: "other-server" } } }, + }), + ); + const fallback = await writeFallback(home, { + rea: { command: "/usr/bin/false" }, + }); + const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.status).toBe("complete"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcp.servers, + ).toEqual({ + other: { command: "other-server" }, + }); + expect(await readFile(fallbackPath(home), "utf8")).toBe(fallback); + }); + it("removes a fallback-only REA entry and keeps a foreign rea entry", async () => { for (const [servers, expected] of [ [{ rea: owned }, {}],