diff --git a/bridge/android/ReaJadxBridge.java b/bridge/android/ReaJadxBridge.java index 51ac0f7032..aa49606ee6 100644 --- a/bridge/android/ReaJadxBridge.java +++ b/bridge/android/ReaJadxBridge.java @@ -25,6 +25,11 @@ class ReaJadxBridge { private record MethodSnapshot(String name, String shortId, Map summary) {} private record ClassSnapshot(JavaClass node, List methods, Map summary) {} + private static final class InputFailure extends IllegalArgumentException { + final String field; + InputFailure(String field, String message) { super(message); this.field = field; } + } + private ReaJadxBridge(SessionHolder holder) { this.holder = holder; } private static CallToolResult text(String value, boolean failed) { @@ -100,8 +105,9 @@ class ReaJadxBridge { } String name = string(args, "class_name"); List candidates = classes.get(name); - if (candidates == null) throw new IllegalArgumentException("Exact class not found: " + name); - if (candidates.size() != 1) throw new IllegalArgumentException("Ambiguous metadata class identity: " + name + if (candidates == null) throw new InputFailure("class_name", "Exact class not found: " + name + + "; use search_android_classes to discover fully qualified names."); + if (candidates.size() != 1) throw new InputFailure("class_name", "Ambiguous metadata class identity: " + name + "; raw identities: " + candidates.stream().map(candidate -> candidate.node().getRawName()).toList()); return candidates.get(0); } @@ -109,7 +115,8 @@ class ReaJadxBridge { private List methods(ClassSnapshot cls, JsonObject args) { String name = string(args, "method_name"); List result = cls.methods().stream().filter(method -> method.name().equals(name)).toList(); - if (result.isEmpty()) throw new IllegalArgumentException("Method not found: " + name); + if (result.isEmpty()) throw new InputFailure("method_name", "Method not found: " + name + + "; use inspect_android_class to discover available methods."); return result; } @@ -141,7 +148,7 @@ class ReaJadxBridge { if (name.equals("get_method_body")) { List overloads = methods(cls, args); int index = integer(args, "overload_index", 0); - if (index >= overloads.size()) throw new IllegalArgumentException("overload_index out of range: " + index); + if (index >= overloads.size()) throw new InputFailure("overload_index", "overload_index out of range: " + index); JavaMethod method = method(cls, overloads.get(index)); JadxSession.SmartCode code = current.getMethodBodySmart(method, current.getMaxSourceBytes(), true); return json(Map.of("class_name", cls.summary().get("full_name"), @@ -155,7 +162,7 @@ class ReaJadxBridge { if (name.equals("get_xrefs_to_class")) uses = cls.node().getUseIn(); else { List overloads = methods(cls, args); - if (overloads.size() != 1) throw new IllegalArgumentException("Method references require a unique overload"); + if (overloads.size() != 1) throw new InputFailure("method_name", "Method references require a unique overload"); target += "." + overloads.get(0).name(); uses = method(cls, overloads.get(0)).getUseIn(); } @@ -211,6 +218,8 @@ class ReaJadxBridge { tool.getInputSchema().getRequired(), null), description, null, null, null, null, null, null); server.addTool(narrowed, (connection, request, continuation) -> { try { return bridge.handle(name, request.getArguments()); } + catch (InputFailure error) { return text(JSON.toJson(Map.of( + "kind", "invalid-input", "field", error.field, "message", error.getMessage())), true); } catch (IllegalArgumentException error) { return text(error.getMessage(), true); } }); } diff --git a/docs/android-analysis.md b/docs/android-analysis.md index d72ba49776..4251b763a7 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -130,6 +130,13 @@ target/configuration changes, failure, timeout, cancellation, MCP disconnect or server shutdown. One-shot CLI commands always join cleanup before returning. REA never writes to the original APK or launches target code. +If Java cannot start the bridge, REA reports the selected executable and retained +startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use +`java --list-modules` to confirm `jdk.compiler` is present. A runnable JRE alone +cannot compile the bridge. Missing or ambiguous class and method selections are +input failures; discover names with `search_android_classes` and +`inspect_android_class` before correcting the selected argument. + If process cleanup cannot be confirmed, REA retains the workspace and reports its location. That provider instance blocks subsequent engine launches until the caller resolves the reported resources and starts a fresh instance. @@ -177,6 +184,11 @@ export REA_ANDROID_TEST_APK="$PWD/_reference/apk-integration/ApiDemos-debug.apk" npm run verify:android ``` +The lane also uses the selected full JDK's `jlink` to create a disposable JRE +without the compiler module. It checks real Java startup failures, missing +selectors, recovery after failures, and CLI cancellation with owned cleanup. +The original APK and host Java configuration remain unchanged. + The download script verifies fixed SHA-256 values, reuses matching files and refuses to overwrite a different existing file. APK/JAR files live under ignored `_reference/` and are excluded from package contents. The lane exercises actual diff --git a/scripts/verify/android/analysis.mjs b/scripts/verify/android/analysis.mjs index 90ff16e0c6..2c42e7696c 100644 --- a/scripts/verify/android/analysis.mjs +++ b/scripts/verify/android/analysis.mjs @@ -12,6 +12,7 @@ import { JADX_RELEASE } from "../../../dist/android/JadxRelease.js"; import fixture from "../../fixtures/android/apidemos.json" with { type: "json" }; import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs"; import { verifyAndroidMetadata } from "./metadata.mjs"; +import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs"; const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const apk = process.env.REA_ANDROID_TEST_APK; @@ -206,6 +207,15 @@ await verifyAndroidCliCancellation({ repository, execute, }); +await verifyAndroidFailureRecovery({ + java, + entrypoint, + environment, + path, + repository, + execute, + fixture, +}); assert.equal( await hashAndroidFile(apk), fixture.sha256, diff --git a/scripts/verify/android/cli-cancellation.mjs b/scripts/verify/android/cli-cancellation.mjs index 849b0c59a3..bc75e8aa14 100644 --- a/scripts/verify/android/cli-cancellation.mjs +++ b/scripts/verify/android/cli-cancellation.mjs @@ -13,6 +13,9 @@ export const verifyAndroidCliCancellation = async ({ repository, execute, }) => { + // Native identity inspection must be ready before observing the short-lived + // engine; compiling its helper after acquisition can miss the whole request. + await readProcessRunId(process.pid); const child = spawn( process.execPath, [entrypoint, "inspect-android-package", path, "--format", "json"], @@ -86,7 +89,7 @@ export const verifyAndroidCliCancellation = async ({ ]).finally(() => clearTimeout(timer)); assert.equal(result.code, 143, `${stdout}\n${stderr}`); assert.equal(result.signal, null); - assert.equal(JSON.parse(stdout).code, "cancelled"); + assert.equal(JSON.parse(stdout).code, "cancelled", `${stdout}\n${stderr}`); assert.throws(() => process.kill(-group, 0), { code: "ESRCH" }); console.log( "PASS real CLI SIGTERM cancellation and owned Java group cleanup", diff --git a/scripts/verify/android/failure-recovery.mjs b/scripts/verify/android/failure-recovery.mjs new file mode 100644 index 0000000000..8402eab597 --- /dev/null +++ b/scripts/verify/android/failure-recovery.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, isAbsolute, join } from "node:path"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +/** Real unusable Java runtimes and selectors must lead to applicable recovery. */ +export async function verifyAndroidFailureRecovery({ + java, + entrypoint, + environment, + path, + repository, + execute, + fixture, +}) { + const root = await mkdtemp(join(tmpdir(), "rea-android-recovery-")); + try { + let jlink; + for (const candidate of isAbsolute(java) + ? [join(dirname(java), "jlink")] + : (environment.PATH ?? "") + .split(delimiter) + .map((directory) => join(directory, "jlink"))) { + try { + await access(candidate); + jlink = candidate; + break; + } catch (cause) { + if (cause.code !== "ENOENT" && cause.code !== "ENOTDIR") throw cause; + } + } + if (jlink === undefined) + throw new Error( + "Android failure-recovery verification requires the full JDK's jlink via JAVA_HOME or PATH.", + ); + const jre = join(root, "jre"); + await execute(jlink, ["--add-modules", "java.base", "--output", jre], { + timeout: 60_000, + }); + await execute(join(jre, "bin/java"), ["-version"], { timeout: 10_000 }); + const empty = join(root, "empty-path"); + await mkdir(empty); + if (process.platform === "linux") { + // Keep the real ownership prerequisite available while removing Java. + const { stdout } = await execute("/bin/sh", ["-c", "command -v ps"], { + env: environment, + timeout: 10_000, + }); + const ps = stdout.trim(); + assert.ok( + isAbsolute(ps), + "Linux recovery verification requires procps ps", + ); + await symlink(ps, join(empty, "ps")); + } + const withoutJavaHome = { ...environment }; + delete withoutJavaHome.JAVA_HOME; + const environments = [ + { + label: "JRE without compiler", + env: { ...environment, JAVA_HOME: jre }, + observation: "jdk.compiler", + }, + { + label: "missing java", + env: { ...withoutJavaHome, PATH: empty }, + observation: "ENOENT", + }, + ]; + if (process.platform === "darwin") { + // /usr/bin/java either resolves an installed runtime or reports the actual stub failure. + try { + await execute("/usr/bin/java", ["-version"], { timeout: 10_000 }); + } catch { + environments.push({ + label: "macOS Java stub", + env: { ...withoutJavaHome, PATH: "/usr/bin:/bin" }, + observation: "Unable to locate a Java Runtime", + }); + } + } + for (const scenario of environments) { + let failure; + try { + await execute( + process.execPath, + [entrypoint, "inspect-android-package", path, "--json"], + { + cwd: repository, + env: scenario.env, + timeout: 45_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + assert.fail(`${scenario.label} unexpectedly succeeded`); + } catch (cause) { + assert.equal(cause.code, 1); + failure = JSON.parse(cause.stdout); + } + assert.equal(failure.code, "capability_unavailable"); + assert.ok( + JSON.stringify(failure).includes(scenario.observation), + JSON.stringify(failure), + ); + assert.match(failure.message, /full JDK/u); + assert.match(failure.remediation.action, /java --list-modules/u); + assert.doesNotMatch(JSON.stringify(failure), /rea doctor/u); + console.log(`PASS CLI applicable recovery: ${scenario.label}`); + } + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + cwd: repository, + env: environment, + stderr: "pipe", + }); + const client = new Client({ + name: "rea-real-android-failures", + version: "1", + }); + try { + await client.connect(transport); + for (const name of [ + "inspect_android_class", + "inspect_android_method", + "trace_android_references", + ]) { + const response = await client.callTool( + { + name, + arguments: { + path, + class_name: "no.such.Class", + ...(name === "inspect_android_method" + ? { method_name: "missing" } + : {}), + }, + }, + { timeout: 150_000 }, + ); + assert.equal(response.isError, true); + assert.equal(response.structuredContent.error.code, "invalid_request"); + assert.deepEqual( + response.structuredContent.error.details.issues[0].path, + ["class_name"], + ); + assert.match( + response.structuredContent.error.details.issues[0].message, + /search_android_classes/u, + ); + assert.doesNotMatch(JSON.stringify(response), /rea doctor/u); + console.log(`PASS MCP selector correction: ${name}`); + } + const method = await client.callTool( + { + name: "inspect_android_method", + arguments: { + path, + class_name: fixture.class_name, + method_name: "noSuchMethod", + }, + }, + { timeout: 150_000 }, + ); + assert.equal(method.structuredContent.error.code, "invalid_request"); + assert.deepEqual(method.structuredContent.error.details.issues[0].path, [ + "method_name", + ]); + await client.ping(); + const control = await client.callTool( + { name: "inspect_android_package", arguments: { path } }, + { timeout: 150_000 }, + ); + assert.notEqual(control.isError, true, JSON.stringify(control)); + assert.equal( + control.structuredContent.result.package_name, + fixture.package, + ); + console.log("PASS MCP valid package after selector failures"); + } finally { + await client.close(); + await transport.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/src/android/JadxMcpTransport.ts b/src/android/JadxMcpTransport.ts index a2dc084b8a..f99aacff9c 100644 --- a/src/android/JadxMcpTransport.ts +++ b/src/android/JadxMcpTransport.ts @@ -109,6 +109,11 @@ export class JadxMcpTransport implements Transport { return this.#supervisor?.snapshot().stderr.text ?? ""; } + /** Whether the retained startup/operation diagnostics lost bytes. */ + diagnosticsTruncated(): boolean { + return this.#supervisor?.snapshot().diagnosticTruncated ?? false; + } + /** Preserve the failed protocol/resource constraint after the SDK closes requests. */ failureReason(): string | null { return this.#failure; diff --git a/src/android/JadxProtocol.ts b/src/android/JadxProtocol.ts index dd8933d291..804b7ffa7e 100644 --- a/src/android/JadxProtocol.ts +++ b/src/android/JadxProtocol.ts @@ -4,6 +4,12 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import type { AndroidOperation } from "../domain/android/androidAnalysis.js"; const count = z.number().int().nonnegative(); +/** Selector failures authored by REA's bridge, separate from arbitrary engine errors. */ +export const jadxInputFailureSchema = z.strictObject({ + kind: z.literal("invalid-input"), + field: z.enum(["class_name", "method_name", "overload_index"]), + message: z.string().min(1), +}); /** Values observed inside the exact JVM producing the analysis. */ export const jadxRuntimeSchema = z.object({ engine_reported_version: z.string().min(1), diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 9c0be14d93..cde877313b 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -73,6 +73,24 @@ const executionError = (context: { "JADX response violated the pinned upstream protocol", { cause }, ); + if (session !== undefined && !session.initialized()) { + const stderr = session.transport.diagnostics(); + const reason = `Java could not start REA's Android bridge using ${session.transport.options.command}: ${cause instanceof Error ? cause.message : String(cause)}.${stderr.length === 0 ? "" : ` Java diagnostics: ${stderr}`} Select a full JDK including jdk.compiler via JAVA_HOME, or a working java on PATH; verify it with java --list-modules. A JRE cannot compile REA's source bridge. If the JDK works, verify the REA bridge and configured JADX JAR installation.`; + return new AnalysisCapabilityUnavailableError( + "jadx", + request.operation, + reason, + { + cause, + userMessage: reason, + capturedOutput: { + stdout: "", + stderr, + truncated: session.transport.diagnosticsTruncated(), + }, + }, + ); + } return new ProviderAdapterError("jadx", request.operation, { cause, diagnostics: { diff --git a/src/android/JadxSession.ts b/src/android/JadxSession.ts index 87308b1d88..a2436de30b 100644 --- a/src/android/JadxSession.ts +++ b/src/android/JadxSession.ts @@ -6,6 +6,7 @@ import { import type { AndroidRequest } from "../domain/android/androidAnalysis.js"; import { AnalysisCapabilityUnavailableError, + AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTarget.js"; @@ -16,6 +17,7 @@ import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js"; import { JadxMcpTransport, type JadxLauncher } from "./JadxMcpTransport.js"; import { jadxLoadSchema, + jadxInputFailureSchema, jadxRuntimeSchema, parseJadxEnvelope, parseJadxJson, @@ -32,6 +34,7 @@ export class JadxSession { readonly transport: JadxMcpTransport; readonly #client = new Client({ name: "rea-android-adapter", version: "1" }); #loaded: ReturnType | undefined; + #connected = false; constructor( options: Omit, @@ -58,8 +61,10 @@ export class JadxSession { signal.throwIfAborted(); this.transport.beginOperation(); const raw: JsonValue[] = []; - if (this.#loaded === undefined) + if (!this.#connected) { await this.#client.connect(this.transport, { timeout: 30_000 }); + this.#connected = true; + } const server = this.#client.getServerVersion(); if ( server?.name !== JADX_BRIDGE_IDENTITY.name || @@ -126,6 +131,11 @@ export class JadxSession { } } + /** Whether the selected Java runtime established the REA bridge protocol. */ + initialized(): boolean { + return this.#connected; + } + /** Join owned cleanup even if SDK connection teardown itself fails. */ async close(): Promise { try { @@ -156,7 +166,20 @@ export class JadxSession { input, response: jsonValueSchema.parse(response), }); - if (envelope.failed) + if (envelope.failed) { + if (envelope.text.startsWith("{")) { + const failure = jadxInputFailureSchema.safeParse( + parseJadxJson(envelope.text, request.operation), + ); + if (failure.success) + throw new AnalysisInputError(request.operation, undefined, [ + { + path: [failure.data.field], + reason: "invalid_value", + message: failure.data.message, + }, + ]); + } throw new ProviderAdapterError("jadx", request.operation, { diagnostics: { upstream_operation: name, @@ -165,6 +188,7 @@ export class JadxSession { target_sha256: target.sha256, }, }); + } return envelope.text; }; return { diff --git a/src/domain/android/androidAnalysis.ts b/src/domain/android/androidAnalysis.ts index 7d9f2c9766..a692001d9e 100644 --- a/src/domain/android/androidAnalysis.ts +++ b/src/domain/android/androidAnalysis.ts @@ -1,10 +1,10 @@ import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; const target = { - path: z - .string() - .min(1) - .describe("Local APK path; the target is never executed"), + path: localPathStringSchema.describe( + "Local APK path; the target is never executed", + ), }; const classTarget = { ...target,