From 913c9fe19461cee27f2e2499eda7bb37e889e2c7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:18:35 +0800 Subject: [PATCH] fix(adb): preserve exact transport identity observations (#10062) --- src/adb/AdbDeviceOperations.ts | 2 + src/adb/AdbDeviceOutput.test.ts | 80 ++++++++++- src/adb/AdbDeviceOutput.ts | 34 ++--- src/adb/AdbProvider.test.ts | 24 +++- .../adb/AdbDeviceAnalysisService.test.ts | 130 +++++++++++++++++- .../adb/AdbDeviceAnalysisService.ts | 96 ++++++++++++- src/contracts/adb/adbToolContracts.ts | 2 +- src/domain/adb/adbDeviceAnalysis.ts | 35 ++++- src/domain/adb/adbTransportIdentity.ts | 69 ++++++++++ tests/boundary/cli/adbCli.test.ts | 39 ++++++ tests/boundary/mcp/adbMcp.test.ts | 68 ++++++++- 11 files changed, 542 insertions(+), 37 deletions(-) create mode 100644 src/domain/adb/adbTransportIdentity.ts diff --git a/src/adb/AdbDeviceOperations.ts b/src/adb/AdbDeviceOperations.ts index 87664314f3..d989f98791 100644 --- a/src/adb/AdbDeviceOperations.ts +++ b/src/adb/AdbDeviceOperations.ts @@ -61,6 +61,8 @@ export const listAdbDevicesOperation = async ( context.signal, ); if (!ran.ok) return ran; + // Keep exact transport identities internally; the shared client boundary + // alone produces the legacy numeric projection. const { devices, unparsedLines, daemonStarted } = parseAdbDevicesOutput( ran.value.stdout, ran.value.stderr, diff --git a/src/adb/AdbDeviceOutput.test.ts b/src/adb/AdbDeviceOutput.test.ts index bd93598a99..279ce3d723 100644 --- a/src/adb/AdbDeviceOutput.test.ts +++ b/src/adb/AdbDeviceOutput.test.ts @@ -28,7 +28,13 @@ describe("parseAdbDevicesOutput", () => { product: "sdk_gphone64_x86_64", model: "sdk_gphone64_x86_64", device: "emu64xa", - transport_id: 1, + transport_identity: { + status: "reported", + decimal: "1", + source_token: "1", + source_line: + "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1", + }, kind: "emulator", kind_basis: "emulator_serial_prefix", }, @@ -39,7 +45,13 @@ describe("parseAdbDevicesOutput", () => { product: "raven", model: "Pixel_6_Pro", device: "raven", - transport_id: 2, + transport_identity: { + status: "reported", + decimal: "2", + source_token: "2", + source_line: + "07ef9c2a device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2", + }, kind: "physical", kind_basis: "usb_transport", }, @@ -50,7 +62,13 @@ describe("parseAdbDevicesOutput", () => { product: "mock", model: "mock", device: null, - transport_id: 3, + transport_identity: { + status: "reported", + decimal: "3", + source_token: "3", + source_line: + "192.168.1.42:5555 device product:mock model:mock transport_id:3", + }, kind: "unknown", kind_basis: "none", }, @@ -196,7 +214,12 @@ describe("ADB device states", () => { product: null, model: null, device: null, - transport_id: 4, + transport_identity: { + status: "reported", + decimal: "4", + source_token: "4", + source_line: `07ef9c2a ${stateText} usb:1-1 transport_id:4`, + }, kind: "physical", }); }); @@ -229,7 +252,12 @@ describe("ADB device states", () => { "", ].join("\n"); const { devices } = parseAdbDevicesOutput(output); - expect(devices[0]?.transport_id).toBeNull(); + expect(devices[0]?.transport_identity).toEqual({ + status: "invalid", + source_token: "not-a-number", + source_line: "emulator-5554 device transport_id:not-a-number", + reason: "invalid_decimal", + }); }); }); @@ -412,3 +440,45 @@ describe("parsePmPathOutput", () => { ).toEqual({ sample: [" package:/data/app/pkg/base.apk "], total: 1 }); }); }); + +it.each([ + ["1", "reported", "1"], + ["2147483648", "reported", "2147483648"], + ["9007199254740991", "reported", "9007199254740991"], + ["9007199254740993", "reported", "9007199254740993"], + ["18446744073709551615", "reported", "18446744073709551615"], + ["0001", "reported", "1"], + ["0", "invalid", "reserved_zero"], + ["-1", "invalid", "invalid_decimal"], + ["1e3", "invalid", "invalid_decimal"], + ["", "invalid", "invalid_decimal"], + ["18446744073709551616", "invalid", "outside_uint64"], +] as const)("retains exact transport identity %s", (token, status, fact) => { + const { devices } = parseAdbDevicesOutput( + `serial device model:Pixel transport_id:${token}\r\n`, + ); + expect(devices[0]).toMatchObject({ + serial: "serial", + model: "Pixel", + transport_identity: { + status, + source_token: token, + ...(status === "reported" ? { decimal: fact } : { reason: fact }), + }, + }); +}); + +it("uses only the final transport field and distinguishes a missing field", () => { + const { devices } = parseAdbDevicesOutput( + [ + "first device product:foo transport_id:7 model:Pixel transport_id:9", + "second device product:foo transport_id:7 model:Pixel", + ].join("\n"), + ); + expect(devices[0]).toMatchObject({ + transport_identity: { status: "reported", decimal: "9", source_token: "9" }, + }); + expect(devices[1]).toMatchObject({ + transport_identity: { status: "not_reported", source_token: null }, + }); +}); diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts index 2dfda6e0b3..438c9aaef6 100644 --- a/src/adb/AdbDeviceOutput.ts +++ b/src/adb/AdbDeviceOutput.ts @@ -16,21 +16,10 @@ import { } from "./AdbUnparsedLines.js"; import { ADB_DEVICE_STATES, - type AdbDeviceState, + type AdbDeviceObservation, } from "../domain/adb/adbDeviceAnalysis.js"; -/** One device exactly as `adb devices -l` reports it. */ -export interface AdbDeviceEntry { - readonly serial: string; - readonly state: AdbDeviceState; - readonly transport: "usb" | "tcp" | "unknown"; - readonly product: string | null; - readonly model: string | null; - readonly device: string | null; - readonly transport_id: number | null; - readonly kind: "emulator" | "physical" | "unknown"; - readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none"; -} +import { parseAdbTransportIdentity } from "../domain/adb/adbTransportIdentity.js"; const DEVICE_LINE = /^(\S+)\s+(.*)$/u; @@ -76,11 +65,11 @@ export const parseAdbDevicesOutput = ( stdout: string, stderr = "", ): { - devices: readonly AdbDeviceEntry[]; + devices: readonly AdbDeviceObservation[]; unparsedLines: AdbUnparsedLines; daemonStarted: boolean; } => { - const devices: AdbDeviceEntry[] = []; + const devices: AdbDeviceObservation[] = []; const unparsed = collectUnparsedLines(); let daemonStarted = false; for (const rawLine of stdout.split(/\r?\n/u)) { @@ -113,11 +102,14 @@ export const parseAdbDevicesOutput = ( const serial = match[1]!; const remainder = match[2]!.slice(state.length); const usb = metadataValue(remainder, "usb"); - const transportIdText = metadataValue(remainder, "transport_id"); - const parsedTransportId = - transportIdText === null || !/^\d+$/u.test(transportIdText) - ? null - : Number(transportIdText); + // AOSP appends the authoritative field last; earlier metadata may contain + // the same spelling. Keep the exact source token before decimal admission. + const transportToken = + /(?:^|\s)transport_id:(\S*)$/u.exec(remainder)?.[1] ?? null; + const transportIdentity = parseAdbTransportIdentity( + transportToken, + rawLine, + ); const isEmulator = /^emulator-\d+$/u.test(serial); const kind = isEmulator ? ("emulator" as const) @@ -136,7 +128,7 @@ export const parseAdbDevicesOutput = ( product: metadataValue(remainder, "product"), model: metadataValue(remainder, "model"), device: metadataValue(remainder, "device"), - transport_id: parsedTransportId, + transport_identity: transportIdentity, kind, kind_basis: kind === "emulator" diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index a2e712bf2e..239baa8875 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -246,7 +246,13 @@ fi`, product: "sdk_gphone64_x86_64", model: "sdk_gphone64_x86_64", device: "emu64xa", - transport_id: 1, + transport_identity: { + status: "reported", + decimal: "1", + source_token: "1", + source_line: + "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1", + }, kind: "emulator", kind_basis: "emulator_serial_prefix", }, @@ -276,7 +282,13 @@ fi`, product: "sdk_gphone64_x86_64", model: "sdk_gphone64_x86_64", device: "emu64xa", - transport_id: 1, + transport_identity: { + status: "reported", + decimal: "1", + source_token: "1", + source_line: + "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1", + }, kind: "emulator", kind_basis: "emulator_serial_prefix", }, @@ -324,7 +336,13 @@ fi`, product: null, model: null, device: null, - transport_id: 4, + transport_identity: { + status: "reported", + decimal: "4", + source_token: "4", + source_line: + "07ef9c2a no permissions; see [http://developer.android.com/tools/device.html] usb:1-1 transport_id:4", + }, kind: "physical", kind_basis: "usb_transport", }, diff --git a/src/application/adb/AdbDeviceAnalysisService.test.ts b/src/application/adb/AdbDeviceAnalysisService.test.ts index 0e8e2079ae..7f6b35c851 100644 --- a/src/application/adb/AdbDeviceAnalysisService.test.ts +++ b/src/application/adb/AdbDeviceAnalysisService.test.ts @@ -1,4 +1,10 @@ -import { expect, it } from "vitest"; +import { chmod, readFile } from "node:fs/promises"; +import { expect, it, onTestFinished } from "vitest"; +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../../tests/support/workspace/workspaceFixture.js"; +import { AdbProvider } from "../../adb/AdbProvider.js"; import { AdbDeviceAnalysisService } from "./AdbDeviceAnalysisService.js"; import type { AdbDeviceAnalysisPort } from "./AdbDeviceAnalysisPort.js"; @@ -139,3 +145,125 @@ it("preserves cancellation before execution", async () => { expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cause).toBe(cancellationReason); }); + +it.skipIf(process.platform === "win32").each([false, true])( + "projects full transport facts through the shared client boundary (late abort %s)", + async (lateAbort) => { + const workspace = await createTestWorkspace("rea-adb-transport-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const lines = [ + "safe device model:Pixel transport_id:2147483648", + "exact device model:Pixel transport_id:18446744073709551615", + "invalid device model:Pixel transport_id:0", + "missing device model:Pixel", + "suffix device product:foo transport_id:7 model:Pixel transport_id:9", + ]; + const logPath = workspace.path("commands.jsonl"); + const binary = await workspace.write( + "adb", + `#!${process.execPath}\nimport {appendFileSync} from 'node:fs';\nconst args=process.argv.slice(2);appendFileSync(${JSON.stringify(logPath)},JSON.stringify(args)+'\\n');\nif(args[0]==='version')process.stdout.write('Android Debug Bridge version 1.0.41\\nVersion 35.0.2-inert\\n');\nelse if(args[0]==='devices'&&args[1]==='-l')process.stdout.write(${JSON.stringify(lines.join("\n") + "\n")});\nelse if(args[0]==='-s'&&args[1]==='exact'&&args[2]==='shell'&&args[3]==='getprop')process.stdout.write('[ro.build.version.sdk]: [34]\\n');\nelse throw Error('No other command authorized');\n`, + ); + await chmod(binary, 0o755); + const provider = new AdbProvider({ environment: { REA_ADB_PATH: binary } }); + onTestFinished(() => provider.close()); + const controller = new AbortController(); + const port: AdbDeviceAnalysisPort = { + inspectAvailability: provider.inspectAvailability.bind(provider), + close: provider.close.bind(provider), + async execute(request, options) { + const result = await provider.execute(request, options); + if (lateAbort) controller.abort("after listing"); + return result.ok + ? ok({ + ...result.value, + rawResult: { fixture_observation: "retained verbatim" }, + }) + : result; + }, + }; + const result = await new AdbDeviceAnalysisService(port).execute( + "list_adb_devices", + {}, + { signal: controller.signal }, + ); + const evidence = result.ok + ? result.value + : parseEvidence(result.error.partialObservation); + expect(result.ok).toBe(!lateAbort); + expect(evidence.normalized_result).toMatchObject({ + devices: [ + { + serial: "safe", + transport_id: 2147483648, + transport_id_text: "2147483648", + transport_id_status: "reported", + }, + { + serial: "exact", + transport_id: null, + transport_id_text: "18446744073709551615", + transport_id_status: "reported", + }, + { + serial: "invalid", + transport_id: null, + transport_id_text: null, + transport_id_status: "invalid", + }, + { + serial: "missing", + transport_id: null, + transport_id_text: null, + transport_id_status: "not_reported", + }, + { + serial: "suffix", + transport_id: 9, + transport_id_text: "9", + transport_id_status: "reported", + }, + ], + }); + expect(evidence.raw_result).toMatchObject({ + provider_raw_result: { fixture_observation: "retained verbatim" }, + transport_id_observations: [ + { + row: 0, + serial: "safe", + source_token: "2147483648", + source_line: lines[0], + }, + { + row: 1, + serial: "exact", + source_token: "18446744073709551615", + source_line: lines[1], + }, + { + row: 2, + serial: "invalid", + source_token: "0", + reason: "reserved_zero", + }, + { + row: 3, + serial: "missing", + source_token: null, + reason: "not_reported", + }, + { row: 4, serial: "suffix", source_token: "9" }, + ], + }); + const inspected = await new AdbDeviceAnalysisService(provider).execute( + "inspect_adb_device", + { serial: "exact" }, + ); + expect(inspected.ok).toBe(true); + expect( + (await readFile(logPath, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + ).toContainEqual(["-s", "exact", "shell", "getprop"]); + }, +); diff --git a/src/application/adb/AdbDeviceAnalysisService.ts b/src/application/adb/AdbDeviceAnalysisService.ts index fac4d93ad0..2fbef7134f 100644 --- a/src/application/adb/AdbDeviceAnalysisService.ts +++ b/src/application/adb/AdbDeviceAnalysisService.ts @@ -1,12 +1,18 @@ import type { AdbDeviceAnalysisPort } from "./AdbDeviceAnalysisPort.js"; -import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { + AnalysisExecution, + ExecutionOptions, +} from "../AnalysisProvider.js"; import { adbRequestSchema, + adbDeviceListObservationSchema, type AdbOperation, } from "../../domain/adb/adbDeviceAnalysis.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Evidence } from "../../domain/evidence.js"; -import type { Result } from "../../domain/result.js"; +import { err, ok, type Result } from "../../domain/result.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { jsonValueSchema } from "../../domain/jsonValue.js"; import { executeProviderOperation } from "../ProviderOperationExecution.js"; /** Shared CLI/MCP admission, execution, and Evidence composition for ADB. */ @@ -24,8 +30,92 @@ export class AdbDeviceAnalysisService { input, options, adbRequestSchema, - this.provider, + { + execute: async (request, options) => { + const result = await this.provider.execute(request, options); + return result.ok && request.operation === "list_adb_devices" + ? projectDeviceList(result.value) + : result; + }, + }, "observed", ); } } + +/** Public number compatibility is a projection of exact provider facts, never identity admission. */ +const projectDeviceList = ( + execution: AnalysisExecution, +): Result => { + const json = jsonValueSchema.safeParse(execution.result); + const parsed = json.success + ? adbDeviceListObservationSchema.safeParse(json.data) + : json; + if (!parsed.success) + return err( + new AnalysisOutputError( + "list_adb_devices", + "ADB provider returned malformed device-list identity facts.", + { cause: parsed.error }, + ), + ); + let numericUnavailable = false; + let invalidIdentity = false; + const devices = parsed.data.devices.map( + ({ transport_identity, ...device }) => { + const text = + transport_identity.status === "reported" + ? transport_identity.decimal + : null; + const safe = + text !== null && + (text.length < 16 || + (text.length === 16 && text <= "9007199254740991")); + if (text !== null && !safe) numericUnavailable = true; + if (transport_identity.status === "invalid") invalidIdentity = true; + return { + ...device, + transport_id: safe ? Number(text) : null, + transport_id_text: text, + transport_id_status: transport_identity.status, + }; + }, + ); + return ok({ + ...execution, + result: { ...parsed.data, devices }, + rawResult: { + ...(execution.rawResult === null + ? {} + : { provider_raw_result: execution.rawResult }), + transport_id_observations: parsed.data.devices.map( + ({ serial, transport_identity }, row) => ({ + row, + serial, + source_line: transport_identity.source_line, + source_token: transport_identity.source_token, + status: transport_identity.status, + reason: + transport_identity.status === "invalid" + ? transport_identity.reason + : transport_identity.status === "not_reported" + ? "not_reported" + : null, + }), + ), + }, + limitations: [ + ...execution.limitations, + ...(numericUnavailable + ? [ + "A reported transport ID cannot be represented as a safe JSON integer. transport_id_text retains its exact identity; existing operations select the device by serial.", + ] + : []), + ...(invalidIdentity + ? [ + "Some transport ID fields were invalid. Known device facts remain available; raw_result.transport_id_observations retains source tokens and reasons.", + ] + : []), + ], + }); +}; diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts index f881548b1b..75dfe1b493 100644 --- a/src/contracts/adb/adbToolContracts.ts +++ b/src/contracts/adb/adbToolContracts.ts @@ -22,7 +22,7 @@ export const ADB_TOOL_CONTRACTS = [ name: "list_adb_devices", ...toolContractMetadata("list_adb_devices"), description: - "List attached devices exactly as `adb devices -l` reports them: serial, state, transport, model metadata, and an inferred emulator/physical kind with its evidence basis. Emulators and USB or network devices appear alike; REA neither starts nor manages them. May start the local adb server on first use.", + "List attached devices exactly as `adb devices -l` reports them: serial, state, transport, model metadata, and an inferred emulator/physical kind with its evidence basis. Emulators and USB or network devices appear alike; REA neither starts nor manages them. transport_id_text preserves exact full-range transport identity; transport_id is its safe numeric projection or null. May start the local adb server on first use.", kind: "adb-provider", inputSchema: adbInputSchemas.list_adb_devices, outputSchema: evidenceResultOf(adbResultSchemas.list_adb_devices), diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 03d4681906..2fa32a0a88 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -1,3 +1,7 @@ +import { + adbTransportIdentitySchema, + adbTransportIdSchema, +} from "./adbTransportIdentity.js"; import { z } from "zod"; import { localPathStringSchema } from "../localPath.js"; import { IDENTIFIER_PATTERN } from "../stringPatterns.js"; @@ -482,14 +486,14 @@ const client = z.strictObject({ installed_path: z.string().min(1).nullable(), }); -const device = z.strictObject({ +export const adbDeviceObservationSchema = z.strictObject({ serial: z.string().min(1), state: adbDeviceStateSchema, transport: z.enum(["usb", "tcp", "unknown"]), product: z.string().nullable(), model: z.string().nullable(), device: z.string().nullable(), - transport_id: z.int().positive().nullable(), + transport_identity: adbTransportIdentitySchema, /** * Inferred device kind with its evidence basis. The serial prefix and the * `device:` metadata line are observations; the kind itself is derived. @@ -498,6 +502,33 @@ const device = z.strictObject({ kind_basis: z.enum(["emulator_serial_prefix", "usb_transport", "none"]), }); +export type AdbDeviceObservation = z.output; + +/** Provider facts before the shared CLI/MCP numeric projection. */ +export const adbDeviceListObservationSchema = z.strictObject({ + client, + devices: z.array(adbDeviceObservationSchema), + may_have_started_adb_server: z.boolean(), +}); + +const device = adbDeviceObservationSchema + .omit({ transport_identity: true }) + .extend({ + transport_id: z + .int() + .positive() + .nullable() + .describe( + "Exact numeric projection when safely representable; otherwise null. transport_id_text retains the full reported identity.", + ), + transport_id_text: adbTransportIdSchema + .nullable() + .describe( + "Canonical positive unsigned 64-bit transport ID as decimal text; null when invalid or not reported.", + ), + transport_id_status: z.enum(["reported", "not_reported", "invalid"]), + }); + const pulledArtifact = z.strictObject({ device_path: z.string().min(1), file_name: z.string().min(1), diff --git a/src/domain/adb/adbTransportIdentity.ts b/src/domain/adb/adbTransportIdentity.ts new file mode 100644 index 0000000000..d4f34a426c --- /dev/null +++ b/src/domain/adb/adbTransportIdentity.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +const MAX_TRANSPORT_ID = "18446744073709551615"; + +/** Canonical positive uint64 identity; independent of JSON number precision. */ +export const adbTransportIdSchema = z + .string() + .regex(/^[1-9]\d*$/u) + .refine( + (value) => + value.length < MAX_TRANSPORT_ID.length || + (value.length === MAX_TRANSPORT_ID.length && value <= MAX_TRANSPORT_ID), + "Transport ID exceeds the unsigned 64-bit range", + ) + .brand<"AdbTransportId">(); + +export const adbTransportIdentitySchema = z.discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("reported"), + decimal: adbTransportIdSchema, + source_token: z.string(), + source_line: z.string(), + }), + z.strictObject({ + status: z.literal("not_reported"), + source_token: z.null(), + source_line: z.string(), + }), + z.strictObject({ + status: z.literal("invalid"), + source_token: z.string(), + source_line: z.string(), + reason: z.enum(["invalid_decimal", "reserved_zero", "outside_uint64"]), + }), +]); +export type AdbTransportIdentity = z.output; + +/** Admit source decimal text once; never round a transport identity through Number. */ +export const parseAdbTransportIdentity = ( + source_token: string | null, + source_line: string, +): AdbTransportIdentity => { + if (source_token === null) + return { status: "not_reported", source_token, source_line }; + if (!/^\d+$/u.test(source_token)) + return { + status: "invalid", + source_token, + source_line, + reason: "invalid_decimal", + }; + const decimal = source_token.replace(/^0+(?=\d)/u, ""); + if (decimal === "0") + return { + status: "invalid", + source_token, + source_line, + reason: "reserved_zero", + }; + const admitted = adbTransportIdSchema.safeParse(decimal); + return admitted.success + ? { status: "reported", decimal: admitted.data, source_token, source_line } + : { + status: "invalid", + source_token, + source_line, + reason: "outside_uint64", + }; +}; diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index 04387cd6f5..9ad8521817 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -791,3 +791,42 @@ exit 0 await expect(lstat(output)).rejects.toMatchObject({ code: "ENOENT" }); }, ); + +it.skipIf(process.platform === "win32")( + "preserves exact transport identity through CLI JSON projection", + async () => { + const workspace = await createTestWorkspace("rea-adb-identity-cli-"); + onTestFinished(() => removeTestWorkspace(workspace.root)); + const line = + "exact-serial device model:Pixel transport_id:18446744073709551615"; + const binary = await workspace.write( + "adb", + `#!${process.execPath}\nif(process.argv[2]==='version')process.stdout.write('Android Debug Bridge version 1.0.41\\nVersion 35.0.2-inert\\n');\nelse if(process.argv[2]==='devices'&&process.argv[3]==='-l')process.stdout.write(${JSON.stringify(line + "\n")});\nelse throw Error('No other command authorized');\n`, + ); + await chmod(binary, 0o755); + const result = await serve(["list-adb-devices", "--json"], { + REA_ADB_PATH: binary, + }); + expect(result.exitCode).toBe(0); + const evidence = JSON.parse(result.stdout); + expect(evidence.normalized_result).toMatchObject({ + devices: [ + { + serial: "exact-serial", + model: "Pixel", + transport_id: null, + transport_id_text: "18446744073709551615", + transport_id_status: "reported", + }, + ], + }); + expect(evidence.raw_result).toMatchObject({ + transport_id_observations: [ + { source_line: line, source_token: "18446744073709551615" }, + ], + }); + expect( + toolContract("list_adb_devices").outputSchema.safeParse(evidence).success, + ).toBe(true); + }, +); diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index 461503e45f..a8114dc5b5 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -8,6 +8,7 @@ import { ok } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js"; const identity = { id: "adb", name: "Android Debug Bridge", version: null }; @@ -34,10 +35,32 @@ const results: Readonly> = { product: "sdk_gphone64_x86_64", model: "sdk_gphone64_x86_64", device: "emu64xa", - transport_id: 1, + transport_identity: { + status: "reported", + decimal: "1", + source_token: "1", + source_line: "emulator-5554 device transport_id:1", + }, kind: "emulator", kind_basis: "emulator_serial_prefix", }, + { + serial: "exact-serial", + state: "device", + transport: "usb", + product: "product", + model: "Pixel", + device: "device", + transport_identity: { + status: "reported", + decimal: "18446744073709551615", + source_token: "18446744073709551615", + source_line: + "exact-serial device model:Pixel transport_id:18446744073709551615", + }, + kind: "physical", + kind_basis: "usb_transport", + }, ], may_have_started_adb_server: false, }, @@ -342,6 +365,49 @@ it("advertises exact valid ADB schemas and records inline evidence through MCP", for (const name of operationNames) expect(names, name).toContain(name); assertAdvertisedMcpContracts(operationNames, tools); + const listed = await client.callTool({ + name: "list_adb_devices", + arguments: {}, + }); + expect(listed.isError).not.toBe(true); + const listing = parseEvidence(listed.structuredContent); + expect(listing.normalized_result).toMatchObject({ + devices: [ + { + serial: "emulator-5554", + transport_id: 1, + transport_id_text: "1", + transport_id_status: "reported", + }, + { + serial: "exact-serial", + transport_id: null, + transport_id_text: "18446744073709551615", + transport_id_status: "reported", + }, + ], + }); + expect(listing.raw_result).toMatchObject({ + transport_id_observations: [ + { serial: "emulator-5554", source_token: "1" }, + { serial: "exact-serial", source_token: "18446744073709551615" }, + ], + }); + const listingSchema = tools.find( + ({ name }) => name === "list_adb_devices", + )?.outputSchema; + if (listingSchema === undefined) + throw new Error("Missing listing output schema"); + const validation = createMcpSchemaValidation(); + onTestFinished(() => validation.clear()); + expect(validation.assertions.validateSchema(listingSchema)).toBe(true); + expect(validation.assertions.validate(listingSchema, listing)).toBe(true); + const malformed = structuredClone(listing); + const report = malformed.normalized_result as { + devices: { transport_id_text: string | null }[]; + }; + report.devices[1]!.transport_id_text = "not-a-decimal"; + expect(validation.assertions.validate(listingSchema, malformed)).toBe(false); const pulled = await client.callTool({ name: "pull_adb_package",