fix(android): report validated ADB and Apktool host availability

Probe the selected clients through their owning providers and expose current host availability through binary_session. Keep device/serial and target applicability separate. Reject unrecognized successful version output; retain the bounded producer output and original version spelling. Apply the documented Apktool floor, including the verified Debian dirty build.

Validation: provider/version tests and public MCP grouped availability; no real Android device or APK provider verification claimed.

Fixes #3067
Fixes #3068
Related: #3077

Co-authored-by: Taksh <takshkothari09@gmail.com>
This commit is contained in:
morlutoandTaksh committed 2026-10-14 16:54:08 +08:00
1 parent cf806ea02a
commit e5deb832cb
14 files changed
+379 -25

No files matched your search

+3
View File
@@ -26,6 +26,9 @@ Readiness is `rea inspect-adb-client`: it runs `adb version` and reports the
resolved binary, its version, and where it was selected from. A missing
binary reports `capability_unavailable` with the `REA_ADB_PATH` remediation
rather than attempting an installation.
The `binary_session` inventory uses the same bounded client-version probe; it
does not query devices, start the adb server, or establish that a requested
serial is reachable. Device state is checked by each device operation.
## Answer an analyst question
+2
View File
@@ -21,6 +21,8 @@ Readiness is `rea inspect-apktool-client`: it runs `apktool --version` and
reports the resolved launcher, its selection source, and its version. A
missing launcher reports `capability_unavailable` with the
`REA_APKTOOL_COMMAND` remediation rather than attempting an installation.
`binary_session` uses the same host probe for tool availability; it does not
validate a particular APK or guarantee that the selected input can be decoded.
On Windows, a `.bat` or `.cmd` launcher (such as Chocolatey's `apktool.bat`)
runs through an explicit `cmd.exe /d /s /c` call. That call has no AutoRun,
+8
View File
@@ -55,4 +55,12 @@ describe("parseAdbVersionOutput", () => {
installedPath: null,
});
});
it("does not accept arbitrary successful output as an adb version", () => {
expect(
parseAdbVersionOutput(
"Android Debug Bridge version tomorrow\nVersion unknown\n",
),
).toMatchObject({ version: null });
});
});
+30 -8
View File
@@ -67,7 +67,9 @@ export interface AdbClientIdentity {
* `Version ` the build version (no colon), and `Installed as` the binary path.
*/
export const parseAdbVersionOutput = (stdout: string): AdbClientIdentity => {
const build = /^Version\s+(\S.*)$/mu.exec(stdout);
const build = /^Version\s+(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/mu.exec(
stdout,
);
const installed = /^Installed as\s+(.+)$/mu.exec(stdout);
return {
version: build === null ? null : build[1]!.trim(),
@@ -84,14 +86,34 @@ export const inspectAdbClient = async (
signal?: AbortSignal,
): Promise<AdbClientIdentity> => {
try {
const { stdout } = await execFileOutput(selection.binary, ["version"], {
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
});
return parseAdbVersionOutput(stdout);
const { stdout, stderr } = await execFileOutput(
selection.binary,
["version"],
{
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
},
);
const identity = parseAdbVersionOutput(stdout);
const hasRecognizedProtocolVersion =
/^Android Debug Bridge version\s+\d+\.\d+\.\d+\s*$/mu.test(stdout);
if (identity.version === null && !hasRecognizedProtocolVersion)
throw new AdbConfigurationFailure(
"version_unresolved",
"The selected adb binary exited successfully but did not report a recognizable protocol or build version",
{
selected_binary: selection.binary,
path_source: selection.pathSource,
version_probe: "unrecognized_output",
stdout,
stderr,
},
);
return identity;
} catch (cause) {
if (cause instanceof AdbConfigurationFailure) throw cause;
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
throw new AnalysisCancelledError("inspect_adb_client", { cause });
+30
View File
@@ -91,6 +91,36 @@ it("inspects the adb client without contacting the server", async () => {
expect(result.contacted_adb_server).toBe(false);
});
it("fails closed when adb exits successfully without a recognized version", async () => {
const stdout = `unexpected adb output ${"x".repeat(700)}\n`;
const stderr = `probe stderr ${"y".repeat(700)}\n`;
const { binary } = await writeAdbStub(
() => `printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit 0`,
);
await expect(
providerOver(binary).inspectAvailability(),
).resolves.toMatchObject({
status: "unavailable",
code: "version_unresolved",
diagnostics: {
version_probe: "unrecognized_output",
stdout,
stderr,
},
});
});
it("accepts adb's recognized protocol version when the build version is absent", async () => {
const { binary } = await writeAdbStub(
() => `printf 'Android Debug Bridge version 1.0.41\\n'`,
);
await expect(
providerOver(binary).inspectAvailability(),
).resolves.toMatchObject({
status: "available",
});
});
it("lists devices with inferred kinds and observed daemon startup", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
+22 -1
View File
@@ -58,7 +58,28 @@ describe("parseApktoolVersionOutput", () => {
});
it("reports null when the launcher prints nothing usable", () => {
expect(parseApktoolVersionOutput(" \n")).toEqual({ version: null });
expect(parseApktoolVersionOutput(" \n")).toEqual({
version: null,
reportedVersion: null,
});
expect(parseApktoolVersionOutput("successful but not a version\n")).toEqual(
{
version: null,
reportedVersion: "successful but not a version",
},
);
});
it("parses semantic prerelease versions without claiming they meet the minimum", () => {
expect(parseApktoolVersionOutput("2.7.0-beta.1\n")).toEqual({
version: "2.7.0-beta.1",
});
});
it("validates normalized semantic versions while preserving caller spelling", () => {
expect(parseApktoolVersionOutput("v2.7.0\n")).toEqual({
version: "v2.7.0",
});
});
});
+52 -10
View File
@@ -15,6 +15,7 @@ import {
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { gte, valid } from "semver";
import {
isWindowsBatchFile,
windowsBatchCommand,
@@ -148,7 +149,8 @@ export type ApktoolConfigurationFailureCode =
| "command_missing"
| "command_unresolved"
| "launcher_unsupported"
| "version_unresolved";
| "version_unresolved"
| "unsupported_version";
export class ApktoolConfigurationFailure extends Error {
constructor(
@@ -161,9 +163,9 @@ export class ApktoolConfigurationFailure extends Error {
}
}
export interface ApktoolClientIdentity {
readonly version: string | null;
}
export type ApktoolClientIdentity =
| { readonly version: string; readonly reportedVersion?: never }
| { readonly version: null; readonly reportedVersion: string | null };
/** Process creation for one apktool run on the selected host. */
export interface ApktoolInvocation {
@@ -199,7 +201,7 @@ export const apktoolInvocation = (
windowsVerbatimArguments: false,
};
/** Read the version from `apktool --version` output (first non-empty line). */
/** Read the version from Apktool's single-line `--version` response. */
export const parseApktoolVersionOutput = (
stdout: string,
): ApktoolClientIdentity => {
@@ -207,7 +209,17 @@ export const parseApktoolVersionOutput = (
.split(/\r?\n/u)
.map((entry) => entry.trim())
.find((entry) => entry !== "");
return { version: line === undefined ? null : line };
if (line === undefined || valid(line) === null)
return { version: null, reportedVersion: line ?? null };
return { version: line };
};
const supportsDocumentedApktoolVersion = (version: string): boolean => {
// Debian's verified 2.7.0-dirty build is a downstream build marker, not a
// prerelease. Treat only that known marker as build metadata for the floor.
const comparisonVersion = version.replace(/-dirty$/u, "+dirty");
const parsedVersion = valid(comparisonVersion);
return parsedVersion !== null && gte(parsedVersion, "2.7.0");
};
/** Probe the selected apktool launcher without decoding anything. */
@@ -239,8 +251,10 @@ export const inspectApktoolClient = async (
{ cause },
);
}
let stdout: string;
let stderr: string;
try {
const { stdout } = await execFileOutput(
({ stdout, stderr } = await execFileOutput(
invocation.command,
invocation.arguments,
{
@@ -251,8 +265,7 @@ export const inspectApktoolClient = async (
env: hostEnvironment,
...(signal === undefined ? {} : { signal }),
},
);
return parseApktoolVersionOutput(stdout);
));
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
@@ -283,6 +296,33 @@ export const inspectApktoolClient = async (
{ cause },
);
}
const identity = parseApktoolVersionOutput(stdout);
if (identity.version === null)
throw new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher exited successfully but did not report a recognizable version",
{
selected_command: selection.command,
command_source: selection.commandSource,
version_probe: "unrecognized_output",
reported_version: identity.reportedVersion,
stdout,
stderr,
},
);
if (!supportsDocumentedApktoolVersion(identity.version))
throw new ApktoolConfigurationFailure(
"unsupported_version",
`The selected Apktool version ${identity.version} is older than the supported minimum 2.7.0`,
{
selected_command: selection.command,
command_source: selection.commandSource,
reported_version: identity.version,
minimum_version: "2.7.0",
remediation: `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
},
);
return identity;
};
/** Availability in the shape shared by every provider. */
@@ -295,7 +335,9 @@ const rejectionCodeOf = (
code === "launcher_unsupported" ||
code === "command_unresolved"
? "not_configured"
: "version_unresolved";
: code === "unsupported_version"
? "unsupported_version"
: "version_unresolved";
export const apktoolConfigurationAvailability = (
cause: ApktoolConfigurationFailure,
+35 -2
View File
@@ -17,7 +17,7 @@ import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.j
const it = test.skipIf(process.platform === "win32");
const VERSION_BLOCK = "printf '2.7.0-stub\\n'";
const VERSION_BLOCK = "printf '2.7.0-dirty\\n'";
const REAL_YML = [
"!!brut.androlib.meta.MetaInfo",
@@ -172,7 +172,7 @@ it("inspects the apktool launcher", async () => {
expect(result.client).toEqual({
command,
command_source: "environment",
apktool_version: "2.7.0-stub",
apktool_version: "2.7.0-dirty",
});
});
@@ -355,6 +355,39 @@ it("reports a missing launcher as a capability gap with recovery", async () => {
});
});
it("fails closed for unrecognized and unsupported Apktool versions", async () => {
const invalidOutput = `unexpected Apktool output ${"x".repeat(700)}\n`;
const unrecognized = await writeApktoolStub(
() => `printf '%s' '${invalidOutput}'; exit 0`,
);
await expect(
providerOver(unrecognized.command).inspectAvailability(),
).resolves.toMatchObject({
status: "unavailable",
code: "version_unresolved",
diagnostics: {
reported_version: invalidOutput.trim(),
stdout: invalidOutput,
},
});
const old = await writeApktoolStub(() => `printf '2.6.3\\n'`);
await expect(
providerOver(old.command).inspectAvailability(),
).resolves.toMatchObject({
status: "unavailable",
code: "unsupported_version",
});
const prerelease = await writeApktoolStub(() => `printf '2.7.0-beta.1\\n'`);
await expect(
providerOver(prerelease.command).inspectAvailability(),
).resolves.toMatchObject({
status: "unavailable",
code: "unsupported_version",
});
});
it("rejects a target that is not a readable file", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const projected = await refusedBy(providerOver(command), {
@@ -97,6 +97,53 @@ const entry = (
return found;
};
describe("ADB and Apktool host availability", () => {
it("uses only their selected owner probes", () => {
const ready = {
status: "available" as const,
code: null,
reason: null,
diagnostics: {},
};
const configuredButInvalid = {
status: "unavailable" as const,
code: "not_configured" as const,
reason: "REA_ADB_PATH is invalid",
diagnostics: {},
};
const readyInventory = buildCapabilityInventory(status(), {
...enabledPolicy,
adbAnalysisAvailability: ready,
apktoolAnalysisAvailability: ready,
});
expect(
readyInventory.find(({ name }) => name === "inspect_adb_client"),
).toMatchObject({ available: true, reason: "available" });
expect(
readyInventory.find(({ name }) => name === "inspect_apktool_client"),
).toMatchObject({ available: true, reason: "available" });
const unavailableInventory = buildCapabilityInventory(status(), {
...enabledPolicy,
adbAnalysisAvailability: configuredButInvalid,
apktoolAnalysisAvailability: {
status: "unavailable",
code: "unsupported_version",
reason: "Apktool 2.6.3 is older than 2.7.0",
diagnostics: {},
},
});
expect(
unavailableInventory.find(({ name }) => name === "inspect_adb_client"),
).toMatchObject({ available: false, reason: "provider_missing" });
expect(
unavailableInventory.find(
({ name }) => name === "decode_android_resources",
),
).toMatchObject({ available: false, reason: "provider_unavailable" });
});
});
describe("capability failure reasons", () => {
it("keeps an unavailable Android runtime distinct from a missing provider", () => {
const inventory = buildCapabilityInventory(status(), {
+31
View File
@@ -54,6 +54,8 @@ export type AvailabilityPolicy = {
readonly firmwareInspectionEnabled?: boolean;
readonly firmwareExtractionEnabled?: boolean;
readonly androidAnalysisAvailability?: ProviderAvailability;
readonly adbAnalysisAvailability?: ProviderAvailability;
readonly apktoolAnalysisAvailability?: ProviderAvailability;
readonly jebAnalysisAvailability?: ProviderAvailability;
readonly javascriptRecoveryEnabled?: boolean;
readonly webModuleResolutionEnabled?: boolean;
@@ -473,6 +475,16 @@ const workflowAvailabilityFor = ({
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, macOS, or Windows x64 with its matching bundled native controls.",
};
if (kind === "adb-provider")
return ownerProbeAvailability(
policy.adbAnalysisAvailability,
"Set REA_ADB_PATH to a caller-supplied adb executable or make adb available on PATH. This checks the client version only; device and serial availability depend on the request.",
);
if (kind === "apktool-provider")
return ownerProbeAvailability(
policy.apktoolAnalysisAvailability,
"Set REA_APKTOOL_COMMAND to a caller-supplied Apktool executable or make Apktool 2.7.0 or newer available on PATH.",
);
if (kind === "jeb-provider")
return policy.jebAnalysisAvailability?.status === "available"
? { reason: "available", remediation: null }
@@ -506,6 +518,25 @@ const workflowAvailabilityFor = ({
return null;
};
const ownerProbeAvailability = (
observation: ProviderAvailability | undefined,
missingRemediation: string,
): Availability => {
if (observation === undefined)
return { reason: "provider_missing", remediation: missingRemediation };
if (observation.status === "available")
return { reason: "available", remediation: null };
return {
reason:
observation.code === "unsupported_host"
? "unsupported_host"
: observation.code === "not_configured"
? "provider_missing"
: "provider_unavailable",
remediation: observation.reason,
};
};
const electronProviderAvailability = (
name: string,
kind: ToolKind,
+4
View File
@@ -577,6 +577,10 @@ export const createServer = (
: { availabilityPolicy: availability.policy }),
androidAnalysisAvailability: (signal) =>
android.inspectAvailability(signal),
adbAnalysisAvailability: (signal) =>
adbDevice.inspectAvailability(signal),
apktoolAnalysisAvailability: (signal) =>
apktool.inspectAvailability(signal),
startedAt,
});
}
+24 -3
View File
@@ -30,6 +30,12 @@ export interface SessionStatusToolOptions {
readonly androidAnalysisAvailability: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly adbAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly apktoolAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
}
/** Register the read-only provider and target status operation. */
@@ -43,6 +49,8 @@ export const registerSessionStatusTool = (
startedAt,
availabilityPolicy,
androidAnalysisAvailability,
adbAnalysisAvailability,
apktoolAnalysisAvailability,
} = options;
server.registerTool(
contract.name,
@@ -53,10 +61,17 @@ export const registerSessionStatusTool = (
server.server,
);
let androidAvailability: ProviderAvailability;
let adbAvailability: ProviderAvailability | undefined;
let apktoolAvailability: ProviderAvailability | undefined;
try {
androidAvailability = await androidAnalysisAvailability(
context.mcpReq.signal,
);
const [android, adb, apktool] = await Promise.all([
androidAnalysisAvailability(context.mcpReq.signal),
adbAnalysisAvailability?.(context.mcpReq.signal) ?? undefined,
apktoolAnalysisAvailability?.(context.mcpReq.signal) ?? undefined,
] as const);
androidAvailability = android;
adbAvailability = adb;
apktoolAvailability = apktool;
} catch (cause) {
if (!context.mcpReq.signal.aborted) throw cause;
return server.delivery.toCallToolResult(
@@ -71,6 +86,12 @@ export const registerSessionStatusTool = (
{
...availabilityPolicy(),
androidAnalysisAvailability: androidAvailability,
...(adbAvailability === undefined
? {}
: { adbAnalysisAvailability: adbAvailability }),
...(apktoolAvailability === undefined
? {}
: { apktoolAnalysisAvailability: apktoolAvailability }),
},
clientFeatures,
);
+26
View File
@@ -155,6 +155,12 @@ export interface LifecycleToolRegistration {
readonly androidAnalysisAvailability: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly adbAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly apktoolAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly closeProcessResources: () => Promise<Result<null, AnalysisError>>;
}
@@ -169,6 +175,8 @@ const registerLifecycleTools = (
startedAt,
availabilityPolicy,
androidAnalysisAvailability,
adbAnalysisAvailability,
apktoolAnalysisAvailability,
} = registration;
registerOpenLifecycleTool(registration, artifactResources);
registerCloseLifecycleTool(registration);
@@ -179,6 +187,12 @@ const registerLifecycleTools = (
startedAt,
availabilityPolicy,
androidAnalysisAvailability,
...(adbAnalysisAvailability === undefined
? {}
: { adbAnalysisAvailability }),
...(apktoolAnalysisAvailability === undefined
? {}
: { apktoolAnalysisAvailability }),
});
};
@@ -238,6 +252,12 @@ export interface SessionToolOptions {
readonly androidAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly adbAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
readonly apktoolAnalysisAvailability?: (
signal: AbortSignal,
) => Promise<ProviderAvailability>;
}
const registerContextTools = (
@@ -317,6 +337,12 @@ export const registerSessionTools = (
),
androidAnalysisAvailability:
options.androidAnalysisAvailability ?? missingAndroidAvailability,
...(options.adbAnalysisAvailability === undefined
? {}
: { adbAnalysisAvailability: options.adbAnalysisAvailability }),
...(options.apktoolAnalysisAvailability === undefined
? {}
: { apktoolAnalysisAvailability: options.apktoolAnalysisAvailability }),
closeProcessResources: async () => {
try {
await processResources.retryCleanup();
+65 -1
View File
@@ -19,6 +19,8 @@ import type {
ProviderAvailability,
} from "../../../src/application/AnalysisProvider.js";
import type { AndroidAnalysisPort } from "../../../src/application/android/AndroidAnalysisPort.js";
import type { AdbDeviceAnalysisPort } from "../../../src/application/adb/AdbDeviceAnalysisPort.js";
import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js";
import { observed as ok } from "../../fixtures/analysisExecution.js";
import { createServer } from "../../../src/server/createServer.js";
import { silentLogger } from "../../../src/logger.js";
@@ -26,7 +28,10 @@ import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { MAX_JSON_DEPTH } from "../../../src/domain/jsonValue.js";
import { parseAnalysisSnapshot } from "../../../src/domain/analysisSnapshot.js";
import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js";
import { expandProviderCapabilities } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import {
expandProviderCapabilities,
toolAvailabilityInventory,
} from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import {
@@ -153,6 +158,65 @@ describe("MCP snapshot lifecycle ordering", () => {
}, 10_000);
});
describe("public provider availability", () => {
it("reports ADB and Apktool host probes in the public session inventory", async () => {
const closed: string[] = [];
const session = createTestBinarySession(provider(closed));
const available: ProviderAvailability = {
status: "available",
code: null,
reason: null,
diagnostics: { version: "1.0.41" },
};
const unavailable: ProviderAvailability = {
status: "unavailable",
code: "unsupported_version",
reason: "Use Apktool 2.7.0 or newer.",
diagnostics: { minimum_version: "2.7.0" },
};
const adbDeviceAnalysis: AdbDeviceAnalysisPort = {
inspectAvailability: async () => available,
close: async () => {},
execute: async () => ok(null),
};
const apktoolAnalysis: ApktoolResourceAnalysisPort = {
inspectAvailability: async () => unavailable,
close: async () => {},
execute: async () => ok(null),
};
const server = createServer(
{ kind: "session", session },
{ adbDeviceAnalysis, apktoolAnalysis, logger: silentLogger },
);
const mcp = new Client({
name: "provider-host-readiness",
version: "1.0.0",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server);
await server.connect(serverTransport);
await mcp.connect(clientTransport);
const status = structured(
await mcp.callTool({ name: "binary_session", arguments: {} }),
);
const availability = toolAvailabilityInventory.parse(
(status.result as { tool_availability: unknown }).tool_availability,
);
expect(
availability.groups.find(({ names }) =>
names.includes("inspect_adb_client"),
),
).toMatchObject({ available: true, reason: "available" });
expect(
availability.groups.find(({ names }) =>
names.includes("inspect_apktool_client"),
),
).toMatchObject({ available: false, reason: "provider_unavailable" });
});
});
describe("target-free MCP lifecycle", () => {
it("takes the session snapshot after Android readiness inspection completes", async () => {
directory = await createTestTempDirectory("rea-mcp-status-readiness-");