Files
reverse-engineering-anything/src/application/ClaudeDesktopConfigLocation.ts
T
ShuaixinHuang 3484432fb1 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
2026-11-05 17:14:19 +08:00

383 lines
14 KiB
TypeScript

import { lstatSync, opendirSync, 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;
/**
* Bounds of the walk through the package's private roaming root when Claude
* Desktop's own folder holds no private file: directories listed or waiting
* to be listed, their depth, entries inspected in all, and files taken from
* one directory before moving on, so the probes spread across folders instead
* of filling up from the first one.
*/
const ROAMING_DIRECTORY_LIMIT = 64;
const ROAMING_DEPTH_LIMIT = 4;
const ROAMING_ENTRY_LIMIT = 1024;
const PROBES_PER_DIRECTORY = 2;
/**
* 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";
/** Observed Claude package names plus Windows' 13-character publisher ID. */
const CLAUDE_PACKAGE_FAMILY =
/^(?:AnthropicPBC\.)?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;
readonly msixPackage?: MsixPackageView;
}
/** Synchronous probes; each throws for failures other than absence. */
export interface ClaudeDesktopLocationFileSystem {
/** A directory's names; with `limit`, at most that many, in enumeration order. */
readonly readdir: (path: string, limit?: number) => readonly string[];
readonly lstat: (path: string) => {
isFile(): boolean;
isDirectory(): boolean;
};
readonly realpath: (path: string) => string;
}
/** Up to `limit` names from a directory, without enumerating the rest. */
const readNames = (path: string, limit: number): string[] => {
const directory = opendirSync(path);
const names: string[] = [];
try {
while (names.length < limit) {
const entry = directory.readSync();
if (entry === null) break;
names.push(entry.name);
}
} finally {
directory.closeSync();
}
return names;
};
const systemFileSystem: ClaudeDesktopLocationFileSystem = {
readdir: (path, limit) =>
limit === undefined ? readdirSync(path) : readNames(path, limit),
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. Other Claude
* package identities and deployments leave 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(", ")} 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 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)
: present(fileSystem, appDataConfigPath);
if (!appDataCopy.ok) return appDataCopy;
return ok({
configPath:
privateCopy.value || !appDataCopy.value
? privateConfigPath
: appDataConfigPath,
markerPath: container,
msixPackage: packageView(),
});
};
/**
* 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.
* The package redirects the whole roaming root: its manifest excludes
* directories only under LocalAppData (version 2.42304.0.0, checked on
* 2026-10-18), so a private file from any roaming folder shows which view
* this is. Claude Desktop's own folder is compared first. Some hosts never
* get one, because the running app writes the real `%APPDATA%\Claude`; there
* the files the package or its processes wrote in other roaming folders
* decide. `LocalCache\Local` is not compared: files under its excluded
* directories are not redirected, so they could not tell the views apart.
*/
const processView = (
fileSystem: ClaudeDesktopLocationFileSystem,
privateRoaming: string,
appDataRoaming: string,
privateConfig: boolean,
): MsixPackageView["processView"] => {
const entryBudget = { inspected: 0 };
const ownProbes = privateConfig
? [join("Claude", CONFIG_FILE)]
: claudeFolderProbes(fileSystem, privateRoaming, entryBudget);
const probes =
ownProbes.length > 0
? ownProbes
: roamingProbes(fileSystem, privateRoaming, entryBudget);
let view: MsixPackageView["processView"] = "unknown";
for (const probe of probes) {
let privateTarget: string;
try {
privateTarget = fileSystem.realpath(join(privateRoaming, probe));
} catch (cause: unknown) {
// A private file that vanished cannot be compared; try another.
void cause;
continue;
}
const appDataPath = join(appDataRoaming, probe);
const first = resolveAppData(fileSystem, appDataPath);
if (first === undefined) return "unknown";
if (first === privateTarget) return "redirected";
// A private file deleted or replaced meanwhile no longer shadows its AppData
// path, so a redirected view would also miss it or reach an ordinary copy
// there. Count the probe only while the file is in place and a second
// lookup agrees; a probe that changed between lookups decides nothing.
if (!isPrivateFile(fileSystem, privateRoaming, probe)) continue;
const second = resolveAppData(fileSystem, appDataPath);
if (second === undefined) return "unknown";
if (second === privateTarget) return "redirected";
if (second === first) view = "direct";
}
return view;
};
/** Where an AppData path resolves: its target, `null` when it is missing, or `undefined` when it cannot be read. */
const resolveAppData = (
fileSystem: ClaudeDesktopLocationFileSystem,
path: string,
): string | null | undefined => {
try {
return fileSystem.realpath(path);
} catch (cause: unknown) {
return isMissing(cause) ? null : undefined;
}
};
/** Private files in Claude Desktop's own roaming folder, as paths under the roaming root. */
const claudeFolderProbes = (
fileSystem: ClaudeDesktopLocationFileSystem,
privateRoaming: string,
entryBudget: { inspected: number },
): readonly string[] => {
try {
const remaining = ROAMING_ENTRY_LIMIT - entryBudget.inspected;
if (remaining <= 0) return [];
const names = fileSystem.readdir(join(privateRoaming, "Claude"), remaining);
// The filesystem has already materialized these names. Debit all returned
// entries before selecting a handful of candidates so the budget covers
// enumeration and sorting work as well as lstat calls.
entryBudget.inspected += names.length;
const probes: string[] = [];
for (const name of names) {
if (isPrivateFile(fileSystem, privateRoaming, join("Claude", name))) {
probes.push(join("Claude", name));
if (probes.length === PROCESS_VIEW_PROBES) break;
}
}
return probes;
} catch (cause: unknown) {
// The folder may not exist; the rest of the roaming root can still decide.
void cause;
return [];
}
};
/**
* Private files anywhere under the roaming root, breadth first and within the
* walk's bounds. Directories that cannot be listed are skipped, links are not
* followed, and an entry is inspected only while the directory still needs a
* file or the queue has room for a folder. A listing reads no more names than
* the budget has left, so a crowded folder is never enumerated whole. A walk
* that runs out of budget keeps the files it found; with none, the view stays
* unknown.
*/
const roamingProbes = (
fileSystem: ClaudeDesktopLocationFileSystem,
privateRoaming: string,
entryBudget: { inspected: number },
): readonly string[] => {
const probes: string[] = [];
const queue: { readonly path: string; readonly depth: number }[] = [
{ path: "", depth: 0 },
];
for (
let listed = 0;
listed < ROAMING_DIRECTORY_LIMIT &&
probes.length < PROCESS_VIEW_PROBES &&
entryBudget.inspected < ROAMING_ENTRY_LIMIT;
listed++
) {
const directory = queue.shift();
if (directory === undefined) break;
let names: readonly string[];
try {
const enumerated = fileSystem.readdir(
join(privateRoaming, directory.path),
ROAMING_ENTRY_LIMIT - entryBudget.inspected,
);
// `readdir` and sorting have already processed every returned name, even
// when queue/file limits mean only a prefix can be inspected below.
entryBudget.inspected += enumerated.length;
names = [...enumerated].sort();
} catch (cause: unknown) {
// An unreadable folder cannot be compared; the others still can.
void cause;
continue;
}
let taken = 0;
for (const name of names) {
const wantsFile = taken < PROBES_PER_DIRECTORY;
const wantsFolder =
directory.depth < ROAMING_DEPTH_LIMIT &&
listed + queue.length < ROAMING_DIRECTORY_LIMIT - 1;
if (!wantsFile && !wantsFolder) break;
const path = join(directory.path, name);
const entry = entryKind(fileSystem, join(privateRoaming, path));
if (entry === "file" && wantsFile) {
probes.push(path);
taken += 1;
if (probes.length === PROCESS_VIEW_PROBES) break;
} else if (entry === "directory" && wantsFolder)
queue.push({ path, depth: directory.depth + 1 });
}
}
return probes;
};
const isPrivateFile = (
fileSystem: ClaudeDesktopLocationFileSystem,
privateRoaming: string,
path: string,
): boolean => entryKind(fileSystem, join(privateRoaming, path)) === "file";
/** A listed entry's kind, without following links; one that vanished or is unreadable is neither. */
const entryKind = (
fileSystem: ClaudeDesktopLocationFileSystem,
path: string,
): "file" | "directory" | undefined => {
try {
const entry = fileSystem.lstat(path);
return entry.isFile()
? "file"
: entry.isDirectory()
? "directory"
: undefined;
} catch (cause: unknown) {
// A candidate may vanish or become unreadable while listed.
void cause;
return undefined;
}
};
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);