mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 15:56:35 +00:00
Uninstall.ts declared systemUninstallHost twice, preventing module load, with the exported copy returning only clients and the second copy missing clientWriteBlocker. Merge into one exported host carrying both blockers. hasOwnershipEvidence used access() existence, so an unrelated malformed ~/.agents/mcp.json established ownership and blocked every removal. Ownership now requires a readable, recognizable REA-owned row via the injected filesystem, so unrelated shared data stays retained. Also cover ZCode discoveryPaths in its path test.
491 lines
17 KiB
TypeScript
491 lines
17 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"),
|
|
discoveryPaths: [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" });
|
|
},
|
|
);
|
|
});
|