fix(android): retain selected Java across workspace launches

This commit is contained in:
morluto committed 2026-10-26 16:02:14 +08:00
1 parent ce5373e42f
commit 1a1121c61f
4 files changed
+135 -21

No files matched your search

+32 -16
View File
@@ -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 { DEFAULT_JADX_OPERATION_TIMEOUT_MS } from "../domain/analysisResourceLimits.js";
import { import {
jadxOperationTimeoutGuidance, jadxOperationTimeoutGuidance,
@@ -600,30 +603,43 @@ const resolveJava = async (
): Promise<string> => { ): Promise<string> => {
const executable = process.platform === "win32" ? "java.exe" : "java"; const executable = process.platform === "win32" ? "java.exe" : "java";
if (environment.JAVA_HOME === undefined) { if (environment.JAVA_HOME === undefined) {
if (process.platform !== "win32") return executable; const windows = process.platform === "win32";
const path = const path = windows
Object.entries(environment).find( ? (Object.entries(environment).find(
([key]) => key.toLowerCase() === "path", ([key]) => key.toLowerCase() === "path",
)?.[1] ?? ""; )?.[1] ?? "")
for (const directory of path : (environment.PATH ?? "/usr/bin:/bin");
.split(delimiter) // Resolve in the caller's directory once. The module probe and the engine
.filter((entry) => entry !== "")) { // 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); throwIfJadxInterrupted(signal);
// Windows PATH commonly quotes individual entries containing spaces. // Windows PATH commonly quotes individual entries containing spaces.
const selected = const selected =
directory.startsWith('"') && directory.endsWith('"') windows && directory.startsWith('"') && directory.endsWith('"')
? directory.slice(1, -1) ? directory.slice(1, -1)
: directory; : directory;
const candidate = resolve(selected, executable); const candidate = windows
? resolve(selected, executable)
: absoluteCallerPath(appendCallerPath(selected, executable));
try { try {
await access(candidate, constants.R_OK | constants.X_OK); await access(
if ((await stat(candidate)).isFile()) return await realpath(candidate); 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) { } catch (cause: unknown) {
if ( if (
typeof cause === "object" && typeof cause === "object" &&
cause !== null && cause !== null &&
"code" in cause && "code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR") (cause.code === "ENOENT" ||
cause.code === "ENOTDIR" ||
(!windows && (cause.code === "EACCES" || cause.code === "EPERM")))
) )
continue; continue;
throw configurationFailure( throw configurationFailure(
@@ -637,8 +653,8 @@ const resolveJava = async (
} }
throw configurationFailure( throw configurationFailure(
"runtime_missing", "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.", `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 }, { java_executable: executable, phase: "path-lookup" },
"java_runtime", "java_runtime",
); );
} }
+3
View File
@@ -579,6 +579,9 @@ export class JadxProvider implements AndroidAnalysisPort {
session = new JadxSession( session = new JadxSession(
{ {
command: configuration.java, 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: [ arguments: [
...configuration.jvmArguments, ...configuration.jvmArguments,
"--class-path", "--class-path",
@@ -244,12 +244,10 @@ it("distinguishes a runnable JRE from a missing Java executable", async () => {
expect(availability).toMatchObject({ expect(availability).toMatchObject({
status: "unavailable", status: "unavailable",
code: "runtime_missing", code: "runtime_missing",
reason: expect.stringContaining("spawn java ENOENT"), reason: expect.stringContaining("Cannot find an executable java"),
diagnostics: { diagnostics: {
java_executable: "java", java_executable: "java",
phase: "module-inspection", phase: "path-lookup",
exit_code: null,
process_error_code: "ENOENT",
}, },
}); });
}); });
+98 -1
View File
@@ -1,6 +1,15 @@
import { SdkErrorCode } from "@modelcontextprotocol/client"; import { SdkErrorCode } from "@modelcontextprotocol/client";
import { expect, it as test, onTestFinished, vi } from "vitest"; 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 { fileURLToPath } from "node:url";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js"; import { AndroidAnalysisService } from "../../../src/application/android/AndroidAnalysisService.js";
@@ -20,10 +29,98 @@ import {
verifyJadxFixtureCleanup as verifyCleanup, verifyJadxFixtureCleanup as verifyCleanup,
} from "../../fixtures/android/jadx.js"; } from "../../fixtures/android/jadx.js";
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js"; import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const it = test.skipIf(process.platform === "win32"); 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. // Select the provider's source hash after resolver and engine admission complete.
const failInputHashCloses = (path: string, failures: number) => { const failInputHashCloses = (path: string, failures: number) => {
const originalHash = stableHash.hashStableFile; const originalHash = stableHash.hashStableFile;