From e3a00bb5f99a708dcab00b278b7019a0c823d318 Mon Sep 17 00:00:00 2001 From: pablodiazjorge <102741971+pablodiazjorge@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:27:18 +0200 Subject: [PATCH] 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> --- docs/client-configuration.md | 17 +- .../ClaudeDesktopConfigLocation.test.ts | 340 +++++++++++++++++- .../ClaudeDesktopConfigLocation.ts | 244 ++++++++++--- src/application/Setup.configuration.test.ts | 16 +- src/application/SupportedClients.ts | 11 +- .../claudeDesktopClientConfiguration.test.ts | 54 ++- 6 files changed, 624 insertions(+), 58 deletions(-) diff --git a/docs/client-configuration.md b/docs/client-configuration.md index b1235de551..b6ccf6bafa 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -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 diff --git a/src/application/ClaudeDesktopConfigLocation.test.ts b/src/application/ClaudeDesktopConfigLocation.test.ts index 9b66ddbd19..b6e6b768f8 100644 --- a/src/application/ClaudeDesktopConfigLocation.test.ts +++ b/src/application/ClaudeDesktopConfigLocation.test.ts @@ -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", diff --git a/src/application/ClaudeDesktopConfigLocation.ts b/src/application/ClaudeDesktopConfigLocation.ts index 0a0208126e..b4e84d92e4 100644 --- a/src/application/ClaudeDesktopConfigLocation.ts +++ b/src/application/ClaudeDesktopConfigLocation.ts @@ -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, diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index b14c6816b9..a9e75613ac 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -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"); diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index d23f1d9ca5..f531f01108 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -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. */ diff --git a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts index b0ffb1cce0..7cc4d2e228 100644 --- a/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts +++ b/tests/boundary/filesystem/claudeDesktopClientConfiguration.test.ts @@ -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({