From 1a1121c61f3aadfb8541bbbc143560ee6100d25e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:01:46 +0800 Subject: [PATCH] fix(android): retain selected Java across workspace launches --- src/android/JadxConfiguration.ts | 48 ++++++--- src/android/JadxProvider.ts | 3 + .../android/jadxConfiguration.test.ts | 6 +- .../boundary/process/jadxIntegration.test.ts | 99 ++++++++++++++++++- 4 files changed, 135 insertions(+), 21 deletions(-) diff --git a/src/android/JadxConfiguration.ts b/src/android/JadxConfiguration.ts index f35aaa80f4..c51bd113f6 100644 --- a/src/android/JadxConfiguration.ts +++ b/src/android/JadxConfiguration.ts @@ -1,4 +1,7 @@ -import { appendCallerPath } from "../filesystem/callerPath.js"; +import { + absoluteCallerPath, + appendCallerPath, +} from "../filesystem/callerPath.js"; import { DEFAULT_JADX_OPERATION_TIMEOUT_MS } from "../domain/analysisResourceLimits.js"; import { jadxOperationTimeoutGuidance, @@ -600,30 +603,43 @@ const resolveJava = async ( ): Promise => { const executable = process.platform === "win32" ? "java.exe" : "java"; if (environment.JAVA_HOME === undefined) { - if (process.platform !== "win32") return executable; - const path = - Object.entries(environment).find( - ([key]) => key.toLowerCase() === "path", - )?.[1] ?? ""; - for (const directory of path - .split(delimiter) - .filter((entry) => entry !== "")) { + const windows = process.platform === "win32"; + const path = windows + ? (Object.entries(environment).find( + ([key]) => key.toLowerCase() === "path", + )?.[1] ?? "") + : (environment.PATH ?? "/usr/bin:/bin"); + // Resolve in the caller's directory once. The module probe and the engine + // run in different directories, and the selected executable also owns the + // retained session's cache identity. Empty POSIX PATH components select cwd. + const directories = path.split(delimiter); + for (const directory of windows + ? directories.filter((entry) => entry !== "") + : directories) { throwIfJadxInterrupted(signal); // Windows PATH commonly quotes individual entries containing spaces. const selected = - directory.startsWith('"') && directory.endsWith('"') + windows && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory; - const candidate = resolve(selected, executable); + const candidate = windows + ? resolve(selected, executable) + : absoluteCallerPath(appendCallerPath(selected, executable)); try { - await access(candidate, constants.R_OK | constants.X_OK); - if ((await stat(candidate)).isFile()) return await realpath(candidate); + await access( + candidate, + windows ? constants.R_OK | constants.X_OK : constants.X_OK, + ); + if ((await stat(candidate)).isFile()) + return windows ? await realpath(candidate) : candidate; } catch (cause: unknown) { if ( typeof cause === "object" && cause !== null && "code" in cause && - (cause.code === "ENOENT" || cause.code === "ENOTDIR") + (cause.code === "ENOENT" || + cause.code === "ENOTDIR" || + (!windows && (cause.code === "EACCES" || cause.code === "EPERM"))) ) continue; throw configurationFailure( @@ -637,8 +653,8 @@ const resolveJava = async ( } throw configurationFailure( "runtime_missing", - "Cannot find java.exe on the selected Windows PATH. Select a full JDK with JAVA_HOME or PATH, then verify it with java --list-modules. REA does not install Java.", - { java_executable: executable }, + `Cannot find an executable ${executable} on the selected ${windows ? "Windows " : ""}PATH. Select a full JDK with JAVA_HOME or PATH, then verify it with java --list-modules. REA does not install Java.`, + { java_executable: executable, phase: "path-lookup" }, "java_runtime", ); } diff --git a/src/android/JadxProvider.ts b/src/android/JadxProvider.ts index 69b8e61dc9..0a07217540 100644 --- a/src/android/JadxProvider.ts +++ b/src/android/JadxProvider.ts @@ -579,6 +579,9 @@ export class JadxProvider implements AndroidAnalysisPort { session = new JadxSession( { command: configuration.java, + // Java PATH shims may execute an interpreter or replace themselves. + // Match the prerequisite probe's run-token ownership policy. + expectedCommand: null, arguments: [ ...configuration.jvmArguments, "--class-path", diff --git a/tests/boundary/android/jadxConfiguration.test.ts b/tests/boundary/android/jadxConfiguration.test.ts index 3cc425b1b2..e3c49f02b1 100644 --- a/tests/boundary/android/jadxConfiguration.test.ts +++ b/tests/boundary/android/jadxConfiguration.test.ts @@ -244,12 +244,10 @@ it("distinguishes a runnable JRE from a missing Java executable", async () => { expect(availability).toMatchObject({ status: "unavailable", code: "runtime_missing", - reason: expect.stringContaining("spawn java ENOENT"), + reason: expect.stringContaining("Cannot find an executable java"), diagnostics: { java_executable: "java", - phase: "module-inspection", - exit_code: null, - process_error_code: "ENOENT", + phase: "path-lookup", }, }); }); diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index a1e8a8cf10..0fffb195bc 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -1,6 +1,15 @@ import { SdkErrorCode } from "@modelcontextprotocol/client"; import { expect, it as test, onTestFinished, vi } from "vitest"; -import { access, readFile, truncate, writeFile } from "node:fs/promises"; +import { + access, + chmod, + mkdir, + readFile, + symlink, + truncate, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; @@ -20,10 +29,98 @@ import { verifyJadxFixtureCleanup as verifyCleanup, } from "../../fixtures/android/jadx.js"; import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; +import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const it = test.skipIf(process.platform === "win32"); +it.each(["alias/../bin", ""])( + "keeps PATH=%j Java tied to each caller directory across private-workspace launches", + async (searchPath) => { + const root = await createTestTempDirectory("rea-jadx-relative-java-"); + const apk = join(root, "fixture.apk"), + jar = join(root, "fixture.jar"); + await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]); + await writeJadxJarInventory(jar); + const fixture = new URL( + "../../fixtures/android/jadx-mcp.mjs", + import.meta.url, + ); + const command = [process.execPath, fileURLToPath(fixture)] + .map((value) => `'${value.replaceAll("'", `'"'"'`)}'`) + .join(" "); + const shim = join(root, "java-shim"); + await writeFile( + shim, + `#!/bin/sh\nselected="\${0%/*}"\n[ -f "$selected/runtime-marker" ] || exit 19\npwd >> "$selected/java.log"\nif [ "$1" = "--list-modules" ]; then\n printf 'java.base@21.0.0\\njdk.compiler@21.0.0\\n'\nelse\n exec ${command}\nfi\n`, + ); + await chmod(shim, 0o755); + const directories = [join(root, "first"), join(root, "second")]; + for (const directory of directories) { + await mkdir(join(directory, "real/deep"), { recursive: true }); + await mkdir(join(directory, "real/bin")); + await symlink(join(directory, "real/deep"), join(directory, "alias")); + await symlink(shim, join(directory, "real/bin/java")); + await writeFile(join(directory, "real/bin/runtime-marker"), directory); + await symlink(shim, join(directory, "java")); + await writeFile(join(directory, "runtime-marker"), directory); + } + const logPath = (directory: string) => + join(directory, searchPath === "" ? "java.log" : "real/bin/java.log"); + const provider = new JadxProvider({ + ...process.env, + JAVA_HOME: undefined, + PATH: searchPath, + REA_JADX_MCP_JAR: jar, + REA_JADX_OPERATION_TIMEOUT_MS: "5000", + REA_FAKE_JADX_CLASS_COUNT: "0", + }); + const service = new AndroidAnalysisService(provider); + const previousCwd = process.cwd(); + try { + process.chdir(directories[0]!); + const availability = await provider.inspectAvailability(); + expect(availability, JSON.stringify(availability)).toMatchObject({ + status: "available", + }); + const first = await service.execute("inspect_android_package", { + path: apk, + }); + if (!first.ok) throw first.error; + const firstStarts = (await readFile(logPath(directories[0]!), "utf8")) + .trim() + .split("\n"); + expect(firstStarts).toHaveLength(3); + const firstWorkspace = firstStarts.at(-1)!; + expect(firstWorkspace).not.toBe(directories[0]); + process.chdir(directories[1]!); + const second = await service.execute("inspect_android_package", { + path: apk, + }); + if (!second.ok) throw second.error; + expect(second.value.subject?.digest.sha256).toBe( + first.value.subject?.digest.sha256, + ); + const secondStarts = (await readFile(logPath(directories[1]!), "utf8")) + .trim() + .split("\n"); + expect(secondStarts).toHaveLength(2); + const secondWorkspace = secondStarts.at(-1)!; + expect(secondWorkspace).not.toBe(firstWorkspace); + await expect(access(firstWorkspace)).rejects.toMatchObject({ + code: "ENOENT", + }); + await provider.close(); + await expect(access(secondWorkspace)).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + process.chdir(previousCwd); + await provider.close(); + } + }, +); + // Select the provider's source hash after resolver and engine admission complete. const failInputHashCloses = (path: string, failures: number) => { const originalHash = stableHash.hashStableFile;