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:
1 parent
cf806ea02a
commit
e5deb832cb
14 files changed
+379
-25
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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-");
|
||||
|
||||
Reference in new issue
Block a user