fix(discovery): carry provider repair instructions into capability discovery (#8532)

* fix(discovery): carry provider repair instructions into capability discovery

Provider readiness reported only a failure description, so capability
discovery copied that description into tool_availability remediation and
dropped repairs the owners already had: the JEB discovery-deadline setting,
the ADB/Apktool execute-permission repair kept in diagnostics, and the IDA
and Kuna configuration recovery.

Unavailable ProviderAvailability now requires the owner's remediation
next to its reason, and readiness-derived unavailable capability
descriptors carry it too. Every producer supplies it (ADB, Apktool, JADX,
JEB, Ghidra, Hopper, IDA, Kuna, pcap, owned-process prerequisites, profile
rejection); the inventory uses it verbatim, falling back to the reason
only for static capability facts that declare no repair. ADB/Apktool
configuration failures own remediation as a field instead of diagnostics,
and execution refusals project it as remediation.action. JADX doctor reads
the availability's remediation instead of re-deriving it.

binary_session complete candidate availability and unavailable capability
groups expose remediation (CLI capabilities/providers too); summary omits
it and lists it in omitted_fields. The superseded IDA
configuration_remediation and windows_native_failure.remediation
diagnostics are removed.

Fixes #8506

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(discovery): keep owner repairs through provider selection and capability maps

Address review findings on the readiness-remediation contract:

- Provider selection rejections carry the availability owner's
  remediation, CLI/MCP error details project it, and an explicit
  selection refused by readiness uses it as remediation.action. IDA's
  unreadable registration and Ghidra's Windows readiness repair no
  longer degrade to generic selection advice now that the diagnostics
  copies are gone.
- Ghidra's Windows readiness and existing-project capability maps carry
  the same repair as the availability.
- pcap readiness keeps the limiter owner's repair (now a typed
  remediationAction) and falls back to Wireshark guidance only when the
  failure has none.
- ADB/Apktool repairs are owner constants that are complete sentences.
  The execution refusal keeps its previous message-plus-repair recovery,
  which the compiled CLI contract asserts.
- Regression tests compare with the owning formatter or constant and
  add public MCP and CLI coverage for an explicit IDA selection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-11-01 08:57:11 -08:00
1 parent eb39a07966
commit cbc69537cd
56 files changed
+913 -259

No files matched your search

@@ -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
@@ -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.
+2 -1
View File
@@ -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
+7 -4
View File
@@ -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,
+2 -1
View File
@@ -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.
+21 -10
View File
@@ -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<Record<string, JsonValue>> = {},
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 },
});
+2 -1
View File
@@ -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: {},
+40 -59
View File
@@ -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);
+11 -7
View File
@@ -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 &&
+1 -1
View File
@@ -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),
},
});
});
+28 -14
View File
@@ -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<Record<string, JsonValue>> = {},
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
+8
View File
@@ -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<Record<string, JsonValue>>;
};
@@ -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: {},
},
});
+25 -10
View File
@@ -61,6 +61,7 @@ const statusSchema = z.object({
type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
type UnavailableDescriptor = Extract<ProviderDescriptor, { available: false }>;
/** 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<string, ProviderDescriptor>,
@@ -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 {
@@ -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<Record<string, JsonValue>> = {},
): AnalysisProviderCandidateEvaluation => ({
candidate,
@@ -144,7 +151,7 @@ const rejectedProfile = (
availability: {
status: "unavailable",
code: "version_unresolved",
reason,
...rejection,
diagnostics: jsonObjectSchema.parse({
...status.availability.diagnostics,
...diagnostics,
@@ -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 =>
@@ -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,
});
@@ -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.",
}),
]);
});
+23 -6
View File
@@ -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)
@@ -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
+2
View File
@@ -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(),
+2 -1
View File
@@ -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
+10 -1
View File
@@ -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<typeof providerCapability>;
@@ -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", [
+3
View File
@@ -487,6 +487,9 @@ const providerErrorDetails = (
origin: rejection.origin,
code: rejection.code,
reason: rejection.reason,
...(rejection.remediation === undefined
? {}
: { remediation: rejection.remediation }),
diagnostics: rejection.diagnostics,
})),
};
+3
View File
@@ -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
@@ -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 },
});
});
+4 -6
View File
@@ -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<Record<string, JsonValue>>;
},
): 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,
+19 -1
View File
@@ -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<Record<string, JsonValue>>;
}
@@ -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,
+2
View File
@@ -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,
};
};
+6 -1
View File
@@ -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", {
+79 -9
View File
@@ -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,
});
});
});
+5 -4
View File
@@ -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,
};
}
+6
View File
@@ -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,
});
}),
+4
View File
@@ -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,
};
}
@@ -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,
+12 -3
View File
@@ -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",
+44 -61
View File
@@ -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<ProviderAvailability, { status: "unavailable" }>,
): 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: {
+7 -2
View File
@@ -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 },
+4 -1
View File
@@ -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 },
};
}
+3 -8
View File
@@ -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 },
};
}
+4 -9
View File
@@ -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.
+4 -2
View File
@@ -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 },
});
+4
View File
@@ -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,
+4 -1
View File
@@ -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,
});
}
};
+9
View File
@@ -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: {},
};
}
+2 -1
View File
@@ -458,7 +458,8 @@ export const registerSessionTools = (
const missingAndroidAvailability = async (): Promise<ProviderAvailability> => ({
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 },
});
@@ -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");
},
);
@@ -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<void>((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,
},
],
});
});
+6 -2
View File
@@ -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 = {
@@ -724,6 +724,7 @@ const candidate = (
status: "unavailable",
code: "not_configured",
reason: `${id} is not configured`,
remediation: "Repair the fixture provider.",
diagnostics: {},
},
inspectTargetSupport: () => ({
+11 -4
View File
@@ -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"),
+1
View File
@@ -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" },
};
@@ -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: {},
}),
});
+8
View File
@@ -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 () => {
@@ -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,
});
},
);
@@ -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: () => ({