fix(android): report actual provider readiness
This commit is contained in:
parent
55c9ce26d1
commit
c715af8058
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { TOOL_CONTRACTS } from "../../../dist/contracts/toolContracts.js";
|
||||
|
||||
/** Real unusable Java runtimes and selectors must lead to applicable recovery. */
|
||||
export async function verifyAndroidFailureRecovery({
|
||||
|
|
@ -17,6 +18,15 @@ export async function verifyAndroidFailureRecovery({
|
|||
}) {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-android-recovery-"));
|
||||
try {
|
||||
const withoutJar = { ...environment };
|
||||
delete withoutJar.REA_JADX_MCP_JAR;
|
||||
await verifyAndroidReadiness({
|
||||
entrypoint,
|
||||
environment: withoutJar,
|
||||
repository,
|
||||
available: false,
|
||||
remediation: /REA_JADX_MCP_JAR/u,
|
||||
});
|
||||
let jlink;
|
||||
for (const candidate of isAbsolute(java)
|
||||
? [join(dirname(java), "jlink")]
|
||||
|
|
@ -107,8 +117,24 @@ export async function verifyAndroidFailureRecovery({
|
|||
assert.match(failure.message, /full JDK/u);
|
||||
assert.match(failure.remediation.action, /java --list-modules/u);
|
||||
assert.doesNotMatch(JSON.stringify(failure), /rea doctor/u);
|
||||
await verifyAndroidReadiness({
|
||||
entrypoint,
|
||||
environment: scenario.env,
|
||||
repository,
|
||||
available: false,
|
||||
remediation: new RegExp(
|
||||
scenario.observation.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
|
||||
"u",
|
||||
),
|
||||
});
|
||||
console.log(`PASS CLI applicable recovery: ${scenario.label}`);
|
||||
}
|
||||
await verifyAndroidReadiness({
|
||||
entrypoint,
|
||||
environment,
|
||||
repository,
|
||||
available: true,
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
|
|
@ -187,3 +213,72 @@ export async function verifyAndroidFailureRecovery({
|
|||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const verifyAndroidReadiness = async ({
|
||||
entrypoint,
|
||||
environment,
|
||||
repository,
|
||||
available,
|
||||
remediation,
|
||||
}) => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-android-readiness", version: "1" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const catalog = await client.listTools();
|
||||
const catalogNames = catalog.tools.map(({ name }) => name).sort();
|
||||
const status = await client.callTool({
|
||||
name: "binary_session",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(status.isError, true, JSON.stringify(status));
|
||||
const availability = status.structuredContent?.result?.tool_availability;
|
||||
assert.ok(Array.isArray(availability));
|
||||
assert.deepEqual(
|
||||
availability.map(({ name }) => name).sort(),
|
||||
TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
"Readiness must retain one availability record for the complete canonical catalog",
|
||||
);
|
||||
assert.deepEqual(
|
||||
catalogNames,
|
||||
TOOL_CONTRACTS.map(({ name }) => name).sort(),
|
||||
"Readiness must not truncate the SDK tool catalog",
|
||||
);
|
||||
const byName = new Map(availability.map((item) => [item.name, item]));
|
||||
for (const name of [
|
||||
"inspect_android_package",
|
||||
"search_android_classes",
|
||||
"inspect_android_class",
|
||||
"inspect_android_method",
|
||||
"trace_android_references",
|
||||
]) {
|
||||
const item = byName.get(name);
|
||||
assert.ok(item, `Missing Android availability entry ${name}`);
|
||||
assert.equal(item.available, available, `${name} readiness mismatch`);
|
||||
if (available) {
|
||||
assert.equal(item.reason, "available");
|
||||
assert.equal(item.remediation, null);
|
||||
} else {
|
||||
assert.equal(item.reason, "provider_missing");
|
||||
assert.match(item.remediation, remediation);
|
||||
}
|
||||
}
|
||||
const graph = byName.get("project_android_application_graph");
|
||||
assert.ok(graph, "Missing execution-free Android graph projection");
|
||||
assert.equal(graph.available, true);
|
||||
assert.equal(graph.reason, "available");
|
||||
assert.equal(graph.remediation, null);
|
||||
console.log(
|
||||
`PASS stdio Android readiness: ${available ? "ready" : "unavailable"}`,
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
|
||||
|
||||
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
|
||||
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
/** Caller-supplied tools, resolved only when an Android operation is selected. */
|
||||
export interface JadxConfiguration {
|
||||
readonly jar: string;
|
||||
|
|
@ -12,51 +21,184 @@ export interface JadxConfiguration {
|
|||
readonly jvmArguments: readonly string[];
|
||||
}
|
||||
|
||||
/** Admit explicit local tooling without installing or changing host configuration. */
|
||||
type ConfigurationFailureCode =
|
||||
| "not_configured"
|
||||
| "executable_missing"
|
||||
| "runtime_missing"
|
||||
| "unsupported_host"
|
||||
| "unsupported_version"
|
||||
| "version_unresolved"
|
||||
| "open_options_invalid";
|
||||
|
||||
class JadxConfigurationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: ConfigurationFailureCode,
|
||||
message: string,
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect the same JAR and Java prerequisites that gate actual execution. */
|
||||
export const inspectJadxAvailability = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProviderAvailability> => {
|
||||
try {
|
||||
const configuration = await readJadxConfiguration(environment, signal);
|
||||
return {
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {
|
||||
configured: true,
|
||||
java_executable: configuration.java,
|
||||
jdk_compiler: true,
|
||||
jar_path: configuration.jar,
|
||||
},
|
||||
};
|
||||
} catch (cause) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
if (cause instanceof JadxConfigurationFailure)
|
||||
return {
|
||||
status: "unavailable",
|
||||
code: cause.code,
|
||||
reason: cause.message,
|
||||
diagnostics: cause.diagnostics,
|
||||
};
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */
|
||||
export const resolveJadxConfiguration = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
operation: AndroidOperation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JadxConfiguration> => {
|
||||
const unavailable = (reason: string) =>
|
||||
new AnalysisCapabilityUnavailableError("jadx", operation, reason);
|
||||
if (process.platform !== "linux" && process.platform !== "darwin")
|
||||
throw unavailable(
|
||||
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. The metadata bridge has real-provider verification on macOS arm64.`,
|
||||
try {
|
||||
return await readJadxConfiguration(environment, signal);
|
||||
} catch (cause) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
|
||||
const output = cause.diagnostics;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
operation,
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
userMessage: cause.message,
|
||||
...(typeof output.stdout === "string" &&
|
||||
typeof output.stderr === "string"
|
||||
? {
|
||||
capturedOutput: {
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
truncated: output.output_truncated === true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const readJadxConfiguration = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JadxConfiguration> => {
|
||||
requireSupportedHost();
|
||||
const jar = await resolveJar(environment);
|
||||
const java = resolveJava(environment);
|
||||
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
|
||||
const jvmArguments = resolveJvmArguments(environment);
|
||||
await inspectJavaRuntime(java, environment, signal);
|
||||
return { jar, java, jvmArguments };
|
||||
};
|
||||
|
||||
const requireSupportedHost = (): void => {
|
||||
if (process.platform === "linux" || process.platform === "darwin") return;
|
||||
throw configurationFailure(
|
||||
"unsupported_host",
|
||||
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. The metadata bridge has real-provider verification on macOS arm64.`,
|
||||
{ platform: process.platform },
|
||||
);
|
||||
};
|
||||
|
||||
const resolveJar = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Promise<string> => {
|
||||
const jar = environment.REA_JADX_MCP_JAR;
|
||||
if (environment.JAVA_HOME !== undefined && !isAbsolute(environment.JAVA_HOME))
|
||||
throw unavailable(
|
||||
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
|
||||
);
|
||||
if (jar === undefined || !isAbsolute(jar))
|
||||
throw unavailable(JADX_JAR_CONFIGURATION_REMEDIATION);
|
||||
const java =
|
||||
environment.JAVA_HOME === undefined
|
||||
? "java"
|
||||
: join(environment.JAVA_HOME, "bin", "java");
|
||||
throw configurationFailure(
|
||||
"not_configured",
|
||||
JADX_JAR_CONFIGURATION_REMEDIATION,
|
||||
{ jar_configured: false },
|
||||
);
|
||||
try {
|
||||
await access(jar, constants.R_OK);
|
||||
if (!(await stat(jar)).isFile())
|
||||
throw unavailable(`REA_JADX_MCP_JAR is not a regular file: ${jar}`);
|
||||
throw configurationFailure(
|
||||
"executable_missing",
|
||||
`REA_JADX_MCP_JAR is not a regular file: ${jar}`,
|
||||
{ jar_path: jar },
|
||||
);
|
||||
return await realpath(jar);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCapabilityUnavailableError) throw cause;
|
||||
throw unavailable(
|
||||
if (cause instanceof JadxConfigurationFailure) throw cause;
|
||||
throw configurationFailure(
|
||||
"executable_missing",
|
||||
`Cannot read REA_JADX_MCP_JAR ${jar}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ jar_path: jar },
|
||||
cause,
|
||||
);
|
||||
}
|
||||
if (environment.JAVA_HOME !== undefined) {
|
||||
try {
|
||||
await access(java, constants.R_OK | constants.X_OK);
|
||||
if (!(await stat(java)).isFile())
|
||||
throw unavailable(`JAVA_HOME java is not a regular file: ${java}`);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCapabilityUnavailableError) throw cause;
|
||||
throw unavailable(
|
||||
`Cannot execute JAVA_HOME java ${java}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveJava = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): string => {
|
||||
if (environment.JAVA_HOME === undefined) return "java";
|
||||
if (isAbsolute(environment.JAVA_HOME))
|
||||
return join(environment.JAVA_HOME, "bin", "java");
|
||||
throw configurationFailure(
|
||||
"open_options_invalid",
|
||||
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
|
||||
{ java_home: environment.JAVA_HOME },
|
||||
);
|
||||
};
|
||||
|
||||
const requireJavaHomeExecutable = async (
|
||||
javaHome: string | undefined,
|
||||
java: string,
|
||||
): Promise<void> => {
|
||||
if (javaHome === undefined) return;
|
||||
try {
|
||||
await access(java, constants.R_OK | constants.X_OK);
|
||||
if ((await stat(java)).isFile()) return;
|
||||
throw configurationFailure(
|
||||
"runtime_missing",
|
||||
`JAVA_HOME java is not a regular file: ${java}`,
|
||||
{ java_executable: java },
|
||||
);
|
||||
} catch (cause) {
|
||||
if (cause instanceof JadxConfigurationFailure) throw cause;
|
||||
throw configurationFailure(
|
||||
"runtime_missing",
|
||||
`Cannot execute JAVA_HOME java ${java}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ java_executable: java },
|
||||
cause,
|
||||
);
|
||||
}
|
||||
const jvmArguments: string[] = [];
|
||||
};
|
||||
|
||||
const resolveJvmArguments = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): readonly string[] => {
|
||||
const result: string[] = [];
|
||||
for (const [name, prefix, suffix] of [
|
||||
["REA_JADX_HEAP_MIB", "-Xmx", "m"],
|
||||
["REA_JADX_ACTIVE_PROCESSOR_COUNT", "-XX:ActiveProcessorCount=", ""],
|
||||
|
|
@ -64,17 +206,142 @@ export const resolveJadxConfiguration = async (
|
|||
const value = environment[name];
|
||||
if (value === undefined) continue;
|
||||
if (!/^[1-9]\d*$/u.test(value) || !Number.isSafeInteger(Number(value)))
|
||||
throw unavailable(
|
||||
throw configurationFailure(
|
||||
"open_options_invalid",
|
||||
`${name} must be a positive safe integer; received ${value}.`,
|
||||
{ setting: name, value },
|
||||
);
|
||||
if (
|
||||
name === "REA_JADX_ACTIVE_PROCESSOR_COUNT" &&
|
||||
Number(value) > 2_147_483_647
|
||||
)
|
||||
throw unavailable(
|
||||
throw configurationFailure(
|
||||
"open_options_invalid",
|
||||
`${name} exceeds the JVM's signed 32-bit processor-count range.`,
|
||||
{ setting: name, value },
|
||||
);
|
||||
jvmArguments.push(`${prefix}${value}${suffix}`);
|
||||
result.push(`${prefix}${value}${suffix}`);
|
||||
}
|
||||
return { jar: await realpath(jar), java, jvmArguments };
|
||||
return result;
|
||||
};
|
||||
|
||||
const inspectJavaRuntime = async (
|
||||
java: string,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
let stdout: string;
|
||||
let stderr: string;
|
||||
try {
|
||||
({ stdout, stderr } = await execFileOutput(java, ["--list-modules"], {
|
||||
env: { ...environment },
|
||||
timeout: JAVA_MODULE_LISTING_TIMEOUT_MS,
|
||||
maxBuffer: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
}));
|
||||
} catch (cause) {
|
||||
if (signal?.aborted === true) throw cause;
|
||||
const output = execFileOutputFailure(cause);
|
||||
if (output === undefined) throw cause;
|
||||
const diagnostics = {
|
||||
java_executable: java,
|
||||
phase: "module-inspection",
|
||||
exit_code: typeof output.code === "number" ? output.code : null,
|
||||
process_error_code:
|
||||
typeof output.code === "string" ? output.code : null,
|
||||
signal: output.signal,
|
||||
killed: output.killed,
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
output_truncated: output.outputTruncated,
|
||||
} satisfies Readonly<Record<string, JsonValue>>;
|
||||
if (output.outputTruncated)
|
||||
throw configurationFailure(
|
||||
"version_unresolved",
|
||||
`The selected Java launcher ${java} exceeded the ${JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES / (1024 * 1024)} MiB output budget while listing modules. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; ` +
|
||||
"java --list-modules should return a concise module listing.",
|
||||
{
|
||||
...diagnostics,
|
||||
failure: "output_limit",
|
||||
output_limit_bytes: JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES,
|
||||
},
|
||||
cause,
|
||||
);
|
||||
if (output.killed)
|
||||
throw configurationFailure(
|
||||
"version_unresolved",
|
||||
`The selected Java launcher ${java} was terminated or did not finish the module-listing probe within ${JAVA_MODULE_LISTING_TIMEOUT_MS / 1000} seconds. Inspect the launcher and its startup responsiveness, then verify it returns compiler and version modules with java --list-modules.`,
|
||||
{
|
||||
...diagnostics,
|
||||
failure: "probe_terminated",
|
||||
deadline_ms: JAVA_MODULE_LISTING_TIMEOUT_MS,
|
||||
},
|
||||
cause,
|
||||
);
|
||||
throw configurationFailure(
|
||||
"runtime_missing",
|
||||
`Cannot inspect selected Java runtime ${java}: ${output.stderr.trim() || errorText(cause)}. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules.`,
|
||||
diagnostics,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
const compiler = /^jdk\.compiler(?:@|$)/mu.test(stdout);
|
||||
if (!compiler)
|
||||
throw configurationFailure(
|
||||
"runtime_missing",
|
||||
`Selected Java runtime ${java} does not provide jdk.compiler. Select a full JDK including jdk.compiler via JAVA_HOME or PATH; verify it with java --list-modules. A JRE cannot compile REA's source bridge.`,
|
||||
{
|
||||
java_executable: java,
|
||||
phase: "module-inspection",
|
||||
jdk_compiler: false,
|
||||
stdout,
|
||||
stderr,
|
||||
output_truncated: false,
|
||||
},
|
||||
);
|
||||
const major = /^java\.base@(\d+)(?:[.+_-]|$)/mu.exec(stdout)?.[1];
|
||||
if (major === undefined)
|
||||
throw configurationFailure(
|
||||
"version_unresolved",
|
||||
`The selected Java launcher ${java} did not report a parseable java.base version in its module listing. Inspect the launcher output and verify it with java --list-modules.`,
|
||||
{
|
||||
java_executable: java,
|
||||
phase: "module-inspection",
|
||||
jdk_compiler: true,
|
||||
java_major: null,
|
||||
stdout,
|
||||
stderr,
|
||||
output_truncated: false,
|
||||
},
|
||||
);
|
||||
if (Number(major) < 17)
|
||||
throw configurationFailure(
|
||||
"unsupported_version",
|
||||
`Selected Java runtime ${java} must be JDK 17 or newer and include jdk.compiler; verify it with java --list-modules.`,
|
||||
{
|
||||
java_executable: java,
|
||||
phase: "module-inspection",
|
||||
jdk_compiler: true,
|
||||
java_major: major ?? null,
|
||||
stdout,
|
||||
stderr,
|
||||
output_truncated: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const configurationFailure = (
|
||||
code: ConfigurationFailureCode,
|
||||
message: string,
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
cause?: unknown,
|
||||
): JadxConfigurationFailure =>
|
||||
new JadxConfigurationFailure(
|
||||
code,
|
||||
message,
|
||||
diagnostics,
|
||||
cause === undefined ? undefined : { cause },
|
||||
);
|
||||
|
||||
const errorText = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ import {
|
|||
hashAndroidFile,
|
||||
snapshotAndroidTarget,
|
||||
} from "./AndroidTargetSnapshot.js";
|
||||
import { resolveJadxConfiguration } from "./JadxConfiguration.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
resolveJadxConfiguration,
|
||||
} from "./JadxConfiguration.js";
|
||||
import type { JadxLauncher } from "./JadxMcpTransport.js";
|
||||
|
||||
import { JadxSession } from "./JadxSession.js";
|
||||
|
|
@ -175,6 +178,11 @@ export class JadxProvider implements AndroidAnalysisPort {
|
|||
readonly launcher?: JadxLauncher,
|
||||
) {}
|
||||
|
||||
/** Report actual local prerequisites without starting JADX or opening a target. */
|
||||
inspectAvailability(signal?: AbortSignal) {
|
||||
return inspectJadxAvailability(this.environment, signal);
|
||||
}
|
||||
|
||||
/** Serialize expensive work and bind both raw and normalized results to the APK. */
|
||||
async execute(
|
||||
target: BinaryTarget,
|
||||
|
|
@ -267,6 +275,7 @@ export class JadxProvider implements AndroidAnalysisPort {
|
|||
const configuration = await resolveJadxConfiguration(
|
||||
this.environment,
|
||||
request.operation,
|
||||
signal,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
const jarHash = await hashAndroidFile(configuration.jar);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
|
|||
v8InspectorObservationEnabled: true,
|
||||
};
|
||||
|
||||
const availableAndroidAvailability: NonNullable<
|
||||
Parameters<typeof buildCapabilityInventory>[1]["androidAnalysisAvailability"]
|
||||
> = { status: "available", code: null, reason: null, diagnostics: {} };
|
||||
|
||||
const status = (
|
||||
options: {
|
||||
readonly open?: boolean;
|
||||
|
|
@ -110,7 +114,7 @@ describe("capability inventory: provider status", () => {
|
|||
expect(
|
||||
entry("inspect_android_package", status(), {
|
||||
processCaptureEnabled: true,
|
||||
androidAnalysisEnabled: true,
|
||||
androidAnalysisAvailability: availableAndroidAvailability,
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: true,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
import type { ToolKind } from "../contracts/toolContractTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { ProviderAvailability } from "./AnalysisProvider.js";
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import {
|
||||
clientRequirementsFor,
|
||||
|
|
@ -38,7 +39,7 @@ export type AvailabilityPolicy = {
|
|||
readonly recordedCrashEnabled?: boolean;
|
||||
readonly firmwareInspectionEnabled?: boolean;
|
||||
readonly firmwareExtractionEnabled?: boolean;
|
||||
readonly androidAnalysisEnabled?: boolean;
|
||||
readonly androidAnalysisAvailability?: ProviderAvailability;
|
||||
readonly javascriptRecoveryEnabled?: boolean;
|
||||
readonly webModuleResolutionEnabled?: boolean;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
|
|
@ -321,12 +322,16 @@ const workflowAvailabilityFor = ({
|
|||
};
|
||||
}
|
||||
if (kind === "android-provider")
|
||||
return policy.androidAnalysisEnabled === true
|
||||
return policy.androidAnalysisAvailability?.status === "available"
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "provider_missing",
|
||||
reason:
|
||||
policy.androidAnalysisAvailability?.code === "unsupported_host"
|
||||
? "unsupported_host"
|
||||
: "provider_missing",
|
||||
remediation:
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide Java on Linux or macOS. Only Linux has real-provider verification.",
|
||||
policy.androidAnalysisAvailability?.reason ??
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
|
||||
};
|
||||
const browser = browserProviderAvailability(name, kind, policy);
|
||||
if (browser !== null) return browser;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import type { AndroidRequest } from "../../domain/android/androidAnalysis.js";
|
|||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import type { ProviderAvailability } from "../AnalysisProvider.js";
|
||||
|
||||
/** Static Android inspection boundary; implementation details belong to the provider. */
|
||||
export interface AndroidAnalysisPort {
|
||||
/** Inspect the selected JAR and Java runtime without opening an APK. */
|
||||
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
|
||||
/** Cancel active work and join cleanup of any retained provider resources. */
|
||||
close(): Promise<void>;
|
||||
execute(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,38 @@ type ExecFileOutputOptions = Omit<
|
|||
"encoding" | "maxBuffer"
|
||||
> & { readonly maxBuffer?: number };
|
||||
|
||||
/** Captured subprocess output read from an execFileOutput rejection. */
|
||||
export interface ExecFileOutputFailure {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly code: string | number | null;
|
||||
readonly signal: string | null;
|
||||
readonly killed: boolean;
|
||||
readonly outputTruncated: boolean;
|
||||
}
|
||||
|
||||
/** Read captured output metadata from a failed execFileOutput invocation. */
|
||||
export const execFileOutputFailure = (
|
||||
cause: unknown,
|
||||
): ExecFileOutputFailure | undefined => {
|
||||
if (!(cause instanceof Error)) return undefined;
|
||||
const stdout = Reflect.get(cause, "stdout");
|
||||
const stderr = Reflect.get(cause, "stderr");
|
||||
const code = Reflect.get(cause, "code");
|
||||
const signal = Reflect.get(cause, "signal");
|
||||
const killed = Reflect.get(cause, "killed");
|
||||
if (typeof stdout !== "string" || typeof stderr !== "string")
|
||||
return undefined;
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
code: typeof code === "string" || typeof code === "number" ? code : null,
|
||||
signal: typeof signal === "string" ? signal : null,
|
||||
killed: killed === true,
|
||||
outputTruncated: code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
};
|
||||
};
|
||||
|
||||
/** Run a shell-free command while capturing its complete UTF-8 output. */
|
||||
export const execFileOutput = (
|
||||
command: string,
|
||||
|
|
|
|||
|
|
@ -122,10 +122,6 @@ const installSessionToolAvailability = (
|
|||
options.firmwareAnalysis !== undefined ||
|
||||
(process.platform === "linux" &&
|
||||
isAbsolute(process.env.REA_UNBLOB_COMMAND ?? "")),
|
||||
androidAnalysisEnabled:
|
||||
options.androidAnalysis !== undefined ||
|
||||
((process.platform === "linux" || process.platform === "darwin") &&
|
||||
isAbsolute(process.env.REA_JADX_MCP_JAR ?? "")),
|
||||
browserObservationEnabled: options.browserObservation !== undefined,
|
||||
javascriptRecoveryEnabled:
|
||||
options.javascriptRecovery !== undefined ||
|
||||
|
|
@ -176,6 +172,7 @@ export const createServer = (
|
|||
const startedAt = new Date().toISOString();
|
||||
const logger = options.logger ?? silentLogger;
|
||||
const server = createMcpServer(session);
|
||||
const android = options.androidAnalysis ?? createAndroidAnalysisProvider();
|
||||
const availability = installSessionToolAvailability(server, session, options);
|
||||
const toolLogger = logger.child({ layer: "server" });
|
||||
const {
|
||||
|
|
@ -196,7 +193,6 @@ export const createServer = (
|
|||
recordEvidenceWithUnknown,
|
||||
};
|
||||
registerBinaryAnalysisTools(toolContext);
|
||||
const android = options.androidAnalysis ?? createAndroidAnalysisProvider();
|
||||
const previousOnclose = server.server.onclose;
|
||||
server.server.onclose = () => {
|
||||
previousOnclose?.();
|
||||
|
|
@ -285,6 +281,8 @@ export const createServer = (
|
|||
...(availability === undefined
|
||||
? {}
|
||||
: { availabilityPolicy: availability.policy }),
|
||||
androidAnalysisAvailability: (signal) =>
|
||||
android.inspectAvailability(signal),
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { BinarySessionPort } from "../application/binary/BinarySession.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { buildCapabilityInventory } from "../application/CapabilityInventory.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
|
|
@ -10,6 +12,7 @@ import { mcpClientMetadata } from "./mcpClientMetadata.js";
|
|||
import type { SessionAvailability } from "./sessionAvailabilityPolicy.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
import { err } from "../domain/result.js";
|
||||
|
||||
type ToolAvailability = ReturnType<typeof buildCapabilityInventory>[number];
|
||||
|
||||
|
|
@ -20,24 +23,49 @@ export interface SessionStatusToolOptions {
|
|||
readonly contract: ReturnType<typeof toolContract<"binary_session">>;
|
||||
readonly startedAt: string;
|
||||
readonly availabilityPolicy: () => SessionAvailability;
|
||||
readonly androidAnalysisAvailability: (
|
||||
signal: AbortSignal,
|
||||
) => Promise<ProviderAvailability>;
|
||||
}
|
||||
|
||||
/** Register the read-only provider and target status operation. */
|
||||
export const registerSessionStatusTool = (
|
||||
options: SessionStatusToolOptions,
|
||||
): void => {
|
||||
const { server, session, contract, startedAt, availabilityPolicy } = options;
|
||||
const {
|
||||
server,
|
||||
session,
|
||||
contract,
|
||||
startedAt,
|
||||
availabilityPolicy,
|
||||
androidAnalysisAvailability,
|
||||
} = options;
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
(input, context) => {
|
||||
async (input, context) => {
|
||||
const { client, clientFeatures, protocolVersion } =
|
||||
mcpClientMetadata(context);
|
||||
let androidAvailability: ProviderAvailability;
|
||||
try {
|
||||
androidAvailability = await androidAnalysisAvailability(
|
||||
context.mcpReq.signal,
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!context.mcpReq.signal.aborted) throw cause;
|
||||
return toCallToolResult(
|
||||
err(new AnalysisCancelledError("binary_session")),
|
||||
contract,
|
||||
);
|
||||
}
|
||||
const status = session.status();
|
||||
const statusObject = jsonObjectSchema.parse(status);
|
||||
const toolAvailability = buildCapabilityInventory(
|
||||
status,
|
||||
availabilityPolicy(),
|
||||
{
|
||||
...availabilityPolicy(),
|
||||
androidAnalysisAvailability: androidAvailability,
|
||||
},
|
||||
clientFeatures,
|
||||
);
|
||||
const serverIdentity = createServerIdentity({
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { Evidence } from "../domain/evidence.js";
|
|||
import type { ProcessCapture } from "../domain/process/processCapture.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
import { mcpProgressReporter } from "./mcpProgress.js";
|
||||
import { registerArtifactComparisonTool } from "./registerArtifactComparisonTool.js";
|
||||
import { registerBundleComparisonTool } from "./registerBundleComparisonTool.js";
|
||||
|
|
@ -136,13 +137,22 @@ export interface LifecycleToolRegistration {
|
|||
readonly statusContract: ReturnType<typeof toolContract<"binary_session">>;
|
||||
readonly startedAt: string;
|
||||
readonly availabilityPolicy: () => SessionAvailability;
|
||||
readonly androidAnalysisAvailability: (
|
||||
signal: AbortSignal,
|
||||
) => Promise<ProviderAvailability>;
|
||||
}
|
||||
|
||||
const registerLifecycleTools = (
|
||||
registration: LifecycleToolRegistration,
|
||||
): void => {
|
||||
const { server, session, statusContract, startedAt, availabilityPolicy } =
|
||||
registration;
|
||||
const {
|
||||
server,
|
||||
session,
|
||||
statusContract,
|
||||
startedAt,
|
||||
availabilityPolicy,
|
||||
androidAnalysisAvailability,
|
||||
} = registration;
|
||||
registerOpenLifecycleTool(registration);
|
||||
registerCloseLifecycleTool(registration);
|
||||
registerSessionStatusTool({
|
||||
|
|
@ -151,6 +161,7 @@ const registerLifecycleTools = (
|
|||
contract: statusContract,
|
||||
startedAt,
|
||||
availabilityPolicy,
|
||||
androidAnalysisAvailability,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -206,6 +217,9 @@ const registerOpenLifecycleTool = ({
|
|||
export interface SessionToolOptions {
|
||||
readonly startedAt?: string;
|
||||
readonly availabilityPolicy?: () => SessionAvailability;
|
||||
readonly androidAnalysisAvailability?: (
|
||||
signal: AbortSignal,
|
||||
) => Promise<ProviderAvailability>;
|
||||
}
|
||||
|
||||
const registerContextTools = (
|
||||
|
|
@ -265,6 +279,8 @@ export const registerSessionTools = (
|
|||
options.availabilityPolicy,
|
||||
{},
|
||||
),
|
||||
androidAnalysisAvailability:
|
||||
options.androidAnalysisAvailability ?? missingAndroidAvailability,
|
||||
});
|
||||
registerEvidenceTools({
|
||||
server,
|
||||
|
|
@ -287,3 +303,11 @@ export const registerSessionTools = (
|
|||
registerUnknownTools({ server, session });
|
||||
registerContextTools(server, session);
|
||||
};
|
||||
|
||||
const missingAndroidAvailability = async (): Promise<ProviderAvailability> => ({
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
reason:
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
|
||||
diagnostics: { configured: false },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export interface SessionAvailabilityDefaults {
|
|||
| "electronObservationEnabled"
|
||||
| "electronAutomationEnabled"
|
||||
| "v8InspectorObservationEnabled"
|
||||
| "androidAnalysisEnabled"
|
||||
| "javascriptRecoveryEnabled"
|
||||
| "webModuleResolutionEnabled"
|
||||
| "binaryLayoutEnabled"
|
||||
|
|
@ -45,8 +44,6 @@ export const sessionAvailabilityPolicy = (
|
|||
defaults.optionalFeatures?.firmwareInspectionEnabled ?? false,
|
||||
firmwareExtractionEnabled:
|
||||
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
|
||||
androidAnalysisEnabled:
|
||||
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
|
||||
javascriptRecoveryEnabled:
|
||||
defaults.optionalFeatures?.javascriptRecoveryEnabled ?? false,
|
||||
webModuleResolutionEnabled:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it as test } from "vitest";
|
||||
|
||||
import { resolveJadxConfiguration } from "../../../src/android/JadxConfiguration.js";
|
||||
import {
|
||||
inspectJadxAvailability,
|
||||
resolveJadxConfiguration,
|
||||
} from "../../../src/android/JadxConfiguration.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
|
@ -25,31 +29,33 @@ it("rejects an explicit JAVA_HOME without an executable java binary", async () =
|
|||
});
|
||||
});
|
||||
|
||||
it("accepts a verified explicit java binary and canonicalizes the JAR", async () => {
|
||||
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 = join(root, "jdk");
|
||||
const java = join(javaHome, "bin", "java");
|
||||
await mkdir(join(javaHome, "bin"), { recursive: true });
|
||||
const javaHome = await createFakeJdk(root);
|
||||
await writeFile(jar, "fixture jar");
|
||||
await writeFile(java, "#!/bin/sh\n");
|
||||
await chmod(java, 0o700);
|
||||
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, JAVA_HOME: javaHome },
|
||||
"inspect_android_package",
|
||||
),
|
||||
).resolves.toEqual({ jar: await realpath(jar), java, jvmArguments: [] });
|
||||
).resolves.toEqual({
|
||||
jar: await realpath(jar),
|
||||
java: join(javaHome, "bin", "java"),
|
||||
jvmArguments: [],
|
||||
});
|
||||
});
|
||||
|
||||
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 writeFile(jar, "fixture jar");
|
||||
const configuration = await resolveJadxConfiguration(
|
||||
{
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
REA_JADX_HEAP_MIB: "8192",
|
||||
REA_JADX_ACTIVE_PROCESSOR_COUNT: "4",
|
||||
},
|
||||
|
|
@ -61,7 +67,11 @@ it("configures heap and visible processors independently without forcing either"
|
|||
]);
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, REA_JADX_ACTIVE_PROCESSOR_COUNT: "2147483648" },
|
||||
{
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
REA_JADX_ACTIVE_PROCESSOR_COUNT: "2147483648",
|
||||
},
|
||||
"inspect_android_package",
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
|
|
@ -72,7 +82,11 @@ it("configures heap and visible processors independently without forcing either"
|
|||
for (const value of ["", "0", "-1", "1.5", "8g", " 4", "9007199254740992"])
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
{ REA_JADX_MCP_JAR: jar, [name]: value },
|
||||
{
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
JAVA_HOME: javaHome,
|
||||
[name]: value,
|
||||
},
|
||||
"inspect_android_package",
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
|
|
@ -80,3 +94,213 @@ it("configures heap and visible processors independently without forcing either"
|
|||
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 writeFile(jar, "fixture jar");
|
||||
const jreHome = await createFakeJdk(root, false);
|
||||
await expect(
|
||||
resolveJadxConfiguration(
|
||||
{ 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 writeFile(jar, "fixture 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 resolveJadxConfiguration(
|
||||
{ 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 writeFile(jar, "fixture 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(
|
||||
resolveJadxConfiguration(
|
||||
{ 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 = 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(jar, "fixture jar");
|
||||
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);
|
||||
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,
|
||||
),
|
||||
)
|
||||
.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 = 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(jar, "fixture jar");
|
||||
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);
|
||||
|
||||
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 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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import type {
|
|||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderAvailability,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import type { AndroidAnalysisPort } from "../../../src/application/android/AndroidAnalysisPort.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
|
|
@ -149,6 +151,49 @@ describe("MCP snapshot lifecycle ordering", () => {
|
|||
});
|
||||
|
||||
describe("target-free MCP lifecycle", () => {
|
||||
it("takes the session snapshot after Android readiness inspection completes", async () => {
|
||||
directory = await createTestTempDirectory("rea-mcp-status-readiness-");
|
||||
const targetPath = join(directory, "target.hop");
|
||||
await writeFile(targetPath, "fixture target");
|
||||
const entered = createDeferred<void>();
|
||||
const availability = createDeferred<ProviderAvailability>();
|
||||
const closed: string[] = [];
|
||||
const session = createTestBinarySession(provider(closed));
|
||||
const androidAnalysis: AndroidAnalysisPort = {
|
||||
inspectAvailability: () => {
|
||||
entered.resolve();
|
||||
return availability.promise;
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
execute: () => Promise.resolve(ok(null)),
|
||||
};
|
||||
const server = createServer(session, session, {
|
||||
androidAnalysis,
|
||||
logger: silentLogger,
|
||||
});
|
||||
const mcp = new Client({ name: "status-readiness", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(mcp, server);
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
|
||||
const status = mcp.callTool({ name: "binary_session", arguments: {} });
|
||||
await entered.promise;
|
||||
expect((await session.open(targetPath)).ok).toBe(true);
|
||||
availability.resolve({
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: { configured: true },
|
||||
});
|
||||
|
||||
expect(structured(await status).result).toMatchObject({
|
||||
open: true,
|
||||
path: targetPath,
|
||||
});
|
||||
});
|
||||
|
||||
it("reopens replaced content at one canonical path through MCP", async () => {
|
||||
directory = await createTestTempDirectory("rea-mcp-replaced-target-");
|
||||
const targetPath = join(directory, "mutable.hop");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { access, rm, writeFile } from "node:fs/promises";
|
||||
import { access, chmod, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
|
|
@ -20,6 +20,15 @@ export const createJadxProtocolFixture = async (mode = "normal") => {
|
|||
const root = await createTestTempDirectory("rea-android-boundary-");
|
||||
const apk = join(root, "fixture.apk");
|
||||
const jar = join(root, "fixture.jar");
|
||||
const javaHome = join(root, "jdk");
|
||||
const javaBin = join(javaHome, "bin");
|
||||
const java = join(javaBin, "java");
|
||||
await mkdir(javaBin, { recursive: true });
|
||||
await writeFile(
|
||||
java,
|
||||
`#!/usr/bin/env node\nif (process.argv[2] === "--list-modules") console.log("java.base@21.0.0\\njdk.compiler@21.0.0");\n`,
|
||||
);
|
||||
await chmod(java, 0o755);
|
||||
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
|
||||
await writeFile(jar, "synthetic engine bytes; not a Java archive");
|
||||
const launches: {
|
||||
|
|
@ -32,6 +41,7 @@ export const createJadxProtocolFixture = async (mode = "normal") => {
|
|||
}[] = [];
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
JAVA_HOME: javaHome,
|
||||
REA_JADX_MCP_JAR: jar,
|
||||
};
|
||||
const provider = createAndroidAnalysisProvider(
|
||||
|
|
|
|||
Loading…
Reference in New Issue