mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
* 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>
420 lines
14 KiB
TypeScript
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),
|
|
}),
|
|
);
|
|
};
|