mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 10:25:42 +00:00
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>
This commit is contained in:
1 parent
1f23e977b5
commit
18aa9af2b2
5 files changed
+227
-78
No files matched your search
@@ -158,6 +158,13 @@ const parseDocument = (
|
||||
// ZCode loads nonempty native files with strict JSON.parse.
|
||||
if (format === "zcode")
|
||||
return requireObject(JSON.parse(text), "The configuration root");
|
||||
// Claude Code loads `.claude.json` with strict JSON.parse after a UTF-8 BOM,
|
||||
// so comments and trailing commas it rejects must not be admitted as JSONC.
|
||||
if (format === "claude_code")
|
||||
return requireObject(
|
||||
JSON.parse(text.replace(/^\uFEFF/, "")),
|
||||
"The configuration root",
|
||||
);
|
||||
const errors: ParseError[] = [];
|
||||
// Accept a UTF-8 BOM without shifting diagnostics or editing the original text.
|
||||
const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text;
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface SetupClient {
|
||||
readonly quitBeforeSetup?: boolean;
|
||||
readonly format?:
|
||||
| "json"
|
||||
| "claude_code"
|
||||
| "toml"
|
||||
| "vscode"
|
||||
| "copilot_cli"
|
||||
@@ -491,7 +492,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(claudeCodeConfigDirectory(context), ".claude.json"),
|
||||
markerPath: claudeCodeMarkerDirectory,
|
||||
format: "json",
|
||||
format: "claude_code",
|
||||
},
|
||||
{
|
||||
name: "claude_desktop",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const peer = '"peer": { "command": "/usr/bin/true", "args": [] }';
|
||||
|
||||
const runIn = (cli: TestCli, home: string) => (arguments_: readonly string[]) =>
|
||||
cli.run({
|
||||
arguments: arguments_,
|
||||
cwd: home,
|
||||
environment: {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CLAUDE_CONFIG_DIR: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
},
|
||||
});
|
||||
|
||||
const setupArguments = [
|
||||
"setup",
|
||||
"--client",
|
||||
"claude_code",
|
||||
"--skill=false",
|
||||
"--json",
|
||||
] as const;
|
||||
const doctorArguments = [
|
||||
"doctor",
|
||||
"--client",
|
||||
"claude_code",
|
||||
"--skill=false",
|
||||
"--json",
|
||||
] as const;
|
||||
|
||||
// Native Claude Code rejects these documents with its strict JSON reader.
|
||||
for (const [syntax, original] of [
|
||||
[
|
||||
"a comment",
|
||||
`{\n // preserve this unrelated setting\n "mcpServers": {\n ${peer}\n }\n}\n`,
|
||||
],
|
||||
["a trailing comma", `{\n "mcpServers": {\n ${peer},\n }\n}\n`],
|
||||
] as const)
|
||||
cliTest(
|
||||
`Claude Code setup, doctor, and uninstall refuse JSON with ${syntax}`,
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory("rea-claude-strict-json-");
|
||||
const config = join(home, ".claude.json");
|
||||
await writeFile(config, original);
|
||||
const run = runIn(cli, home);
|
||||
|
||||
for (const mode of ["--dry-run", "--yes"]) {
|
||||
const setup = await run([...setupArguments, mode]);
|
||||
expect(setup.exitCode, setup.stdout).toBe(1);
|
||||
expect(setup.json).toMatchObject({
|
||||
status: "needs_human",
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
remediation: expect.stringContaining("malformed"),
|
||||
});
|
||||
}
|
||||
const doctor = await run(doctorArguments);
|
||||
expect(doctor.exitCode).toBe(1);
|
||||
expect(doctor.json).toMatchObject({
|
||||
healthy: false,
|
||||
identity: {
|
||||
registrations: [
|
||||
expect.objectContaining({
|
||||
client: "claude_code",
|
||||
state: "invalid",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const removed = await run(["uninstall", "--json"]);
|
||||
expect(removed.exitCode).toBe(1);
|
||||
expect(removed.json).toMatchObject({
|
||||
status: "failed",
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "claude_code", status: "failed" }),
|
||||
]),
|
||||
});
|
||||
expect(await readFile(config, "utf8")).toBe(original);
|
||||
await expect(readFile(`${config}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
for (const prefix of ["", ""])
|
||||
cliTest(
|
||||
`Claude Code setup configures strict JSON with prefix ${JSON.stringify(prefix)}`,
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory("rea-claude-strict-json-");
|
||||
const config = join(home, ".claude.json");
|
||||
const original = `${prefix}{\n "mcpServers": {\n ${peer}\n }\n}\n`;
|
||||
await writeFile(config, original);
|
||||
const run = runIn(cli, home);
|
||||
|
||||
const setup = await run([...setupArguments, "--yes"]);
|
||||
expect(setup.exitCode, setup.stdout).toBe(0);
|
||||
expect(setup.json).toMatchObject({
|
||||
status: "ready",
|
||||
clients: { claude_code: { status: "configured" } },
|
||||
});
|
||||
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
|
||||
const configured = await readFile(config, "utf8");
|
||||
expect(configured.startsWith(`${prefix}{`)).toBe(true);
|
||||
expect(JSON.parse(configured.slice(prefix.length))).toMatchObject({
|
||||
mcpServers: {
|
||||
peer: { command: "/usr/bin/true", args: [] },
|
||||
rea: { command: expect.any(String) },
|
||||
},
|
||||
});
|
||||
const doctor = await run(doctorArguments);
|
||||
expect(doctor.json).toMatchObject({
|
||||
identity: {
|
||||
registrations: [
|
||||
expect.objectContaining({
|
||||
client: "claude_code",
|
||||
state: "aligned",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -19,86 +19,91 @@ import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe.each([
|
||||
"claude_code",
|
||||
"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(
|
||||
{
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
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",
|
||||
|
||||
@@ -55,7 +55,11 @@ it.each([
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ client: "claude_code", state: "stale" }),
|
||||
);
|
||||
const client = { name: "claude_code", configPath, format: "json" } as const;
|
||||
const client = {
|
||||
name: "claude_code",
|
||||
configPath,
|
||||
format: "claude_code",
|
||||
} as const;
|
||||
const command = setupRegistrationCommand("win32", "npx", undefined);
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, command),
|
||||
@@ -119,7 +123,11 @@ it.each(["npx", "bunx"] as const)(
|
||||
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: "json" } as const;
|
||||
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"],
|
||||
@@ -158,7 +166,7 @@ it.skipIf(process.platform !== "win32").each([
|
||||
const setupClient = {
|
||||
name: "claude_code",
|
||||
configPath,
|
||||
format: "json",
|
||||
format: "claude_code",
|
||||
} as const;
|
||||
const command = setupRegistrationCommand("win32", runner, undefined);
|
||||
// The default writer and npm-run setup must produce the same native launcher.
|
||||
|
||||
Reference in new issue
Block a user