diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 6e2c635705..2bafebfe53 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -377,8 +377,9 @@ unless the group overrides them. The grouped name union is the complete tool inventory; use `tools/list` for canonical tool schemas. The default summary keeps each provider candidate's availability and target-support status, code and reason; `omitted_fields` lists what it leaves out. Request `{"detail": "complete"}` -only when you need provider diagnostics, candidate capability sets, or tool -surface, effect and annotation flags; in those closed flag vocabularies, listed +only when you need an unavailable candidate's remediation, provider +diagnostics, candidate capability sets, or tool surface, effect and annotation +flags; in those closed flag vocabularies, listed flags are true and omitted known flags are false. Provider candidate facts describe possible providers, while top-level session capabilities describe the current route and `analysis_provider_binding` names the selected provider. Do diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index f81378acb8..35576998f9 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -346,8 +346,9 @@ the complete tool inventory grouped by current status. Expand each group's overrides them; availability, reasons, remediation, and modes remain inline. Provider candidates keep availability and target-support status, code, and reason. `omitted_fields` names what the summary leaves out; `{"detail": -"complete"}` adds provider diagnostics, candidate capability sets, and tool -surface, `effects`, and `annotations`. Those arrays name true flags from fixed +"complete"}` adds each unavailable candidate's `remediation`, provider +diagnostics, candidate capability sets, and tool surface, `effects`, and +`annotations`. Those arrays name true flags from fixed vocabularies, so an omitted known flag is false. `tools/list` remains the canonical source for full tool schemas when target or provider state changes. diff --git a/docs/ida-provider.md b/docs/ida-provider.md index 685d638b9a..03362fec16 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -43,7 +43,8 @@ rea decompile /absolute/path/to/program main --json If the registration cannot be read or validated, provider diagnostics retain its selected `registration_path` and the configuration owner's setting/constraint -facts in `configuration_details.settings`, plus `configuration_remediation`. +facts in `configuration_details.settings`. The availability's `remediation`, and +each operation it makes unavailable, carry the configuration owner's repair. `configured` means a path was selected, not that it is valid or an IDA connection was established; failed registrations remain unavailable. Correct `REA_IDA_MCP_CONFIG` in the next CLI invocation's environment or the MCP server's diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 5ce902f6ab..2145b64b16 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -807,9 +807,10 @@ without these outcomes. `summary` keeps open state, target identity, the binding, current-route `capabilities`, run, activity and health status, and each provider candidate's identity, selection, and availability and target-support `status`, `code`, and -`reason`. It lists the fields it leaves out in `omitted_fields`: provider -`diagnostics`, candidate `capabilities`, and each tool group's static `surface`, -`effects`, and `annotations`. Omitted fields are not empty or false. +`reason`. It lists the fields it leaves out in `omitted_fields`: an unavailable +candidate's `remediation`, provider `diagnostics`, candidate `capabilities`, and +each tool group's static `surface`, `effects`, and `annotations`. Omitted fields +are not empty or false. `detail: "complete"` returns them inline, groups tools by those flags too, and has no `omitted_fields`. The output schema keys each result's candidate, tool-group and omission fields by its `detail`. CLI `capabilities` and @@ -862,7 +863,9 @@ set, `shared_limitations` is the ordered common prefix; append a group's ordered `effects` arrays use the same true-only closed-vocabulary rule as tool availability. Expand every group operation while retaining its provider identity, availability, reason, rejection code, effects, and ordered -limitations. +limitations. An unavailable group carries `remediation` when its readiness +owner supplied a repair instruction; `tool_availability` uses that instruction +rather than the failure description. `binary_session.analysis_provider_candidates` is authoritative for deep-engine discovery. Each candidate's capability sets, returned with complete detail, diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index 80c649b042..a72c91112b 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -87,7 +87,8 @@ example under Smart App Control or an App Control policy, reports failure stage invocation or invalid inspection uses `runtime_unavailable` and doctor classification `runtime_failure`; it does not mean Windows x64 itself is unsupported. Provider diagnostics retain the failure -stage under `windows_native_failure`, together with the corresponding remediation. +stage under `windows_native_failure`; the availability's `remediation` carries +the corresponding repair. Self-test failures preserve the native operation and OS error text. Check the process's writable local NTFS temporary directory and the reported filesystem, DACL, Job Object or cleanup constraint, then rerun doctor after resolving it. diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts index d0056dbc7b..a25bbe0010 100644 --- a/src/adb/AdbConfiguration.ts +++ b/src/adb/AdbConfiguration.ts @@ -23,6 +23,12 @@ import type { JsonValue } from "../domain/jsonValue.js"; export const ADB_BINARY_ENV = "REA_ADB_PATH"; const VERSION_TIMEOUT_MS = 10_000; + +const ADB_VERSION_REMEDIATION = `Set ${ADB_BINARY_ENV} to an Android SDK Platform-Tools adb binary whose \`adb version\` reports its version, or put one on PATH.`; +/** Repair when the selected adb binary does not exist. */ +export const ADB_MISSING_REMEDIATION = `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary.`; +/** Repair when execution of the selected adb binary is denied. */ +export const ADB_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary.`; const VERSION_MAX_OUTPUT_BYTES = 64 * 1024; /** How the adb binary was selected for this provider instance. */ @@ -61,6 +67,7 @@ export const resolveAdbBinary = async ( throw new AdbConfigurationFailure( "not_absolute", `${ADB_BINARY_ENV} must be an absolute path to the adb binary`, + `Set ${ADB_BINARY_ENV} to an absolute adb executable path, or unset it to use adb from PATH.`, { configured_path: configured }, ); return { @@ -93,10 +100,10 @@ const resolveWindowsAdb = async ( throw new AdbConfigurationFailure( failure.kind === "missing" ? "binary_missing" : "binary_unresolved", "The adb binary could not be selected from the Windows PATH/PATHEXT", + `Set ${ADB_BINARY_ENV} to an absolute adb executable, or add it to PATH/PATHEXT.`, { selected_binary: "adb", path_source: "path", - remediation: `Set ${ADB_BINARY_ENV} to an absolute adb executable, or add it to PATH/PATHEXT`, ...(failure.kind === "invalid_extension" ? { invalid_extension: failure.extension } : {}), @@ -123,10 +130,12 @@ export type AdbConfigurationFailureCode = | "binary_not_executable" | "version_unresolved"; +/** Why the selected adb binary is unusable, with the owner's repair instruction. */ export class AdbConfigurationFailure extends Error { constructor( readonly code: AdbConfigurationFailureCode, message: string, + readonly remediation: string, readonly diagnostics: Readonly> = {}, options?: ErrorOptions, ) { @@ -181,6 +190,7 @@ export const inspectAdbClient = async ( throw new AdbConfigurationFailure( "version_unresolved", "The selected adb binary exited successfully but did not report a recognizable protocol or build version", + ADB_VERSION_REMEDIATION, { selected_binary: selection.binary, path_source: selection.pathSource, @@ -237,10 +247,10 @@ export const inspectAdbClient = async ( throw new AdbConfigurationFailure( "binary_missing", `The selected adb binary was not found: ${selection.binary}`, + ADB_MISSING_REMEDIATION, { selected_binary: selection.binary, path_source: selection.pathSource, - remediation: `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary`, }, { cause }, ); @@ -248,17 +258,18 @@ export const inspectAdbClient = async ( throw new AdbConfigurationFailure( "binary_not_executable", `Execution of the selected adb binary was denied (${failure.code}): ${selection.binary}`, + ADB_NOT_EXECUTABLE_REMEDIATION, { selected_binary: selection.binary, path_source: selection.pathSource, filesystem_error: failure.code, - remediation: `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary`, }, { cause }, ); throw new AdbConfigurationFailure( "version_unresolved", "The selected adb binary did not report a usable version", + ADB_VERSION_REMEDIATION, { selected_binary: selection.binary, ...(failure === undefined @@ -287,10 +298,10 @@ const rejectionCodeOf = ( export const adbConfigurationAvailability = ( cause: AdbConfigurationFailure, -): ProviderAvailability => - ({ - status: "unavailable", - code: rejectionCodeOf(cause.code), - reason: cause.message, - diagnostics: { ...cause.diagnostics }, - }) as ProviderAvailability; +): ProviderAvailability => ({ + status: "unavailable", + code: rejectionCodeOf(cause.code), + reason: cause.message, + remediation: cause.remediation, + diagnostics: { ...cause.diagnostics }, +}); diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index d81ade095a..08303731fb 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -591,7 +591,8 @@ it("reports a missing selected binary as a capability gap with recovery", async const availability = await provider.inspectAvailability(); expect(availability).toMatchObject({ status: "unavailable" }); if (availability.status !== "unavailable") return; - expect(availability.diagnostics.remediation).toContain("REA_ADB_PATH"); + expect(availability.remediation).toContain("REA_ADB_PATH"); + expect(availability.diagnostics).not.toHaveProperty("remediation"); const result = await provider.execute({ operation: "list_adb_devices", input: {}, diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index cd614af68e..9e2487eae9 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -72,10 +72,10 @@ type ConfigurationFailureCode = | "open_options_invalid"; /** - * Failure kinds tagged at the JADX configuration boundary. Consumers (the - * doctor adapter) project the boundary's remediation and classification for - * a kind; they must not reinterpret the phase/setting evidence to derive - * them. + * Failure kinds tagged at the JADX configuration boundary. Availability + * carries the boundary's remediation for a kind and the doctor adapter + * projects its classification; neither reinterprets the phase/setting + * evidence to derive them. */ export type JadxFailureKind = | "windows_native_artifact" @@ -101,72 +101,52 @@ export const JADX_FAILURE_KINDS: readonly JadxFailureKind[] = [ "unsupported_host", ]; -export interface JadxFailureOutcome { - readonly remediation: string; - readonly classification: DoctorProviderCheck["classification"]; -} +/** The boundary-owned repair instruction for one tagged failure kind. */ +const jadxFailureRemediation = (kind: JadxFailureKind): string => { + switch (kind) { + case "windows_native_artifact": + case "windows_native_runtime": + return JADX_WINDOWS_NATIVE_REMEDIATION; + case "java_runtime": + case "java_home": + return JADX_JAVA_RUNTIME_REMEDIATION; + case "jvm_options": + return JADX_JVM_OPTIONS_REMEDIATION; + case "operation_timeout": + return JADX_OPERATION_TIMEOUT_REMEDIATION; + case "jar": + return JADX_JAR_CONFIGURATION_REMEDIATION; + case "java_probe": + return JADX_JAVA_PROBE_REMEDIATION; + case "unsupported_host": + return JADX_UNSUPPORTED_HOST_REMEDIATION; + } +}; /** - * Project the boundary-owned remediation and classification for a failure - * kind. A null kind (missing or corrupt tag) keeps JAR guidance, matching - * the previous fallthrough for unclassified failures. + * Project the doctor classification for a failure kind. A null kind (an + * untagged readiness failure) is classified as configuration drift. */ -export const projectJadxFailureOutcome = ( +export const jadxFailureClassification = ( kind: JadxFailureKind | null, configured: boolean, -): JadxFailureOutcome => { +): DoctorProviderCheck["classification"] => { const drift = configured ? "config_drift" : "missing_analysis_engine"; switch (kind) { case "windows_native_artifact": - return { - remediation: JADX_WINDOWS_NATIVE_REMEDIATION, - classification: "missing_dependency", - }; - case "windows_native_runtime": - return { - remediation: JADX_WINDOWS_NATIVE_REMEDIATION, - classification: "runtime_failure", - }; case "java_runtime": - return { - remediation: JADX_JAVA_RUNTIME_REMEDIATION, - classification: "missing_dependency", - }; - case "java_home": - return { - remediation: JADX_JAVA_RUNTIME_REMEDIATION, - classification: drift, - }; - case "jvm_options": - return { - remediation: JADX_JVM_OPTIONS_REMEDIATION, - classification: drift, - }; - case "operation_timeout": - return { - remediation: JADX_OPERATION_TIMEOUT_REMEDIATION, - classification: drift, - }; - case "jar": - return { - remediation: JADX_JAR_CONFIGURATION_REMEDIATION, - classification: drift, - }; - case "java_probe": - return { - remediation: JADX_JAVA_PROBE_REMEDIATION, - classification: drift, - }; + return "missing_dependency"; + case "windows_native_runtime": + return "runtime_failure"; case "unsupported_host": - return { - remediation: JADX_UNSUPPORTED_HOST_REMEDIATION, - classification: "unsupported_host", - }; + return "unsupported_host"; + case "java_home": + case "jvm_options": + case "operation_timeout": + case "jar": + case "java_probe": case null: - return { - remediation: JADX_JAR_CONFIGURATION_REMEDIATION, - classification: drift, - }; + return drift; } }; @@ -227,6 +207,7 @@ export const inspectJadxAvailability = async ( status: "unavailable", code: cause.code, reason: cause.message, + remediation: jadxFailureRemediation(cause.kind), diagnostics: { ...cause.diagnostics, failure_kind: cause.kind }, }; const prerequisite = ownedProcessPrerequisiteAvailability(cause); diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts index bb2102080b..9667f49920 100644 --- a/src/android/JadxDoctor.ts +++ b/src/android/JadxDoctor.ts @@ -8,9 +8,10 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { inspectJadxAvailability, JADX_FAILURE_KINDS, - projectJadxFailureOutcome, + jadxFailureClassification, type JadxFailureKind, } from "./JadxConfiguration.js"; +import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js"; /** Project the validated JADX configuration into the doctor contract. */ export const inspectJadxProvider = async ( @@ -93,14 +94,14 @@ const configurationCheck = ( : {}), }; } - // The failure kind is tagged at the configuration boundary; project its - // remediation and classification here instead of reinterpreting the - // phase/setting evidence. Every failed check keeps a remediation. + // Availability carries the configuration boundary's remediation and tags + // the failure kind; project its classification here instead of + // reinterpreting the phase/setting evidence. Every failed check keeps a + // remediation. const kind = availability.status === "unavailable" ? asFailureKind(availability.diagnostics["failure_kind"]) : null; - const outcome = projectJadxFailureOutcome(kind, configured); return { name: "configuration", ok: false, @@ -109,8 +110,11 @@ const configurationCheck = ( availability.status === "available" ? "Validated JADX configuration reported no JAR path." : availability.reason, - remediation: outcome.remediation, - classification: outcome.classification, + remediation: + availability.status === "available" + ? JADX_JAR_CONFIGURATION_REMEDIATION + : availability.remediation, + classification: jadxFailureClassification(kind, configured), ...(kind === "java_probe" && availability.status === "unavailable" ? { details: probeDetails(availability.diagnostics) } : kind !== null && diff --git a/src/apktool/ApktoolConfiguration.test.ts b/src/apktool/ApktoolConfiguration.test.ts index b3e4a4d142..e8d1479477 100644 --- a/src/apktool/ApktoolConfiguration.test.ts +++ b/src/apktool/ApktoolConfiguration.test.ts @@ -271,9 +271,9 @@ describe("apktoolInvocation", () => { expect(apktoolConfigurationAvailability(failure)).toMatchObject({ status: "unavailable", code: "not_configured", + remediation: expect.stringContaining(APKTOOL_COMMAND_ENV), diagnostics: { selected_command: "C:\\tools & more\\apktool.cmd", - remediation: expect.stringContaining(APKTOOL_COMMAND_ENV), }, }); }); diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 39180047a2..8c77f61d5a 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -46,6 +46,12 @@ const apktoolVersionProbeTimeoutGuidance = (): { remediationAction: `This ${String(VERSION_TIMEOUT_MS)} ms apktool version probe has no runtime override. decode_timeout_ms does not raise it. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, }); +const APKTOOL_VERSION_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an Apktool 2.7.0 or newer launcher whose --version prints its version, or put one on PATH.`; +/** Repair when the selected apktool launcher does not exist. */ +export const APKTOOL_MISSING_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script).`; +/** Repair when execution of the selected apktool launcher is denied. */ +export const APKTOOL_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher.`; + /** How the apktool launcher was selected for this provider instance. */ export interface ApktoolCommandSelection { readonly command: string; @@ -77,6 +83,7 @@ export const resolveApktoolCommand = async ( throw new ApktoolConfigurationFailure( "not_absolute", `${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`, + `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher path, or unset it to use apktool from PATH.`, { configured_command: configured }, ); return { command: configured, commandSource: "environment" }; @@ -106,6 +113,7 @@ export const resolveApktoolCommand = async ( throw new ApktoolConfigurationFailure( "command_unresolved", "Windows PATHEXT must contain filename extensions, not paths", + `Correct PATHEXT so it lists filename extensions only, or set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher.`, { selected_command: "apktool", command_source: "path", @@ -116,6 +124,7 @@ export const resolveApktoolCommand = async ( throw new ApktoolConfigurationFailure( "command_unresolved", `The PATH-selected apktool launcher could not be inspected: ${failure.path}`, + `Make the PATH-selected apktool launcher readable, or set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher.`, { selected_command: "apktool", command_source: "path", @@ -131,10 +140,10 @@ export const resolveApktoolCommand = async ( throw new ApktoolConfigurationFailure( "command_missing", "The apktool launcher was not found on the selected Windows PATH/PATHEXT", + `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT.`, { selected_command: "apktool", command_source: "path", - remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`, }, ); }; @@ -148,10 +157,12 @@ export type ApktoolConfigurationFailureCode = | "version_unresolved" | "unsupported_version"; +/** Why the selected apktool launcher is unusable, with the owner's repair instruction. */ export class ApktoolConfigurationFailure extends Error { constructor( readonly code: ApktoolConfigurationFailureCode, message: string, + readonly remediation: string, readonly diagnostics: Readonly> = {}, options?: ErrorOptions, ) { @@ -287,10 +298,10 @@ export const inspectApktoolClient = async ( throw new ApktoolConfigurationFailure( "launcher_unsupported", `The selected apktool launcher cannot be run through cmd.exe: ${cause.message}`, + `Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable.`, { selected_command: selection.command, command_source: selection.commandSource, - remediation: `Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable`, }, { cause }, ); @@ -349,6 +360,7 @@ export const inspectApktoolClient = async ( : new ApktoolConfigurationFailure( recordedFailure.code, recordedFailure.message, + recordedFailure.remediation, { ...recordedFailure.diagnostics, ...(cancellation === undefined @@ -376,6 +388,7 @@ export const inspectApktoolClient = async ( throw new ApktoolConfigurationFailure( "version_unresolved", "The owned version command completed without an output observation", + APKTOOL_VERSION_REMEDIATION, { selected_command: selection.command }, ); if (!interpreted.ok) throw interpreted.error; @@ -393,6 +406,7 @@ const interpretApktoolVersion = ( throw new ApktoolConfigurationFailure( "version_unresolved", "The selected apktool launcher exited successfully but did not report a recognizable version", + APKTOOL_VERSION_REMEDIATION, { selected_command: selection.command, command_source: selection.commandSource, @@ -406,12 +420,12 @@ const interpretApktoolVersion = ( throw new ApktoolConfigurationFailure( "unsupported_version", `The selected Apktool version ${identity.version} is older than the supported minimum 2.7.0`, + `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`, { selected_command: selection.command, command_source: selection.commandSource, reported_version: identity.version, minimum_version: "2.7.0", - remediation: `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`, }, ); return identity; @@ -434,13 +448,13 @@ const rejectionCodeOf = ( export const apktoolConfigurationAvailability = ( cause: ApktoolConfigurationFailure, -): ProviderAvailability => - ({ - status: "unavailable", - code: rejectionCodeOf(cause.code), - reason: cause.message, - diagnostics: { ...cause.diagnostics }, - }) as ProviderAvailability; +): ProviderAvailability => ({ + status: "unavailable", + code: rejectionCodeOf(cause.code), + reason: cause.message, + remediation: cause.remediation, + diagnostics: { ...cause.diagnostics }, +}); /** Interpret the command owner's outcome, without inferring a new failure from a late signal. */ const versionProbeFailure = ( @@ -501,14 +515,13 @@ const versionProbeFailure = ( cause.code === "EACCES" ? `Execution of the selected apktool launcher was denied: ${selection.command}` : `The selected apktool launcher was not found: ${selection.command}`, + cause.code === "EACCES" + ? APKTOOL_NOT_EXECUTABLE_REMEDIATION + : APKTOOL_MISSING_REMEDIATION, { filesystem_error: cause.code, selected_command: selection.command, command_source: selection.commandSource, - remediation: - cause.code === "EACCES" - ? `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher` - : `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`, }, { cause }, ); @@ -535,6 +548,7 @@ const versionProbeFailure = ( return new ApktoolConfigurationFailure( "version_unresolved", "The selected apktool launcher did not report a usable version", + APKTOOL_VERSION_REMEDIATION, { selected_command: selection.command, ...(observed === undefined diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 391a9d5ec3..74d41d2d81 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -258,6 +258,11 @@ type CapabilityAvailability = readonly available: false; readonly reason: string; readonly availabilityCode?: ProviderRejectionCode; + /** + * Repair instruction from the readiness owner that made this operation + * unavailable. Static capability facts omit it. + */ + readonly remediation?: string; }; export type CapabilityDescriptor = CapabilityAvailability & { @@ -304,7 +309,10 @@ export type ProviderAvailability = | { readonly status: "unavailable"; readonly code: ProviderRejectionCode; + /** What the owner observed; not a repair instruction. */ readonly reason: string; + /** The owner's repair instruction for this exact failure. */ + readonly remediation: string; readonly diagnostics: Readonly>; }; diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 6c965c26c9..024e94b6c4 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -184,6 +184,7 @@ describe("ADB and Apktool host availability", () => { status: "unavailable" as const, code: "not_configured" as const, reason: "REA_ADB_PATH is invalid", + remediation: "Set REA_ADB_PATH to an absolute adb executable path.", diagnostics: {}, }; const readyInventory = buildCapabilityInventory(status(), { @@ -205,6 +206,7 @@ describe("ADB and Apktool host availability", () => { status: "unavailable", code: "unsupported_version", reason: "Apktool 2.6.3 is older than 2.7.0", + remediation: "Repair the fixture provider.", diagnostics: {}, }, }); @@ -227,6 +229,7 @@ describe("capability failure reasons", () => { status: "unavailable", code: "runtime_unavailable", reason: "The JADX native self-test failed.", + remediation: "Repair the fixture provider.", diagnostics: {}, }, }); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 69534371e3..2ae0fb4ab7 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -61,6 +61,7 @@ const statusSchema = z.object({ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason; type ProviderDescriptor = ProviderCapability; +type UnavailableDescriptor = Extract; /** Optional integrations whose verified hosts exclude some platforms. */ export const HOST_RESTRICTED_FEATURES = [ @@ -575,7 +576,7 @@ const workflowAvailabilityFor = ({ if (profile.reason !== "available") return profile; const limiter = policy.evmLimiterAvailability; if (limiter?.status === "unavailable") - return { reason: "provider_missing", remediation: limiter.reason }; + return { reason: "provider_missing", remediation: limiter.remediation }; return { reason: "available", remediation: null }; } if (name === "inspect_recorded_crash") @@ -609,7 +610,7 @@ const workflowAvailabilityFor = ({ ? "provider_unavailable" : "provider_missing", remediation: - policy.androidAnalysisAvailability?.reason ?? + policy.androidAnalysisAvailability?.remediation ?? "Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.", }; if (kind === "adb-provider") @@ -701,7 +702,7 @@ const ownerProbeAvailability = ( : observation.code === "not_configured" ? "provider_missing" : "provider_unavailable", - remediation: observation.reason, + remediation: observation.remediation, }; }; @@ -765,7 +766,10 @@ const hostAvailability = ({ const descriptor = descriptors.get(name); return descriptor?.available === false && descriptor.availability_code === "unsupported_host" - ? { reason: "unsupported_host", remediation: descriptor.reason } + ? { + reason: "unsupported_host", + remediation: descriptorRemediation(descriptor), + } : null; }; @@ -791,7 +795,10 @@ const targetAvailability = ({ (descriptor.availability_code === "target_kind_unsupported" || descriptor.availability_code === "target_format_unsupported") ) - return { reason: "target_unsupported", remediation: descriptor.reason }; + return { + reason: "target_unsupported", + remediation: descriptorRemediation(descriptor), + }; if ( targetKind !== undefined && targetKind !== "executable" && @@ -840,11 +847,19 @@ const providerAvailability = ({ ? { reason: "available", remediation: null } : { reason: "provider_unavailable", - remediation: - descriptor.reason ?? "Choose another target or configured provider.", + remediation: descriptorRemediation(descriptor), }; }; +/** + * Prefer the readiness owner's repair instruction. A static capability fact + * declares no separate repair, so its reason remains the only guidance. + */ +const descriptorRemediation = (descriptor: UnavailableDescriptor): string => + descriptor.remediation ?? + descriptor.reason ?? + "Choose another target or configured provider."; + const composedAvailability = ( name: string, descriptors: ReadonlyMap, @@ -891,7 +906,8 @@ const composedAvailabilityFor = ( (descriptor) => descriptor?.available === true, ), unavailable: observations.find( - (descriptor) => descriptor?.available === false, + (descriptor): descriptor is UnavailableDescriptor => + descriptor?.available === false, ), }; }) @@ -922,8 +938,7 @@ const composedAvailabilityFor = ( unavailable.availability_code === "unsupported_host" ? "unsupported_host" : "provider_unavailable", - remediation: - unavailable.reason ?? "Choose another target or configured provider.", + remediation: descriptorRemediation(unavailable), }, }; return { diff --git a/src/application/binary/AnalysisProviderEvaluation.ts b/src/application/binary/AnalysisProviderEvaluation.ts index 359401a76b..0d69c0d597 100644 --- a/src/application/binary/AnalysisProviderEvaluation.ts +++ b/src/application/binary/AnalysisProviderEvaluation.ts @@ -17,6 +17,7 @@ import type { } from "../AnalysisProvider.js"; import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { analysisErrorRemediationAction } from "../../domain/analysisErrorPresentation.js"; /** Profile-resolution truth for one discovered provider candidate. */ export interface AnalysisProviderCandidateEvaluation { @@ -76,9 +77,15 @@ export const evaluateAnalysisProviderCandidate = async ( }, }, } - : rejectedProfile(candidate, status, error.message, { - error_tag: error._tag, - }); + : rejectedProfile( + candidate, + status, + { + reason: error.message, + remediation: analysisErrorRemediationAction(error), + }, + { error_tag: error._tag }, + ); return ok({ ...rejection, profileError: error }); } if (signalIsAborted(signal)) @@ -88,11 +95,10 @@ export const evaluateAnalysisProviderCandidate = async ( try { if (resolved.value.profile === null) return ok( - rejectedProfile( - candidate, - status, - "Provider version or analysis profile could not be resolved", - ), + rejectedProfile(candidate, status, { + reason: "Provider version or analysis profile could not be resolved", + remediation: PROFILE_REMEDIATION, + }), ); const profile = analysisProfileSchema.parse(resolved.value.profile); const identity = candidate.identity(); @@ -103,11 +109,10 @@ export const evaluateAnalysisProviderCandidate = async ( profile.provider.version !== identity.version) ) return ok( - rejectedProfile( - candidate, - status, - "Resolved analysis profile has mismatched provider identity", - ), + rejectedProfile(candidate, status, { + reason: "Resolved analysis profile has mismatched provider identity", + remediation: PROFILE_REMEDIATION, + }), ); return ok({ candidate, @@ -120,11 +125,10 @@ export const evaluateAnalysisProviderCandidate = async ( } catch (cause: unknown) { void cause; return ok( - rejectedProfile( - candidate, - status, - "Provider returned an invalid analysis profile", - ), + rejectedProfile(candidate, status, { + reason: "Provider returned an invalid analysis profile", + remediation: PROFILE_REMEDIATION, + }), ); } }; @@ -132,10 +136,13 @@ export const evaluateAnalysisProviderCandidate = async ( const signalIsAborted = (signal: AbortSignal | undefined): boolean => signal?.aborted === true; +const PROFILE_REMEDIATION = + "Run rea doctor to check this provider's installation and version, then reopen the target, or select another analysis provider."; + const rejectedProfile = ( candidate: AnalysisProviderCandidate, status: AnalysisProviderCandidateStatus, - reason: string, + rejection: { readonly reason: string; readonly remediation: string }, diagnostics: Readonly> = {}, ): AnalysisProviderCandidateEvaluation => ({ candidate, @@ -144,7 +151,7 @@ const rejectedProfile = ( availability: { status: "unavailable", code: "version_unresolved", - reason, + ...rejection, diagnostics: jsonObjectSchema.parse({ ...status.availability.diagnostics, ...diagnostics, diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index a0497b9b11..1eb030451c 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -93,6 +93,7 @@ it("uses one owner readiness snapshot for candidate availability and capabilitie status: "unavailable", code: "runtime_unavailable", reason: "native inspection failed", + remediation: "Repair the fixture provider.", diagnostics: { failure_stage: "self-test" }, }, capabilities: [capability(base.identity())], @@ -462,6 +463,7 @@ describe("analysis provider registry: rejection origins", () => { status: "unavailable", code: "open_options_invalid", reason: "Configured registration is invalid", + remediation: "Repair the fixture provider.", diagnostics: { registration_path: "/configured/alpha.json" }, }), }, @@ -727,6 +729,7 @@ const candidate = ( status: "unavailable", code: options.unconfigured ? "not_configured" : "runtime_missing", reason: `${id} runtime is unavailable`, + remediation: "Repair the fixture provider.", diagnostics: { executable_path: `/opt/${id}/bin/analyze` }, }, inspectTargetSupport: (target): ProviderTargetSupport => diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index ba7df2051f..ef6cdb8012 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -914,6 +914,7 @@ const selectedRejection = ( origin, code: rejection.code, reason: rejection.reason, + ...("remediation" in rejection ? { remediation: rejection.remediation } : {}), // ProviderSelectionError owns the single defensive diagnostic copy. diagnostics: rejection.diagnostics, }); diff --git a/src/application/binary/BinarySessionStatus.test.ts b/src/application/binary/BinarySessionStatus.test.ts index e012a7139a..e4e81f0dfe 100644 --- a/src/application/binary/BinarySessionStatus.test.ts +++ b/src/application/binary/BinarySessionStatus.test.ts @@ -668,7 +668,8 @@ describe("binary session summary projection", () => { availability: { status: "unavailable", code: "not_configured", - reason: "Set PROVIDER_B_HOME; see docs/provider-b.md.", + reason: "PROVIDER_B_HOME is not set.", + remediation: "Set PROVIDER_B_HOME; see docs/provider-b.md.", diagnostics: { checks: [{ name: "configuration", ok: false }] }, }, targetSupport: { @@ -726,7 +727,7 @@ describe("binary session summary projection", () => { availability: { status: "unavailable", code: "not_configured", - reason: "Set PROVIDER_B_HOME; see docs/provider-b.md.", + reason: "PROVIDER_B_HOME is not set.", }, target_support: { status: "unsupported", @@ -737,6 +738,13 @@ describe("binary session summary projection", () => { }, ]); expect(JSON.stringify(raw)).not.toContain("diagnostics"); + // Complete detail keeps the owner's repair; summary omits it. + expect( + complete.analysis_provider_candidates[1]?.availability, + ).toMatchObject({ + remediation: "Set PROVIDER_B_HOME; see docs/provider-b.md.", + }); + expect(JSON.stringify(raw)).not.toContain("PROVIDER_B_HOME;"); expect( analysisProviderCandidate .array() @@ -775,3 +783,68 @@ it("narrows summary capabilities without inventing omitted candidate facts", asy reason: "No target is open.", }); }); + +it("groups readiness repairs exactly and drops one replaced by a runtime observation", async () => { + const unavailable = ( + operation: CapabilityDescriptor["operation"], + remediation: string, + ): CapabilityDescriptor => { + const declared = descriptor(providerA, operation, [], { + available: false, + reason: "Configured registration is invalid.", + }); + if (declared.available) throw new Error("Expected an unavailable fixture"); + return { ...declared, remediation }; + }; + const status = await project( + [ + unavailable("procedure_info", "Correct REA_FIXTURE_CONFIG."), + unavailable("read_bytes", "Correct REA_FIXTURE_CONFIG."), + unavailable("list_names", "Unset REA_FIXTURE_CACHE."), + unavailable("xrefs", "Correct REA_FIXTURE_CONFIG."), + ], + [], + new Map([["xrefs", { reason: "Runtime operation failure" }]]), + ); + const groups = status.capabilities.flatMap(({ groups }) => groups); + expect(groups).toEqual([ + expect.objectContaining({ + operations: ["list_names"], + remediation: "Unset REA_FIXTURE_CACHE.", + }), + expect.objectContaining({ + operations: ["procedure_info", "read_bytes"], + reason: "Configured registration is invalid.", + remediation: "Correct REA_FIXTURE_CONFIG.", + }), + expect.not.objectContaining({ remediation: expect.anything() }), + ]); + expect(groups[2]).toMatchObject({ + operations: ["xrefs"], + reason: "Runtime operation failure", + }); + const inventory = new Map( + buildCapabilityInventory( + { open: true, kind: "executable", capabilities: status.capabilities }, + { processCaptureEnabled: true }, + ).map((entry) => [entry.name, entry]), + ); + expect(inventory.get("procedure_info")?.remediation).toBe( + "Correct REA_FIXTURE_CONFIG.", + ); + // A static or runtime-only refusal has no separate repair to prefer. + expect(inventory.get("xrefs")?.remediation).toBe("Runtime operation failure"); + // A scoped registration narrows operations without dropping their repair. + const scoped = projectedStatusSchema.parse( + restrictAdvertisedCapabilities( + status, + (operation) => operation === "procedure_info", + ), + ); + expect(scoped.capabilities.flatMap(({ groups }) => groups)).toEqual([ + expect.objectContaining({ + operations: ["procedure_info"], + remediation: "Correct REA_FIXTURE_CONFIG.", + }), + ]); +}); diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 91aa615cc5..958897ed22 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -93,11 +93,7 @@ export const binarySessionStatus = async ({ const unavailable = runtimeUnavailability.get(descriptor.operation); return unavailable === undefined ? descriptor - : { - ...descriptor, - available: false as const, - reason: unavailable.reason, - }; + : runtimeUnavailable(descriptor, unavailable.reason); }); const capabilities = providerCapabilitySets(routeCapabilities); const analysisProviderBinding = @@ -178,6 +174,19 @@ export const binarySessionStatus = async ({ }; }; +/** A runtime observation replaces the declared state and its repair instruction. */ +const runtimeUnavailable = ( + descriptor: CapabilityDescriptor, + reason: string, +): CapabilityDescriptor => { + if (descriptor.available) { + const { availabilityCode: _available, ...declared } = descriptor; + return { ...declared, available: false, reason }; + } + const { remediation: _superseded, ...declared } = descriptor; + return { ...declared, reason }; +}; + /** Capability surfaces a registration may narrow without hiding provider facts. */ const advertisedCapabilitySurfaces = z.object({ capabilities: z.array(providerCapabilitySet), @@ -338,7 +347,11 @@ type CapabilityProjection = Omit< "operation" | "available" | "reason" > & { readonly operation: string } & ( | { readonly available: true; readonly reason: null } - | { readonly available: false; readonly reason: string } + | { + readonly available: false; + readonly reason: string; + readonly remediation?: string; + } ); type ProviderCapabilityGroup = ProviderCapabilitySet["groups"][number]; @@ -388,6 +401,9 @@ export const providerCapabilitySets = ( available: false as const, reason: descriptor.reason, availability_code: availabilityCode, + ...(descriptor.remediation === undefined + ? {} + : { remediation: descriptor.remediation }), }; const key = JSON.stringify([ effects, @@ -395,6 +411,7 @@ export const providerCapabilitySets = ( status.available, status.reason, status.availability_code, + status.available ? null : (status.remediation ?? null), ]); const group = groups.get(key); if (group === undefined) diff --git a/src/application/ownedProcessAvailability.ts b/src/application/ownedProcessAvailability.ts index 918d777714..9c60cea3e9 100644 --- a/src/application/ownedProcessAvailability.ts +++ b/src/application/ownedProcessAvailability.ts @@ -73,6 +73,7 @@ export const ownedProcessPrerequisiteAvailability = ( status: "unavailable", code: "runtime_unavailable", reason: `${refusal.reason}. ${REMEDIATION}`, + remediation: REMEDIATION, diagnostics: { stage: "ownership_preparation", prerequisite_provider_id: refusal.providerId, @@ -85,6 +86,7 @@ export const ownedProcessPrerequisiteAvailability = ( status: "unavailable", code: "runtime_unavailable", reason: `${darwin.message}. ${DARWIN_REMEDIATION}`, + remediation: DARWIN_REMEDIATION, diagnostics: { stage: "ownership_preparation", ...(darwin.compilerOutcome === undefined diff --git a/src/composition/jeb.ts b/src/composition/jeb.ts index cc6cc0438c..2cbfe295d1 100644 --- a/src/composition/jeb.ts +++ b/src/composition/jeb.ts @@ -2,6 +2,7 @@ import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js"; import { parseJebMcpTimeoutMs } from "../config/environment.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js"; import { err } from "../domain/result.js"; import type { JebMcpConnectionFactory } from "../jeb/JebMcpConnection.js"; import { JebProvider } from "../jeb/JebProvider.js"; @@ -53,6 +54,7 @@ const unconfiguredJebProvider = ( status: "unavailable", code: "not_configured", reason: `${setting} is invalid: ${error.settings[0]?.constraint ?? error.message}`, + remediation: analysisErrorRemediationAction(error), diagnostics: { setting }, }), close: () => Promise.resolve(), diff --git a/src/contracts/sessionStatusContract.ts b/src/contracts/sessionStatusContract.ts index e7c9bcbe44..20d188fca6 100644 --- a/src/contracts/sessionStatusContract.ts +++ b/src/contracts/sessionStatusContract.ts @@ -10,6 +10,7 @@ export type BinarySessionDetail = (typeof BINARY_SESSION_DETAILS)[number]; * available from the same session with detail: "complete". */ export const BINARY_SESSION_SUMMARY_OMITTED_FIELDS = [ + "analysis_provider_candidates.availability.remediation", "analysis_provider_candidates.availability.diagnostics", "analysis_provider_candidates.target_support.diagnostics", "analysis_provider_candidates.capabilities", @@ -24,7 +25,7 @@ export const binarySessionInputSchema = z.strictObject({ .enum(BINARY_SESSION_DETAILS) .default("summary") .describe( - "summary (default) keeps open state, the binding, current-route capabilities, each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. complete adds provider diagnostics, candidate capabilities and static tool surface, effect and annotation flags.", + "summary (default) keeps open state, the binding, current-route capabilities, each provider candidate's availability and target-support status, code and reason, and every tool's availability, reason and remediation; omitted_fields lists what it leaves out. complete adds each unavailable candidate's remediation, provider diagnostics, candidate capabilities and static tool surface, effect and annotation flags.", ), expected_package_version: z.string().min(1).optional(), expected_catalog_digest: z diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index fc41b2b90b..0d7d5e923e 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -49,6 +49,9 @@ export const requireOutputSchema = < return schema; }; +/** The readiness owner's repair instruction; never the failure description. */ +const providerRemediation = z.string().min(1); + const providerCapabilityFacts = { operation: z.string(), effects: z.object({ @@ -75,6 +78,7 @@ export const providerCapability = z.discriminatedUnion("available", [ available: z.literal(false), reason: z.string().nullable(), availability_code: z.enum(PROVIDER_REJECTION_CODES).nullable(), + remediation: providerRemediation.exactOptional(), }), ]); export type ProviderCapability = z.output; @@ -108,6 +112,7 @@ const providerCapabilityGroup = z.discriminatedUnion("available", [ available: z.literal(false), reason: z.string().nullable(), availability_code: z.enum(PROVIDER_REJECTION_CODES).nullable(), + remediation: providerRemediation.exactOptional(), }), ]); @@ -133,6 +138,9 @@ export const expandProviderCapabilities = ( available: group.available, reason: group.reason, availability_code: group.availability_code, + ...("remediation" in group && group.remediation !== undefined + ? { remediation: group.remediation } + : {}), effects: Object.fromEntries( PROVIDER_EFFECT_NAMES.map((name) => [ name, @@ -255,6 +263,7 @@ const providerAvailability = z.discriminatedUnion("status", [ diagnostics: jsonObjectSchema, }), providerAvailabilityStates.unavailable.extend({ + remediation: providerRemediation, diagnostics: jsonObjectSchema, }), ]); @@ -304,7 +313,7 @@ export const analysisProviderCandidate = z.object({ capabilities: z.array(providerCapabilitySet), }); -/** Summary candidate: the same status, code, and reason without diagnostics or capabilities. */ +/** Summary candidate: the same status, code, and reason without remediation, diagnostics or capabilities. */ export const analysisProviderCandidateSummary = z.strictObject({ provider: providerIdentity, availability: z.discriminatedUnion("status", [ diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 6a7930b324..1ed227078d 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -487,6 +487,9 @@ const providerErrorDetails = ( origin: rejection.origin, code: rejection.code, reason: rejection.reason, + ...(rejection.remediation === undefined + ? {} + : { remediation: rejection.remediation }), diagnostics: rejection.diagnostics, })), }; diff --git a/src/domain/pcap/pcapAnalysis.ts b/src/domain/pcap/pcapAnalysis.ts index d7aee43333..6359781777 100644 --- a/src/domain/pcap/pcapAnalysis.ts +++ b/src/domain/pcap/pcapAnalysis.ts @@ -7,6 +7,9 @@ export const pcapCaptureHostSupported = (platform: string): boolean => platform === "linux" || platform === "win32"; export const PCAP_CAPTURE_UNSUPPORTED_HOST_REASON = "Packet summaries require an enforceable native memory bound: Linux with util-linux prlimit, or Windows with owned Job Objects. This host's capture boundary remains unsupported."; +/** Repair for an unsupported capture host; no setting on this host changes it. */ +export const PCAP_CAPTURE_HOST_REMEDIATION = + "Run packet-capture summaries on Linux with util-linux prlimit, or on Windows with owned Job Objects."; /** * Caller intent for packet-capture analysis through caller-supplied tshark diff --git a/src/domain/providerConfigurationRefusal.test.ts b/src/domain/providerConfigurationRefusal.test.ts index 63fa64f56f..af4901a784 100644 --- a/src/domain/providerConfigurationRefusal.test.ts +++ b/src/domain/providerConfigurationRefusal.test.ts @@ -2,12 +2,12 @@ import { expect, it } from "vitest"; import { providerConfigurationRefusal } from "./providerConfigurationRefusal.js"; import { projectAnalysisError } from "./analysisErrorProjection.js"; -it("retains provider-owned configuration diagnostics in public refusals", () => { +it("retains provider-owned configuration diagnostics and repair in public refusals", () => { const failure = Object.assign(new Error("version unreadable"), { + remediation: "Inspect the selected launcher.", diagnostics: { selected_command: "/selected/apktool", command_source: "environment", - remediation: "Inspect the selected launcher", }, }); expect( @@ -20,6 +20,10 @@ it("retains provider-owned configuration diagnostics in public refusals", () => ), ).toMatchObject({ code: "capability_unavailable", + message: "version unreadable. Inspect the selected launcher.", + remediation: { + action: "version unreadable. Inspect the selected launcher.", + }, details: { diagnostics: failure.diagnostics }, }); }); diff --git a/src/domain/providerConfigurationRefusal.ts b/src/domain/providerConfigurationRefusal.ts index 762cf91dc3..3d8628f30f 100644 --- a/src/domain/providerConfigurationRefusal.ts +++ b/src/domain/providerConfigurationRefusal.ts @@ -1,19 +1,17 @@ import { AnalysisCapabilityUnavailableError } from "./analysisErrorCore.js"; import type { JsonValue } from "./jsonValue.js"; -/** Convert a provider's host configuration failure into actionable refusal evidence. */ +/** Project a provider-owned configuration failure and its repair instruction. */ export const providerConfigurationRefusal = ( providerId: string, operation: string, failure: Error & { + /** A complete repair sentence owned by the failing configuration. */ + readonly remediation: string; readonly diagnostics?: Readonly>; }, ): AnalysisCapabilityUnavailableError => { - const remediation = failure.diagnostics?.remediation; - const reason = - typeof remediation === "string" - ? `${failure.message}. ${remediation}.` - : `${failure.message}.`; + const reason = `${failure.message}. ${failure.remediation}`; return new AnalysisCapabilityUnavailableError(providerId, operation, reason, { cause: failure, userMessage: reason, diff --git a/src/domain/providerSelectionError.ts b/src/domain/providerSelectionError.ts index 1ce240946b..7acb753b9f 100644 --- a/src/domain/providerSelectionError.ts +++ b/src/domain/providerSelectionError.ts @@ -22,6 +22,8 @@ export interface ProviderSelectionRejection { readonly origin: ProviderRejectionOrigin; readonly code: string; readonly reason: string; + /** The readiness owner's repair; target-support refusals carry none. */ + readonly remediation?: string; readonly diagnostics: Readonly>; } @@ -68,7 +70,8 @@ export class ProviderSelectionError extends AnalysisError { ...rejection, diagnostics: structuredClone(rejection.diagnostics), })); - this.remediationAction = options.remediationAction; + this.remediationAction = + options.remediationAction ?? requestedProviderRepair(options); this.userMessage = options.userMessage ?? providerSelectionUserMessage(options); } @@ -84,6 +87,21 @@ const providerSelectionDiagnostic = ( return `Analysis provider selection failed (${options.reason}) for ${options.requestedProviderId}; candidates: ${candidates}`; }; +/** + * A concrete selection refused by its provider's readiness is repaired by + * that provider's own instruction, not by generic selection advice. + */ +const requestedProviderRepair = ( + options: ProviderSelectionErrorOptions, +): string | undefined => + options.reason === "provider_unavailable" + ? options.rejections?.find( + ({ providerId, origin }) => + providerId === options.requestedProviderId && + origin === "availability", + )?.remediation + : undefined; + /** True when every recorded rejection describes availability, not target support. */ const providerAvailabilityOnly = ( rejections: readonly ProviderSelectionRejection[] | undefined, diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index b8f41cf152..2a72fef70d 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -195,6 +195,8 @@ export const evmLimiterAvailability = async ( status: "unavailable", code: "executable_missing", reason: selected.error.reason, + // The limiter owner does not yet author a separate repair (#8508). + remediation: selected.error.reason, diagnostics: selected.error.diagnostics, }; }; diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 7f30a7f6a0..e3b49766b4 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -248,6 +248,7 @@ export interface UnavailableGhidraInstallation extends GhidraInstallationObserva readonly rejection: Readonly<{ code: ProviderRejectionCode; reason: string; + remediation: string; }>; } @@ -399,7 +400,11 @@ export const inspectGhidraInstallation = async ( providerVersion: coordinates.properties?.providerVersion ?? null, javaHome: javaObservation?.home ?? options.javaHome ?? null, javaVersion: javaObservation?.version ?? null, - rejection: { code: failed.code, reason: failed.remediation }, + rejection: { + code: failed.code, + reason: failed.remediation, + remediation: failed.remediation, + }, }; if (options.signal?.aborted) throw new AnalysisCancelledError("ghidra:installation", { diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index e3c41ad9df..cb92f8d6f3 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1,4 +1,12 @@ -import { EXISTING_PROJECT_WINDOWS_UNAVAILABLE } from "./GhidraProviderCapabilities.js"; +import { z } from "zod"; +import { + EXISTING_PROJECT_WINDOWS_REMEDIATION, + EXISTING_PROJECT_WINDOWS_UNAVAILABLE, + existingProjectCapabilities, +} from "./GhidraProviderCapabilities.js"; +import { AnalysisProviderRegistry } from "../application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../application/binary/BinarySessionComposition.js"; +import { analysisProviderCandidate } from "../contracts/toolOutputSchemaPrimitives.js"; import { ProcessOutputTokenScanner } from "../process/ProcessOutputTokenScanner.js"; import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; @@ -748,11 +756,9 @@ describe("Ghidra Windows platform support", () => { status: "unavailable", code: "runtime_missing", reason, + remediation: expect.any(String), diagnostics: { - windows_native_failure: { - stage: "artifact", - remediation: expect.any(String), - }, + windows_native_failure: { stage: "artifact" }, windows_security: { job_object_process_ownership: expect.objectContaining({ available: false, @@ -853,11 +859,9 @@ describe("Windows Ghidra native failure diagnostics", () => { reason: expect.stringContaining( "Windows native controls are unavailable", ), + remediation: expect.any(String), diagnostics: { - windows_native_failure: { - stage: failureStage, - remediation: expect.any(String), - }, + windows_native_failure: { stage: failureStage }, windows_security: { job_object_process_ownership: expect.objectContaining({ available: false, @@ -5024,3 +5028,69 @@ it.each(["search_procedures", "search_strings"] as const)( } }, ); + +describe("Windows Ghidra readiness repair", () => { + it("carries the native readiness repair into candidate capabilities and explicit selection", async () => { + vi.spyOn(nativeLoader, "systemWindowsNativeAuthority").mockReturnValue({ + available: false, + failureStage: "artifact", + reason: "Windows native controls are unavailable: missing manifest", + }); + const ghidra = provider(installationHost("win32")); + const owner = await ghidra.inspectAvailability(); + if (owner.status !== "unavailable") + throw new Error( + "Windows P0 must stay unavailable without native controls", + ); + const registry = new AnalysisProviderRegistry([ghidra]); + const session = composeBinarySession(registry); + onTestFinished(async () => { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + }); + + // Complete binary_session output: readiness-gated groups keep the repair, + // the static mutation refusal declares none. + const status = z + .object({ + analysis_provider_candidates: z.array(analysisProviderCandidate), + }) + .parse(await session.status({ detail: "complete" })); + const groups = status.analysis_provider_candidates + .flatMap(({ capabilities }) => capabilities) + .flatMap(({ groups }) => groups); + expect( + groups.find(({ operations }) => operations.includes("list_procedures")), + ).toMatchObject({ available: false, remediation: owner.remediation }); + expect( + groups.find(({ operations }) => + operations.includes("annotate_native_function"), + ), + ).not.toHaveProperty("remediation"); + + // An explicit selection returns the same owner repair as its recovery. + const selected = await registry.select(peTarget("x86_64"), "ghidra"); + if (selected.ok) throw new Error("An unready provider must be refused"); + const projected = projectAnalysisError(selected.error); + expect(projected.remediation.action).toBe(owner.remediation); + expect(projected.details).toMatchObject({ + selection_reason: "provider_unavailable", + rejections: [ + { + provider_id: "ghidra", + origin: "availability", + remediation: owner.remediation, + }, + ], + }); + }); + + it("gives every operation of a Windows existing-project session the same repair", () => { + for (const capability of existingProjectCapabilities("win32")) + expect(capability).toMatchObject({ + available: false, + reason: EXISTING_PROJECT_WINDOWS_UNAVAILABLE, + remediation: EXISTING_PROJECT_WINDOWS_REMEDIATION, + }); + }); +}); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 860c835746..040b7330a4 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -73,6 +73,7 @@ import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js"; import { CAPABILITIES, existingProjectCapabilities, + EXISTING_PROJECT_WINDOWS_REMEDIATION, EXISTING_PROJECT_WINDOWS_UNAVAILABLE, windowsP0Capabilities, WINDOWS_P0_NATIVE_PE_ARCHITECTURES, @@ -223,6 +224,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { status: "unavailable", code: "unsupported_host", reason: EXISTING_PROJECT_WINDOWS_UNAVAILABLE, + remediation: EXISTING_PROJECT_WINDOWS_REMEDIATION, diagnostics, }; if (installation.status === "available" && readiness?.available === false) @@ -230,15 +232,13 @@ export class GhidraProvider implements AnalysisProviderCandidate { status: "unavailable", code: readiness.code, reason: readiness.reason, + remediation: readiness.remediation, diagnostics: { ...diagnostics, windows_security: jsonObjectSchema.parse( windowsNativeCapabilities(installation.platform, readiness), ), - windows_native_failure: { - stage: readiness.failureStage, - remediation: readiness.remediation, - }, + windows_native_failure: { stage: readiness.failureStage }, }, }; return installation.status === "available" @@ -252,6 +252,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { status: "unavailable", code: installation.rejection.code, reason: installation.rejection.reason, + remediation: installation.rejection.remediation, diagnostics, }; } diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 0714b6f7cc..999abae9d6 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -217,6 +217,10 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( export const EXISTING_PROJECT_WINDOWS_UNAVAILABLE = "An existing Ghidra project cannot be opened on the Windows launch path."; +/** Windows P0 imports targets into ephemeral projects only. */ +export const EXISTING_PROJECT_WINDOWS_REMEDIATION = + "Unset REA_GHIDRA_PROJECT_PATH in the environment that starts REA to import targets into an ephemeral project, or open the existing project on a supported Linux or macOS host; restart REA after changing the environment."; + /** Read-only project mode retains precise unavailable mutation capabilities. */ export const existingProjectCapabilities = ( platform: NodeJS.Platform, @@ -235,6 +239,7 @@ export const existingProjectCapabilities = ( available: false, reason: EXISTING_PROJECT_WINDOWS_UNAVAILABLE, availabilityCode: "unsupported_host", + remediation: EXISTING_PROJECT_WINDOWS_REMEDIATION, }; if (capability.effects.mutatesArtifact) return { @@ -271,6 +276,7 @@ export const windowsP0Capabilities = ( available: false, reason: readiness.reason, availabilityCode: readiness.code, + remediation: readiness.remediation, limitations, }); }), diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 15f44dc88a..aa905a0152 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -106,6 +106,8 @@ export class HopperProvider implements AnalysisProviderCandidate { status: "unavailable", code: "unsupported_host", reason: `Hopper integration is not supported on ${this.platform}.`, + remediation: + "Run Hopper analysis on macOS or Linux, or select another analysis provider with REA_ANALYSIS_PROVIDER.", diagnostics, }; try { @@ -126,6 +128,8 @@ export class HopperProvider implements AnalysisProviderCandidate { status: "unavailable", code: "executable_missing", reason: `Hopper launcher is missing or not executable: ${this.config.hopperLauncherPath}`, + remediation: + "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH to an executable Hopper launcher in the environment that starts REA, then restart REA.", diagnostics, }; } diff --git a/src/hopper/HopperProviderDiscovery.test.ts b/src/hopper/HopperProviderDiscovery.test.ts index 7180d5b110..892a0007b8 100644 --- a/src/hopper/HopperProviderDiscovery.test.ts +++ b/src/hopper/HopperProviderDiscovery.test.ts @@ -27,6 +27,7 @@ describe("Hopper provider discovery", () => { status: "unavailable", code: "executable_missing", reason: `Hopper launcher is missing or not executable: ${missingPath}`, + remediation: expect.stringContaining("HOPPER_LAUNCHER_PATH"), diagnostics: { launcher_path: missingPath, platform: process.platform, @@ -43,6 +44,7 @@ describe("Hopper provider discovery", () => { status: "unavailable", code: "executable_missing", reason: `Hopper launcher is missing or not executable: ${directory}`, + remediation: expect.stringContaining("HOPPER_LAUNCHER_PATH"), diagnostics: { launcher_path: directory, platform: process.platform, diff --git a/src/ida/IdaProvider.test.ts b/src/ida/IdaProvider.test.ts index bc563d15fc..f091495678 100644 --- a/src/ida/IdaProvider.test.ts +++ b/src/ida/IdaProvider.test.ts @@ -40,6 +40,7 @@ it.each([ status: "unavailable", code: selected ? "open_options_invalid" : "not_configured", reason: expect.stringContaining("REA_IDA_MCP_CONFIG"), + remediation: expect.stringContaining("REA_IDA_MCP_CONFIG"), diagnostics: { configured: selected, registration_path: selected ? path : null, @@ -49,10 +50,11 @@ it.each([ { setting: "REA_IDA_MCP_CONFIG", constraint: expect.any(String) }, ], }, - configuration_remediation: - expect.stringContaining("REA_IDA_MCP_CONFIG"), }, }); + expect(snapshot.availability.diagnostics).not.toHaveProperty( + "configuration_remediation", + ); expect(connect).not.toHaveBeenCalled(); } finally { const closed = await provider.closeOwnedResources(); @@ -120,15 +122,22 @@ it.each(["missing", "file", "directory", "omitted"] as const)( status: "unavailable", code: "open_options_invalid", reason: expect.stringContaining(workspace), + remediation: expect.stringContaining("workspaceRoot"), diagnostics: { workspace_root: workspace, live_connection_probed: false, - configuration_remediation: expect.stringContaining("workspaceRoot"), }, }); expect(snapshot.capabilities.every(({ available }) => !available)).toBe( true, ); + expect( + snapshot.capabilities.find( + ({ operation }) => operation === "procedure_pseudo_code", + ), + ).toMatchObject({ + remediation: expect.stringContaining("workspaceRoot"), + }); expect( snapshot.capabilities.find( ({ operation }) => operation === "procedure_callers", diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 9dd0594d6f..17f5df26c3 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -50,7 +50,6 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { ProviderInspectionScope } from "../application/ProviderInspectionScope.js"; -import type { ProviderRejectionCode } from "../domain/providerSelection.js"; /** * Families IDA admits today. Every other family is refused, including families @@ -202,86 +201,70 @@ export class IdaProvider implements AnalysisProviderCandidate { registration_path: this.config.idaMcpConfigPath ?? null, live_connection_probed: false, }; - const unavailableCapabilities = ( - reason: string, - code: ProviderRejectionCode, - ) => - capabilities.map((capability) => + // Readiness-gated operations carry the same owner observation and repair. + const unavailable = ( + availability: Extract, + ): AnalysisProviderReadinessSnapshot => ({ + availability, + capabilities: capabilities.map((capability) => capability.available ? { ...capability, available: false as const, - reason, - availabilityCode: code, + reason: availability.reason, + availabilityCode: availability.code, + remediation: availability.remediation, } : capability, - ); + ), + }); if (!config.ok) { const failure = projectAnalysisError(config.error); - return { - availability: { - status: "unavailable", - code: - this.config.idaMcpConfigPath === undefined - ? "not_configured" - : "open_options_invalid", - reason: failure.message, - diagnostics: { - ...registrationDiagnostics, - configuration_details: failure.details ?? null, - configuration_remediation: failure.remediation.action, - }, - }, - capabilities: unavailableCapabilities( - failure.message, + return unavailable({ + status: "unavailable", + code: this.config.idaMcpConfigPath === undefined ? "not_configured" : "open_options_invalid", - ), - }; + reason: failure.message, + remediation: failure.remediation.action, + diagnostics: { + ...registrationDiagnostics, + configuration_details: failure.details ?? null, + }, + }); } if (config.value.mode === "headless") { const { privacy, unusable } = await inspectIdaWorkspaceReadiness( config.value.workspaceRoot, ); if (!privacy.available) - return { - availability: { - status: "unavailable" as const, - code: privacy.code, - reason: privacy.reason, - diagnostics: { - ...registrationDiagnostics, - mode: "headless", - private_workspace_available: false, - windows_native_failure: { - stage: privacy.failureStage, - remediation: privacy.remediation, - }, - }, + return unavailable({ + status: "unavailable", + code: privacy.code, + reason: privacy.reason, + remediation: privacy.remediation, + diagnostics: { + ...registrationDiagnostics, + mode: "headless", + private_workspace_available: false, + windows_native_failure: { stage: privacy.failureStage }, }, - capabilities: unavailableCapabilities(privacy.reason, privacy.code), - }; + }); if (unusable !== undefined) - return { - availability: { - status: "unavailable" as const, - code: "open_options_invalid" as const, - reason: unusable, - diagnostics: { - ...registrationDiagnostics, - mode: "headless", - workspace_root: config.value.workspaceRoot ?? null, - private_workspace_available: false, - configuration_remediation: - "Set workspaceRoot to an existing directory REA can create entries in, or omit it to use the system temporary directory; see docs/ida-provider.md.", - }, + return unavailable({ + status: "unavailable", + code: "open_options_invalid", + reason: unusable, + remediation: + "Set workspaceRoot to an existing directory REA can create entries in, or omit it to use the system temporary directory; see docs/ida-provider.md.", + diagnostics: { + ...registrationDiagnostics, + mode: "headless", + workspace_root: config.value.workspaceRoot ?? null, + private_workspace_available: false, }, - capabilities: unavailableCapabilities( - unusable, - "open_options_invalid", - ), - }; + }); } return { availability: { diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index 54bd186af1..bb9a3a6445 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -248,8 +248,13 @@ describe("JebProvider observations and failures", () => { ); const availability = await provider.inspectAvailability(); expect(availability.status).toBe("unavailable"); - expect(availability.reason).toContain("Start a JEB client serving MCP"); - expect(availability.reason).toContain("REA_JEB_MCP_URL"); + if (availability.status !== "unavailable") + throw new Error("An unreachable endpoint must be unavailable"); + expect(availability.reason).toContain("ECONNREFUSED"); + expect(availability.remediation).toContain( + "Start a JEB client serving MCP", + ); + expect(availability.remediation).toContain("REA_JEB_MCP_URL"); const result = await provider.execute({ operation: "list_jeb_units", input: { count: 10, index: 0 }, diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 235609d258..3906d8adb0 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -214,11 +214,13 @@ export class JebProvider implements JebAnalysisPort { } catch (cause) { if (signal?.aborted === true) throw cause; if (cause instanceof AnalysisError) { + // The failure's owner, such as the discovery deadline, also owns the repair. const projected = projectAnalysisError(cause); return { status: "unavailable", code: "runtime_unavailable", reason: projected.message, + remediation: projected.remediation.action, diagnostics: { ...cause.diagnostics, endpoint: this.#selectedEndpoint, @@ -228,7 +230,8 @@ export class JebProvider implements JebAnalysisPort { return { status: "unavailable", code: "not_configured", - reason: `${cause instanceof Error ? cause.message : "The JEB MCP endpoint did not respond"}. ${JEB_CONNECTION_REMEDIATION}`, + reason: `${cause instanceof Error ? cause.message : "The JEB MCP endpoint did not respond"}.`, + remediation: JEB_CONNECTION_REMEDIATION, diagnostics: { endpoint: this.#selectedEndpoint }, }; } diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 2d8a408906..33960e3cc7 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -108,15 +108,8 @@ export class KunaProvider implements AnalysisProviderCandidate { } capabilities() { - const availability = this.inspectAvailability(); return kunaCapabilities( - availability.status === "available" - ? { available: true } - : { - available: false, - reason: availability.reason, - code: availability.code, - }, + this.inspectAvailability(), this.#deadline, this.#heapRequest, ); @@ -132,6 +125,7 @@ export class KunaProvider implements AnalysisProviderCandidate { status: "unavailable", code: configuration.error.code, reason: configuration.error.reason, + remediation: configuration.error.remediation, diagnostics: { configured: configuration.error.code !== "not_configured", version_probed: false, @@ -147,6 +141,7 @@ export class KunaProvider implements AnalysisProviderCandidate { status: "unavailable", code: "executable_missing", reason: `Cannot execute REA_KUNA_COMMAND ${command}: ${cause instanceof Error ? cause.message : String(cause)}. ${KUNA_COMMAND_REMEDIATION}`, + remediation: KUNA_COMMAND_REMEDIATION, diagnostics: { configured: true, command, version_probed: false }, }; } diff --git a/src/kuna/KunaProviderCapabilities.ts b/src/kuna/KunaProviderCapabilities.ts index 1b325265a2..d952649cd1 100644 --- a/src/kuna/KunaProviderCapabilities.ts +++ b/src/kuna/KunaProviderCapabilities.ts @@ -5,8 +5,8 @@ import { import type { AnalysisOperation, CapabilityDescriptor, + ProviderAvailability, } from "../application/AnalysisProvider.js"; -import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import { DEFAULT_REGEX_MATCHING_DEADLINE, @@ -97,25 +97,20 @@ export const KUNA_SINGLE_FUNCTION_LIMITATION = /** Advertise Kuna's static, process-launching operations truthfully. */ export const kunaCapabilities = ( - availability: - | { readonly available: true } - | { - readonly available: false; - readonly reason: string; - readonly code: ProviderRejectionCode; - }, + availability: ProviderAvailability, deadline: RegexMatchingDeadline = DEFAULT_REGEX_MATCHING_DEADLINE, heapRequest: RegexWorkerHeapRequest = DEFAULT_REGEX_WORKER_HEAP_REQUEST, ): readonly CapabilityDescriptor[] => KUNA_OPERATIONS.map((operation) => ({ provider: KUNA_PROVIDER_IDENTITY, operation, - ...(availability.available + ...(availability.status === "available" ? ({ available: true, reason: null } as const) : ({ available: false, reason: availability.reason, availabilityCode: availability.code, + remediation: availability.remediation, } as const)), // Text can change when the export finishes. References own bounded session // reuse; generic snapshot replay must not bypass that lifecycle. diff --git a/src/pcap/PcapConfiguration.ts b/src/pcap/PcapConfiguration.ts index 5eb33217ac..8506aae1a7 100644 --- a/src/pcap/PcapConfiguration.ts +++ b/src/pcap/PcapConfiguration.ts @@ -47,7 +47,8 @@ export const assertWiresharkExecutableIdentity = ( export const TSHARK_COMMAND_ENV = "REA_TSHARK_COMMAND"; export const CAPINFOS_COMMAND_ENV = "REA_CAPINFOS_COMMAND"; -const TOOL_REMEDIATION = `Supply Wireshark 4.x tshark and capinfos on PATH or through absolute ${TSHARK_COMMAND_ENV} and ${CAPINFOS_COMMAND_ENV}; REA does not install them.`; +/** Repair for any unusable caller-selected Wireshark tool; shared by readiness and execution. */ +export const PCAP_TOOL_REMEDIATION = `Supply Wireshark 4.x tshark and capinfos on PATH or through absolute ${TSHARK_COMMAND_ENV} and ${CAPINFOS_COMMAND_ENV}; REA does not install them.`; /** * Host-tool refusals concern the caller's Wireshark installation, not the @@ -61,7 +62,7 @@ export const pcapToolUnavailable = ( ): AnalysisCapabilityUnavailableError => new AnalysisCapabilityUnavailableError("pcap", operation, reason, { ...options, - userMessage: `${/[.!?]$/u.test(reason) ? reason : `${reason}.`} ${TOOL_REMEDIATION}`, + userMessage: `${/[.!?]$/u.test(reason) ? reason : `${reason}.`} ${PCAP_TOOL_REMEDIATION}`, }); /** How one caller-supplied Wireshark tool was selected. */ @@ -246,5 +247,6 @@ export const pcapConfigurationAvailability = ( status: "unavailable", code: rejectionCodeOf(cause.code), reason: cause.message, + remediation: PCAP_TOOL_REMEDIATION, diagnostics: { ...cause.diagnostics }, }); diff --git a/src/pcap/PcapNativeBound.test.ts b/src/pcap/PcapNativeBound.test.ts index 684d2d9934..2b221ab9d2 100644 --- a/src/pcap/PcapNativeBound.test.ts +++ b/src/pcap/PcapNativeBound.test.ts @@ -158,6 +158,10 @@ it("rejects a directory or a command that does not identify as util-linux prlimi ), ).rejects.toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", + // The limiter owns the repair; readiness keeps it over Wireshark advice. + remediationAction: expect.stringContaining( + "REA_TSHARK_PRLIMIT_COMMAND", + ) as unknown, diagnostics: { configuration_key: "REA_TSHARK_PRLIMIT_COMMAND", configured: path, diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts index 04aa08da74..d38c32271f 100644 --- a/src/pcap/PcapNativeBound.ts +++ b/src/pcap/PcapNativeBound.ts @@ -24,6 +24,8 @@ import type { runPcapCommand } from "./PcapCommand.js"; import type { ArtifactCommandPath } from "../process/ArtifactCommandPath.js"; const PRLIMIT_COMMAND_ENV = "REA_TSHARK_PRLIMIT_COMMAND"; +/** Repair for an unusable selected capture limiter; readiness and admission share it. */ +export const PCAP_LIMITER_REMEDIATION = `Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`; /** Capture CPU ceiling, in seconds. */ const PCAP_CPU_SECONDS = 300; @@ -139,7 +141,8 @@ export const preparePcapNativeBound = async ( configuration_key: PRLIMIT_COMMAND_ENV, configured, }, - userMessage: `${reason} (${cause instanceof Error ? cause.message : String(cause)}) Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`, + userMessage: `${reason} (${cause instanceof Error ? cause.message : String(cause)}) ${PCAP_LIMITER_REMEDIATION}`, + remediationAction: PCAP_LIMITER_REMEDIATION, }); } }; diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 7cee0ed5c8..9d46b67381 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -8,6 +8,7 @@ import { } from "../application/AnalysisProvider.js"; import { pcapResultSchemas, + PCAP_CAPTURE_HOST_REMEDIATION, pcapCaptureHostSupported, type PcapRequest, type PcapOperation, @@ -34,6 +35,7 @@ import { locateWiresharkTool, resolvePcapTools, pcapConfigurationAvailability, + PCAP_TOOL_REMEDIATION, pcapToolUnavailable, wiresharkExecutableIdentity, type PcapToolSelection, @@ -555,6 +557,7 @@ export class PcapProvider { code: "unsupported_host", reason: "No enforceable native memory boundary is available for capture summaries on this host.", + remediation: PCAP_CAPTURE_HOST_REMEDIATION, diagnostics: client, }; await this.prepareCaptureBound("inspect_pcap_client", signal); @@ -579,6 +582,12 @@ export class PcapProvider { ? "executable_missing" : "version_unresolved", reason: cause instanceof Error ? cause.message : String(cause), + // Keep the failing owner's repair, such as the limiter's; only an + // unattributed tool failure falls back to the Wireshark guidance. + remediation: + (cause instanceof AnalysisCapabilityUnavailableError + ? cause.remediationAction + : undefined) ?? PCAP_TOOL_REMEDIATION, diagnostics: {}, }; } diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 8808ceadc9..e07389528f 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -458,7 +458,8 @@ export const registerSessionTools = ( const missingAndroidAvailability = async (): Promise => ({ status: "unavailable", code: "not_configured", - reason: + reason: "No Android JADX analysis provider is configured.", + remediation: "Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.", diagnostics: { configured: false }, }); diff --git a/tests/boundary/cli/providerSelectionRemediationCli.test.ts b/tests/boundary/cli/providerSelectionRemediationCli.test.ts new file mode 100644 index 0000000000..53ade79c81 --- /dev/null +++ b/tests/boundary/cli/providerSelectionRemediationCli.test.ts @@ -0,0 +1,51 @@ +import { expect } from "vitest"; + +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { IdaProvider } from "../../../src/ida/IdaProvider.js"; +import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; + +workspaceCliTest( + "returns an unready IDA registration's repair for an explicit --provider selection", + async ({ cli, workspace }) => { + const registration = workspace.path("missing-ida.json"); + const target = await workspace.write("target.elf", elf(2, 1, 62)); + // The readiness owner is the oracle for the repair the caller receives. + const config = parseConfig({ REA_IDA_MCP_CONFIG: registration }); + if (!config.ok) throw config.error; + const ida = new IdaProvider(config.value, {}, () => { + throw new Error("Readiness must not connect to IDA"); + }); + const owner = await ida.inspectAvailability(); + const released = await ida.closeOwnedResources(); + if (!released.ok) throw released.error; + if (owner.status !== "unavailable") + throw new Error("A missing registration must be unavailable"); + + const result = await cli.run({ + arguments: ["decompile", target, "main", "--provider", "ida", "--json"], + environment: { + ...workspace.environment, + REA_IDA_MCP_CONFIG: registration, + REA_LOG_LEVEL: "silent", + }, + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + remediation: { action: owner.remediation }, + details: { + selection_reason: "provider_unavailable", + requested_provider_id: "ida", + rejections: expect.arrayContaining([ + expect.objectContaining({ + provider_id: "ida", + origin: "availability", + reason: owner.reason, + remediation: owner.remediation, + }), + ]), + }, + }); + expect(owner.remediation).toContain("REA_IDA_MCP_CONFIG"); + }, +); diff --git a/tests/boundary/mcp/capabilityRemediationMcp.test.ts b/tests/boundary/mcp/capabilityRemediationMcp.test.ts new file mode 100644 index 0000000000..54fcf13294 --- /dev/null +++ b/tests/boundary/mcp/capabilityRemediationMcp.test.ts @@ -0,0 +1,197 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { afterAll, expect, it, onTestFinished } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { IdaProvider } from "../../../src/ida/IdaProvider.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + expandToolAvailability, + toolAvailabilityInventory, +} from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { startJebMcpFixture } from "../../fixtures/jebMcp.js"; +import { jebMcpTimeoutGuidance } from "../../../src/jeb/JebMcpDeadline.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + ADB_MISSING_REMEDIATION, + ADB_NOT_EXECUTABLE_REMEDIATION, +} from "../../../src/adb/AdbConfiguration.js"; +import { + APKTOOL_MISSING_REMEDIATION, + APKTOOL_NOT_EXECUTABLE_REMEDIATION, +} from "../../../src/apktool/ApktoolConfiguration.js"; + +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + +/** Read final capability discovery through the production MCP composition. */ +const discoverTools = async (environment: NodeJS.ProcessEnv) => { + const { client, close } = await createApplicationMcpHarness({ + serverOptions: { environment }, + clientIdentity: { name: "capability-remediation", version: "1" }, + schemaValidator: schemaValidation, + }); + try { + const result = await client.callTool({ + name: "binary_session", + arguments: { detail: "complete" }, + }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + const status = toolContract("binary_session").outputSchema.parse( + result.structuredContent, + ).result; + return new Map( + expandToolAvailability( + toolAvailabilityInventory.parse(status.tool_availability), + ).map((tool) => [tool.name, tool]), + ); + } finally { + await close(); + } +}; + +it("names the JEB discovery deadline setting instead of repeating the expired deadline", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fixture = await startJebMcpFixture( + () => null, + async () => { + await gate; + return { tools: [] }; + }, + ); + try { + const tools = await discoverTools({ + REA_JEB_MCP_URL: fixture.endpoint, + REA_JEB_MCP_TIMEOUT_MS: "100", + }); + const entry = tools.get("inspect_jeb_client"); + expect(entry).toMatchObject({ + available: false, + reason: "provider_unavailable", + }); + // The deadline owner authors the repair; discovery must not restate the failure. + expect(entry?.remediation).toBe( + jebMcpTimeoutGuidance("discovery", 100).remediationAction, + ); + expect(entry?.remediation).toContain("REA_JEB_MCP_TIMEOUT_MS"); + expect(entry?.remediation).not.toContain("deadlineExpired"); + } finally { + release(); + await fixture.close(); + } +}); + +it.skipIf(process.platform === "win32").each([ + { + selection: "non-executable", + adb: ADB_NOT_EXECUTABLE_REMEDIATION, + apktool: APKTOOL_NOT_EXECUTABLE_REMEDIATION, + }, + { + selection: "missing", + adb: ADB_MISSING_REMEDIATION, + apktool: APKTOOL_MISSING_REMEDIATION, + }, +] as const)( + "keeps the owner's repair for a $selection ADB and Apktool selection", + async ({ selection, adb, apktool }) => { + const root = await createTestTempDirectory("rea-capability-remediation-"); + const selected = { adb: join(root, "adb"), apktool: join(root, "apktool") }; + if (selection === "non-executable") + for (const path of Object.values(selected)) + await writeFile(path, "#!/bin/sh\nexit 0\n", { mode: 0o600 }); + const tools = await discoverTools({ + ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }), + REA_ADB_PATH: selected.adb, + REA_APKTOOL_COMMAND: selected.apktool, + }); + const entries = [ + { setting: "REA_ADB_PATH", repair: adb, names: ["inspect_adb_client"] }, + { + setting: "REA_APKTOOL_COMMAND", + repair: apktool, + names: ["inspect_apktool_client", "decode_android_resources"], + }, + ]; + for (const { setting, repair, names } of entries) + for (const name of names) { + const entry = tools.get(name); + expect(entry).toMatchObject({ available: false, remediation: repair }); + // Independent of wording: the repair names the setting and matches + // the observed failure, not the other outcome's repair. + expect(entry?.remediation).toContain(setting); + expect(/execute permission/u.test(entry?.remediation ?? "")).toBe( + selection === "non-executable", + ); + } + }, +); + +it("returns an unready IDA registration's repair from an explicit open_binary selection", async () => { + const root = await createTestTempDirectory("rea-selection-remediation-"); + const target = join(root, "target.elf"); + await writeFile(target, elf(2, 1, 62)); + const config = parseConfig({ + REA_IDA_MCP_CONFIG: join(root, "missing.json"), + }); + if (!config.ok) throw config.error; + const ida = new IdaProvider(config.value, {}, () => { + throw new Error("Selection must not connect to IDA"); + }); + // The readiness owner is the oracle for the repair the caller receives. + const owner = await ida.inspectAvailability(); + if (owner.status !== "unavailable") + throw new Error("A missing registration must be unavailable"); + const session = composeBinarySession(new AnalysisProviderRegistry([ida])); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const client = new Client({ name: "selection-remediation", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + onTestFinished(async () => { + await client.close(); + await server.close(); + const closed = await session.close(); + if (!closed.ok) throw closed.error; + const released = await ida.closeOwnedResources(); + if (!released.ok) throw released.error; + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const { error } = parseMcpToolError( + (await client.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "ida" }, + })) as CallToolResult, + ); + expect(error.remediation.action).toBe(owner.remediation); + expect(error.remediation.action).toContain("REA_IDA_MCP_CONFIG"); + expect(error.details).toMatchObject({ + selection_reason: "provider_unavailable", + requested_provider_id: "ida", + rejections: [ + { + provider_id: "ida", + origin: "availability", + reason: owner.reason, + remediation: owner.remediation, + }, + ], + }); +}); diff --git a/tests/boundary/mcp/jebMcp.test.ts b/tests/boundary/mcp/jebMcp.test.ts index 8509fb7752..d3a69cb028 100644 --- a/tests/boundary/mcp/jebMcp.test.ts +++ b/tests/boundary/mcp/jebMcp.test.ts @@ -32,7 +32,8 @@ it("discovers current JEB operation support and recovery through binary_session" let availability: JebAnalysisAvailability = { status: "unavailable", code: "not_configured", - reason: "Start JEB at REA_JEB_MCP_URL", + reason: "The JEB MCP endpoint did not respond.", + remediation: "Start JEB at REA_JEB_MCP_URL", diagnostics: {}, }; let probes = 0; @@ -71,7 +72,10 @@ it("discovers current JEB operation support and recovery through binary_session" expect(unavailable).toHaveLength(4); expect( unavailable.every( - (tool) => !tool.available && tool.remediation === availability.reason, + (tool) => + !tool.available && + availability.status === "unavailable" && + tool.remediation === availability.remediation, ), ).toBe(true); availability = { diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts index 6809915f51..a821fb5f30 100644 --- a/tests/boundary/mcp/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -724,6 +724,7 @@ const candidate = ( status: "unavailable", code: "not_configured", reason: `${id} is not configured`, + remediation: "Repair the fixture provider.", diagnostics: {}, }, inspectTargetSupport: () => ({ diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index 2dbe57ee55..5001a9ae23 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -180,7 +180,8 @@ describe("public provider availability", () => { const unavailable: ProviderAvailability = { status: "unavailable", code: "unsupported_version", - reason: "Use Apktool 2.7.0 or newer.", + reason: "Apktool 2.6.3 is older than the supported minimum 2.7.0.", + remediation: "Use Apktool 2.7.0 or newer.", diagnostics: { minimum_version: "2.7.0" }, }; const adbDeviceAnalysis: AdbDeviceAnalysisPort = { @@ -222,7 +223,11 @@ describe("public provider availability", () => { availability.groups.find(({ names }) => names.includes("inspect_apktool_client"), ), - ).toMatchObject({ available: false, reason: "provider_unavailable" }); + ).toMatchObject({ + available: false, + reason: "provider_unavailable", + remediation: "Use Apktool 2.7.0 or newer.", + }); }); }); @@ -284,8 +289,10 @@ describe("owned-process prerequisite during status discovery", () => { available: false, reason: "provider_unavailable", }); - expect(apktool?.remediation).toContain("procps-compatible ps"); - expect(apktool?.remediation).toContain("spawn ps ENOENT"); + // Discovery carries the prerequisite owner's repair, not its failure text. + expect(apktool?.remediation).toBe( + "Install your distribution's procps or procps-ng package, or put a procps-compatible ps on REA's PATH, then retry.", + ); expect( availability.groups.find(({ names }) => names.includes("inspect_adb_client"), diff --git a/tests/boundary/mcp/sessionMcpHarness.ts b/tests/boundary/mcp/sessionMcpHarness.ts index 28dcd12596..4a5312a38c 100644 --- a/tests/boundary/mcp/sessionMcpHarness.ts +++ b/tests/boundary/mcp/sessionMcpHarness.ts @@ -34,6 +34,7 @@ const HOST_TOOLS_NOT_CONFIGURED: ProviderAvailability = { status: "unavailable", code: "not_configured", reason: "The target-free MCP fixture does not probe host tools.", + remediation: "Repair the fixture provider.", diagnostics: { source: "test_fixture" }, }; diff --git a/tests/boundary/mcp/toolFamiliesMcp.test.ts b/tests/boundary/mcp/toolFamiliesMcp.test.ts index 885dcbd3ce..ed8661c405 100644 --- a/tests/boundary/mcp/toolFamiliesMcp.test.ts +++ b/tests/boundary/mcp/toolFamiliesMcp.test.ts @@ -191,6 +191,7 @@ describe("MCP tool family advertising", () => { status: "unavailable", code: "runtime_missing", reason: `${name} runtime is unavailable`, + remediation: "Repair the fixture provider.", diagnostics: {}, } as const; }; @@ -231,6 +232,7 @@ describe("MCP tool family advertising", () => { status: "unavailable", code: "runtime_missing", reason: "android runtime is unavailable", + remediation: "Repair the fixture provider.", diagnostics: {}, }), }); diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index d690e3e62d..e9ac466a76 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -7,6 +7,7 @@ import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { PcapProvider } from "../../../src/pcap/PcapProvider.js"; +import { PCAP_LIMITER_REMEDIATION } from "../../../src/pcap/PcapNativeBound.js"; import { CAPINFOS_COMMAND_ENV, TSHARK_COMMAND_ENV, @@ -468,6 +469,12 @@ it("names the prlimit selection when the Linux limiter is unusable", async () => }, }); onTestFinished(() => provider.close()); + // Readiness keeps the limiter owner's repair instead of Wireshark advice. + expect(await provider.inspectAvailability()).toMatchObject({ + status: "unavailable", + reason: expect.stringContaining("Cannot establish the selected Linux"), + remediation: PCAP_LIMITER_REMEDIATION, + }); const result = await provider.execute({ operation: "inspect_pcap_capture", input: { path: "/not-opened/capture.pcap" }, @@ -480,6 +487,7 @@ it("names the prlimit selection when the Linux limiter is unusable", async () => }); expect(projected.message).toContain("must be absolute"); expect(projected.message).toContain("Check that REA_TSHARK_PRLIMIT_COMMAND"); + expect(projected.remediation.action).toBe(PCAP_LIMITER_REMEDIATION); }); it("rejects directory and non-prlimit selections before opening the capture", async () => { diff --git a/tests/boundary/providers/readinessRemediationInventory.test.ts b/tests/boundary/providers/readinessRemediationInventory.test.ts new file mode 100644 index 0000000000..198f56428d --- /dev/null +++ b/tests/boundary/providers/readinessRemediationInventory.test.ts @@ -0,0 +1,98 @@ +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; + +import type { CapabilityDescriptor } from "../../../src/application/AnalysisProvider.js"; +import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; +import { providerCapabilitySets } from "../../../src/application/binary/BinarySessionStatus.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { IdaProvider } from "../../../src/ida/IdaProvider.js"; +import { readKunaConfiguration } from "../../../src/kuna/KunaConfiguration.js"; +import { KunaProvider } from "../../../src/kuna/KunaProvider.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { inspectKunaProviderOwned } from "./kuna/kunaResourceScope.js"; + +// A controlled open target exercises only the final discovery projection. +const openTargetInventory = (capabilities: readonly CapabilityDescriptor[]) => + new Map( + buildCapabilityInventory( + { + open: true, + kind: "executable", + format: "elf", + capabilities: providerCapabilitySets(capabilities), + }, + { processCaptureEnabled: true }, + ).map((entry) => [entry.name, entry]), + ); + +it("carries an unreadable IDA registration's launch-environment repair into discovery", async () => { + const root = await createTestTempDirectory("rea-ida-discovery-repair-"); + const config = parseConfig({ + REA_IDA_MCP_CONFIG: join(root, "missing.json"), + }); + if (!config.ok) throw config.error; + const connect = vi.fn(() => { + throw new Error("Readiness must not connect to IDA"); + }); + const provider = new IdaProvider(config.value, {}, connect); + try { + const { availability, capabilities } = + await provider.inspectReadinessSnapshot(); + if (availability.status !== "unavailable") + throw new Error("A missing registration must be unavailable"); + expect(availability.remediation).toContain("REA_IDA_MCP_CONFIG"); + expect(availability.remediation).toContain("environment that starts REA"); + expect(availability.remediation).not.toBe(availability.reason); + const inventory = openTargetInventory(capabilities); + for (const name of ["analyze_function", "procedure_pseudo_code"]) + expect(inventory.get(name)).toMatchObject({ + available: false, + reason: "provider_unavailable", + remediation: availability.remediation, + }); + expect(connect).not.toHaveBeenCalled(); + } finally { + const closed = await provider.closeOwnedResources(); + if (!closed.ok) throw closed.error; + } +}); + +it.skipIf(process.platform === "win32").each([ + { setting: "REA_KUNA_JOBS", value: "33" }, + { setting: "REA_KUNA_TIMEOUT_MS", value: "0" }, + { setting: "REA_KUNA_CACHE_DIR", value: "relative" }, +])( + "keeps the Kuna configuration owner's repair for invalid $setting through discovery and doctor", + async ({ setting, value }) => { + // Configuration validation refuses the setting before any command probe. + const environment = { + REA_KUNA_COMMAND: "/audit/not-invoked/kuna", + [setting]: value, + }; + const owner = readKunaConfiguration(environment); + if (owner.ok) throw new Error(`${setting}=${value} must be refused`); + expect(owner.error.remediation).toContain(setting); + expect(owner.error.remediation).not.toBe(owner.error.reason); + + const provider = new KunaProvider(environment); + expect(provider.inspectAvailability()).toMatchObject({ + status: "unavailable", + reason: owner.error.reason, + remediation: owner.error.remediation, + }); + const inventory = openTargetInventory(provider.capabilities()); + for (const name of ["analyze_function", "procedure_pseudo_code"]) + expect(inventory.get(name)).toMatchObject({ + available: false, + reason: "provider_unavailable", + remediation: owner.error.remediation, + }); + + // The doctor path already reports the same owner repair. + const doctor = await inspectKunaProviderOwned(environment); + expect(doctor.checks[0]).toMatchObject({ + ok: false, + remediation: owner.error.remediation, + }); + }, +); diff --git a/tests/composition/analysis-sessions/providerBinding.test.ts b/tests/composition/analysis-sessions/providerBinding.test.ts index afc2ef9b54..efd8bd7808 100644 --- a/tests/composition/analysis-sessions/providerBinding.test.ts +++ b/tests/composition/analysis-sessions/providerBinding.test.ts @@ -307,6 +307,7 @@ const deepProvider = (id: string): DeepProviderFixture => { status: "unavailable", code: "runtime_missing", reason: `${id} runtime is unavailable`, + remediation: "Repair the fixture provider.", diagnostics: { executable_path: `/opt/${id}/bin/analyze` }, }, inspectTargetSupport: () => ({