mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
abb54ce374
Add direct controls for literal AppData roots, denied MSIX target rechecks, and unrecognized Claude package deployments. Verify setup refuses mutation and registration diagnostics remain invalid for unknown package readers. Six new controls fail at6d5281407before the existing9abdad335fix; one existing-behavior guard passes. All seven pass on current main. Keep native Windows/app behavior distinct from macOS filesystem and platform-selected adapter checks. Validation: 82 tests across four configuration/CLI files, 42 header/skill/ lifecycle tests, 144 Ghidra/doctor consumer tests, check:fast, format:check, and test-discovery verification passed. No provider/target execution. Refs #4378, #4432, #4496, #4501, #4384, #4676
560 lines
18 KiB
TypeScript
560 lines
18 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("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",
|
|
]);
|
|
});
|