fix(adb): own numeric declaration validation and diagnostics
Parse declarations before selecting values, admit safe integers once, and retain bounded raw numeric issues. Preserve display and package selection policies while distinguishing absent and malformed values. Keep completed display facts in typed physical-size failures, share the existing diagnostic sample limit, and verify real CLI/MCP contracts with a strict fake ADB.
This commit is contained in:
1 parent
0b1af48a37
commit
667ab5ae08
11 files changed
+753
-54
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 = <Issue>() => {
|
||||
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 }),
|
||||
};
|
||||
};
|
||||
+49
-10
@@ -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<AdbDisplayNumericIssues["sample"][number]>();
|
||||
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",
|
||||
],
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+97
-38
@@ -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<DisplayNumericIssue>();
|
||||
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<DisplayNumericIssue>();
|
||||
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<AdbPackageNumericIssues["sample"][number]>();
|
||||
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() };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -513,6 +513,51 @@ export const adbWindowFocusContextSchema = z.strictObject({
|
||||
});
|
||||
export type AdbWindowFocusContext = z.infer<typeof adbWindowFocusContextSchema>;
|
||||
|
||||
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<typeof adbResultSchemas.inspect_adb_window>,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -102,6 +102,7 @@ const results: Readonly<Record<string, unknown>> = {
|
||||
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<Record<string, unknown>> = {
|
||||
override_size: null,
|
||||
physical_density: 420,
|
||||
override_density: null,
|
||||
numeric_issues: { sample: [], total: 0 },
|
||||
coverage: "complete",
|
||||
},
|
||||
inspect_adb_window: {
|
||||
client: clientIdentity,
|
||||
|
||||
Reference in new issue
Block a user