Files
reverse-engineering-anything/src/application/DoctorDiagnostics.ts
T
b718ad166d fix(doctor): reject unusable Kuna caches and invalid native settings (#5647)
* fix(kuna): reject dangling cache roots in doctor

Treat a cache path as creatable only when no directory entry occupies it.
A dangling symbolic link, including one hidden by a trailing separator,
fails the cache check and clears provider availability while the installation
check and caller-selected registration settings remain.

Fixes #5554
Fixes #5105

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): admit the same settings native analysis rejects

Project parseConfig failures into scoped engine readiness. Invalid provider
preferences, startup timeouts, and other shared settings now fail the
selected provider and the full audit, while a JEB-only timeout stays outside
that admission.

Fixes #5454

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): report an invalid log level from doctor

The dispatcher rejected REA_LOG_LEVEL before every command, so doctor never
emitted its readiness check. Doctor now runs and names that setting; other
commands still stop before execution.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): require log-level failures in every scope

An invalid REA_LOG_LEVEL stops the executable and MCP server before a client
or skill can run, so those scopes now fail readiness too. Other native
admission failures stay limited to a selected provider.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(kuna): strip only the platform cache separator

On Darwin and Linux a trailing backslash is a filename character. Doctor now
removes only the selected platform separator before inspecting the cache path.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): fail every provider on an invalid Hopper timeout

REA_HOPPER_STARTUP_TIMEOUT_MS is rejected before any native engine starts.
A selected non-Hopper provider now includes the existing timeout check.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* style(doctor): format the Hopper timeout regression

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(doctor): fail client scopes on MCP startup errors

A registered client starts the MCP server, and parseConfig rejects every
native admission failure before that server starts. Client readiness now
includes those failures, not only REA_LOG_LEVEL.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 03:44:49 +08:00

390 lines
13 KiB
TypeScript

