diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index 9b06a93fc..dbddb0eb9 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`, -`hermes`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a +`hermes`, `zcode`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. For unsupported clients, use version-pinned manual stdio registration or CLI. diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 640f559eb..16ba81400 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -145,6 +145,26 @@ REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion, and home expansion before resolving that profile. Setup previews the resolved configuration and skill destinations; doctor and uninstall use those same paths. +## ZCode + +For [ZCode](https://zcode.z.ai/en/docs/mcp-services), setup registers `rea` in the nested `mcp.servers` table of the +user-level `~/.zcode/cli/config.json`. Entries carry `command`, `args`, and +`env` without a transport discriminator. ZCode discovers personal skills from +`~/.zcode/skills` rather than the shared directory, so setup installs its +skill copy there. + +ZCode reads the user-scope `~/.agents/mcp.json` fallback only while the native +`.zcode` config defines no servers at all, with no merging. When setup writes +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 +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 +writing a registration ZCode cannot load. + ## DeepSeek Harness DeepSeek Harness registers MCP clients through a Cordis YAML patch sequence diff --git a/docs/installation.md b/docs/installation.md index 181b75c92..69e800f05 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -280,6 +280,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | OMP | `omp` | | Pi | `pi` | | Hermes | `hermes` | +| ZCode | `zcode` | | DeepSeek Harness | `dsh` | `dsh` is main-only until a release after 6.4.0. The registry check above does diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 27b5393d2..cecbd440a 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -86,6 +86,7 @@ export const clientConfigurationServersKey = ( case "hermes": return "mcp_servers"; case "opencode": + case "zcode": return "mcp"; case "vscode": return "servers"; @@ -116,6 +117,8 @@ const parseDocument = ( if (isEmptyClientConfigurationText(text)) return {}; // Hermes stores MCP servers in `mcp_servers` inside a YAML document. if (format === "hermes") return objectSchema.parse(parseYaml(text)); + // ZCode loads nonempty native files with strict JSON.parse. + if (format === "zcode") return objectSchema.parse(JSON.parse(text)); const errors: ParseError[] = []; // Accept a UTF-8 BOM without shifting diagnostics or editing the original text. const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text; @@ -142,6 +145,23 @@ const requireObject = ( }); }; +/** ZCode reads the same-scope fallback as strict JSON with a mcpServers table. */ +export const parseZcodeFallbackConfiguration = ( + text: string, +): ClientConfigurationDocument => { + const document = requireObject(JSON.parse(text), "ZCode fallback root"); + return { + document, + servers: + document.mcpServers === undefined + ? {} + : requireObject(document.mcpServers, "ZCode fallback mcpServers"), + serversPath: ["mcpServers"], + dialect: "json", + legacyServers: {}, + }; +}; + /** Members of OpenCode V2's `mcp` object that are not server names. */ const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]); @@ -169,6 +189,21 @@ export const parseClientConfiguration = ( const value = document[serversKey]; const servers = value === undefined ? {} : requireObject(value, `"${serversKey}"`); + // ZCode reads stdio servers from the nested `mcp.servers` table of + // `~/.zcode/cli/config.json`; entries carry `command`, `args`, and `env`. + if (format === "zcode") { + const nested = servers.servers; + return { + document, + servers: + nested === undefined + ? {} + : requireObject(nested, `"${serversKey}.servers"`), + serversPath: [serversKey, "servers"], + dialect: format, + legacyServers: {}, + }; + } const native = servers.servers; // A V1 server named `servers` has a string `type` discriminator; the V2 // table may instead hold a server object named `type`. @@ -228,8 +263,16 @@ export const withClientServers = ( : editDshDocument(parsed); const [key = "", nested] = parsed.serversPath; if (nested === undefined) return { ...parsed.document, [key]: servers }; - const outer = { ...objectSchema.parse(parsed.document[key]) }; - if (removeLegacy !== undefined && !OPENCODE_V2_MCP_SETTINGS.has(removeLegacy)) + // A fresh document may not hold the outer table yet (for example a new + // ZCode `config.json` without an `mcp` object), so treat absence as empty. + const outerValue = parsed.document[key]; + const outer = + outerValue === undefined ? {} : { ...objectSchema.parse(outerValue) }; + if ( + parsed.dialect === "opencode_v2" && + removeLegacy !== undefined && + !OPENCODE_V2_MCP_SETTINGS.has(removeLegacy) + ) delete outer[removeLegacy]; return { ...parsed.document, [key]: { ...outer, [nested]: servers } }; }; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index dc246d5e6..e2696e088 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -226,7 +226,9 @@ const registrationAligned = ( const command = [registration.command, ...registration.args]; if ( (client.format !== "grok" && registration.disabled === true) || - (registration.enabled === false && !forcedEnabled) + (registration.enabled === false && !forcedEnabled) || + // ZCode's native flag is `enable`; other dialects leave that field opaque. + (client.format === "zcode" && registration.enable === false) ) return false; if (!isOwnedClientRegistrationCommand(command, currentCommandPath)) diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index a4f4e837e..bfab80cbf 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -1,5 +1,9 @@ import { readClientPolicyBlock } from "./GeminiClientSettings.js"; -import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js"; +import { + readClientConfigurationFiles, + readConfigurationText, +} from "./ClientConfigurationFiles.js"; +import { err, ok, type Result } from "../domain/result.js"; import { clientRegistrationEntry, clientConfigurationValuesEqual, @@ -8,6 +12,7 @@ import { legacyClientServerPath, OMP_DISABLED_SERVERS_KEY, parseClientConfiguration, + parseZcodeFallbackConfiguration, serializeClientConfiguration, withClientServers, type ClientConfigurationDocument, @@ -120,12 +125,15 @@ const configureClientDocument = async ( void cause; return { status: "failed", reason: "readback" }; } - const desired = clientConfigurationDesired( - client, - environment, - command, - parsed, - ); + const fallback = await readZcodeFallbackServers(client, parsed); + if (!fallback.ok) { + throwCleanupFailure(fallback.error); + return { status: "failed", reason: "readback" }; + } + const desired = clientConfigurationDesired(client, environment, command, { + ...parsed, + servers: { ...fallback.value, ...parsed.servers }, + }); if (registrationCurrent(parsed, desired)) return { status: "unchanged" }; return persistClientConfiguration({ client, @@ -134,6 +142,7 @@ const configureClientDocument = async ( original, parsed, desired, + fallbackServers: fallback.value, }); }; @@ -144,6 +153,7 @@ interface ClientConfigurationUpdate { readonly original: string | undefined; readonly parsed: ClientConfigurationDocument; readonly desired: unknown; + readonly fallbackServers: Readonly>; } const persistClientConfiguration = async ({ @@ -153,6 +163,7 @@ const persistClientConfiguration = async ({ original, parsed, desired, + fallbackServers, }: ClientConfigurationUpdate): Promise => { const backupPath = original === undefined ? undefined : `${client.configPath}.rea.backup`; @@ -169,7 +180,11 @@ const persistClientConfiguration = async ({ ); const registered = withClientServers( parsed, - { ...parsed.servers, [PRODUCT_IDENTITY.mcpServerKey]: desired }, + { + ...fallbackServers, + ...parsed.servers, + [PRODUCT_IDENTITY.mcpServerKey]: desired, + }, PRODUCT_IDENTITY.mcpServerKey, ); // OMP's denylist would hide the registration; keep every other listed name. @@ -194,6 +209,9 @@ const persistClientConfiguration = async ({ await writeFileAtomic( transactionPath, serializeClientConfiguration(document, format, original, [ + ...Object.keys(fallbackServers) + .filter((name) => name !== PRODUCT_IDENTITY.mcpServerKey) + .map((name) => clientServerPath(parsed, name)), clientServerPath(parsed, PRODUCT_IDENTITY.mcpServerKey), ...(legacyPath === undefined ? [] : [legacyPath]), ...(enableOmp ? [[OMP_DISABLED_SERVERS_KEY]] : []), @@ -213,7 +231,14 @@ const persistClientConfiguration = async ({ await readRegularFileText(transactionPath), format, ); - if (!registrationCurrent(readback, desired)) { + if ( + !registrationCurrent(readback, desired) || + Object.entries(fallbackServers).some( + ([name, entry]) => + name !== PRODUCT_IDENTITY.mcpServerKey && + !clientConfigurationValuesEqual(readback.servers[name], entry), + ) + ) { await restoreConfig(transactionPath, original); return { status: "failed", reason: "readback" }; } @@ -329,7 +354,14 @@ export const inspectClientConfiguration = async ( original = await readRegularFileText(transactionPath); } catch (cause: unknown) { if (cause instanceof RegularFileCleanupFailure) throw cause; - if (isMissing(cause)) return { status: "create" }; + if (isMissing(cause)) { + const fallback = await readZcodeFallbackServers(client); + if (!fallback.ok) { + throwCleanupFailure(fallback.error); + return { status: "invalid", remediation: fallback.error.detail }; + } + return { status: "create" }; + } return { status: "invalid", remediation: `The configuration file could not be read. Check its permissions before rerunning setup. ${cause instanceof Error ? cause.message : String(cause)}`, @@ -359,6 +391,11 @@ export const inspectClientConfiguration = async ( ); if (registrationCurrent(parsed, desired)) return { status: "already_current" }; + const fallback = await readZcodeFallbackServers(client, parsed); + if (!fallback.ok) { + throwCleanupFailure(fallback.error); + return { status: "invalid", remediation: fallback.error.detail }; + } } catch (cause: unknown) { if (cause instanceof RegularFileCleanupFailure) throw cause; // Malformed configuration is reported with the invalid remediation. @@ -382,6 +419,48 @@ const throwCleanupFailure = (error: ClientConfigurationFileError): void => { if (error.cleanupFailure !== undefined) throw error.cleanupFailure; }; +/** Admit the fallback only when the first native ZCode server would suppress it. */ +const readZcodeFallbackServers = async ( + client: SetupClient, + parsed?: ClientConfigurationDocument, +): Promise< + Result>, ClientConfigurationFileError> +> => { + if ( + client.format !== "zcode" || + (parsed !== undefined && Object.keys(parsed.servers).length > 0) + ) + return ok({}); + const path = client.zcodeFallbackPath; + const detail = `The ZCode fallback configuration ${path ?? "path"} is unresolved, malformed or unreadable. Repair it before rerunning setup: the first native server would suppress that fallback.`; + if (path === undefined) + return err({ kind: "unreadable", path: client.configPath, detail }); + const transactionPath = await resolveClientConfigTransactionPath(path); + if (transactionPath === undefined) + return err({ kind: "unreadable", path, detail }); + const source = await readConfigurationText(transactionPath); + if (!source.ok) + return err({ ...source.error, detail: `${detail} ${source.error.detail}` }); + if (source.value === undefined) return ok({}); + try { + const parsed = parseZcodeFallbackConfiguration(source.value); + const environment = clientRegistrationEnvironment(parsed); + if (!environment.ok) + return err({ + kind: "malformed", + path, + detail: `${detail} ${environment.error.settings.map(({ setting, constraint }) => `${setting}: ${constraint}`).join("; ")}`, + }); + return ok(parsed.servers); + } catch (cause: unknown) { + return err({ + kind: "malformed", + path, + detail: `${detail} ${cause instanceof Error ? cause.message : String(cause)}`, + }); + } +}; + const preserveConfigBackup = async ( source: string, destination: string, diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index b44e5017a..5c74bb972 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -26,6 +26,8 @@ export interface SetupClient { }; /** Unresolved path evidence only; consumers must not perform I/O on it. */ readonly configPathError?: string; + /** Same-scope fallback ZCode stops loading after its first native server. */ + readonly zcodeFallbackPath?: string; readonly markerPath?: string; readonly format?: | "json" @@ -38,6 +40,7 @@ export interface SetupClient { | "omp" | "pi" | "hermes" + | "zcode" | "dsh" | "unsupported"; } @@ -64,6 +67,7 @@ interface ClientDefinition { readonly configPath: ClientPath; readonly markerPath: ClientPath; readonly format: NonNullable; + readonly zcodeFallbackPath?: (context: ClientPathContext) => string; readonly skillPath?: ClientPath; readonly configPaths?: (context: ClientPathContext) => readonly string[]; readonly geminiSettings?: ( @@ -499,6 +503,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: hermesDirectory, format: "hermes", }, + { + name: "zcode", + zcodeFallbackPath: ({ home }: ClientPathContext) => + join(home, ".agents", "mcp.json"), + displayName: "ZCode", + configPath: [".zcode", "cli", "config.json"], + markerPath: [".zcode", "cli"], + skillPath: [".zcode", "skills"], + format: "zcode", + }, { name: "dsh", displayName: "DeepSeek Harness", @@ -590,6 +604,9 @@ export const supportedClients = ( ...new Set([...definition.configPaths(context), configPath]), ], }), + ...(definition.zcodeFallbackPath === undefined + ? {} + : { zcodeFallbackPath: definition.zcodeFallbackPath(context) }), markerPath: marker.value, format: definition.format, }; diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 89c4dc95a..caf154949 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -69,6 +69,7 @@ describe("client configuration filesystem lifecycle", () => { "omp", "pi", "hermes", + "zcode", "dsh", "grok_bot", ]); diff --git a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts index 19a1c77d5..3bf33c312 100644 --- a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts @@ -31,6 +31,66 @@ beforeEach(async () => { afterEach(() => openMock.mockReset()); +describe("ZCode fallback cleanup ownership", () => { + it.each(["preflight", "configure"])( + "retains a failed ZCode fallback close during %s until host cleanup", + async (operation) => { + const home = await createTestTempDirectory("rea-zcode-fallback-close-"); + const host = systemSetupHost( + { platform: "linux", homeDirectory: home } as DoctorHost, + { HOME: home }, + ); + const client = (await host.supportedClients()).find( + ({ name }) => name === "zcode", + ); + if (client?.zcodeFallbackPath === undefined) + throw new Error("ZCode fallback path missing"); + const fallback = client.zcodeFallbackPath; + await mkdir(dirname(fallback), { recursive: true }); + const original = '{"mcpServers":{"other":{"command":"other"}}}\n'; + await writeFile(fallback, original); + const closeAttempts: object[] = []; + let allowClose = false; + openMock.mockImplementation(async (...args) => { + const handle = await actualFs.open(...args); + if (String(args[0]) !== fallback) return handle; + const nativeClose = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closeAttempts.push(handle); + if (!allowClose) throw new Error("held fallback handle"); + await nativeClose(); + }); + return handle; + }); + try { + const action = + operation === "preflight" + ? host.inspectClientConfiguration?.(client, {}, ["rea", "mcp"]) + : host.configureClient(client, {}, ["rea", "mcp"]); + if (action === undefined) throw new Error("setup preflight missing"); + await expect(action).rejects.toMatchObject({ + _tag: "ConfigurationError", + cause: expect.any(RegularFileCleanupFailure), + cleanup: { resources: [fallback] }, + }); + expect(closeAttempts).toHaveLength(2); + expect(closeAttempts[0]).toBe(closeAttempts[1]); + expect(await readFile(fallback, "utf8")).toBe(original); + await expect(readFile(client.configPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + allowClose = true; + expect(await host.close?.()).toBeUndefined(); + expect(closeAttempts).toHaveLength(3); + expect(closeAttempts[2]).toBe(closeAttempts[0]); + } finally { + allowClose = true; + await host.close?.(); + } + }, + ); +}); + describe("setup client configuration cleanup ownership", () => { it("stops client configuration and retries the same alignment handle from host close", async () => { const home = await createTestTempDirectory("rea-setup-config-close-"); diff --git a/tests/boundary/filesystem/zcodeClientConfiguration.test.ts b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts new file mode 100644 index 000000000..4b00a2e01 --- /dev/null +++ b/tests/boundary/filesystem/zcodeClientConfiguration.test.ts @@ -0,0 +1,401 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { + configureClientConfiguration, + inspectClientConfiguration, +} 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 { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const zcodePath = ( + home: string, + platform: NodeJS.Platform = "linux", + env: Parameters[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "zcode"); + +describe("ZCode configuration paths", () => { + it("resolves the user config file and marker directory", () => { + expect(zcodePath("/home/a")).toEqual({ + name: "zcode", + displayName: "ZCode", + configPath: join("/home/a", ".zcode", "cli", "config.json"), + markerPath: join("/home/a", ".zcode", "cli"), + format: "zcode", + zcodeFallbackPath: join("/home/a", ".agents", "mcp.json"), + }); + expect(zcodePath("/Users/a", "darwin")?.configPath).toBe( + join("/Users/a", ".zcode", "cli", "config.json"), + ); + expect(zcodePath("C:\\Users\\a", "win32")?.configPath).toBe( + join("C:\\Users\\a", ".zcode", "cli", "config.json"), + ); + }); + + it("resolves the personal skills directory beside the config root", () => { + expect( + skillDestinations("/home/a", ["zcode"]).map(({ path }) => path), + ).toEqual([ + join("/home/a", ".zcode", "skills", "reverse-engineer-anything"), + ]); + }); +}); + +const zcodeClient = async () => { + const home = await createTestTempDirectory("rea-zcode-"); + const client = zcodePath(home, process.platform); + if (client?.markerPath === undefined) throw new Error("missing ZCode client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; +}; + +describe("ZCode JSON registration", () => { + it("upserts rea into mcp.servers while preserving unrelated settings", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + `{ + "mcp": { + "rea": { "unrelated": true }, + "servers": { + "other": { "command": "other-server" } + } + } +}`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.rea).toEqual({ unrelated: true }); + expect(parsed.mcp.servers.other).toEqual({ command: "other-server" }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ state: "aligned", command }); + }); + + it("creates the mcp.servers table in an existing config without servers", async () => { + const { client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify({ ui: { theme: "dark" } }, null, 2), + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.ui).toEqual({ theme: "dark" }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("registers into an empty config file", async () => { + const { client } = await zcodeClient(); + await writeFile(client.configPath, ""); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + }); + + it("removes only the rea entry on uninstall", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify( + { mcp: { servers: { other: { command: "other-server" } } } }, + null, + 2, + ), + ); + await configureClientConfiguration(client, {}, command); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.other).toEqual({ command: "other-server" }); + expect(parsed.mcp.servers.rea).toBeUndefined(); + }); +}); + +const fallbackPath = (home: string) => join(home, ".agents", "mcp.json"); + +describe("ZCode .agents fallback preservation", () => { + it.each([undefined, '{"ui":{"theme":"dark"}}\n', '{"mcp":{"servers":{}}}\n'])( + "migrates fallback servers with native config %s", + async (original) => { + const { home, client } = await zcodeClient(); + if (original !== undefined) await writeFile(client.configPath, original); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallbackPath(home), + JSON.stringify( + { + mcpServers: { + other: { command: "other-server", env: { TOKEN: "x" } }, + rea: { + command: "old-rea", + args: ["mcp"], + env: { + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + GHIDRA_INSTALL_DIR: "/caller/ghidra", + CALLER_CHOICE: "keep-me", + }, + }, + }, + }, + null, + 2, + ), + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.other).toEqual({ + command: "other-server", + env: { TOKEN: "x" }, + }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + env: { + REA_MCP_INPUT_SCHEMA_PROFILE: "compact", + GHIDRA_INSTALL_DIR: "/caller/ghidra", + CALLER_CHOICE: "keep-me", + }, + }); + if (original !== undefined) { + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + original, + ); + if (JSON.parse(original).ui !== undefined) + expect(parsed.ui).toEqual({ theme: "dark" }); + } + // The fallback file stays in place; ZCode ignores it from now on. + expect( + JSON.parse(await readFile(fallbackPath(home), "utf8")).mcpServers.other, + ).toEqual({ command: "other-server", env: { TOKEN: "x" } }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + }, + ); + + it("leaves an existing native table alone instead of merging the fallback", async () => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile( + fallbackPath(home), + JSON.stringify( + { mcpServers: { shared: { command: "fallback-server" } } }, + null, + 2, + ), + ); + await writeFile( + client.configPath, + JSON.stringify( + { mcp: { servers: { existing: { command: "existing-server" } } } }, + null, + 2, + ), + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const parsed = JSON.parse(await readFile(client.configPath, "utf8")); + expect(parsed.mcp.servers.existing).toEqual({ + command: "existing-server", + }); + expect(parsed.mcp.servers.rea).toEqual({ + command: command[0], + args: command.slice(1), + }); + expect(parsed.mcp.servers.shared).toBeUndefined(); + }); +}); + +describe("ZCode fallback admission", () => { + it.each([{ PORT: 123 }, { REA_MCP_INPUT_SCHEMA_PROFILE: "unknown" }])( + "refuses malformed fallback REA environment %j before native writes", + async (env) => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + const original = JSON.stringify({ + mcpServers: { rea: { command: "old-rea", env } }, + }); + await writeFile(fallbackPath(home), original); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ + status: "invalid", + remediation: expect.stringContaining( + Object.keys(env)[0] ?? "environment", + ), + }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await readFile(fallbackPath(home), "utf8")).toBe(original); + }, + ); + + it("fails closed when the fallback configuration is malformed", async () => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + await writeFile(fallbackPath(home), "{ not json"); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath, "utf8")).rejects.toThrow(); + expect(await inspectClientConfiguration(client, {}, command)).toMatchObject( + { status: "invalid" }, + ); + }); + + it("fails closed when the fallback path is not a regular file", async () => { + const { home, client } = await zcodeClient(); + await mkdir(fallbackPath(home), { recursive: true }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath, "utf8")).rejects.toThrow(); + expect(await inspectClientConfiguration(client, {}, command)).toMatchObject( + { status: "invalid" }, + ); + }); + + it.skipIf(process.platform === "win32")( + "rejects a FIFO fallback without blocking on it", + async () => { + const { home, client } = await zcodeClient(); + await mkdir(join(home, ".agents"), { recursive: true }); + await promisify(execFile)("mkfifo", [fallbackPath(home)]); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + await expect(readFile(client.configPath, "utf8")).rejects.toThrow(); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ status: "invalid" }); + }, + ); +}); + +describe("ZCode disabled registrations", () => { + it("reports enable: false as stale and repairs it on setup", async () => { + const { home, client } = await zcodeClient(); + await writeFile( + client.configPath, + JSON.stringify( + { + mcp: { + servers: { + rea: { + command: command[0], + args: command.slice(1), + enable: false, + }, + }, + }, + }, + null, + 2, + ), + ); + const statusFor = async () => + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"); + expect(await statusFor()).toMatchObject({ state: "stale" }); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(await statusFor()).toMatchObject({ state: "aligned", command }); + }); +}); + +describe("ZCode strict native JSON", () => { + it.each(['{ // comment\n "mcp": {}}', '{"mcp":{},}', "\uFEFF{}"])( + "rejects unreadable native syntax without modifying %s", + async (original) => { + const { home, client } = await zcodeClient(); + await writeFile(client.configPath, original); + expect( + await inspectClientConfiguration(client, {}, command), + ).toMatchObject({ status: "invalid" }); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + await expect( + readFile(`${client.configPath}.rea.backup`), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "zcode"), + ).toMatchObject({ state: "invalid" }); + }, + ); +});