fix(uninstall): protect ZCode fallback registrations (#7970)
fix(uninstall): preserve inactive ZCode fallback servers
This commit is contained in:
1 parent
7b9bcf724f
commit
3dcb40dc26
3 files changed
+109
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }, {}],
|
||||
|
||||
Reference in new issue
Block a user