mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
495 lines
18 KiB
TypeScript
495 lines
18 KiB
TypeScript
import {
|
|
chmod,
|
|
copyFile,
|
|
lstat,
|
|
readFile,
|
|
realpath,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { parse as parseToml } from "smol-toml";
|
|
|
|
import {
|
|
configureClientConfiguration,
|
|
inspectClientConfiguration,
|
|
} from "../../../src/application/SetupClientConfiguration.js";
|
|
import {
|
|
systemUninstallHost,
|
|
type UninstallFileSystem,
|
|
} from "../../../src/application/Uninstall.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { ClientConfigurationPublication } from "../../../src/application/ClientConfigurationPublication.js";
|
|
|
|
const failedVerificationFileSystem = (
|
|
failure: "verification" | "recovery",
|
|
): UninstallFileSystem => {
|
|
return {
|
|
readText: (path) => readFile(path, "utf8"),
|
|
copy: (source, destination) =>
|
|
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
|
|
publishConfiguration: async (path, contents, original) => {
|
|
const publication = await ClientConfigurationPublication.write(
|
|
path,
|
|
contents,
|
|
original,
|
|
);
|
|
publication.assertCurrent = async () => {
|
|
throw new Error("verification failed");
|
|
};
|
|
if (failure === "recovery")
|
|
publication.restore = async () => {
|
|
throw new Error("restore failed");
|
|
};
|
|
return publication;
|
|
},
|
|
stat: (path) => lstat(path),
|
|
realpath: (path) => realpath(path),
|
|
remove: (path) => rm(path, { recursive: true }),
|
|
};
|
|
};
|
|
|
|
describe("uninstall backups after edits made since setup", () => {
|
|
const editedSinceSetup = async (prefix: string) => {
|
|
const root = await createTestTempDirectory(prefix);
|
|
const configPath = join(root, "config.json");
|
|
const client = { name: "cursor", format: "json", configPath } as const;
|
|
const beforeSetup = '{"keep":"before setup"}\n';
|
|
await writeFile(configPath, beforeSetup);
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toMatchObject({ status: "configured" });
|
|
const configured = JSON.parse(await readFile(configPath, "utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const beforeUninstall = `${JSON.stringify({ ...configured, keep: "edited after setup" }, null, 2)}\n`;
|
|
await writeFile(configPath, beforeUninstall);
|
|
const recoveryPath = `${configPath}.rea.backup.${createHash("sha256")
|
|
.update(beforeUninstall, "utf8")
|
|
.digest("hex")}.recovery`;
|
|
return {
|
|
root,
|
|
configPath,
|
|
client,
|
|
beforeSetup,
|
|
beforeUninstall,
|
|
recoveryPath,
|
|
};
|
|
};
|
|
|
|
it("preserves the pre-uninstall bytes when removal succeeds", async () => {
|
|
const {
|
|
root,
|
|
configPath,
|
|
client,
|
|
beforeSetup,
|
|
beforeUninstall,
|
|
recoveryPath,
|
|
} = await editedSinceSetup("rea-uninstall-current-backup-");
|
|
expect((await systemUninstallHost(root).removeClient(client)).status).toBe(
|
|
"removed",
|
|
);
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
|
|
beforeSetup,
|
|
);
|
|
expect(await readFile(recoveryPath, "utf8")).toBe(beforeUninstall);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"verification",
|
|
(path: string) =>
|
|
`Configuration could not be updated. The original was restored and its backup at ${path} was retained. Repair the configuration or restore that backup, then rerun uninstall.`,
|
|
],
|
|
[
|
|
"recovery",
|
|
(path: string) =>
|
|
`Configuration could not be updated or restored. Restore the backup at ${path} manually, then rerun uninstall.`,
|
|
],
|
|
] as const)(
|
|
"names the backup holding the pre-uninstall bytes when %s fails",
|
|
async (failure, message) => {
|
|
const {
|
|
root,
|
|
configPath,
|
|
client,
|
|
beforeSetup,
|
|
beforeUninstall,
|
|
recoveryPath,
|
|
} = await editedSinceSetup("rea-uninstall-current-backup-failure-");
|
|
expect(
|
|
await systemUninstallHost(
|
|
root,
|
|
failedVerificationFileSystem(failure),
|
|
).removeClient(client),
|
|
).toEqual({
|
|
name: "cursor",
|
|
status: "failed",
|
|
detail: message(recoveryPath),
|
|
});
|
|
if (failure === "verification")
|
|
expect(await readFile(configPath, "utf8")).toBe(beforeUninstall);
|
|
else
|
|
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({
|
|
keep: "edited after setup",
|
|
mcpServers: {},
|
|
});
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
|
|
beforeSetup,
|
|
);
|
|
expect(await readFile(recoveryPath, "utf8")).toBe(beforeUninstall);
|
|
},
|
|
);
|
|
|
|
it("blocks removal when the content-addressed backup holds other data", async () => {
|
|
const { root, configPath, client, beforeUninstall, recoveryPath } =
|
|
await editedSinceSetup("rea-uninstall-current-backup-occupied-");
|
|
await writeFile(recoveryPath, "occupied by other data");
|
|
const result = await systemUninstallHost(root).removeClient(client);
|
|
expect(result.status).toBe("failed");
|
|
expect(result.detail).toContain(recoveryPath);
|
|
expect(await readFile(configPath, "utf8")).toBe(beforeUninstall);
|
|
expect(await readFile(recoveryPath, "utf8")).toBe("occupied by other data");
|
|
});
|
|
|
|
it.each([
|
|
["missing", async () => undefined],
|
|
[
|
|
"wrong",
|
|
(_source: string, destination: string) =>
|
|
writeFile(destination, "not the current configuration", {
|
|
flag: "wx",
|
|
}),
|
|
],
|
|
] as const)(
|
|
"blocks removal when the reported copy leaves %s backup bytes",
|
|
async (_label, copy) => {
|
|
const { root, configPath, client, beforeUninstall } =
|
|
await editedSinceSetup("rea-uninstall-current-backup-unverified-");
|
|
let writes = 0;
|
|
const result = await systemUninstallHost(root, {
|
|
...failedVerificationFileSystem("verification"),
|
|
copy,
|
|
publishConfiguration: async () => {
|
|
writes += 1;
|
|
throw new Error("unexpected publication before verified backup");
|
|
},
|
|
}).removeClient(client);
|
|
expect(result.status).toBe("failed");
|
|
expect(result.detail).toMatch(
|
|
/^Configuration could not be backed up, so no change was made\. /,
|
|
);
|
|
expect(writes).toBe(0);
|
|
expect(await readFile(configPath, "utf8")).toBe(beforeUninstall);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("accepts a literal UTF-8 replacement character without changing its value", async () => {
|
|
const root = await createTestTempDirectory("rea-client-config-utf8-");
|
|
const configPath = join(root, "config.json");
|
|
const original = Buffer.concat([
|
|
Buffer.from('{"label":"'),
|
|
Buffer.from("\uFFFD"),
|
|
Buffer.from('"}'),
|
|
]);
|
|
await writeFile(configPath, original);
|
|
const result = await configureClientConfiguration(
|
|
{ name: "cursor", format: "json", configPath },
|
|
{},
|
|
["rea", "mcp"],
|
|
);
|
|
expect(result.status).toBe("configured");
|
|
expect(await readFile(`${configPath}.rea.backup`)).toEqual(original);
|
|
expect(JSON.parse(await readFile(configPath, "utf8")).label).toBe("\uFFFD");
|
|
});
|
|
|
|
it("previews and creates a current recovery backup while preserving the first backup", async () => {
|
|
const root = await createTestTempDirectory("rea-client-current-recovery-");
|
|
const configPath = join(root, "config.json");
|
|
const firstOriginal = '{"keep":"first"}\n';
|
|
const currentOriginal = '{"keep":"current"}\n';
|
|
const client = { name: "cursor", format: "json", configPath } as const;
|
|
await writeFile(configPath, currentOriginal);
|
|
await writeFile(`${configPath}.rea.backup`, firstOriginal);
|
|
|
|
const recoveryPath = `${configPath}.rea.backup.${createHash("sha256")
|
|
.update(currentOriginal, "utf8")
|
|
.digest("hex")}.recovery`;
|
|
expect(await inspectClientConfiguration(client, {}, ["rea", "mcp"])).toEqual({
|
|
status: "update",
|
|
backupPath: recoveryPath,
|
|
});
|
|
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({ status: "configured", backupPath: recoveryPath });
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
|
|
firstOriginal,
|
|
);
|
|
expect(await readFile(recoveryPath, "utf8")).toBe(currentOriginal);
|
|
});
|
|
|
|
it("blocks configuration when the planned content-addressed backup is occupied", async () => {
|
|
const root = await createTestTempDirectory("rea-client-recovery-conflict-");
|
|
const configPath = join(root, "config.json");
|
|
const firstOriginal = '{"keep":"first"}\n';
|
|
const currentOriginal = '{"keep":"current"}\n';
|
|
const client = { name: "cursor", format: "json", configPath } as const;
|
|
await writeFile(configPath, currentOriginal);
|
|
await writeFile(`${configPath}.rea.backup`, firstOriginal);
|
|
const recoveryPath = `${configPath}.rea.backup.${createHash("sha256")
|
|
.update(currentOriginal, "utf8")
|
|
.digest("hex")}.recovery`;
|
|
await writeFile(recoveryPath, "occupied by other data");
|
|
|
|
expect(
|
|
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toMatchObject({
|
|
status: "invalid",
|
|
remediation: expect.stringContaining(recoveryPath),
|
|
});
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({ status: "failed", reason: "backup" });
|
|
expect(await readFile(configPath, "utf8")).toBe(currentOriginal);
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
|
|
firstOriginal,
|
|
);
|
|
expect(await readFile(recoveryPath, "utf8")).toBe("occupied by other data");
|
|
});
|
|
|
|
describe("client configuration write failures", () => {
|
|
it.skipIf(process.platform === "win32" || process.getuid?.() === 0).each([
|
|
["json", configureClientConfiguration],
|
|
["toml", configureClientConfiguration],
|
|
] as const)(
|
|
"classifies a denied %s write as a write failure",
|
|
async (format, configure) => {
|
|
const root = await createTestTempDirectory("rea-client-unwritable-");
|
|
await chmod(root, 0o500);
|
|
try {
|
|
expect(
|
|
await configure({
|
|
name: format === "toml" ? "codex" : "cursor",
|
|
format,
|
|
configPath: join(root, `config.${format}`),
|
|
}),
|
|
).toMatchObject({
|
|
status: "failed",
|
|
reason: "write",
|
|
failure: {
|
|
operation: "publish",
|
|
path: join(root, `config.${format}`),
|
|
systemCode: expect.stringMatching(/^(?:EACCES|EPERM)$/u),
|
|
},
|
|
});
|
|
} finally {
|
|
await chmod(root, 0o700);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("TOML client configuration comparison", () => {
|
|
it("replaces a registration containing a non-finite TOML float and preserves unrelated settings", async () => {
|
|
const value = "-inf";
|
|
const root = await createTestTempDirectory("rea-client-toml-");
|
|
const configPath = join(root, "config.toml");
|
|
const original = `unrelated = ${value}\n[mcp_servers.rea]\ncommand = "old"\nstartup_timeout_sec = ${value}\n`;
|
|
await writeFile(configPath, original);
|
|
const client = { name: "codex", format: "toml", configPath } as const;
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({
|
|
status: "configured",
|
|
backupPath: `${configPath}.rea.backup`,
|
|
});
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
|
expect(await readFile(configPath, "utf8")).toContain(
|
|
`unrelated = ${value}`,
|
|
);
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({
|
|
status: "unchanged",
|
|
});
|
|
});
|
|
});
|
|
|
|
const unrelatedToml = [
|
|
"# Keep this explanation.",
|
|
'notify = ["a", "b"]',
|
|
"literal = 'C:\\demo\\path'",
|
|
'disabled_mcp_servers = ["rea", "other"] # Not a Codex registration.',
|
|
"",
|
|
].join("\n");
|
|
|
|
const unrelatedServer = [
|
|
"# Keep the other server.",
|
|
'[mcp_servers."rea.env"]',
|
|
"command = 'other'",
|
|
"",
|
|
].join("\n");
|
|
|
|
const unrelatedValues = (text: string) => {
|
|
const document = parseToml(text);
|
|
const servers = document.mcp_servers;
|
|
if (typeof servers === "object" && servers !== null) {
|
|
Reflect.deleteProperty(servers, "rea");
|
|
if (Object.keys(servers).length === 0) delete document.mcp_servers;
|
|
}
|
|
return document;
|
|
};
|
|
|
|
describe("Codex configuration text preservation", () => {
|
|
it.each([
|
|
{
|
|
name: "new registration",
|
|
original: unrelatedToml,
|
|
preserved: [unrelatedToml],
|
|
},
|
|
{
|
|
name: "quoted tables and nested environment",
|
|
original: `${unrelatedToml}[mcp_servers."rea"]\ncommand = "old"\n[mcp_servers.rea.env]\nOLD = "value"\n${unrelatedServer}`,
|
|
preserved: [unrelatedToml, unrelatedServer],
|
|
},
|
|
{
|
|
name: "dotted registration keys",
|
|
original: `${unrelatedToml}mcp_servers.rea.command = "old"\n${unrelatedServer}`,
|
|
preserved: [unrelatedToml, unrelatedServer],
|
|
},
|
|
{
|
|
name: "inline server table",
|
|
original: `${unrelatedToml}mcp_servers = { rea = { command = "old" }, other = { command = 'other' } } # Shared table.\n`,
|
|
preserved: [
|
|
unrelatedToml,
|
|
"other = { command = 'other' }",
|
|
"# Shared table.",
|
|
],
|
|
},
|
|
{
|
|
name: "BOM, CRLF, and multiline text resembling a registration",
|
|
original:
|
|
`\uFEFF${unrelatedToml}banner = '''\n[mcp_servers.rea]\n# This is string content.\n'''\n`.replaceAll(
|
|
"\n",
|
|
"\r\n",
|
|
),
|
|
preserved: [
|
|
unrelatedToml.replaceAll("\n", "\r\n"),
|
|
"banner = '''\r\n[mcp_servers.rea]\r\n# This is string content.\r\n'''",
|
|
],
|
|
},
|
|
{
|
|
name: "comments without settings",
|
|
original: "# Keep this explanation.\n# And this trailing comment.\n\n",
|
|
preserved: ["# Keep this explanation.", "# And this trailing comment."],
|
|
},
|
|
])(
|
|
"preserves unrelated text through $name setup and uninstall",
|
|
async ({ original, preserved }) => {
|
|
const root = await createTestTempDirectory("rea-codex-preserve-");
|
|
const configPath = join(root, "config.toml");
|
|
const client = { name: "codex", format: "toml", configPath } as const;
|
|
await writeFile(configPath, original);
|
|
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toMatchObject({ status: "configured" });
|
|
const configured = await readFile(configPath, "utf8");
|
|
for (const text of preserved) expect(configured).toContain(text);
|
|
expect(unrelatedValues(configured)).toEqual(unrelatedValues(original));
|
|
expect(configured.startsWith("\uFEFF")).toBe(
|
|
original.startsWith("\uFEFF"),
|
|
);
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({ status: "unchanged" });
|
|
expect(await readFile(configPath, "utf8")).toBe(configured);
|
|
|
|
expect(
|
|
(await systemUninstallHost(root).removeClient(client)).status,
|
|
).toBe("removed");
|
|
const removed = await readFile(configPath, "utf8");
|
|
for (const text of preserved) expect(removed).toContain(text);
|
|
expect(unrelatedValues(removed)).toEqual(unrelatedValues(original));
|
|
expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea");
|
|
expect(removed.startsWith("\uFEFF")).toBe(original.startsWith("\uFEFF"));
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
|
},
|
|
);
|
|
|
|
it("retains the first backup after uninstall and a later setup", async () => {
|
|
const root = await createTestTempDirectory("rea-codex-backup-");
|
|
const configPath = join(root, "config.toml");
|
|
const client = { name: "codex", format: "toml", configPath } as const;
|
|
const original = 'notify = ["a", "b"]\n';
|
|
await writeFile(configPath, original);
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]);
|
|
expect((await systemUninstallHost(root).removeClient(client)).status).toBe(
|
|
"removed",
|
|
);
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]);
|
|
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
|
});
|
|
|
|
it("refuses malformed Codex TOML unchanged without creating a backup", async () => {
|
|
const root = await createTestTempDirectory("rea-codex-malformed-");
|
|
const configPath = join(root, "config.toml");
|
|
const client = { name: "codex", format: "toml", configPath } as const;
|
|
const original =
|
|
"# Keep this explanation.\n[mcp_servers.rea]\ncommand = [broken\n";
|
|
await writeFile(configPath, original);
|
|
expect(
|
|
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
|
).toEqual({ status: "failed", reason: "readback" });
|
|
expect((await systemUninstallHost(root).removeClient(client)).status).toBe(
|
|
"failed",
|
|
);
|
|
expect(await readFile(configPath, "utf8")).toBe(original);
|
|
await expect(
|
|
readFile(`${configPath}.rea.backup`, "utf8"),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
});
|
|
|
|
it
|
|
.skipIf(process.platform === "win32" || process.getuid?.() === 0)
|
|
.each([0o400, 0o640])(
|
|
"uninstall preserves configuration mode %s while retaining recovery authority",
|
|
async (mode) => {
|
|
const root = await createTestTempDirectory("rea-uninstall-file-mode-");
|
|
const configPath = join(root, "config.json");
|
|
await writeFile(
|
|
configPath,
|
|
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":true}\n',
|
|
);
|
|
await chmod(configPath, mode);
|
|
const host = systemUninstallHost(root);
|
|
try {
|
|
expect(
|
|
(
|
|
await host.removeClient({
|
|
name: "cursor",
|
|
format: "json",
|
|
configPath,
|
|
})
|
|
).status,
|
|
).toBe("removed");
|
|
expect((await lstat(configPath)).mode & 0o777).toBe(mode);
|
|
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({
|
|
mcpServers: {},
|
|
keep: true,
|
|
});
|
|
} finally {
|
|
await host.close?.();
|
|
}
|
|
},
|
|
);
|