fix(adb): preserve numeric intent and typed acquisition outcomes

This commit is contained in:
morluto committed 2026-10-24 02:29:08 +08:00
1 parent 8e57b7672b
commit 57e3e0b04d
9 files changed
+283 -25

No files matched your search

+10
View File
@@ -242,3 +242,13 @@ Record: verified against adb 34.0.5 on Linux against an Android 14
287 services, 92 features, a real two-APK split set pulled with byte-exact
digests, a verified push/pull roundtrip, a 1.3 MB screen capture, and the
complete package lifecycle; see `docs/testing.md` for the lane summary.
Intent integer extras use signed decimal text. The ADB argument encoder removes
redundant leading zeros for Android's radix-sensitive `--ei` parser while retained
Evidence keeps the caller's original spelling. Package inspection accepts both
`userId` and Android 16's `appId` spelling for the legacy `user_id` result field.
A completed push and device digest verification are separate observations. A
verification timeout, cancellation, or output budget failure returns the typed
failure with partial Evidence of the completed transfer. An unavailable checksum
command or unrecognized checksum output leaves verification explicitly unknown.
+14 -1
View File
@@ -51,6 +51,7 @@ export const resolveAdbBinary = (
export type AdbConfigurationFailureCode =
| "not_absolute"
| "binary_missing"
| "binary_not_executable"
| "version_unresolved";
export class AdbConfigurationFailure extends Error {
@@ -173,6 +174,18 @@ export const inspectAdbClient = async (
},
{ cause },
);
if (failure?.code === "EACCES" || failure?.code === "EPERM")
throw new AdbConfigurationFailure(
"binary_not_executable",
`Execution of the selected adb binary was denied (${failure.code}): ${selection.binary}`,
{
selected_binary: selection.binary,
path_source: selection.pathSource,
filesystem_error: failure.code,
remediation: `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary`,
},
{ cause },
);
throw new AdbConfigurationFailure(
"version_unresolved",
"The selected adb binary did not report a usable version",
@@ -196,7 +209,7 @@ const rejectionCodeOf = (
): ProviderRejectionCode =>
code === "binary_missing"
? "executable_missing"
: code === "not_absolute"
: code === "not_absolute" || code === "binary_not_executable"
? "not_configured"
: "version_unresolved";
+92 -2
View File
@@ -2013,12 +2013,20 @@ ${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat
action: "android.intent.action.VIEW",
data_uri: "https://example.com/",
extras: [
{ key: "level", type: "int", value: "3" },
{ key: "level", type: "int", value: "010" },
{ key: "negative", type: "int", value: "-010" },
{ key: "eight", type: "int", value: "08" },
{ key: "label", type: "string", value: "probe" },
],
},
});
expect(started.action).toBe("android.intent.action.VIEW");
expect(started.extras).toEqual([
{ key: "level", type: "int", value: "010" },
{ key: "negative", type: "int", value: "-010" },
{ key: "eight", type: "int", value: "08" },
{ key: "label", type: "string", value: "probe" },
]);
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
@@ -2036,7 +2044,13 @@ ${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat
"https://example.com/",
"--ei",
"level",
"3",
"10",
"--ei",
"negative",
"-10",
"--ei",
"eight",
"8",
"--es",
"label",
"probe",
@@ -2122,3 +2136,79 @@ it("reports cancellation arriving during the version probe", async () => {
clearTimeout(timer);
}
});
it("preserves the typed offline failure and failed paths when every package pull fails", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$3" = "shell" ]; then printf 'package:/data/app/base.apk\\n'; exit 0; fi
if [ "$3" = "pull" ]; then printf 'error: device offline\\n' >&2; exit 1; fi
exit 1`,
);
const result = await providerOver(binary).execute({
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.example",
output_directory: join(root, "pulls"),
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unsupported_target",
details: { reason: "error: device offline" },
});
expect(result.error.partialObservation).toMatchObject({
result: {
artifacts: [],
failures: [{ device_path: "/data/app/base.apk", stage: "pull" }],
coverage: "partial",
},
});
});
it("retains a completed push when checksum capture exceeds its budget", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$3" = "push" ]; then exit 0; fi
if [ "$4" = "sha256sum" ]; then head -c 2048 /dev/zero | tr '\\000' a; exit 0; fi
exit 1`,
);
const local = join(root, "payload.bin");
await writeFile(local, "payload");
const result = await providerOver(binary).execute({
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: local,
device_path: "/data/local/tmp/payload.bin",
overwrite: true,
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "resource_constraint",
details: { operation: "push_adb_file" },
});
expect(result.error.partialObservation).toBeDefined();
});
it("attributes version capture failures to the public operation and retains the prerequisite", async () => {
const { binary } = await writeAdbStub(
() => "head -c 70000 /dev/zero | tr '\\000' a",
);
const result = await providerOver(binary).execute({
operation: "list_adb_devices",
input: {},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "resource_constraint",
details: {
operation: "list_adb_devices",
diagnostics: { prerequisite_operation: "inspect_adb_client" },
},
});
});
+74 -14
View File
@@ -1,3 +1,4 @@
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { createHash } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import {
@@ -1325,7 +1326,15 @@ export class AdbProvider {
selection = this.selection();
identity = await inspectAdbClient(selection, signal);
} catch (cause) {
if (cause instanceof AnalysisError) return err(cause);
if (cause instanceof AnalysisError)
return err(
analysisClientFailureForOperation(
cause,
ADB_PROVIDER_IDENTITY.id,
request.operation,
{ prerequisite_operation: "inspect_adb_client" },
),
);
if (cause instanceof AdbConfigurationFailure)
return err(
providerConfigurationRefusal(
@@ -2253,15 +2262,32 @@ export class AdbProvider {
}
}
if (artifacts.length === 0 && failures.length > 0) {
const primary = new AnalysisProtocolError(
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
{
cause: new AggregateError(
failures.map(({ cause }) => cause),
"APK pulls failed",
),
const firstCause = failures[0]!.cause;
const primary =
firstCause instanceof AnalysisError
? firstCause
: new AnalysisProtocolError(
`Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`,
{
cause: new AggregateError(
failures.map(({ cause }) => cause),
"APK pulls failed",
),
},
);
primary.retainPartialObservation({
provider_id: "adb",
operation,
result: {
client,
serial,
package_name: packageName,
output_directory: directory.value,
artifacts,
failures: observedFailures(),
coverage: "partial",
},
);
});
return failOwnedPackage(primary);
}
return ok(
@@ -2377,7 +2403,7 @@ export class AdbProvider {
new AnalysisOutputError(
operation,
`The pulled file could not be read back after pulling: ${localPath}`,
{ cause },
{ cause, partialObservation },
),
);
}
@@ -2510,14 +2536,20 @@ export class AdbProvider {
if (!deviceDigest.ok) {
const cancelled =
deviceDigest.error instanceof AnalysisCancelledError;
const verificationFailed =
cancelled ||
deviceDigest.error instanceof AnalysisResourceConstraintError ||
deviceDigest.error instanceof AnalysisTimeoutError;
const completed = receipt({
device_sha256: null,
device_digest_source: "unavailable",
reason: cancelled
? "The device-side digest check was cancelled after push completed"
: "The device did not answer sha256sum, so the transfer is not device-side verified",
: verificationFailed
? `Device-side digest verification failed after push completed: ${deviceDigest.error.message}`
: "The device did not answer sha256sum, so the transfer is not device-side verified",
});
if (cancelled)
if (verificationFailed)
deviceDigest.error.retainPartialObservation(
createEvidence(
completed.subject ?? undefined,
@@ -2533,7 +2565,7 @@ export class AdbProvider {
},
),
);
return cancelled ? err(deviceDigest.error) : ok(completed);
return verificationFailed ? err(deviceDigest.error) : ok(completed);
}
const parsedDigest = parseSha256sumOutput(deviceDigest.value.stdout);
const completed =
@@ -2642,6 +2674,20 @@ export class AdbProvider {
installed
);
const verdict = parsePackageChangeOutput(installed.value.stdout);
if (verdict.status === "unknown")
return err(
new AnalysisOutputError(
operation,
"ADB returned no installation verdict",
{
capturedOutput: {
stdout: installed.value.stdout,
stderr: installed.value.stderr,
truncated: false,
},
},
),
);
if (verdict.status === "failure")
return err(
new AnalysisUnsupportedTargetError(
@@ -2706,6 +2752,16 @@ export class AdbProvider {
uninstalled
);
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
if (verdict.status === "unknown")
return err(
new AnalysisOutputError(operation, "ADB returned no removal verdict", {
capturedOutput: {
stdout: uninstalled.value.stdout,
stderr: uninstalled.value.stderr,
truncated: false,
},
}),
);
if (verdict.status === "failure")
return err(
new AnalysisUnsupportedTargetError(
@@ -2823,7 +2879,11 @@ export class AdbProvider {
...input.extras.flatMap((extra) => [
extraFlags[extra.type],
extra.key,
extra.value,
// Android decodes --ei with Integer.decode, whose leading zero is octal.
// Encode decimal intent at this boundary; Evidence retains caller text.
extra.type === "int"
? extra.value.replace(/^(-?)0+(?=\d)/u, "$1")
: extra.value,
]),
];
return this.runAmStart(
+43
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
parseAmStartOutput,
parsePackageChangeOutput,
parseDumpsysPackageOutput,
parseWmDensityOutput,
parseWindowFocusOutput,
@@ -18,6 +19,16 @@ const packageDump = (installerLine: string | undefined): string =>
].join("\n");
describe("parseDumpsysPackageOutput", () => {
it.each(["userId", "appId"])(
"retains the printed %s package identifier",
(key) => {
expect(
parseDumpsysPackageOutput(
`Packages:\n Package [com.example] (abcdef):\n ${key}=10234\n`,
).userId,
).toBe(10234);
},
);
it.each(["", " My Build ", " ", "null"])(
"preserves the printed version name %j",
(versionName) => {
@@ -224,6 +235,16 @@ describe("parseWindowFocusOutput declaration presence", () => {
});
describe("parseDumpsysPackageOutput", () => {
it.each(["userId", "appId"])(
"retains the printed %s package identifier",
(key) => {
expect(
parseDumpsysPackageOutput(
`Packages:\n Package [com.example] (abcdef):\n ${key}=10234\n`,
).userId,
).toBe(10234);
},
);
it("reports unknown requested permissions when the package is null", () => {
expect(
parseDumpsysPackageOutput("Package [com.example] (abcdef):\n pkg=null\n")
@@ -490,3 +511,25 @@ describe("parseAmStartOutput", () => {
});
});
});
describe("parsePackageChangeOutput", () => {
it.each(["", "Performing Streamed Install\n", "progress"])(
"keeps an absent package-change verdict unknown: %j",
(text) => {
expect(parsePackageChangeOutput(text)).toEqual({
status: "unknown",
reason: null,
});
},
);
it("retains explicit failure even alongside success text", () => {
expect(parsePackageChangeOutput("Success\nFailure [DENIED]\n")).toEqual({
status: "failure",
reason: "DENIED",
});
expect(parsePackageChangeOutput("Success\n")).toEqual({
status: "success",
reason: null,
});
});
});
+5 -8
View File
@@ -360,7 +360,7 @@ export const parseDumpsysPackageOutput = (
details.installerPackageName === null
)
details.installerPackageName = absentInstallerOrName(value);
else if (key === "userId" && details.userId === null) {
else if ((key === "userId" || key === "appId") && details.userId === null) {
const parsed = /^(\d+)/u.exec(value);
if (parsed !== null) details.userId = Number(parsed[1]!);
} else if (key === "pkgFlags" && details.pkgFlags === null)
@@ -419,17 +419,14 @@ export const packageChangeFailureReason = (output: string): string | null =>
export const parsePackageChangeOutput = (
stdout: string,
): {
readonly status: "success" | "failure";
readonly status: "success" | "failure" | "unknown";
readonly reason: string | null;
} => {
const text = stdout.trim();
const failure = packageChangeFailureReason(text);
if (failure !== null) return { status: "failure", reason: failure };
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
return {
status: "failure",
reason:
packageChangeFailureReason(text) ??
(text === "" ? null : text.slice(0, 512)),
};
return { status: "unknown", reason: null };
};
/**
+19
View File
@@ -60,3 +60,22 @@ it("names the adb version-output cap and says it has no runtime override", async
expect(projected.remediation.action).toContain("65536 bytes");
expect(projected.remediation.action).toContain("no runtime override");
});
it("distinguishes execute denial from missing or unreadable version output", async () => {
vi.mocked(execFileOutput).mockRejectedValueOnce(
Object.assign(new Error("denied"), {
code: "EACCES",
stdout: "",
stderr: "",
signal: null,
killed: false,
}),
);
await expect(inspectAdbClient(selection)).rejects.toMatchObject({
code: "binary_not_executable",
diagnostics: {
selected_binary: selection.binary,
filesystem_error: "EACCES",
},
});
});
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import { providerConfigurationRefusal } from "./providerConfigurationRefusal.js";
import { projectAnalysisError } from "./analysisErrorProjection.js";
it("retains provider-owned configuration diagnostics in public refusals", () => {
const failure = Object.assign(new Error("version unreadable"), {
diagnostics: {
selected_command: "/selected/apktool",
command_source: "environment",
remediation: "Inspect the selected launcher",
},
});
expect(
projectAnalysisError(
providerConfigurationRefusal(
"apktool",
"inspect_apktool_client",
failure,
),
),
).toMatchObject({
code: "capability_unavailable",
details: { diagnostics: failure.diagnostics },
});
});
@@ -17,5 +17,6 @@ export const providerConfigurationRefusal = (
return new AnalysisCapabilityUnavailableError(providerId, operation, reason, {
cause: failure,
userMessage: reason,
diagnostics: failure.diagnostics,
});
};