Files
reverse-engineering-anything/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts
fd65e278be fix(setup): resolve the configuration MSIX Claude Desktop reads (#2426)
* fix(setup): resolve the configuration MSIX Claude Desktop reads

On Windows, setup, doctor and uninstall always used
%APPDATA%\Claude\claude_desktop_config.json. The MSIX build of Claude
Desktop (package family Claude_pzs8sxrjxfjjc) virtualizes AppData writes
and reads its package-private copy under
%LOCALAPPDATA%\Packages\<family>\LocalCache\Roaming\Claude once it exists.
Setup from an ordinary process therefore wrote a file the app never reads,
and doctor reported that file healthy.

Resolve the Claude Desktop location once, in supportedClients(): the
private copy when it exists, else the AppData file the package still
reads, else a new private copy. An unrecognized Claude_* publisher or
unreadable package storage is reported instead of falling back to AppData.

Compare one of the package's private files with its AppData path through
realpath.native to learn whether the running package started this
process. From that redirected view, or when no file can be compared,
setup and uninstall refuse to change Claude Desktop's configuration,
because the running app rewrites it from memory. From a redirected view
they also refuse writes under %APPDATA% and %LOCALAPPDATA% for other
clients and skills, which would land in the package's private storage.

Setup refuses to register a launcher stored in any MSIX package's
private storage, discloses which file the package reads, and tells the
user to quit Claude Desktop fully on every platform. Configuration fails
with target_changed if the package switched to its private copy after
the plan was shown. Uninstall removes REA from both files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(setup): record the verified MSIX Claude Desktop registration

On the Windows 11 host with the MSIX build 2.31226.1.0, setup from an
ordinary terminal with the app quit wrote the package-private copy. After
the app restarted, the entry persisted, doctor reported it aligned, and
Claude Desktop's MCP log showed REA starting and answering tools/list.
Uninstall against the real app remains unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(setup): format MSIX review corrections

* fix(setup): carry the admitted legacy path through uninstall removal

clientRegistrationRemoval returned a removeLegacy boolean, and removeClient
recomputed the legacy path separately, so TypeScript could not prove the
path existed when removeLegacy was true and `tsc --noEmit` failed in
Uninstall.ts. The decision now returns the admitted legacy path itself;
preflight and removal consume that one value.

The alias regression matched new-destination children with a "/"
separator while building paths with path.join, so it failed on Windows.
It now uses path.sep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(setup): split MSIX Claude Desktop lifecycle cases under the function length limit

The uninstall cases pushed the registration lifecycle describe past the 150-line lint limit. Group them separately, with the AppData registration case kept among the registration cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:04:22 +08:00

413 lines
15 KiB
TypeScript

import {
access,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../../src/application/ClaudeDesktopConfigLocation.js";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
import {
configureClientConfiguration,
inspectClientConfiguration,
} from "../../../src/application/SetupClientConfiguration.js";
import { detectClients } from "../../../src/application/SetupHost.js";
import {
supportedClients,
type SetupClient,
} from "../../../src/application/SupportedClients.js";
import {
runUninstall,
systemUninstallHost,
} from "../../../src/application/Uninstall.js";
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
/** The Windows registration setup writes, independent of the test host. */
const command = npxRegistrationCommand("win32");
const CONFIG = "claude_desktop_config.json";
const roots: string[] = [];
beforeEach(clearClientLocationEnvironment);
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
/** A Windows profile with separate roaming and local AppData roots. */
const windowsProfile = async () => {
const home = await createTestTempDirectory("rea-claude-desktop-");
roots.push(home);
const env = {
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
};
const appDataConfig = join(env.APPDATA, "Claude", CONFIG);
const container = join(
env.LOCALAPPDATA,
"Packages",
CLAUDE_DESKTOP_PACKAGE_FAMILY,
);
const privateConfig = join(
container,
"LocalCache",
"Roaming",
"Claude",
CONFIG,
);
return { home, env, appDataConfig, container, privateConfig };
};
const claudeDesktop = (
home: string,
env: Readonly<Record<string, string>>,
): SetupClient => {
const client = supportedClients(home, "win32", env).find(
({ name }) => name === "claude_desktop",
);
if (client === undefined) throw new Error("claude_desktop is not supported");
return client;
};
const writeJson = async (path: string, value: unknown): Promise<void> => {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
};
const readJson = async (path: string): Promise<Record<string, unknown>> =>
JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
const reaEntry = { command: command[0], args: command.slice(1) };
describe("Windows Claude Desktop configuration location", () => {
it("uses AppData when no MSIX package storage exists", async () => {
const { home, env, appDataConfig } = await windowsProfile();
await mkdir(
join(env.LOCALAPPDATA, "Packages", "Contoso.App_8wekyb3d8bbwe"),
{
recursive: true,
},
);
const client = claudeDesktop(home, env);
expect(client.configPath).toBe(appDataConfig);
expect(client.markerPath).toBe(dirname(appDataConfig));
expect(client.msixPackage).toBeUndefined();
expect(client.quitBeforeSetup).toBe(true);
});
it("uses the package-private copy that the MSIX package reads", async () => {
const { home, env, appDataConfig, container, privateConfig } =
await windowsProfile();
await writeJson(privateConfig, { preferences: { theme: "dark" } });
await writeJson(appDataConfig, { mcpServers: {} });
const client = claudeDesktop(home, env);
expect(client.configPath).toBe(privateConfig);
expect(client.markerPath).toBe(container);
expect(client.msixPackage).toEqual({
packageFamilyName: CLAUDE_DESKTOP_PACKAGE_FAMILY,
privateConfigPath: privateConfig,
appDataConfigPath: appDataConfig,
virtualizedRoots: [env.APPDATA, env.LOCALAPPDATA],
processView: "direct",
});
expect(
(await detectClients(home, "win32", env)).map(({ name }) => name),
).toContain("claude_desktop");
});
it("uses AppData while the MSIX package has no private copy", async () => {
const { home, env, appDataConfig, container, privateConfig } =
await windowsProfile();
await mkdir(container, { recursive: true });
await writeJson(appDataConfig, { preferences: {} });
const client = claudeDesktop(home, env);
expect(client.configPath).toBe(appDataConfig);
expect(client.msixPackage).toMatchObject({
privateConfigPath: privateConfig,
processView: "unknown",
});
// No private file shows whether the running package started this process.
const inspection = await inspectClientConfiguration(client, {}, command);
expect(inspection.status).toBe("invalid");
expect(inspection.status === "invalid" && inspection.remediation).toContain(
"Open Claude Desktop once if it has never run, quit it fully, then rerun setup",
);
});
it("creates the private copy when the MSIX package has no configuration", async () => {
const { home, env, privateConfig } = await windowsProfile();
await writeJson(join(dirname(privateConfig), "Local State"), {});
const client = claudeDesktop(home, env);
expect(client.configPath).toBe(privateConfig);
expect(client.msixPackage?.processView).toBe("direct");
await expect(
inspectClientConfiguration(client, {}, command),
).resolves.toEqual({ status: "create" });
await expect(
configureClientConfiguration(client, {}, command),
).resolves.toEqual({ status: "configured" });
expect(await readJson(privateConfig)).toEqual({
mcpServers: { rea: reaEntry },
});
});
it("reports an unrecognized Claude package instead of falling back to AppData", async () => {
const { home, env, appDataConfig } = await windowsProfile();
const foreign = join(env.LOCALAPPDATA, "Packages", "Claude_abcdefghjkmnp");
await mkdir(foreign, { recursive: true });
await writeJson(appDataConfig, { mcpServers: {} });
const client = claudeDesktop(home, env);
expect(client.configPath).toBe(foreign);
expect(client.configPathError).toContain("unrecognized publisher");
expect(client.configPathError).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
expect(
(await detectClients(home, "win32", env)).map(({ name }) => name),
).not.toContain("claude_desktop");
const status = (
await readClientRegistrationStatuses(home, "unused", {
platform: "win32",
environment: env,
})
).find(({ client: name }) => name === "claude_desktop");
expect(status).toMatchObject({ state: "invalid", config_path: foreign });
await expect(
inspectClientConfiguration(client, {}, command),
).resolves.toMatchObject({ status: "invalid" });
});
});
describe("MSIX Claude Desktop registration lifecycle", () => {
it("registers in the private copy and reports an AppData-only entry as missing", async () => {
const { home, env, appDataConfig, privateConfig } = await windowsProfile();
const privateOriginal = {
coworkUserFilesPath: "C:\\Users\\a\\Claude",
preferences: { menuBarEnabled: false },
mcpServers: { other: { command: "other", args: ["serve"] } },
};
await writeJson(privateConfig, privateOriginal);
// Earlier setups wrote AppData, which the package never reads.
await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } });
const appDataOriginal = await readFile(appDataConfig, "utf8");
const status = async () =>
(
await readClientRegistrationStatuses(home, "unused", {
platform: "win32",
environment: env,
})
).find(({ client }) => client === "claude_desktop");
expect(await status()).toMatchObject({
state: "missing",
config_path: privateConfig,
});
expect((await status())?.remediation).toContain(
"Quit Claude Desktop fully",
);
const client = claudeDesktop(home, env);
await expect(
inspectClientConfiguration(client, {}, command),
).resolves.toEqual({
status: "update",
backupPath: `${privateConfig}.rea.backup`,
});
await expect(
configureClientConfiguration(client, {}, command),
).resolves.toEqual({
status: "configured",
backupPath: `${privateConfig}.rea.backup`,
});
expect(await readJson(privateConfig)).toEqual({
...privateOriginal,
mcpServers: {
...privateOriginal.mcpServers,
rea: reaEntry,
},
});
expect(await readJson(`${privateConfig}.rea.backup`)).toEqual(
privateOriginal,
);
expect(await readFile(appDataConfig, "utf8")).toBe(appDataOriginal);
expect(await status()).toMatchObject({
state: "aligned",
config_path: privateConfig,
});
await expect(
configureClientConfiguration(client, {}, command),
).resolves.toEqual({ status: "unchanged" });
});
it("refuses to write from a process the running package started", async () => {
const { home, env, privateConfig } = await windowsProfile();
await writeJson(privateConfig, { preferences: {} });
const resolved = claudeDesktop(home, env);
if (resolved.msixPackage === undefined)
throw new Error("expected MSIX package storage");
// Only a packaged process sees AppData redirected; model that observation.
const client: SetupClient = {
...resolved,
msixPackage: { ...resolved.msixPackage, processView: "redirected" },
};
const inspection = await inspectClientConfiguration(client, {}, command);
expect(inspection.status).toBe("invalid");
expect(inspection.status === "invalid" && inspection.remediation).toContain(
"Quit Claude Desktop fully, then rerun setup from a terminal that Claude Desktop did not start",
);
expect(await readJson(privateConfig)).toEqual({ preferences: {} });
await configureClientConfiguration(resolved, {}, command);
await expect(
inspectClientConfiguration(client, {}, command),
).resolves.toEqual({ status: "already_current" });
});
it("refuses a write once the package reads a newer private copy", async () => {
const { home, env, appDataConfig, privateConfig } = await windowsProfile();
await writeJson(join(dirname(privateConfig), "Preferences"), {});
await writeJson(appDataConfig, { preferences: {} });
const client = claudeDesktop(home, env);
expect(client.configPath).toBe(appDataConfig);
// Quitting the app after the plan was shown can save its private copy.
await writeJson(privateConfig, { preferences: {} });
await expect(
configureClientConfiguration(client, {}, command),
).resolves.toEqual({ status: "failed", reason: "target_changed" });
expect(await readJson(appDataConfig)).toEqual({ preferences: {} });
expect(await readJson(privateConfig)).toEqual({ preferences: {} });
});
it("registers in AppData while the package has no private copy", async () => {
const { home, env, appDataConfig, privateConfig } = await windowsProfile();
await writeJson(join(dirname(privateConfig), "Preferences"), {});
await writeJson(appDataConfig, { preferences: { theme: "light" } });
const client = claudeDesktop(home, env);
expect(client.configPath).toBe(appDataConfig);
expect(client.msixPackage?.processView).toBe("direct");
await expect(
configureClientConfiguration(client, {}, command),
).resolves.toMatchObject({ status: "configured" });
expect(await readJson(appDataConfig)).toEqual({
preferences: { theme: "light" },
mcpServers: { rea: reaEntry },
});
await expect(access(privateConfig)).rejects.toMatchObject({
code: "ENOENT",
});
});
});
describe("MSIX Claude Desktop uninstall", () => {
it("refuses uninstall when the package view is unknown", async () => {
const { home, env, appDataConfig, container } = await windowsProfile();
await mkdir(container, { recursive: true });
await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } });
const result = await runUninstall(
false,
systemUninstallHost(home, undefined, env, "win32"),
);
expect(result.status).toBe("failed");
expect(result.items).toContainEqual(
expect.objectContaining({
name: "claude_desktop",
status: "failed",
detail: expect.stringContaining("rerun uninstall"),
}),
);
expect(await readJson(appDataConfig)).toEqual({
mcpServers: { rea: reaEntry },
});
});
it("retains an unrelated rea-named entry when the package view is unknown", async () => {
const { home, env, appDataConfig, container } = await windowsProfile();
await mkdir(container, { recursive: true });
const unrelated = { mcpServers: { rea: { command: "unrelated-server" } } };
await writeJson(appDataConfig, unrelated);
const result = await runUninstall(
false,
systemUninstallHost(home, undefined, env, "win32"),
);
expect(result.status).toBe("complete");
expect(await readJson(appDataConfig)).toEqual(unrelated);
});
it("blocks managed skill removal through redirected AppData before any removal", async () => {
const { home, env, appDataConfig, privateConfig } = await windowsProfile();
await writeJson(join(dirname(privateConfig), "Preferences"), {});
await mkdir(dirname(dirname(appDataConfig)), { recursive: true });
await symlink(dirname(privateConfig), dirname(appDataConfig), "dir");
const agents = join(env.APPDATA, "dsh", "agents");
const manifest = join(
agents,
"skills",
"reverse-engineer-anything",
"SKILL.md",
);
await mkdir(dirname(manifest), { recursive: true });
await writeFile(
manifest,
"---\nname: reverse-engineer-anything\n---\nmanaged fixture\n",
);
const environment = { ...env, DSH_AGENTS_HOME: agents };
const result = await runUninstall(
false,
systemUninstallHost(home, undefined, environment, "win32"),
);
expect(result.status).toBe("failed");
expect(result.items).toContainEqual(
expect.objectContaining({
name: "skill",
status: "failed",
detail: expect.stringContaining("redirects its writes"),
}),
);
expect(await readFile(manifest, "utf8")).toContain("managed fixture");
});
it("removes REA from both the private copy and AppData on uninstall", async () => {
const { home, env, appDataConfig, privateConfig } = await windowsProfile();
await writeJson(privateConfig, {
preferences: {},
mcpServers: { other: { command: "other" }, rea: reaEntry },
});
await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } });
const result = await runUninstall(
false,
systemUninstallHost(home, undefined, env, "win32"),
);
expect(result.status).toBe("complete");
expect(await readJson(privateConfig)).toEqual({
preferences: {},
mcpServers: { other: { command: "other" } },
});
expect(await readJson(appDataConfig)).toEqual({ mcpServers: {} });
});
});