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>; /** Names the client records as expressions this process cannot evaluate. */ readonly unresolved: ReadonlySet; /** The registration could not be read or its effective settings resolved. */ readonly unknown?: boolean; } const selectedRegistrationEnvironment = async ( clients: readonly string[], processEnvironment: Readonly, resourceScope: RetainedResourceScope, signal: AbortSignal, ): Promise => { 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, platform: NodeJS.Platform = process.platform, ): Readonly> => { if (selected === undefined) return terminal; const settings: Record = { ...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>; readonly unresolvedSettings: ReadonlySet; }; /** Read the selected client's registration, or report it as unreadable. */ const readSelectedRegistration = async ( client: Parameters[0], resourceScope: RetainedResourceScope, platform: NodeJS.Platform, ): Promise => { // 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> = { 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>, 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>, 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> = { 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 = process.env, signal?: AbortSignal, ): DoctorHost => { const registrationReads = new WeakMap< RetainedResourceScope, Map> >(); 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, resourceScope: RetainedResourceScope, signal?: AbortSignal, ): Promise => { 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(" "), }; };