Files
rea/tests/boundary/filesystem/clientConfigurationBom.test.ts
T
Kaoru0822 18aa9af2b2 fix(setup): admit only strict JSON for Claude Code configuration (#4944)
Claude Code shared the generic `json` client format, which the shared
configuration reader admits through JSONC parsing with comments and
trailing commas enabled. Native Claude Code reads `.claude.json` with a
strict JSON parser (after a UTF-8 BOM), so setup reported `ready`, wrote
a registration into a file the client rejects as corrupt, and doctor
reported it `aligned`.

Give Claude Code its own `claude_code` configuration format and parse it
at the owning boundary with strict JSON.parse after removing a leading
BOM. Setup, doctor, and uninstall all consume that parser, so comment or
trailing-comma documents are now refused as malformed before any backup
or write, while plain and BOM-prefixed JSON still configure. Clients that
natively accept JSONC keep the `json` format and its behavior.

Fixes #4940

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:57:28 -07:00

208 lines
8.4 KiB
TypeScript

import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import {
clientConfigurationServersKey,
clientRegistrationEntry,
parseClientConfiguration,
} from "../../../src/application/ClientConfigurationDocument.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import {
clientConfigurationAligned,
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe.each(["vscode", "copilot_cli", "opencode", "commandcode"])(
"client configuration for %s",
(name) => {
it.each(["", "\uFEFF"])(
"preflights, configures, and reads back prefix %j in an isolated home",
async (bom) => {
const home = await createTestTempDirectory("rea-client-config-bom-");
const client = supportedClients(home, "linux", {}).find(
(candidate) => candidate.name === name,
);
if (client?.format === undefined) throw new Error(`missing ${name}`);
const serversKey = clientConfigurationServersKey(client.format);
const original = `${bom}// Keep the client preferences.\r\n${JSON.stringify(
{
label: "left\uFEFFright",
[serversKey]: { other: { command: "other" } },
theme: "dark",
},
null,
2,
).replaceAll("\n", "\r\n")}\r\n`;
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(client.configPath, original);
const currentCommandPath = join(home, "rea");
const command = [currentCommandPath, "mcp"];
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "update",
backupPath: `${client.configPath}.rea.backup`,
});
expect(await clientConfigurationAligned(client, {}, command)).toBe(
false,
);
expect(await configureClientConfiguration(client, {}, command)).toEqual(
{
status: "configured",
backupPath: `${client.configPath}.rea.backup`,
},
);
const updated = await readFile(client.configPath, "utf8");
expect(
updated.startsWith(`${bom}// Keep the client preferences.\r\n`),
).toBe(true);
expect(
parseClientConfiguration(updated, client.format).document,
).toEqual({
label: "left\uFEFFright",
[serversKey]: {
other: { command: "other" },
rea: clientRegistrationEntry(client.format, command, {}),
},
theme: "dark",
});
expect(await clientConfigurationAligned(client, {}, command)).toBe(
true,
);
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
});
expect(
await readClientRegistrationStatuses(home, currentCommandPath, {
platform: "linux",
environment: {},
}),
).toEqual([
{
client: name,
config_path: client.configPath,
state: "aligned",
command,
remediation: null,
},
]);
expect(await configureClientConfiguration(client, {}, command)).toEqual(
{
status: "unchanged",
},
);
expect(await readFile(client.configPath)).toEqual(Buffer.from(updated));
expect(await readFile(`${client.configPath}.rea.backup`)).toEqual(
Buffer.from(original),
);
},
);
},
);
it.each(['\uFEFF{"servers": {"rea": @}}', '\uFEFF{"setting":\uFEFFtrue}'])(
"rejects malformed BOM placement without mutation: %j",
async (original) => {
const home = await createTestTempDirectory(
"rea-client-config-bom-invalid-",
);
const configPath = join(home, "mcp.json");
const client = { name: "vscode", format: "vscode", configPath } as const;
await writeFile(configPath, original);
const invalidOffset = original.includes("@") ? original.indexOf("@") : 12;
expect(() => parseClientConfiguration(original, client.format)).toThrow(
`Invalid JSON/JSONC at offset ${invalidOffset}: InvalidSymbol`,
);
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "invalid" });
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toEqual({ status: "failed", reason: "readback" });
expect(await readFile(configPath)).toEqual(Buffer.from(original));
expect(await readdir(home)).toEqual(["mcp.json"]);
},
);
it("preserves BOM-prefixed JSONC values and comments through update and removal", async () => {
const home = await createTestTempDirectory("rea-client-config-bom-cycle-");
const client = supportedClients(home, "linux", {}).find(
(candidate) => candidate.name === "cursor",
);
if (client?.format !== "json") throw new Error("missing Cursor JSON config");
const serversKey = clientConfigurationServersKey(client.format);
const prefix = `\uFEFF{\r\n // Keep prefix \uFEFF comment.\r\n "label": "left\uFEFFright",\r\n "${serversKey}": {\r\n // Keep this unrelated registration.\r\n "other": {\r\n "command": "other\uFEFFtool"\r\n },\r\n`;
const suffix = ` },\r\n // Keep suffix comment.\r\n "theme": "dark"\r\n}\r\n`;
const original = `${prefix}${suffix}`;
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(client.configPath, original);
if (CURRENT_ENTRYPOINT_PATH === undefined)
throw new Error("test runner entrypoint is unavailable");
const firstCommand = ["rea", "mcp"];
const updatedCommand = [process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"];
expect(
await configureClientConfiguration(client, {}, firstCommand),
).toMatchObject({ status: "configured" });
expect(
await configureClientConfiguration(client, {}, updatedCommand),
).toMatchObject({ status: "configured" });
const updated = await readFile(client.configPath, "utf8");
expect(parseClientConfiguration(updated, client.format).servers).toEqual({
other: { command: "other\uFEFFtool" },
rea: clientRegistrationEntry(client.format, updatedCommand, {}),
});
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
name: "cursor",
status: "removed",
});
const removed = await readFile(client.configPath, "utf8");
expect(removed.startsWith("\uFEFF")).toBe(true);
expect(removed).toContain("\r\n // Keep prefix \uFEFF comment.\r\n");
expect(removed).toContain("\r\n // Keep suffix comment.\r\n");
expect(removed).toContain("// Keep this unrelated registration.");
expect(parseClientConfiguration(removed, client.format).document).toEqual(
parseClientConfiguration(original, client.format).document,
);
expect(await readFile(`${client.configPath}.rea.backup`)).toEqual(
Buffer.from(original),
);
});
it.each(["", "\n \n", "\uFEFF"])(
"configures an empty client file %j as a new document",
async (original) => {
const home = await createTestTempDirectory("rea-client-config-empty-");
const client = supportedClients(home, "linux", {}).find(
(candidate) => candidate.name === "antigravity",
);
if (client?.format !== "json")
throw new Error("missing Antigravity JSON config");
await mkdir(dirname(client.configPath), { recursive: true });
await writeFile(client.configPath, original);
const command = [join(home, "rea"), "mcp"];
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "update",
backupPath: `${client.configPath}.rea.backup`,
});
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "configured",
backupPath: `${client.configPath}.rea.backup`,
});
expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({
mcpServers: { rea: clientRegistrationEntry(client.format, command, {}) },
});
expect(await inspectClientConfiguration(client, {}, command)).toEqual({
status: "already_current",
});
},
);