mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 18:05:45 +00:00
Replace configuration-specific write-file-atomic delegation with a verified input snapshot, exclusive private staging, and an explicit canonical-parent destination. Existing files publish by rename; first-run files use an exclusive link so a newly created destination is retained. Both consumers read back the publication owner's selected path. Retain descriptor and staging cleanup capabilities through recovery and host retry. Preserve mode and ownership, remove temporary permission handoffs, and use positional restoration writes. Keep UTF-8 diagnostics with failed cleanup. Validation: 284 focused tests passed, one skipped across 14 files; check:fast, format:check, docs:check (including five consistency tests), two source reviews, and 12 completed isolated public CLI setup/uninstall cost controls passed. Native Windows/Linux, real editor concurrency, and providers were not verified. Final-check/publication races and same-inode concurrent writers remain unexcluded. Fixes #4904
487 lines
17 KiB
TypeScript
487 lines
17 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}`),
|
|
}),
|
|
).toEqual({ status: "failed", reason: "write" });
|
|
} 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?.();
|
|
}
|
|
},
|
|
);
|