Files
rea/src/application/ClaudeDesktopConfigLocation.ts
T
pablodiazjorgeandmorluto e3a00bb5f9 fix(setup): tell MSIX Claude Desktop's process view from any private roaming file (#4903)
* fix(setup): tell MSIX Claude Desktop's process view from any private roaming file

Some profiles keep writing the real %APPDATA%\Claude, so the MSIX package never gets a private LocalCache\Roaming\Claude folder and setup and uninstall refused Claude Desktop for good. Compare that folder first and, when it has no file, a bounded set of files from the package's other private roaming folders, which the package redirects in the same way. A package with no private roaming file still refuses, now with the manual edit as the remedy.

Fixes #4278

* fix(setup): address review findings on the MSIX roaming probe

Name the configuration setup would write, and both package files for uninstall, in the unknown-view advice. Bound the roaming walk by 1024 inspected entries and a queue no longer than the directories it can still list. Count ENOENT at the AppData path as a direct view only while the private file still exists.

* fix(setup): bound the roaming walk's directory listings by its entry budget

Read at most the names the walk's budget has left, with opendirSync in production, so a crowded package-private folder is never enumerated whole before the budget applies.

* fix(setup): recheck the private probe before accepting a mismatched target

A private file deleted between the two lookups no longer shadows its AppData path, so a redirected view can reach an ordinary copy there. Count a different target, like ENOENT, as a direct view only while the private file still exists.

* fix(setup): repeat the AppData lookup before trusting a rechecked probe

A private file deleted and recreated between the lookups passed the existence recheck on stale evidence. Look the AppData path up again after the recheck: a match with the private target is a redirected view, an agreeing non-match counts as direct, and a probe whose lookups disagree decides nothing.

* fix: bound Claude roaming folder probes

* fix(setup): charge enumerated Claude folder names

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-23 02:27:18 +08:00

361 lines
13 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;
}
/** 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 family = families[0];
if (family === undefined)
return ok({ configPath: appDataConfigPath, markerPath: appDataDirectory });
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);
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,
privateRoaming,
roots.roaming,
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.
* 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);