import {
SUPPORTED_NODE_VERSION_PROSE,
supportsNodeVersion,
} from "../domain/runtimeVersion.js";
import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js";
import {
linuxHopperLauncherCandidates,
systemLinuxHopperLauncherCandidates,
supportsReaHost,
} from "./LinuxHopper.js";
import {
MAC_HOPPER_SYSTEM_LAUNCHER,
macHopperCanonicalLauncherCandidates,
} from "./MacHopperPaths.js";
import type {
DoctorCheck,
DoctorHost,
DoctorProviderInspection,
} from "./Doctor.js";
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
export interface DoctorDiagnostics {
readonly checks: readonly DoctorCheck[];
readonly hopperPath?: string;
readonly providerInspections?: readonly DoctorProviderInspection[];
}
/** Collect host, provider, and optional-target diagnostics without mutation. */
export const collectDoctorDiagnostics = async (
target: string | undefined,
host: DoctorHost,
): Promise<DoctorDiagnostics> => {
const checks: DoctorCheck[] = [nodeCheck(host)];
checks.push(await hostCheck(host));
const hopperPath = await findHopper(host);
checks.push(hopperCheck(host, hopperPath));
const hopperTimeoutCheck = optionalHopperStartupTimeoutCheck(host);
if (hopperTimeoutCheck !== undefined) checks.push(hopperTimeoutCheck);
checks.push(...runtimeConfigurationChecks(host));
const hopperVersionCheck = await optionalHopperVersionCheck(host, hopperPath);
if (hopperVersionCheck !== undefined) checks.push(hopperVersionCheck);
const providerInspections = await host.providerInspections?.();
if (providerInspections !== undefined)
checks.push(...providerInspections.flatMap(providerDoctorChecks));
const ilspyCheck = await optionalIlspyCheck(host);
if (ilspyCheck !== undefined) checks.push(ilspyCheck);
const hopperDemoCheck = await optionalHopperDemoCheck(host, hopperPath);
if (hopperDemoCheck !== undefined) checks.push(hopperDemoCheck);
const targetCheck = await optionalTargetCheck(target, host);
if (targetCheck !== undefined) checks.push(targetCheck);
return {
checks,
...(hopperPath === undefined ? {} : { hopperPath }),
...(providerInspections === undefined ? {} : { providerInspections }),
};
};
/** Evaluate core health while treating unconfigured providers as optional. */
export const doctorHealthy = (
checks: readonly DoctorCheck[],
providers: {
readonly hopperAvailable: boolean;
readonly hopperConfigured: boolean;
readonly providerInspections:
| readonly DoctorProviderInspection[]
| undefined;
},
): boolean => {
if (providers.providerInspections === undefined)
return checks.every(({ ok }) => ok);
const providerChecks = checks.filter(
({ name }) =>
name.startsWith("hopper") ||
providers.providerInspections?.some((inspection) =>
providerCheckName(inspection.id, name),
) === true,
);
const coreHealthy = checks
.filter((candidate) => !providerChecks.includes(candidate))
.every(({ ok }) => ok);
const configuredProvidersHealthy = providerChecks
.filter(({ name }) => {
// Validate the supplied setting independently of provider discovery.
if (name === "hopper-startup-timeout") return true;
if (name.startsWith("hopper")) return providers.hopperConfigured;
return providers.providerInspections?.some(
(inspection) =>
inspection.configured && providerCheckName(inspection.id, name),
);
})
.every(({ ok }) => ok);
return (
coreHealthy &&
configuredProvidersHealthy &&
(providers.hopperAvailable ||
providers.providerInspections.some(({ available }) => available))
);
};
/** Bun passes when its reported Node.js compatibility version is supported. */
const nodeCheck = ({ runtime }: DoctorHost): DoctorCheck => {
const failure = {
remediation: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
classification: "missing_dependency",
} as const;
if (runtime.id === "node")
return {
...check(
"node",
supportsNodeVersion(runtime.version),
runtime.version,
failure,
),
details: { runtime: "node", version: runtime.version },
};
return {
...check(
"node",
supportsNodeVersion(runtime.nodeCompatibilityVersion),
`Bun ${runtime.version} (Node.js ${runtime.nodeCompatibilityVersion} compatibility)`,
failure,
),
details: {
runtime: "bun",
version: runtime.version,
node_compatibility_version: runtime.nodeCompatibilityVersion,
},
};
};
const hostCheck = async (host: DoctorHost): Promise<DoctorCheck> => {
const macosVersion =
host.platform === "darwin" ? await host.macosVersion() : undefined;
const linuxDistribution =
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const supported =
(host.platform === "darwin" && parseMajor(macosVersion) >= 12) ||
(host.platform === "linux" && supportsReaHost(linuxDistribution)) ||
(host.platform === "win32" && host.architecture === "x64");
const detail =
macosVersion ??
(linuxDistribution === undefined
? `${host.platform} ${host.architecture}`
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`);
return check("host", supported, detail, {
remediation:
"REA supports macOS 12+, Linux distributions with a recognized apt, dnf, or pacman package family, and the experimental Windows x64 Ghidra P0 boundary; automated Hopper setup additionally requires Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, or Omarchy.",
classification: "unsupported_host",
});
};
const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
const candidates =
host.configuredHopperPath === undefined
? [
...(host.platform === "darwin"
? macHopperCanonicalLauncherCandidates(host.homeDirectory)
: [MAC_HOPPER_SYSTEM_LAUNCHER]),
...(host.platform === "linux"
? linuxHopperLauncherCandidates(host.homeDirectory)
: [...systemLinuxHopperLauncherCandidates]),
...(host.platform === "darwin"
? []
: macHopperCanonicalLauncherCandidates(host.homeDirectory).slice(
1,
)),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];
const hopperPath = await firstExecutable(host, candidates);
if (hopperPath !== undefined || host.configuredHopperPath !== undefined)
return hopperPath;
const brewCandidate = await host.brewHopperPath();
return firstExecutable(
host,
brewCandidate === undefined ? [] : [brewCandidate],
);
};
const firstExecutable = async (
host: DoctorHost,
candidates: readonly string[],
): Promise<string | undefined> => {
for (const candidate of new Set(candidates))
if (await host.executable(candidate)) return candidate;
return undefined;
};
const hopperCheck = (
host: DoctorHost,
hopperPath: string | undefined,
): DoctorCheck =>
check(
"hopper",
hopperPath !== undefined,
hopperPath ?? host.configuredHopperPath,
{
remediation:
host.configuredHopperPath === undefined
? "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH."
: "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.",
classification:
host.configuredHopperPath === undefined
? "missing_analysis_engine"
: "config_drift",
},
);
const optionalHopperStartupTimeoutCheck = (
host: DoctorHost,
): DoctorCheck | undefined => {
if (host.configuredHopperStartupTimeout === undefined) return undefined;
const parsed = providerStartupTimeoutSchema.safeParse(
host.configuredHopperStartupTimeout,
);
return check(
"hopper-startup-timeout",
parsed.success,
parsed.success
? `REA_HOPPER_STARTUP_TIMEOUT_MS=${String(parsed.data)}`
: `REA_HOPPER_STARTUP_TIMEOUT_MS: ${parsed.error.issues.map(({ message }) => message).join("; ")}`,
{
classification: "config_drift",
remediation:
"Unset REA_HOPPER_STARTUP_TIMEOUT_MS to use the default, or set it to a positive decimal integer from 1 to 2147483647 milliseconds.",
},
);
};
/**
* Project every native-analysis admission failure except the Hopper timeout,
* which already has its own check. Provider scopes include engine settings.
* Settings that block process start are marked for every doctor scope.
*/
const runtimeConfigurationChecks = (
host: DoctorHost,
): readonly DoctorCheck[] => {
const issues = host.configurationIssues;
if (issues === undefined || issues.length === 0) return [];
const grouped: { setting: string; constraints: string[] }[] = [];
for (const issue of issues) {
if (issue.setting === "REA_HOPPER_STARTUP_TIMEOUT_MS") continue;
const existing = grouped.find((item) => item.setting === issue.setting);
if (existing === undefined)
grouped.push({ setting: issue.setting, constraints: [issue.constraint] });
else existing.constraints.push(issue.constraint);
}
return grouped.map(({ setting, constraints }) => ({
...check(
`runtime-configuration:${setting}`,
false,
constraints
.map((constraint) =>
constraint.includes(setting)
? constraint.replace(/\.$/u, "")
: `${setting}: ${constraint}`,
)
.join("; "),
{
classification: "config_drift",
remediation: `Correct ${setting} in the environment that starts REA (your shell or the MCP client's registration), then rerun.`,
},
),
details: { setting },
}));
};
const optionalHopperVersionCheck = async (
host: DoctorHost,
hopperPath: string | undefined,
): Promise<DoctorCheck | undefined> => {
if (host.platform !== "linux" || hopperPath === undefined) return undefined;
const inspected = await host.inspectLinuxHopper(hopperPath);
const buildCheck = check("hopper-version", inspected.ok, hopperPath, {
remediation: unsupportedHopperRemediation(
host.configuredHopperPath === hopperPath,
),
classification:
!inspected.ok &&
[
"release_metadata",
"download",
"verification_unavailable",
"cancelled",
"cleanup_failed",
].includes(inspected.error.reason)
? "unknown"
: "config_drift",
});
return {
...buildCheck,
details: inspected.ok
? {
reported_release_version: inspected.value.version,
launcher_sha256: inspected.value.sha256,
primary_fingerprint: inspected.value.primaryFingerprint,
source: inspected.value.source,
}
: { reason: inspected.error.reason, message: inspected.error.message },
};
};
const optionalHopperDemoCheck = async (
host: DoctorHost,
hopperPath: string | undefined,
): Promise<DoctorCheck | undefined> =>
host.platform !== "linux" || hopperPath === undefined
? undefined
: host.linuxDemoRuntimeCheck();
const optionalIlspyCheck = async (
host: DoctorHost,
): Promise<DoctorCheck | undefined> => {
if (host.configuredIlspyCmdPath === undefined) return undefined;
const version = await host.ilspyCmdVersion?.(host.configuredIlspyCmdPath);
return check(
"ilspycmd",
version !== undefined,
version === undefined
? host.configuredIlspyCmdPath
: `${host.configuredIlspyCmdPath} (${version})`,
{
remediation:
"Unset REA_ILSPY_CMD_PATH or point it at a runnable ilspycmd executable.",
classification: "config_drift",
},
);
};
const optionalTargetCheck = async (
target: string | undefined,
host: DoctorHost,
): Promise<DoctorCheck | undefined> =>
target === undefined
? undefined
: host.inspectTarget !== undefined
? host.inspectTarget(target)
: check("target", await host.validTarget(target), target, {
remediation: "Supply a readable local app or program path.",
classification: "config_drift",
});
const check = (
name: string,
ok: boolean,
detail: string | undefined,
failure: {
readonly remediation: string;
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
},
): DoctorCheck => ({
name,
ok,
classification: ok ? "healthy" : failure.classification,
...(detail === undefined ? {} : { detail }),
...(ok ? {} : { remediation: failure.remediation }),
});
const providerDoctorChecks = (
inspection: DoctorProviderInspection,
): readonly DoctorCheck[] =>
inspection.checks.map((candidate) => ({
name:
candidate.name === "configuration"
? inspection.id
: `${inspection.id}-${candidate.name}`,
ok: candidate.ok,
classification: candidate.ok ? "healthy" : candidate.classification,
detail: candidate.detail,
...(candidate.remediation === null
? {}
: { remediation: candidate.remediation }),
...(candidate.details === undefined ? {} : { details: candidate.details }),
}));
const unsupportedHopperRemediation = (configured: boolean): string =>
configured
? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use."
: "Run rea setup to install and retain an authenticated official Hopper package for offline use.";
const parseMajor = (version: string | undefined): number =>
Number.parseInt(version?.split(".")[0] ?? "0", 10);
const providerCheckName = (providerId: string, name: string): boolean =>
name === providerId || name.startsWith(`${providerId}-`);