mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
fix(adb): preserve exact transport identity observations (#10062)
This commit is contained in:
1 parent
3eb502ae5d
commit
913c9fe194
11 files changed
+542
-37
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
+13
-21
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
},
|
||||
);
|
||||
@@ -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<AnalysisExecution, AnalysisError> => {
|
||||
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.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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<typeof adbDeviceObservationSchema>;
|
||||
|
||||
/** 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),
|
||||
|
||||
@@ -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<typeof adbTransportIdentitySchema>;
|
||||
|
||||
/** 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",
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<Record<string, unknown>> = {
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user