mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
582 lines
21 KiB
TypeScript
582 lines
21 KiB
TypeScript
import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js";
|
|
import { capturedApktoolFailureOutput } from "./ApktoolCommandOutput.js";
|
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
import { ok, err, type Result } from "../domain/result.js";
|
|
import { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisResourceConstraintError,
|
|
AnalysisTimeoutError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { isAbsolute, win32 } from "node:path";
|
|
import { stat } from "node:fs/promises";
|
|
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
|
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
|
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import {
|
|
OwnedCommandFailure,
|
|
runOwnedCommand,
|
|
prepareOwnedCommand,
|
|
} from "../process/OwnedCommand.js";
|
|
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
|
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { parse } from "semver";
|
|
import {
|
|
isWindowsBatchFile,
|
|
windowsBatchCommand,
|
|
WindowsBatchCommandError,
|
|
} from "../process/WindowsBatchCommand.js";
|
|
|
|
/** Environment key selecting the caller's apktool launcher. */
|
|
export const APKTOOL_COMMAND_ENV = "REA_APKTOOL_COMMAND";
|
|
|
|
const VERSION_TIMEOUT_MS = 20_000;
|
|
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
|
|
const apktoolVersionProbeTimeoutGuidance = (): {
|
|
readonly userMessage: string;
|
|
readonly remediationAction: string;
|
|
} => ({
|
|
userMessage: `apktool version probe reached its ${String(VERSION_TIMEOUT_MS)} ms deadline (deadlineExpired). This release has no runtime override.`,
|
|
remediationAction: `This ${String(VERSION_TIMEOUT_MS)} ms apktool version probe has no runtime override. decode_timeout_ms does not raise it. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
|
|
});
|
|
|
|
/** How the apktool launcher was selected for this provider instance. */
|
|
export interface ApktoolCommandSelection {
|
|
readonly command: string;
|
|
readonly commandSource: "environment" | "path";
|
|
}
|
|
|
|
/**
|
|
* Preserve an explicit launcher, or resolve the default Windows command before
|
|
* choosing native versus batch process creation. POSIX keeps execFile's PATH
|
|
* lookup. The resolved selection is reused for the version and decode runs.
|
|
*/
|
|
export const resolveApktoolCommand = async (
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
platform: NodeJS.Platform = process.platform,
|
|
signal?: AbortSignal,
|
|
): Promise<ApktoolCommandSelection> => {
|
|
if (signal?.aborted === true)
|
|
throw new AnalysisCancelledError("inspect_apktool_client", {
|
|
cause: signal.reason,
|
|
});
|
|
const selectedEnvironment = snapshotEnvironment(environment, platform);
|
|
const configured = selectedEnvironment[APKTOOL_COMMAND_ENV];
|
|
if (configured !== undefined && configured.trim() !== "") {
|
|
if (
|
|
!(platform === "win32"
|
|
? win32.isAbsolute(configured)
|
|
: isAbsolute(configured))
|
|
)
|
|
throw new ApktoolConfigurationFailure(
|
|
"not_absolute",
|
|
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
|
|
{ configured_command: configured },
|
|
);
|
|
return { command: configured, commandSource: "environment" };
|
|
}
|
|
if (platform !== "win32")
|
|
return { command: "apktool", commandSource: "path" };
|
|
const extensions = (selectedEnvironment.PATHEXT ?? ".EXE;.COM;.BAT;.CMD")
|
|
.split(";")
|
|
.filter((extension) => extension !== "");
|
|
for (const extension of extensions)
|
|
if (!extension.startsWith(".") || /[\\/:\0]/u.test(extension))
|
|
throw new ApktoolConfigurationFailure(
|
|
"command_unresolved",
|
|
"Windows PATHEXT must contain filename extensions, not paths",
|
|
{
|
|
selected_command: "apktool",
|
|
command_source: "path",
|
|
invalid_extension: extension,
|
|
},
|
|
);
|
|
const directories = (selectedEnvironment.PATH ?? "")
|
|
.split(";")
|
|
.filter((directory) => directory !== "");
|
|
if (selectedEnvironment.NODEFAULTCURRENTDIRECTORYINEXEPATH === undefined)
|
|
directories.unshift(process.cwd());
|
|
// A single deadline owns the whole PATH search, including filesystem waits.
|
|
const deadline = new ProviderStartupDeadline(VERSION_TIMEOUT_MS, signal);
|
|
const lookupInterruption = () =>
|
|
deadline.interruption === "cancelled"
|
|
? new AnalysisCancelledError("inspect_apktool_client", {
|
|
cause: signal?.reason,
|
|
})
|
|
: new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, {
|
|
diagnostics: { phase: "windows_path_lookup" },
|
|
...apktoolVersionProbeTimeoutGuidance(),
|
|
});
|
|
try {
|
|
for (const entry of directories) {
|
|
const directory =
|
|
entry.startsWith('"') && entry.endsWith('"')
|
|
? entry.slice(1, -1)
|
|
: entry;
|
|
for (const extension of extensions) {
|
|
if (deadline.interruption !== undefined) throw lookupInterruption();
|
|
const candidate = win32.resolve(directory, `apktool${extension}`);
|
|
try {
|
|
const metadata = await waitForAbortable(
|
|
stat(candidate),
|
|
deadline.signal,
|
|
);
|
|
if (metadata === ABORTED || deadline.interruption !== undefined)
|
|
throw lookupInterruption();
|
|
if (metadata.isFile())
|
|
return { command: candidate, commandSource: "path" };
|
|
} catch (cause: unknown) {
|
|
if (deadline.interruption !== undefined) throw lookupInterruption();
|
|
const code =
|
|
typeof cause === "object" && cause !== null
|
|
? Reflect.get(cause, "code")
|
|
: undefined;
|
|
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
|
throw new ApktoolConfigurationFailure(
|
|
"command_unresolved",
|
|
`The PATH-selected apktool launcher could not be inspected: ${candidate}`,
|
|
{
|
|
selected_command: "apktool",
|
|
command_source: "path",
|
|
candidate_path: candidate,
|
|
...(typeof code === "string" ? { filesystem_error: code } : {}),
|
|
},
|
|
{ cause },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
deadline.dispose();
|
|
}
|
|
|
|
throw new ApktoolConfigurationFailure(
|
|
"command_missing",
|
|
"The apktool launcher was not found on the selected Windows PATH/PATHEXT",
|
|
{
|
|
selected_command: "apktool",
|
|
command_source: "path",
|
|
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`,
|
|
},
|
|
);
|
|
};
|
|
|
|
export type ApktoolConfigurationFailureCode =
|
|
| "not_absolute"
|
|
| "command_missing"
|
|
| "command_not_executable"
|
|
| "command_unresolved"
|
|
| "launcher_unsupported"
|
|
| "version_unresolved"
|
|
| "unsupported_version";
|
|
|
|
export class ApktoolConfigurationFailure extends Error {
|
|
constructor(
|
|
readonly code: ApktoolConfigurationFailureCode,
|
|
message: string,
|
|
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(message, options);
|
|
}
|
|
}
|
|
|
|
export interface ApktoolClientIdentity {
|
|
readonly version: string;
|
|
readonly supportsDocumentedVersion: boolean;
|
|
readonly limitations: readonly string[];
|
|
}
|
|
|
|
/** Process creation for one apktool run on the selected host. */
|
|
export interface ApktoolInvocation {
|
|
readonly command: string;
|
|
readonly arguments: readonly string[];
|
|
readonly windowsVerbatimArguments: boolean;
|
|
}
|
|
|
|
/**
|
|
* Run the selected launcher directly, or through cmd.exe when Windows can
|
|
* only execute it there (the Chocolatey `apktool.bat` and `java -jar`
|
|
* wrapper scripts). Throws WindowsBatchCommandError when a token cannot be
|
|
* passed to cmd.exe safely.
|
|
*/
|
|
export const apktoolInvocation = (
|
|
selection: ApktoolCommandSelection,
|
|
arguments_: readonly string[],
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): ApktoolInvocation =>
|
|
platform === "win32" && isWindowsBatchFile(selection.command)
|
|
? {
|
|
...windowsBatchCommand({
|
|
environment,
|
|
batchPath: selection.command,
|
|
arguments: arguments_,
|
|
}),
|
|
windowsVerbatimArguments: true,
|
|
}
|
|
: {
|
|
command: selection.command,
|
|
arguments: arguments_,
|
|
windowsVerbatimArguments: false,
|
|
};
|
|
|
|
/** Read the version from Apktool's single-line `--version` response. */
|
|
export const parseApktoolVersionOutput = (
|
|
stdout: string,
|
|
platform: NodeJS.Platform = process.platform,
|
|
):
|
|
| ApktoolClientIdentity
|
|
| { readonly version: null; readonly reportedVersion: string | null } => {
|
|
const line = stdout
|
|
.split(/\r?\n/u)
|
|
.map((entry) => entry.trim())
|
|
.find((entry) => entry !== "");
|
|
const parsed = line === undefined ? null : parse(line);
|
|
if (line === undefined || parsed === null)
|
|
return { version: null, reportedVersion: line ?? null };
|
|
// Debian's exact -dirty marker denotes a downstream release build. Preserve
|
|
// the reported spelling; other prereleases remain earlier than their release.
|
|
const downstreamRelease =
|
|
parsed.prerelease.length === 1 &&
|
|
parsed.prerelease[0] === "dirty" &&
|
|
parsed.build.length === 0;
|
|
const atLeast = (release: string): boolean =>
|
|
(downstreamRelease
|
|
? parsed.compareMain(release)
|
|
: parsed.compare(release)) >= 0;
|
|
const limitations: string[] = [];
|
|
const discloseAdvisory = (
|
|
fix: string,
|
|
advisory: string,
|
|
slug: string,
|
|
): string =>
|
|
`Reported apktool version ${JSON.stringify(line)} predates the upstream ${fix} fix for ${advisory} (decoded resource paths could escape the output directory). Verify the selected decoder's patches before decoding untrusted APKs; launcher version alone does not establish engine contents or downstream backports. https://github.com/iBotPeaches/Apktool/security/advisories/${slug}`;
|
|
if (!atLeast("2.9.2"))
|
|
limitations.push(
|
|
discloseAdvisory("2.9.2", "CVE-2024-21633", "GHSA-2hqv-2xv4-5h5w"),
|
|
);
|
|
if (platform === "win32" && !atLeast("2.9.3"))
|
|
limitations.push(
|
|
discloseAdvisory(
|
|
"2.9.3",
|
|
"GHSA-vgwr-4w3p-xmjv on Windows",
|
|
"GHSA-vgwr-4w3p-xmjv",
|
|
),
|
|
);
|
|
// Upstream 65dd848 validates ResTypeSpec names in 3.0.2. The tagged 3.0.0
|
|
// producer also lacks that check, although the advisory names only 3.0.1.
|
|
if (parsed.major === 3 && parsed.minor === 0 && !atLeast("3.0.2"))
|
|
limitations.push(
|
|
discloseAdvisory("3.0.2", "CVE-2026-39973", "GHSA-m8mh-x359-vm8m") +
|
|
(parsed.patch === 0
|
|
? " Inclusion of the 3.0.0 release line is source-derived; the advisory lists 3.0.1."
|
|
: ""),
|
|
);
|
|
return {
|
|
version: line,
|
|
supportsDocumentedVersion: atLeast("2.7.0"),
|
|
limitations,
|
|
};
|
|
};
|
|
|
|
/** Probe within the caller-admitted command scope; decodes nothing. */
|
|
export const inspectApktoolClient = async (
|
|
selection: ApktoolCommandSelection,
|
|
cleanup: ApktoolCommandCleanup,
|
|
options: {
|
|
readonly signal?: AbortSignal;
|
|
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
readonly platform?: NodeJS.Platform;
|
|
} = {},
|
|
): Promise<ApktoolClientIdentity> => {
|
|
const {
|
|
signal,
|
|
environment = process.env,
|
|
platform = process.platform,
|
|
} = options;
|
|
const hostEnvironment = snapshotEnvironment(environment, platform);
|
|
let invocation: ApktoolInvocation;
|
|
try {
|
|
invocation = apktoolInvocation(
|
|
selection,
|
|
["--version"],
|
|
hostEnvironment,
|
|
platform,
|
|
);
|
|
} catch (cause) {
|
|
if (!(cause instanceof WindowsBatchCommandError)) throw cause;
|
|
throw new ApktoolConfigurationFailure(
|
|
"launcher_unsupported",
|
|
`The selected apktool launcher cannot be run through cmd.exe: ${cause.message}`,
|
|
{
|
|
selected_command: selection.command,
|
|
command_source: selection.commandSource,
|
|
remediation: `Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable`,
|
|
},
|
|
{ cause },
|
|
);
|
|
}
|
|
let interpreted:
|
|
| Result<ApktoolClientIdentity, ApktoolConfigurationFailure>
|
|
| undefined;
|
|
try {
|
|
// The owned process group stops a shell launcher's children with it; a
|
|
// child holding inherited output pipes cannot outlive the deadline.
|
|
await prepareOwnedCommand(signal);
|
|
await runOwnedCommand(
|
|
{
|
|
command: invocation.command,
|
|
arguments: invocation.arguments,
|
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
// Apktool launchers are commonly interpreter scripts, so ownership is
|
|
// proved by the run token rather than the launcher's argv prefix.
|
|
expectedCommand: null,
|
|
runId: randomUUID(),
|
|
hostEnvironment,
|
|
},
|
|
{
|
|
timeoutMs: VERSION_TIMEOUT_MS,
|
|
diagnosticBytes: VERSION_MAX_OUTPUT_BYTES,
|
|
observeCompleted: (snapshot) => {
|
|
try {
|
|
interpreted = ok(
|
|
interpretApktoolVersion(
|
|
snapshot.stdout.text,
|
|
snapshot.stderr.text,
|
|
selection,
|
|
platform,
|
|
),
|
|
);
|
|
} catch (cause) {
|
|
if (!(cause instanceof ApktoolConfigurationFailure)) throw cause;
|
|
interpreted = err(cause);
|
|
}
|
|
},
|
|
},
|
|
signal === undefined ? {} : { signal },
|
|
);
|
|
} catch (cause) {
|
|
const recordedFailure =
|
|
interpreted?.ok === false ? interpreted.error : undefined;
|
|
const cancellation =
|
|
cause instanceof OwnedCommandFailure ? cause.cancellation : undefined;
|
|
const failure =
|
|
recordedFailure === undefined
|
|
? versionProbeFailure(
|
|
cause,
|
|
selection,
|
|
interpreted?.ok === true ? interpreted.value : undefined,
|
|
)
|
|
: new ApktoolConfigurationFailure(
|
|
recordedFailure.code,
|
|
recordedFailure.message,
|
|
{
|
|
...recordedFailure.diagnostics,
|
|
...(cancellation === undefined
|
|
? {}
|
|
: { cancellation_observed: true }),
|
|
},
|
|
{ cause },
|
|
);
|
|
const primary =
|
|
failure instanceof AnalysisError
|
|
? failure
|
|
: new ProviderAdapterError("apktool", "inspect_apktool_client", {
|
|
cause: failure,
|
|
userMessage: failure.message,
|
|
diagnostics: failure.diagnostics,
|
|
});
|
|
const retained = cleanup.retainFailure(
|
|
cause,
|
|
primary,
|
|
"inspect_apktool_client",
|
|
);
|
|
throw retained === primary ? failure : retained;
|
|
}
|
|
if (interpreted === undefined)
|
|
throw new ApktoolConfigurationFailure(
|
|
"version_unresolved",
|
|
"The owned version command completed without an output observation",
|
|
{ selected_command: selection.command },
|
|
);
|
|
if (!interpreted.ok) throw interpreted.error;
|
|
return interpreted.value;
|
|
};
|
|
|
|
const interpretApktoolVersion = (
|
|
stdout: string,
|
|
stderr: string,
|
|
selection: ApktoolCommandSelection,
|
|
platform: NodeJS.Platform,
|
|
): ApktoolClientIdentity => {
|
|
const identity = parseApktoolVersionOutput(stdout, platform);
|
|
if (identity.version === null)
|
|
throw new ApktoolConfigurationFailure(
|
|
"version_unresolved",
|
|
"The selected apktool launcher exited successfully but did not report a recognizable version",
|
|
{
|
|
selected_command: selection.command,
|
|
command_source: selection.commandSource,
|
|
version_probe: "unrecognized_output",
|
|
reported_version: identity.reportedVersion,
|
|
stdout,
|
|
stderr,
|
|
},
|
|
);
|
|
if (!identity.supportsDocumentedVersion)
|
|
throw new ApktoolConfigurationFailure(
|
|
"unsupported_version",
|
|
`The selected Apktool version ${identity.version} is older than the supported minimum 2.7.0`,
|
|
{
|
|
selected_command: selection.command,
|
|
command_source: selection.commandSource,
|
|
reported_version: identity.version,
|
|
minimum_version: "2.7.0",
|
|
remediation: `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
|
|
},
|
|
);
|
|
return identity;
|
|
};
|
|
|
|
/** Availability in the shape shared by every provider. */
|
|
const rejectionCodeOf = (
|
|
code: ApktoolConfigurationFailureCode,
|
|
): ProviderRejectionCode =>
|
|
code === "command_missing"
|
|
? "executable_missing"
|
|
: code === "command_not_executable" ||
|
|
code === "not_absolute" ||
|
|
code === "launcher_unsupported" ||
|
|
code === "command_unresolved"
|
|
? "not_configured"
|
|
: code === "unsupported_version"
|
|
? "unsupported_version"
|
|
: "version_unresolved";
|
|
|
|
export const apktoolConfigurationAvailability = (
|
|
cause: ApktoolConfigurationFailure,
|
|
): ProviderAvailability =>
|
|
({
|
|
status: "unavailable",
|
|
code: rejectionCodeOf(cause.code),
|
|
reason: cause.message,
|
|
diagnostics: { ...cause.diagnostics },
|
|
}) as ProviderAvailability;
|
|
|
|
/** Interpret the command owner's outcome, without inferring a new failure from a late signal. */
|
|
const versionProbeFailure = (
|
|
cause: unknown,
|
|
selection: ApktoolCommandSelection,
|
|
observed: ApktoolClientIdentity | undefined,
|
|
): AnalysisError | ApktoolConfigurationFailure => {
|
|
if (cause instanceof AnalysisError) return cause;
|
|
const preparation =
|
|
cause instanceof OwnedCommandFailure
|
|
? cause.preparationFailure
|
|
: cause instanceof DarwinProcessOwnershipInspectionError
|
|
? cause
|
|
: undefined;
|
|
const output = capturedApktoolFailureOutput(cause);
|
|
if (cause instanceof OwnedCommandFailure && cause.reason === "cancelled")
|
|
return new AnalysisCancelledError("inspect_apktool_client", {
|
|
cause: cause.cancellation?.reason,
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
...(observed === undefined
|
|
? {}
|
|
: { diagnostics: { client: { version: observed.version } } }),
|
|
});
|
|
if (preparation !== undefined)
|
|
return new ProviderAdapterError("apktool", "inspect_apktool_client", {
|
|
cause,
|
|
diagnostics: {
|
|
stage: "ownership_preparation",
|
|
...(preparation.compilerOutcome === undefined
|
|
? {}
|
|
: {
|
|
compiler_outcome: jsonValueSchema.parse(
|
|
preparation.compilerOutcome,
|
|
),
|
|
}),
|
|
},
|
|
...(preparation.capturedOutput === undefined
|
|
? {}
|
|
: { capturedOutput: preparation.capturedOutput }),
|
|
});
|
|
if (cause instanceof OwnedCommandFailure && cause.reason === "timeout")
|
|
return new AnalysisTimeoutError(
|
|
"inspect_apktool_client",
|
|
VERSION_TIMEOUT_MS,
|
|
{
|
|
cause,
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
...apktoolVersionProbeTimeoutGuidance(),
|
|
},
|
|
);
|
|
if (
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
(cause.code === "ENOENT" || cause.code === "EACCES")
|
|
)
|
|
return new ApktoolConfigurationFailure(
|
|
cause.code === "EACCES" ? "command_not_executable" : "command_missing",
|
|
cause.code === "EACCES"
|
|
? `Execution of the selected apktool launcher was denied: ${selection.command}`
|
|
: `The selected apktool launcher was not found: ${selection.command}`,
|
|
{
|
|
filesystem_error: cause.code,
|
|
selected_command: selection.command,
|
|
command_source: selection.commandSource,
|
|
remediation:
|
|
cause.code === "EACCES"
|
|
? `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher`
|
|
: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
|
|
},
|
|
{ cause },
|
|
);
|
|
const snapshot = cause instanceof OwnedCommandFailure ? cause.snapshot : null;
|
|
if (cause instanceof OwnedCommandFailure && cause.reason === "output-limit")
|
|
return new AnalysisResourceConstraintError(
|
|
"inspect_apktool_client",
|
|
"memory",
|
|
`apktool version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget`,
|
|
{ max_output_bytes: VERSION_MAX_OUTPUT_BYTES },
|
|
{
|
|
cause,
|
|
diagnostics: {
|
|
selected_command: selection.command,
|
|
command_source: selection.commandSource,
|
|
},
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
...fixedAllowanceOptions(
|
|
`The apktool version-output capture allowance of ${String(VERSION_MAX_OUTPUT_BYTES)} bytes (this release has no runtime override; decode_timeout_ms does not raise it)`,
|
|
"capture",
|
|
),
|
|
},
|
|
);
|
|
return new ApktoolConfigurationFailure(
|
|
"version_unresolved",
|
|
"The selected apktool launcher did not report a usable version",
|
|
{
|
|
selected_command: selection.command,
|
|
...(observed === undefined
|
|
? {}
|
|
: { client: { version: observed.version } }),
|
|
...(snapshot === null
|
|
? {}
|
|
: {
|
|
exit_code: snapshot.exitCode ?? null,
|
|
stderr_excerpt: snapshot.stderr.text.slice(0, 512),
|
|
}),
|
|
},
|
|
{ cause },
|
|
);
|
|
};
|