diff --git a/docs/client-configuration.md b/docs/client-configuration.md index d03984ec8..346ff77be 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -294,7 +294,11 @@ the first native server into an empty table, it copies the fallback's `mcpServers` entries into the native table so they keep loading, and leaves 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. ZCode disables a +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 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/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index f7ab8f0e2..896442835 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -167,7 +167,7 @@ export const parseZcodeFallbackConfiguration = ( ? {} : requireObject(document.mcpServers, "ZCode fallback mcpServers"), serversPath: ["mcpServers"], - dialect: "json", + dialect: "zcode_fallback", legacyServers: {}, }; }; @@ -183,6 +183,7 @@ export const parseClientConfiguration = ( if (format === undefined || format === "unsupported") throw new TypeError("client does not have a supported MCP config format"); // DeepSeek Harness stores a Cordis patch list, not a server table. + if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text); if (format === "dsh") return parseDshCordisDocument(text); const document = parseDocument(text, format); const serversKey = clientConfigurationServersKey(format); diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 20ab3ed29..c4da675e7 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -47,6 +47,7 @@ export interface SetupClient { | "pi" | "kimi" | "hermes" + | "zcode_fallback" | "zcode" | "dsh" | "unsupported"; diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index eac36c73d..d6447a8f6 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -231,12 +231,21 @@ export const systemUninstallHost = ( const clients = supportedClients(home, platform, environment); redirection = appDataRedirection(clients); return Promise.resolve( - clients.flatMap((client) => - uninstallConfigPaths(client).map((configPath) => ({ + clients.flatMap((client) => [ + ...uninstallConfigPaths(client).map((configPath) => ({ ...client, configPath, })), - ), + ...(client.zcodeFallbackPath === undefined + ? [] + : [ + { + ...client, + configPath: client.zcodeFallbackPath, + format: "zcode_fallback" as const, + }, + ]), + ]), ); }, inspectClient: async (client) => { diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts index 4b00a2e01..268747356 100644 --- a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -13,7 +13,10 @@ import { } from "../../../src/application/SetupClientConfiguration.js"; import { skillDestinations } from "../../../src/application/SetupSkill.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; -import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { + runUninstall, + systemUninstallHost, +} from "../../../src/application/Uninstall.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; @@ -257,6 +260,91 @@ describe("ZCode .agents fallback preservation", () => { }); }); +describe("ZCode fallback uninstall", () => { + const owned = { command: command[0], args: command.slice(1) }; + const writeFallback = async (home: string, servers: object) => { + await mkdir(join(home, ".agents"), { recursive: true }); + const text = `${JSON.stringify({ ui: { keep: true }, mcpServers: servers }, null, 2)}\n`; + await writeFile(fallbackPath(home), text); + return text; + }; + + it.each(["comment", "trailing-comma", "bom"])( + "fails closed on strict fallback syntax: %s", + async (mode) => { + const { home } = await zcodeClient(); + const valid = await writeFallback(home, { + rea: owned, + other: { command: "other-server" }, + }); + const original = + mode === "comment" + ? `// unsupported comment\n${valid}` + : mode === "bom" + ? `\uFEFF${valid}` + : valid.replace(/\}\s*$/u, ",}"); + await writeFile(fallbackPath(home), original); + const result = await runUninstall(false, systemUninstallHost(home)); + expect(result.items).toContainEqual( + expect.objectContaining({ name: "zcode", status: "failed" }), + ); + expect(await readFile(fallbackPath(home), "utf8")).toBe(original); + await expect( + readFile(`${fallbackPath(home)}.rea.backup`), + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + + it("removes the REA-owned fallback entry with the native registration", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify({ mcp: { servers: { rea: owned } } }), + ); + const original = await writeFallback(home, { + rea: owned, + other: { command: "other-server" }, + }); + + const result = await runUninstall(false, systemUninstallHost(home)); + + expect(result.status).toBe("complete"); + expect(result.items.find(({ name }) => name === "zcode")?.status).toBe( + "removed", + ); + const native = JSON.parse(await readFile(client.configPath, "utf8")); + expect(native.mcp.servers).toEqual({}); + const fallback = JSON.parse(await readFile(fallbackPath(home), "utf8")); + expect(fallback).toEqual({ + ui: { keep: true }, + mcpServers: { other: { command: "other-server" } }, + }); + expect(await readFile(`${fallbackPath(home)}.rea.backup`, "utf8")).toBe( + original, + ); + }); + + it("removes a fallback-only REA entry and keeps a foreign rea entry", async () => { + for (const [servers, expected] of [ + [{ rea: owned }, {}], + [ + { rea: { command: "someone-else" } }, + { rea: { command: "someone-else" } }, + ], + ] as const) { + const { home } = await zcodeClient(); + await writeFallback(home, servers); + + expect( + (await runUninstall(false, systemUninstallHost(home))).status, + ).toBe("complete"); + expect( + JSON.parse(await readFile(fallbackPath(home), "utf8")).mcpServers, + ).toEqual(expected); + } + }); +}); + describe("ZCode fallback admission", () => { it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])( "refuses malformed fallback REA environment %j before native writes",