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:
pablodiazjorge
2026-10-22 20:27:18 +02:00
committed by GitHub
parent af42f856ec
commit e3a00bb5f9
6 changed files with 624 additions and 58 deletions
+11 -6
View File
@@ -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",
+202 -42
View File
@@ -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,
+15 -1
View File
@@ -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");
+8 -3
View File
@@ -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({