Files
rea/tests/boundary/filesystem/windowsNpxRegistration.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

226 lines
8.3 KiB
TypeScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/client";
import {
StdioClientTransport,
getDefaultEnvironment,
} from "@modelcontextprotocol/client/stdio";
import { expect, it } from "vitest";
import { z } from "zod";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { setupRegistrationCommand } from "../../../src/application/SetupHost.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const configurationSchema = z.object({
mcpServers: z.object({
rea: z.object({ command: z.string(), args: z.array(z.string()) }),
}),
});
it.each([
{
previousCommand: [
"npx",
"-y",
PRODUCT_IDENTITY.registrationPackageSpecifier,
"mcp",
],
},
{ previousCommand: ["cmd", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp"] },
])(
"migrates Windows npx registration $previousCommand idempotently and uninstalls only REA",
async ({ previousCommand }) => {
const home = await createTestTempDirectory("rea-windows-registration-");
const configPath = join(home, ".claude.json");
const original = JSON.stringify({
theme: "dark",
mcpServers: {
other: { command: "another-server" },
rea: {
command: previousCommand[0],
args: previousCommand.slice(1),
},
},
});
await writeFile(configPath, original);
expect(
await readClientRegistrationStatuses(home, undefined, {
platform: "win32",
environment: {},
}),
).toContainEqual(
expect.objectContaining({ client: "claude_code", state: "stale" }),
);
const client = {
name: "claude_code",
configPath,
format: "claude_code",
} as const;
const command = setupRegistrationCommand("win32", "npx", undefined);
expect(
await configureClientConfiguration(client, {}, command),
).toMatchObject({ status: "configured" });
expect(
await readClientRegistrationStatuses(home, undefined, {
platform: "win32",
environment: {},
}),
).toContainEqual(
expect.objectContaining({ client: "claude_code", state: "aligned" }),
);
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
status: "removed",
});
const remaining: unknown = JSON.parse(await readFile(configPath, "utf8"));
expect(remaining).toEqual({
theme: "dark",
mcpServers: { other: { command: "another-server" } },
});
},
);
it.each(["npx", "bunx"] as const)(
"keeps the Windows %s launcher aligned through Codex TOML setup and removal",
async (runner) => {
const home = await createTestTempDirectory("rea-windows-codex-");
await mkdir(join(home, ".codex"));
const configPath = join(home, ".codex", "config.toml");
const original =
'\uFEFF# Keep this explanation.\nnotify = ["a", "b"]\nliteral = \'C:\\demo\\path\'\ndisabled_mcp_servers = ["rea"]\n\n[mcp_servers.other]\ncommand = "another-server"\n';
await writeFile(configPath, original);
const client = { name: "codex", configPath, format: "toml" } as const;
const command = setupRegistrationCommand("win32", runner, undefined);
expect(
await configureClientConfiguration(client, {}, command),
).toMatchObject({ status: "configured" });
expect(
await readClientRegistrationStatuses(home, undefined, {
platform: "win32",
environment: {},
}),
).toContainEqual(
expect.objectContaining({ client: "codex", state: "aligned" }),
);
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
status: "removed",
});
expect(await readFile(configPath, "utf8")).toBe(original);
},
);
it("retains rea-named custom shell registrations without writing a backup", async () => {
const home = await createTestTempDirectory("rea-custom-shell-registration-");
const configPath = join(home, ".claude.json");
const client = {
name: "claude_code",
configPath,
format: "claude_code",
} as const;
for (const command of [
["cmd.exe", "/d", "/c", "node", "custom.js", "mcp"],
["cmd.exe", "/k", "npx", "-y", "rea-agents@5.0.0", "mcp"],
["cmd.exe", "/c", "npx -y rea-agents@5.0.0 mcp"],
["cmd.exe", "/c", "npx", "-y", "another-package", "mcp"],
["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0", "mcp", "&", "other"],
["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0 & other", "mcp"],
["cmd.exe", "/c", "bunx", "another-package", "mcp"],
["cmd.exe", "/c", "bunx", "-y", "rea-agents@5.0.0", "mcp"],
["cmd.exe", "/c", "bunx", "rea-agents@5.0.0", "mcp", "&", "other"],
]) {
const original = JSON.stringify({
mcpServers: { rea: { command: command[0], args: command.slice(1) } },
});
await writeFile(configPath, original);
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
status: "retained",
});
expect(await readFile(configPath, "utf8")).toBe(original);
await expect(readFile(`${configPath}.rea.backup`)).rejects.toMatchObject({
code: "ENOENT",
});
}
});
it.skipIf(process.platform !== "win32").each([
["npx", ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]],
["bunx", [PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]],
] as const)(
"starts the written Windows registration through a native %s.cmd shim",
async (runner, shimArguments) => {
const root = await createTestTempDirectory("rea-windows-mcp-");
const home = join(root, "home with spaces");
await mkdir(home);
const configPath = join(home, ".claude.json");
const setupClient = {
name: "claude_code",
configPath,
format: "claude_code",
} as const;
const command = setupRegistrationCommand("win32", runner, undefined);
// The default writer and npm-run setup must produce the same native launcher.
expect(
await (runner === "npx"
? configureClientConfiguration(setupClient)
: configureClientConfiguration(setupClient, {}, command)),
).toMatchObject({ status: "configured" });
const decoded: unknown = JSON.parse(await readFile(configPath, "utf8"));
const registration = configurationSchema.parse(decoded).mcpServers.rea;
expect([registration.command, ...registration.args]).toEqual(command);
const entry = fileURLToPath(
new URL("../../../scripts/rea.mjs", import.meta.url),
);
// Exercise the actual batch-shim boundary without registry access or package installation.
await writeFile(
join(home, `${runner}.cmd`),
[
"@echo off",
...shimArguments.map(
(argument, index) =>
`if not "%~${String(index + 1)}"=="${argument}" exit /b ${String(11 + index)}`,
),
`if not "%~${String(shimArguments.length + 1)}"=="" exit /b 19`,
`"${process.execPath}" "${entry}" mcp`,
"",
].join("\r\n"),
);
const transport = new StdioClientTransport({
command: registration.command,
args: registration.args,
cwd: home,
env: getDefaultEnvironment(),
stderr: "pipe",
});
const client = new Client({ name: "windows-setup-launcher", version: "1" });
try {
await client.connect(transport);
expect(client.getServerVersion()?.version).toBe(
PRODUCT_IDENTITY.packageVersion,
);
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
expect(status.isError).not.toBe(true);
expect(status.structuredContent).toMatchObject({
result: { open: false },
});
} finally {
await client.close();
await transport.close();
}
},
30_000,
);