From 3484432fb18482dcced81272e6181719a3cdfbfa Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Thu, 5 Nov 2026 17:14:19 +0800 Subject: [PATCH] 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 --- docs/client-configuration.md | 7 ++- .../ClaudeDesktopConfigLocation.test.ts | 40 ++++++++++++++-- .../ClaudeDesktopConfigLocation.ts | 48 ++++++++++++++----- src/application/Setup.configuration.test.ts | 46 ++++++++++++++++++ src/application/SupportedClients.ts | 4 ++ 5 files changed, 128 insertions(+), 17 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 796e80b443..73d7c387d4 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -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__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_` package from another publisher, an `AnthropicPBC.Claude_` package, or cannot be read, REA cannot establish which file Claude diff --git a/src/application/ClaudeDesktopConfigLocation.test.ts b/src/application/ClaudeDesktopConfigLocation.test.ts index b6e6b768f8..662b3ebef2 100644 --- a/src/application/ClaudeDesktopConfigLocation.test.ts +++ b/src/application/ClaudeDesktopConfigLocation.test.ts @@ -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, }); diff --git a/src/application/ClaudeDesktopConfigLocation.ts b/src/application/ClaudeDesktopConfigLocation.ts index b4e84d92e4..fb10e496ff 100644 --- a/src/application/ClaudeDesktopConfigLocation.ts +++ b/src/application/ClaudeDesktopConfigLocation.ts @@ -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(), }); }; diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index 360157b0e8..43ef58f85e 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -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"); diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 7ee94bf8a0..09b44b67af 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -70,6 +70,7 @@ export interface SetupClient { interface ClientPathFailure { readonly path: string; readonly message: string; + readonly msixPackage?: MsixPackageView; } type ClientPathResult = Result; @@ -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 {