Files
42e1a17d6e fix(setup): preserve concurrent client configuration edits (#4855)
* fix(setup): preserve concurrent client configuration edits

* fix(setup): normalize configuration transaction queue paths

* fix(setup): report concurrent snapshot read conflicts

* test(setup): isolate Pi workflows from installed Hopper

* test: align inherited fixtures with current analysis contracts

* test(ida): assert search recovery instead of diagnostic prose

* fix(setup): share configuration queue keys across directory aliases

Resolve parent-directory aliases for regular and missing configuration
paths so a directory symlink and its real directory serialize setup
instead of racing the same file.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-23 05:06:13 +08:00

592 lines
20 KiB
TypeScript

import {
chmod,
lstat,
mkdir,
readFile,
readdir,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { resolveClientConfigTransactionPath } from "../../../src/application/ClientConfigPath.js";
import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js";
import {
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import { resolveWindowsClaudeDesktopLocation } from "../../../src/application/ClaudeDesktopConfigLocation.js";
import { supportedClients } from "../../../src/application/SupportedClients.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { failedConfigurationMessage } from "../../../src/application/SetupClients.js";
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier;
describe("JSON client configuration transaction: writing configuration through safe paths", () => {
it.skipIf(typeof process.getuid !== "function")(
"rejects a symlink target not owned by the current user",
async () => {
const currentUid = process.getuid?.();
if (currentUid === undefined)
throw new Error("UID support is required by this test");
let statCalls = 0;
expect(
await resolveClientConfigTransactionPath("/config", {
lstat: () => {
statCalls += 1;
return Promise.resolve({
uid: statCalls === 1 ? currentUid : currentUid + 1,
isFile: () => statCalls > 1,
isSymbolicLink: () => statCalls === 1,
});
},
realpath: () => Promise.resolve("/target"),
}),
).toBeUndefined();
},
);
it.skipIf(process.platform === "win32")(
"resolves a directory symlink and its real directory to one transaction path",
async () => {
const root = await createTestTempDirectory("rea-config-dir-alias-");
const realDirectory = join(root, "real");
const alias = join(root, "alias");
await mkdir(realDirectory);
await symlink(realDirectory, alias, "dir");
const realPath = join(realDirectory, "config.json");
const aliasPath = join(alias, "config.json");
const nestedRealPath = join(realDirectory, "nested", "config.json");
const nestedAliasPath = join(alias, "nested", "config.json");
expect(await resolveClientConfigTransactionPath(aliasPath)).toBe(
realPath,
);
expect(await resolveClientConfigTransactionPath(realPath)).toBe(realPath);
expect(await resolveClientConfigTransactionPath(nestedAliasPath)).toBe(
nestedRealPath,
);
expect(await resolveClientConfigTransactionPath(nestedRealPath)).toBe(
nestedRealPath,
);
await writeFile(realPath, "{}\n");
expect(await resolveClientConfigTransactionPath(aliasPath)).toBe(
realPath,
);
expect(await resolveClientConfigTransactionPath(realPath)).toBe(realPath);
},
);
it("preserves existing keys, creates a backup, and reads back the MCP entry", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
const original =
'{"theme":"dark","mcpServers":{"other":{"command":"other"}}}\n';
await writeFile(configPath, original);
const result = await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
});
expect(result.status).toBe("configured");
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
theme: "dark",
mcpServers: {
other: { command: "other" },
rea: {
command: "npx",
args: ["-y", pinnedPackage, "mcp"],
},
},
});
});
it("updates a symlink target without replacing the JSON config symlink", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const targetPath = join(directory, "managed.json");
const configPath = join(directory, "mcp.json");
const original = '{"theme":"dark"}\n';
await writeFile(targetPath, original);
await symlink(targetPath, configPath);
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toMatchObject({ status: "configured" });
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(JSON.parse(await readFile(targetPath, "utf8"))).toMatchObject({
theme: "dark",
mcpServers: {
rea: {
command: "npx",
args: ["-y", pinnedPackage, "mcp"],
},
},
});
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toEqual({
status: "unchanged",
});
});
it("fails before mutation when a JSON config symlink is dangling", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
await symlink(join(directory, "missing.json"), configPath);
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toEqual({
status: "failed",
reason: "path",
});
expect((await lstat(configPath)).isSymbolicLink()).toBe(true);
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
});
describe("JSON client configuration transaction: keeping matching configuration unchanged", () => {
it("performs no write or second backup when configuration already matches", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
await writeFile(
configPath,
`${JSON.stringify({ mcpServers: { rea: { command: "npx", args: ["-y", pinnedPackage, "mcp"] } } })}\n`,
);
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toEqual({
status: "unchanged",
});
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
it("does not rewrite an equivalent configuration with reordered environment keys", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
await writeFile(
configPath,
`${JSON.stringify({
mcpServers: {
rea: {
env: {
JAVA_HOME: "/usr/lib/jvm/jdk-21",
GHIDRA_INSTALL_DIR: "/opt/ghidra",
},
args: ["-y", pinnedPackage, "mcp"],
command: "npx",
},
},
})}\n`,
);
expect(
await configureClientConfiguration(
{ name: "cursor", configPath, format: "json" },
{
GHIDRA_INSTALL_DIR: "/opt/ghidra",
JAVA_HOME: "/usr/lib/jvm/jdk-21",
},
),
).toEqual({ status: "unchanged" });
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
});
describe("JSON client configuration migration and provider paths", () => {
it("migrates an unversioned npx registration and preserves sibling configuration", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
const original =
'{"theme":"dark","mcpServers":{"rea":{"command":"npx","args":["-y","rea-agents","mcp"]},"other":{"command":"other"}}}\n';
await writeFile(configPath, original);
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toEqual(expect.objectContaining({ status: "configured" }));
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({
theme: "dark",
mcpServers: {
rea: {
command: "npx",
args: ["-y", pinnedPackage, "mcp"],
},
other: { command: "other" },
},
});
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
});
it("persists a custom Hopper launcher and remains idempotent", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
const hopperPath = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
const client = { name: "cursor", configPath, format: "json" } as const;
const original = "{}\n";
await writeFile(configPath, original);
expect(
await configureClientConfiguration(client, {
HOPPER_LAUNCHER_PATH: hopperPath,
}),
).toMatchObject({
status: "configured",
});
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
mcpServers: {
rea: {
command: "npx",
args: ["-y", pinnedPackage, "mcp"],
env: { HOPPER_LAUNCHER_PATH: hopperPath },
},
},
});
expect(
await configureClientConfiguration(client, {
HOPPER_LAUNCHER_PATH: hopperPath,
}),
).toEqual({
status: "unchanged",
});
expect(
await configureClientConfiguration(client, {
HOPPER_LAUNCHER_PATH: "/opt/hopper/bin/Hopper",
}),
).toMatchObject({ status: "configured" });
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
});
it("adds exact BYO Ghidra and Java paths without installing dependencies", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
const client = { name: "cursor", configPath, format: "json" } as const;
const environment = {
GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.4_PUBLIC",
JAVA_HOME: "/usr/lib/jvm/jdk-21",
};
expect(
await configureClientConfiguration(client, environment),
).toMatchObject({
status: "configured",
});
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
mcpServers: {
rea: {
env: {
GHIDRA_INSTALL_DIR: environment.GHIDRA_INSTALL_DIR,
JAVA_HOME: environment.JAVA_HOME,
},
},
},
});
expect(await configureClientConfiguration(client, environment)).toEqual({
status: "unchanged",
});
});
it("refuses malformed existing JSON without overwriting it", async () => {
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
await writeFile(configPath, "not-json");
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toEqual({
status: "failed",
reason: "readback",
});
expect(await readFile(configPath, "utf8")).toBe("not-json");
});
it("refuses a non-object JSON root without overwriting it", async () => {
const original = "null";
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
await writeFile(configPath, original);
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toEqual({
status: "failed",
reason: "readback",
});
expect(await readFile(configPath, "utf8")).toBe(original);
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
it("preserves a non-object mcpServers value", async () => {
const servers = "[]";
const directory = await createTestTempDirectory("rea-setup-");
const configPath = join(directory, "mcp.json");
const original = `{"mcpServers":${servers}}`;
await writeFile(configPath, original);
expect(
await configureClientConfiguration({
name: "cursor",
configPath,
format: "json",
}),
).toEqual({
status: "failed",
reason: "readback",
});
expect(await readFile(configPath, "utf8")).toBe(original);
await expect(
readFile(`${configPath}.rea.backup`, "utf8"),
).rejects.toThrow();
});
});
describe("JSON client configuration shape diagnostics", () => {
it.each([
{
format: "json" as const,
original:
'{"__proto__":{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}}',
serversKey: "mcpServers",
},
{
format: "opencode" as const,
original:
'{"mcp":{"__proto__":{"servers":{"rea":{"type":"local","command":["rea","mcp"]}}}}}',
serversKey: "mcp",
},
])(
"registers an own server instead of trusting inherited $format settings",
async ({ format, original, serversKey }) => {
const directory = await createTestTempDirectory("rea-setup-prototype-");
const configPath = join(directory, "mcp.json");
const client = {
name: format === "json" ? "cursor" : "opencode",
configPath,
format,
};
await writeFile(configPath, original);
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "update" });
expect(
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "configured" });
const document = JSON.parse(await readFile(configPath, "utf8"));
expect(Object.hasOwn(document, serversKey)).toBe(true);
expect(Object.hasOwn(document[serversKey], "rea")).toBe(true);
expect(document[serversKey].rea.command).toEqual(
format === "json" ? "rea" : ["rea", "mcp"],
);
const originalDocument = JSON.parse(original);
expect(
format === "json" ? document.__proto__ : document[serversKey].__proto__,
).toEqual(
format === "json"
? originalDocument.__proto__
: originalDocument[serversKey].__proto__,
);
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toMatchObject({ status: "already_current" });
},
);
it.each([
['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'],
["[]", "The configuration root must be an object"],
['{"mcpServers":"x"}', '"mcpServers" must be an object'],
] as const)(
"names structural shape problems in setup remediation: %s",
async (original, problem) => {
const directory = await createTestTempDirectory("rea-setup-shape-");
const configPath = join(directory, "mcp.json");
const client = {
name: "cursor",
configPath,
format: "json" as const,
};
await writeFile(configPath, original);
const read = await readConfigurationFile(configPath, "json");
expect(read.ok).toBe(false);
if (read.ok) throw new Error("expected malformed configuration");
expect(read.error.detail).toBe(
`Configuration ${configPath} is malformed: ${problem}`,
);
expect(
await inspectClientConfiguration(client, {}, ["rea", "mcp"]),
).toEqual({
status: "invalid",
remediation: `Configuration ${configPath} is malformed: ${problem}`,
});
expect(await readFile(configPath, "utf8")).toBe(original);
},
);
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"preserves the private-path access diagnostic during the final MSIX target recheck",
async () => {
const home = await createTestTempDirectory("rea-msix-recheck-denied-");
const configPath = join(home, "client.json");
const locked = join(home, "private");
const privateConfigPath = join(locked, "client.json");
const original = '{"keep":true}\n';
await writeFile(configPath, original);
await mkdir(locked);
await chmod(locked, 0);
try {
const result = await configureClientConfiguration({
name: "claude_desktop",
format: "json",
configPath,
msixPackage: {
packageFamilyName: "Claude_pzs8sxrjxfjjc",
appDataConfigPath: configPath,
privateConfigPath,
virtualizedRoots: [],
processView: "direct",
},
});
expect(result).toMatchObject({
status: "failed",
reason: "target_unavailable",
failure: {
kind: "inspect",
path: privateConfigPath,
detail: expect.stringContaining("EACCES"),
},
});
if (result.status !== "failed")
throw new Error("Expected inspection failure");
expect(failedConfigurationMessage(result)).toContain(privateConfigPath);
expect(failedConfigurationMessage(result)).not.toContain(
"began reading a different",
);
expect(await readFile(configPath, "utf8")).toBe(original);
expect(await readdir(home)).toEqual(["client.json", "private"]);
} finally {
await chmod(locked, 0o700);
}
},
);
it.skipIf(process.platform === "win32")(
"selects literal AppData directories with trailing spaces through native filesystem discovery",
async () => {
const home = await createTestTempDirectory("rea-appdata-literal-root-");
const environment = {
APPDATA: join(home, "Roaming "),
LOCALAPPDATA: join(home, "Local "),
};
const appDataConfig = join(
environment.APPDATA,
"Claude",
"claude_desktop_config.json",
);
await mkdir(join(environment.APPDATA, "Claude"), { recursive: true });
await writeFile(appDataConfig, "{}");
// The trimmed alternate root contains a different package identity and must not be selected.
await mkdir(
join(home, "Local", "Packages", "AnthropicPBC.Claude_fnn82j28hfe8t"),
{ recursive: true },
);
expect(resolveWindowsClaudeDesktopLocation(home, environment)).toEqual({
ok: true,
value: {
configPath: appDataConfig,
markerPath: join(environment.APPDATA, "Claude"),
},
});
expect(await readFile(appDataConfig, "utf8")).toBe("{}");
},
);
it("keeps setup and registration diagnostics blocked for an unrecognized Claude package deployment", async () => {
const home = await createTestTempDirectory("rea-claude-deployment-unknown-");
const environment = {
APPDATA: join(home, "Roaming"),
LOCALAPPDATA: join(home, "Local"),
};
await mkdir(
join(
environment.LOCALAPPDATA,
"Packages",
"AnthropicPBC.Claude_fnn82j28hfe8t",
),
{ recursive: true },
);
const config = join(
environment.APPDATA,
"Claude",
"claude_desktop_config.json",
);
await mkdir(join(environment.APPDATA, "Claude"), { recursive: true });
const original = '{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}\n';
await writeFile(config, original);
const client = supportedClients(home, "win32", environment).find(
(client) => client.name === "claude_desktop",
);
if (client === undefined) throw new Error("Claude client definition missing");
expect(client.configPathError).toContain("unrecognized Claude Desktop");
expect(await configureClientConfiguration(client)).toMatchObject({
status: "failed",
reason: "path",
});
expect(
(
await readClientRegistrationStatuses(home, "/current/rea", {
platform: "win32",
environment,
})
).find((status) => status.client === "claude_desktop"),
).toMatchObject({
state: "invalid",
command: [],
remediation: expect.stringContaining("does not fall back"),
});
expect(await readFile(config, "utf8")).toBe(original);
expect(await readdir(join(environment.APPDATA, "Claude"))).toEqual([
"claude_desktop_config.json",
]);
});