Files
reverse-engineering-anything/src/doctorRuntime.ts
T
Luqman Bramantyo Rahmadiandmorluto dae1551403 feat(setup): support Cline as a client (#10128)
* feat(setup): support Cline as a client

* test(setup): cover Cline doctor and uninstall ownership

* docs(setup): document Cline support

* test(setup): verify Cline against an installed client

* test(setup): include Cline in the exhaustive client detection list

* docs(setup): note that a hand-written rea entry blocks Cline setup

* fix(setup): isolate Cline verification and await MCP discovery

* fix(verification): own and isolate Cline discovery

* fix(setup): preserve native Cline transport settings

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-11-11 17:42:53 -05:00

567 lines
20 KiB
TypeScript

import {
systemDoctorHost,
type DoctorCheck,
type DoctorHost,
type DoctorProviderInspection,
} from "./application/Doctor.js";
import { fileURLToPath } from "node:url";
import { readDesktopBuildFacts } from "./desktop/DesktopBuildDoctorFacts.js";
import {
JADX_DOCTOR_SETTINGS,
inspectJadxProvider,
} from "./android/JadxDoctor.js";
import {
GHIDRA_DOCTOR_SETTINGS,
inspectSystemGhidraProvider,
} from "./ghidra/GhidraDoctor.js";
import {
IDA_DOCTOR_SETTINGS,
inspectIdaRegistration,
} from "./ida/IdaDoctor.js";
import {
KUNA_DOCTOR_SETTINGS,
inspectKunaProvider,
} from "./kuna/KunaDoctor.js";
import { readIdaConfiguration } from "./ida/IdaConfiguration.js";
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
import { ok } from "./domain/result.js";
import { AnalysisCancelledError } from "./domain/analysisErrorCore.js";
import { ProviderCleanupError } from "./domain/providerCleanupError.js";
import { AnalysisCleanupLeaseGroup } from "./artifacts/AnalysisCleanupLease.js";
import type { RetainedResourceScope } from "./application/AnalysisProvider.js";
import { RegularFileCleanupFailure } from "./filesystem/OwnedRegularFile.js";
import { readEffectiveClientRegistrationConfiguration } from "./application/ClientConfigurationFiles.js";
import { clientRegistrationEnvironmentDetails } from "./application/ClientRegistrationEnvironment.js";
import {
mergeEnvironmentOverrides,
snapshotEnvironment,
} from "./process/snapshotEnvironment.js";
import { supportedClients } from "./application/SupportedClients.js";
import { homeDirectoryFromEnvironment } from "./config/homeDirectory.js";
import {
inspectLinuxHopperPythonRuntime,
linuxHopperPythonUnavailable,
} from "./application/LinuxHopperPython.js";
/**
* A selected client's own environment, when its registration records one.
* Provider checks read the terminal by default; a caller that asked about one
* client wants the values that client passes to the server it starts.
*/
interface SelectedRegistrationEnvironment {
/** The terminal a selected client inherits, overlaid by its registration. */
readonly environment: Readonly<Record<string, string | undefined>>;
/** Names the client records as expressions this process cannot evaluate. */
readonly unresolved: ReadonlySet<string>;
/** The registration could not be read or its effective settings resolved. */
readonly unknown?: boolean;
}
const selectedRegistrationEnvironment = async (
clients: readonly string[],
processEnvironment: Readonly<NodeJS.ProcessEnv>,
resourceScope: RetainedResourceScope,
signal: AbortSignal,
): Promise<SelectedRegistrationEnvironment | undefined> => {
const [selected] = clients;
// Only an unambiguous selection can stand for the terminal's environment.
if (selected === undefined || clients.length !== 1) return undefined;
signal.throwIfAborted();
const home = homeDirectoryFromEnvironment(
processEnvironment,
process.platform,
);
const client = supportedClients(
home,
process.platform,
processEnvironment,
).find(({ name }) => name === selected);
if (client === undefined || client.format === "unsupported")
return { environment: {}, unresolved: new Set(), unknown: true };
const registered = await readSelectedRegistration(
client,
resourceScope,
process.platform,
);
if (registered === undefined) return undefined;
if ("unknown" in registered)
return { environment: {}, unresolved: new Set(), unknown: true };
return {
environment: registered.values,
unresolved: registered.unresolvedSettings,
};
};
/**
* Fold a selected registration and the terminal it inherits into the canonical
* names a provider check reads: the registration's recorded values win, names
* it records as unreadable expressions are dropped, and everything else
* survives from the terminal.
*
* Windows treats setting names case-insensitively, so a registration recording
* `ghidra_install_dir` must override the terminal's `GHIDRA_INSTALL_DIR`
* rather than sit beside it, and a dropped expression must be found under
* either spelling. `snapshotEnvironment` performs that fold;
* `mergeEnvironmentOverrides` alone keeps the winning layer's spelling.
*/
export const selectedProviderSettings = (
selected: SelectedRegistrationEnvironment | undefined,
terminal: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
): Readonly<Record<string, string | undefined>> => {
if (selected === undefined) return terminal;
const settings: Record<string, string | undefined> = {
...snapshotEnvironment(
mergeEnvironmentOverrides(terminal, selected.environment, platform),
platform,
),
};
for (const name of Object.keys(
snapshotEnvironment(
Object.fromEntries(
[...selected.unresolved].map((entry): [string, string] => [entry, ""]),
),
platform,
),
))
delete settings[name];
return settings;
};
type SelectedRegistration = {
readonly values: Readonly<Record<string, string>>;
readonly unresolvedSettings: ReadonlySet<string>;
};
/** Read the selected client's registration, or report it as unreadable. */
const readSelectedRegistration = async (
client: Parameters<typeof readEffectiveClientRegistrationConfiguration>[0],
resourceScope: RetainedResourceScope,
platform: NodeJS.Platform,
): Promise<SelectedRegistration | { readonly unknown: true } | undefined> => {
// A failed close is a retained owner, not a read outcome: transfer it to the
// caller's scope so `close()` retries it instead of losing it.
const configuration = await resourceScope
.run(() =>
readEffectiveClientRegistrationConfiguration(client, undefined, platform),
)
.catch((cause: unknown) => {
if (cause instanceof RegularFileCleanupFailure) {
resourceScope.retain(cause.owner, cause.path);
return undefined;
}
throw cause;
});
// A registration this process cannot read says nothing about the values the
// client passes on; the terminal is not those values.
if (configuration === undefined) return { unknown: true };
if (!configuration.ok) {
const cleanup = configuration.error.cleanupFailure;
if (cleanup !== undefined)
resourceScope.retain(cleanup.owner, cleanup.path);
return { unknown: true };
}
if (configuration.value.document === undefined) return undefined;
const details = clientRegistrationEnvironmentDetails(
configuration.value.document,
client,
{},
"effective",
platform,
);
return details.ok
? {
values: details.value.values,
unresolvedSettings: details.value.unresolvedSettings,
}
: { unknown: true };
};
/** Registration settings read by each provider's readiness probe. */
const PROVIDER_SETTINGS: Readonly<Record<string, readonly string[]>> = {
ghidra: GHIDRA_DOCTOR_SETTINGS,
ida: IDA_DOCTOR_SETTINGS,
jadx: JADX_DOCTOR_SETTINGS,
kuna: KUNA_DOCTOR_SETTINGS,
};
/**
* Whether a setting an unresolved expression removed is one this probe actually
* reads for the configuration the client supplied. A literal `JAVA_HOME` and
* jar path make JADX never consult `PATH`, so failing that probe closed would
* report `unknown` for a provider that has everything it needs.
*/
export const settingAffectsProbe = (
id: string,
name: string,
environment: Readonly<Record<string, string | undefined>>,
platform: NodeJS.Platform,
): boolean => {
const read = (key: string) =>
environment[platform === "win32" ? key.toUpperCase() : key];
switch (id) {
case "jadx":
// Java comes from JAVA_HOME when it is explicit; only then is PATH unused.
return name !== "PATH" || read("JAVA_HOME") === undefined;
case "ghidra":
// Both the install directory and the JDK fall back to PATH lookups.
return (
name !== "PATH" ||
read("GHIDRA_INSTALL_DIR") === undefined ||
read("JAVA_HOME") === undefined
);
case "kuna": {
// Only cache-location defaults are decided by REA_KUNA_CACHE_DIR. An
// unresolved command, job or timeout setting still leaves the probe's
// answer unknown, so those keep reporting unknown.
if (
name !== "HOME" &&
name !== "USERPROFILE" &&
name !== "XDG_CACHE_HOME"
)
return true;
// A literal cache directory decides the root on its own.
if (read("REA_KUNA_CACHE_DIR") !== undefined) return false;
if (name === "XDG_CACHE_HOME") return platform !== "darwin";
if (
name === "USERPROFILE" &&
platform !== "win32" &&
(read("HOME")?.length ?? 0) > 0
)
return false;
return true;
}
default:
return true;
}
};
const unresolvedProvider = (
id: string,
selected: SelectedRegistrationEnvironment | undefined,
environment: Readonly<Record<string, string | undefined>>,
platform: NodeJS.Platform,
): DoctorProviderInspection | undefined => {
if (selected === undefined) return undefined;
const normalize = (key: string) =>
platform === "win32" ? key.toUpperCase() : key;
const read = new Set((PROVIDER_SETTINGS[id] ?? []).map(normalize));
const unresolved = [...selected.unresolved].filter(
(name) =>
read.has(normalize(name)) &&
settingAffectsProbe(id, normalize(name), environment, platform),
);
if (selected.unknown !== true && unresolved.length === 0) return undefined;
const configurationKeys: Readonly<Record<string, string>> = {
ghidra: "GHIDRA_INSTALL_DIR",
ida: "REA_IDA_MCP_CONFIG",
jadx: "REA_JADX_MCP_JAR",
kuna: "REA_KUNA_COMMAND",
};
const configurationKey = configurationKeys[id];
const unknownConfiguration =
selected.unknown === true ||
configurationKey === undefined ||
unresolved.some((name) => normalize(name) === normalize(configurationKey));
const configuration =
configurationKey === undefined ? undefined : environment[configurationKey];
const configured = unknownConfiguration
? null
: id === "kuna"
? configuration !== undefined && configuration.length > 0
: configuration !== undefined;
return {
id,
configured,
available: null,
providerVersion: null,
registrationEnvironment: {},
checks: [
{
name: "configuration",
ok: false,
code: "registration_environment_unknown",
classification: "unknown",
detail:
selected.unknown === true
? "The selected client's registration could not be read or resolved."
: `The selected client's registration has unresolved settings: ${unresolved.join(", ")}.`,
remediation:
"Inspect the selected client's registration on the host and rerun doctor.",
},
],
};
};
/** Compose provider diagnostics at the outer CLI adapter boundary. */
export const createSystemDoctorHost = (
environment: Readonly<NodeJS.ProcessEnv> = process.env,
signal?: AbortSignal,
): DoctorHost => {
const registrationReads = new WeakMap<
RetainedResourceScope,
Map<string, ReturnType<typeof readIdaConfiguration>>
>();
const readRegistration = (
path: string,
resourceScope: RetainedResourceScope,
inspectionSignal: AbortSignal,
) => {
let byPath = registrationReads.get(resourceScope);
if (byPath === undefined) {
byPath = new Map();
registrationReads.set(resourceScope, byPath);
}
let registration = byPath.get(path);
if (registration === undefined) {
registration = readIdaConfiguration(path, {
resourceScope,
signal: inspectionSignal,
});
byPath.set(path, registration);
}
return registration;
};
return systemDoctorHost({
environment,
...(signal === undefined ? {} : { signal }),
resolveProviderRequestBudget: async (
analysisProvider,
registrationEnvironment,
resourceScope,
inspectionSignal,
) => {
if (analysisProvider !== "ida" && analysisProvider !== "auto")
return ok(undefined);
const path = registrationEnvironment.REA_IDA_MCP_CONFIG;
if (path === undefined) return ok(undefined);
const registration = await readRegistration(
path,
resourceScope,
inspectionSignal,
);
if (registration.ok) return ok(registration.value.timeoutMs);
// An invalid IDA registration cannot be selected automatically. Keep it
// out of recommendations for another available provider, but surface the
// parser-owned error when IDA itself was explicitly selected.
return analysisProvider === "ida" ? registration : ok(undefined);
},
providerInspections: async (
resourceScope: RetainedResourceScope,
inspectionSignal: AbortSignal,
clients: readonly string[],
) => {
const selected = await selectedRegistrationEnvironment(
clients,
environment,
resourceScope,
inspectionSignal,
);
const inspectionEnvironment = selectedProviderSettings(
selected,
environment,
);
return [
unresolvedProvider(
"ghidra",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectSystemGhidraProvider(
inspectionEnvironment,
resourceScope,
inspectionSignal,
)),
unresolvedProvider(
"ida",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectIdaRegistration(
inspectionEnvironment.REA_IDA_MCP_CONFIG,
resourceScope,
inspectionSignal,
inspectionEnvironment.REA_IDA_MCP_CONFIG === undefined
? undefined
: readRegistration(
inspectionEnvironment.REA_IDA_MCP_CONFIG,
resourceScope,
inspectionSignal,
),
)),
unresolvedProvider(
"jadx",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectJadxProvider(
inspectionEnvironment,
resourceScope,
inspectionSignal,
)),
unresolvedProvider(
"kuna",
selected,
inspectionEnvironment,
process.platform,
) ??
(await inspectKunaProvider(inspectionEnvironment, process.platform, {
resourceScope,
signal: inspectionSignal,
})),
] satisfies readonly DoctorProviderInspection[];
},
desktopBuildFacts: readDesktopBuildFacts,
linuxDemoRuntimeCheck: (resourceScope, inspectionSignal) =>
inspectLinuxHopperDemoRuntime(
environment,
resourceScope,
inspectionSignal,
),
});
};
const HOPPER_DEMO_HELPER_PATH = fileURLToPath(
new URL("../scripts/hopper-demo-x11.py", import.meta.url),
);
const inspectLinuxHopperDemoRuntime = async (
environment: Readonly<NodeJS.ProcessEnv>,
resourceScope: RetainedResourceScope,
signal?: AbortSignal,
): Promise<DoctorCheck> => {
const selection = await selectLinuxPrivateDisplayStrategy({
helperPath: HOPPER_DEMO_HELPER_PATH,
environment,
...(signal === undefined ? {} : { signal }),
});
if (!selection.ok && selection.cleanup !== undefined) {
const { lease, reason } = selection.cleanup;
resourceScope.retain(
{
close: () => lease.release(),
get cleanupResources() {
return lease.cleanupResources;
},
},
"hopper-private-display-probe",
);
const cleanup = {
reason,
resources: [
...(lease.cleanupResources ?? ["hopper-private-display-probe"]),
],
};
const diagnostics = {
hopper_demo_runtime: { private_display: { ...selection.diagnostic } },
};
const failure =
signal?.aborted === true
? new AnalysisCancelledError("doctor", {
cause: signal.reason,
cleanup,
diagnostics,
})
: new ProviderCleanupError(
"hopper",
cleanup.resources,
{ reason, ...diagnostics },
{ operation: "doctor" },
);
new AnalysisCleanupLeaseGroup(failure, [lease]);
throw failure;
}
if (signal?.aborted === true)
throw new AnalysisCancelledError("doctor", {
cause: signal.reason,
diagnostics: {
hopper_demo_runtime: { private_display: { ...selection.diagnostic } },
},
});
const python = await inspectLinuxHopperPythonRuntime(environment, signal);
const pythonFailure = !python.ok
? python.error
: python.value.library.status !== "loadable"
? linuxHopperPythonUnavailable(python.value.library)
: undefined;
const pythonDiagnostic = {
scope: "system_interpreter",
hopper_interpreter: "unknown",
...(python.ok
? { status: "observed", ...python.value }
: {
status: "unknown",
reason: python.error.reason,
message: python.error.message,
}),
};
const value = selection.diagnostic;
const detail = [
`strategy=${selection.strategy}`,
`socket_directory=${value.socket_directory}`,
`mode=${value.socket_directory_mode ?? "unknown"}`,
`mount_read_only=${String(value.mount_read_only)}`,
`effective_mode=${value.effective_socket_directory_mode ?? "unknown"}`,
`effective_mount_read_only=${String(value.effective_mount_read_only)}`,
`wsl=${String(value.wsl)}`,
`system_python=${python.ok ? python.value.library.status : "unknown"}`,
].join("; ");
const details = {
private_display: { ...value },
embedded_python: pythonDiagnostic,
};
if (!python.ok && python.error.reason === "cancelled")
throw new AnalysisCancelledError("doctor", {
cause: python.error.cause,
diagnostics: { hopper_demo_runtime: details },
});
if (selection.ok && pythonFailure === undefined)
return {
name: "hopper-demo-runtime",
ok: true,
classification: "healthy",
detail,
details,
};
const missingDependency =
value.failure_code === "runtime_dependency_unavailable" ||
value.failure_code === "x11_authorization_failed" ||
(python.ok && python.value.library.status === "unavailable");
const pythonRemediation =
pythonFailure === undefined
? undefined
: pythonFailure.reason === "runtime_unavailable"
? pythonFailure.message +
" On a supported system run rea setup --install-hopper, then rerun rea doctor --provider hopper. This checks the system interpreter; a custom Hopper Python interpreter needs its own matching shared runtime."
: pythonFailure.reason === "configuration_unknown"
? pythonFailure.message +
" Inspect this interpreter's shared-runtime configuration, then rerun rea doctor --provider hopper."
: pythonFailure.message +
" Retry rea doctor --provider hopper and inspect /usr/bin/python3 if its runtime remains unknown.";
const displayRemediation = selection.ok
? undefined
: value.failure_code === "x11_socket_directory_unusable"
? "Enable unprivileged user and mount namespaces, or provide a writable mode-1777 /tmp/.X11-unix directory. Do not mutate the current host mount with sudo; then rerun rea doctor --provider hopper."
: value.failure_code === "runtime_dependency_unavailable" ||
value.failure_code === "x11_authorization_failed"
? "Install Xvfb, xauth, Python 3, libX11, and libXtst, then rerun rea doctor --provider hopper."
: "Inspect the private-display diagnostic, correct the reported local condition, and rerun rea doctor --provider hopper.";
return {
name: "hopper-demo-runtime",
ok: false,
classification: missingDependency
? "missing_dependency"
: selection.ok
? "unknown"
: "config_drift",
detail,
details,
remediation: [pythonRemediation, displayRemediation]
.filter((message) => message !== undefined)
.join(" "),
};
};