Files
rea/src/application/MsixPackageStorage.ts
FeeHaard 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

188 lines
7.2 KiB
TypeScript

import { lstat } from "node:fs/promises";
import { realpathSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
import { err, ok, type Result } from "../domain/result.js";
/**
* How one MSIX-packaged client reads its AppData configuration. A package
* with write virtualization saves AppData files to its private
* `LocalCache\Roaming` copy, then reads that copy whenever it exists and the
* ordinary AppData file otherwise.
*/
export interface MsixPackageView {
readonly packageFamilyName: string;
/** The package-private copy, which shadows the AppData file once it exists. */
readonly privateConfigPath: string;
/** The AppData path that unpackaged processes read and write. */
readonly appDataConfigPath: string;
/** Roots whose writes the package redirects for processes it starts. */
readonly virtualizedRoots: readonly string[];
/**
* `redirected`: this process sees one of the package's private files at its
* AppData path, so the running package started it. `direct`: it does not.
* `unknown`: no private file could be compared.
*/
readonly processView: "redirected" | "direct" | "unknown";
}
/** Windows' roaming and local application data roots; empty means unset. */
export const windowsAppDataRoots = (
home: string,
env: Readonly<NodeJS.ProcessEnv>,
): { readonly roaming: string; readonly local: string } => ({
roaming: env.APPDATA?.trim() || join(home, "AppData", "Roaming"),
local: env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
});
/** The per-user root of MSIX package data containers. */
export const msixPackagesDirectory = (
home: string,
env: Readonly<NodeJS.ProcessEnv>,
): string => join(windowsAppDataRoots(home, env).local, "Packages");
/** Whether `path` is `root` or inside it, using the host's path comparison. */
const pathWithin = (path: string, root: string): boolean => {
const inner = relative(root, path);
return (
inner === "" ||
(inner !== ".." && !inner.startsWith(`..${sep}`) && !isAbsolute(inner))
);
};
/**
* Name the package whose private `LocalCache` holds a canonical path. Its
* processes see those files at virtual AppData locations; other processes do
* not, and Windows removes them with the package.
*/
export const packagePrivateStorageFamily = (
canonicalPath: string,
packagesDirectory: string,
): string | undefined => {
if (!pathWithin(canonicalPath, packagesDirectory)) return undefined;
const [family, storage] = relative(packagesDirectory, canonicalPath).split(
/[\\/]/u,
);
return family !== undefined &&
family !== "" &&
storage?.toLowerCase() === "localcache"
? family
: undefined;
};
/**
* Explain why setup must not register a launcher that exists only in one
* package's private storage. Other clients could not start it, and doctor
* outside that package could not verify it.
*/
export const packagePrivateLauncherBlocker = (
command: readonly string[],
packagesDirectory: string,
canonicalPath: (path: string) => string = realpathSync.native,
): string | undefined => {
// Compare canonical forms: a short-name, junctioned or substituted profile
// spells the same directory differently.
let packages = packagesDirectory;
try {
packages = canonicalPath(packagesDirectory);
} catch (cause: unknown) {
// Without package storage, the spelled path is the only comparison.
void cause;
}
for (const part of command) {
if (!isAbsolute(part)) continue;
let resolved: string;
try {
resolved = canonicalPath(part);
} catch (cause: unknown) {
// A launcher that cannot be resolved is not evidence of package storage.
void cause;
continue;
}
const family = packagePrivateStorageFamily(resolved, packages);
if (family !== undefined)
return `The REA launcher ${part} is stored at ${resolved}, inside the private storage of MSIX package ${family}. Only processes started by that app can see it at ${part}, and Windows removes it with the package, so other clients could not start this registration. Install REA from a terminal that the app did not start (for example with \`npm install -g rea-agents\`), then rerun setup from that terminal.`;
}
return undefined;
};
/**
* Explain why this process must not write `targets`: the running package
* started it, so writes under its virtualized roots land in that package's
* private storage, which the targets' owners never read.
*/
const canonicalDestination = (
path: string,
canonicalPath: (path: string) => string,
): Result<string, { readonly path: string; readonly detail: string }> => {
let ancestor = path;
const missing: string[] = [];
while (true) {
try {
return ok(join(canonicalPath(ancestor), ...missing));
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR") &&
dirname(ancestor) !== ancestor
) {
missing.unshift(basename(ancestor));
ancestor = dirname(ancestor);
continue;
}
return err({
path,
detail: cause instanceof Error ? cause.message : String(cause),
});
}
}
};
export const redirectedWriteBlocker = (
view: MsixPackageView | undefined,
targets: readonly string[],
operation: "setup" | "uninstall",
canonicalPath: (path: string) => string = realpathSync.native,
): string | undefined => {
if (view?.processView !== "redirected" || targets.length === 0)
return undefined;
const roots: string[] = [];
const redirected: string[] = [];
for (const root of view.virtualizedRoots) {
const resolved = canonicalDestination(root, canonicalPath);
if (!resolved.ok)
return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`;
roots.push(resolved.value);
}
for (const target of targets) {
const resolved = canonicalDestination(target, canonicalPath);
if (!resolved.ok)
return `Cannot establish the MSIX write location for ${resolved.error.path}: ${resolved.error.detail}. Rerun ${operation} from a terminal that this app did not start.`;
if (roots.some((root) => pathWithin(resolved.value, root)))
redirected.push(target);
}
if (redirected.length === 0) return undefined;
return `This process was started by MSIX package ${view.packageFamilyName}, which redirects its writes under ${view.virtualizedRoots.join(" and ")} into the package's private storage. Changes to ${redirected.join(", ")} would not reach the files other apps read. Rerun ${operation} from a terminal that this app did not start.`;
};
/**
* Whether the package began reading its private copy after the AppData file
* was chosen, for example by saving its settings when it quit.
*/
export const packageReadTargetChanged = async (
view: MsixPackageView,
configPath: string,
): Promise<boolean> => {
if (configPath !== view.appDataConfigPath) return false;
try {
await lstat(view.privateConfigPath);
return true;
} catch (cause: unknown) {
return !(
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
);
}
};