mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
* 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>
342 lines
10 KiB
TypeScript
342 lines
10 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,
|
|
} 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 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 };
|
|
const privateDirectory = dirname(privateConfig);
|
|
const privatePreferences = join(privateDirectory, "Preferences");
|
|
const appDataDirectory = dirname(appDataConfig);
|
|
|
|
/** 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("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",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("unresolved Claude Desktop package identity", () => {
|
|
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: () => {
|
|
throw failure("EPERM");
|
|
},
|
|
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();
|
|
});
|
|
});
|