fix(android): report actual provider readiness

This commit is contained in:
morluto 2026-10-09 07:04:11 +08:00
parent 55c9ce26d1
commit c715af8058
14 changed files with 807 additions and 66 deletions

View File

@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { delimiter, dirname, isAbsolute, join } from "node:path";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { TOOL_CONTRACTS } from "../../../dist/contracts/toolContracts.js";
/** Real unusable Java runtimes and selectors must lead to applicable recovery. */
export async function verifyAndroidFailureRecovery({
@ -17,6 +18,15 @@ export async function verifyAndroidFailureRecovery({
}) {
const root = await mkdtemp(join(tmpdir(), "rea-android-recovery-"));
try {
const withoutJar = { ...environment };
delete withoutJar.REA_JADX_MCP_JAR;
await verifyAndroidReadiness({
entrypoint,
environment: withoutJar,
repository,
available: false,
remediation: /REA_JADX_MCP_JAR/u,
});
let jlink;
for (const candidate of isAbsolute(java)
? [join(dirname(java), "jlink")]
@ -107,8 +117,24 @@ export async function verifyAndroidFailureRecovery({
assert.match(failure.message, /full JDK/u);
assert.match(failure.remediation.action, /java --list-modules/u);
assert.doesNotMatch(JSON.stringify(failure), /rea doctor/u);
await verifyAndroidReadiness({
entrypoint,
environment: scenario.env,
repository,
available: false,
remediation: new RegExp(
scenario.observation.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
"u",
),
});
console.log(`PASS CLI applicable recovery: ${scenario.label}`);
}
await verifyAndroidReadiness({
entrypoint,
environment,
repository,
available: true,
});
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
@ -187,3 +213,72 @@ export async function verifyAndroidFailureRecovery({
await rm(root, { recursive: true, force: true });
}
}
const verifyAndroidReadiness = async ({
entrypoint,
environment,
repository,
available,
remediation,
}) => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
cwd: repository,
env: environment,
stderr: "pipe",
});
const client = new Client({ name: "rea-android-readiness", version: "1" });
try {
await client.connect(transport);
const catalog = await client.listTools();
const catalogNames = catalog.tools.map(({ name }) => name).sort();
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
assert.notEqual(status.isError, true, JSON.stringify(status));
const availability = status.structuredContent?.result?.tool_availability;
assert.ok(Array.isArray(availability));
assert.deepEqual(
availability.map(({ name }) => name).sort(),
TOOL_CONTRACTS.map(({ name }) => name).sort(),
"Readiness must retain one availability record for the complete canonical catalog",
);
assert.deepEqual(
catalogNames,
TOOL_CONTRACTS.map(({ name }) => name).sort(),
"Readiness must not truncate the SDK tool catalog",
);
const byName = new Map(availability.map((item) => [item.name, item]));
for (const name of [
"inspect_android_package",
"search_android_classes",
"inspect_android_class",
"inspect_android_method",
"trace_android_references",
]) {
const item = byName.get(name);
assert.ok(item, `Missing Android availability entry ${name}`);
assert.equal(item.available, available, `${name} readiness mismatch`);
if (available) {
assert.equal(item.reason, "available");
assert.equal(item.remediation, null);
} else {
assert.equal(item.reason, "provider_missing");
assert.match(item.remediation, remediation);
}
}
const graph = byName.get("project_android_application_graph");
assert.ok(graph, "Missing execution-free Android graph projection");
assert.equal(graph.available, true);
assert.equal(graph.reason, "available");
assert.equal(graph.remediation, null);
console.log(
`PASS stdio Android readiness: ${available ? "ready" : "unavailable"}`,
);
} finally {
await client.close();
await transport.close();
}
};

View File

@ -1,10 +1,19 @@
import { access, realpath, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { access, realpath, stat } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
/** Caller-supplied tools, resolved only when an Android operation is selected. */
export interface JadxConfiguration {
readonly jar: string;
@ -12,51 +21,184 @@ export interface JadxConfiguration {
readonly jvmArguments: readonly string[];
}
/** Admit explicit local tooling without installing or changing host configuration. */
type ConfigurationFailureCode =
| "not_configured"
| "executable_missing"
| "runtime_missing"
| "unsupported_host"
| "unsupported_version"
| "version_unresolved"
| "open_options_invalid";
class JadxConfigurationFailure extends Error {
constructor(
readonly code: ConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>>,
options?: ErrorOptions,
) {
super(message, options);
}
}
/** Inspect the same JAR and Java prerequisites that gate actual execution. */
export const inspectJadxAvailability = async (
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<ProviderAvailability> => {
try {
const configuration = await readJadxConfiguration(environment, signal);
return {
status: "available",
code: null,
reason: null,
diagnostics: {
configured: true,
java_executable: configuration.java,
jdk_compiler: true,
jar_path: configuration.jar,
},
};
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (cause instanceof JadxConfigurationFailure)
return {
status: "unavailable",
code: cause.code,
reason: cause.message,
diagnostics: cause.diagnostics,
};
throw cause;
}
};
/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */
export const resolveJadxConfiguration = async (
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
const unavailable = (reason: string) =>
new AnalysisCapabilityUnavailableError("jadx", operation, reason);
if (process.platform !== "linux" && process.platform !== "darwin")
throw unavailable(
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. The metadata bridge has real-provider verification on macOS arm64.`,
try {
return await readJadxConfiguration(environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
const output = cause.diagnostics;
throw new AnalysisCapabilityUnavailableError(
"jadx",
operation,
cause.message,
{
cause,
userMessage: cause.message,
...(typeof output.stdout === "string" &&
typeof output.stderr === "string"
? {
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.output_truncated === true,
},
}
: {}),
},
);
}
};
const readJadxConfiguration = async (
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
requireSupportedHost();
const jar = await resolveJar(environment);
const java = resolveJava(environment);
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
return { jar, java, jvmArguments };
};
const requireSupportedHost = (): void => {
if (process.platform === "linux" || process.platform === "darwin") return;
throw configurationFailure(
"unsupported_host",
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. The metadata bridge has real-provider verification on macOS arm64.`,
{ platform: process.platform },
);
};
const resolveJar = async (
environment: Readonly<Record<string, string | undefined>>,
): Promise<string> => {
const jar = environment.REA_JADX_MCP_JAR;
if (environment.JAVA_HOME !== undefined && !isAbsolute(environment.JAVA_HOME))
throw unavailable(
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
);
if (jar === undefined || !isAbsolute(jar))
throw unavailable(JADX_JAR_CONFIGURATION_REMEDIATION);
const java =
environment.JAVA_HOME === undefined
? "java"
: join(environment.JAVA_HOME, "bin", "java");
throw configurationFailure(
"not_configured",
JADX_JAR_CONFIGURATION_REMEDIATION,
{ jar_configured: false },
);
try {
await access(jar, constants.R_OK);
if (!(await stat(jar)).isFile())
throw unavailable(`REA_JADX_MCP_JAR is not a regular file: ${jar}`);
throw configurationFailure(
"executable_missing",
`REA_JADX_MCP_JAR is not a regular file: ${jar}`,
{ jar_path: jar },
);
return await realpath(jar);
} catch (cause) {
if (cause instanceof AnalysisCapabilityUnavailableError) throw cause;
throw unavailable(
if (cause instanceof JadxConfigurationFailure) throw cause;
throw configurationFailure(
"executable_missing",
`Cannot read REA_JADX_MCP_JAR ${jar}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ jar_path: jar },
cause,
);
}
if (environment.JAVA_HOME !== undefined) {
try {
await access(java, constants.R_OK | constants.X_OK);
if (!(await stat(java)).isFile())
throw unavailable(`JAVA_HOME java is not a regular file: ${java}`);
} catch (cause) {
if (cause instanceof AnalysisCapabilityUnavailableError) throw cause;
throw unavailable(
`Cannot execute JAVA_HOME java ${java}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
};
const resolveJava = (
environment: Readonly<Record<string, string | undefined>>,
): string => {
if (environment.JAVA_HOME === undefined) return "java";
if (isAbsolute(environment.JAVA_HOME))
return join(environment.JAVA_HOME, "bin", "java");
throw configurationFailure(
"open_options_invalid",
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
{ java_home: environment.JAVA_HOME },
);
};
const requireJavaHomeExecutable = async (
javaHome: string | undefined,
java: string,
): Promise<void> => {
if (javaHome === undefined) return;
try {
await access(java, constants.R_OK | constants.X_OK);
if ((await stat(java)).isFile()) return;
throw configurationFailure(
"runtime_missing",
`JAVA_HOME java is not a regular file: ${java}`,
{ java_executable: java },
);
} catch (cause) {
if (cause instanceof JadxConfigurationFailure) throw cause;
throw configurationFailure(
"runtime_missing",
`Cannot execute JAVA_HOME java ${java}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ java_executable: java },
cause,
);
}
const jvmArguments: string[] = [];
};
const resolveJvmArguments = (
environment: Readonly<Record<string, string | undefined>>,
): readonly string[] => {
const result: string[] = [];
for (const [name, prefix, suffix] of [
["REA_JADX_HEAP_MIB", "-Xmx", "m"],
["REA_JADX_ACTIVE_PROCESSOR_COUNT", "-XX:ActiveProcessorCount=", ""],
@ -64,17 +206,142 @@ export const resolveJadxConfiguration = async (
const value = environment[name];
if (value === undefined) continue;
if (!/^[1-9]\d*$/u.test(value) || !Number.isSafeInteger(Number(value)))
throw unavailable(
throw configurationFailure(
"open_options_invalid",
`${name} must be a positive safe integer; received ${value}.`,
{ setting: name, value },
);
if (
name === "REA_JADX_ACTIVE_PROCESSOR_COUNT" &&
Number(value) > 2_147_483_647
)
throw unavailable(
throw configurationFailure(
"open_options_invalid",
`${name} exceeds the JVM's signed 32-bit processor-count range.`,
{ setting: name, value },
);
jvmArguments.push(`${prefix}${value}${suffix}`);
result.push(`${prefix}${value}${suffix}`);
}
return { jar: await realpath(jar), java, jvmArguments };
return result;
};
const inspectJavaRuntime = async (
java: string,
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<void> => {
let stdout: string;
let stderr: string;
try {
({ stdout, stderr } = await execFileOutput(java, ["--list-modules"], {
env: { ...environment },
timeout: JAVA_MODULE_LISTING_TIMEOUT_MS,
maxBuffer: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
}));
} catch (cause) {
if (signal?.aborted === true) throw cause;
const output = execFileOutputFailure(cause);
if (output === undefined) throw cause;
const diagnostics = {
java_executable: java,
phase: "module-inspection",
exit_code: typeof output.code === "number" ? output.code : null,
process_error_code:
typeof output.code === "string" ? output.code : null,
signal: output.signal,
killed: output.killed,
stdout: output.stdout,
stderr: output.stderr,
output_truncated: output.outputTruncated,
} satisfies Readonly<Record<string, JsonValue>>;
if (output.outputTruncated)
throw configurationFailure(
"version_unresolved",
`The selected Java launcher ${java} exceeded the ${JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES / (1024 * 1024)} MiB output budget while listing modules. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; ` +
"java --list-modules should return a concise module listing.",
{
...diagnostics,
failure: "output_limit",
output_limit_bytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
},
cause,
);
if (output.killed)
throw configurationFailure(
"version_unresolved",
`The selected Java launcher ${java} was terminated or did not finish the module-listing probe within ${JAVA_MODULE_LISTING_TIMEOUT_MS / 1000} seconds. Inspect the launcher and its startup responsiveness, then verify it returns compiler and version modules with java --list-modules.`,
{
...diagnostics,
failure: "probe_terminated",
deadline_ms: JAVA_MODULE_LISTING_TIMEOUT_MS,
},
cause,
);
throw configurationFailure(
"runtime_missing",
`Cannot inspect selected Java runtime ${java}: ${output.stderr.trim() || errorText(cause)}. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules.`,
diagnostics,
cause,
);
}
const compiler = /^jdk\.compiler(?:@|$)/mu.test(stdout);
if (!compiler)
throw configurationFailure(
"runtime_missing",
`Selected Java runtime ${java} does not provide jdk.compiler. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules. A JRE cannot compile REA's source bridge.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: false,
stdout,
stderr,
output_truncated: false,
},
);
const major = /^java\.base@(\d+)(?:[.+_-]|$)/mu.exec(stdout)?.[1];
if (major === undefined)
throw configurationFailure(
"version_unresolved",
`The selected Java launcher ${java} did not report a parseable java.base version in its module listing. Inspect the launcher output and verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: null,
stdout,
stderr,
output_truncated: false,
},
);
if (Number(major) < 17)
throw configurationFailure(
"unsupported_version",
`Selected Java runtime ${java} must be JDK 17 or newer and include jdk.compiler; verify it with java --list-modules.`,
{
java_executable: java,
phase: "module-inspection",
jdk_compiler: true,
java_major: major ?? null,
stdout,
stderr,
output_truncated: false,
},
);
};
const configurationFailure = (
code: ConfigurationFailureCode,
message: string,
diagnostics: Readonly<Record<string, JsonValue>>,
cause?: unknown,
): JadxConfigurationFailure =>
new JadxConfigurationFailure(
code,
message,
diagnostics,
cause === undefined ? undefined : { cause },
);
const errorText = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);

View File

@ -23,7 +23,10 @@ import {
hashAndroidFile,
snapshotAndroidTarget,
} from "./AndroidTargetSnapshot.js";
import { resolveJadxConfiguration } from "./JadxConfiguration.js";
import {
inspectJadxAvailability,
resolveJadxConfiguration,
} from "./JadxConfiguration.js";
import type { JadxLauncher } from "./JadxMcpTransport.js";
import { JadxSession } from "./JadxSession.js";
@ -175,6 +178,11 @@ export class JadxProvider implements AndroidAnalysisPort {
readonly launcher?: JadxLauncher,
) {}
/** Report actual local prerequisites without starting JADX or opening a target. */
inspectAvailability(signal?: AbortSignal) {
return inspectJadxAvailability(this.environment, signal);
}
/** Serialize expensive work and bind both raw and normalized results to the APK. */
async execute(
target: BinaryTarget,
@ -267,6 +275,7 @@ export class JadxProvider implements AndroidAnalysisPort {
const configuration = await resolveJadxConfiguration(
this.environment,
request.operation,
signal,
);
signal.throwIfAborted();
const jarHash = await hashAndroidFile(configuration.jar);

View File

@ -40,6 +40,10 @@ const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
v8InspectorObservationEnabled: true,
};
const availableAndroidAvailability: NonNullable<
Parameters<typeof buildCapabilityInventory>[1]["androidAnalysisAvailability"]
> = { status: "available", code: null, reason: null, diagnostics: {} };
const status = (
options: {
readonly open?: boolean;
@ -110,7 +114,7 @@ describe("capability inventory: provider status", () => {
expect(
entry("inspect_android_package", status(), {
processCaptureEnabled: true,
androidAnalysisEnabled: true,
androidAnalysisAvailability: availableAndroidAvailability,
}),
).toMatchObject({
available: true,

View File

@ -14,6 +14,7 @@ import {
} from "../contracts/toolOutputSchemaPrimitives.js";
import type { ToolKind } from "../contracts/toolContractTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ProviderAvailability } from "./AnalysisProvider.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import {
clientRequirementsFor,
@ -38,7 +39,7 @@ export type AvailabilityPolicy = {
readonly recordedCrashEnabled?: boolean;
readonly firmwareInspectionEnabled?: boolean;
readonly firmwareExtractionEnabled?: boolean;
readonly androidAnalysisEnabled?: boolean;
readonly androidAnalysisAvailability?: ProviderAvailability;
readonly javascriptRecoveryEnabled?: boolean;
readonly webModuleResolutionEnabled?: boolean;
readonly browserObservationEnabled?: boolean;
@ -321,12 +322,16 @@ const workflowAvailabilityFor = ({
};
}
if (kind === "android-provider")
return policy.androidAnalysisEnabled === true
return policy.androidAnalysisAvailability?.status === "available"
? { reason: "available", remediation: null }
: {
reason: "provider_missing",
reason:
policy.androidAnalysisAvailability?.code === "unsupported_host"
? "unsupported_host"
: "provider_missing",
remediation:
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide Java on Linux or macOS. Only Linux has real-provider verification.",
policy.androidAnalysisAvailability?.reason ??
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
};
const browser = browserProviderAvailability(name, kind, policy);
if (browser !== null) return browser;

View File

@ -6,9 +6,12 @@ import type { AndroidRequest } from "../../domain/android/androidAnalysis.js";
import type { BinaryTarget } from "../../domain/binaryTarget.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Result } from "../../domain/result.js";
import type { ProviderAvailability } from "../AnalysisProvider.js";
/** Static Android inspection boundary; implementation details belong to the provider. */
export interface AndroidAnalysisPort {
/** Inspect the selected JAR and Java runtime without opening an APK. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
/** Cancel active work and join cleanup of any retained provider resources. */
close(): Promise<void>;
execute(

View File

@ -8,6 +8,38 @@ type ExecFileOutputOptions = Omit<
"encoding" | "maxBuffer"
> & { readonly maxBuffer?: number };
/** Captured subprocess output read from an execFileOutput rejection. */
export interface ExecFileOutputFailure {
readonly stdout: string;
readonly stderr: string;
readonly code: string | number | null;
readonly signal: string | null;
readonly killed: boolean;
readonly outputTruncated: boolean;
}
/** Read captured output metadata from a failed execFileOutput invocation. */
export const execFileOutputFailure = (
cause: unknown,
): ExecFileOutputFailure | undefined => {
if (!(cause instanceof Error)) return undefined;
const stdout = Reflect.get(cause, "stdout");
const stderr = Reflect.get(cause, "stderr");
const code = Reflect.get(cause, "code");
const signal = Reflect.get(cause, "signal");
const killed = Reflect.get(cause, "killed");
if (typeof stdout !== "string" || typeof stderr !== "string")
return undefined;
return {
stdout,
stderr,
code: typeof code === "string" || typeof code === "number" ? code : null,
signal: typeof signal === "string" ? signal : null,
killed: killed === true,
outputTruncated: code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
};
};
/** Run a shell-free command while capturing its complete UTF-8 output. */
export const execFileOutput = (
command: string,

View File

@ -122,10 +122,6 @@ const installSessionToolAvailability = (
options.firmwareAnalysis !== undefined ||
(process.platform === "linux" &&
isAbsolute(process.env.REA_UNBLOB_COMMAND ?? "")),
androidAnalysisEnabled:
options.androidAnalysis !== undefined ||
((process.platform === "linux" || process.platform === "darwin") &&
isAbsolute(process.env.REA_JADX_MCP_JAR ?? "")),
browserObservationEnabled: options.browserObservation !== undefined,
javascriptRecoveryEnabled:
options.javascriptRecovery !== undefined ||
@ -176,6 +172,7 @@ export const createServer = (
const startedAt = new Date().toISOString();
const logger = options.logger ?? silentLogger;
const server = createMcpServer(session);
const android = options.androidAnalysis ?? createAndroidAnalysisProvider();
const availability = installSessionToolAvailability(server, session, options);
const toolLogger = logger.child({ layer: "server" });
const {
@ -196,7 +193,6 @@ export const createServer = (
recordEvidenceWithUnknown,
};
registerBinaryAnalysisTools(toolContext);
const android = options.androidAnalysis ?? createAndroidAnalysisProvider();
const previousOnclose = server.server.onclose;
server.server.onclose = () => {
previousOnclose?.();
@ -285,6 +281,8 @@ export const createServer = (
...(availability === undefined
? {}
: { availabilityPolicy: availability.policy }),
androidAnalysisAvailability: (signal) =>
android.inspectAvailability(signal),
startedAt,
});
}

View File

@ -1,6 +1,8 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/binary/BinarySession.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { buildCapabilityInventory } from "../application/CapabilityInventory.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
@ -10,6 +12,7 @@ import { mcpClientMetadata } from "./mcpClientMetadata.js";
import type { SessionAvailability } from "./sessionAvailabilityPolicy.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { toCallToolResult } from "./toolResult.js";
import { err } from "../domain/result.js";
type ToolAvailability = ReturnType<typeof buildCapabilityInventory>[number];
@ -20,24 +23,49 @@ export interface SessionStatusToolOptions {
readonly contract: ReturnType<typeof toolContract<"binary_session">>;
readonly startedAt: string;
readonly availabilityPolicy: () => SessionAvailability;
readonly androidAnalysisAvailability: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
}
/** Register the read-only provider and target status operation. */
export const registerSessionStatusTool = (
options: SessionStatusToolOptions,
): void => {
const { server, session, contract, startedAt, availabilityPolicy } = options;
const {
server,
session,
contract,
startedAt,
availabilityPolicy,
androidAnalysisAvailability,
} = options;
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input, context) => {
async (input, context) => {
const { client, clientFeatures, protocolVersion } =
mcpClientMetadata(context);
let androidAvailability: ProviderAvailability;
try {
androidAvailability = await androidAnalysisAvailability(
context.mcpReq.signal,
);
} catch (cause) {
if (!context.mcpReq.signal.aborted) throw cause;
return toCallToolResult(
err(new AnalysisCancelledError("binary_session")),
contract,
);
}
const status = session.status();
const statusObject = jsonObjectSchema.parse(status);
const toolAvailability = buildCapabilityInventory(
status,
availabilityPolicy(),
{
...availabilityPolicy(),
androidAnalysisAvailability: androidAvailability,
},
clientFeatures,
);
const serverIdentity = createServerIdentity({

View File

@ -17,6 +17,7 @@ import type { Evidence } from "../domain/evidence.js";
import type { ProcessCapture } from "../domain/process/processCapture.js";
import { ok, type Result } from "../domain/result.js";
import type { Logger } from "../logger.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool.js";
import { registerBundleComparisonTool } from "./registerBundleComparisonTool.js";
@ -136,13 +137,22 @@ export interface LifecycleToolRegistration {
readonly statusContract: ReturnType<typeof toolContract<"binary_session">>;
readonly startedAt: string;
readonly availabilityPolicy: () => SessionAvailability;
readonly androidAnalysisAvailability: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
}
const registerLifecycleTools = (
registration: LifecycleToolRegistration,
): void => {
const { server, session, statusContract, startedAt, availabilityPolicy } =
registration;
const {
server,
session,
statusContract,
startedAt,
availabilityPolicy,
androidAnalysisAvailability,
} = registration;
registerOpenLifecycleTool(registration);
registerCloseLifecycleTool(registration);
registerSessionStatusTool({
@ -151,6 +161,7 @@ const registerLifecycleTools = (
contract: statusContract,
startedAt,
availabilityPolicy,
androidAnalysisAvailability,
});
};
@ -206,6 +217,9 @@ const registerOpenLifecycleTool = ({
export interface SessionToolOptions {
readonly startedAt?: string;
readonly availabilityPolicy?: () => SessionAvailability;
readonly androidAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
}
const registerContextTools = (
@ -265,6 +279,8 @@ export const registerSessionTools = (
options.availabilityPolicy,
{},
),
androidAnalysisAvailability:
options.androidAnalysisAvailability ?? missingAndroidAvailability,
});
registerEvidenceTools({
server,
@ -287,3 +303,11 @@ export const registerSessionTools = (
registerUnknownTools({ server, session });
registerContextTools(server, session);
};
const missingAndroidAvailability = async (): Promise<ProviderAvailability> => ({
status: "unavailable",
code: "not_configured",
reason:
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
diagnostics: { configured: false },
});

View File

@ -16,7 +16,6 @@ export interface SessionAvailabilityDefaults {
| "electronObservationEnabled"
| "electronAutomationEnabled"
| "v8InspectorObservationEnabled"
| "androidAnalysisEnabled"
| "javascriptRecoveryEnabled"
| "webModuleResolutionEnabled"
| "binaryLayoutEnabled"
@ -45,8 +44,6 @@ export const sessionAvailabilityPolicy = (
defaults.optionalFeatures?.firmwareInspectionEnabled ?? false,
firmwareExtractionEnabled:
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
androidAnalysisEnabled:
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
javascriptRecoveryEnabled:
defaults.optionalFeatures?.javascriptRecoveryEnabled ?? false,
webModuleResolutionEnabled:

View File

@ -1,9 +1,13 @@
import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it as test } from "vitest";
import { resolveJadxConfiguration } from "../../../src/android/JadxConfiguration.js";
import {
inspectJadxAvailability,
resolveJadxConfiguration,
} from "../../../src/android/JadxConfiguration.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const it = test.skipIf(process.platform === "win32");
@ -25,31 +29,33 @@ it("rejects an explicit JAVA_HOME without an executable java binary", async () =
});
});
it("accepts a verified explicit java binary and canonicalizes the JAR", async () => {
it("accepts a full JDK and canonicalizes the selected JAR", async () => {
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = join(root, "jdk");
const java = join(javaHome, "bin", "java");
await mkdir(join(javaHome, "bin"), { recursive: true });
const javaHome = await createFakeJdk(root);
await writeFile(jar, "fixture jar");
await writeFile(java, "#!/bin/sh\n");
await chmod(java, 0o700);
await expect(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
),
).resolves.toEqual({ jar: await realpath(jar), java, jvmArguments: [] });
).resolves.toEqual({
jar: await realpath(jar),
java: join(javaHome, "bin", "java"),
jvmArguments: [],
});
});
it("configures heap and visible processors independently without forcing either", async () => {
const root = await createTestTempDirectory("rea-jadx-config-");
const jar = join(root, "engine.jar");
const javaHome = await createFakeJdk(root);
await writeFile(jar, "fixture jar");
const configuration = await resolveJadxConfiguration(
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
REA_JADX_HEAP_MIB: "8192",
REA_JADX_ACTIVE_PROCESSOR_COUNT: "4",
},
@ -61,7 +67,11 @@ it("configures heap and visible processors independently without forcing either"
]);
await expect(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, REA_JADX_ACTIVE_PROCESSOR_COUNT: "2147483648" },
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
REA_JADX_ACTIVE_PROCESSOR_COUNT: "2147483648",
},
"inspect_android_package",
),
).rejects.toMatchObject({
@ -72,7 +82,11 @@ it("configures heap and visible processors independently without forcing either"
for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"])
await expect(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, [name]: value },
{
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
[name]: value,
},
"inspect_android_package",
),
).rejects.toMatchObject({
@ -80,3 +94,213 @@ it("configures heap and visible processors independently without forcing either"
reason: expect.stringContaining(name),
});
});
it("distinguishes a runnable JRE from a missing Java executable", async () => {
const root = await createTestTempDirectory("rea-jadx-runtime-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeFile(jar, "fixture jar");
const jreHome = await createFakeJdk(root, false);
await expect(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: jreHome },
"inspect_android_package",
),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("does not provide jdk.compiler"),
userMessage: expect.stringContaining("A JRE cannot compile"),
capturedOutput: {
stdout: "java.base@21\n",
stderr: "",
truncated: false,
},
});
const emptyPath = join(root, "empty-path");
await mkdir(emptyPath);
const availability = await inspectJadxAvailability({
REA_JADX_MCP_JAR: jar,
PATH: emptyPath,
});
expect(availability).toMatchObject({
status: "unavailable",
code: "runtime_missing",
reason: expect.stringContaining("spawn java ENOENT"),
diagnostics: {
java_executable: "java",
phase: "module-inspection",
exit_code: null,
process_error_code: "ENOENT",
},
});
});
it("preserves exact Java probe output and marks max-buffer capture as truncated", async () => {
const root = await createTestTempDirectory("rea-jadx-output-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = join(root, "fake-jdk");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'process.stdout.write("x".repeat(2 * 1024 * 1024))'\n`,
);
await chmod(java, 0o700);
const failure = await resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
"inspect_android_package",
).then(
() => undefined,
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(AnalysisCapabilityUnavailableError);
if (!(failure instanceof AnalysisCapabilityUnavailableError)) return;
expect(failure.reason).toContain("exceeded the 1 MiB output budget");
expect(failure.reason).not.toContain("Select a full JDK");
expect(failure.capturedOutput).toMatchObject({
stdout: expect.stringMatching(/^x/u),
stderr: "",
truncated: true,
});
expect(failure.capturedOutput?.stdout.length).toBeLessThan(2 * 1024 * 1024);
await expect(
inspectJadxAvailability({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
}),
).resolves.toMatchObject({
status: "unavailable",
code: "version_unresolved",
diagnostics: {
exit_code: null,
process_error_code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
failure: "output_limit",
output_truncated: true,
},
});
});
it("distinguishes an unreported Java version from an unsupported old version", async () => {
const root = await createTestTempDirectory("rea-jadx-version-listing-");
const jar = join(root, "jadx-headless-mcp.jar");
await writeFile(jar, "fixture jar");
const unresolvedHome = await createFakeJdk(root);
await writeFile(
join(unresolvedHome, "bin", "java"),
"#!/bin/sh\nprintf 'jdk.compiler@21\\n'\n",
);
await chmod(join(unresolvedHome, "bin", "java"), 0o700);
await expect(
inspectJadxAvailability({ REA_JADX_MCP_JAR: jar, JAVA_HOME: unresolvedHome }),
).resolves.toMatchObject({
status: "unavailable",
code: "version_unresolved",
reason: expect.stringContaining("did not report a parseable java.base version"),
diagnostics: { stdout: "jdk.compiler@21\n", java_major: null },
});
const oldHome = await createFakeJdk(root);
await writeFile(
join(oldHome, "bin", "java"),
"#!/bin/sh\nprintf 'java.base@16\\njdk.compiler@16\\n'\n",
);
await chmod(join(oldHome, "bin", "java"), 0o700);
await expect(
resolveJadxConfiguration(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: oldHome },
"inspect_android_package",
),
).rejects.toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
reason: expect.stringContaining("must be JDK 17 or newer"),
});
});
it("cancels an in-flight Java readiness probe", async () => {
const root = await createTestTempDirectory("rea-jadx-cancel-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = join(root, "fake-jdk");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
const marker = join(root, "java-probe-started");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
);
await chmod(java, 0o700);
const controller = new AbortController();
const pending = inspectJadxAvailability(
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
controller.signal,
);
try {
await expect
.poll(() =>
readFile(marker, "utf8").then(
() => true,
() => false,
),
)
.toBe(true);
controller.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
} finally {
controller.abort();
await pending.catch(() => undefined);
}
});
it("reports a killed Java probe as unresolved instead of missing runtime", async () => {
const root = await createTestTempDirectory("rea-jadx-timeout-");
const jar = join(root, "jadx-headless-mcp.jar");
const javaHome = join(root, "fake-jdk");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
const marker = join(root, "java-probe-started");
await mkdir(bin, { recursive: true });
await writeFile(jar, "fixture jar");
await writeFile(
java,
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
);
await chmod(java, 0o700);
const availability = await inspectJadxAvailability({
REA_JADX_MCP_JAR: jar,
JAVA_HOME: javaHome,
});
expect(availability).toMatchObject({
status: "unavailable",
code: "version_unresolved",
reason: expect.stringContaining("within 10 seconds"),
diagnostics: {
java_executable: java,
failure: "probe_terminated",
killed: true,
deadline_ms: 10_000,
},
});
expect(availability.reason).not.toContain("Select a full JDK");
});
const createFakeJdk = async (
root: string,
compiler = true,
): Promise<string> => {
const javaHome = join(root, compiler ? "fake-jdk" : "fake-jre");
const bin = join(javaHome, "bin");
const java = join(bin, "java");
await mkdir(bin, { recursive: true });
await writeFile(
java,
`#!/bin/sh\nprintf 'java.base@21${compiler ? "\\njdk.compiler@21" : ""}\\n'\n`,
);
await chmod(java, 0o700);
return javaHome;
};

View File

@ -15,7 +15,9 @@ import type {
AnalysisClient,
AnalysisProvider,
CapabilityDescriptor,
ProviderAvailability,
} from "../../../src/application/AnalysisProvider.js";
import type { AndroidAnalysisPort } from "../../../src/application/android/AndroidAnalysisPort.js";
import { observed as ok } from "../../fixtures/analysisExecution.js";
import { createServer } from "../../../src/server/createServer.js";
import { silentLogger } from "../../../src/logger.js";
@ -149,6 +151,49 @@ describe("MCP snapshot lifecycle ordering", () => {
});
describe("target-free MCP lifecycle", () => {
it("takes the session snapshot after Android readiness inspection completes", async () => {
directory = await createTestTempDirectory("rea-mcp-status-readiness-");
const targetPath = join(directory, "target.hop");
await writeFile(targetPath, "fixture target");
const entered = createDeferred<void>();
const availability = createDeferred<ProviderAvailability>();
const closed: string[] = [];
const session = createTestBinarySession(provider(closed));
const androidAnalysis: AndroidAnalysisPort = {
inspectAvailability: () => {
entered.resolve();
return availability.promise;
},
close: () => Promise.resolve(),
execute: () => Promise.resolve(ok(null)),
};
const server = createServer(session, session, {
androidAnalysis,
logger: silentLogger,
});
const mcp = new Client({ name: "status-readiness", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server);
await server.connect(serverTransport);
await mcp.connect(clientTransport);
const status = mcp.callTool({ name: "binary_session", arguments: {} });
await entered.promise;
expect((await session.open(targetPath)).ok).toBe(true);
availability.resolve({
status: "available",
code: null,
reason: null,
diagnostics: { configured: true },
});
expect(structured(await status).result).toMatchObject({
open: true,
path: targetPath,
});
});
it("reopens replaced content at one canonical path through MCP", async () => {
directory = await createTestTempDirectory("rea-mcp-replaced-target-");
const targetPath = join(directory, "mutable.hop");

View File

@ -1,4 +1,4 @@
import { access, rm, writeFile } from "node:fs/promises";
import { access, chmod, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished } from "vitest";
@ -20,6 +20,15 @@ export const createJadxProtocolFixture = async (mode = "normal") => {
const root = await createTestTempDirectory("rea-android-boundary-");
const apk = join(root, "fixture.apk");
const jar = join(root, "fixture.jar");
const javaHome = join(root, "jdk");
const javaBin = join(javaHome, "bin");
const java = join(javaBin, "java");
await mkdir(javaBin, { recursive: true });
await writeFile(
java,
`#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") console.log("java.base@21.0.0\\njdk.compiler@21.0.0");\n`,
);
await chmod(java, 0o755);
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
await writeFile(jar, "synthetic engine bytes; not a Java archive");
const launches: {
@ -32,6 +41,7 @@ export const createJadxProtocolFixture = async (mode = "normal") => {
}[] = [];
const environment: NodeJS.ProcessEnv = {
...process.env,
JAVA_HOME: javaHome,
REA_JADX_MCP_JAR: jar,
};
const provider = createAndroidAnalysisProvider(