mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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>
This commit is contained in:
@@ -73,12 +73,17 @@ to rerun it.
|
||||
A process that the MSIX app started, such as a terminal in Claude Desktop's
|
||||
Code tab, sees the package's private files at their `%APPDATA%` paths even
|
||||
though it reports no package identity. Setup compares one of those files at
|
||||
both paths. When it finds that redirected view, the app is running, so setup
|
||||
and uninstall refuse to change Claude Desktop's configuration; rerun them from
|
||||
a terminal that Claude Desktop did not start. If the package has no private
|
||||
file to compare, setup and uninstall cannot rule that out and also refuse; open
|
||||
Claude Desktop once if it has never run, quit it fully, then rerun. Doctor
|
||||
reads the same file from either kind of process.
|
||||
both paths: first one from the package's private `Claude` folder, and when
|
||||
that folder has none (some installations keep writing the real
|
||||
`%APPDATA%\Claude`), one from the package's other private roaming folders,
|
||||
which the package redirects in the same way. When it finds that redirected
|
||||
view, the app is running, so setup and uninstall refuse to change Claude
|
||||
Desktop's configuration; rerun them from a terminal that Claude Desktop did not
|
||||
start. If the package keeps no private roaming file at all, setup and
|
||||
uninstall cannot rule that out and also refuse. With Claude Desktop fully
|
||||
quit, add REA's entry by hand to the file setup names, which is the one the
|
||||
app reads, or remove it from both the private copy and the `%APPDATA%` file.
|
||||
Doctor reads the same file from either kind of process.
|
||||
|
||||
The same process also writes everything under `%APPDATA%` and
|
||||
`%LOCALAPPDATA%` into the package's private storage, where other apps never
|
||||
|
||||
@@ -34,7 +34,8 @@ const privateConfig = join(
|
||||
const failure = (code: string): Error =>
|
||||
Object.assign(new Error(`${code}: injected`), { code });
|
||||
|
||||
const file = { isFile: () => true };
|
||||
const file = { isFile: () => true, isDirectory: () => false };
|
||||
const folder = { isFile: () => false, isDirectory: () => true };
|
||||
const privateDirectory = dirname(privateConfig);
|
||||
const privatePreferences = join(privateDirectory, "Preferences");
|
||||
const appDataDirectory = dirname(appDataConfig);
|
||||
@@ -171,6 +172,343 @@ describe("Claude Desktop MSIX process view", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const privateRoaming = dirname(privateDirectory);
|
||||
|
||||
/**
|
||||
* Package storage whose private roaming root holds `files` (paths under that
|
||||
* root) and no Claude folder; `realpath` decides the process view and
|
||||
* `unreadable` folders refuse to be listed.
|
||||
*/
|
||||
const roamingFileSystem = (
|
||||
files: readonly string[],
|
||||
realpath: ClaudeDesktopLocationFileSystem["realpath"],
|
||||
unreadable: readonly string[] = [],
|
||||
): ClaudeDesktopLocationFileSystem => {
|
||||
const absolute = files.map((path) => join(privateRoaming, path));
|
||||
const within = (path: string) => (entry: string) =>
|
||||
entry.startsWith(`${path}${sep}`);
|
||||
return {
|
||||
readdir: (path, limit) => {
|
||||
if (path === packages) return [CLAUDE_DESKTOP_PACKAGE_FAMILY];
|
||||
if (
|
||||
unreadable.some(
|
||||
(folderPath) => join(privateRoaming, folderPath) === path,
|
||||
)
|
||||
)
|
||||
throw failure("EACCES");
|
||||
const below = absolute.filter(within(path));
|
||||
if (path !== privateRoaming && below.length === 0)
|
||||
throw failure("ENOENT");
|
||||
return [
|
||||
...new Set(
|
||||
below.map(
|
||||
(entry) => entry.slice(path.length + 1).split(sep)[0] ?? "",
|
||||
),
|
||||
),
|
||||
].slice(0, limit);
|
||||
},
|
||||
lstat: (path) => {
|
||||
if (absolute.includes(path)) return file;
|
||||
if (absolute.some(within(path))) return folder;
|
||||
throw failure("ENOENT");
|
||||
},
|
||||
realpath,
|
||||
};
|
||||
};
|
||||
|
||||
/** The process view a resolver reports for `fileSystem`. */
|
||||
const viewOf = (fileSystem: ClaudeDesktopLocationFileSystem) => {
|
||||
const location = resolveWindowsClaudeDesktopLocation(home, env, fileSystem);
|
||||
return location.ok ? location.value.msixPackage?.processView : undefined;
|
||||
};
|
||||
|
||||
describe("Claude Desktop MSIX process view from other roaming folders", () => {
|
||||
const telemetry = join("turborepo", "telemetry.json");
|
||||
|
||||
it("compares a private file outside Claude Desktop's own folder", () => {
|
||||
expect(viewOf(roamingFileSystem([telemetry], (path) => path))).toBe(
|
||||
"direct",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes a redirected view through that file", () => {
|
||||
const fileSystem = roamingFileSystem([telemetry], (path) =>
|
||||
path === join(env.APPDATA, telemetry)
|
||||
? join(privateRoaming, telemetry)
|
||||
: path,
|
||||
);
|
||||
expect(viewOf(fileSystem)).toBe("redirected");
|
||||
});
|
||||
|
||||
it("treats a private file missing at its AppData path as a direct view", () => {
|
||||
const fileSystem = roamingFileSystem([telemetry], (path) => {
|
||||
if (path === join(env.APPDATA, telemetry)) throw failure("ENOENT");
|
||||
return path;
|
||||
});
|
||||
expect(viewOf(fileSystem)).toBe("direct");
|
||||
});
|
||||
|
||||
it("skips a folder that cannot be listed and decides from another", () => {
|
||||
const fileSystem = roamingFileSystem(
|
||||
[join("Microsoft", "locked", "x"), telemetry],
|
||||
(path) =>
|
||||
path === join(env.APPDATA, telemetry)
|
||||
? join(privateRoaming, telemetry)
|
||||
: path,
|
||||
["Microsoft"],
|
||||
);
|
||||
expect(viewOf(fileSystem)).toBe("redirected");
|
||||
});
|
||||
|
||||
it("spreads its probes so a crowded folder cannot hide a redirected one", () => {
|
||||
const crowded = Array.from({ length: 20 }, (_, index) =>
|
||||
join("ghidra", `log-${String(index).padStart(2, "0")}`),
|
||||
);
|
||||
const compared: string[] = [];
|
||||
const fileSystem = roamingFileSystem([...crowded, telemetry], (path) => {
|
||||
compared.push(path);
|
||||
return path === join(env.APPDATA, telemetry)
|
||||
? join(privateRoaming, telemetry)
|
||||
: path;
|
||||
});
|
||||
expect(viewOf(fileSystem)).toBe("redirected");
|
||||
expect(
|
||||
compared.filter((path) =>
|
||||
path.startsWith(join(privateRoaming, "ghidra")),
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("stops after a bounded number of probes", () => {
|
||||
const files = Array.from({ length: 30 }, (_, index) =>
|
||||
join(`folder-${String(index).padStart(2, "0")}`, "file"),
|
||||
);
|
||||
const compared: string[] = [];
|
||||
viewOf(
|
||||
roamingFileSystem(files, (path) => {
|
||||
compared.push(path);
|
||||
return path;
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
compared.filter((path) => path.startsWith(privateRoaming)),
|
||||
).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("keeps the view unknown when the roaming root has no private file", () => {
|
||||
expect(viewOf(roamingFileSystem([], (path) => path))).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Desktop roaming enumeration budget", () => {
|
||||
const entryLimit = 1024;
|
||||
it("stops inspecting entries once its budget is spent", () => {
|
||||
const folders = Array.from({ length: 1500 }, (_, index) =>
|
||||
join(`folder-${String(index).padStart(4, "0")}`, "file"),
|
||||
);
|
||||
const fileSystem = roamingFileSystem(folders, (path) => path);
|
||||
let enumerated = 0;
|
||||
let inspected = 0;
|
||||
const requested: (number | undefined)[] = [];
|
||||
const view = viewOf({
|
||||
...fileSystem,
|
||||
readdir: (path, limit) => {
|
||||
if (path === privateRoaming) requested.push(limit);
|
||||
const names = fileSystem.readdir(path, limit);
|
||||
if (
|
||||
path === privateRoaming ||
|
||||
path.startsWith(`${privateRoaming}${sep}`)
|
||||
)
|
||||
enumerated += names.length;
|
||||
return names;
|
||||
},
|
||||
lstat: (path) => {
|
||||
if (dirname(path) === privateRoaming) inspected += 1;
|
||||
return fileSystem.lstat(path);
|
||||
},
|
||||
});
|
||||
// The root lists only folders, so the budget runs out before any file.
|
||||
expect(view).toBe("unknown");
|
||||
expect(enumerated).toBe(1024);
|
||||
expect(inspected).toBe(1024);
|
||||
// The listing itself stops at the budget instead of reading all 1500 names.
|
||||
expect(requested).toEqual([1024]);
|
||||
});
|
||||
|
||||
it("charges names materialized from an early queued leaf directory", () => {
|
||||
const files = Array.from({ length: 64 * 1000 }, (_, index) =>
|
||||
join(
|
||||
`folder-${String(Math.floor(index / 1000)).padStart(2, "0")}`,
|
||||
`file-${String(index % 1000).padStart(4, "0")}`,
|
||||
),
|
||||
);
|
||||
const base = roamingFileSystem(files, (path) => path);
|
||||
let enumerated = 0;
|
||||
const requests: Array<{ path: string; limit: number | undefined }> = [];
|
||||
const view = viewOf({
|
||||
...base,
|
||||
readdir: (path, limit) => {
|
||||
const names = base.readdir(path, limit);
|
||||
if (
|
||||
path === privateRoaming ||
|
||||
path.startsWith(`${privateRoaming}${sep}`)
|
||||
) {
|
||||
enumerated += names.length;
|
||||
requests.push({ path, limit });
|
||||
}
|
||||
return names;
|
||||
},
|
||||
});
|
||||
|
||||
expect(view).toBe("direct");
|
||||
expect(enumerated).toBe(entryLimit);
|
||||
expect(requests).toEqual([
|
||||
{ path: privateRoaming, limit: entryLimit },
|
||||
{
|
||||
path: join(privateRoaming, "folder-00"),
|
||||
limit: entryLimit - 64,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds the preferred Claude folder before selecting its probes", () => {
|
||||
const files = Array.from({ length: 1500 }, (_, index) =>
|
||||
join("Claude", `file-${String(index).padStart(4, "0")}.log`),
|
||||
);
|
||||
const base = roamingFileSystem(files, (path) => path);
|
||||
const listed: number[] = [];
|
||||
let inspected = 0;
|
||||
const view = viewOf({
|
||||
...base,
|
||||
readdir: (path, limit) => {
|
||||
if (path === join(privateRoaming, "Claude") && limit !== undefined)
|
||||
listed.push(limit);
|
||||
return base.readdir(path, limit);
|
||||
},
|
||||
lstat: (path) => {
|
||||
if (
|
||||
path.startsWith(`${join(privateRoaming, "Claude")}${sep}`) &&
|
||||
!path.endsWith(join("Claude", "claude_desktop_config.json"))
|
||||
)
|
||||
inspected += 1;
|
||||
return base.lstat(path);
|
||||
},
|
||||
});
|
||||
|
||||
expect(view).toBe("direct");
|
||||
expect(listed).toEqual([1024]);
|
||||
// Each selected candidate is inspected once while listing and again during
|
||||
// the race-safe comparison; selection itself remains capped at eight.
|
||||
expect(inspected).toBe(16);
|
||||
});
|
||||
|
||||
it("shares the entry budget between the preferred folder and fallback walk", () => {
|
||||
const folders = Array.from({ length: 1500 }, (_, index) =>
|
||||
join("Claude", `folder-${String(index).padStart(4, "0")}`, "file"),
|
||||
);
|
||||
const files = [...folders, join("turborepo", "telemetry.json")];
|
||||
const base = roamingFileSystem(files, (path) => path);
|
||||
const listed: Array<{ path: string; limit: number | undefined }> = [];
|
||||
let inspected = 0;
|
||||
const view = viewOf({
|
||||
...base,
|
||||
readdir: (path, limit) => {
|
||||
listed.push({ path, limit });
|
||||
return base.readdir(path, limit);
|
||||
},
|
||||
lstat: (path) => {
|
||||
if (
|
||||
path.startsWith(`${join(privateRoaming, "Claude")}${sep}`) &&
|
||||
!path.endsWith(join("Claude", "claude_desktop_config.json"))
|
||||
)
|
||||
inspected += 1;
|
||||
return base.lstat(path);
|
||||
},
|
||||
});
|
||||
|
||||
expect(view).toBe("unknown");
|
||||
expect(inspected).toBe(1024);
|
||||
expect(
|
||||
listed.filter(({ path }) => path === join(privateRoaming, "Claude")),
|
||||
).toEqual([{ path: join(privateRoaming, "Claude"), limit: 1024 }]);
|
||||
expect(listed.some(({ path }) => path === privateRoaming)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Desktop MSIX process view when a probe changes during the comparison", () => {
|
||||
const telemetry = join("turborepo", "telemetry.json");
|
||||
|
||||
it("does not count a private file deleted during the comparison as a direct view", () => {
|
||||
const fileSystem = roamingFileSystem([telemetry], (path) => path);
|
||||
let deleted = false;
|
||||
const view = viewOf({
|
||||
...fileSystem,
|
||||
realpath: (path) => {
|
||||
if (path !== join(env.APPDATA, telemetry)) return path;
|
||||
// A redirected view loses the file between the two comparisons.
|
||||
deleted = true;
|
||||
throw failure("ENOENT");
|
||||
},
|
||||
lstat: (path) => {
|
||||
if (deleted && path === join(privateRoaming, telemetry))
|
||||
throw failure("ENOENT");
|
||||
return fileSystem.lstat(path);
|
||||
},
|
||||
});
|
||||
expect(view).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does not count an ordinary copy reached after the private file was deleted as a direct view", () => {
|
||||
const fileSystem = roamingFileSystem([telemetry], (path) => path);
|
||||
let deleted = false;
|
||||
const view = viewOf({
|
||||
...fileSystem,
|
||||
realpath: (path) => {
|
||||
// The redirected view falls through to the ordinary AppData copy.
|
||||
if (path === join(env.APPDATA, telemetry)) deleted = true;
|
||||
return path;
|
||||
},
|
||||
lstat: (path) => {
|
||||
if (deleted && path === join(privateRoaming, telemetry))
|
||||
throw failure("ENOENT");
|
||||
return fileSystem.lstat(path);
|
||||
},
|
||||
});
|
||||
expect(view).toBe("unknown");
|
||||
});
|
||||
|
||||
it("recognizes a redirected view through a private file recreated during the comparison", () => {
|
||||
const fileSystem = roamingFileSystem([telemetry], (path) => path);
|
||||
let lookups = 0;
|
||||
const view = viewOf({
|
||||
...fileSystem,
|
||||
realpath: (path) => {
|
||||
if (path !== join(env.APPDATA, telemetry)) return path;
|
||||
lookups += 1;
|
||||
// The first lookup lands while the file is being replaced.
|
||||
if (lookups === 1) throw failure("ENOENT");
|
||||
return join(privateRoaming, telemetry);
|
||||
},
|
||||
});
|
||||
expect(view).toBe("redirected");
|
||||
});
|
||||
|
||||
it("does not decide from a probe whose AppData lookups disagree", () => {
|
||||
const fileSystem = roamingFileSystem([telemetry], (path) => path);
|
||||
let lookups = 0;
|
||||
const view = viewOf({
|
||||
...fileSystem,
|
||||
realpath: (path) => {
|
||||
if (path !== join(env.APPDATA, telemetry)) return path;
|
||||
lookups += 1;
|
||||
if (lookups === 1) throw failure("ENOENT");
|
||||
return path;
|
||||
},
|
||||
});
|
||||
expect(view).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unresolved Claude Desktop package identity", () => {
|
||||
it.each(["AnthropicPBC.Claude_fnn82j28hfe8t", "Claude_fnn82j28hfe8t"])(
|
||||
"refuses an unrecognized package deployment %s instead of selecting AppData",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { lstatSync, opendirSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -12,6 +12,18 @@ 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",
|
||||
@@ -38,13 +50,34 @@ export interface ClaudeDesktopLocationFailure {
|
||||
|
||||
/** Synchronous probes; each throws for failures other than absence. */
|
||||
export interface ClaudeDesktopLocationFileSystem {
|
||||
readonly readdir: (path: string) => readonly string[];
|
||||
readonly lstat: (path: string) => { isFile(): boolean };
|
||||
/** 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) => readdirSync(path),
|
||||
readdir: (path, limit) =>
|
||||
limit === undefined ? readdirSync(path) : readNames(path, limit),
|
||||
lstat: (path) => lstatSync(path),
|
||||
realpath: (path) => realpathSync.native(path),
|
||||
};
|
||||
@@ -91,7 +124,8 @@ export const resolveWindowsClaudeDesktopLocation = (
|
||||
if (family === undefined)
|
||||
return ok({ configPath: appDataConfigPath, markerPath: appDataDirectory });
|
||||
const container = join(packagesDirectory, family);
|
||||
const privateDirectory = join(container, "LocalCache", "Roaming", "Claude");
|
||||
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;
|
||||
@@ -112,8 +146,8 @@ export const resolveWindowsClaudeDesktopLocation = (
|
||||
virtualizedRoots: [roots.roaming, roots.local],
|
||||
processView: processView(
|
||||
fileSystem,
|
||||
privateDirectory,
|
||||
appDataDirectory,
|
||||
privateRoaming,
|
||||
roots.roaming,
|
||||
privateCopy.value,
|
||||
),
|
||||
},
|
||||
@@ -123,58 +157,184 @@ export const resolveWindowsClaudeDesktopLocation = (
|
||||
/**
|
||||
* 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.
|
||||
* 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,
|
||||
privateDirectory: string,
|
||||
appDataDirectory: string,
|
||||
privateRoaming: string,
|
||||
appDataRoaming: 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";
|
||||
}
|
||||
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 name of probes) {
|
||||
for (const probe of probes) {
|
||||
let privateTarget: string;
|
||||
try {
|
||||
privateTarget = fileSystem.realpath(join(privateDirectory, name));
|
||||
privateTarget = fileSystem.realpath(join(privateRoaming, probe));
|
||||
} 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";
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -7,7 +7,10 @@ import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { discoverSetupState } from "./SetupPlan.js";
|
||||
import type { MsixPackageView } from "./MsixPackageStorage.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import {
|
||||
registrationWriteBlocker,
|
||||
type SetupClient,
|
||||
} from "./SupportedClients.js";
|
||||
|
||||
// Host joins keep containment checks meaningful on POSIX test hosts too.
|
||||
const PROFILE = String.raw`C:\Users\a`;
|
||||
@@ -419,6 +422,17 @@ describe("MSIX Claude Desktop setup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("names the files to edit by hand when the process view is unknown", () => {
|
||||
// The package reads its private copy, so a manual setup edit belongs there.
|
||||
const client = msixClaudeDesktop("unknown");
|
||||
expect(registrationWriteBlocker(client, "setup")).toContain(
|
||||
`Add REA's entry to ${PRIVATE_CONFIG} by hand`,
|
||||
);
|
||||
expect(registrationWriteBlocker(client, "uninstall")).toContain(
|
||||
`Remove REA's entry by hand from each of ${PRIVATE_CONFIG} and ${APPDATA_CONFIG} that has one`,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses AppData writes from a process the MSIX package started", async () => {
|
||||
const host = new FakeSetupHost("win32");
|
||||
host.hopper = join(PROFILE, "Hopper");
|
||||
|
||||
@@ -283,9 +283,14 @@ export const registrationWriteBlocker = (
|
||||
const msix = client.msixPackage;
|
||||
if (msix === undefined || msix.processView === "direct") return undefined;
|
||||
const name = client.displayName ?? client.name;
|
||||
return msix.processView === "redirected"
|
||||
? `This process was started from ${name}'s MSIX package ${msix.packageFamilyName}: its AppData is redirected into that package, so ${name} is running and would overwrite ${msix.privateConfigPath} from memory. Quit ${name} fully, then rerun ${operation} from a terminal that ${name} did not start.`
|
||||
: `REA cannot tell whether ${name}'s MSIX package ${msix.packageFamilyName} started this process, because none of its files under ${dirname(msix.privateConfigPath)} could be compared with ${dirname(msix.appDataConfigPath)}. A running ${name} would overwrite the change. Open ${name} once if it has never run, quit it fully, then rerun ${operation} from a terminal that ${name} did not start.`;
|
||||
if (msix.processView === "redirected")
|
||||
return `This process was started from ${name}'s MSIX package ${msix.packageFamilyName}: its AppData is redirected into that package, so ${name} is running and would overwrite ${msix.privateConfigPath} from memory. Quit ${name} fully, then rerun ${operation} from a terminal that ${name} did not start.`;
|
||||
// Setup would write the configuration the app reads; uninstall cleans both files.
|
||||
const manualEdit =
|
||||
operation === "setup"
|
||||
? `Add REA's entry to ${client.configPath} by hand`
|
||||
: `Remove REA's entry by hand from each of ${msix.privateConfigPath} and ${msix.appDataConfigPath} that has one`;
|
||||
return `REA cannot tell whether ${name}'s MSIX package ${msix.packageFamilyName} started this process, because no private file under ${dirname(dirname(msix.privateConfigPath))} could be compared with ${dirname(dirname(msix.appDataConfigPath))}, and a ${name} that started it would overwrite the change from memory. ${manualEdit} while ${name} is fully quit, or rerun ${operation} from a terminal that ${name} did not start once REA can compare a private file there.`;
|
||||
};
|
||||
|
||||
/** The MSIX package view, if any, that redirects this process's AppData writes. */
|
||||
|
||||
@@ -131,10 +131,10 @@ describe("Windows Claude Desktop configuration location", () => {
|
||||
).toContain("claude_desktop");
|
||||
});
|
||||
|
||||
it("uses AppData while the MSIX package has no private copy", async () => {
|
||||
it("uses AppData but refuses to write while the package keeps no private roaming file", async () => {
|
||||
const { home, env, appDataConfig, container, privateConfig } =
|
||||
await windowsProfile();
|
||||
await mkdir(container, { recursive: true });
|
||||
await mkdir(join(container, "LocalCache", "Roaming"), { recursive: true });
|
||||
await writeJson(appDataConfig, { preferences: {} });
|
||||
|
||||
const client = claudeDesktop(home, env);
|
||||
@@ -148,7 +148,7 @@ describe("Windows Claude Desktop configuration location", () => {
|
||||
const inspection = await inspectClientConfiguration(client, {}, command);
|
||||
expect(inspection.status).toBe("invalid");
|
||||
expect(inspection.status === "invalid" && inspection.remediation).toContain(
|
||||
"Open Claude Desktop once if it has never run, quit it fully, then rerun setup",
|
||||
`Add REA's entry to ${appDataConfig} by hand while Claude Desktop is fully quit`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -228,6 +228,47 @@ describe("Windows Claude Desktop configuration location", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("Claude Desktop MSIX process view without its own private folder (#4278)", () => {
|
||||
it("decides the process view from another private roaming folder when Claude Desktop keeps none", async () => {
|
||||
const { home, env, appDataConfig, container } = await windowsProfile();
|
||||
// The app writes the real %APPDATA%\Claude; the package redirected other roaming writes.
|
||||
await writeJson(
|
||||
join(container, "LocalCache", "Roaming", "turborepo", "telemetry.json"),
|
||||
{},
|
||||
);
|
||||
await writeJson(appDataConfig, { preferences: {} });
|
||||
|
||||
const client = claudeDesktop(home, env);
|
||||
|
||||
expect(client.configPath).toBe(appDataConfig);
|
||||
expect(client.msixPackage).toMatchObject({ processView: "direct" });
|
||||
await expect(
|
||||
configureClientConfiguration(client, {}, command),
|
||||
).resolves.toMatchObject({ status: "configured" });
|
||||
expect(await readJson(appDataConfig)).toMatchObject({
|
||||
preferences: {},
|
||||
mcpServers: { rea: reaEntry },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to write when another private roaming folder shows a redirected view", async () => {
|
||||
const { home, env, appDataConfig, container } = await windowsProfile();
|
||||
const privateFolder = join(container, "LocalCache", "Roaming", "turborepo");
|
||||
await writeJson(join(privateFolder, "telemetry.json"), {});
|
||||
await writeJson(appDataConfig, { preferences: {} });
|
||||
// A process the package started sees that private folder at its AppData path.
|
||||
// A junction needs no symlink privilege on Windows; POSIX ignores the type.
|
||||
await symlink(privateFolder, join(env.APPDATA, "turborepo"), "junction");
|
||||
|
||||
const client = claudeDesktop(home, env);
|
||||
|
||||
expect(client.msixPackage).toMatchObject({ processView: "redirected" });
|
||||
const inspection = await inspectClientConfiguration(client, {}, command);
|
||||
expect(inspection.status).toBe("invalid");
|
||||
expect(await readJson(appDataConfig)).toEqual({ preferences: {} });
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit AppData root whitespace throughout setup, doctor and uninstall", async () => {
|
||||
const { home, env, appDataConfig: otherConfig } = await windowsProfile();
|
||||
const selected = {
|
||||
@@ -462,7 +503,8 @@ describe("MSIX Claude Desktop registration lifecycle", () => {
|
||||
|
||||
describe("MSIX Claude Desktop uninstall", () => {
|
||||
it("refuses uninstall when the package view is unknown", async () => {
|
||||
const { home, env, appDataConfig, container } = await windowsProfile();
|
||||
const { home, env, appDataConfig, container, privateConfig } =
|
||||
await windowsProfile();
|
||||
await mkdir(container, { recursive: true });
|
||||
await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } });
|
||||
|
||||
@@ -476,7 +518,9 @@ describe("MSIX Claude Desktop uninstall", () => {
|
||||
expect.objectContaining({
|
||||
name: "claude_desktop",
|
||||
status: "failed",
|
||||
detail: expect.stringContaining("rerun uninstall"),
|
||||
detail: expect.stringContaining(
|
||||
`Remove REA's entry by hand from each of ${privateConfig} and ${appDataConfig} that has one`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(await readJson(appDataConfig)).toEqual({
|
||||
|
||||
Reference in New Issue
Block a user