mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
* 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>
567 lines
20 KiB
TypeScript
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(" "),
|
|
};
|
|
};
|