Files
rea/src/adb/AdbConfiguration.ts
T
morluto 0134e15ab1 refactor(process): centralize captured command failure facts
Project admitted output facts beside the command owner and remove adapter-local copies. Preserve byte-count omission, explicit signal and exit facts, and caller-specific truncation and failure policies.

Update ADB timeout regressions to assert the existing prerequisite frame and original process cause, including captured output on both frames.
2026-10-24 06:53:53 +08:00

225 lines
7.7 KiB
TypeScript

import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import {
execFileOutput,
execFileFailureCapturedOutput,
ExecFileOutputCancelledError,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Environment key selecting the caller's adb binary. */
export const ADB_BINARY_ENV = "REA_ADB_PATH";
const VERSION_TIMEOUT_MS = 10_000;
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
/** How the adb binary was selected for this provider instance. */
export interface AdbBinarySelection {
readonly binary: string;
readonly pathSource: "environment" | "path";
}
/**
* Resolve the caller-supplied adb binary. `REA_ADB_PATH` must be absolute so
* the selection stays explicit; without it, adb is resolved from `PATH` by
* the platform exec at run time, like any bring-your-own tool.
*/
export const resolveAdbBinary = (
environment: Readonly<Record<string, string | undefined>>,
): AdbBinarySelection => {
const configured = environment[ADB_BINARY_ENV];
if (configured === undefined || configured.trim() === "")
return { binary: "adb", pathSource: "path" };
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" };
};
export type AdbConfigurationFailureCode =
| "not_absolute"
| "binary_missing"
| "binary_not_executable"
| "version_unresolved";
export class AdbConfigurationFailure extends Error {
constructor(
readonly code: AdbConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
super(message, options);
}
}
export interface AdbClientIdentity {
readonly version: string | null;
readonly installedPath: string | null;
}
/**
* Parse `adb version` output. The first line carries the protocol version,
* `Version ` the build version (no colon), and `Installed as` the binary path.
*/
export const parseAdbVersionOutput = (stdout: string): AdbClientIdentity => {
const build = /^Version\s+(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/mu.exec(
stdout,
);
const installed = /^Installed as\s+(.+)$/mu.exec(stdout);
return {
version: build === null ? null : build[1]!.trim(),
installedPath: installed === null ? null : installed[1]!.trim(),
};
};
/**
* Probe the selected adb binary without contacting devices. `adb version`
* never starts the adb server, so this stays a pure client check.
*/
export const inspectAdbClient = async (
selection: AdbBinarySelection,
signal?: AbortSignal,
): Promise<AdbClientIdentity> => {
try {
const { stdout, stderr } = await execFileOutput(
selection.binary,
["version"],
{
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
},
);
const identity = parseAdbVersionOutput(stdout);
const hasRecognizedProtocolVersion =
/^Android Debug Bridge version\s+\d+\.\d+\.\d+\s*$/mu.test(stdout);
if (identity.version === null && !hasRecognizedProtocolVersion)
throw new AdbConfigurationFailure(
"version_unresolved",
"The selected adb binary exited successfully but did not report a recognizable protocol or build version",
{
selected_binary: selection.binary,
path_source: selection.pathSource,
version_probe: "unrecognized_output",
stdout,
stderr,
},
);
return identity;
} catch (cause) {
if (cause instanceof AdbConfigurationFailure) throw cause;
const failure = execFileOutputFailure(cause);
if (cause instanceof ExecFileOutputCancelledError)
throw new AnalysisCancelledError("inspect_adb_client", {
cause: cause.cause,
...(failure === undefined
? {}
: { capturedOutput: execFileFailureCapturedOutput(failure) }),
});
const diagnostics = {
selected_binary: selection.binary,
path_source: selection.pathSource,
...(failure === undefined ? {} : { exit_code: failure.code }),
};
const options = {
cause,
diagnostics,
...(failure === undefined
? {}
: { capturedOutput: execFileFailureCapturedOutput(failure) }),
};
if (failure?.outputTruncated === true) {
throw new AnalysisResourceConstraintError(
"inspect_adb_client",
"memory",
`adb version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget`,
{ max_output_bytes: VERSION_MAX_OUTPUT_BYTES },
{
...options,
...fixedAllowanceOptions(
`The adb version-output capture allowance of ${String(VERSION_MAX_OUTPUT_BYTES)} bytes (this release has no runtime override)`,
"capture",
),
},
);
}
if (failure?.code === "ETIMEDOUT")
throw new AnalysisTimeoutError("inspect_adb_client", VERSION_TIMEOUT_MS, {
...options,
userMessage: `adb version probe reached its ${String(VERSION_TIMEOUT_MS)} ms deadline (deadlineExpired). This release has no runtime override.`,
remediationAction: `This ${String(VERSION_TIMEOUT_MS)} ms adb version probe has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
});
if (failure?.code === "ENOENT")
throw new AdbConfigurationFailure(
"binary_missing",
`The selected adb binary was not found: ${selection.binary}`,
{
selected_binary: selection.binary,
path_source: selection.pathSource,
remediation: `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary`,
},
{ 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",
{
selected_binary: selection.binary,
...(failure === undefined
? {}
: {
exit_code: failure.code,
stderr_excerpt: failure.stderr.slice(0, 512),
}),
},
{ cause },
);
}
};
/** Availability in the shape shared by every provider. */
const rejectionCodeOf = (
code: AdbConfigurationFailureCode,
): ProviderRejectionCode =>
code === "binary_missing"
? "executable_missing"
: code === "not_absolute" || code === "binary_not_executable"
? "not_configured"
: "version_unresolved";
export const adbConfigurationAvailability = (
cause: AdbConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;