fix(android): name the JADX deadline and recover apktool decode (#6024)

* fix(android): name the JADX deadline and recover apktool decode

JADX operation timeouts now name REA_JADX_OPERATION_TIMEOUT_MS, the
effective milliseconds, the 1..2147483647 range, and that a change applies
on the next process start. Apktool decode accepts decode_timeout_ms,
omitted means 600000, and a timeout names that key. ADB deadlines and
apktool and Java version probes stay fixed and say there is no runtime
override.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(android): derive capture recovery from the fixed allowance

Main now stores a fixed allowance instead of a prebuilt remediation
string. Apktool and adb capture refusals keep the no-runtime-override
sentence in that allowance name, including that decode_timeout_ms does
not raise the apktool output cap.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(apktool): observe a real decode deadline and process cleanup

A hanging interpreter launcher and its pipe-holding child stay alive
until decode_timeout_ms elapses. The provider then reports
provider_timeout with completed cleanup, and both processes are gone.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 00:31:43 +08:00
1 parent aa51e43439
commit 01788bac86
25 files changed
+636 -47

No files matched your search

@@ -25,8 +25,9 @@ stdio. A full JDK, including `jdk.compiler`, is required on every host; Windows
`JAVA_HOME` selects `bin\java.exe`. Consult the Android guide for actual host
verification coverage. Calls use one worker and provider deadlines; optional
`REA_JADX_HEAP_MIB` selects the JVM heap and `REA_JADX_OPERATION_TIMEOUT_MS`
the per-operation deadline (default 120000 ms; the MCP client's tool timeout
must also allow it). The APK is never launched,
the per-operation deadline (integer milliseconds from 1 to 2147483647, default
120000; a change applies on the next process start; the MCP client's tool
timeout must also allow it). The APK is never launched,
and no emulator is required.
Manifest entries and decompiled text are static observations, not execution or
+5 -3
View File
@@ -186,9 +186,11 @@ and during shutdown.
The app/activity lifecycle tools use `am start -W` and `am force-stop`.
- Per-invocation budgets: client/devices 10-20 s, shell reads 20-60 s with
64 KiB-8 MiB capture caps (logcat 4 MiB, screen 32 MiB), transfers and
bugreports 600 s. Output capture overflow reports `resource_constraint`;
deadline expiration, including the client-version probe, reports
`provider_timeout`. Command cancellation preserves the caller's exact reason;
bugreports 600 s. Output capture overflow reports `resource_constraint`
and names the byte allowance. Deadline expiration, including the
client-version probe, reports `provider_timeout` and names the effective
milliseconds. This release has no runtime override for those deadlines or
capture caps. Command cancellation preserves the caller's exact reason;
a later abort does not replace an already completed command failure.
- Pull and capture outputs are additive: the destination must not already
exist. Re-running a completed pull refuses the existing directory instead
+6 -3
View File
@@ -335,9 +335,12 @@ export REA_JADX_OPERATION_TIMEOUT_MS=600000
```
The value must be a decimal millisecond count from 1 to 2147483647; anything
else is refused before Java starts. The same deadline bounds each upstream
call, a timeout reports the selected value, and a passing JADX configuration
check in `rea doctor` reports the effective `operation_timeout_ms`. Explicit
else is refused before Java starts. REA reads it from the environment captured
at process start. A change applies on the next process start; an already
running server keeps the deadline it started with. The same deadline bounds
each upstream call. A timeout names `REA_JADX_OPERATION_TIMEOUT_MS`, the
effective milliseconds, and that range. A passing JADX configuration check in
`rea doctor` reports the effective `operation_timeout_ms`. Explicit
cancellation still ends the operation immediately, and partial observations
and owned-process cleanup are unchanged. Raising the deadline does not extend
the bridge's 30-second startup handshake or the upstream **90-second**
+9 -3
View File
@@ -65,6 +65,7 @@ rea decode-android-resources /targets/Example.apk --locale de
rea decode-android-resources /targets/Example.apk --locales de --locales pt-BR
rea decode-android-resources /targets/Example.apk --locales de --locales pt-BR --string-tables-only
rea decode-android-resources /targets/Example.apk --no-strings
rea decode-android-resources /targets/Example.apk --decode-timeout-ms 900000
```
Equivalent MCP methods are `inspect_apktool_client` and
@@ -133,9 +134,14 @@ still suppresses all string projection, including in this mode.
## Resource and lifecycle limits
- The decode runs with a 600 s deadline and a 4 MiB output capture budget;
a stopped decode reports `provider_timeout` and exceeded budgets report
`resource_constraint`.
- The decode wall clock is `decode_timeout_ms` (CLI `--decode-timeout-ms`).
Omitted means 600000 ms. The integer range is 1 to 2147483647. A change
applies on the next decode. It does not raise the 4194304-byte (4 MiB)
output capture budget. That capture budget has no runtime override. A
stopped decode reports `provider_timeout` and names the effective
milliseconds; an exceeded capture budget reports `resource_constraint`.
- The version probe waits 20000 ms and retains 65536 bytes. This release has
no runtime override for that probe. `decode_timeout_ms` does not raise it.
- Manifest reads are bounded to 512 KiB before decoding UTF-8. Strings XML
reads are bounded to 8 MiB; larger documents are skipped with partial
coverage and an explicit limit, rather than reported as missing.
+1 -1
View File
@@ -17,7 +17,7 @@ export const SOURCE_TEST_PROJECTS = [
group: "source",
include: [
"src/*.test.ts",
"src/{apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts",
"src/{android,apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts",
],
},
{
+35
View File
@@ -217,9 +217,44 @@ it.each([true, false])(
? "live output may change between requests"
: "Repeating the unchanged request fails the same way.",
);
expect(projected.remediation.action).toContain("1024 bytes");
expect(projected.remediation.action).toContain("no runtime override");
},
);
it("names the effective adb deadline and says it has no runtime override", async () => {
vi.mocked(execFileBytes).mockRejectedValueOnce(
Object.assign(new Error("deadline"), {
code: "ETIMEDOUT",
stdout: "",
stderr: "",
signal: null,
killed: true,
}),
);
const result = await runAdbCapture(
"pull_adb_file",
{ device: "device" },
"/host/adb",
["pull", "/remote"],
{ timeoutMs: 600_000, maxOutputBytes: 1024 },
undefined,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected an adb deadline");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 600_000 },
});
expect(projected.message).toContain("600000 ms");
expect(projected.message).toContain("no runtime override");
expect(projected.remediation.action).toContain("no runtime override");
expect(projected.remediation.action).not.toContain(
"REA_JADX_OPERATION_TIMEOUT_MS",
);
});
it("keeps a physical failure even when the later abort reason is that same error", async () => {
const controller = new AbortController();
const failure = new Error("physical command failure");
+16 -10
View File
@@ -138,23 +138,25 @@ export const runAdbCapture = async (
return err(
new AnalysisProtocolError(`adb failed for ${operation}`, { cause }),
);
if (failure.outputTruncated)
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 },
{
cause,
...fixedAllowanceOptions(
"The adb output capture allowance",
limits.volatileOutput === true ? "live" : "capture",
),
...(limits.volatileOutput === true ? { retryable: true } : {}),
},
{ cause, ...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.
@@ -212,7 +214,11 @@ export const runAdbCapture = async (
}
if (failure.killed)
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
new AnalysisTimeoutError(operation, limits.timeoutMs, {
cause,
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.`,
}),
);
return err(
new AdbProtocolError(
+8 -7
View File
@@ -141,7 +141,7 @@ export const inspectAdbClient = async (
? {}
: { capturedOutput: adbCommandCapturedOutput(failure) }),
};
if (failure?.outputTruncated === true)
if (failure?.outputTruncated === true) {
throw new AnalysisResourceConstraintError(
"inspect_adb_client",
"memory",
@@ -150,17 +150,18 @@ export const inspectAdbClient = async (
{
...options,
...fixedAllowanceOptions(
"The adb version-output capture allowance",
`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,
);
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",
+62
View File
@@ -0,0 +1,62 @@
import { expect, it, vi } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { inspectAdbClient } from "./AdbConfiguration.js";
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
execFileOutput: vi.fn(),
}));
const selection = {
binary: "/host/adb",
pathSource: "environment",
} as const;
it("names the adb version-probe deadline and says it has no runtime override", async () => {
vi.mocked(execFileOutput).mockRejectedValueOnce(
Object.assign(new Error("deadline"), {
code: "ETIMEDOUT",
stdout: "",
stderr: "",
signal: null,
killed: true,
}),
);
const failure = await inspectAdbClient(selection).catch(
(cause: unknown) => cause,
);
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
const projected = projectAnalysisError(failure as AnalysisTimeoutError);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 10_000 },
});
expect(projected.message).toContain("10000 ms");
expect(projected.message).toContain("no runtime override");
expect(projected.remediation.action).toContain("no runtime override");
});
it("names the adb version-output cap and says it has no runtime override", async () => {
vi.mocked(execFileOutput).mockRejectedValueOnce(
Object.assign(new Error("too much"), {
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
stdout: "",
stderr: "",
signal: null,
killed: false,
outputTruncated: true,
}),
);
const failure = await inspectAdbClient(selection).catch(
(cause: unknown) => cause,
);
const projected = projectAnalysisError(
failure as Parameters<typeof projectAnalysisError>[0],
);
expect(projected.message).toContain("65536-byte");
expect(projected.remediation.action).toContain("65536 bytes");
expect(projected.remediation.action).toContain("no runtime override");
});
+2
View File
@@ -33,6 +33,7 @@ import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js";
import type { DoctorProviderCheck } from "../application/Doctor.js";
import {
DEFAULT_JADX_OPERATION_TIMEOUT_MS,
jadxOperationTimeoutGuidance,
JADX_JAR_CONFIGURATION_REMEDIATION,
JADX_JAVA_PROBE_REMEDIATION,
JADX_JAVA_RUNTIME_REMEDIATION,
@@ -483,6 +484,7 @@ const archiveCancellation = (
? new AnalysisTimeoutError(operation, options.operationDeadline.timeoutMs, {
cause: reason,
diagnostics,
...jadxOperationTimeoutGuidance(options.operationDeadline.timeoutMs),
})
: new AnalysisCancelledError(operation, { cause: reason, diagnostics });
};
+6 -5
View File
@@ -1,3 +1,4 @@
import { jadxOperationTimeoutGuidance } from "./JadxRelease.js";
import { JadxInterruption } from "./JadxInterruption.js";
import { z } from "zod";
import { AnalysisError } from "../domain/analysisErrorBase.js";
@@ -108,6 +109,7 @@ const observeJadxFailure = (
: timeout.aborted && cancellation.reason === timeout.reason
? new AnalysisTimeoutError(request.operation, timeoutMs, {
cause: cancellation.reason,
...jadxOperationTimeoutGuidance(timeoutMs),
})
: new AnalysisCancelledError(request.operation, {
cause: cancellation.reason,
@@ -185,11 +187,10 @@ const presentDiagnosticFailure = (
if (failure.kind === "cancelled")
return new AnalysisCancelledError(failure.operation, options);
if (failure.kind === "timeout")
return new AnalysisTimeoutError(
failure.operation,
failure.timeoutMs,
options,
);
return new AnalysisTimeoutError(failure.operation, failure.timeoutMs, {
...options,
...jadxOperationTimeoutGuidance(failure.timeoutMs),
});
if (failure.kind === "output")
return new AnalysisOutputError(failure.operation, failure.reason, options);
if (failure.kind === "startup") {
+9 -3
View File
@@ -22,6 +22,7 @@ import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { retainOwnedCommandCleanup } from "../providers/OwnedCommandCleanup.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { jadxOperationTimeoutGuidance } from "./JadxRelease.js";
const JAVA_MODULE_LISTING_TIMEOUT_MS = 10_000;
const JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES = 1024 * 1024;
@@ -184,7 +185,12 @@ export const inspectJadxJava = async (
? new AnalysisTimeoutError(
operation,
options.operationDeadline.timeoutMs,
errorOptions,
{
...errorOptions,
...jadxOperationTimeoutGuidance(
options.operationDeadline.timeoutMs,
),
},
)
: new AnalysisCancelledError(operation, errorOptions);
} else if (
@@ -269,9 +275,9 @@ const commandProbeRefusal = (
: "runtime_unavailable",
kind: systemCode === "ENOENT" ? "java_runtime" : "java_probe",
message: limited
? `The selected Java launcher ${java} exceeded the ${JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES / (1024 * 1024)} MiB output budget while listing modules. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; java --list-modules should return a concise module listing.`
? `The selected Java launcher ${java} exceeded the ${String(JAVA_MODULE_LISTING_MAX_OUTPUT_BYTES)}-byte module-listing output budget. This release has no runtime override. REA retained partial output but could not establish compiler or version availability. Inspect the selected launcher for verbose or unbounded output; java --list-modules should return a concise module listing. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.`
: timedOut
? `The selected Java launcher ${java} did not finish the module-listing probe within ${timeoutMs / 1000} seconds. Inspect its startup responsiveness and verify it with java --list-modules.`
? `The selected Java launcher ${java} did not finish the module-listing probe within ${String(timeoutMs)} ms. This release has no runtime override. Inspect its startup responsiveness and verify it with java --list-modules. REA_JADX_OPERATION_TIMEOUT_MS does not raise this probe.`
: `Cannot inspect selected Java runtime ${java}: ${systemCode === "ENOENT" ? "spawn java ENOENT" : cause instanceof Error ? cause.message : cause === undefined ? `exit ${String(output?.exit_code ?? "unknown")}${output?.signal == null ? "" : ` (${output.signal})`}` : String(cause)}. Review the selected launcher and retained diagnostics.`,
diagnostics: {
java_executable: java,
+35
View File
@@ -0,0 +1,35 @@
import { expect, it } from "vitest";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { captureJadxFailure } from "./JadxFailure.js";
import { jadxOperationTimeoutGuidance } from "./JadxRelease.js";
it("names REA_JADX_OPERATION_TIMEOUT_MS when the operation deadline elapses", () => {
const timeout = AbortSignal.abort(new Error("deadline"));
const present = captureJadxFailure({
cause: timeout.reason,
request: {
operation: "inspect_android_package",
input: { path: "/targets/app.apk" },
},
timeout,
timeoutMs: 1500,
signal: undefined,
session: undefined,
});
const projected = projectAnalysisError(present());
const guidance = jadxOperationTimeoutGuidance(1500);
expect(projected.message).toBe(guidance.userMessage);
expect(projected.remediation.action).toBe(guidance.remediationAction);
expect(projected.message).toContain("1500 ms");
expect(projected.remediation.action).toContain(
"REA_JADX_OPERATION_TIMEOUT_MS",
);
expect(projected.remediation.action).toContain("1 to 2147483647");
expect(projected.remediation.action).toContain("120000");
expect(projected.remediation.action).toContain("next process start");
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 1500 },
});
});
+8
View File
@@ -29,6 +29,14 @@ export const DEFAULT_JADX_OPERATION_TIMEOUT_MS = 120_000;
/** Remediation when the JADX operation deadline override is malformed. */
export const JADX_OPERATION_TIMEOUT_REMEDIATION = `Set REA_JADX_OPERATION_TIMEOUT_MS to a decimal millisecond count from 1 to 2147483647, or unset it to keep the ${String(DEFAULT_JADX_OPERATION_TIMEOUT_MS)} ms default.`;
/** Name the launch-environment deadline when a JADX operation reaches it. */
export const jadxOperationTimeoutGuidance = (
timeoutMs: number,
): { readonly userMessage: string; readonly remediationAction: string } => ({
userMessage: `JADX reached its configured operation deadline of ${String(timeoutMs)} ms (deadlineExpired).`,
remediationAction: `Set REA_JADX_OPERATION_TIMEOUT_MS in REA's launch environment to an integer from 1 to 2147483647 milliseconds (omitted means ${String(DEFAULT_JADX_OPERATION_TIMEOUT_MS)} ms). A change applies on the next process start. The effective deadline for this attempt was ${String(timeoutMs)} ms. It does not raise the bridge's 30000 ms startup handshake. Raising only the MCP client deadline does not change this budget. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
});
/** Remediation when the host cannot run the Android JADX subprocess. */
export const JADX_UNSUPPORTED_HOST_REMEDIATION =
"Use Linux, macOS, or Windows x64 with its bundled native controls for Android JADX analysis.";
+19 -2
View File
@@ -37,6 +37,14 @@ 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;
@@ -102,6 +110,7 @@ export const resolveApktoolCommand = async (
})
: new AnalysisTimeoutError("inspect_apktool_client", VERSION_TIMEOUT_MS, {
diagnostics: { phase: "windows_path_lookup" },
...apktoolVersionProbeTimeoutGuidance(),
});
try {
for (const entry of directories) {
@@ -501,7 +510,11 @@ const versionProbeFailure = (
return new AnalysisTimeoutError(
"inspect_apktool_client",
VERSION_TIMEOUT_MS,
{ cause, ...(output === undefined ? {} : { capturedOutput: output }) },
{
cause,
...(output === undefined ? {} : { capturedOutput: output }),
...apktoolVersionProbeTimeoutGuidance(),
},
);
if (
cause instanceof Error &&
@@ -519,9 +532,13 @@ const versionProbeFailure = (
{ cause },
);
const snapshot = cause instanceof OwnedCommandFailure ? cause.snapshot : null;
const outputLimited =
cause instanceof OwnedCommandFailure && cause.reason === "output-limit";
return new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher did not report a usable version",
outputLimited
? `apktool version output exceeded the ${String(VERSION_MAX_OUTPUT_BYTES)}-byte capture budget. This release has no runtime override. decode_timeout_ms does not raise it.`
: "The selected apktool launcher did not report a usable version",
{
selected_command: selection.command,
...(observed === undefined
+153
View File
@@ -0,0 +1,153 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
DEFAULT_APKTOOL_DECODE_TIMEOUT_MS,
MAX_APKTOOL_DECODE_TIMEOUT_MS,
} from "../domain/apktool/apktoolResourceAnalysis.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { ApktoolProvider } from "./ApktoolProvider.js";
vi.mock("../process/OwnedCommand.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/OwnedCommand.js")>()),
runOwnedCommand: vi.fn(),
}));
const YML = [
"!!brut.androlib.meta.MetaInfo",
"sdkInfo:",
" minSdkVersion: '24'",
" targetSdkVersion: '34'",
"versionInfo:",
" versionCode: '7'",
" versionName: 1.2.3",
"",
].join("\n");
const MANIFEST = [
'<?xml version="1.0" encoding="utf-8" standalone="no"?>',
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.rea.apktool.probe"/>',
"",
].join("\n");
const decodeTimeouts: number[] = [];
const outputBudgets: number[] = [];
afterEach(() => {
decodeTimeouts.length = 0;
outputBudgets.length = 0;
vi.mocked(runOwnedCommand).mockReset();
});
const completed = (text: string): ProviderProcessSnapshot =>
({
stdout: { text },
stderr: { text: "" },
}) as ProviderProcessSnapshot;
const installLauncher = (): void => {
vi.mocked(runOwnedCommand).mockImplementation(async (spawn, limits) => {
if (spawn.arguments[0] === "--version") {
const output = completed("2.9.3\n");
limits.observeCompleted?.(output);
return output;
}
decodeTimeouts.push(limits.timeoutMs);
outputBudgets.push(limits.diagnosticBytes);
// Projects the deadline sentence only. A real launcher expiry and its
// process-tree cleanup are covered by apktoolDecodeDeadline.test.ts.
if (
limits.timeoutMs === 200 ||
limits.timeoutMs === MAX_APKTOOL_DECODE_TIMEOUT_MS
)
throw new OwnedCommandFailure("timeout", "Command deadline elapsed.");
const outputIndex = spawn.arguments.indexOf("-o");
const workspace = spawn.arguments[outputIndex + 1];
if (workspace === undefined) throw new Error("decode output was not named");
await mkdir(workspace, { recursive: true });
await writeFile(join(workspace, "apktool.yml"), YML);
await writeFile(join(workspace, "AndroidManifest.xml"), MANIFEST);
return completed("I: Using Apktool on stub\n");
});
};
const decode = async (
provider: ApktoolProvider,
path: string,
decodeTimeoutMs?: number,
) =>
new ApktoolResourceAnalysisService(provider).execute(
"decode_android_resources",
{
path,
include_strings: false,
...(decodeTimeoutMs === undefined
? {}
: { decode_timeout_ms: decodeTimeoutMs }),
},
);
it("applies the omitted 600000 ms decode deadline and a selected value on the next decode", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-apktool-deadline-"));
const path = join(root, "probe.apk");
await writeFile(path, "apk-bytes");
installLauncher();
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: "/opt/apktool/apktool" },
});
try {
const omitted = await decode(provider, path);
expect(omitted.ok).toBe(true);
if (!omitted.ok) return;
expect(omitted.value.parameters).not.toHaveProperty("decode_timeout_ms");
expect(decodeTimeouts).toEqual([DEFAULT_APKTOOL_DECODE_TIMEOUT_MS]);
expect(outputBudgets).toEqual([4 * 1024 * 1024]);
const timedOut = await decode(provider, path, 200);
expect(timedOut.ok).toBe(false);
if (timedOut.ok) throw new Error("Expected a decode deadline");
const projected = projectAnalysisError(timedOut.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: 200 },
});
expect(projected.message).toContain("200 ms");
expect(projected.remediation.action).toContain("decode_timeout_ms");
expect(projected.remediation.action).toContain(
String(MAX_APKTOOL_DECODE_TIMEOUT_MS),
);
expect(projected.remediation.action).toContain("600000");
expect(projected.remediation.action).toContain("next decode");
expect(projected.remediation.action).toContain("no runtime override");
expect(decodeTimeouts).toEqual([DEFAULT_APKTOOL_DECODE_TIMEOUT_MS, 200]);
const selected = await decode(provider, path, 5_000);
expect(selected.ok).toBe(true);
if (!selected.ok) return;
expect(selected.value.parameters).toMatchObject({
decode_timeout_ms: 5_000,
});
expect(decodeTimeouts).toEqual([
DEFAULT_APKTOOL_DECODE_TIMEOUT_MS,
200,
5_000,
]);
const maximum = await decode(provider, path, MAX_APKTOOL_DECODE_TIMEOUT_MS);
expect(maximum.ok).toBe(false);
if (maximum.ok) throw new Error("Expected the maximum decode deadline");
expect(projectAnalysisError(maximum.error).remediation.action).toContain(
"do not increase it further",
);
} finally {
await provider.close();
await rm(root, { recursive: true, force: true });
}
});
+19 -4
View File
@@ -32,6 +32,8 @@ import {
} from "../application/AnalysisProvider.js";
import {
apktoolResultSchemas,
DEFAULT_APKTOOL_DECODE_TIMEOUT_MS,
MAX_APKTOOL_DECODE_TIMEOUT_MS,
type ApktoolRequest,
type ApktoolDecodeResult,
} from "../domain/apktool/apktoolResourceAnalysis.js";
@@ -77,12 +79,22 @@ export const APKTOOL_PROVIDER_IDENTITY = {
version: null,
} as const;
const DECODE_TIMEOUT_MS = 600_000;
const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
const MANIFEST_MAX_BYTES = 512 * 1024;
const MAX_DECODED_ENTRIES_VISITED = 100_000;
const STDERR_EXCERPT_BYTES = 512;
const apktoolDecodeTimeoutGuidance = (
timeoutMs: number,
): { readonly userMessage: string; readonly remediationAction: string } => ({
userMessage: `apktool decode reached its ${String(timeoutMs)} ms wall-clock deadline (deadlineExpired).`,
remediationAction: `${
timeoutMs < MAX_APKTOOL_DECODE_TIMEOUT_MS
? `Increase decode_timeout_ms above ${String(timeoutMs)} (integer milliseconds from 1 to ${String(MAX_APKTOOL_DECODE_TIMEOUT_MS)}; omitted means ${String(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS)}), or use rea decode-android-resources <path> --decode-timeout-ms <milliseconds> on the CLI.`
: `decode_timeout_ms is already at the maximum ${String(MAX_APKTOOL_DECODE_TIMEOUT_MS)} ms; do not increase it further.`
} A change applies on the next decode. It does not raise the ${String(DECODE_MAX_OUTPUT_BYTES)}-byte output capture allowance or the version probe; both have no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
});
const throwIfCancelled = (
operation: string,
signal: AbortSignal | undefined,
@@ -185,8 +197,9 @@ const apktoolCommandFailure = (
return new AnalysisTimeoutError(operation, limits.timeoutMs, {
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
...apktoolDecodeTimeoutGuidance(limits.timeoutMs),
});
if (cause.reason === "output-limit")
if (cause.reason === "output-limit") {
return new AnalysisResourceConstraintError(
operation,
"memory",
@@ -196,11 +209,12 @@ const apktoolCommandFailure = (
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
...fixedAllowanceOptions(
"The apktool output capture allowance",
`The apktool output capture allowance of ${String(limits.maxOutputBytes)} bytes (this release has no runtime override; decode_timeout_ms does not raise it)`,
"capture",
),
},
);
}
// A physical launcher/capture failure does not establish target refusal.
// Only a fully collected ordinary nonzero decode exit carries that meaning.
if (
@@ -564,7 +578,8 @@ export class ApktoolProvider {
snapshot,
],
{
timeoutMs: DECODE_TIMEOUT_MS,
timeoutMs:
input.decode_timeout_ms ?? DEFAULT_APKTOOL_DECODE_TIMEOUT_MS,
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
},
signal,
+6 -1
View File
@@ -72,11 +72,16 @@ it("reports an expired version probe as a typed timeout", async () => {
platform: "linux",
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisTimeoutError);
expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({
const projected = projectAnalysisError(failure as AnalysisTimeoutError);
expect(projected).toMatchObject({
code: "provider_timeout",
category: "timeout",
details: { timeout_ms: 20_000 },
});
expect(projected.message).toContain("20000 ms");
expect(projected.message).toContain("no runtime override");
expect(projected.remediation.action).toContain("decode_timeout_ms");
expect(projected.remediation.action).toContain("no runtime override");
});
it("retains an unresolved probe cleanup owner and reports the cleanup", async () => {
+5
View File
@@ -61,6 +61,8 @@ export const registerApktoolCommands = (
apktoolInputSchemas.decode_android_resources.shape.locales.describe(
"Repeat to project multiple localized string tables, e.g. --locales de --locales pt-BR",
),
decodeTimeoutMs:
apktoolInputSchemas.decode_android_resources.shape.decode_timeout_ms,
strings: z
.boolean()
.default(
@@ -81,6 +83,9 @@ export const registerApktoolCommands = (
: { string_tables_only: options["string-tables-only"] }),
...(options.locale === undefined ? {} : { locale: options.locale }),
...(options.locales === undefined ? {} : { locales: options.locales }),
...(options.decodeTimeoutMs === undefined
? {}
: { decode_timeout_ms: options.decodeTimeoutMs }),
}),
});
};
@@ -22,7 +22,7 @@ export const APKTOOL_TOOL_CONTRACTS = [
name: "decode_android_resources",
...toolContractMetadata("decode_android_resources"),
description:
"Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or selected locales, bounded to 2000 entries per table), selectable string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family.",
"Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or selected locales, bounded to 2000 entries per table), selectable string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family. Optional decode_timeout_ms is the decode wall clock in milliseconds (default 600000, range 1..2147483647); omitted means 600000 and the value applies on the next decode. It does not raise the fixed output capture allowance or the version probe.",
kind: "apktool-provider",
inputSchema: apktoolInputSchemas.decode_android_resources,
outputSchema: evidenceResultOf(
@@ -2,7 +2,10 @@ import { expect, it } from "vitest";
import {
androidLocaleDirectorySuffix,
androidLocaleQualifierSchema,
apktoolInputSchemas,
classifyAndroidStringsTable,
DEFAULT_APKTOOL_DECODE_TIMEOUT_MS,
MAX_APKTOOL_DECODE_TIMEOUT_MS,
} from "./apktoolResourceAnalysis.js";
it.each([
@@ -81,3 +84,34 @@ it.each([
});
},
);
const decodeInput = apktoolInputSchemas.decode_android_resources;
it("keeps 600000 ms as the omitted apktool decode deadline", () => {
const parsed = decodeInput.parse({ path: "/targets/app.apk" });
expect(parsed).not.toHaveProperty("decode_timeout_ms");
expect(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS).toBe(600_000);
expect(decodeInput.shape.decode_timeout_ms.description).toContain("600000");
expect(decodeInput.shape.decode_timeout_ms.description).toContain(
"next decode",
);
});
it.each([1, 600_000, MAX_APKTOOL_DECODE_TIMEOUT_MS])(
"accepts decode_timeout_ms %s",
(decode_timeout_ms) => {
expect(
decodeInput.parse({ path: "/targets/app.apk", decode_timeout_ms }),
).toMatchObject({ decode_timeout_ms });
},
);
it.each([0, -1, 1.5, MAX_APKTOOL_DECODE_TIMEOUT_MS + 1])(
"rejects decode_timeout_ms %s",
(decode_timeout_ms) => {
expect(
decodeInput.safeParse({ path: "/targets/app.apk", decode_timeout_ms })
.success,
).toBe(false);
},
);
@@ -6,6 +6,10 @@ export const MAX_STRINGS = 2_000;
export const MAX_SELECTED_LOCALES = 20;
export const MAX_AGGREGATE_STRINGS = 10_000;
export const AGGREGATE_STRINGS_XML_MAX_BYTES = 32 * 1024 * 1024;
/** Decode wall clock used when decode_timeout_ms is omitted. */
export const DEFAULT_APKTOOL_DECODE_TIMEOUT_MS = 600_000;
/** Largest delay supported without overflowing Node timers. */
export const MAX_APKTOOL_DECODE_TIMEOUT_MS = 2_147_483_647;
const ANDROID_NON_LOCALE_QUALIFIERS = new Set([
"car",
"desk",
@@ -91,6 +95,15 @@ export const apktoolInputSchemas = {
.describe(
`Project multiple localized string tables from res/values-<locale>/strings.xml alongside the default table (bounded to ${String(MAX_SELECTED_LOCALES)} locales)`,
),
decode_timeout_ms: z
.number()
.int()
.min(1)
.max(MAX_APKTOOL_DECODE_TIMEOUT_MS)
.exactOptional()
.describe(
`Decode wall-clock budget in milliseconds (default ${String(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS)}), from apktool d start through process exit. Omitted means ${String(DEFAULT_APKTOOL_DECODE_TIMEOUT_MS)}. Applies on the next decode. Does not raise the fixed output capture allowance or the version probe.`,
),
}),
} as const;
@@ -249,7 +249,8 @@ it("preserves exact Java probe output and marks max-buffer capture as truncated"
);
expect(failure).toBeInstanceOf(AnalysisCapabilityUnavailableError);
if (!(failure instanceof AnalysisCapabilityUnavailableError)) return;
expect(failure.reason).toContain("exceeded the 1 MiB output budget");
expect(failure.reason).toContain("1048576-byte");
expect(failure.reason).toContain("no runtime override");
expect(failure.reason).not.toContain("Select a full JDK");
expect(failure.capturedOutput).toMatchObject({
stdout: expect.stringMatching(/^x/u),
@@ -359,7 +360,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async
expect(availability).toMatchObject({
status: "unavailable",
code: "version_unresolved",
reason: expect.stringContaining("within 10 seconds"),
reason: expect.stringContaining("10000 ms"),
diagnostics: {
java_executable: java,
failure: "probe_terminated",
@@ -367,6 +368,7 @@ it("reports a killed Java probe as unresolved instead of missing runtime", async
deadline_ms: 10_000,
},
});
expect(availability.reason).toContain("no runtime override");
expect(availability.reason).not.toContain("Select a full JDK");
});
@@ -35,6 +35,40 @@ it("keeps string projection enabled when the flag is omitted", async () => {
]);
});
it("passes decode_timeout_ms only when the caller selects it", async () => {
const omitted = await invokeApktoolCli([
"decode-android-resources",
"/targets/app.apk",
"--json",
]);
expect(omitted.exitCode).toBe(0);
expect(omitted.requests[0]?.input).not.toHaveProperty("decode_timeout_ms");
const selected = await invokeApktoolCli([
"decode-android-resources",
"/targets/app.apk",
"--decode-timeout-ms",
"900000",
"--json",
]);
expect(selected.exitCode).toBe(0);
expect(selected.requests[0]?.input).toMatchObject({
decode_timeout_ms: 900_000,
});
for (const value of ["0", "2147483648"]) {
const rejected = await invokeApktoolCli([
"decode-android-resources",
"/targets/app.apk",
"--decode-timeout-ms",
value,
"--json",
]);
expect(rejected.exitCode).not.toBe(0);
expect(rejected.requests).toEqual([]);
}
});
it("documents the supported negative flag in command help", async () => {
const result = await invokeApktoolCli(["decode-android-resources", "--help"]);
@@ -0,0 +1,143 @@
import { execFile } from "node:child_process";
import { chmod, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { ApktoolProvider } from "../../../src/apktool/ApktoolProvider.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
/**
* Only an exited process counts as stopped: it is gone, a zombie waiting to
* be reaped, or absent from `ps`. A failed inspection is not proof of exit.
*/
const alive = async (pid: number): Promise<boolean> => {
try {
process.kill(pid, 0);
} catch (cause: unknown) {
if ((cause as NodeJS.ErrnoException).code === "ESRCH") return false;
return true;
}
return promisify(execFile)("ps", ["-o", "stat=", "-p", `${pid}`]).then(
({ stdout }) => {
const state = stdout.trim();
return state !== "" && !state.startsWith("Z");
},
// `ps` exits 1 when no process matches; any other failure is unknown.
(cause: unknown) => (cause as { code?: unknown }).code !== 1,
);
};
const waitFor = async (check: () => Promise<boolean>, ms: number) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await check()) return true;
await delay(25);
}
return false;
};
const readPids = async (path: string): Promise<readonly number[]> =>
readFile(path, "utf8")
.then((text) => JSON.parse(text) as number[])
.catch(() => []);
const DECODE_TIMEOUT_MS = 2_500;
const CHILD_SCRIPT =
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
// Windows Job Object cleanup is a separate owned-command lane. This observes
// the POSIX process group the apktool decode launcher actually uses.
it.skipIf(process.platform === "win32")(
"stops a hanging launcher and its pipe-holding child when the decode deadline elapses",
async () => {
const root = await createTestTempDirectory("rea-apktool-decode-deadline-");
const pidsPath = join(root, "pids.json");
const launcher = join(root, "apktool");
await writeFile(
launcher,
[
`#!${process.execPath}`,
'const { spawn } = require("node:child_process");',
'const { writeFileSync } = require("node:fs");',
'if (process.argv[2] === "--version") { process.stdout.write("2.9.3\\n"); process.exit(0); }',
'if (process.argv[2] !== "d") { process.stderr.write("unexpected " + process.argv.slice(2).join(" ")); process.exit(1); }',
`const child = spawn(process.execPath, ["-e", ${JSON.stringify(CHILD_SCRIPT)}], { stdio: "inherit" });`,
"if (child.pid === undefined) process.exit(1);",
`writeFileSync(${JSON.stringify(pidsPath)}, JSON.stringify([process.pid, child.pid]));`,
'process.on("SIGTERM", () => {});',
"setInterval(() => {}, 1000);",
"",
].join("\n"),
);
await chmod(launcher, 0o755);
const apk = join(root, "probe.apk");
await writeFile(apk, "apk-bytes");
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: launcher },
});
const started = performance.now();
const pending = provider.execute({
operation: "decode_android_resources",
input: {
path: apk,
include_strings: false,
decode_timeout_ms: DECODE_TIMEOUT_MS,
},
});
let launched: readonly number[] = [];
try {
expect(
await waitFor(async () => {
const pids = await readPids(pidsPath);
if (pids.length !== 2) return false;
launched = pids;
return (await Promise.all(pids.map(alive))).every(Boolean);
}, 8_000),
).toBe(true);
const outcome = await Promise.race([
pending,
delay(15_000).then(() => "still pending" as const),
]);
if (outcome === "still pending")
throw new Error("apktool decode deadline did not settle");
const elapsed = performance.now() - started;
expect(outcome.ok).toBe(false);
if (outcome.ok) return;
expect(outcome.error.cleanupIncomplete).toBe(false);
const projected = projectAnalysisError(outcome.error);
expect(projected).toMatchObject({
code: "provider_timeout",
details: { timeout_ms: DECODE_TIMEOUT_MS },
});
expect(projected.message).toContain(`${String(DECODE_TIMEOUT_MS)} ms`);
expect(projected.remediation.action).toContain("decode_timeout_ms");
expect(projected.remediation.action).toContain("next decode");
// Settlement includes the owned-command deadline, so the timer elapsed.
expect(elapsed).toBeGreaterThanOrEqual(DECODE_TIMEOUT_MS - 50);
expect(elapsed).toBeLessThan(15_000);
expect(
await waitFor(
async () =>
(await Promise.all(launched.map(alive))).every((live) => !live),
5_000,
),
).toBe(true);
} finally {
const closing = provider.close();
await Promise.race([pending, delay(10_000)]).catch(() => undefined);
for (const pid of await readPids(pidsPath))
if (await alive(pid))
try {
process.kill(pid, "SIGKILL");
} catch {
// Already exited between the check and the signal.
}
await Promise.race([closing, delay(10_000)]).catch(() => undefined);
}
},
30_000,
);