diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index a62164838..23d9b9c6e 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -6,11 +6,9 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { parseBinaryTarget } from "../domain/binaryTarget.js"; -import { supportsNodeVersion } from "../domain/runtimeVersion.js"; import { probeHomebrew } from "./homebrew.js"; import { linuxHopperBinarySupported, - linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, type LinuxDistribution, @@ -25,12 +23,12 @@ import { inspectRuntimeExecutables, type RuntimeExecutableInventory, } from "./RuntimeExecutableDiagnostics.js"; +import { + collectDoctorDiagnostics, + doctorHealthy, +} from "./DoctorDiagnostics.js"; const execFileAsync = promisify(execFile); -const DEFAULT_HOPPER = - "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; -const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper"; - /** One actionable environment diagnostic. */ export interface DoctorCheck { readonly name: string; @@ -97,6 +95,40 @@ interface InstalledSkillIdentity { readonly catalogDigest: string | null; } +/** Structured result returned by the read-only doctor workflow. */ +export interface DoctorReport { + readonly healthy: boolean; + readonly hopperPath?: string; + readonly providerInspections?: readonly DoctorProviderInspection[]; + readonly checks: readonly DoctorCheck[]; + readonly identity?: DoctorIdentity; +} + +/** Installed-package, skill, client, and runtime identity observations. */ +export interface DoctorIdentity { + readonly cli_package_version: string; + readonly expected_skill_version: string; + readonly sdk: typeof SDK_IDENTITY; + readonly catalog: typeof CATALOG_IDENTITY; + readonly live_server: { + readonly state: "unknown"; + readonly remediation: string; + }; + readonly installations: { + readonly paths: readonly string[]; + readonly state: "single" | "multiple" | "unknown"; + }; + readonly skill: { + readonly installed_version: string | null; + readonly installed_tool_count: number | null; + readonly installed_catalog_digest: string | null; + readonly state: "aligned" | "stale" | "missing"; + readonly remediation: string | null; + }; + readonly registrations: readonly ClientRegistrationStatus[]; + readonly runtime_executables: RuntimeExecutableInventory | null; +} + /** * Check requirements and an optional target without mutating the host. * Every failed check includes remediation suitable for either the one-shot CLI @@ -105,221 +137,15 @@ interface InstalledSkillIdentity { export const runDoctor = async ( target?: string, host: DoctorHost = systemDoctorHost(), -): Promise<{ - readonly healthy: boolean; - readonly hopperPath?: string; - readonly providerInspections?: readonly DoctorProviderInspection[]; - readonly checks: readonly DoctorCheck[]; - readonly identity?: { - readonly cli_package_version: string; - readonly expected_skill_version: string; - readonly sdk: typeof SDK_IDENTITY; - readonly catalog: typeof CATALOG_IDENTITY; - readonly live_server: { - readonly state: "unknown"; - readonly remediation: string; - }; - readonly installations: { - readonly paths: readonly string[]; - readonly state: "single" | "multiple" | "unknown"; - }; - readonly skill: { - readonly installed_version: string | null; - readonly installed_tool_count: number | null; - readonly installed_catalog_digest: string | null; - readonly state: "aligned" | "stale" | "missing"; - readonly remediation: string | null; - }; - readonly registrations: readonly ClientRegistrationStatus[]; - readonly runtime_executables: RuntimeExecutableInventory | null; - }; -}> => { - const checks: DoctorCheck[] = []; - checks.push( - check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, { - remediation: "Install Node.js 22.19+ or 24.11+.", - classification: "missing_dependency", - }), - ); - const runtimeExecutables = await host.runtimeExecutables?.(); - if (runtimeExecutables !== undefined) { - const broken = runtimeExecutables.candidates.filter( - ({ healthy }) => !healthy, - ); - checks.push( - check( - "node-toolchains", - broken.length === 0, - broken.length === 0 - ? `${String(runtimeExecutables.candidates.length)} runtime executable candidates passed bounded version probes.` - : `${String(broken.length)} of ${String(runtimeExecutables.candidates.length)} runtime executable candidates failed bounded version probes: ${broken - .slice(0, 5) - .map(({ lexical_path: path }) => path) - .join(", ")}${broken.length > 5 ? ", …" : ""}`, - { - remediation: - "Select a verified healthy Node toolchain or repair the failing installation, then rerun rea doctor. Do not create compatibility-library symlinks.", - classification: "missing_dependency", - }, - ), - ); - } - const macosVersion = - host.platform === "darwin" ? await host.macosVersion() : undefined; - const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion); - const linuxDistribution = - host.platform === "linux" ? await host.linuxDistribution() : undefined; - const supportedHost = - (host.platform === "darwin" && macosMajor >= 12) || - (host.platform === "linux" && linuxDistribution?.supported === true) || - (host.platform === "win32" && host.architecture === "x64"); - checks.push( - check( - "host", - supportedHost, - macosVersion ?? - (linuxDistribution === undefined - ? `${host.platform} ${host.architecture}` - : `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`), - { - remediation: - "REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.", - classification: "unsupported_host", - }, - ), - ); - const candidates = - host.configuredHopperPath === undefined - ? [ - DEFAULT_HOPPER, - SYSTEM_LINUX_HOPPER, - linuxHopperLauncherPath(homedir()), - ...(await host.manualHopperPaths()), - ] - : [host.configuredHopperPath]; - let hopperPath = await firstExecutable(host, candidates); - if (hopperPath === undefined && host.configuredHopperPath === undefined) { - const brewCandidate = await host.brewHopperPath(); - hopperPath = await firstExecutable( - host, - brewCandidate === undefined ? [] : [brewCandidate], - ); - } - checks.push( - 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", - }, - ), - ); - if (host.platform === "linux" && hopperPath !== undefined) - checks.push( - check( - "hopper-version", - await host.supportedLinuxHopper(hopperPath), - hopperPath, - { - remediation: unsupportedHopperRemediation( - host.configuredHopperPath === hopperPath, - ), - classification: "config_drift", - }, - ), - ); - const providerInspections = await inspectDoctorProviders(host, checks); - const javascriptReplayCheck = await host.javascriptReplayCheck?.(); - if (javascriptReplayCheck !== undefined) checks.push(javascriptReplayCheck); - if (host.configuredIlspyCmdPath !== undefined) { - const version = await host.ilspyCmdVersion?.(host.configuredIlspyCmdPath); - checks.push( - 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", - }, - ), - ); - } - if (host.platform === "linux" && hopperPath !== undefined) - checks.push( - check( - "hopper-demo-runtime", - await host.linuxDemoRuntimeReady(), - undefined, - { - remediation: - "Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.", - classification: "missing_dependency", - }, - ), - ); - if (target !== undefined) - checks.push( - check("target", await host.validTarget(target), target, { - remediation: "Supply a readable local app or program path.", - classification: "config_drift", - }), - ); - const installationPaths = (await host.installationPaths?.()) ?? []; - const observedSkillIdentity = await host.installedSkillIdentity?.(); - const legacySkillVersion = - observedSkillIdentity === undefined - ? await host.installedSkillVersion?.() - : undefined; - const installedSkillIdentity = - observedSkillIdentity ?? - (legacySkillVersion === undefined - ? undefined - : { - version: legacySkillVersion, - toolCount: null, - catalogDigest: null, - }); - const installedSkillVersion = installedSkillIdentity?.version ?? undefined; - const skillAligned = - installedSkillIdentity?.version === PRODUCT_IDENTITY.skillVersion && - installedSkillIdentity.toolCount === CATALOG_IDENTITY.counts.mcp_tools && - installedSkillIdentity.catalogDigest === - CATALOG_IDENTITY.digests.combined_sha256; - if (!skillAligned) - checks.push({ - name: "skill:identity", - ok: false, - classification: "config_drift", - detail: - installedSkillIdentity === undefined - ? "Installed REA skill identity is missing." - : "Installed REA skill identity is stale.", - remediation: "Run rea setup to update the installed REA skill.", - }); - const registrations = (await host.clientRegistrations?.()) ?? []; - for (const registration of registrations) - if (registration.state !== "aligned") - checks.push({ - name: `registration:${registration.client}`, - ok: false, - classification: "config_drift", - detail: registration.config_path, - remediation: - registration.remediation ?? - "Run rea setup, then restart the affected client.", - }); +): Promise => { + const { + checks: diagnosticChecks, + hopperPath, + providerInspections, + runtimeExecutables, + } = await collectDoctorDiagnostics(target, host); + const identity = await collectDoctorIdentity(host, runtimeExecutables); + const checks = [...diagnosticChecks, ...identity.checks]; return { healthy: doctorHealthy(checks, { hopperAvailable: @@ -333,7 +159,29 @@ export const runDoctor = async ( ...(hopperPath === undefined ? {} : { hopperPath }), ...(providerInspections === undefined ? {} : { providerInspections }), checks, - identity: { + identity: identity.value, + }; +}; + +const collectDoctorIdentity = async ( + host: DoctorHost, + runtimeExecutables: RuntimeExecutableInventory | undefined, +): Promise<{ + readonly checks: readonly DoctorCheck[]; + readonly value: DoctorIdentity; +}> => { + const installationPaths = (await host.installationPaths?.()) ?? []; + const installedSkillIdentity = await readInstalledSkillIdentity(host); + const skillAligned = skillIdentityAligned(installedSkillIdentity); + const registrations = (await host.clientRegistrations?.()) ?? []; + return { + checks: [ + ...(skillAligned ? [] : [skillIdentityCheck(installedSkillIdentity)]), + ...registrations + .filter(({ state }) => state !== "aligned") + .map(registrationCheck), + ], + value: { cli_package_version: PRODUCT_IDENTITY.packageVersion, expected_skill_version: PRODUCT_IDENTITY.skillVersion, sdk: SDK_IDENTITY, @@ -345,15 +193,10 @@ export const runDoctor = async ( }, installations: { paths: installationPaths, - state: - installationPaths.length === 0 - ? "unknown" - : installationPaths.length === 1 - ? "single" - : "multiple", + state: installationState(installationPaths), }, skill: { - installed_version: installedSkillVersion ?? null, + installed_version: installedSkillIdentity?.version ?? null, installed_tool_count: installedSkillIdentity?.toolCount ?? null, installed_catalog_digest: installedSkillIdentity?.catalogDigest ?? null, state: @@ -372,10 +215,53 @@ export const runDoctor = async ( }; }; -const unsupportedHopperRemediation = (configured: boolean): string => - configured - ? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA." - : "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build."; +const readInstalledSkillIdentity = async ( + host: DoctorHost, +): Promise => { + const observed = await host.installedSkillIdentity?.(); + if (observed !== undefined) return observed; + const legacyVersion = await host.installedSkillVersion?.(); + return legacyVersion === undefined + ? undefined + : { version: legacyVersion, toolCount: null, catalogDigest: null }; +}; + +const skillIdentityAligned = ( + identity: InstalledSkillIdentity | undefined, +): boolean => + identity?.version === PRODUCT_IDENTITY.skillVersion && + identity.toolCount === CATALOG_IDENTITY.counts.mcp_tools && + identity.catalogDigest === CATALOG_IDENTITY.digests.combined_sha256; + +const skillIdentityCheck = ( + identity: InstalledSkillIdentity | undefined, +): DoctorCheck => ({ + name: "skill:identity", + ok: false, + classification: "config_drift", + detail: + identity === undefined + ? "Installed REA skill identity is missing." + : "Installed REA skill identity is stale.", + remediation: "Run rea setup to update the installed REA skill.", +}); + +const registrationCheck = ( + registration: ClientRegistrationStatus, +): DoctorCheck => ({ + name: `registration:${registration.client}`, + ok: false, + classification: "config_drift", + detail: registration.config_path, + remediation: + registration.remediation ?? + "Run rea setup, then restart the affected client.", +}); + +const installationState = ( + paths: readonly string[], +): DoctorIdentity["installations"]["state"] => + paths.length === 0 ? "unknown" : paths.length === 1 ? "single" : "multiple"; /** Optional outer-adapter diagnostics composed without reversing dependencies. */ export interface SystemDoctorHostOptions { @@ -552,8 +438,6 @@ export const systemDoctorHost = ( clientRegistrations: () => readClientRegistrationStatuses(homedir()), }); -const parseMajor = (version: string): number => - Number.parseInt(version.split(".")[0] ?? "0", 10); const uniqueLines = (value: string): string[] => [ ...new Set( value @@ -563,99 +447,5 @@ const uniqueLines = (value: string): string[] => [ ), ]; -const firstExecutable = async ( - host: DoctorHost, - candidates: readonly string[], -): Promise => { - for (const candidate of new Set(candidates)) - if (await host.executable(candidate)) return candidate; - return undefined; -}; - const firstIlspyVersionLine = (value: string): string | undefined => uniqueLines(value).find((line) => line.startsWith("ilspycmd:")); - -const check = ( - name: string, - ok: boolean, - detail: string | undefined, - failure: { - readonly remediation: string; - readonly classification: Exclude; - }, -): 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 }), - })); - -const inspectDoctorProviders = async ( - host: DoctorHost, - checks: DoctorCheck[], -): Promise => { - const inspections = await host.providerInspections?.(); - if (inspections === undefined) return undefined; - for (const inspection of inspections) - checks.push(...providerDoctorChecks(inspection)); - return inspections; -}; - -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 }) => { - 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)) - ); -}; - -const providerCheckName = (providerId: string, name: string): boolean => - name === providerId || name.startsWith(`${providerId}-`); diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts new file mode 100644 index 000000000..2d286b281 --- /dev/null +++ b/src/application/DoctorDiagnostics.ts @@ -0,0 +1,305 @@ +import { homedir } from "node:os"; + +import { supportsNodeVersion } from "../domain/runtimeVersion.js"; +import { linuxHopperLauncherPath } from "./LinuxHopper.js"; +import type { + DoctorCheck, + DoctorHost, + DoctorProviderInspection, +} from "./Doctor.js"; +import type { RuntimeExecutableInventory } from "./RuntimeExecutableDiagnostics.js"; + +const DEFAULT_HOPPER = + "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; +const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper"; + +/** Ordered environment diagnostics consumed by the Doctor report assembler. */ +export interface DoctorDiagnostics { + readonly checks: readonly DoctorCheck[]; + readonly hopperPath?: string; + readonly providerInspections?: readonly DoctorProviderInspection[]; + readonly runtimeExecutables?: RuntimeExecutableInventory; +} + +/** Collect host, provider, and optional-target diagnostics without mutation. */ +export const collectDoctorDiagnostics = async ( + target: string | undefined, + host: DoctorHost, +): Promise => { + const runtimeExecutables = await host.runtimeExecutables?.(); + const checks: DoctorCheck[] = [ + nodeCheck(host), + ...runtimeChecks(runtimeExecutables), + ]; + checks.push(await hostCheck(host)); + + const hopperPath = await findHopper(host); + checks.push(hopperCheck(host, hopperPath)); + 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 javascriptReplayCheck = await host.javascriptReplayCheck?.(); + if (javascriptReplayCheck !== undefined) checks.push(javascriptReplayCheck); + 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 }), + ...(runtimeExecutables === undefined ? {} : { runtimeExecutables }), + }; +}; + +/** 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 }) => { + 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)) + ); +}; + +const nodeCheck = (host: DoctorHost): DoctorCheck => + check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, { + remediation: "Install Node.js 22.19+ or 24.11+.", + classification: "missing_dependency", + }); + +const runtimeChecks = ( + inventory: RuntimeExecutableInventory | undefined, +): readonly DoctorCheck[] => { + if (inventory === undefined) return []; + const broken = inventory.candidates.filter(({ healthy }) => !healthy); + const detail = + broken.length === 0 + ? `${String(inventory.candidates.length)} runtime executable candidates passed bounded version probes.` + : `${String(broken.length)} of ${String(inventory.candidates.length)} runtime executable candidates failed bounded version probes: ${broken + .slice(0, 5) + .map(({ lexical_path: path }) => path) + .join(", ")}${broken.length > 5 ? ", …" : ""}`; + return [ + check("node-toolchains", broken.length === 0, detail, { + remediation: + "Select a verified healthy Node toolchain or repair the failing installation, then rerun rea doctor. Do not create compatibility-library symlinks.", + classification: "missing_dependency", + }), + ]; +}; + +const hostCheck = async (host: DoctorHost): Promise => { + 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" && linuxDistribution?.supported === true) || + (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+, Ubuntu 24.04+, Fedora 41+, 64-bit Arch Linux, and the experimental Windows x64 Ghidra P0 boundary.", + classification: "unsupported_host", + }); +}; + +const findHopper = async (host: DoctorHost): Promise => { + const candidates = + host.configuredHopperPath === undefined + ? [ + DEFAULT_HOPPER, + SYSTEM_LINUX_HOPPER, + linuxHopperLauncherPath(homedir()), + ...(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 => { + 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 optionalHopperVersionCheck = async ( + host: DoctorHost, + hopperPath: string | undefined, +): Promise => { + if (host.platform !== "linux" || hopperPath === undefined) return undefined; + return check( + "hopper-version", + await host.supportedLinuxHopper(hopperPath), + hopperPath, + { + remediation: unsupportedHopperRemediation( + host.configuredHopperPath === hopperPath, + ), + classification: "config_drift", + }, + ); +}; + +const optionalHopperDemoCheck = async ( + host: DoctorHost, + hopperPath: string | undefined, +): Promise => + host.platform !== "linux" || hopperPath === undefined + ? undefined + : check( + "hopper-demo-runtime", + await host.linuxDemoRuntimeReady(), + undefined, + { + remediation: + "Rerun rea setup to install the Xvfb, xauth, Python, X11, and XTEST packages required for Linux demo sessions.", + classification: "missing_dependency", + }, + ); + +const optionalIlspyCheck = async ( + host: DoctorHost, +): Promise => { + 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 => + target === undefined + ? undefined + : 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 => ({ + 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 }), + })); + +const unsupportedHopperRemediation = (configured: boolean): string => + configured + ? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA." + : "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build."; + +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}-`); diff --git a/src/application/ProcessCli.ts b/src/application/ProcessCli.ts index 3de0d199a..f41ded670 100644 --- a/src/application/ProcessCli.ts +++ b/src/application/ProcessCli.ts @@ -16,6 +16,7 @@ import { parseProcessCapture, } from "../domain/processCapture.js"; import { captureProcessScenario } from "./ProcessHarness.js"; +import { processCapturePermissionRequest } from "./ProcessCapturePermission.js"; import { PROCESS_PROVIDER, createProcessCaptureEvidence, @@ -49,18 +50,7 @@ export const captureProcessScenarioFile = async (path: string) => { const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return cliAnalysisError(authority.error); const authorized = await authority.value.authorize( - { - capability: "process_capture", - roots: [scenario.working_directory, ...scenario.filesystem_roots], - executables: [scenario.executable], - environment_names: [ - ...Object.keys(scenario.environment), - ...scenario.inherit_environment, - ], - network: scenario.network_access === "host" ? "external" : "none", - mount: false, - operation_identity: `capture_process_scenario:${scenario.executable}`, - }, + processCapturePermissionRequest(scenario), "read", ); if (!authorized.ok) diff --git a/src/domain/errors.ts b/src/domain/errors.ts index e05d0bf27..e064f7f00 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -596,118 +596,156 @@ export const projectAnalysisError = ( const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => { if (error instanceof ReplayPlanStaleError) return "plan_stale"; if (error instanceof PermissionRequiredError) return "permission_required"; - if ( - error instanceof ProviderSelectionError && - error.reason === "provider_unavailable" - ) - return "provider_unavailable"; - if (error instanceof BrowserObservationError) { - if (error.reason === "payload_limit") return "truncated"; - if ( - error.reason === "target_not_found" || - error.reason === "target_not_allowed" || - error.reason === "target_changed" - ) - return "target_unavailable"; - if ( - error.reason === "endpoint_unreachable" || - error.reason === "disconnected" - ) - return "provider_unavailable"; - return "unreadable_output"; - } + if (error instanceof ProviderSelectionError) + return error.reason === "provider_unavailable" + ? "provider_unavailable" + : "capability_unavailable"; + if (error instanceof BrowserObservationError) + return browserErrorCode(error.reason); if (error instanceof ArtifactOperationError) - return error.reason === "integrity" && error.artifactDetails !== undefined - ? "artifact_integrity_mismatch" - : error.reason === "limit" - ? "truncated" - : error.reason === "cancelled" - ? "cancelled" - : "artifact_operation_failed"; - if (error instanceof EvidenceFileError) - return error.reason === "outside-root" - ? "outside_approved_root" - : error.reason === "too-large" - ? "truncated" - : error.reason === "disabled" - ? "capability_unavailable" - : "execution_failure"; - if (error instanceof InvestigationWorkspaceError) { - if ( - error.reason === "revision-conflict" || - error.reason === "name-conflict" - ) - return "revision_conflict"; - if (error.reason === "too-large") return "truncated"; - if (error.reason === "disabled") return "capability_unavailable"; - if (error.reason === "outside-root") return "outside_approved_root"; - if (error.reason === "integrity" || error.reason === "invalid-json") - return "evidence_integrity_mismatch"; - return "execution_failure"; - } + return artifactOperationCode(error); + if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason); + if (error instanceof InvestigationWorkspaceError) + return investigationWorkspaceCode(error.reason); if (error instanceof UnknownRegistryError) - return error.reason === "revision-conflict" || - error.reason === "already-exists" - ? "revision_conflict" - : error.reason === "limit" - ? "truncated" - : error.reason === "integrity" - ? "evidence_integrity_mismatch" - : "execution_failure"; - if (error._tag === "ProcessCaptureError") - return error.cleanupIncomplete - ? "cleanup_incomplete" - : error.userCategory === "cancelled" - ? "cancelled" - : error.userCategory === "permission_required" - ? "permission_required" - : "process_capture_failed"; - switch (error._tag) { - case "AnalysisProtocolError": - case "AnalysisOutputError": - case "HopperProtocolError": - return "unreadable_output"; - case "AnalysisInputError": - return "invalid_request"; - case "AnalysisCapabilityUnavailableError": - case "ProviderSelectionError": - return "capability_unavailable"; - case "AnalysisCancelledError": - case "HopperCancelledError": - return "cancelled"; - case "AnalysisTimeoutError": - case "HopperTimeoutError": - return "provider_timeout"; - case "HopperProcessError": - case "HopperStartError": - return "provider_unavailable"; - case "ConfigurationError": - return "configuration_invalid"; - case "NoBinaryOpenError": - case "BinaryTargetError": - return "target_unavailable"; - case "EvidenceIntegrityError": - return "evidence_integrity_mismatch"; - case "EvidenceLimitError": - return "truncated"; - case "ProviderAdapterError": - case "HopperRemoteError": - return "execution_failure"; - case "BrowserObservationError": - throw new TypeError("Unhandled specialized browser error"); + return unknownRegistryCode(error.reason); + if (error._tag === "ProcessCaptureError") return processCaptureCode(error); + return staticErrorCode(error._tag); +}; + +const browserErrorCode = ( + reason: BrowserObservationError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "payload_limit") return "truncated"; + if ( + reason === "target_not_found" || + reason === "target_not_allowed" || + reason === "target_changed" + ) + return "target_unavailable"; + if (reason === "endpoint_unreachable" || reason === "disconnected") + return "provider_unavailable"; + return "unreadable_output"; +}; + +const artifactOperationCode = ( + error: ArtifactOperationError, +): AnalysisErrorProjection["code"] => { + if (error.reason === "integrity" && error.artifactDetails !== undefined) + return "artifact_integrity_mismatch"; + if (error.reason === "limit") return "truncated"; + if (error.reason === "cancelled") return "cancelled"; + return "artifact_operation_failed"; +}; + +const evidenceFileCode = ( + reason: EvidenceFileError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "outside-root") return "outside_approved_root"; + if (reason === "too-large") return "truncated"; + if (reason === "disabled") return "capability_unavailable"; + return "execution_failure"; +}; + +const investigationWorkspaceCode = ( + reason: InvestigationWorkspaceError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "revision-conflict" || reason === "name-conflict") + return "revision_conflict"; + if (reason === "too-large") return "truncated"; + if (reason === "disabled") return "capability_unavailable"; + if (reason === "outside-root") return "outside_approved_root"; + if (reason === "integrity" || reason === "invalid-json") + return "evidence_integrity_mismatch"; + return "execution_failure"; +}; + +const unknownRegistryCode = ( + reason: UnknownRegistryError["reason"], +): AnalysisErrorProjection["code"] => { + if (reason === "revision-conflict" || reason === "already-exists") + return "revision_conflict"; + if (reason === "limit") return "truncated"; + if (reason === "integrity") return "evidence_integrity_mismatch"; + return "execution_failure"; +}; + +const processCaptureCode = ( + error: AnalysisError, +): AnalysisErrorProjection["code"] => { + if (error.cleanupIncomplete) return "cleanup_incomplete"; + if (error.userCategory === "cancelled") return "cancelled"; + if (error.userCategory === "permission_required") + return "permission_required"; + return "process_capture_failed"; +}; + +type SpecializedErrorTag = + | "ArtifactOperationError" + | "BrowserObservationError" + | "EvidenceFileError" + | "InvestigationWorkspaceError" + | "PermissionRequiredError" + | "ProcessCaptureError" + | "ProviderSelectionError" + | "ReplayPlanStaleError" + | "UnknownRegistryError"; + +const STATIC_ERROR_CODES = { + AnalysisProtocolError: "unreadable_output", + AnalysisOutputError: "unreadable_output", + HopperProtocolError: "unreadable_output", + AnalysisInputError: "invalid_request", + AnalysisCapabilityUnavailableError: "capability_unavailable", + AnalysisCancelledError: "cancelled", + HopperCancelledError: "cancelled", + AnalysisTimeoutError: "provider_timeout", + HopperTimeoutError: "provider_timeout", + HopperProcessError: "provider_unavailable", + HopperStartError: "provider_unavailable", + ConfigurationError: "configuration_invalid", + NoBinaryOpenError: "target_unavailable", + BinaryTargetError: "target_unavailable", + EvidenceIntegrityError: "evidence_integrity_mismatch", + EvidenceLimitError: "truncated", + ProviderAdapterError: "execution_failure", + HopperRemoteError: "execution_failure", +} as const satisfies Readonly< + Record< + Exclude, + AnalysisErrorProjection["code"] + > +>; + +const staticErrorCode = ( + tag: AnalysisErrorTag, +): AnalysisErrorProjection["code"] => { + switch (tag) { case "ArtifactOperationError": + case "BrowserObservationError": case "EvidenceFileError": case "InvestigationWorkspaceError": - case "UnknownRegistryError": case "PermissionRequiredError": - throw new TypeError("Unhandled specialized analysis error"); + case "ProcessCaptureError": + case "ProviderSelectionError": case "ReplayPlanStaleError": - throw new TypeError("Unhandled specialized replay error"); + case "UnknownRegistryError": + throw new TypeError(`Unhandled specialized analysis error: ${tag}`); + default: + return STATIC_ERROR_CODES[tag]; } }; const errorDetails = ( error: AnalysisError, +): Readonly> | undefined => + requestErrorDetails(error) ?? + artifactStateErrorDetails(error) ?? + providerErrorDetails(error) ?? + lifecycleErrorDetails(error); + +const requestErrorDetails = ( + error: AnalysisError, ): Readonly> | undefined => { if (error instanceof AnalysisInputError && error.issues.length > 0) return { @@ -740,6 +778,12 @@ const errorDetails = ( missing: missingScopeDetails(error.missing), ceiling: error.ceiling === null ? null : scopeDetails(error.ceiling), }; + return undefined; +}; + +const artifactStateErrorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { if (error instanceof ArtifactOperationError && error.artifactDetails) return { logical_path: error.artifactDetails.logicalPath, @@ -753,14 +797,6 @@ const errorDetails = ( reason: error.reason, ...(error.reason === "limit" ? { truncated: true } : {}), }; - if (error instanceof AnalysisCancelledError) - return { operation: error.operation, cleanup: "complete" }; - if (error instanceof HopperCancelledError) - return { operation: "hopper", cleanup: "complete" }; - if (error instanceof AnalysisTimeoutError) - return { operation: error.operation, timeout_ms: error.timeoutMs }; - if (error instanceof HopperTimeoutError) - return { operation: "hopper", timeout_ms: error.timeoutMs }; if (error instanceof EvidenceLimitError) return { limit: error.limit, maximum: error.maximum, truncated: true }; if (error instanceof InvestigationWorkspaceError) @@ -768,6 +804,12 @@ const errorDetails = ( if (error instanceof UnknownRegistryError) return { reason: error.reason }; if (error instanceof EvidenceFileError) return { operation: error.operation, reason: error.reason }; + return undefined; +}; + +const providerErrorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { if (error instanceof AnalysisCapabilityUnavailableError) return { provider_id: error.providerId, operation: error.operation }; if (error instanceof ProviderSelectionError) @@ -797,6 +839,20 @@ const errorDetails = ( return { provider_code: error.code, diagnostic_type: error.diagnosticType }; if (error instanceof HopperProcessError && error.failureCode !== undefined) return { failure_code: error.failureCode, exit_code: error.exitCode }; + return undefined; +}; + +const lifecycleErrorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { + if (error instanceof AnalysisCancelledError) + return { operation: error.operation, cleanup: "complete" }; + if (error instanceof HopperCancelledError) + return { operation: "hopper", cleanup: "complete" }; + if (error instanceof AnalysisTimeoutError) + return { operation: error.operation, timeout_ms: error.timeoutMs }; + if (error instanceof HopperTimeoutError) + return { operation: "hopper", timeout_ms: error.timeoutMs }; if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete) return { cleanup: "incomplete", @@ -870,17 +926,8 @@ const errorCategory = ( return "unavailable"; if (error._tag === "ProcessCaptureError") return error.userCategory ?? "execution_failure"; - if (error instanceof BrowserObservationError) { - if (error.reason === "payload_limit") return "truncated"; - if ( - error.reason === "target_not_found" || - error.reason === "target_not_allowed" || - error.reason === "target_changed" || - error.reason === "endpoint_unreachable" || - error.reason === "disconnected" - ) - return "unavailable"; - } + if (error instanceof BrowserObservationError) + return browserErrorCategory(error.reason); if ( error instanceof HopperRemoteError && error.diagnosticType === "authorization" @@ -895,6 +942,21 @@ const errorCategory = ( return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure"; }; +const browserErrorCategory = ( + reason: BrowserObservationError["reason"], +): AnalysisErrorProjection["category"] => { + if (reason === "payload_limit") return "truncated"; + if ( + reason === "target_not_found" || + reason === "target_not_allowed" || + reason === "target_changed" || + reason === "endpoint_unreachable" || + reason === "disconnected" + ) + return "unavailable"; + return "execution_failure"; +}; + const investigationWorkspaceCategory = ( reason: InvestigationWorkspaceError["reason"], ): AnalysisErrorProjection["category"] => { @@ -944,18 +1006,8 @@ const userMessage = (error: AnalysisError): string => { if (error instanceof AnalysisInputError) return "Analysis input is invalid. Check the arguments and try again."; if (error.userMessage !== undefined) return error.userMessage; - if (UNREADABLE_OUTPUT_TAGS.has(error._tag)) - return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`."; - if (UNSUPPORTED_PROVIDER_TAGS.has(error._tag)) - return "This analysis is unavailable for the current target. Choose another analysis or target."; - if (CANCELLED_TAGS.has(error._tag)) - return "Analysis was cancelled. Start it again when ready."; - if (TIMEOUT_TAGS.has(error._tag)) - return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues."; - if (ADAPTER_FAILURE_TAGS.has(error._tag)) - return "Analysis could not complete. Retry once; if it continues, run `rea doctor`."; - if (START_FAILURE_TAGS.has(error._tag)) - return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again."; + const standardMessage = standardErrorMessage(error._tag); + if (standardMessage !== undefined) return standardMessage; if (error instanceof ArtifactOperationError) return artifactMessage(error.reason); if (error instanceof EvidenceIntegrityError) @@ -981,6 +1033,22 @@ const userMessage = (error: AnalysisError): string => { return "Analysis could not complete. Run `rea doctor`, then try again."; }; +const standardErrorMessage = (tag: AnalysisErrorTag): string | undefined => { + if (UNREADABLE_OUTPUT_TAGS.has(tag)) + return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`."; + if (UNSUPPORTED_PROVIDER_TAGS.has(tag)) + return "This analysis is unavailable for the current target. Choose another analysis or target."; + if (CANCELLED_TAGS.has(tag)) + return "Analysis was cancelled. Start it again when ready."; + if (TIMEOUT_TAGS.has(tag)) + return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues."; + if (ADAPTER_FAILURE_TAGS.has(tag)) + return "Analysis could not complete. Retry once; if it continues, run `rea doctor`."; + if (START_FAILURE_TAGS.has(tag)) + return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again."; + return undefined; +}; + const UNREADABLE_OUTPUT_TAGS: ReadonlySet = new Set([ "AnalysisProtocolError", "AnalysisOutputError", diff --git a/src/native/CommandRunner.ts b/src/native/CommandRunner.ts index 65d27a913..b0ffbd793 100644 --- a/src/native/CommandRunner.ts +++ b/src/native/CommandRunner.ts @@ -41,16 +41,19 @@ export class NativeCommandFailure extends Error { } } +/** Bounds and cancellation applied to one allowlisted native command. */ +export interface NativeCommandOptions { + readonly signal?: AbortSignal; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly acceptNonZero?: boolean; +} + export interface NativeCommandRunner { run( tool: string, arguments_: readonly string[], - options: { - readonly signal?: AbortSignal; - readonly timeoutMs: number; - readonly maxOutputBytes: number; - readonly acceptNonZero?: boolean; - }, + options: NativeCommandOptions, ): Promise>; } @@ -74,12 +77,7 @@ export class XcrunCommandRunner implements NativeCommandRunner { run( tool: string, arguments_: readonly string[], - options: { - readonly signal?: AbortSignal; - readonly timeoutMs: number; - readonly maxOutputBytes: number; - readonly acceptNonZero?: boolean; - }, + options: NativeCommandOptions, ): Promise> { if (!ALLOWED_TOOLS.has(tool)) return Promise.resolve(