fix(adb): preserve empty properties and verbatim version names

This commit is contained in:
morluto
2026-10-23 16:23:56 +08:00
parent 14e745950e
commit bcd1f95e68
6 changed files with 150 additions and 5 deletions
+1 -1
View File
@@ -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];
});
+10
View File
@@ -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(
+5 -2
View File
@@ -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]!;
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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(),
@@ -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);
},
);
}