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
This commit is contained in:
ShuaixinHuang authored and GitHub committed 2026-11-05 17:14:19 +08:00
1 parent d854fb155a
commit 3484432fb1
5 files changed
+128 -17

No files matched your search

+6 -1
View File
@@ -155,7 +155,7 @@ in the file the installed app reads:
- Without Claude Desktop's MSIX package storage, setup uses
`%APPDATA%\Claude\claude_desktop_config.json`.
- The MSIX build (package family `Claude_pzs8sxrjxfjjc`, installed under
- The legacy MSIX storage layout (package family `Claude_pzs8sxrjxfjjc`, installed under
`C:\Program Files\WindowsApps\Claude_<version>_x64__pzs8sxrjxfjjc`)
virtualizes AppData writes. Once it has saved its configuration, it reads
the package-private copy
@@ -164,6 +164,11 @@ in the file the installed app reads:
private copy when it exists. Before the package has saved one, they use the
`%APPDATA%` file it still reads; with neither file, setup creates the
private copy.
- If `%LOCALAPPDATA%\Claude-Data\claude_pzs8sxrjxfjjc` exists, setup, doctor
and uninstall report an unresolved location. Migrated MSIX installations
have been observed reading `claude_desktop_config.json` in this directory.
Quit Claude Desktop and verify its active configuration before registering
manually. See [#8968](https://github.com/morluto/rea/issues/8968).
- If `%LOCALAPPDATA%\Packages` holds a `Claude_<publisher ID>` package from
another publisher, an `AnthropicPBC.Claude_<publisher ID>` package, or cannot
be read, REA cannot establish which file Claude
@@ -21,6 +21,11 @@ const env = {
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,
@@ -77,6 +82,33 @@ const packagedFileSystem = (
});
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,
@@ -535,7 +567,9 @@ describe("unresolved Claude Desktop package identity", () => {
])("does not treat %s as a Claude package family", (name) => {
const location = resolveWindowsClaudeDesktopLocation(home, env, {
readdir: () => [name],
lstat: () => file,
lstat: () => {
throw failure("ENOENT");
},
realpath: (path) => path,
});
expect(location).toEqual({
@@ -564,8 +598,8 @@ describe("unresolved Claude Desktop package identity", () => {
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");
lstat: (path) => {
throw failure(path === privateConfig ? "EPERM" : "ENOENT");
},
realpath: (path) => path,
});
+35 -13
View File
@@ -46,6 +46,7 @@ export interface ClaudeDesktopLocation {
export interface ClaudeDesktopLocationFailure {
readonly path: string;
readonly message: string;
readonly msixPackage?: MsixPackageView;
}
/** Synchronous probes; each throws for failures other than absence. */
@@ -120,14 +121,46 @@ export const resolveWindowsClaudeDesktopLocation = (
path: join(packagesDirectory, foreign[0] ?? ""),
message: `MSIX package storage ${foreign.map((name) => join(packagesDirectory, name)).join(", ")} has an unrecognized Claude Desktop package identity or deployment (the supported identity is ${CLAUDE_DESKTOP_PACKAGE_FAMILY}). REA cannot establish which ${CONFIG_FILE} that package reads and does not fall back to ${appDataConfigPath}. Quit Claude Desktop fully and register REA in the configuration the installed app reads.`,
});
const migratedDirectory = join(
roots.local,
"Claude-Data",
CLAUDE_DESKTOP_PACKAGE_FAMILY.toLowerCase(),
);
const migrated = present(fileSystem, migratedDirectory);
const migratedConfigPath = join(migratedDirectory, CONFIG_FILE);
const migrationFailure = !migrated.ok
? migrated.error
: migrated.value
? {
path: migratedConfigPath,
message: `Found migrated Claude Desktop storage at ${migratedDirectory}. REA cannot verify which configuration the installed app reads. Quit Claude Desktop fully, verify whether it reads ${migratedConfigPath}, then register REA manually in the active file.`,
}
: undefined;
const family = families[0];
if (family === undefined)
return ok({ configPath: appDataConfigPath, markerPath: appDataDirectory });
return migrationFailure === undefined
? ok({ configPath: appDataConfigPath, markerPath: appDataDirectory })
: err(migrationFailure);
const container = join(packagesDirectory, family);
const privateRoaming = join(container, "LocalCache", "Roaming");
const privateDirectory = join(privateRoaming, "Claude");
const privateConfigPath = join(privateDirectory, CONFIG_FILE);
const privateCopy = present(fileSystem, privateConfigPath);
const packageView = (): MsixPackageView => ({
packageFamilyName: family,
privateConfigPath,
appDataConfigPath,
virtualizedRoots: [roots.roaming, roots.local],
processView: processView(
fileSystem,
privateRoaming,
roots.roaming,
privateCopy.ok && privateCopy.value,
),
});
// The current process view matters even when the active Claude config is unknown.
if (migrationFailure !== undefined)
return err({ ...migrationFailure, msixPackage: packageView() });
if (!privateCopy.ok) return privateCopy;
const appDataCopy = privateCopy.value
? ok(false)
@@ -139,18 +172,7 @@ export const resolveWindowsClaudeDesktopLocation = (
? privateConfigPath
: appDataConfigPath,
markerPath: container,
msixPackage: {
packageFamilyName: family,
privateConfigPath,
appDataConfigPath,
virtualizedRoots: [roots.roaming, roots.local],
processView: processView(
fileSystem,
privateRoaming,
roots.roaming,
privateCopy.value,
),
},
msixPackage: packageView(),
});
};
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { FakeSetupHost, options } from "./Setup.fixture.js";
import { runSetup } from "./Setup.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
@@ -9,6 +11,7 @@ import { discoverSetupState } from "./SetupPlan.js";
import type { MsixPackageView } from "./MsixPackageStorage.js";
import {
registrationWriteBlocker,
supportedClients,
type SetupClient,
} from "./SupportedClients.js";
@@ -626,6 +629,49 @@ describe("MSIX Claude Desktop setup", () => {
expect(host.configurations).toBe(1);
});
it("blocks redirected AppData writes when migrated Claude storage is unresolved", async () => {
const home = await createTestTempDirectory("setup-claude-migrated-");
const local = join(home, "Local");
const roaming = join(home, "Roaming");
const family = "Claude_pzs8sxrjxfjjc";
const privateRoaming = join(
local,
"Packages",
family,
"LocalCache",
"Roaming",
);
const privateClaude = join(privateRoaming, "Claude");
await mkdir(privateClaude, { recursive: true });
await writeFile(join(privateClaude, "claude_desktop_config.json"), "{}");
await mkdir(join(local, "Claude-Data", family.toLowerCase()), {
recursive: true,
});
await symlink(privateRoaming, roaming, "junction");
const host = new FakeSetupHost("win32");
host.hopper = join(home, "Hopper");
host.skill = "unchanged";
host.clients = supportedClients(home, "win32", {
APPDATA: roaming,
LOCALAPPDATA: local,
});
expect(
host.clients.find(({ name }) => name === "claude_desktop")
?.configPathError,
).toContain("migrated Claude Desktop storage");
const blocked = await runSetup(
{ ...options(true), clientIds: ["vscode"] },
host,
);
expect(blocked.status).toBe("needs_human");
expect(blocked.remediation).toContain(
`Changes to ${join(roaming, "Code", "User", "mcp.json")} would not reach the files other apps read.`,
);
expect(host.configurations).toBe(0);
});
it("refuses registrations whose launcher only one MSIX package can see", async () => {
const host = new FakeSetupHost("win32");
host.hopper = join(PROFILE, "Hopper");
+4
View File
@@ -70,6 +70,7 @@ export interface SetupClient {
interface ClientPathFailure {
readonly path: string;
readonly message: string;
readonly msixPackage?: MsixPackageView;
}
type ClientPathResult = Result<string, ClientPathFailure>;
@@ -904,6 +905,9 @@ export const supportedClients = (
format: definition.format,
configPath: location.error.path,
configPathError: location.error.message,
...(location.error.msixPackage === undefined
? {}
: { msixPackage: location.error.msixPackage }),
};
const { configPath, markerPath, msixPackage } = location.value;
return {