mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
* fix(setup): flag unresolved migrated Claude Desktop storage * fix(setup): preserve Claude MSIX process view when config is unresolved
767 lines
24 KiB
TypeScript
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();
|
|
});
|
|
});
|