diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 7d6721a80d..1df083c5c7 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -136,6 +136,29 @@ and during shutdown. ## Interpret the result +- **Package numeric declarations retain failures.** `inspect_adb_package` + admits the first valid `userId` or `appId` as `user_id`: a nonnegative safe + integer, including zero. A null value means no valid declaration was found. + Malformed declarations before or after a valid value remain in + `numeric_issues`: the first ten issues with command, field, reason and original + `raw_line`, plus the exact `total`. Reasons are `invalid_format` or + `unsafe_integer`. Any issue makes package coverage partial; existing section + omissions can also make it partial. `version_code` remains exact source text. +- **Display nulls preserve uncertainty.** `inspect_adb_display` admits positive + safe integers. Absent or malformed optional values are null; `numeric_issues` + distinguishes malformed declarations from absence. It keeps the first ten + issues and exact total, with command, field, original line and reason + (`invalid_format`, `unsafe_integer` or `nonpositive`). The last recognized + labeled declaration wins, even when malformed. A physical-density label + suppresses bare-number fallback even when malformed; without that label, the + first unsigned bare number wins, including zero or an unsafe integer that + leaves the value null. Display coverage is complete only with known physical + density and no numeric issues. Missing or malformed required physical size + produces a protocol failure with `details.partial_observation`: an + `inspect_adb_display` observation from provider `adb`, reason + `physical_size_absent` or `physical_size_malformed`, and a `result` retaining + client, serial, optional display facts, numeric issues, null physical size + and partial coverage. - **Directory names retain whitespace and newlines.** Symlink targets do too, including trailing newlines. Records containing invalid UTF-8 remain as labelled hex in `unparsed_lines` and make directory coverage partial. diff --git a/src/adb/AdbNumericIssues.ts b/src/adb/AdbNumericIssues.ts new file mode 100644 index 0000000000..6484107e5f --- /dev/null +++ b/src/adb/AdbNumericIssues.ts @@ -0,0 +1,24 @@ +import { UNPARSED_LINE_SAMPLE_LIMIT } from "./AdbUnparsedLines.js"; + +/** Retain the first diagnostic facts and exact count without copying command output. */ +export const collectNumericIssues = () => { + const sample: Issue[] = []; + let total = 0; + return { + add: (issue: Issue) => { + total += 1; + if (sample.length < UNPARSED_LINE_SAMPLE_LIMIT) sample.push(issue); + }, + append: (issues: { + readonly sample: readonly Issue[]; + readonly total: number; + }) => { + total += issues.total; + for (const issue of issues.sample) { + if (sample.length === UNPARSED_LINE_SAMPLE_LIMIT) break; + sample.push(issue); + } + }, + facts: () => ({ sample, total }), + }; +}; diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index dcabe8b5ca..bf76f8778c 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -1,3 +1,4 @@ +import { collectNumericIssues } from "./AdbNumericIssues.js"; import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js"; import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; @@ -26,6 +27,8 @@ import { type AnalysisExecution, } from "../application/AnalysisProvider.js"; import type { + AdbDisplayNumericIssues, + AdbDisplayPartialObservation, AdbActivityStartPartialObservation, AdbAcquiredFilePartialObservation, AdbPackagePullPartialObservation, @@ -40,7 +43,10 @@ import { AnalysisCleanupLease, AnalysisCleanupLeaseGroup, } from "../artifacts/AnalysisCleanupLease.js"; -import { adbResultSchemas } from "../domain/adb/adbDeviceAnalysis.js"; +import { + adbDisplayPartialObservationSchema, + adbResultSchemas, +} from "../domain/adb/adbDeviceAnalysis.js"; import { AnalysisArtifactChangedError, AnalysisCancelledError, @@ -1578,7 +1584,9 @@ export class AdbProvider { user_id: details.userId, pkg_flags: details.pkgFlags, requested_permissions_count: details.requestedPermissions, + numeric_issues: details.numericIssues, coverage: + details.numericIssues.total > 0 || details.requestedPermissions === null || (details.versionCode === null && details.versionName === null) ? "partial" @@ -1735,27 +1743,58 @@ export class AdbProvider { }); if (!density.ok) return density; const parsedSize = parseWmSizeOutput(size.value.stdout); - if (parsedSize.physical === null) + const parsedDensity = parseWmDensityOutput(density.value.stdout); + const issues = + collectNumericIssues(); + issues.append(parsedSize.numericIssues); + issues.append(parsedDensity.numericIssues); + const numericIssues = issues.facts(); + const displayFacts = { + client, + serial: request.input.serial, + override_size: parsedSize.override, + physical_density: parsedDensity.physical, + override_density: parsedDensity.override, + numeric_issues: numericIssues, + }; + if (parsedSize.physical === null) { + const reason = parsedSize.physicalDeclared + ? "physical_size_malformed" + : "physical_size_absent"; + const partialObservation: AdbDisplayPartialObservation = + adbDisplayPartialObservationSchema.parse({ + provider_id: "adb", + operation: "inspect_adb_display", + reason, + result: { + ...displayFacts, + physical_size: null, + coverage: "partial", + }, + }); return err( new AnalysisProtocolError( - "wm size did not report a physical size; the build may use an unsupported format", + parsedSize.physicalDeclared + ? "wm size reported a malformed physical size" + : "wm size did not report a physical size; the build may use an unsupported format", + { partialObservation }, ), ); - const parsedDensity = parseWmDensityOutput(density.value.stdout); + } return ok( createAnalysisExecution( { - client, - serial: request.input.serial, + ...displayFacts, physical_size: parsedSize.physical, - override_size: parsedSize.override, - physical_density: parsedDensity.physical, - override_density: parsedDensity.override, + coverage: + numericIssues.total > 0 || parsedDensity.physical === null + ? "partial" + : "complete", }, provider, { limitations: [ - "Override values are present only when a build has overridden the physical values", + "Override fields are null when no declaration was reported or its value was malformed; numeric_issues retains malformed declarations", ], }, ), diff --git a/src/adb/AdbShellOutput.test.ts b/src/adb/AdbShellOutput.test.ts index 9253e239e3..4dac2b1bca 100644 --- a/src/adb/AdbShellOutput.test.ts +++ b/src/adb/AdbShellOutput.test.ts @@ -343,10 +343,15 @@ describe("parseWmDensityOutput", () => { it("reads labeled physical and override densities", () => { expect( parseWmDensityOutput("Physical density: 420\nOverride density: 480\n"), - ).toEqual({ physical: 420, override: 480 }); + ).toEqual({ + physical: 420, + override: 480, + numericIssues: { sample: [], total: 0 }, + }); expect(parseWmDensityOutput("Physical density: 420\n")).toEqual({ physical: 420, override: null, + numericIssues: { sample: [], total: 0 }, }); }); @@ -356,10 +361,12 @@ describe("parseWmDensityOutput", () => { expect(parseWmDensityOutput("420\n")).toEqual({ physical: 420, override: null, + numericIssues: { sample: [], total: 0 }, }); expect(parseWmDensityOutput("420\r\n")).toEqual({ physical: 420, override: null, + numericIssues: { sample: [], total: 0 }, }); }); @@ -367,6 +374,7 @@ describe("parseWmDensityOutput", () => { expect(parseWmDensityOutput("560\nPhysical density: 420\n")).toEqual({ physical: 420, override: null, + numericIssues: { sample: [], total: 0 }, }); }); @@ -374,10 +382,12 @@ describe("parseWmDensityOutput", () => { expect(parseWmDensityOutput("")).toEqual({ physical: null, override: null, + numericIssues: { sample: [], total: 0 }, }); expect(parseWmDensityOutput("420dpi\n")).toEqual({ physical: null, override: null, + numericIssues: { sample: [], total: 0 }, }); }); }); diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts index 4c9b13222c..d0112a2ca5 100644 --- a/src/adb/AdbShellOutput.ts +++ b/src/adb/AdbShellOutput.ts @@ -1,4 +1,7 @@ +import { collectNumericIssues } from "./AdbNumericIssues.js"; import type { + AdbDisplayNumericIssues, + AdbPackageNumericIssues, AdbWindowFocusContext, AdbWindowFocusObservation, } from "../domain/adb/adbDeviceAnalysis.js"; @@ -139,64 +142,109 @@ export const parseServiceListOutput = ( return { services, unparsedLines: unparsed.facts() }; }; -const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u; -const SIZE_OVERRIDE = /^Override size:\s+(\d+)x(\d+)$/u; -const DENSITY_PHYSICAL = /^Physical density:\s+(\d+)$/u; -const DENSITY_OVERRIDE = /^Override density:\s+(\d+)$/u; +const SIZE_DECLARATION = /^(Physical|Override) size:(.*)$/u; +const SIZE_VALUE = /^\s+(\d+)x(\d+)$/u; +const DENSITY_DECLARATION = /^(Physical|Override) density:(.*)$/u; +const DENSITY_VALUE = /^\s+(\d+)$/u; const DENSITY_BARE = /^(\d+)$/u; -/** Parse `wm size` output into physical and optional override sizes. */ +type DisplayNumericIssue = AdbDisplayNumericIssues["sample"][number]; + +/** Parse declarations before admission so a malformed last declaration stays owned. */ export const parseWmSizeOutput = ( stdout: string, ): { physical: { readonly width: number; readonly height: number } | null; override: { readonly width: number; readonly height: number } | null; + physicalDeclared: boolean; + numericIssues: AdbDisplayNumericIssues; } => { let physical = null; let override = null; - for (const line of stdout.split(/\r?\n/u)) { - const trimmed = line.trim(); - const physicalMatch = SIZE_PAIR.exec(trimmed); - if (physicalMatch !== null) - physical = { - width: Number(physicalMatch[1]!), - height: Number(physicalMatch[2]!), - }; - const overrideMatch = SIZE_OVERRIDE.exec(trimmed); - if (overrideMatch !== null) - override = { - width: Number(overrideMatch[1]!), - height: Number(overrideMatch[2]!), - }; + let physicalDeclared = false; + const issues = collectNumericIssues(); + for (const rawLine of stdout.split(/\r?\n/u)) { + const declaration = SIZE_DECLARATION.exec(rawLine.trim()); + if (declaration === null) continue; + const field = + declaration[1] === "Physical" ? "physical_size" : "override_size"; + if (field === "physical_size") physicalDeclared = true; + const match = SIZE_VALUE.exec(declaration[2]!); + const width = match === null ? null : safeInteger(match[1]!); + const height = match === null ? null : safeInteger(match[2]!); + const reason = + match === null + ? "invalid_format" + : width === null || height === null + ? "unsafe_integer" + : width <= 0 || height <= 0 + ? "nonpositive" + : null; + const value = + reason === null && width !== null && height !== null + ? { width, height } + : null; + if (reason !== null) + issues.add({ command: "wm size", field, reason, raw_line: rawLine }); + if (field === "physical_size") physical = value; + else override = value; } - return { physical, override }; + return { + physical, + override, + physicalDeclared, + numericIssues: issues.facts(), + }; }; -/** - * Parse `wm density` output. Current builds print labeled physical and - * optional override lines; older builds print the physical density as a - * bare number on its own line (morluto/rea#3942). A labeled declaration - * wins over a bare one so a mixed dump cannot downgrade the physical value. - */ +/** Labeled physical density dominates the first bare declaration, even when malformed. */ export const parseWmDensityOutput = ( stdout: string, ): { physical: number | null; override: number | null; + numericIssues: AdbDisplayNumericIssues; } => { let physical = null; let override = null; let bare = null; - for (const line of stdout.split(/\r?\n/u)) { - const trimmed = line.trim(); - const physicalMatch = DENSITY_PHYSICAL.exec(trimmed); - if (physicalMatch !== null) physical = Number(physicalMatch[1]!); - const overrideMatch = DENSITY_OVERRIDE.exec(trimmed); - if (overrideMatch !== null) override = Number(overrideMatch[1]!); - const bareMatch = DENSITY_BARE.exec(trimmed); - if (bareMatch !== null && bare === null) bare = Number(bareMatch[1]!); + let physicalDeclared = false; + let bareDeclared = false; + const issues = collectNumericIssues(); + for (const rawLine of stdout.split(/\r?\n/u)) { + const trimmed = rawLine.trim(); + const declaration = DENSITY_DECLARATION.exec(trimmed); + const bareMatch = declaration === null ? DENSITY_BARE.exec(trimmed) : null; + if (declaration === null && (bareMatch === null || bareDeclared)) continue; + const field = + declaration?.[1] === "Override" ? "override_density" : "physical_density"; + const match = + declaration === null ? bareMatch : DENSITY_VALUE.exec(declaration[2]!); + const value = match === null ? null : safeInteger(match[1]!); + const reason = + match === null + ? "invalid_format" + : value === null + ? "unsafe_integer" + : value <= 0 + ? "nonpositive" + : null; + const admitted = reason === null ? value : null; + if (reason !== null) + issues.add({ command: "wm density", field, reason, raw_line: rawLine }); + if (declaration === null) { + bareDeclared = true; + bare = admitted; + } else if (field === "physical_density") { + physicalDeclared = true; + physical = admitted; + } else override = admitted; } - return { physical: physical ?? bare, override }; + return { + physical: physicalDeclared ? physical : bare, + override, + numericIssues: issues.facts(), + }; }; /** Own declaration identity and omission before deriving a singular summary. */ @@ -280,6 +328,7 @@ export interface AdbPackageDetails { readonly userId: number | null; readonly pkgFlags: string | null; readonly requestedPermissions: number | null; + readonly numericIssues: AdbPackageNumericIssues; } const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u; @@ -320,6 +369,8 @@ export const parseDumpsysPackageOutput = ( userId: null, pkgFlags: null, }; + const issues = + collectNumericIssues(); let permissionsIndent: number | null = null; let hasPermissionsSection = false; let permissions = 0; @@ -369,9 +420,17 @@ export const parseDumpsysPackageOutput = ( details.installerPackageName === null ) details.installerPackageName = absentInstallerOrName(value); - else if ((key === "userId" || key === "appId") && details.userId === null) { + else if (key === "userId" || key === "appId") { const parsed = /^(\d+)/u.exec(value); - if (parsed !== null) details.userId = Number(parsed[1]!); + const admitted = parsed === null ? null : safeInteger(parsed[1]!); + if (admitted === null) + issues.add({ + command: "dumpsys package", + field: "user_id", + reason: parsed === null ? "invalid_format" : "unsafe_integer", + raw_line: line, + }); + else if (details.userId === null) details.userId = admitted; } else if (key === "pkgFlags" && details.pkgFlags === null) details.pkgFlags = value === "" ? null : value; } @@ -385,7 +444,7 @@ export const parseDumpsysPackageOutput = ( : parsedPackageAvailable === true ? 0 : null; - return { ...details, requestedPermissions }; + return { ...details, requestedPermissions, numericIssues: issues.facts() }; }; /** diff --git a/src/adb/AdbUnparsedLines.ts b/src/adb/AdbUnparsedLines.ts index adaabf3d57..a64bd07f1e 100644 --- a/src/adb/AdbUnparsedLines.ts +++ b/src/adb/AdbUnparsedLines.ts @@ -1,5 +1,5 @@ /** Most unrecognized output lines a parser retains as source evidence. */ -const UNPARSED_LINE_SAMPLE_LIMIT = 10; +export const UNPARSED_LINE_SAMPLE_LIMIT = 10; /** * Unrecognized output lines one parser observed: the first lines verbatim, up diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts index 4d1245c99d..1f6afd760d 100644 --- a/src/contracts/adb/adbToolContracts.ts +++ b/src/contracts/adb/adbToolContracts.ts @@ -93,7 +93,7 @@ export const ADB_TOOL_CONTRACTS = [ name: "inspect_adb_package", ...toolContractMetadata("inspect_adb_package"), description: - "Inspect one installed package through `dumpsys package`: version name/code, install and update times, installer, user id, flags, and the requested-permission count. A fixed projection of version-dependent output; unknown sections are ignored rather than guessed. Read-only.", + "Inspect installed-package versions, install times, installer, user id, flags, and requested-permission count through read-only `dumpsys package`. Malformed numeric declarations are retained in bounded numeric_issues and make coverage partial; version_code stays exact text. Unknown sections are not inferred.", kind: "adb-provider", inputSchema: adbInputSchemas.inspect_adb_package, outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_package), @@ -212,7 +212,7 @@ export const ADB_TOOL_CONTRACTS = [ name: "inspect_adb_display", ...toolContractMetadata("inspect_adb_display"), description: - "Report the display's physical and any overridden size (`wm size`) and density (`wm density`) as the device declares them. Read-only; setting values is not exposed.", + "Read physical and override size/density from `wm size` and `wm density`. Malformed numeric declarations are retained in numeric_issues with partial coverage; optional values remain null. Required physical-size failures retain partial display facts. Read-only.", kind: "adb-provider", inputSchema: adbInputSchemas.inspect_adb_display, outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_display), diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 52085a8662..be1baa50ff 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -513,6 +513,51 @@ export const adbWindowFocusContextSchema = z.strictObject({ }); export type AdbWindowFocusContext = z.infer; +const displayNumericReason = z.enum([ + "invalid_format", + "unsafe_integer", + "nonpositive", +]); +const displayNumericIssue = z.discriminatedUnion("command", [ + z.strictObject({ + command: z.literal("wm size"), + field: z.enum(["physical_size", "override_size"]), + reason: displayNumericReason, + raw_line: z.string().min(1), + }), + z.strictObject({ + command: z.literal("wm density"), + field: z.enum(["physical_density", "override_density"]), + reason: displayNumericReason, + raw_line: z.string().min(1), + }), +]); + +/** Bounded source diagnostics for display numeric declarations. */ +export const adbDisplayNumericIssuesSchema = z.strictObject({ + sample: z.array(displayNumericIssue), + total: z.int().nonnegative(), +}); +export type AdbDisplayNumericIssues = z.infer< + typeof adbDisplayNumericIssuesSchema +>; + +/** Bounded source diagnostics for Package Manager's userId/appId declarations. */ +export const adbPackageNumericIssuesSchema = z.strictObject({ + sample: z.array( + z.strictObject({ + command: z.literal("dumpsys package"), + field: z.literal("user_id"), + reason: z.enum(["invalid_format", "unsafe_integer"]), + raw_line: z.string().min(1), + }), + ), + total: z.int().nonnegative(), +}); +export type AdbPackageNumericIssues = z.infer< + typeof adbPackageNumericIssuesSchema +>; + /** Portable ADB observations with explicit identity and coverage limitations. */ export const adbResultSchemas = { inspect_adb_client: z.strictObject({ @@ -589,9 +634,10 @@ export const adbResultSchemas = { first_install_time: z.string().nullable(), last_update_time: z.string().nullable(), installer_package_name: z.string().nullable(), - user_id: z.int().nullable(), + user_id: z.int().nonnegative().nullable(), pkg_flags: z.string().nullable(), requested_permissions_count: z.int().nonnegative().nullable(), + numeric_issues: adbPackageNumericIssuesSchema, coverage: z.enum(["complete", "partial"]), }), pull_adb_file: z.strictObject({ @@ -700,6 +746,8 @@ export const adbResultSchemas = { .nullable(), physical_density: z.int().positive().nullable(), override_density: z.int().positive().nullable(), + numeric_issues: adbDisplayNumericIssuesSchema, + coverage: z.enum(["complete", "partial"]), }), inspect_adb_window: z.strictObject({ client, @@ -788,6 +836,20 @@ export const adbResultSchemas = { }), } as const; +/** Completed display facts retained when the required physical size is unreadable. */ +export const adbDisplayPartialObservationSchema = z.strictObject({ + provider_id: z.literal("adb"), + operation: z.literal("inspect_adb_display"), + reason: z.enum(["physical_size_absent", "physical_size_malformed"]), + result: adbResultSchemas.inspect_adb_display.extend({ + physical_size: z.null(), + coverage: z.literal("partial"), + }), +}); +export type AdbDisplayPartialObservation = z.infer< + typeof adbDisplayPartialObservationSchema +>; + /** Parsed focus facts, before the adapter supplies client and command identity. */ export type AdbWindowFocusObservation = Omit< z.infer, diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 152995a0e6..573a93e1ad 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -242,6 +242,7 @@ import type { AdbActivityStartPartialObservation, AdbAcquiredFilePartialObservation, AdbPackagePullPartialObservation, + AdbDisplayPartialObservation, } from "./adb/adbDeviceAnalysis.js"; import type { InspectSignature } from "./native/nativeInspection.js"; import type { NativeUiObservationResult } from "./native/nativeUiObservation.js"; @@ -297,4 +298,5 @@ export type AnalysisPartialObservation = | ElectronPagePartialObservation | AdbActivityStartPartialObservation | AdbAcquiredFilePartialObservation - | AdbPackagePullPartialObservation; + | AdbPackagePullPartialObservation + | AdbDisplayPartialObservation; diff --git a/tests/acceptance/applications/adbNumericDeclarations.test.ts b/tests/acceptance/applications/adbNumericDeclarations.test.ts new file mode 100644 index 0000000000..9337fe5259 --- /dev/null +++ b/tests/acceptance/applications/adbNumericDeclarations.test.ts @@ -0,0 +1,477 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { adbDisplayPartialObservationSchema } from "../../../src/domain/adb/adbDeviceAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const SERIAL = "numeric-declarations-device"; +const PACKAGE = "com.example.numeric"; +const UNSAFE = "9007199254740993"; +const OVERFLOW = "9".repeat(400); +const VERSION_NAME = " caller version\t "; + +const createNumericAdbFixture = async () => { + const root = await createTestTempDirectory("rea-adb-numeric-declarations-"); + const binary = join(root, "adb"); + const size = join(root, "size.txt"); + const density = join(root, "density.txt"); + const packageDetails = join(root, "package.txt"); + await writeFile( + binary, + `#!${process.execPath} +const args = process.argv.slice(2); +if (args.length === 1 && args[0] === 'version') { + console.log('Android Debug Bridge version 1.0.41\\nVersion 34.0.5-fixture\\nInstalled as ' + process.argv[1]); + process.exit(0); +} +const outputs = new Map([ + [${JSON.stringify(`-s ${SERIAL} shell wm size`)}, ${JSON.stringify(size)}], + [${JSON.stringify(`-s ${SERIAL} shell wm density`)}, ${JSON.stringify(density)}], + [${JSON.stringify(`-s ${SERIAL} shell dumpsys package ${PACKAGE}`)}, ${JSON.stringify(packageDetails)}], +]); +const path = outputs.get(args.join(' ')); +if (path === undefined) process.exit(91); +process.stdout.write(require('node:fs').readFileSync(path)); +`, + { mode: 0o700 }, + ); + const environment = { + HOME: root, + USERPROFILE: root, + REA_ADB_PATH: binary, + REA_LOG_LEVEL: "silent", + }; + const { call } = await connectLocalToolsMcp(environment); + return { environment, size, density, packageDetails, call }; +}; + +const invalidPhysical = " \tPhysical size: portrait \t"; +const unsafeOverride = ` \tOverride size: ${UNSAFE}x2 \t`; +const zeroOverride = " \tOverride size: 0x2 \t"; +const unsafeDensity = ` \tPhysical density: ${UNSAFE} \t`; +const invalidDensity = " \tPhysical density: many \t"; +const zeroDensity = " \tOverride density: 0 \t"; +const overflowDensity = ` \tOverride density: ${OVERFLOW} \t`; +const bareZero = " \t0 \t"; +const cappedSizeIssues = Array.from({ length: 6 }, (_, index) => ({ + command: "wm size", + field: "override_size", + reason: "invalid_format", + raw_line: ` \tOverride size: malformed-${String(index)} \t`, +})); +const cappedDensityIssues = Array.from({ length: 6 }, (_, index) => ({ + command: "wm density", + field: "override_density", + reason: "nonpositive", + raw_line: ` \tOverride density: 0${" ".repeat(index + 1)}\t`, +})); + +const displayScenarios = [ + { + name: "absent optional declarations", + size: "Physical size: 1080x2340\n", + density: "420\n", + physical_size: { width: 1080, height: 2340 }, + override_size: null, + physical_density: 420, + override_density: null, + issues: [], + coverage: "complete", + }, + { + name: "unknown density without a malformed declaration", + size: "Physical size: 1080x2340\n", + density: "\n", + physical_size: { width: 1080, height: 2340 }, + override_size: null, + physical_density: null, + override_density: null, + issues: [], + coverage: "partial", + }, + { + name: "positive safe boundaries", + size: "Physical size: 9007199254740991x1\nOverride size: 1x9007199254740991\n", + density: "Physical density: 9007199254740991\nOverride density: 1\n", + physical_size: { width: Number.MAX_SAFE_INTEGER, height: 1 }, + override_size: { width: 1, height: Number.MAX_SAFE_INTEGER }, + physical_density: Number.MAX_SAFE_INTEGER, + override_density: 1, + issues: [], + coverage: "complete", + }, + { + name: "later valid labeled declarations retain earlier issues", + size: `${invalidPhysical}\nPhysical size: 100x200\nOverride size: 1x2\nOverride size: 640x480\n`, + density: `${unsafeDensity}\nPhysical density: 320\n${zeroDensity}\nOverride density: 480\n`, + physical_size: { width: 100, height: 200 }, + override_size: { width: 640, height: 480 }, + physical_density: 320, + override_density: 480, + issues: [ + { + command: "wm size", + field: "physical_size", + reason: "invalid_format", + raw_line: invalidPhysical, + }, + { + command: "wm density", + field: "physical_density", + reason: "unsafe_integer", + raw_line: unsafeDensity, + }, + { + command: "wm density", + field: "override_density", + reason: "nonpositive", + raw_line: zeroDensity, + }, + ], + coverage: "partial", + }, + { + name: "malformed final labels suppress earlier values and bare density", + size: `Physical size: 1080x2340\nOverride size: 720x1280\n${unsafeOverride}\n${zeroOverride}\n`, + density: `420\nPhysical density: 320\n${invalidDensity}\n480\n${overflowDensity}\n`, + physical_size: { width: 1080, height: 2340 }, + override_size: null, + physical_density: null, + override_density: null, + issues: [ + { + command: "wm size", + field: "override_size", + reason: "unsafe_integer", + raw_line: unsafeOverride, + }, + { + command: "wm size", + field: "override_size", + reason: "nonpositive", + raw_line: zeroOverride, + }, + { + command: "wm density", + field: "physical_density", + reason: "invalid_format", + raw_line: invalidDensity, + }, + { + command: "wm density", + field: "override_density", + reason: "unsafe_integer", + raw_line: overflowDensity, + }, + ], + coverage: "partial", + }, + { + name: "the first bare numeric density wins even when invalid", + size: "Physical size: 1080x2340\n", + density: `${bareZero}\n420\n`, + physical_size: { width: 1080, height: 2340 }, + override_size: null, + physical_density: null, + override_density: null, + issues: [ + { + command: "wm density", + field: "physical_density", + reason: "nonpositive", + raw_line: bareZero, + }, + ], + coverage: "partial", + }, + { + name: "one issue sample bound across both commands", + size: [ + "Physical size: 1080x2340", + ...cappedSizeIssues.map(({ raw_line }) => raw_line), + "", + ].join("\n"), + density: [ + "Physical density: 420", + ...cappedDensityIssues.map(({ raw_line }) => raw_line), + "", + ].join("\n"), + physical_size: { width: 1080, height: 2340 }, + override_size: null, + physical_density: 420, + override_density: null, + issues: [...cappedSizeIssues, ...cappedDensityIssues], + coverage: "partial", + }, +]; + +cliTest.skipIf(process.platform === "win32")( + "preserves display declaration precedence and bounded numeric issues through CLI and MCP", + async ({ cli }) => { + const fixture = await createNumericAdbFixture(); + const contract = toolContract("inspect_adb_display"); + for (const scenario of displayScenarios) { + await writeFile(fixture.size, scenario.size); + await writeFile(fixture.density, scenario.density); + const output = await cli.run({ + arguments: ["inspect-adb-display", SERIAL, "--json"], + environment: fixture.environment, + }); + expect(output.exitCode, `${scenario.name}: ${output.stderr}`).toBe(0); + const evidence = contract.outputSchema.parse(output.json); + expect(evidence.parameters).toEqual({ serial: SERIAL }); + expect(evidence.normalized_result).toMatchObject({ + serial: SERIAL, + physical_size: scenario.physical_size, + override_size: scenario.override_size, + physical_density: scenario.physical_density, + override_density: scenario.override_density, + numeric_issues: { + sample: scenario.issues.slice(0, 10), + total: scenario.issues.length, + }, + coverage: scenario.coverage, + }); + const reply = await fixture.call("inspect_adb_display", { + serial: SERIAL, + }); + expect(reply.isError).not.toBe(true); + const structured = contract.outputSchema.parse(reply.structuredContent); + expect(structured.normalized_result).toEqual(evidence.normalized_result); + expect(structured.limitations).toEqual(evidence.limitations); + } + }, +); + +cliTest.skipIf(process.platform === "win32")( + "returns typed partial display facts instead of success for absent or malformed physical size", + async ({ cli }) => { + const fixture = await createNumericAdbFixture(); + await writeFile( + fixture.density, + "Physical density: 320\nOverride density: 480\n", + ); + for (const [value, reason] of [ + [null, null], + ["portrait", "invalid_format"], + [`${UNSAFE}x2`, "unsafe_integer"], + [`2x${OVERFLOW}`, "unsafe_integer"], + ["0x2", "nonpositive"], + ] as const) { + const raw = value === null ? null : ` \tPhysical size: ${value} \t`; + await writeFile( + fixture.size, + [ + ...(raw === null ? [] : ["Physical size: 1080x2340", raw]), + "Override size: 7x9", + "", + ].join("\n"), + ); + const output = await cli.run({ + arguments: ["inspect-adb-display", SERIAL, "--json"], + environment: fixture.environment, + }); + expect(output.exitCode, output.stderr).toBe(1); + const projected = analysisErrorProjectionSchema.parse(output.json); + expect(projected.code).toBe("unreadable_output"); + const partial = adbDisplayPartialObservationSchema.parse( + projected.details?.partial_observation, + ); + expect(partial).toMatchObject({ + provider_id: "adb", + operation: "inspect_adb_display", + reason: + raw === null ? "physical_size_absent" : "physical_size_malformed", + result: { + serial: SERIAL, + physical_size: null, + override_size: { width: 7, height: 9 }, + physical_density: 320, + override_density: 480, + numeric_issues: { + sample: + raw === null + ? [] + : [ + { + command: "wm size", + field: "physical_size", + reason, + raw_line: raw, + }, + ], + total: raw === null ? 0 : 1, + }, + coverage: "partial", + }, + }); + const reply = await fixture.call("inspect_adb_display", { + serial: SERIAL, + }); + const mcpError = parseMcpToolError(reply).error; + expect(mcpError).toEqual(projected); + expect( + adbDisplayPartialObservationSchema.parse( + mcpError.details?.partial_observation, + ), + ).toEqual(partial); + } + }, +); + +const malformedUser = " \tuserId=unknown \t"; +const unsafeApp = ` \tappId=${UNSAFE} \t`; +const overflowUser = ` \tuserId=${OVERFLOW} \t`; +const malformedApp = " \tappId=-1 \t"; +const cappedPackageIssues = Array.from({ length: 12 }, (_, index) => ({ + command: "dumpsys package", + field: "user_id", + reason: "invalid_format", + raw_line: ` \t${index % 2 === 0 ? "userId" : "appId"}=bad-${String(index)} \t`, +})); +const packageScenarios = [ + { + name: "absent user id before a hidden package", + lines: [], + userId: null, + issues: [], + }, + { + name: "zero admitted first through appId", + lines: [" appId=0 shared", " userId=10001"], + userId: 0, + issues: [], + }, + { + name: "safe boundary with numeric suffix grammar", + lines: [" userId=9007199254740991 target"], + userId: Number.MAX_SAFE_INTEGER, + issues: [], + }, + { + name: "invalid aliases before and after the first admitted user", + lines: [ + malformedUser, + unsafeApp, + " userId=10001 installing=true", + " appId=20002", + overflowUser, + malformedApp, + ], + userId: 10001, + issues: [ + { + command: "dumpsys package", + field: "user_id", + reason: "invalid_format", + raw_line: malformedUser, + }, + { + command: "dumpsys package", + field: "user_id", + reason: "unsafe_integer", + raw_line: unsafeApp, + }, + { + command: "dumpsys package", + field: "user_id", + reason: "unsafe_integer", + raw_line: overflowUser, + }, + { + command: "dumpsys package", + field: "user_id", + reason: "invalid_format", + raw_line: malformedApp, + }, + ], + }, + { + name: "unsafe declarations leave the user unknown", + lines: [unsafeApp, overflowUser], + userId: null, + issues: [ + { + command: "dumpsys package", + field: "user_id", + reason: "unsafe_integer", + raw_line: unsafeApp, + }, + { + command: "dumpsys package", + field: "user_id", + reason: "unsafe_integer", + raw_line: overflowUser, + }, + ], + }, + { + name: "bounded diagnostics after an admitted zero", + lines: [ + " userId=0", + ...cappedPackageIssues.map(({ raw_line }) => raw_line), + ], + userId: 0, + issues: cappedPackageIssues, + }, +]; + +cliTest.skipIf(process.platform === "win32")( + "preserves package user alias admission, raw versions and numeric issues through CLI and MCP", + async ({ cli }) => { + const fixture = await createNumericAdbFixture(); + const contract = toolContract("inspect_adb_package"); + for (const scenario of packageScenarios) { + await writeFile( + fixture.packageDetails, + [ + `Package [${PACKAGE}]:`, + ` pkg=Package{fixture ${PACKAGE}}`, + ` versionName=${VERSION_NAME}`, + ` versionCode=${UNSAFE} minSdk=26`, + ...scenario.lines, + "Hidden system packages:", + " userId=hidden-invalid", + " appId=777", + " versionName=hidden-version", + "", + ].join("\n"), + ); + const output = await cli.run({ + arguments: ["inspect-adb-package", SERIAL, PACKAGE, "--json"], + environment: fixture.environment, + }); + expect(output.exitCode, `${scenario.name}: ${output.stderr}`).toBe(0); + const evidence = contract.outputSchema.parse(output.json); + expect(evidence.parameters).toEqual({ serial: SERIAL, package: PACKAGE }); + expect(evidence.normalized_result).toMatchObject({ + serial: SERIAL, + package_name: PACKAGE, + user_id: scenario.userId, + version_name: VERSION_NAME, + version_code: UNSAFE, + requested_permissions_count: 0, + numeric_issues: { + sample: scenario.issues.slice(0, 10), + total: scenario.issues.length, + }, + coverage: scenario.issues.length === 0 ? "complete" : "partial", + }); + const reply = await fixture.call("inspect_adb_package", { + serial: SERIAL, + package: PACKAGE, + }); + expect(reply.isError).not.toBe(true); + const structured = contract.outputSchema.parse(reply.structuredContent); + expect(structured.normalized_result).toEqual(evidence.normalized_result); + expect(structured.limitations).toEqual(evidence.limitations); + } + }, +); diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts index 3ad6b69ede..c0f08cae21 100644 --- a/tests/boundary/mcp/adbMcp.test.ts +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -102,6 +102,7 @@ const results: Readonly> = { user_id: 10234, pkg_flags: "[ HAS_CODE ]", requested_permissions_count: 2, + numeric_issues: { sample: [], total: 0 }, coverage: "complete", }, pull_adb_file: { @@ -191,6 +192,8 @@ const results: Readonly> = { override_size: null, physical_density: 420, override_density: null, + numeric_issues: { sample: [], total: 0 }, + coverage: "complete", }, inspect_adb_window: { client: clientIdentity,