Files
rea/tests/boundary/filesystem/zcodeClientConfiguration.test.ts
T
ff56f78594 fix(uninstall): remove the REA-owned ZCode fallback registration (#2783)
* fix(uninstall): remove the REA-owned ZCode fallback registration

ZCode loads the user-scope `~/.agents/mcp.json` `mcpServers` fallback
whenever its native `mcp.servers` table is empty. Uninstall removed REA
only from the native table, so a REA-owned fallback entry became eligible
to load again once the last native server was gone, while uninstall
reported ZCode as removed and complete.

Also visit the ZCode fallback as a standard `mcpServers` JSON
configuration in the uninstall host. It goes through the same
ownership check, `.rea.backup`, readback and restore path as every other
client configuration, so other fallback servers and settings, and a
foreign `rea` entry, are preserved. Its result merges into the ZCode item.

Fixes #2664

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(uninstall): preserve the strict ZCode fallback dialect

Address review findings at their producing boundaries and preserve directly affected consumers. Source inspection and git diff --check completed. Tests and automated checks were not run at the user’s explicit request.

* fix(comparison): recheck historical calls-only changes (#2223)

* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

* fix(ci): isolate Linux Hopper process fixture inventory

* fix(android): make DEX descriptor pattern portable

* fix: repair the static-check failures on the default branch (#2895)

* fix: repair the static-check failures on the default branch

The default branch fails its own required `Static checks` job three ways, so
every pull request reports that job as failed regardless of its own change.

- `npm run format:check` reports `src/domain/functionComparison.test.ts` and
  `src/ida/IdaOperationRunner.ts`.
- `npm run lint` reports a duplicate `import { join } from "node:path"` in
  `tests/boundary/mcp/ghidraInventoryMcp.test.ts` (lines 4 and 17).
- `npm run typecheck` reports `format: "devin"` missing from the client format
  union in `src/application/SupportedClients.ts`, and an unchecked index in
  `tests/boundary/cli/clientProfileLifecycle.test.ts`, whose pair array is
  inferred as `string[][]`.

Apply the pinned formatter, drop the duplicate import, list the `"devin"` format
that its own definition already uses, and mark the pair array `as const`.

Validation: `npm run check:fast` and `npm run format:check` pass.

* fix(setup): keep Devin on the JSON configuration dialect

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com>
Co-authored-by: 吴佳浩Alben <35291372+XiaoBinGan@users.noreply.github.com>
Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
2026-10-13 17:33:26 -07:00

490 lines
16 KiB
TypeScript

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 {
runUninstall,
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<typeof supportedClients>[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 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",
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" });
},
);
});