fix(uninstall): protect ZCode fallback registrations (#7970)

fix(uninstall): preserve inactive ZCode fallback servers
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 10:05:35 +08:00
1 parent 7b9bcf724f
commit 3dcb40dc26
3 files changed
+109 -6

No files matched your search

+4 -3
View File
@@ -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
+44
View File
@@ -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
@@ -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 }, {}],