Files
reverse-engineering-anything/src/adb/AdbCommand.ts
T
김준호andt 92f332652d fix(adb): separate unresolvable paths and host capacity in adb spawn failures (#6188)
* fix(adb): name unexecutable adb paths in readiness refusals

When REA_ADB_PATH or the PATH-resolved adb is a directory or a file the
host refuses to execute, the version probe fails at spawn with EACCES or
EPERM. That fell through to the generic "did not report a usable version"
refusal, which named neither the path nor the cause.

Classify those spawn errors as binary_not_executable (provider rejection
executable_missing), name the selected path and errno, and give
remediation for the environment, PATH and host-policy cases. The generic
fallback now names the selected path too.

Fixes #5689

* fix(process): admit synchronous spawn errors as captured failures

Node throws spawn errors other than EACCES, EAGAIN, EMFILE, ENFILE and
ENOENT synchronously from execFile. execFileBytes called execFile inside
its Promise executor, so ENOTDIR or EPERM became a rejection without
capture fields and execFileOutputFailure returned undefined. Consumers
that classify by failure code, such as the adb version probe, could not
see the code, which left the adb EPERM branch unreachable.

Catch synchronous spawn errno errors at the capture boundary and attach
the same empty capture an asynchronous spawn failure carries. Argument
validation errors stay unadmitted.

The adb version probe now reports an ENOTDIR selected path, such as a
REA_ADB_PATH whose parent is a regular file, as a missing binary with
the filesystem error code instead of the generic version refusal.

* fix(process): expose spawn failures from the capture owner

execFileBytes knows when a command never started, but
ExecFileOutputFailure did not say so, so consumers reread raw syscall
and code fields and disagreed. Mark both the synchronous throw and an
asynchronous `spawn <path>` error with `spawnFailed: true`, and set
`path` on the synchronous error to match the asynchronous shape.
Argument validation errors stay unadmitted.

Consumers now use the owner field:

- inspectAdbClient never reports a spawn errno as `exit_code`. ENOENT,
  ENOTDIR, ELOOP and ENAMETOOLONG are `binary_missing`; EACCES and EPERM
  stay `binary_not_executable`; any other spawn errno, such as EINVAL,
  is `binary_not_executable` with a path-naming "could not be started"
  message. Each records `filesystem_error` and `path_source`. EACCES
  remediation also names parent directory search permission, and EPERM
  remediation no longer names host policy as the only cause.
- DarwinProcessRunTokenReader tags every not-started compiler as
  `spawn_failed`, not only ENOENT. EACCES, EPERM and ENOTDIR were
  previously `failed`.
- NativeDmgArtifactReader uses one not-started rule for both
  `processNotStarted` (previously `spawn ` only, so synchronous errors
  were missed) and the ENOENT unavailable/io split (previously any
  `spawn` prefix). Its fake hosts now reject with real execFileOutput
  spawn failures.

* fix(process): classify spawn failures consistently across consumers

Admit synchronous and asynchronous spawn errnos through one helper that
requires an errno and a `spawn` or `spawn <path>` syscall. The
synchronous shape now also gains `spawnargs`, so both shapes match.

Use the owner's `spawnFailed` fact in the remaining consumers:

- inspectAdbClient reports exhausted host capacity (EAGAIN, EMFILE,
  ENFILE, ENOMEM) as a host failure to retry and no longer tells the
  caller to change REA_ADB_PATH. Path-resolution errnos come from a
  predicate the filesystem owner exports.
- runAdbCapture says adb could not be started instead of claiming an
  exit code for a process that never ran.
- The Darwin compiler probe words every spawn_failed outcome as a start
  failure, not a compilation failure.
- Asset catalog inspection treats any assetutil that never started as
  unavailable, not only ENOENT.

* fix(process): report host spawn capacity as a retryable constraint

Spawn errnos that report exhausted host capacity (EAGAIN, EMFILE, ENFILE,
ENOMEM) were classified by adb readiness alone. assetutil reported them as
an unavailable tool, which marked inspect_asset_catalog unavailable in the
session, and adb commands reported them as protocol failures.

Classify them once in the process capture owner and report a retryable
capacity constraint with recovery advice from assetutil and adb commands.

* docs(adb): describe host capacity failures from device operations

* fix(adb): report exhausted host spawn capacity as runtime unavailability

A host that cannot start adb for lack of process, memory or descriptor
capacity is not a configuration problem. Give it its own failure code and
map it to the runtime_unavailable provider rejection instead of
not_configured.

* fix(adb): report probe-time host capacity as a retryable constraint

A device operation whose adb version probe could not start for lack of host
capacity was refused as capability_unavailable without retryable. It now
reports the same retryable capacity constraint as a later adb command.

* fix(adb): keep the selected adb path in probe-time capacity errors

The retryable capacity constraint raised at the version probe dropped the
selected_binary and path_source diagnostics the refusal used to carry.

* refactor(adb): keep the spawn-failure fact to adb consumers

Restore the DMG reader, Darwin compiler probe and assetutil to main. The
capture owner still marks a never-started command, including the spawn
errors Node throws synchronously, and only the adb readiness probe and
adb commands read that fact.

* test(adb): assert spawn-failure facts instead of refusal wording

The new adb tests compared complete refusal sentences and banned an old
phrase. They now check the errno, selected path, path source and the
owner's spawn-failure marker, so wording edits do not break them.

* fix(adb): own one host-capacity remediation and cover the spawn route

The host-capacity constraint and the configuration failure each carried their own remediation sentence. AdbConfiguration now owns the text and both use it, so the constraint's remediation.action equals diagnostics.remediation.

The synchronous spawn-throw test now runs on Node's execFile route and on the owned spawn route adb uses (stopSignal). The provider test covers inspect_adb_client under EMFILE next to list_adb_devices, and the docs say which operations report the retryable capacity constraint and which report runtime_unavailable.

* fix(process): report EMFILE and ENFILE spawn failures without stdio

After an EMFILE or ENFILE spawn failure Node creates no stdio, so the
owned stop-signal route threw a TypeError while reading the streams and
the later spawn error went unhandled. Attach the error listener first and
capture absent streams as empty, so adb reports retryable host capacity.

* fix(adb): keep a completed push when host capacity stops its digest check

A capacity failure starting sha256sum came back as a retryable error even
though adb push had already written the file. Repeating the request with
overwrite=false is then refused because the destination exists.

Return the receipt with an unavailable device digest and name the capacity
failure in its limitations, so the caller is told the transfer is unverified
rather than advised to retry.

* docs(adb): list host capacity as an unverified push case

---------

Co-authored-by: t <t@t>
2026-11-05 15:40:32 +08:00

420 lines
14 KiB
TypeScript

import {
AnalysisCancelledError,
AnalysisProtocolError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import {
execFileBytes,
execFileFailureCapturedOutput,
ExecFileOutputCancelledError,
execFileOutputFailure,
type ExecFileOutputFailure,
spawnCapacityExhausted,
} from "../process/ExecFileOutput.js";
import { packageChangeFailureReason } from "./AdbShellOutput.js";
import {
isSuccessfulDaemonStartNotice,
isDaemonStartingNotice,
hasOnlySuccessfulAdbStartupNotices,
} from "./AdbDeviceOutput.js";
import {
adbHostCapacityRemediation,
type AdbBinarySelection,
} from "./AdbConfiguration.js";
const STDERR_EXCERPT_CHARACTERS = 512;
// Match the known host operand in adb's local-file diagnostics. The suffix is
// part of the format: opening/reading identify local failures explicitly.
const LOCAL_FILE_DIAGNOSTICS = [
["cannot create", ":"],
["cannot create file/directory", ":"],
["cannot stat", ":"],
["cannot open", ":"],
["cannot write", ":"],
["failed to stat local file", ":"],
["failed to read all of", ":"],
["failed to access", ":"],
["failed to stat", ":"],
["failed to open", ":"],
["opening", " locally failed:"],
["reading", " locally failed:"],
] as const;
/** Parsed command failure owned by this adapter, separate from its public tag. */
class AdbProtocolError extends AnalysisProtocolError {
constructor(
message: string,
readonly commandFailure: ExecFileOutputFailure,
cause: unknown,
) {
super(message, {
cause,
capturedOutput: execFileFailureCapturedOutput(commandFailure),
});
}
}
/** Return the retained process result only for command failures owned here. */
export const adbCommandOutputFailure = (
error: AnalysisError,
): ExecFileOutputFailure | undefined =>
error instanceof AdbProtocolError ? error.commandFailure : undefined;
/** adb joins shell operands into a remote POSIX command; quote at that boundary. */
const quoteShellOperand = (value: string): string =>
/^[A-Za-z0-9_./:=@+,-]+$/u.test(value)
? value
: "'" + value.replaceAll("'", "'\\''") + "'";
export const adbArguments = (
serial: string | undefined,
arguments_: readonly string[],
): readonly string[] => {
const operands =
arguments_[0] === "shell"
? ["shell", ...arguments_.slice(1).map(quoteShellOperand)]
: [...arguments_];
return serial === undefined ? operands : ["-s", serial, ...operands];
};
export interface AdbRunLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
/**
* Live device observations can shrink between calls. Mutation commands omit
* this so a truncated result stays non-retryable.
*/
readonly volatileOutput?: boolean;
}
/** Keep the selected device separate from the file addressed by the command. */
export interface AdbCommandTarget {
readonly device: string;
/** Caller-addressed file, such as a remote path or the original APK input. */
readonly path?: string;
/** Exact host operand adb consumes, including an owned input snapshot. */
readonly localPath?: string;
}
/** The host, not adb, lacked process, memory or descriptor capacity to start it. */
export const adbSpawnCapacityError = (
operation: string,
spawnError: string,
cause: unknown,
diagnostics?: Readonly<Record<string, JsonValue>>,
): AnalysisResourceConstraintError =>
new AnalysisResourceConstraintError(
operation,
"capacity",
`The host could not start adb (${spawnError}) for ${operation}`,
{ spawn_error: spawnError },
{
cause,
diagnostics,
retryable: true,
remediationAction: adbHostCapacityRemediation(spawnError),
},
);
/** Run one bounded adb invocation and keep its exact failure shape. */
export const runAdbCapture = async (
operation: string,
target: AdbCommandTarget,
selection: AdbBinarySelection,
arguments_: readonly string[],
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<{ stdout: Buffer; stderr: Buffer }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation, { cause: signal.reason }));
try {
return ok(
await execFileBytes(selection.binary, arguments_, {
env: selection.environment,
timeout: limits.timeoutMs,
maxBuffer: limits.maxOutputBytes,
stopSignal: "SIGTERM",
...(signal === undefined ? {} : { signal }),
}),
);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (cause instanceof ExecFileOutputCancelledError)
return err(
new AnalysisCancelledError(operation, {
cause: cause.cause,
...(failure === undefined
? {}
: { capturedOutput: execFileFailureCapturedOutput(failure) }),
}),
);
if (failure === undefined)
return err(
new AnalysisProtocolError(`adb failed for ${operation}`, { cause }),
);
const detail = {
cause,
capturedOutput: execFileFailureCapturedOutput(failure),
};
if (failure.outputTruncated) {
const name = `The adb output capture allowance of ${String(limits.maxOutputBytes)} bytes (this release has no runtime override)`;
const allowance =
limits.volatileOutput === true
? {
fixedAllowance: { name, kind: "live" as const },
retryable: true as const,
}
: fixedAllowanceOptions(name, "capture");
return err(
new AnalysisResourceConstraintError(
operation,
"memory",
`adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{ ...detail, ...allowance },
),
);
}
const stderr = failure.stderr.trim();
const excerpt = stderr.slice(0, STDERR_EXCERPT_CHARACTERS);
// Device-state words inside a reported file name are not device verdicts.
// Only leading notices need line parsing. Do not expand a capture-sized
// stderr into an array whose entries can exceed the byte capture budget.
let diagnosticStart = 0;
while (diagnosticStart < stderr.length) {
const newline = stderr.indexOf("\n", diagnosticStart);
const line = stderr
.slice(diagnosticStart, newline === -1 ? stderr.length : newline)
.trimEnd();
if (!isSuccessfulDaemonStartNotice(line) && !isDaemonStartingNotice(line))
break;
diagnosticStart = newline === -1 ? stderr.length : newline + 1;
}
const diagnostic = stderr
.slice(diagnosticStart)
.replace(
/^(?:adb:\s*)?(?:error:\s*)?(?:(?:failed to get feature set|connect failed):\s*)?/iu,
"",
);
if (
/^(?:unauthorized\b|device(?: '[^'\n]*')? (?:unauthorized\b|offline\b|not found\b|still authorizing\b)|still authorizing\b|more than one device\b)/iu.test(
diagnostic,
)
)
return err(
new AnalysisUnsupportedTargetError(
operation,
target.device,
excerpt,
detail,
),
);
if (
/No such file or directory|Permission denied|read: Permission denied/iu.test(
diagnostic,
)
) {
const localPath = target.localPath;
const path =
localPath !== undefined &&
LOCAL_FILE_DIAGNOSTICS.some(([verb, suffix]) =>
[localPath, `'${localPath}'`, `"${localPath}"`].some((operand) =>
diagnostic.startsWith(`${verb} ${operand}${suffix}`),
),
)
? localPath
: (target.path ?? target.device);
return err(
new AnalysisUnsupportedTargetError(operation, path, excerpt, detail),
);
}
if (failure.killed)
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, {
...detail,
userMessage: `adb ${operation} reached its ${String(limits.timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`,
remediationAction: `This ${String(limits.timeoutMs)} ms adb deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
}),
);
// The host, not adb, lacked capacity to start it.
if (spawnCapacityExhausted(failure))
return err(adbSpawnCapacityError(operation, String(failure.code), cause));
return err(
new AdbProtocolError(
failure.spawnFailed === true
? `adb could not be started (${String(failure.code)}) for ${operation}`
: excerpt === ""
? `adb exited with code ${String(failure.code)} for ${operation}`
: excerpt,
failure,
cause,
),
);
}
};
/** Decode textual adb protocols after the shared byte capture and error boundary. */
export const runAdb = async (
...arguments_: Parameters<typeof runAdbCapture>
): Promise<
Result<{ stdout: string; stderr: string; stdoutBytes: number }, AnalysisError>
> => {
const captured = await runAdbCapture(...arguments_);
if (!captured.ok) return captured;
return ok({
stdout: captured.value.stdout.toString("utf8"),
stderr: captured.value.stderr.toString("utf8"),
// Byte count of the captured output before UTF-8 decoding, so malformed
// sequences replaced by U+FFFD still report their true captured size.
stdoutBytes: captured.value.stdout.byteLength,
});
};
/**
* adb exits nonzero when the device refuses a package change, printing the
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
* refusal from that failed run so it is not reported as unreadable output.
*/
export const packageChangeRefusal = (
operation: string,
serial: string,
error: AnalysisError,
): Result<never, AnalysisError> | undefined => {
if (!(error instanceof AdbProtocolError)) return undefined;
const failure = error.commandFailure;
const reason = packageChangeFailureReason(
`${failure.stdout}\n${failure.stderr}`,
);
return reason === null
? undefined
: err(
new AnalysisUnsupportedTargetError(operation, serial, reason, {
cause: error,
}),
);
};
/** Parsed predicate and the complete output that supports it. */
export interface AdbPathProbe {
readonly matches: boolean;
readonly capturedOutput: {
readonly stdout: string;
readonly stderr: string;
readonly stdout_bytes: number;
readonly stderr_bytes: number;
readonly exit_code: 0 | 1;
readonly signal: null;
readonly truncated: false;
};
}
const completedAdbPathProbe = (
matches: boolean,
stderr: string,
): AdbPathProbe => ({
matches,
capturedOutput: {
stdout: "",
stderr,
stdout_bytes: 0,
stderr_bytes: Buffer.byteLength(stderr),
exit_code: matches ? 0 : 1,
signal: null,
truncated: false,
},
});
const recognizedProbeStderr = (stderr: string): boolean =>
stderr === "" || hasOnlySuccessfulAdbStartupNotices(stderr);
/** Only a complete false predicate, with no device diagnostics, establishes absence. */
export const probeAdbPathExistence = async (
operation: string,
serial: string,
path: string,
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<AdbPathProbe, AnalysisError>> => {
return probeAdbPath(operation, serial, path, selection, limits, signal, "-e");
};
/** Completed predicate status is independent of recognized host startup notices. */
export const probeAdbPathDirectory = async (
operation: string,
serial: string,
path: string,
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
): Promise<Result<AdbPathProbe, AnalysisError>> =>
probeAdbPath(operation, serial, path, selection, limits, signal, "-d");
const probeAdbPath = async (
operation: string,
serial: string,
path: string,
selection: AdbBinarySelection,
limits: AdbRunLimits,
signal: AbortSignal | undefined,
predicate: "-d" | "-e",
): Promise<Result<AdbPathProbe, AnalysisError>> => {
const fact =
predicate === "-d" ? "destination_directory" : "destination_existence";
const probed = await runAdbCapture(
operation,
{ device: serial, path },
selection,
adbArguments(serial, ["shell", "test", predicate, path]),
{ ...limits, volatileOutput: true },
signal,
);
if (probed.ok) {
const stderr = probed.value.stderr.toString("utf8");
if (probed.value.stdout.length === 0 && recognizedProbeStderr(stderr))
return ok(completedAdbPathProbe(true, stderr));
return err(
new AnalysisProtocolError(
`The ADB path ${predicate === "-d" ? "directory" : "existence"} probe returned unexpected output; destination status is unknown`,
{
diagnostics: { [fact]: "unknown" },
capturedOutput: {
stdout: probed.value.stdout.toString("utf8"),
stderr: probed.value.stderr.toString("utf8"),
stdout_bytes: probed.value.stdout.length,
stderr_bytes: probed.value.stderr.length,
exit_code: 0,
signal: null,
truncated: false,
},
},
),
);
}
const error = probed.error;
if (!(error instanceof AdbProtocolError)) return probed;
const failure = error.commandFailure;
if (
failure.code === 1 &&
failure.signal === null &&
!failure.killed &&
!failure.outputTruncated &&
failure.collectionFailure !== true &&
failure.stdout === "" &&
recognizedProbeStderr(failure.stderr)
)
return ok(completedAdbPathProbe(false, failure.stderr));
return err(
new AnalysisProtocolError(error.message, {
cause: error.cause,
diagnostics: { [fact]: "unknown" },
capturedOutput: execFileFailureCapturedOutput(failure),
}),
);
};