Files
reverse-engineering-anything/src/application/ClaudeDesktopConfigLocation.ts
T
fd65e278be fix(setup): resolve the configuration MSIX Claude Desktop reads (#2426)
* 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>
2026-10-13 22:04:22 +08:00

200 lines
7.2 KiB
TypeScript

import { lstatSync, readdirSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { err, ok, type Result } from "../domain/result.js";
import {
windowsAppDataRoots,
type MsixPackageView,
} from "./MsixPackageStorage.js";
const CONFIG_FILE = "claude_desktop_config.json";
/** Private files compared at most when looking for a redirected AppData view. */
const PROCESS_VIEW_PROBES = 8;
/**
* Claude Desktop's MSIX package family: package name `Claude` and the
* publisher ID Windows derives from `CN="Anthropic, PBC", O="Anthropic, PBC",
* L=San Francisco, S=California, C=US, …`.
*/
export const CLAUDE_DESKTOP_PACKAGE_FAMILY = "Claude_pzs8sxrjxfjjc";
/** `Claude_` plus a 13-character Crockford base32 MSIX publisher ID. */
const CLAUDE_PACKAGE_FAMILY = /^Claude_[0-9a-hjkmnp-tv-z]{13}$/iu;
/** The configuration Claude Desktop reads and the evidence that it is installed. */
export interface ClaudeDesktopLocation {
readonly configPath: string;
readonly markerPath: string;
readonly msixPackage?: MsixPackageView;
}
/** Unresolved Claude Desktop identity, reported instead of guessing a file. */
export interface ClaudeDesktopLocationFailure {
readonly path: string;
readonly message: string;
}
/** Synchronous probes; each throws for failures other than absence. */
export interface ClaudeDesktopLocationFileSystem {
readonly readdir: (path: string) => readonly string[];
readonly lstat: (path: string) => { isFile(): boolean };
readonly realpath: (path: string) => string;
}
const systemFileSystem: ClaudeDesktopLocationFileSystem = {
readdir: (path) => readdirSync(path),
lstat: (path) => lstatSync(path),
realpath: (path) => realpathSync.native(path),
};
/**
* Resolve the `claude_desktop_config.json` that Windows Claude Desktop reads.
* Without its MSIX package storage, that is the AppData file. With it, the
* package reads its private copy when one exists and the AppData file
* otherwise; a new registration goes to the private copy. A `Claude_*`
* package from another publisher leaves the reader unknown.
*/
export const resolveWindowsClaudeDesktopLocation = (
home: string,
env: Readonly<NodeJS.ProcessEnv>,
fileSystem: ClaudeDesktopLocationFileSystem = systemFileSystem,
): Result<ClaudeDesktopLocation, ClaudeDesktopLocationFailure> => {
const roots = windowsAppDataRoots(home, env);
const appDataDirectory = join(roots.roaming, "Claude");
const appDataConfigPath = join(appDataDirectory, CONFIG_FILE);
const packagesDirectory = join(roots.local, "Packages");
let families: readonly string[];
try {
families = fileSystem
.readdir(packagesDirectory)
.filter((name) => CLAUDE_PACKAGE_FAMILY.test(name));
} catch (cause: unknown) {
if (!isMissing(cause))
return err({
path: packagesDirectory,
message: `MSIX package storage ${packagesDirectory} could not be inspected, so REA cannot establish whether Claude Desktop reads ${appDataConfigPath} or a package-private copy: ${errorMessage(cause)}. Repair access to that directory, then rerun.`,
});
families = [];
}
const foreign = families.filter(
(name) =>
name.toLowerCase() !== CLAUDE_DESKTOP_PACKAGE_FAMILY.toLowerCase(),
);
if (foreign.length > 0)
return err({
path: join(packagesDirectory, foreign[0] ?? ""),
message: `MSIX package storage ${foreign.map((name) => join(packagesDirectory, name)).join(", ")} uses Claude Desktop's package name with an unrecognized publisher (Claude Desktop is ${CLAUDE_DESKTOP_PACKAGE_FAMILY}). REA cannot establish which ${CONFIG_FILE} that package reads and does not fall back to ${appDataConfigPath}. Remove the unrecognized package, or register REA in the configuration it reads.`,
});
const family = families[0];
if (family === undefined)
return ok({ configPath: appDataConfigPath, markerPath: appDataDirectory });
const container = join(packagesDirectory, family);
const privateDirectory = join(container, "LocalCache", "Roaming", "Claude");
const privateConfigPath = join(privateDirectory, CONFIG_FILE);
const privateCopy = present(fileSystem, privateConfigPath);
if (!privateCopy.ok) return privateCopy;
const appDataCopy = privateCopy.value
? ok(false)
: present(fileSystem, appDataConfigPath);
if (!appDataCopy.ok) return appDataCopy;
return ok({
configPath:
privateCopy.value || !appDataCopy.value
? privateConfigPath
: appDataConfigPath,
markerPath: container,
msixPackage: {
packageFamilyName: family,
privateConfigPath,
appDataConfigPath,
virtualizedRoots: [roots.roaming, roots.local],
processView: processView(
fileSystem,
privateDirectory,
appDataDirectory,
privateCopy.value,
),
},
});
};
/**
* A process the package started sees the package's private files at their
* AppData paths; any other process sees the real AppData files or nothing.
* Claude Desktop saves its user data there while it runs, so the
* configuration, or any other private file, can show which view this is.
*/
const processView = (
fileSystem: ClaudeDesktopLocationFileSystem,
privateDirectory: string,
appDataDirectory: string,
privateConfig: boolean,
): MsixPackageView["processView"] => {
let probes: readonly string[];
try {
probes = privateConfig
? [CONFIG_FILE]
: fileSystem
.readdir(privateDirectory)
.filter((name) => {
try {
return fileSystem.lstat(join(privateDirectory, name)).isFile();
} catch (cause: unknown) {
// A candidate may vanish or become unreadable while listed.
void cause;
return false;
}
})
.slice(0, PROCESS_VIEW_PROBES);
} catch (cause: unknown) {
// No private file can be listed, so no view can be compared.
void cause;
return "unknown";
}
let view: MsixPackageView["processView"] = "unknown";
for (const name of probes) {
let privateTarget: string;
try {
privateTarget = fileSystem.realpath(join(privateDirectory, name));
} catch (cause: unknown) {
// A private file that vanished cannot be compared; try another.
void cause;
continue;
}
try {
if (fileSystem.realpath(join(appDataDirectory, name)) === privateTarget)
return "redirected";
view = "direct";
} catch (cause: unknown) {
if (!isMissing(cause)) return "unknown";
view = "direct";
}
}
return view;
};
const present = (
fileSystem: ClaudeDesktopLocationFileSystem,
path: string,
): Result<boolean, ClaudeDesktopLocationFailure> => {
try {
fileSystem.lstat(path);
return ok(true);
} catch (cause: unknown) {
if (isMissing(cause)) return ok(false);
return err({
path,
message: `${path} could not be inspected, so REA cannot establish which ${CONFIG_FILE} Claude Desktop's MSIX package reads: ${errorMessage(cause)}. Repair access to that path, then rerun.`,
});
}
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);