From d124809950d2e6cafb95f95a9171d44dec3ef5a6 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 04:57:55 -0700 Subject: [PATCH] fix(android): report a rejected relative JADX JAR instead of an unset one (#5862) A supplied non-absolute REA_JADX_MCP_JAR was folded into the unset case, emitting not_configured with jar_configured: false and dropping the caller-selected value. The JAR parser now keeps the two outcomes distinct: unset stays not_configured, while a supplied non-absolute value (including an empty string) fails with open_options_invalid, jar_configured: true, the exact rejected jar_path and the absolute-path requirement. CLI, MCP and doctor derive their output from that one boundary result. Fixes #5844 Co-authored-by: Claude Opus 5.5 --- src/android/JadxConfiguration.ts | 11 +++- tests/acceptance/nonBinaryComposition.test.ts | 55 +++++++++++++++++++ tests/boundary/android/jadxDoctor.test.ts | 25 +++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index bcc93ad236..ddeeb2cb6c 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -544,13 +544,22 @@ const resolveJar = async ( environment: Readonly>, ): Promise => { const jar = environment.REA_JADX_MCP_JAR; - if (jar === undefined || !isAbsolute(jar)) + if (jar === undefined) throw configurationFailure( "not_configured", JADX_JAR_CONFIGURATION_REMEDIATION, { jar_configured: false }, "jar", ); + // A supplied selection stays configured even when rejected, so callers can + // tell it from an omitted setting and see the exact value REA refused. + if (!isAbsolute(jar)) + throw configurationFailure( + "open_options_invalid", + `REA_JADX_MCP_JAR must be an absolute path; received ${JSON.stringify(jar)}. ${JADX_JAR_CONFIGURATION_REMEDIATION}`, + { jar_configured: true, jar_path: jar, path_requirement: "absolute" }, + "jar", + ); try { await access(jar, constants.R_OK); if (!(await stat(jar)).isFile()) diff --git a/tests/acceptance/nonBinaryComposition.test.ts b/tests/acceptance/nonBinaryComposition.test.ts index d2409723f4..87805a5a1e 100644 --- a/tests/acceptance/nonBinaryComposition.test.ts +++ b/tests/acceptance/nonBinaryComposition.test.ts @@ -76,6 +76,61 @@ for (const { jarConfigured, setting } of [ }, ); +cliTest.skipIf(process.platform === "win32")( + "distinguishes a rejected relative REA_JADX_MCP_JAR from an unset one through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-android-relative-jar-"); + const apk = join(root, "fixture.apk"); + await writeOrderedZip(apk, ["AndroidManifest.xml"]); + const run = async (environment: Record) => { + const cliResult = await cli.run({ + arguments: ["inspect-android-package", apk, "--json"], + environment: { REA_LOG_LEVEL: "silent", ...environment }, + timeoutMs: 10_000, + }); + expect(cliResult.exitCode).toBe(1); + const client = await connect({ REA_LOG_LEVEL: "silent", ...environment }); + const response = await client.callTool({ + name: "inspect_android_package", + arguments: { path: apk }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response)).toEqual({ error: cliResult.json }); + return cliResult.json as { + code: string; + message: string; + details: { diagnostics: Record }; + }; + }; + + const relative = "./tools/jadx-headless-mcp.jar"; + const rejected = await run({ REA_JADX_MCP_JAR: relative }); + expect(rejected.code).toBe("capability_unavailable"); + expect(rejected.details.diagnostics).toEqual({ + jar_configured: true, + jar_path: relative, + path_requirement: "absolute", + failure_kind: "jar", + failure_code: "open_options_invalid", + }); + expect(rejected.message).toContain(`received "${relative}"`); + + const empty = await run({ REA_JADX_MCP_JAR: "" }); + expect(empty.details.diagnostics).toMatchObject({ + jar_configured: true, + jar_path: "", + failure_code: "open_options_invalid", + }); + + const unset = await run({}); + expect(unset.details.diagnostics).toEqual({ + jar_configured: false, + failure_kind: "jar", + failure_code: "not_configured", + }); + }, +); + cliTest.skipIf(process.platform !== "linux")( "preserves the missing firmware command reason through both caller paths", async ({ cli }) => { diff --git a/tests/boundary/android/jadxDoctor.test.ts b/tests/boundary/android/jadxDoctor.test.ts index f5d09866be..f48c86d341 100644 --- a/tests/boundary/android/jadxDoctor.test.ts +++ b/tests/boundary/android/jadxDoctor.test.ts @@ -133,6 +133,31 @@ it("publishes no registration environment when REA_JADX_MCP_JAR is unset", async }); }); +it("reports a relative REA_JADX_MCP_JAR as a rejected selection, not an unset one", async () => { + const inspection = await inspectJadxProvider({ + REA_JADX_MCP_JAR: "tools/jadx-headless-mcp.jar", + }); + + expect(inspection).toMatchObject({ + id: "jadx", + configured: true, + available: false, + registrationEnvironment: {}, + checks: [ + { + name: "configuration", + ok: false, + code: "open_options_invalid", + classification: "config_drift", + }, + ], + }); + expect(inspection.checks[0]?.detail).toContain( + 'REA_JADX_MCP_JAR must be an absolute path; received "tools/jadx-headless-mcp.jar".', + ); + expect(inspection.checks[0]?.remediation).toContain("REA_JADX_MCP_JAR"); +}); + it("publishes no registration environment when the configured JAR fails validation", async () => { const root = await createTestTempDirectory("rea-jadx-doctor-invalid-"); const jar = join(root, "engine.jar");