fix(adb): preserve selected environment through provider execution

This commit is contained in:
morluto committed 2026-10-26 23:06:36 +08:00
1 parent a30ffd27a6
commit b7e76a57cd
8 files changed
+126 -41

No files matched your search

+5 -5
View File
@@ -16,7 +16,7 @@ const probe = (signal?: AbortSignal) =>
"push_adb_file",
"device",
"/device/path",
"/host/adb",
{ binary: "/host/adb", pathSource: "environment", environment: {} },
{
timeoutMs: 100,
maxOutputBytes: 1024,
@@ -40,7 +40,7 @@ it("classifies the complete diagnostic before shortening its displayed reason",
runAdbCapture(
"pull_adb_file",
{ device: "device", path: "/remote/file", localPath },
"/host/adb",
{ binary: "/host/adb", pathSource: "environment", environment: {} },
[],
{ timeoutMs: 100, maxOutputBytes: 2048 },
undefined,
@@ -200,7 +200,7 @@ it.each([true, false])(
const result = await runAdbCapture(
volatileOutput ? "read_adb_logcat" : "install_adb_package",
{ device: "device" },
"/host/adb",
{ binary: "/host/adb", pathSource: "environment", environment: {} },
volatileOutput ? ["logcat", "-d"] : ["install", "app.apk"],
{ timeoutMs: 100, maxOutputBytes: 1024, volatileOutput },
undefined,
@@ -230,7 +230,7 @@ it("reports the effective fixed adb deadline", async () => {
const result = await runAdbCapture(
"pull_adb_file",
{ device: "device" },
"/host/adb",
{ binary: "/host/adb", pathSource: "environment", environment: {} },
["pull", "/remote"],
{ timeoutMs: 600_000, maxOutputBytes: 1024 },
undefined,
@@ -255,7 +255,7 @@ it("keeps a physical failure even when the later abort reason is that same error
const result = await runAdbCapture(
"inspect_adb_device",
{ device: "device" },
"/host/adb",
{ binary: "/host/adb", pathSource: "environment", environment: {} },
[],
{ timeoutMs: 100, maxOutputBytes: 1024 },
controller.signal,
+10 -8
View File
@@ -17,6 +17,7 @@ import {
} from "../process/ExecFileOutput.js";
import { packageChangeFailureReason } from "./AdbShellOutput.js";
import { isSuccessfulDaemonStartNotice } from "./AdbDeviceOutput.js";
import type { AdbBinarySelection } from "./AdbConfiguration.js";
const STDERR_EXCERPT_CHARACTERS = 512;
@@ -97,7 +98,7 @@ export interface AdbCommandTarget {
export const runAdbCapture = async (
operation: string,
target: AdbCommandTarget,
binary: string,
selection: AdbBinarySelection,
arguments_: readonly string[],
limits: AdbRunLimits,
signal: AbortSignal | undefined,
@@ -106,7 +107,8 @@ export const runAdbCapture = async (
return err(new AnalysisCancelledError(operation, { cause: signal.reason }));
try {
return ok(
await execFileBytes(binary, arguments_, {
await execFileBytes(selection.binary, arguments_, {
env: selection.environment,
timeout: limits.timeoutMs,
maxBuffer: limits.maxOutputBytes,
stopSignal: "SIGTERM",
@@ -268,11 +270,11 @@ export const probeAdbPathExistence = async (
operation: string,
serial: string,
path: string,
binary: string,
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<boolean, AnalysisError>> => {
return probeAdbPath(operation, serial, path, binary, limits, signal, "-e");
return probeAdbPath(operation, serial, path, selection, limits, signal, "-e");
};
/** Only the clean true/false result of test -d establishes directory status. */
@@ -280,17 +282,17 @@ export const probeAdbPathDirectory = async (
operation: string,
serial: string,
path: string,
binary: string,
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<boolean, AnalysisError>> =>
probeAdbPath(operation, serial, path, binary, limits, signal, "-d");
probeAdbPath(operation, serial, path, selection, limits, signal, "-d");
const probeAdbPath = async (
operation: string,
serial: string,
path: string,
binary: string,
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
predicate: "-d" | "-e",
@@ -300,7 +302,7 @@ const probeAdbPath = async (
const probed = await runAdbCapture(
operation,
{ device: serial, path },
binary,
selection,
adbArguments(serial, ["shell", "test", predicate, path]),
{ ...limits, volatileOutput: true },
signal,
+3
View File
@@ -11,6 +11,7 @@ describe("resolveAdbBinary", () => {
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: "/opt/tools/adb" })).toEqual({
binary: "/opt/tools/adb",
pathSource: "environment",
environment: { [ADB_BINARY_ENV]: "/opt/tools/adb" },
});
});
@@ -18,10 +19,12 @@ describe("resolveAdbBinary", () => {
expect(resolveAdbBinary({})).toEqual({
binary: "adb",
pathSource: "path",
environment: {},
});
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: " " })).toEqual({
binary: "adb",
pathSource: "path",
environment: { [ADB_BINARY_ENV]: " " },
});
});
+15 -3
View File
@@ -4,6 +4,7 @@ import {
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
execFileOutput,
@@ -26,6 +27,7 @@ const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
export interface AdbBinarySelection {
readonly binary: string;
readonly pathSource: "environment" | "path";
readonly environment: Readonly<Record<string, string | undefined>>;
}
/**
@@ -36,16 +38,25 @@ export interface AdbBinarySelection {
export const resolveAdbBinary = (
environment: Readonly<Record<string, string | undefined>>,
): AdbBinarySelection => {
const configured = environment[ADB_BINARY_ENV];
const selectedEnvironment = snapshotEnvironment(environment);
const configured = selectedEnvironment[ADB_BINARY_ENV];
if (configured === undefined || configured.trim() === "")
return { binary: "adb", pathSource: "path" };
return {
binary: "adb",
pathSource: "path",
environment: selectedEnvironment,
};
if (!isAbsolute(configured))
throw new AdbConfigurationFailure(
"not_absolute",
`${ADB_BINARY_ENV} must be an absolute path to the adb binary`,
{ configured_path: configured },
);
return { binary: configured, pathSource: "environment" };
return {
binary: configured,
pathSource: "environment",
environment: selectedEnvironment,
};
};
export type AdbConfigurationFailureCode =
@@ -98,6 +109,7 @@ export const inspectAdbClient = async (
selection.binary,
["version"],
{
env: selection.environment,
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
+20 -19
View File
@@ -104,6 +104,7 @@ import {
type AdbBinarySelection,
type AdbClientIdentity,
} from "./AdbConfiguration.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
displayDevicePath,
parseAdbDevicesOutput,
@@ -883,7 +884,7 @@ export class AdbProvider {
#outputCleanupPromise: Promise<void> | undefined;
constructor(options: AdbProviderOptions = {}) {
this.environment = options.environment ?? process.env;
this.environment = snapshotEnvironment(options.environment ?? process.env);
this.#createRuntimeRoot =
options.createRuntimeRoot ??
((rootOptions) => PrivateRuntimeRoot.create(rootOptions));
@@ -1370,7 +1371,7 @@ export class AdbProvider {
{
device: "serial" in request.input ? request.input.serial : operation,
},
selection.binary,
selection,
adbArguments(
"serial" in request.input ? request.input.serial : undefined,
["shell", ...arguments_],
@@ -1390,7 +1391,7 @@ export class AdbProvider {
const ran = await runAdb(
operation,
{ device: "adb-devices" },
selection.binary,
selection,
["devices", "-l"],
{
timeoutMs: DEVICES_TIMEOUT_MS,
@@ -1427,7 +1428,7 @@ export class AdbProvider {
const ran = await runAdb(
operation,
{ device: request.input.serial },
selection.binary,
selection,
adbArguments(request.input.serial, ["shell", "getprop"]),
{
timeoutMs: SHELL_TIMEOUT_MS,
@@ -1662,7 +1663,7 @@ export class AdbProvider {
const ran = await runAdbCapture(
operation,
{ device: request.input.serial, path: request.input.device_path },
selection.binary,
selection,
adbArguments(request.input.serial, [
"shell",
"sh",
@@ -1972,7 +1973,7 @@ export class AdbProvider {
const paths = await runAdb(
operation,
{ device: serial },
selection.binary,
selection,
adbArguments(serial, ["shell", "pm", "path", packageName]),
{
timeoutMs: PM_PATH_TIMEOUT_MS,
@@ -2197,7 +2198,7 @@ export class AdbProvider {
path: entry.device_path,
localPath,
},
selection.binary,
selection,
adbArguments(serial, ["pull", entry.device_path, localPath]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
@@ -2391,7 +2392,7 @@ export class AdbProvider {
path: input.device_path,
localPath,
},
selection.binary,
selection,
adbArguments(input.serial, ["pull", input.device_path, localPath]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
@@ -2496,7 +2497,7 @@ export class AdbProvider {
operation,
input.serial,
input.device_path,
selection.binary,
selection,
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
);
@@ -2508,7 +2509,7 @@ export class AdbProvider {
operation,
input.serial,
devicePath,
selection.binary,
selection,
{ timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
);
@@ -2528,7 +2529,7 @@ export class AdbProvider {
path: input.device_path,
localPath: digest.path,
},
selection.binary,
selection,
adbArguments(input.serial, ["push", digest.path, input.device_path]),
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
signal,
@@ -2569,7 +2570,7 @@ export class AdbProvider {
const deviceDigest = await runAdb(
operation,
{ device: input.serial, path: devicePath },
selection.binary,
selection,
adbArguments(input.serial, ["shell", "sha256sum", devicePath]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: 1024 },
signal,
@@ -2700,7 +2701,7 @@ export class AdbProvider {
const installed = await runAdb(
operation,
{ device: input.serial, path: apkPath, localPath: digest.path },
selection.binary,
selection,
adbArguments(input.serial, [
"install",
...(input.replace ? ["-r"] : []),
@@ -2772,7 +2773,7 @@ export class AdbProvider {
const uninstalled = await runAdb(
operation,
{ device: input.serial },
selection.binary,
selection,
adbArguments(input.serial, [
"shell",
"pm",
@@ -2839,7 +2840,7 @@ export class AdbProvider {
const resolved = await runAdb(
operation,
{ device: input.serial },
selection.binary,
selection,
adbArguments(input.serial, [
"shell",
"cmd",
@@ -2959,7 +2960,7 @@ export class AdbProvider {
const started = await runAdb(
operation,
{ device: serial },
selection.binary,
selection,
adbArguments(serial, ["shell", "am", "start", "-W", ...arguments_]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
signal,
@@ -3047,7 +3048,7 @@ export class AdbProvider {
const stopped = await runAdb(
operation,
{ device: input.serial },
selection.binary,
selection,
adbArguments(input.serial, ["shell", "am", "force-stop", input.package]),
{ timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES },
signal,
@@ -3089,7 +3090,7 @@ export class AdbProvider {
const captured = await runAdbCapture(
operation,
{ device: input.serial },
selection.binary,
selection,
adbArguments(input.serial, ["exec-out", "screencap", "-p"]),
{
timeoutMs: SCREEN_TIMEOUT_MS,
@@ -3243,7 +3244,7 @@ export class AdbProvider {
const collected = await runAdb(
operation,
{ device: input.serial },
selection.binary,
selection,
adbArguments(input.serial, ["bugreport", stagingDirectory]),
{
timeoutMs: BUGREPORT_TIMEOUT_MS,
+1
View File
@@ -13,6 +13,7 @@ vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
const selection = {
binary: "/host/adb",
pathSource: "environment",
environment: {},
} as const;
it("reports the fixed adb version-probe deadline", async () => {
@@ -3,7 +3,10 @@ import { dirname, join } from "node:path";
import { beforeEach, expect, onTestFinished, test, vi } from "vitest";
import { runAdbCapture } from "../../../../src/adb/AdbCommand.js";
import { inspectAdbClient } from "../../../../src/adb/AdbConfiguration.js";
import {
inspectAdbClient,
resolveAdbBinary,
} from "../../../../src/adb/AdbConfiguration.js";
import { AdbDeviceAnalysisService } from "../../../../src/application/adb/AdbDeviceAnalysisService.js";
import {
execFileOutput,
@@ -65,7 +68,7 @@ const capture = (
runAdbCapture(
"inspect_adb_device",
{ device: "device" },
process.execPath,
resolveAdbBinary({ ...process.env, REA_ADB_PATH: process.execPath }),
["-e", script],
limits,
signal,
@@ -152,7 +155,7 @@ const clientScript = async (source: string) => {
await writeFile(binary, `#!${process.execPath}\n${source}\n`, {
mode: 0o755,
});
return { binary, pathSource: "environment" as const };
return resolveAdbBinary({ ...process.env, REA_ADB_PATH: binary });
};
const nativeClientTest = test.skipIf(process.platform === "win32");
@@ -162,7 +165,7 @@ test.each([null, "caller stopped", new Error("caller stopped")] as const)(
const controller = new AbortController();
controller.abort(reason);
const error: unknown = await inspectAdbClient(
{ binary: "/must-not-start", pathSource: "environment" },
resolveAdbBinary({ REA_ADB_PATH: "/must-not-start" }),
controller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({
@@ -207,7 +210,7 @@ nativeClientTest(
const controller = new AbortController();
hooks.afterCommand = () => controller.abort(null);
const error: unknown = await inspectAdbClient(
{ binary: join(root, "missing"), pathSource: "environment" },
resolveAdbBinary({ REA_ADB_PATH: join(root, "missing") }),
controller.signal,
).catch((cause: unknown) => cause);
expect(error).toMatchObject({
@@ -8,7 +8,7 @@ import {
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { expect, onTestFinished, test, vi } from "vitest";
import { AdbProvider } from "../../../../src/adb/AdbProvider.js";
import { AdbDeviceAnalysisService } from "../../../../src/application/adb/AdbDeviceAnalysisService.js";
@@ -26,6 +26,69 @@ const png = Buffer.from(
const digest = (bytes: string | Buffer) =>
createHash("sha256").update(bytes).digest("hex");
it.each(["path", "explicit"] as const)(
"uses the captured %s launcher environment for both ADB probing and device queries",
async (source) => {
const root = await createTestTempDirectory("rea-adb-selected-environment-");
const selectedDirectory = join(root, "selected");
const ambientDirectory = join(root, "ambient");
const sourceCode = `#!/bin/sh
printf '%s|%s|%s|%s\\n' "$1" "$ADB_SERVER_SOCKET" "$CALLER_EMPTY" "\${CALLER_ABSENT-unset}" >> "$0.calls"
if [ "$1" = version ]; then
printf 'Android Debug Bridge version 1.0.41\\nVersion 35.0.1-fixture\\nInstalled as %s\\n' "$0"
elif [ "$1" = devices ]; then
printf 'List of devices attached\\n%s device model:fixture transport_id:1\\n' "$ANDROID_SERIAL"
else exit 1; fi
`;
for (const directory of [selectedDirectory, ambientDirectory]) {
await mkdir(directory);
await writeFile(join(directory, "adb"), sourceCode, { mode: 0o755 });
}
const selected: NodeJS.ProcessEnv = {
...process.env,
PATH: selectedDirectory,
ADB_SERVER_SOCKET: "tcp:localhost:5041",
ANDROID_SERIAL: "selected-device",
CALLER_EMPTY: "",
...(source === "explicit"
? { REA_ADB_PATH: join(selectedDirectory, "adb") }
: {}),
};
delete selected.CALLER_ABSENT;
if (source === "path") delete selected.REA_ADB_PATH;
const provider = new AdbProvider({ environment: selected });
onTestFinished(async () => {
try {
await provider.close();
} finally {
vi.unstubAllEnvs();
}
});
selected.ADB_SERVER_SOCKET = "tcp:localhost:5042";
selected.ANDROID_SERIAL = "later-selected-device";
vi.stubEnv("PATH", ambientDirectory);
vi.stubEnv("ADB_SERVER_SOCKET", "tcp:localhost:5043");
vi.stubEnv("ANDROID_SERIAL", "ambient-device");
vi.stubEnv("CALLER_EMPTY", "ambient-value");
vi.stubEnv("CALLER_ABSENT", "ambient-only");
const service = new AdbDeviceAnalysisService(provider);
const result = await service.execute("list_adb_devices", {});
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toMatchObject({
devices: [{ serial: "selected-device" }],
});
expect(JSON.stringify(result.value)).not.toContain("tcp:localhost:");
expect(await readFile(join(selectedDirectory, "adb.calls"), "utf8")).toBe(
"version|tcp:localhost:5041||unset\ndevices|tcp:localhost:5041||unset\n",
);
await expect(
access(join(ambientDirectory, "adb.calls")),
).rejects.toMatchObject({
code: "ENOENT",
});
},
);
// All ADB commands run a selected local script. No device, server or real adb
// executable participates; file snapshots, publication and Evidence are real.
const fixture = async () => {