Files
reverse-engineering-anything/src/application/SetupPlan.ts
T
fd65e278be fix(setup): resolve the configuration MSIX Claude Desktop reads (#2426)
* fix(setup): resolve the configuration MSIX Claude Desktop reads

On Windows, setup, doctor and uninstall always used
%APPDATA%\Claude\claude_desktop_config.json. The MSIX build of Claude
Desktop (package family Claude_pzs8sxrjxfjjc) virtualizes AppData writes
and reads its package-private copy under
%LOCALAPPDATA%\Packages\<family>\LocalCache\Roaming\Claude once it exists.
Setup from an ordinary process therefore wrote a file the app never reads,
and doctor reported that file healthy.

Resolve the Claude Desktop location once, in supportedClients(): the
private copy when it exists, else the AppData file the package still
reads, else a new private copy. An unrecognized Claude_* publisher or
unreadable package storage is reported instead of falling back to AppData.

Compare one of the package's private files with its AppData path through
realpath.native to learn whether the running package started this
process. From that redirected view, or when no file can be compared,
setup and uninstall refuse to change Claude Desktop's configuration,
because the running app rewrites it from memory. From a redirected view
they also refuse writes under %APPDATA% and %LOCALAPPDATA% for other
clients and skills, which would land in the package's private storage.

Setup refuses to register a launcher stored in any MSIX package's
private storage, discloses which file the package reads, and tells the
user to quit Claude Desktop fully on every platform. Configuration fails
with target_changed if the package switched to its private copy after
the plan was shown. Uninstall removes REA from both files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(setup): record the verified MSIX Claude Desktop registration

On the Windows 11 host with the MSIX build 2.31226.1.0, setup from an
ordinary terminal with the app quit wrote the package-private copy. After
the app restarted, the entry persisted, doctor reported it aligned, and
Claude Desktop's MCP log showed REA starting and answering tools/list.
Uninstall against the real app remains unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(setup): format MSIX review corrections

* fix(setup): carry the admitted legacy path through uninstall removal

clientRegistrationRemoval returned a removeLegacy boolean, and removeClient
recomputed the legacy path separately, so TypeScript could not prove the
path existed when removeLegacy was true and `tsc --noEmit` failed in
Uninstall.ts. The decision now returns the admitted legacy path itself;
preflight and removal consume that one value.

The alias regression matched new-destination children with a "/"
separator while building paths with path.join, so it failed on Windows.
It now uses path.sep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(setup): split MSIX Claude Desktop lifecycle cases under the function length limit

The uninstall cases pushed the registration lifecycle describe past the 150-line lint limit. Group them separately, with the AppData registration case kept among the registration cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:04:22 +08:00

357 lines
13 KiB
TypeScript

import { join } from "node:path";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
linuxHopperInstallDisclosure,
type LinuxPackageFamily,
} from "./LinuxHopper.js";
import type { SkillDestination } from "./SetupSkill.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
import type {
ClientConfigurationInspection,
SetupAction,
SetupClientState,
SetupHost,
SetupProviderEnvironment,
} from "./SetupTypes.js";
import {
appDataRedirection,
registrationPersistenceDetail,
type SetupClient,
} from "./SupportedClients.js";
import { redirectedWriteBlocker } from "./MsixPackageStorage.js";
import type { DoctorReport } from "./Doctor.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
/** Read-only setup facts shared by client selection and preflight. */
export interface SetupDiscovery {
readonly initialDoctor: Awaited<ReturnType<SetupHost["doctor"]>>;
readonly installHopper: boolean;
readonly clients: readonly SetupClient[];
readonly detectedClientIds: readonly string[];
readonly defaultClientIds: readonly string[];
readonly clientStates: readonly SetupClientState[];
readonly linuxPackageFamily?: LinuxPackageFamily;
}
/** Discover supported clients and current registrations without mutation. */
export const discoverSetupState = async (input: {
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly forceHopperInstall: boolean;
readonly proposeHopper: boolean;
readonly initialDoctor: DoctorReport;
}): Promise<SetupDiscovery> => {
const { initialDoctor } = input;
const linuxHopperRepairNeeded = initialDoctor.checks.some(
({ name, ok, detail }) =>
!ok &&
(name === "hopper-demo-runtime" ||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
);
const linuxDistribution =
input.host.platform === "linux"
? await input.host.linuxDistribution()
: undefined;
const hopperInstallSupported =
(input.host.platform === "darwin" &&
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
(input.host.platform === "linux" && linuxDistribution?.supported === true);
const installHopper =
input.forceHopperInstall ||
(hopperInstallSupported &&
input.proposeHopper &&
(Object.keys(input.providerEnvironment).length === 0 ||
linuxHopperRepairNeeded));
const [clients, detectedClients] = await Promise.all([
input.host.supportedClients(),
input.host.detectedClients(),
]);
const detectedIds = new Set(detectedClients.map(({ name }) => name));
const registrations = initialDoctor.identity?.registrations ?? [];
const registrationByClient = new Map(
registrations.map((registration) => [registration.client, registration]),
);
const clientStates = clients.map((client): SetupClientState => {
const registration = registrationByClient.get(client.name);
const ownedRegistration =
(registration?.state === "aligned" || registration?.state === "stale") &&
isOwnedClientRegistrationCommand(registration.command);
const configured = ownedRegistration;
const status: SetupClientState["status"] =
registration?.state === "invalid"
? "invalid"
: ownedRegistration && registration.state === "aligned"
? "configured"
: ownedRegistration && registration.state === "stale"
? "needs_configuration"
: detectedIds.has(client.name)
? "needs_configuration"
: "missing";
return {
client,
detected: detectedIds.has(client.name),
configured,
status,
};
});
const defaultClientIds = registrations
.filter(
(registration) =>
(registration.state === "aligned" || registration.state === "stale") &&
isOwnedClientRegistrationCommand(registration.command),
)
.map(({ client }) => client);
return {
initialDoctor,
installHopper,
clients,
detectedClientIds: [...detectedIds],
defaultClientIds,
clientStates,
...(linuxDistribution?.packageFamily === undefined
? {}
: { linuxPackageFamily: linuxDistribution.packageFamily }),
};
};
/** Preflight exactly the selected clients and produce their concrete actions. */
export const planSetupActions = async (input: {
readonly discovery: SetupDiscovery;
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly command: readonly string[];
readonly clientIds: readonly string[];
readonly skillClientIds: readonly string[];
readonly installSkill: boolean;
}): Promise<{
readonly plannedActions: readonly SetupAction[];
readonly selectedClients: readonly SetupClient[];
readonly clientsToConfigure: readonly SetupClient[];
readonly blocker?: string;
}> => {
const byId = new Map(
input.discovery.clients.map((client) => [client.name, client]),
);
const unknownClient = input.clientIds.find((id) => !byId.has(id));
if (unknownClient !== undefined) {
return {
plannedActions: [],
selectedClients: [],
clientsToConfigure: [],
blocker: `Unsupported setup client: ${unknownClient}.`,
};
}
const selectedClients = input.clientIds.flatMap((id) => {
const client = byId.get(id);
return client === undefined ? [] : [client];
});
const inspections = await Promise.all(
selectedClients.map(async (client) => ({
client,
inspection: await input.host.inspectClientConfiguration(
client,
input.providerEnvironment,
input.command,
),
})),
);
const invalid = inspections.find(
({ inspection }) => inspection.status === "invalid",
);
if (invalid?.inspection.status === "invalid") {
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`,
};
}
const clientPlans = plannedClientWrites(
inspections,
input.discovery.installHopper,
);
const plannedActions = setupPlan({
platform: input.host.platform,
homeDirectory: input.host.homeDirectory,
installHopper: input.discovery.installHopper,
installSkill: input.installSkill,
skillClientIds: input.skillClientIds,
skillDestinations: input.host.skillDestinations(input.skillClientIds),
clients: clientPlans,
providerEnvironment: input.providerEnvironment,
...(input.discovery.linuxPackageFamily === undefined
? {}
: { linuxPackageFamily: input.discovery.linuxPackageFamily }),
command: input.command,
});
const blocker =
(clientPlans.length > 0
? input.host.registrationCommandBlocker
: undefined) ??
redirectedWriteBlocker(
appDataRedirection(input.discovery.clients),
plannedActions
.filter(({ kind }) => kind !== "install_hopper")
.map(({ target }) => target),
"setup",
);
return {
plannedActions,
selectedClients,
clientsToConfigure: selectedClients.filter((client) =>
clientPlans.some(({ client: planned }) => planned.name === client.name),
),
...(blocker === undefined ? {} : { blocker }),
};
};
/** Selected clients whose registration setup writes, with the disclosed operation. */
const plannedClientWrites = (
inspections: readonly {
readonly client: SetupClient;
readonly inspection: ClientConfigurationInspection;
}[],
installHopper: boolean,
) =>
inspections.flatMap(({ client, inspection }) =>
client.format !== "unsupported" &&
(inspection.status === "create" ||
inspection.status === "update" ||
(installHopper && inspection.status === "already_current"))
? [
{
client,
operation:
inspection.status === "already_current"
? ("update" as const)
: inspection.status,
...(inspection.status !== "already_current" &&
inspection.backupPath !== undefined
? { backupPath: inspection.backupPath }
: {}),
},
]
: [],
);
/** Describe every selected setup mutation before user approval. */
const setupPlan = (input: {
readonly platform: NodeJS.Platform;
readonly homeDirectory: string;
readonly installHopper: boolean;
readonly installSkill: boolean;
readonly skillClientIds: readonly string[];
readonly skillDestinations: readonly SkillDestination[];
readonly clients: readonly {
readonly client: SetupClient;
readonly operation: "create" | "update";
readonly backupPath?: string;
}[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
readonly command: readonly string[];
}): readonly SetupAction[] => [
...(input.installHopper
? [
{
id: "install_hopper",
kind: "install_hopper" as const,
label: "Hopper deep-analysis provider",
target:
input.platform === "darwin"
? join(
input.homeDirectory,
"Applications/Hopper Disassembler.app",
)
: "system package manager",
detail:
input.platform === "linux"
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
operation: "install" as const,
...(input.platform === "linux"
? input.linuxPackageFamily === undefined
? {}
: linuxDisclosure(input.linuxPackageFamily)
: {
networkOrigins: macHopperInstallDisclosure.networkOrigins,
commands: macHopperInstallDisclosure.commands,
}),
},
]
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
.map(({ client, operation, backupPath }): SetupAction => ({
id: `configure_client:${client.name}`,
kind: "configure_client",
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
client,
input.command,
input.providerEnvironment,
input.installHopper,
),
external: false,
operation,
...(backupPath === undefined ? {} : { backupPath }),
})),
...(input.installSkill
? input.skillDestinations.map(({ client, path }): SetupAction => ({
id: client === "shared" ? "install_skill" : `install_skill:${client}`,
kind: "install_skill",
label:
client === "claude_code"
? "REA skill for Claude Code"
: "REA reverse-engineering skill",
target: path,
detail:
client === "claude_code"
? "Install or update the REA skill in Claude Code's personal skill directory."
: "Install or update the bundled REA reverse-engineering skill and on-demand references.",
external: false,
operation: "install",
}))
: []),
];
const linuxDisclosure = (family: LinuxPackageFamily) => {
const disclosure = linuxHopperInstallDisclosure(
family,
process.getuid?.() === 0,
);
return {
networkOrigins: [disclosure.downloadUrl],
commands: disclosure.commands,
integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`,
};
};
const majorVersion = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const clientConfigurationDetail = (
client: SetupClient,
command: readonly string[],
environment: SetupProviderEnvironment,
installHopper: boolean,
): string => {
const entries = Object.entries(environment)
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
.map(([name, value]) => `${name}=${value}`);
const environmentDetail =
entries.length === 0
? " No additional provider environment variables. Existing server environment overrides are retained."
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys.`;
const hopperDetail = installHopper
? environment.HOPPER_LAUNCHER_PATH === undefined
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
: "";
const persistence = registrationPersistenceDetail(client);
return `Add the REA MCP registration for ${client.displayName ?? client.name}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}${persistence === undefined ? "" : ` ${persistence}`}`;
};