From 51d6c87cdbf5f11eaf3cb1862befe704a81827c5 Mon Sep 17 00:00:00 2001 From: Iven Koo <73214171+IvenKooLab@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:51:40 +0800 Subject: [PATCH] feat(clients): register ZCode during setup (#1564) * feat(clients): register ZCode during setup - SupportedClients: zcode definition (~/.zcode/cli/config.json, format zcode, personal skills at ~/.zcode/skills) - ClientConfigurationDocument: zcode dialect reads/writes the nested mcp.servers table; withClientServers now tolerates a missing outer table for fresh documents - docs: add ZCode to the supported-clients table with configuration notes - tests: zcodeClientConfiguration.test.ts (path resolution, nested upsert, fresh/empty configs, uninstall) Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/zcodeClientConfiguration.test.ts 6/6. * fix(clients): preserve ZCode fallback servers and honor enable flag Review follow-up (#1564), two integration gaps: - ZCode reads the user-scope ~/.agents/mcp.json fallback only while the native .zcode config defines no servers at all, with no merging. Setup now copies the fallback's mcpServers entries into the native table when writing the first native server, so registrations that only live in the fallback keep loading; the fallback file is left untouched. A malformed fallback fails setup closed (configure and preflight) instead of being silently suppressed. - ZCode disables a server with "enable": false on its entry, which the generic status parsing (disabled/enabled) did not recognize: doctor reported such a registration as aligned although ZCode never starts it. The ZCode dialect now treats enable: false as disabled (state stale) and setup rewrites the entry on rerun. Docs describe both behaviors; the bundled REA skill's supported client list includes zcode. Verified: tsc --noEmit clean; vitest run tests/boundary/filesystem/zcodeClientConfiguration.test.ts 10/10; npm run check:fast green; npm run verify:test-discovery 733 files. * fix(clients): reject non-regular ZCode fallback files before reading Resolve the fallback through the client-config transaction path and the regular-file admission before parsing it, so a FIFO or other non-regular file at ~/.agents/mcp.json fails setup closed instead of blocking the read indefinitely. Directory and FIFO fallback cases are covered in tests, and the preflight remediation names unreadable fallbacks too. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/connection-and-recovery.md | 2 +- docs/client-configuration.md | 20 + docs/installation.md | 1 + .../ClientConfigurationDocument.ts | 47 +- src/application/ClientRegistrationStatus.ts | 4 +- src/application/SetupClientConfiguration.ts | 99 ++++- src/application/SupportedClients.ts | 17 + .../filesystem/clientLifecycle.test.ts | 1 + .../setupClientConfigCleanupOwnership.test.ts | 60 +++ .../zcodeClientConfiguration.test.ts | 401 ++++++++++++++++++ 10 files changed, 638 insertions(+), 14 deletions(-) create mode 100644 tests/boundary/filesystem/zcodeClientConfiguration.test.ts 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" }); + }, + ); +});