diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts index 0ac231d99a..dc24522b75 100644 --- a/src/adb/AdbDeviceOutput.ts +++ b/src/adb/AdbDeviceOutput.ts @@ -169,7 +169,7 @@ export const parseGetpropOutput = ( } const properties = DEVICE_PROPERTY_WHITELIST.flatMap((name) => { const value = values.get(name); - return value === undefined || value === "" + return value === undefined ? [] : [{ name, value } satisfies AdbDeviceProperty]; }); diff --git a/src/adb/AdbShellOutput.test.ts b/src/adb/AdbShellOutput.test.ts index 9af4debbaf..8748eca920 100644 --- a/src/adb/AdbShellOutput.test.ts +++ b/src/adb/AdbShellOutput.test.ts @@ -18,6 +18,16 @@ const packageDump = (installerLine: string | undefined): string => ].join("\n"); describe("parseDumpsysPackageOutput", () => { + it.each(["", " My Build ", " ", "null"])( + "preserves the printed version name %j", + (versionName) => { + const result = parseDumpsysPackageOutput( + ` pkg=Package{abcdef com.example}\n versionName=${versionName}\n`, + ); + expect(result.versionName).toBe(versionName); + }, + ); + it("keeps a reported installer package name", () => { expect( parseDumpsysPackageOutput( diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index 2ae876384f..6ba646611b 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -335,13 +335,16 @@ export const parseDumpsysPackageOutput = ( const match = DETAIL_LINE.exec(line); if (match === null) continue; const key = match[1]!; - const value = match[2]!.trim(); + // versionName is an arbitrary manifest string, printed verbatim by AOSP. + // Only the fields with their own structured grammar are normalized below. + const rawValue = match[2]!; + const value = rawValue.trim(); if (key === "pkg" && parsedPackageAvailable === null) { if (value === "null") parsedPackageAvailable = false; else if (value.startsWith("Package{") && value.endsWith("}")) parsedPackageAvailable = true; } else if (key === "versionName" && details.versionName === null) - details.versionName = value === "" ? null : value; + details.versionName = rawValue; else if (key === "versionCode" && details.versionCode === null) { const parsed = /^(-?\d+)/u.exec(value); if (parsed !== null) details.versionCode = parsed[1]!; diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts index 1d1299dd5f..e84f34a1ec 100644 --- a/src/contracts/adb/adbToolContracts.ts +++ b/src/contracts/adb/adbToolContracts.ts @@ -32,7 +32,7 @@ export const ADB_TOOL_CONTRACTS = [ name: "inspect_adb_device", ...toolContractMetadata("inspect_adb_device"), description: - "Inspect one device's build identity through a fixed `getprop` whitelist: Android and SDK versions, security patch, fingerprint, product identity, CPU ABIs, and debuggable flag, plus the observed emulator indicator ro.kernel.qemu. Read-only with respect to the device.", + "Inspect one device's build identity through a fixed `getprop` whitelist: Android and SDK versions, security patch, fingerprint, product identity, CPU ABIs, and debuggable flag, plus the observed emulator indicator ro.kernel.qemu. Present properties retain empty values; omitted keys were not reported. Read-only with respect to the device.", kind: "adb-provider", inputSchema: adbInputSchemas.inspect_adb_device, outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_device), diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 3249a1f6f7..b0effcf2bf 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -527,7 +527,7 @@ export const adbResultSchemas = { client, serial: z.string().min(1), properties: z.array( - z.strictObject({ name: z.string().min(1), value: z.string().min(1) }), + z.strictObject({ name: z.string().min(1), value: z.string() }), ), property_count: z.int().nonnegative(), emulator_observed: z.boolean().nullable(), diff --git a/tests/boundary/mcp/adbDeviceProperties.test.ts b/tests/boundary/mcp/adbDeviceProperties.test.ts new file mode 100644 index 0000000000..179d572a23 --- /dev/null +++ b/tests/boundary/mcp/adbDeviceProperties.test.ts @@ -0,0 +1,132 @@ +import { chmod } from "node:fs/promises"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { AdbProvider } from "../../../src/adb/AdbProvider.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +const scenarios = [ + { + title: "empty, whitespace and zero device properties", + tool: "inspect_adb_device", + argv: ["inspect-adb-device", "device", "--json"], + input: { serial: "device" }, + expected: { + serial: "device", + property_count: 4, + emulator_observed: false, + properties: [ + { name: "ro.build.version.security_patch", value: "" }, + { name: "ro.debuggable", value: "0" }, + { name: "ro.kernel.qemu", value: "0" }, + { name: "ro.product.model", value: " " }, + ], + }, + }, + { + title: "version name whitespace", + tool: "inspect_adb_package", + argv: ["inspect-adb-package", "device", "com.example", "--json"], + input: { serial: "device", package: "com.example" }, + expected: { + serial: "device", + package_name: "com.example", + version_name: " My Build ", + version_code: "0", + }, + }, +] as const; + +for (const scenario of scenarios) { + it.skipIf(process.platform === "win32").each(["cli", "mcp"] as const)( + `preserves ${scenario.title} through %s`, + async (adapter) => { + const workspace = await createTestWorkspace("rea-adb-properties-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const binary = await workspace.write( + "adb", + `#!/bin/sh +if [ "$1" = version ]; then + printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' "$0" + exit 0 +fi +if [ "$1" = -s ] && [ "$3" = shell ] && [ "$4" = getprop ]; then + printf '[ro.build.version.security_patch]: []\\n[ro.product.model]: [ ]\\n[ro.debuggable]: [0]\\n[ro.kernel.qemu]: [0]\\n[sys.unlisted]: []\\n' + exit 0 +fi +if [ "$1" = -s ] && [ "$3" = shell ] && [ "$4" = dumpsys ]; then + printf ' Package [com.example] (abcdef):\\n pkg=Package{abcdef com.example}\\n versionName= My Build \\n versionCode=0 minSdk=23 targetSdk=34\\n' + exit 0 +fi +exit 1 +`, + ); + await chmod(binary, 0o755); + let evidence; + if (adapter === "cli") { + const output = await execFileOutput( + process.execPath, + ["scripts/rea.mjs", ...scenario.argv], + { + env: { ...process.env, REA_ADB_PATH: binary }, + timeout: 20_000, + stopSignal: "SIGTERM", + maxBuffer: 1024 * 1024, + }, + ); + evidence = parseEvidence(JSON.parse(output.stdout)); + } else { + const server = createServer( + { + kind: "session", + session: createTestBinarySession(() => { + throw new Error( + "Device inspection must not open a binary provider", + ); + }), + }, + { + adbDeviceAnalysis: new AdbProvider({ + environment: { REA_ADB_PATH: binary }, + }), + }, + ); + onTestFinished(() => server.close()); + const client = new Client({ name: "adb-properties", version: "1" }); + onTestFinished(() => client.close()); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const { tools } = await client.listTools(); + const advertised = tools.find(({ name }) => name === scenario.tool); + const outputSchema = z + .record(z.string(), z.unknown()) + .parse(advertised?.outputSchema); + const ajv = new Ajv2020({ strict: false, allErrors: true }); + expect(ajv.validateSchema(outputSchema)).toBe(true); + const validate = ajv.compile(outputSchema); + const reply = await client.callTool({ + name: scenario.tool, + arguments: scenario.input, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect( + validate(reply.structuredContent), + JSON.stringify(validate.errors), + ).toBe(true); + evidence = parseEvidence(reply.structuredContent); + } + expect(evidence.normalized_result).toMatchObject(scenario.expected); + }, + ); +}