From e5deb832cb09bb524877d0df40f49d5b5bb7dea4 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:53:14 +0800 Subject: [PATCH] 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 --- docs/adb-device-analysis.md | 3 + docs/apktool-resource-analysis.md | 2 + src/adb/AdbConfiguration.test.ts | 8 +++ src/adb/AdbConfiguration.ts | 38 +++++++++--- src/adb/AdbProvider.test.ts | 30 ++++++++++ src/apktool/ApktoolConfiguration.test.ts | 23 ++++++- src/apktool/ApktoolConfiguration.ts | 62 +++++++++++++++---- src/apktool/ApktoolProvider.test.ts | 37 +++++++++++- src/application/CapabilityInventory.test.ts | 47 +++++++++++++++ src/application/CapabilityInventory.ts | 31 ++++++++++ src/server/createServer.ts | 4 ++ src/server/registerSessionStatusTool.ts | 27 ++++++++- src/server/registerSessionTools.ts | 26 ++++++++ tests/boundary/mcp/sessionMcp.test.ts | 66 ++++++++++++++++++++- 14 files changed, 379 insertions(+), 25 deletions(-) diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 8c94df25f..44b5e1334 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -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 diff --git a/docs/apktool-resource-analysis.md b/docs/apktool-resource-analysis.md index f29d7948c..447d6ccfc 100644 --- a/docs/apktool-resource-analysis.md +++ b/docs/apktool-resource-analysis.md @@ -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, diff --git a/src/adb/AdbConfiguration.test.ts b/src/adb/AdbConfiguration.test.ts index ea5039d22..bc786db71 100644 --- a/src/adb/AdbConfiguration.test.ts +++ b/src/adb/AdbConfiguration.test.ts @@ -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 }); + }); }); diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts index 15e2e40eb..704d8d683 100644 --- a/src/adb/AdbConfiguration.ts +++ b/src/adb/AdbConfiguration.ts @@ -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 => { 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 }); diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index c9be8b176..77b06e3c9 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -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 diff --git a/src/apktool/ApktoolConfiguration.test.ts b/src/apktool/ApktoolConfiguration.test.ts index 39cf1c652..aae9d9ed1 100644 --- a/src/apktool/ApktoolConfiguration.test.ts +++ b/src/apktool/ApktoolConfiguration.test.ts @@ -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", + }); }); }); diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index b1861f2ac..729c670e9 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -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, diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index 5ac36876a..2796c933b 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -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), { diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 4f1a217f0..7fcd237bb 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -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(), { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 5f3fba6a6..139c7d6f8 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -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, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 4a018306e..23e09e06b 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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, }); } diff --git a/src/server/registerSessionStatusTool.ts b/src/server/registerSessionStatusTool.ts index d0f4d2580..89420c8bb 100644 --- a/src/server/registerSessionStatusTool.ts +++ b/src/server/registerSessionStatusTool.ts @@ -30,6 +30,12 @@ export interface SessionStatusToolOptions { readonly androidAnalysisAvailability: ( signal: AbortSignal, ) => Promise; + readonly adbAnalysisAvailability?: ( + signal: AbortSignal, + ) => Promise; + readonly apktoolAnalysisAvailability?: ( + signal: AbortSignal, + ) => Promise; } /** 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, ); diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 16b9e6408..2e2429cf7 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -155,6 +155,12 @@ export interface LifecycleToolRegistration { readonly androidAnalysisAvailability: ( signal: AbortSignal, ) => Promise; + readonly adbAnalysisAvailability?: ( + signal: AbortSignal, + ) => Promise; + readonly apktoolAnalysisAvailability?: ( + signal: AbortSignal, + ) => Promise; readonly closeProcessResources: () => Promise>; } @@ -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; + readonly adbAnalysisAvailability?: ( + signal: AbortSignal, + ) => Promise; + readonly apktoolAnalysisAvailability?: ( + signal: AbortSignal, + ) => Promise; } 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(); diff --git a/tests/boundary/mcp/sessionMcp.test.ts b/tests/boundary/mcp/sessionMcp.test.ts index 5b8c7de00..2c3627502 100644 --- a/tests/boundary/mcp/sessionMcp.test.ts +++ b/tests/boundary/mcp/sessionMcp.test.ts @@ -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-");