mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
f433786463
* feat(android): make the JADX operation deadline configurable JADX operations used a fixed 120 s provider deadline and a separate fixed 120 s per-call bound in the session, so an APK whose cold load exceeds 120 s could not complete, and raising only the MCP client timeout could not extend either. Add REA_JADX_OPERATION_TIMEOUT_MS, parsed once by the JADX configuration owner (decimal milliseconds from 1 to 2147483647, default 120000). The provider resolves it before acquiring anything and uses it for the operation deadline and its timeout errors, and the session uses it for every upstream call. Doctor reports the effective value, and setup forwards an explicit value like the other JADX settings. Fixes #3918 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(android): isolate JADX fixtures from inherited shell settings The shared JADX protocol fixture spread the developer's process.env, so an exported REA_JADX_OPERATION_TIMEOUT_MS (or REA_JADX_HEAP_MIB) changed every provider test that never selected one. Keep inheriting the host environment for process launch, but take REA_JADX_* settings only from the fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { expect, it as test } from "vitest";
|
|
|
|
import {
|
|
inspectJadxAvailability,
|
|
resolveJadxConfigurationInputs,
|
|
validateJadxConfiguration,
|
|
} from "../../../src/android/JadxConfiguration.js";
|
|
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { writeOrderedZip } from "../../fixtures/artifactEntryOrder.js";
|
|
import { writeJadxJarInventory } from "../../fixtures/android/jadxJar.js";
|
|
|
|
const it = test.skipIf(process.platform === "win32");
|
|
|
|
const resolveConfiguration = async (
|
|
environment: Parameters<typeof resolveJadxConfigurationInputs>[0],
|
|
operation: Parameters<typeof resolveJadxConfigurationInputs>[1],
|
|
) => {
|
|
const configuration = await resolveJadxConfigurationInputs(
|
|
environment,
|
|
operation,
|
|
);
|
|
await validateJadxConfiguration(configuration, environment, operation);
|
|
return configuration;
|
|
};
|
|
|
|
it("rejects malformed or unrelated engine archives with JAR-specific recovery", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-invalid-jar-");
|
|
const javaHome = await createFakeJdk(root);
|
|
const jar = join(root, "engine.jar");
|
|
for (const malformed of [true, false]) {
|
|
if (malformed) await writeFile(jar, "not a Java archive");
|
|
else await writeOrderedZip(jar, ["unrelated/Library.class"]);
|
|
const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome };
|
|
const availability = await inspectJadxAvailability(environment);
|
|
expect(availability).toMatchObject({
|
|
status: "unavailable",
|
|
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
|
|
diagnostics: { jar_path: await realpath(jar), phase: "jar-inspection" },
|
|
});
|
|
await expect(
|
|
resolveConfiguration(environment, "inspect_android_package"),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
|
|
});
|
|
expect(availability.reason).not.toContain("Select a full JDK");
|
|
}
|
|
});
|
|
|
|
it("rejects an explicit JAVA_HOME without an executable java binary", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-config-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
const javaHome = join(root, "missing-jdk");
|
|
await writeJadxJarInventory(jar);
|
|
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining(join(javaHome, "bin", "java")),
|
|
});
|
|
});
|
|
|
|
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 = await createFakeJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
"inspect_android_package",
|
|
),
|
|
).resolves.toEqual({
|
|
jar: await realpath(jar),
|
|
java: join(javaHome, "bin", "java"),
|
|
jvmArguments: [],
|
|
});
|
|
});
|
|
|
|
it("admits engine classes past repeated ZIP resource filenames", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-duplicate-resource-");
|
|
const jar = join(root, "engine.jar");
|
|
const javaHome = await createFakeJdk(root);
|
|
await writeOrderedZip(jar, [
|
|
"META-INF/LICENSE.txt",
|
|
"META-INF/LICENCE.txt",
|
|
"com/atxx/jhmcp/JadxSession.class",
|
|
"com/atxx/jhmcp/SessionHolder.class",
|
|
"jadx/api/JadxDecompiler.class",
|
|
"com/google/gson/Gson.class",
|
|
"io/modelcontextprotocol/kotlin/sdk/server/Server.class",
|
|
]);
|
|
const bytes = await readFile(jar);
|
|
const original = Buffer.from("META-INF/LICENCE.txt");
|
|
let renamed = 0;
|
|
for (
|
|
let offset = bytes.indexOf(original);
|
|
offset >= 0;
|
|
offset = bytes.indexOf(original, offset + original.length)
|
|
) {
|
|
bytes.write("META-INF/LICENSE.txt", offset);
|
|
renamed++;
|
|
}
|
|
expect(renamed).toBe(2);
|
|
await writeFile(jar, bytes);
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
|
"inspect_android_package",
|
|
),
|
|
).resolves.toMatchObject({ jar: await realpath(jar) });
|
|
});
|
|
|
|
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 writeJadxJarInventory(jar);
|
|
const configuration = await resolveConfiguration(
|
|
{
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
REA_JADX_HEAP_MIB: "8192",
|
|
REA_JADX_ACTIVE_PROCESSOR_COUNT: "4",
|
|
},
|
|
"inspect_android_package",
|
|
);
|
|
expect(configuration.jvmArguments).toEqual([
|
|
"-Xmx8192m",
|
|
"-XX:ActiveProcessorCount=4",
|
|
]);
|
|
await expect(
|
|
resolveConfiguration(
|
|
{
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
REA_JADX_ACTIVE_PROCESSOR_COUNT: "2147483648",
|
|
},
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
reason: expect.stringContaining("32-bit"),
|
|
});
|
|
for (const name of ["REA_JADX_HEAP_MIB", "REA_JADX_ACTIVE_PROCESSOR_COUNT"])
|
|
for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"])
|
|
await expect(
|
|
resolveConfiguration(
|
|
{
|
|
REA_JADX_MCP_JAR: jar,
|
|
JAVA_HOME: javaHome,
|
|
[name]: value,
|
|
},
|
|
"inspect_android_package",
|
|
),
|
|
).rejects.toMatchObject({
|
|
_tag: "AnalysisCapabilityUnavailableError",
|
|
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 writeJadxJarInventory(jar);
|
|
const jreHome = await createFakeJdk(root, false);
|
|
await expect(
|
|
resolveConfiguration(
|
|
{ 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 writeJadxJarInventory(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 resolveConfiguration(
|
|
{ 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 writeJadxJarInventory(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(
|
|
resolveConfiguration(
|
|
{ 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, marker } = await createHangingJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
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,
|
|
),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.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, java } = await createHangingJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
|
|
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 createHangingJdk = async (root: string) => {
|
|
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(
|
|
java,
|
|
`#!/bin/sh\nexec ${process.execPath} -e 'require("node:fs").writeFileSync(process.argv[1], "ready"); setTimeout(() => {}, 30_000)' '${marker}'\n`,
|
|
);
|
|
await chmod(java, 0o700);
|
|
return { javaHome, java, marker };
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
it("tags unavailable diagnostics with the boundary failure kind", async () => {
|
|
const availability = await inspectJadxAvailability({});
|
|
expect(availability.status).toBe("unavailable");
|
|
if (availability.status !== "unavailable") throw new Error("unreachable");
|
|
expect(availability.diagnostics["failure_kind"]).toBe("jar");
|
|
});
|
|
|
|
it("reports the effective operation deadline and rejects unusable values", async () => {
|
|
const root = await createTestTempDirectory("rea-jadx-deadline-");
|
|
const jar = join(root, "jadx-headless-mcp.jar");
|
|
const javaHome = await createFakeJdk(root);
|
|
await writeJadxJarInventory(jar);
|
|
const environment = { REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome };
|
|
for (const [value, expected] of [
|
|
[undefined, 120_000],
|
|
["1", 1],
|
|
["900000", 900_000],
|
|
["2147483647", 2_147_483_647],
|
|
] as const)
|
|
await expect(
|
|
inspectJadxAvailability({
|
|
...environment,
|
|
...(value === undefined
|
|
? {}
|
|
: { REA_JADX_OPERATION_TIMEOUT_MS: value }),
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: "available",
|
|
diagnostics: { operation_timeout_ms: expected },
|
|
});
|
|
// Node clamps timer delays above the signed 32-bit range to 1 ms.
|
|
for (const value of ["", "0", "-1", "1.5", "1e3", " 5", "abc", "2147483648"])
|
|
await expect(
|
|
inspectJadxAvailability({
|
|
...environment,
|
|
REA_JADX_OPERATION_TIMEOUT_MS: value,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
status: "unavailable",
|
|
code: "open_options_invalid",
|
|
reason: expect.stringContaining("REA_JADX_OPERATION_TIMEOUT_MS"),
|
|
diagnostics: {
|
|
setting: "REA_JADX_OPERATION_TIMEOUT_MS",
|
|
value,
|
|
failure_kind: "operation_timeout",
|
|
},
|
|
});
|
|
});
|