Files
reverse-engineering-anything/src/application/ClaudeDesktopConfigLocation.test.ts
T
ShuaixinHuang 3484432fb1 fix(setup): flag unresolved migrated Claude Desktop storage (#8971)
* fix(setup): flag unresolved migrated Claude Desktop storage

* fix(setup): preserve Claude MSIX process view when config is unresolved
2026-11-05 17:14:19 +08:00

767 lines
24 KiB
TypeScript

import { basename, dirname, join, sep } from "node:path";
import { describe, expect, it } from "vitest";
import {
CLAUDE_DESKTOP_PACKAGE_FAMILY,
resolveWindowsClaudeDesktopLocation,
type ClaudeDesktopLocationFileSystem,
} from "./ClaudeDesktopConfigLocation.js";
import {
packagePrivateLauncherBlocker,
packagePrivateStorageFamily,
redirectedWriteBlocker,
type MsixPackageView,
windowsAppDataRoots,
} from "./MsixPackageStorage.js";
const home = join("/profile", "a");
const env = {
APPDATA: join(home, "AppData", "Roaming"),
LOCALAPPDATA: join(home, "AppData", "Local"),
};
const packages = join(env.LOCALAPPDATA, "Packages");
const migratedDirectory = join(
env.LOCALAPPDATA,
"Claude-Data",
CLAUDE_DESKTOP_PACKAGE_FAMILY.toLowerCase(),
);
const appDataConfig = join(env.APPDATA, "Claude", "claude_desktop_config.json");
const privateConfig = join(
packages,
CLAUDE_DESKTOP_PACKAGE_FAMILY,
"LocalCache",
"Roaming",
"Claude",
"claude_desktop_config.json",
);
const failure = (code: string): Error =>
Object.assign(new Error(`${code}: injected`), { code });
const file = { isFile: () => true, isDirectory: () => false };
const folder = { isFile: () => false, isDirectory: () => true };
const privateDirectory = dirname(privateConfig);
const privatePreferences = join(privateDirectory, "Preferences");
const appDataDirectory = dirname(appDataConfig);
it.each([undefined, ""])(
"uses default AppData roots only for unset or empty values: %s",
(value) => {
expect(
windowsAppDataRoots(home, { APPDATA: value, LOCALAPPDATA: value }),
).toEqual({ roaming: env.APPDATA, local: env.LOCALAPPDATA });
},
);
it.each([" root with trailing space ", " "])(
"preserves a nonempty AppData root literally: %j",
(value) => {
expect(
windowsAppDataRoots(home, { APPDATA: value, LOCALAPPDATA: value }),
).toEqual({ roaming: value, local: value });
},
);
/** Package storage holding `privateFiles`; realpath decides the process view. */
const packagedFileSystem = (
realpath: ClaudeDesktopLocationFileSystem["realpath"],
privateFiles: readonly string[] = [privateConfig],
): ClaudeDesktopLocationFileSystem => ({
readdir: (path) => {
if (path === packages) return [CLAUDE_DESKTOP_PACKAGE_FAMILY];
if (path === privateDirectory)
return privateFiles.map((name) => basename(name));
throw failure("ENOENT");
},
lstat: (path) => {
if (!privateFiles.includes(path)) throw failure("ENOENT");
return file;
},
realpath,
});
describe("Claude Desktop MSIX process view", () => {
it.each([true, false])(
"reports migrated storage as unresolved with legacy package storage %s",
(hasLegacyStorage) => {
const fs = packagedFileSystem((path) => path);
expect(
resolveWindowsClaudeDesktopLocation(home, env, {
...fs,
readdir: (path) =>
path === packages && !hasLegacyStorage ? [] : fs.readdir(path),
lstat: (path) =>
path === migratedDirectory ? folder : fs.lstat(path),
}),
).toEqual({
ok: false,
error: {
path: join(migratedDirectory, "claude_desktop_config.json"),
message: expect.stringContaining("migrated Claude Desktop storage"),
...(hasLegacyStorage
? {
msixPackage: expect.objectContaining({ processView: "direct" }),
}
: {}),
},
});
},
);
it("recognizes a process that sees the private copy at the AppData path", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) =>
path === appDataConfig ? privateConfig : path,
),
);
expect(location).toEqual({
ok: true,
value: expect.objectContaining({
configPath: privateConfig,
msixPackage: expect.objectContaining({ processView: "redirected" }),
}),
});
});
it("treats an absent AppData file as an unredirected process", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) => {
if (path === appDataConfig) throw failure("ENOENT");
return path;
}),
);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"direct",
);
});
it("recognizes redirection through another private file before the configuration exists", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem(
(path) =>
path === join(appDataDirectory, "Preferences")
? privatePreferences
: path,
[privatePreferences],
),
);
expect(location.ok && location.value.configPath).toBe(privateConfig);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"redirected",
);
});
it("continues after a listed private file disappears", () => {
const fs = packagedFileSystem(
(path) =>
path === join(appDataDirectory, "Preferences")
? privatePreferences
: path,
[privatePreferences],
);
const location = resolveWindowsClaudeDesktopLocation(home, env, {
...fs,
readdir: (path) =>
path === privateDirectory
? ["vanished", "Preferences"]
: fs.readdir(path),
});
expect(location.ok && location.value.msixPackage?.processView).toBe(
"redirected",
);
});
it("keeps the view unknown when the package has no private files", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) => path, []),
);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"unknown",
);
});
it("keeps the view unknown when the AppData path cannot be resolved", () => {
const location = resolveWindowsClaudeDesktopLocation(
home,
env,
packagedFileSystem((path) => {
if (path === appDataConfig) throw failure("EACCES");
return path;
}),
);
expect(location.ok && location.value.msixPackage?.processView).toBe(
"unknown",
);
});
});
const privateRoaming = dirname(privateDirectory);
/**
* Package storage whose private roaming root holds `files` (paths under that
* root) and no Claude folder; `realpath` decides the process view and
* `unreadable` folders refuse to be listed.
*/
const roamingFileSystem = (
files: readonly string[],
realpath: ClaudeDesktopLocationFileSystem["realpath"],
unreadable: readonly string[] = [],
): ClaudeDesktopLocationFileSystem => {
const absolute = files.map((path) => join(privateRoaming, path));
const within = (path: string) => (entry: string) =>
entry.startsWith(`${path}${sep}`);
return {
readdir: (path, limit) => {
if (path === packages) return [CLAUDE_DESKTOP_PACKAGE_FAMILY];
if (
unreadable.some(
(folderPath) => join(privateRoaming, folderPath) === path,
)
)
throw failure("EACCES");
const below = absolute.filter(within(path));
if (path !== privateRoaming && below.length === 0)
throw failure("ENOENT");
return [
...new Set(
below.map(
(entry) => entry.slice(path.length + 1).split(sep)[0] ?? "",
),
),
].slice(0, limit);
},
lstat: (path) => {
if (absolute.includes(path)) return file;
if (absolute.some(within(path))) return folder;
throw failure("ENOENT");
},
realpath,
};
};
/** The process view a resolver reports for `fileSystem`. */
const viewOf = (fileSystem: ClaudeDesktopLocationFileSystem) => {
const location = resolveWindowsClaudeDesktopLocation(home, env, fileSystem);
return location.ok ? location.value.msixPackage?.processView : undefined;
};
describe("Claude Desktop MSIX process view from other roaming folders", () => {
const telemetry = join("turborepo", "telemetry.json");
it("compares a private file outside Claude Desktop's own folder", () => {
expect(viewOf(roamingFileSystem([telemetry], (path) => path))).toBe(
"direct",
);
});
it("recognizes a redirected view through that file", () => {
const fileSystem = roamingFileSystem([telemetry], (path) =>
path === join(env.APPDATA, telemetry)
? join(privateRoaming, telemetry)
: path,
);
expect(viewOf(fileSystem)).toBe("redirected");
});
it("treats a private file missing at its AppData path as a direct view", () => {
const fileSystem = roamingFileSystem([telemetry], (path) => {
if (path === join(env.APPDATA, telemetry)) throw failure("ENOENT");
return path;
});
expect(viewOf(fileSystem)).toBe("direct");
});
it("skips a folder that cannot be listed and decides from another", () => {
const fileSystem = roamingFileSystem(
[join("Microsoft", "locked", "x"), telemetry],
(path) =>
path === join(env.APPDATA, telemetry)
? join(privateRoaming, telemetry)
: path,
["Microsoft"],
);
expect(viewOf(fileSystem)).toBe("redirected");
});
it("spreads its probes so a crowded folder cannot hide a redirected one", () => {
const crowded = Array.from({ length: 20 }, (_, index) =>
join("ghidra", `log-${String(index).padStart(2, "0")}`),
);
const compared: string[] = [];
const fileSystem = roamingFileSystem([...crowded, telemetry], (path) => {
compared.push(path);
return path === join(env.APPDATA, telemetry)
? join(privateRoaming, telemetry)
: path;
});
expect(viewOf(fileSystem)).toBe("redirected");
expect(
compared.filter((path) =>
path.startsWith(join(privateRoaming, "ghidra")),
),
).toHaveLength(2);
});
it("stops after a bounded number of probes", () => {
const files = Array.from({ length: 30 }, (_, index) =>
join(`folder-${String(index).padStart(2, "0")}`, "file"),
);
const compared: string[] = [];
viewOf(
roamingFileSystem(files, (path) => {
compared.push(path);
return path;
}),
);
expect(
compared.filter((path) => path.startsWith(privateRoaming)),
).toHaveLength(8);
});
it("keeps the view unknown when the roaming root has no private file", () => {
expect(viewOf(roamingFileSystem([], (path) => path))).toBe("unknown");
});
});
describe("Claude Desktop roaming enumeration budget", () => {
const entryLimit = 1024;
it("stops inspecting entries once its budget is spent", () => {
const folders = Array.from({ length: 1500 }, (_, index) =>
join(`folder-${String(index).padStart(4, "0")}`, "file"),
);
const fileSystem = roamingFileSystem(folders, (path) => path);
let enumerated = 0;
let inspected = 0;
const requested: (number | undefined)[] = [];
const view = viewOf({
...fileSystem,
readdir: (path, limit) => {
if (path === privateRoaming) requested.push(limit);
const names = fileSystem.readdir(path, limit);
if (
path === privateRoaming ||
path.startsWith(`${privateRoaming}${sep}`)
)
enumerated += names.length;
return names;
},
lstat: (path) => {
if (dirname(path) === privateRoaming) inspected += 1;
return fileSystem.lstat(path);
},
});
// The root lists only folders, so the budget runs out before any file.
expect(view).toBe("unknown");
expect(enumerated).toBe(1024);
expect(inspected).toBe(1024);
// The listing itself stops at the budget instead of reading all 1500 names.
expect(requested).toEqual([1024]);
});
it("charges names materialized from an early queued leaf directory", () => {
const files = Array.from({ length: 64 * 1000 }, (_, index) =>
join(
`folder-${String(Math.floor(index / 1000)).padStart(2, "0")}`,
`file-${String(index % 1000).padStart(4, "0")}`,
),
);
const base = roamingFileSystem(files, (path) => path);
let enumerated = 0;
const requests: Array<{ path: string; limit: number | undefined }> = [];
const view = viewOf({
...base,
readdir: (path, limit) => {
const names = base.readdir(path, limit);
if (
path === privateRoaming ||
path.startsWith(`${privateRoaming}${sep}`)
) {
enumerated += names.length;
requests.push({ path, limit });
}
return names;
},
});
expect(view).toBe("direct");
expect(enumerated).toBe(entryLimit);
expect(requests).toEqual([
{ path: privateRoaming, limit: entryLimit },
{
path: join(privateRoaming, "folder-00"),
limit: entryLimit - 64,
},
]);
});
it("bounds the preferred Claude folder before selecting its probes", () => {
const files = Array.from({ length: 1500 }, (_, index) =>
join("Claude", `file-${String(index).padStart(4, "0")}.log`),
);
const base = roamingFileSystem(files, (path) => path);
const listed: number[] = [];
let inspected = 0;
const view = viewOf({
...base,
readdir: (path, limit) => {
if (path === join(privateRoaming, "Claude") && limit !== undefined)
listed.push(limit);
return base.readdir(path, limit);
},
lstat: (path) => {
if (
path.startsWith(`${join(privateRoaming, "Claude")}${sep}`) &&
!path.endsWith(join("Claude", "claude_desktop_config.json"))
)
inspected += 1;
return base.lstat(path);
},
});
expect(view).toBe("direct");
expect(listed).toEqual([1024]);
// Each selected candidate is inspected once while listing and again during
// the race-safe comparison; selection itself remains capped at eight.
expect(inspected).toBe(16);
});
it("shares the entry budget between the preferred folder and fallback walk", () => {
const folders = Array.from({ length: 1500 }, (_, index) =>
join("Claude", `folder-${String(index).padStart(4, "0")}`, "file"),
);
const files = [...folders, join("turborepo", "telemetry.json")];
const base = roamingFileSystem(files, (path) => path);
const listed: Array<{ path: string; limit: number | undefined }> = [];
let inspected = 0;
const view = viewOf({
...base,
readdir: (path, limit) => {
listed.push({ path, limit });
return base.readdir(path, limit);
},
lstat: (path) => {
if (
path.startsWith(`${join(privateRoaming, "Claude")}${sep}`) &&
!path.endsWith(join("Claude", "claude_desktop_config.json"))
)
inspected += 1;
return base.lstat(path);
},
});
expect(view).toBe("unknown");
expect(inspected).toBe(1024);
expect(
listed.filter(({ path }) => path === join(privateRoaming, "Claude")),
).toEqual([{ path: join(privateRoaming, "Claude"), limit: 1024 }]);
expect(listed.some(({ path }) => path === privateRoaming)).toBe(false);
});
});
describe("Claude Desktop MSIX process view when a probe changes during the comparison", () => {
const telemetry = join("turborepo", "telemetry.json");
it("does not count a private file deleted during the comparison as a direct view", () => {
const fileSystem = roamingFileSystem([telemetry], (path) => path);
let deleted = false;
const view = viewOf({
...fileSystem,
realpath: (path) => {
if (path !== join(env.APPDATA, telemetry)) return path;
// A redirected view loses the file between the two comparisons.
deleted = true;
throw failure("ENOENT");
},
lstat: (path) => {
if (deleted && path === join(privateRoaming, telemetry))
throw failure("ENOENT");
return fileSystem.lstat(path);
},
});
expect(view).toBe("unknown");
});
it("does not count an ordinary copy reached after the private file was deleted as a direct view", () => {
const fileSystem = roamingFileSystem([telemetry], (path) => path);
let deleted = false;
const view = viewOf({
...fileSystem,
realpath: (path) => {
// The redirected view falls through to the ordinary AppData copy.
if (path === join(env.APPDATA, telemetry)) deleted = true;
return path;
},
lstat: (path) => {
if (deleted && path === join(privateRoaming, telemetry))
throw failure("ENOENT");
return fileSystem.lstat(path);
},
});
expect(view).toBe("unknown");
});
it("recognizes a redirected view through a private file recreated during the comparison", () => {
const fileSystem = roamingFileSystem([telemetry], (path) => path);
let lookups = 0;
const view = viewOf({
...fileSystem,
realpath: (path) => {
if (path !== join(env.APPDATA, telemetry)) return path;
lookups += 1;
// The first lookup lands while the file is being replaced.
if (lookups === 1) throw failure("ENOENT");
return join(privateRoaming, telemetry);
},
});
expect(view).toBe("redirected");
});
it("does not decide from a probe whose AppData lookups disagree", () => {
const fileSystem = roamingFileSystem([telemetry], (path) => path);
let lookups = 0;
const view = viewOf({
...fileSystem,
realpath: (path) => {
if (path !== join(env.APPDATA, telemetry)) return path;
lookups += 1;
if (lookups === 1) throw failure("ENOENT");
return path;
},
});
expect(view).toBe("unknown");
});
});
describe("unresolved Claude Desktop package identity", () => {
it.each(["AnthropicPBC.Claude_fnn82j28hfe8t", "Claude_fnn82j28hfe8t"])(
"refuses an unrecognized package deployment %s instead of selecting AppData",
(name) => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => [name],
lstat: () => file,
realpath: (path) => path,
});
expect(location).toMatchObject({
ok: false,
error: {
path: join(packages, name),
message: expect.stringContaining("does not fall back"),
},
});
},
);
it.each([
"Notes.AnthropicPBC.Claude_fnn82j28hfe8t",
"AnthropicPBC.Claude_fnn82j28hfe8t.backup",
"AnthropicPBCxClaude_fnn82j28hfe8t",
])("does not treat %s as a Claude package family", (name) => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => [name],
lstat: () => {
throw failure("ENOENT");
},
realpath: (path) => path,
});
expect(location).toEqual({
ok: true,
value: { configPath: appDataConfig, markerPath: appDataDirectory },
});
});
it("reports unreadable package storage instead of assuming AppData", () => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => {
throw failure("EACCES");
},
lstat: () => file,
realpath: (path) => path,
});
expect(location).toEqual({
ok: false,
error: {
path: packages,
message: expect.stringContaining("could not be inspected"),
},
});
});
it("reports an uninspectable private copy instead of choosing a file", () => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY],
lstat: (path) => {
throw failure(path === privateConfig ? "EPERM" : "ENOENT");
},
realpath: (path) => path,
});
expect(location.ok).toBe(false);
expect(!location.ok && location.error.path).toBe(privateConfig);
});
it("matches the package family case-insensitively, as Windows does", () => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => [CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase()],
lstat: () => {
throw failure("ENOENT");
},
realpath: (path) => path,
});
expect(location.ok && location.value.msixPackage?.packageFamilyName).toBe(
CLAUDE_DESKTOP_PACKAGE_FAMILY.toUpperCase(),
);
});
});
describe("package-private REA launchers", () => {
const launcher = join(
env.APPDATA,
"npm",
"node_modules",
"rea-agents",
"scripts",
"rea.mjs",
);
const stored = join(
packages,
CLAUDE_DESKTOP_PACKAGE_FAMILY,
"LocalCache",
"Roaming",
"npm",
"node_modules",
"rea-agents",
"scripts",
"rea.mjs",
);
it("names the package that privately stores a path", () => {
expect(packagePrivateStorageFamily(stored, packages)).toBe(
CLAUDE_DESKTOP_PACKAGE_FAMILY,
);
expect(packagePrivateStorageFamily(launcher, packages)).toBeUndefined();
expect(
packagePrivateStorageFamily(
join(packages, CLAUDE_DESKTOP_PACKAGE_FAMILY, "LocalState", "x"),
packages,
),
).toBeUndefined();
});
it("blocks a launcher that only the package's processes can see", () => {
const blocker = packagePrivateLauncherBlocker(
[join("/Program Files", "nodejs", "node.exe"), launcher, "mcp"],
packages,
(path) => (path === launcher ? stored : path),
);
expect(blocker).toContain(stored);
expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
expect(blocker).toContain("npm install -g rea-agents");
});
it("compares the launcher with the canonical package storage path", () => {
const shortProfilePackages = join(
"/profile",
"A~1",
"AppData",
"Local",
"Packages",
);
const blocker = packagePrivateLauncherBlocker(
[launcher, "mcp"],
shortProfilePackages,
(path) =>
path === shortProfilePackages
? packages
: path === launcher
? stored
: path,
);
expect(blocker).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY);
});
it("allows ordinary and unresolvable launchers", () => {
expect(
packagePrivateLauncherBlocker(
["cmd.exe", "/d", "/c", "npx", "-y", "rea-agents@6.4.0", "mcp"],
packages,
() => {
throw new Error("package runners are not resolved");
},
),
).toBeUndefined();
expect(
packagePrivateLauncherBlocker([launcher, "mcp"], packages, () => {
throw failure("ENOENT");
}),
).toBeUndefined();
});
});
describe("writes from a redirected AppData view", () => {
const view: MsixPackageView = {
packageFamilyName: CLAUDE_DESKTOP_PACKAGE_FAMILY,
privateConfigPath: privateConfig,
appDataConfigPath: appDataConfig,
virtualizedRoots: [env.APPDATA, env.LOCALAPPDATA],
processView: "redirected",
};
const vscode = join(env.APPDATA, "Code", "User", "mcp.json");
const hermes = join(env.LOCALAPPDATA, "hermes", "config.yaml");
const codex = join(home, ".codex", "config.toml");
it("refuses only targets under the package's virtualized roots", () => {
const blocker = redirectedWriteBlocker(
view,
[vscode, codex, hermes],
"setup",
);
expect(blocker).toContain(`${vscode}, ${hermes}`);
expect(blocker).not.toContain(codex);
expect(blocker).toContain("Rerun setup from a terminal");
expect(redirectedWriteBlocker(view, [codex], "setup")).toBeUndefined();
});
it("compares aliases through the existing parent of a new destination", () => {
const alias = join(home, "skill-alias");
const target = join(alias, "skills", "reverse-engineer-anything");
const blocker = redirectedWriteBlocker(
view,
[target],
"uninstall",
(path) => {
if (path === alias) return join(env.APPDATA, "dsh");
if (path.startsWith(`${alias}${sep}`)) throw failure("ENOENT");
return path;
},
);
expect(blocker).toContain(target);
expect(blocker).toContain("Rerun uninstall");
});
it("keeps canonicalization failures visible instead of assuming a target is outside AppData", () => {
expect(
redirectedWriteBlocker(view, [codex], "setup", () => {
throw failure("EACCES");
}),
).toContain("Cannot establish the MSIX write location");
});
it("allows every target without positive evidence of redirection", () => {
for (const processView of ["direct", "unknown"] as const)
expect(
redirectedWriteBlocker({ ...view, processView }, [vscode], "uninstall"),
).toBeUndefined();
expect(
redirectedWriteBlocker(undefined, [vscode], "setup"),
).toBeUndefined();
});
});