fix(android): support owned JADX stdio on Windows (#1276)
Support owned JADX stdio on Windows x64, preserve protocol input errors through cancellation, and verify native stdin and Android CLI/MCP lifecycle on the real Windows host. Closes #1262
This commit is contained in:
parent
b71a591a60
commit
636b245f1b
|
|
@ -157,7 +157,7 @@ Additional tools and host support depend on the target:
|
|||
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://rea.tools/guides/browser/) |
|
||||
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
|
||||
| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | Static inspection; [managed-code guide](docs/managed-code-analysis.md) |
|
||||
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS; [Android guide](docs/android-analysis.md) |
|
||||
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) |
|
||||
| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
|
||||
| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
|
||||
| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |
|
||||
|
|
|
|||
|
|
@ -23,6 +23,23 @@ export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar
|
|||
export JAVA_HOME=/absolute/path/existing-jdk
|
||||
```
|
||||
|
||||
On Windows x64, use a REA package with its matching bundled Windows native
|
||||
controls **including owned protocol stdin support**. This Windows boundary is
|
||||
new on repository main; an older released package can still reject this host.
|
||||
PowerShell accepts paths containing spaces without additional escaping
|
||||
inside these environment values:
|
||||
|
||||
```powershell
|
||||
$env:REA_JADX_MCP_JAR = 'D:\tools\jadx-headless-mcp-0.7.1-all.jar'
|
||||
$env:JAVA_HOME = 'D:\tools\jdk-21'
|
||||
rea inspect-android-package 'D:\targets\Example.apk'
|
||||
```
|
||||
|
||||
REA selects `JAVA_HOME\bin\java.exe`, or resolves `java.exe` on the selected
|
||||
Windows `PATH`. A missing or mismatched native artifact reports the package
|
||||
prerequisite rather than attempting an unowned Java launch. Windows arm64 is
|
||||
outside this provider's current native boundary.
|
||||
|
||||
Readiness checks the selected JAR's ZIP directory for the classes consumed by
|
||||
REA's metadata bridge, as well as Java's compiler and version modules. It does
|
||||
not load an APK or execute engine code. Invalid archives and missing bridge
|
||||
|
|
@ -34,7 +51,9 @@ the exhausted read budget and the selected JAR path.
|
|||
The current metadata bridge is verified on macOS arm64 with OpenJDK 21 and the
|
||||
public Appium ApiDemos fixture. The POSIX adapter also supports Linux; the new
|
||||
bridge has not yet undergone real verification on Linux/JDK 25.
|
||||
Windows is unsupported for this provider's owned stdio process boundary.
|
||||
Windows x64 uses an owned Job Object with bidirectional stdio, atomic job
|
||||
assignment and kill-on-owner-close. Real Windows verification uses JDK 21 and
|
||||
the pinned JADX 0.7.1 JAR; see the real verification lane below.
|
||||
|
||||
## Answer an analyst question
|
||||
|
||||
|
|
@ -138,6 +157,12 @@ target/configuration changes, failure, timeout, cancellation, MCP disconnect or
|
|||
server shutdown. One-shot CLI commands always join cleanup before returning.
|
||||
REA never writes to the original APK or launches target code.
|
||||
|
||||
On Windows, stdin writes preserve stream backpressure and run outside the Node
|
||||
event loop. Cancellation terminates the retained job, including descendants,
|
||||
before workspace cleanup. An abrupt CLI/server process termination closes the
|
||||
job and stops Java, but cannot run JavaScript workspace cleanup; temporary
|
||||
workspace files can remain after that forced exit.
|
||||
|
||||
If Java cannot start the bridge, REA reports the selected executable and retained
|
||||
startup diagnostics. Select a full JDK through `JAVA_HOME` or `PATH`, and use
|
||||
`java --list-modules` to confirm `jdk.compiler` is present. A runnable JRE alone
|
||||
|
|
@ -197,6 +222,12 @@ without the compiler module. It checks real Java startup failures, missing
|
|||
selectors, recovery after failures, and CLI cancellation with owned cleanup.
|
||||
The original APK and host Java configuration remain unchanged.
|
||||
|
||||
The same lane runs on Windows x64 with the bundled native controls and a full
|
||||
JDK. It checks CLI/MCP results, real MCP cancellation and disconnect, and Java
|
||||
exit after abrupt CLI owner termination. Windows forced termination is checked
|
||||
separately from the POSIX CLI SIGTERM handler; it does not claim a structured
|
||||
cancellation result or workspace removal after the owner is forcibly killed.
|
||||
|
||||
The download script verifies fixed SHA-256 values, reuses matching files and
|
||||
refuses to overwrite a different existing file. APK/JAR files live under ignored
|
||||
`_reference/` and are excluded from package contents. The lane exercises actual
|
||||
|
|
|
|||
|
|
@ -463,6 +463,13 @@ Electron GUI behavior or another engine.
|
|||
|
||||
## Android APK analysis
|
||||
|
||||
The real Android lane also runs on Windows x64 with its matching bundled native
|
||||
controls. It verifies bidirectional protocol input, CLI/MCP result parity,
|
||||
real MCP cancellation/disconnect and Java exit after abrupt CLI owner
|
||||
termination. The forced Windows exit does not exercise the POSIX SIGTERM
|
||||
handler and can leave temporary workspace files. `verify:windows-native`
|
||||
separately checks binary input, backpressure, EOF and pending-write job closure.
|
||||
|
||||
`npm run verify:android` requires an existing Java 17+ and an explicit
|
||||
`REA_JADX_MCP_JAR` for jadx-headless-mcp 0.7.1. Set `REA_ANDROID_TEST_APK` to the
|
||||
fixed public ApiDemos v6.0.18 fixture. Obtain both with the explicit
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ struct Runtime : Resource {
|
|||
Runtime() : Resource(Kind::Runtime) {}
|
||||
};
|
||||
struct Process : Resource {
|
||||
Handle job, process, stdoutRead, stderrRead;
|
||||
Handle job, process, stdoutRead, stderrRead, stdinWrite;
|
||||
bool inputPending = false;
|
||||
DWORD pid = 0;
|
||||
Process() : Resource(Kind::Process) {}
|
||||
};
|
||||
|
|
@ -122,7 +123,8 @@ void closeRuntime(Runtime& runtime);
|
|||
napi_value filesystemCall(napi_env env, const std::wstring& operation,
|
||||
const std::vector<napi_value>& args);
|
||||
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
|
||||
const std::wstring& cwd, const std::vector<std::wstring>& environment);
|
||||
const std::wstring& cwd, const std::vector<std::wstring>& environment,
|
||||
bool protocolStdin = false);
|
||||
napi_value processCall(napi_env env, const std::wstring& operation,
|
||||
const std::vector<napi_value>& args);
|
||||
napi_value inspect(napi_env env);
|
||||
|
|
|
|||
|
|
@ -34,16 +34,17 @@ static Handle createJob() {
|
|||
return job;
|
||||
}
|
||||
|
||||
static void pipe(Handle& read, Handle& write) {
|
||||
static void pipe(Handle& read, Handle& write, bool parentReads = true) {
|
||||
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
|
||||
HANDLE rawRead, rawWrite;
|
||||
require(CreatePipe(&rawRead, &rawWrite, &attributes, 0), "Create owned process output pipe failed");
|
||||
read.reset(rawRead); write.reset(rawWrite);
|
||||
require(SetHandleInformation(read.get(), HANDLE_FLAG_INHERIT, 0), "Protect process output handle inheritance failed");
|
||||
require(SetHandleInformation(parentReads ? read.get() : write.get(), HANDLE_FLAG_INHERIT, 0), "Protect parent process pipe handle inheritance failed");
|
||||
}
|
||||
|
||||
std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::wstring& commandLine,
|
||||
const std::wstring& cwd, const std::vector<std::wstring>& environment) {
|
||||
const std::wstring& cwd, const std::vector<std::wstring>& environment,
|
||||
bool protocolStdin) {
|
||||
const auto executable = ordinaryDriveSeparators(command);
|
||||
require(executable.size() >= 3 && executable[1] == L':' && executable[2] == L'\\',
|
||||
"Owned Windows process requires an absolute executable path", command, ERROR_INVALID_PARAMETER);
|
||||
|
|
@ -52,9 +53,13 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
|
|||
Handle stdoutWrite, stderrWrite;
|
||||
pipe(result->stdoutRead, stdoutWrite); pipe(result->stderrRead, stderrWrite);
|
||||
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
|
||||
Handle input(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
&security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
require(input.valid(), "Open owned process null input failed");
|
||||
Handle input;
|
||||
if (protocolStdin) pipe(input, result->stdinWrite, false);
|
||||
else {
|
||||
input.reset(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
&security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
require(input.valid(), "Open owned process null input failed");
|
||||
}
|
||||
Attributes attributes(2);
|
||||
HANDLE job = result->job.get();
|
||||
attributes.add(PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, sizeof(job));
|
||||
|
|
@ -198,16 +203,93 @@ static napi_value callerToken(napi_env env) {
|
|||
return result;
|
||||
}
|
||||
|
||||
struct InputWork {
|
||||
napi_async_work work = nullptr;
|
||||
napi_deferred deferred = nullptr;
|
||||
napi_ref processReference = nullptr;
|
||||
Process* process;
|
||||
Handle pipe;
|
||||
std::vector<unsigned char> bytes;
|
||||
size_t written = 0;
|
||||
std::unique_ptr<Failure> failure;
|
||||
explicit InputWork(Process& process) : process(&process) {}
|
||||
};
|
||||
|
||||
static void writeInput(napi_env, void* pointer) {
|
||||
auto& work = *static_cast<InputWork*>(pointer);
|
||||
try {
|
||||
// Anonymous pipe writes may block. Keep them off the JS thread so output
|
||||
// polling and job termination remain live while a child stops reading.
|
||||
while (work.written < work.bytes.size()) {
|
||||
DWORD written = 0;
|
||||
const auto count = static_cast<DWORD>(std::min<size_t>(65536, work.bytes.size() - work.written));
|
||||
require(WriteFile(work.pipe.get(), work.bytes.data() + work.written, count, &written, nullptr),
|
||||
"Write owned process input failed");
|
||||
require(written > 0, "Owned process input write made no progress", L"", ERROR_WRITE_FAULT);
|
||||
work.written += written;
|
||||
}
|
||||
} catch (const Failure& failure) {
|
||||
work.failure = std::make_unique<Failure>(failure);
|
||||
} catch (const std::exception& failure) {
|
||||
work.failure = std::make_unique<Failure>(failure.what(), L"", ERROR_GEN_FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
static void completeInput(napi_env env, napi_status status, void* pointer) {
|
||||
std::unique_ptr<InputWork> work(static_cast<InputWork*>(pointer));
|
||||
work->process->inputPending = false;
|
||||
if (work->failure) napi_reject_deferred(env, work->deferred, failureValue(env, *work->failure));
|
||||
else if (status != napi_ok)
|
||||
napi_reject_deferred(env, work->deferred, failureValue(env, Failure("Owned process input worker was cancelled", L"", ERROR_OPERATION_ABORTED)));
|
||||
else napi_resolve_deferred(env, work->deferred, number(env, work->written));
|
||||
napi_delete_reference(env, work->processReference);
|
||||
napi_delete_async_work(env, work->work);
|
||||
}
|
||||
|
||||
static napi_value inputWrite(napi_env env, napi_value owner, Process& process, napi_value input) {
|
||||
require(process.stdinWrite.valid(), "Owned process has no writable input", L"", ERROR_BROKEN_PIPE);
|
||||
require(!process.inputPending, "Owned process input already has a pending write", L"", ERROR_BUSY);
|
||||
auto work = std::make_unique<InputWork>(process);
|
||||
void* bytes = nullptr;
|
||||
size_t size = 0;
|
||||
bool buffer = false;
|
||||
check(napi_is_buffer(env, input, &buffer));
|
||||
require(buffer, "Owned process input must be a Buffer", L"", ERROR_INVALID_PARAMETER);
|
||||
check(napi_get_buffer_info(env, input, &bytes, &size));
|
||||
if (size > 0) work->bytes.assign(static_cast<unsigned char*>(bytes), static_cast<unsigned char*>(bytes) + size);
|
||||
HANDLE pipe;
|
||||
require(DuplicateHandle(GetCurrentProcess(), process.stdinWrite.get(), GetCurrentProcess(), &pipe,
|
||||
0, FALSE, DUPLICATE_SAME_ACCESS), "Retain owned process input handle failed");
|
||||
work->pipe.reset(pipe);
|
||||
napi_value promise;
|
||||
check(napi_create_promise(env, &work->deferred, &promise));
|
||||
check(napi_create_reference(env, owner, 1, &work->processReference));
|
||||
try {
|
||||
check(napi_create_async_work(env, nullptr, string(env, std::string("REA owned process input")),
|
||||
writeInput, completeInput, work.get(), &work->work));
|
||||
check(napi_queue_async_work(env, work->work));
|
||||
process.inputPending = true;
|
||||
} catch (...) {
|
||||
if (work->work) napi_delete_async_work(env, work->work);
|
||||
napi_delete_reference(env, work->processReference);
|
||||
throw;
|
||||
}
|
||||
work.release();
|
||||
return promise;
|
||||
}
|
||||
|
||||
napi_value processCall(napi_env env, const std::wstring& operation, const std::vector<napi_value>& args) {
|
||||
if (operation == L"process_caller_token") {
|
||||
require(args.empty(), "Caller token observation takes no arguments", L"", ERROR_INVALID_PARAMETER);
|
||||
return callerToken(env);
|
||||
}
|
||||
if (operation == L"process_spawn") {
|
||||
require(args.size() == 4, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER);
|
||||
require(args.size() == 4 || args.size() == 5, "Wrong native process launch argument count", L"", ERROR_INVALID_PARAMETER);
|
||||
bool protocolStdin = false;
|
||||
if (args.size() == 5) check(napi_get_value_bool(env, args[4], &protocolStdin));
|
||||
std::vector<std::wstring> environment;
|
||||
for (const auto& entry : array(env, args[3])) environment.push_back(wide(env, entry));
|
||||
auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment);
|
||||
auto process = spawnProcess(wide(env, args[0]), wide(env, args[1]), wide(env, args[2]), environment, protocolStdin);
|
||||
auto result = object(env);
|
||||
set(env, result, "pid", number(env, process->pid));
|
||||
set(env, result, "jobAssigned", boolean(env, true));
|
||||
|
|
@ -215,8 +297,16 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
|
|||
set(env, result, "handle", wrap(env, std::move(process)));
|
||||
return result;
|
||||
}
|
||||
if (operation == L"process_stdin_write") {
|
||||
require(args.size() == 2, "Wrong native process input argument count", L"", ERROR_INVALID_PARAMETER);
|
||||
return inputWrite(env, args[0], static_cast<Process&>(resource(env, args[0], Kind::Process)), args[1]);
|
||||
}
|
||||
require(args.size() == 1, "Wrong native process handle argument count", L"", ERROR_INVALID_PARAMETER);
|
||||
auto& process = static_cast<Process&>(resource(env, args[0], Kind::Process));
|
||||
if (operation == L"process_stdin_close") {
|
||||
process.stdinWrite.reset();
|
||||
return null(env);
|
||||
}
|
||||
if (operation == L"process_poll") {
|
||||
auto result = object(env);
|
||||
bool stdoutEnded = false, stderrEnded = false;
|
||||
|
|
@ -240,7 +330,7 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
|
|||
}
|
||||
if (operation == L"process_close") {
|
||||
// Closing the job is the crash-safe authority; no PID enumeration is used.
|
||||
process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset();
|
||||
process.job.reset(); process.process.reset(); process.stdoutRead.reset(); process.stderrRead.reset(); process.stdinWrite.reset();
|
||||
process.closed = true;
|
||||
return null(env);
|
||||
}
|
||||
|
|
@ -266,6 +356,7 @@ napi_value inspect(napi_env env) {
|
|||
set(env, result, "privateDacl", boolean(env, true));
|
||||
set(env, result, "atomicJobAssignment", boolean(env, true));
|
||||
set(env, result, "killOnOwnerClose", boolean(env, true));
|
||||
set(env, result, "protocolStdin", boolean(env, true));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
/** Exercise owned Windows stdin through its production Writable and native boundary. */
|
||||
export async function verifyWindowsProtocolInput(packageRoot, workspace) {
|
||||
const { WindowsOwnedProcess } = await import(
|
||||
pathToFileURL(join(packageRoot, "dist/windows/WindowsOwnedProcess.js"))
|
||||
);
|
||||
const { requireWindowsNativeAuthority } = await import(
|
||||
pathToFileURL(join(packageRoot, "dist/windows/WindowsNativeLoader.js"))
|
||||
);
|
||||
const native = requireWindowsNativeAuthority();
|
||||
assert.equal(native.inspection.protocolStdin, true);
|
||||
const launch = (script) => acquire(WindowsOwnedProcess, workspace, script);
|
||||
const echo = await verifyEcho(launch);
|
||||
const cancellation = await verifyBlockedCancellation(launch);
|
||||
await verifyNativePendingWrite(native, workspace);
|
||||
const { JadxMcpTransport } = await import(
|
||||
pathToFileURL(join(packageRoot, "dist/android/JadxMcpTransport.js"))
|
||||
);
|
||||
await verifyTransportPendingWrite(JadxMcpTransport, workspace);
|
||||
return {
|
||||
echo,
|
||||
cancellation,
|
||||
native_pending_write_closed: true,
|
||||
forged_input_handle_rejected: true,
|
||||
malformed_input_rejected: true,
|
||||
provider_transport_pending_write_cancelled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function acquire(Process, workspace, script) {
|
||||
const child = new Process(
|
||||
process.execPath,
|
||||
["-e", script],
|
||||
workspace,
|
||||
process.env,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (bytes) => {
|
||||
stdout += bytes.toString();
|
||||
});
|
||||
child.stderr.on("data", (bytes) => {
|
||||
stderr += bytes.toString();
|
||||
});
|
||||
const inputErrors = [];
|
||||
child.stdin.on("error", (cause) => inputErrors.push(cause.message));
|
||||
const closed = new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
return { child, closed, inputErrors, stdout: () => stdout };
|
||||
}
|
||||
|
||||
async function verifyEcho(launch) {
|
||||
const acquired = launch(`
|
||||
const {createHash} = require('node:crypto');
|
||||
const hash = createHash('sha256'); let count = 0;
|
||||
process.stdin.on('data', bytes => { hash.update(bytes); count += bytes.length; });
|
||||
process.stdin.pause(); setTimeout(() => process.stdin.resume(), 250);
|
||||
process.stdin.on('end', () => console.log(JSON.stringify({count,sha256:hash.digest('hex')})));
|
||||
`);
|
||||
const bytes = Buffer.from(
|
||||
"caller-selected UTF8: 路径 Ω; binary:\0\n".repeat(60000),
|
||||
);
|
||||
const expected = {
|
||||
count: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
let ticks = 0;
|
||||
const timer = setInterval(() => ticks++, 20);
|
||||
try {
|
||||
let writable;
|
||||
await new Promise((resolve, reject) => {
|
||||
writable = acquired.child.stdin.write(bytes, (cause) =>
|
||||
cause ? reject(cause) : resolve(),
|
||||
);
|
||||
// The native worker owns its copy before returning to the caller.
|
||||
bytes.fill(0x5a);
|
||||
});
|
||||
assert.equal(
|
||||
writable,
|
||||
false,
|
||||
"Large protocol input did not expose backpressure",
|
||||
);
|
||||
assert.ok(ticks > 0, "Input write blocked the JS event loop");
|
||||
acquired.child.stdin.end();
|
||||
const result = await deadline(acquired.closed);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), expected);
|
||||
assert.deepEqual(acquired.inputErrors, []);
|
||||
assert.equal((await acquired.child.cleanup()).cleaned, true);
|
||||
return {
|
||||
bytes: expected.count,
|
||||
sha256: expected.sha256,
|
||||
backpressure: true,
|
||||
event_loop_responsive: true,
|
||||
eof_observed: true,
|
||||
};
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
await acquired.child.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyBlockedCancellation(launch) {
|
||||
const acquired = launch(`
|
||||
const {spawn} = require('node:child_process');
|
||||
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:'inherit'});
|
||||
console.log('owned descendant ' + child.pid);
|
||||
setInterval(() => {}, 1000);
|
||||
`);
|
||||
try {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 100 && !acquired.stdout().includes("owned descendant");
|
||||
attempt++
|
||||
)
|
||||
await delay(25);
|
||||
assert.match(acquired.stdout(), /owned descendant \d+/u);
|
||||
const write = new Promise((resolve) =>
|
||||
acquired.child.stdin.write(Buffer.alloc(2 * 1024 * 1024), (cause) =>
|
||||
resolve(cause),
|
||||
),
|
||||
);
|
||||
await delay(100);
|
||||
const cleanup = await acquired.child.cleanup();
|
||||
assert.equal(cleanup.cleaned, true, cleanup.reason);
|
||||
assert.ok(
|
||||
await deadline(write),
|
||||
"Closing the owned job reported a successful blocked write",
|
||||
);
|
||||
await deadline(acquired.closed);
|
||||
return { blocked_write_cancelled: true, descendant_job_settled: true };
|
||||
} finally {
|
||||
await acquired.child.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyNativePendingWrite(native, workspace) {
|
||||
const quote = (value) =>
|
||||
`"${value.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\+)$/u, "$1$1")}"`;
|
||||
const child = native.call("process_spawn", [
|
||||
process.execPath,
|
||||
[process.execPath, "-e", "setInterval(() => {}, 1000)"]
|
||||
.map(quote)
|
||||
.join(" "),
|
||||
workspace,
|
||||
Object.entries(process.env)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => `${key}=${value}`),
|
||||
true,
|
||||
]);
|
||||
let rejected;
|
||||
try {
|
||||
assert.throws(
|
||||
() => native.call("process_stdin_write", [child.handle, "not a Buffer"]),
|
||||
/must be a Buffer/u,
|
||||
);
|
||||
const pending = native.call("process_stdin_write", [
|
||||
child.handle,
|
||||
Buffer.alloc(2 * 1024 * 1024),
|
||||
]);
|
||||
rejected = pending.then(
|
||||
() => false,
|
||||
(cause) => cause,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
native.call("process_stdin_write", [
|
||||
child.handle,
|
||||
Buffer.from("second"),
|
||||
]),
|
||||
/pending write/u,
|
||||
);
|
||||
assert.throws(
|
||||
() => native.call("process_stdin_write", [{}, Buffer.from("forged")]),
|
||||
/does not belong to REA/u,
|
||||
);
|
||||
} finally {
|
||||
// The worker retains its handle/buffer after the JS resource closes.
|
||||
native.call("process_close", [child.handle]);
|
||||
}
|
||||
const failure = await deadline(rejected);
|
||||
assert.ok(failure instanceof Error);
|
||||
assert.match(failure.message, /Write owned process input failed/u);
|
||||
}
|
||||
|
||||
async function verifyTransportPendingWrite(Transport, workspace) {
|
||||
const transport = new Transport({
|
||||
command: process.execPath,
|
||||
arguments: ["-e", "setInterval(() => {}, 1000)"],
|
||||
cwd: workspace,
|
||||
});
|
||||
transport.onerror = () => {};
|
||||
try {
|
||||
await transport.start();
|
||||
const pending = transport
|
||||
.send({
|
||||
jsonrpc: "2.0",
|
||||
id: "blocked",
|
||||
method: "probe",
|
||||
params: { payload: "x".repeat(2 * 1024 * 1024) },
|
||||
})
|
||||
.then(
|
||||
() => false,
|
||||
(cause) => cause,
|
||||
);
|
||||
await delay(100);
|
||||
await transport.close();
|
||||
assert.ok(
|
||||
await deadline(pending),
|
||||
"Transport cancellation accepted a blocked write",
|
||||
);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deadline(promise) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Owned input fixture exceeded its deadline")),
|
||||
10000,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { verifyWindowsProtocolInput } from "./lib/windows-protocol-input.mjs";
|
||||
|
||||
if (process.platform !== "win32")
|
||||
throw new Error(
|
||||
|
|
@ -411,6 +412,10 @@ setInterval(()=>{},1000);\n`,
|
|||
await delay();
|
||||
assert.equal(live(ownerClose.pid), false);
|
||||
report.processes.killOnOwnerClose = true;
|
||||
report.processes.protocolInput = await verifyWindowsProtocolInput(
|
||||
packageRoot,
|
||||
workspace,
|
||||
);
|
||||
const exited = launch([
|
||||
"--max-old-space-size=128",
|
||||
"-e",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import fixture from "../../fixtures/android/apidemos.json" with { type: "json" }
|
|||
import { verifyAndroidCliCancellation } from "./cli-cancellation.mjs";
|
||||
import { verifyAndroidMetadata } from "./metadata.mjs";
|
||||
import { verifyAndroidFailureRecovery } from "./failure-recovery.mjs";
|
||||
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
|
||||
import { verifyWindowsAndroidLifecycle } from "./windows-lifecycle.mjs";
|
||||
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const apk = process.env.REA_ANDROID_TEST_APK;
|
||||
|
|
@ -33,10 +35,10 @@ assert.equal(
|
|||
JADX_RELEASE.sha256,
|
||||
"Real Android lane requires the audited headless JADX release JAR",
|
||||
);
|
||||
const java =
|
||||
process.env.JAVA_HOME === undefined
|
||||
? "java"
|
||||
: resolve(process.env.JAVA_HOME, "bin/java");
|
||||
const { java, jvmArguments } = await resolveJadxConfiguration(
|
||||
process.env,
|
||||
"inspect_android_package",
|
||||
);
|
||||
const execute = promisify(execFile);
|
||||
await execute(java, ["-version"], { timeout: 10_000 });
|
||||
const entrypoint =
|
||||
|
|
@ -49,6 +51,7 @@ await verifyAndroidMetadata({
|
|||
apk: path,
|
||||
repository,
|
||||
execute,
|
||||
jvmArguments,
|
||||
bridge: resolve(dirname(entrypoint), "../bridge/android/ReaJadxBridge.java"),
|
||||
});
|
||||
const tasks = [
|
||||
|
|
@ -200,15 +203,22 @@ assert.ok(
|
|||
reference.dex_offset === null && reference.source_line === null,
|
||||
),
|
||||
);
|
||||
await verifyAndroidCliCancellation({
|
||||
const lifecycleOptions = {
|
||||
entrypoint,
|
||||
environment,
|
||||
path,
|
||||
repository,
|
||||
execute,
|
||||
});
|
||||
};
|
||||
const windowsLifecycle =
|
||||
process.platform === "win32"
|
||||
? await verifyWindowsAndroidLifecycle(lifecycleOptions)
|
||||
: undefined;
|
||||
if (windowsLifecycle === undefined)
|
||||
await verifyAndroidCliCancellation(lifecycleOptions);
|
||||
await verifyAndroidFailureRecovery({
|
||||
java,
|
||||
jvmArguments,
|
||||
entrypoint,
|
||||
environment,
|
||||
path,
|
||||
|
|
@ -230,7 +240,10 @@ const report = {
|
|||
cli_mcp_parity: true,
|
||||
original_apk_unchanged: true,
|
||||
apk_executed: false,
|
||||
cli_sigterm_cleanup: true,
|
||||
cli_sigterm_cleanup: process.platform !== "win32",
|
||||
...(windowsLifecycle === undefined
|
||||
? {}
|
||||
: { windows_lifecycle: windowsLifecycle }),
|
||||
metadata_without_code_generation: true,
|
||||
stable_overload_selection: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { TOOL_CONTRACTS } from "../../../dist/contracts/toolContracts.js";
|
|||
/** Real unusable Java runtimes and selectors must lead to applicable recovery. */
|
||||
export async function verifyAndroidFailureRecovery({
|
||||
java,
|
||||
jvmArguments = [],
|
||||
entrypoint,
|
||||
environment,
|
||||
path,
|
||||
|
|
@ -24,6 +25,8 @@ export async function verifyAndroidFailureRecovery({
|
|||
fixture,
|
||||
}) {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-android-recovery-"));
|
||||
const javaName = process.platform === "win32" ? "java.exe" : "java";
|
||||
const jlinkName = process.platform === "win32" ? "jlink.exe" : "jlink";
|
||||
try {
|
||||
const withoutJar = { ...environment };
|
||||
delete withoutJar.REA_JADX_MCP_JAR;
|
||||
|
|
@ -36,10 +39,10 @@ export async function verifyAndroidFailureRecovery({
|
|||
});
|
||||
let jlink;
|
||||
for (const candidate of isAbsolute(java)
|
||||
? [join(dirname(java), "jlink")]
|
||||
? [join(dirname(java), jlinkName)]
|
||||
: (environment.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.map((directory) => join(directory, "jlink"))) {
|
||||
.map((directory) => join(directory, jlinkName))) {
|
||||
try {
|
||||
await access(candidate);
|
||||
jlink = candidate;
|
||||
|
|
@ -53,10 +56,22 @@ export async function verifyAndroidFailureRecovery({
|
|||
"Android failure-recovery verification requires the full JDK's jlink via JAVA_HOME or PATH.",
|
||||
);
|
||||
const jre = join(root, "jre");
|
||||
await execute(jlink, ["--add-modules", "java.base", "--output", jre], {
|
||||
timeout: 60_000,
|
||||
await execute(
|
||||
jlink,
|
||||
[
|
||||
...jvmArguments.map((argument) => `-J${argument}`),
|
||||
"--add-modules",
|
||||
"java.base",
|
||||
"--output",
|
||||
jre,
|
||||
],
|
||||
{
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
await execute(join(jre, "bin", javaName), ["-version"], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await execute(join(jre, "bin/java"), ["-version"], { timeout: 10_000 });
|
||||
const empty = join(root, "empty-path");
|
||||
await mkdir(empty);
|
||||
if (process.platform === "linux") {
|
||||
|
|
@ -74,6 +89,8 @@ export async function verifyAndroidFailureRecovery({
|
|||
}
|
||||
const withoutJavaHome = { ...environment };
|
||||
delete withoutJavaHome.JAVA_HOME;
|
||||
for (const key of Object.keys(withoutJavaHome))
|
||||
if (key.toLowerCase() === "path") delete withoutJavaHome[key];
|
||||
const invalidJar = join(root, "invalid.jar");
|
||||
await writeFile(invalidJar, "not a Java archive");
|
||||
const environments = [
|
||||
|
|
@ -85,7 +102,8 @@ export async function verifyAndroidFailureRecovery({
|
|||
{
|
||||
label: "missing java",
|
||||
env: { ...withoutJavaHome, PATH: empty },
|
||||
observation: "ENOENT",
|
||||
observation:
|
||||
process.platform === "win32" ? "Cannot find java.exe" : "ENOENT",
|
||||
},
|
||||
{
|
||||
label: "invalid engine archive with a working JDK",
|
||||
|
|
|
|||
|
|
@ -11,11 +11,16 @@ export const verifyAndroidMetadata = async ({
|
|||
bridge,
|
||||
repository,
|
||||
execute,
|
||||
jvmArguments = [],
|
||||
}) => {
|
||||
const javac =
|
||||
process.env.JAVA_HOME === undefined
|
||||
? "javac"
|
||||
: resolve(process.env.JAVA_HOME, "bin/javac");
|
||||
: resolve(
|
||||
process.env.JAVA_HOME,
|
||||
"bin",
|
||||
process.platform === "win32" ? "javac.exe" : "javac",
|
||||
);
|
||||
try {
|
||||
await execute(javac, ["-version"], { timeout: 10_000 });
|
||||
} catch (cause) {
|
||||
|
|
@ -29,6 +34,7 @@ export const verifyAndroidMetadata = async ({
|
|||
await execute(
|
||||
javac,
|
||||
[
|
||||
...jvmArguments.map((argument) => `-J${argument}`),
|
||||
"-classpath",
|
||||
jar,
|
||||
"-d",
|
||||
|
|
@ -44,6 +50,7 @@ export const verifyAndroidMetadata = async ({
|
|||
const result = await execute(
|
||||
java,
|
||||
[
|
||||
...jvmArguments,
|
||||
"-classpath",
|
||||
`${classes}${delimiter}${jar}`,
|
||||
"ReaJadxMetadataRegression",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
mkdtemp,
|
||||
readdir,
|
||||
realpath,
|
||||
rmdir,
|
||||
symlink,
|
||||
unlink,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { resolveJadxConfiguration } from "../../../dist/android/JadxConfiguration.js";
|
||||
|
||||
/** Verify real Windows cancellation, disconnect and abrupt CLI owner exit. */
|
||||
export async function verifyWindowsAndroidLifecycle(options) {
|
||||
assert.equal(process.platform, "win32");
|
||||
await verifyJavaSelection(options.environment);
|
||||
const cancellation = await verifyMcp(options, "cancel");
|
||||
await verifyMcp(options, "disconnect");
|
||||
await verifyCliOwnerExit(options);
|
||||
return {
|
||||
mcp_cancelled_engine_exited: true,
|
||||
mcp_cancelled_workspace_removed: true,
|
||||
mcp_ping_and_next_call: true,
|
||||
mcp_disconnect_engine_exited: true,
|
||||
cli_abrupt_owner_exit_engine_exited: true,
|
||||
// A forced Windows process exit cannot execute JavaScript workspace cleanup.
|
||||
cli_abrupt_owner_exit_workspace_cleanup: false,
|
||||
observed_resources: cancellation,
|
||||
java_home_and_quoted_case_insensitive_path: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyJavaSelection(environment) {
|
||||
const selected = await resolveJadxConfiguration(
|
||||
environment,
|
||||
"inspect_android_package",
|
||||
);
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-windows-java-selection-"));
|
||||
const alias = join(root, "JDK 路径 with spaces");
|
||||
const pathEnvironment = Object.fromEntries(
|
||||
Object.entries(environment).filter(
|
||||
([key]) => !["path", "java_home"].includes(key.toLowerCase()),
|
||||
),
|
||||
);
|
||||
try {
|
||||
// The owned junction borrows the selected JDK directory without copying it.
|
||||
await symlink(dirname(selected.java), alias, "junction");
|
||||
pathEnvironment.Path = `"${alias}"`;
|
||||
const onPath = await resolveJadxConfiguration(
|
||||
pathEnvironment,
|
||||
"inspect_android_package",
|
||||
);
|
||||
assert.equal(onPath.java, await realpath(selected.java));
|
||||
console.log(
|
||||
"PASS Windows JAVA_HOME and quoted Unicode/space Path selection",
|
||||
);
|
||||
} finally {
|
||||
await unlink(alias).catch((cause) => {
|
||||
if (cause.code !== "ENOENT") throw cause;
|
||||
});
|
||||
await rmdir(root);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyMcp({ entrypoint, environment, path, repository }, mode) {
|
||||
const baseline = await runtimeRoots();
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
const client = new Client({
|
||||
name: `rea-windows-android-${mode}`,
|
||||
version: "1",
|
||||
});
|
||||
let observer;
|
||||
try {
|
||||
await client.connect(transport);
|
||||
assert.ok(transport.pid !== null);
|
||||
observer = observeJava(transport.pid);
|
||||
const controller = new AbortController();
|
||||
const pending = client
|
||||
.callTool(
|
||||
{ name: "inspect_android_package", arguments: { path } },
|
||||
{ timeout: 150000, signal: controller.signal },
|
||||
)
|
||||
.then(
|
||||
(response) => ({ response }),
|
||||
(error) => ({ error }),
|
||||
);
|
||||
const resources = await observer.acquired;
|
||||
if (mode === "cancel") controller.abort();
|
||||
else await client.close();
|
||||
const outcome = await deadline(pending, 15000);
|
||||
assert.ok("error" in outcome, "Cancelled request published a success");
|
||||
await observer.verifyExit();
|
||||
await waitFor(
|
||||
async () => {
|
||||
const remaining = await runtimeRoots();
|
||||
return remaining.every((root) => baseline.includes(root));
|
||||
},
|
||||
15000,
|
||||
"Owned Android workspace remained after cleanup",
|
||||
);
|
||||
if (mode === "cancel") {
|
||||
await waitFor(
|
||||
() => stderr.includes('"status":"error"'),
|
||||
15000,
|
||||
`Server did not finish the cancelled request: ${stderr}`,
|
||||
);
|
||||
assert.ok(
|
||||
!stderr.includes('"status":"ok"'),
|
||||
"Cancelled operation was logged successful",
|
||||
);
|
||||
await client.ping();
|
||||
const next = await client.callTool(
|
||||
{ name: "inspect_android_package", arguments: { path } },
|
||||
{ timeout: 150000 },
|
||||
);
|
||||
assert.notEqual(next.isError, true, JSON.stringify(next));
|
||||
assert.ok(next.structuredContent?.evidence_id);
|
||||
}
|
||||
console.log(
|
||||
`PASS Windows MCP ${mode}: real Java exit and workspace cleanup`,
|
||||
);
|
||||
return resources;
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
await observer?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCliOwnerExit({
|
||||
entrypoint,
|
||||
environment,
|
||||
path,
|
||||
repository,
|
||||
}) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-android-package", path, "--format", "json"],
|
||||
{ cwd: repository, env: environment, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
output = (output + chunk.toString()).slice(-65536);
|
||||
});
|
||||
child.stderr.resume();
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", resolve);
|
||||
});
|
||||
assert.ok(child.pid !== undefined);
|
||||
const observer = observeJava(child.pid);
|
||||
try {
|
||||
await observer.acquired;
|
||||
// Node's Windows SIGTERM terminates the retained child handle abruptly.
|
||||
// The Java job must settle through kill-on-owner-close without JS cleanup.
|
||||
assert.equal(child.kill(), true);
|
||||
await deadline(exited, 15000);
|
||||
await observer.verifyExit();
|
||||
assert.equal(output, "", "Aborted CLI published successful Evidence");
|
||||
console.log("PASS Windows CLI abrupt owner exit: real Java job cleanup");
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill();
|
||||
await deadline(exited, 15000);
|
||||
await observer.close();
|
||||
}
|
||||
}
|
||||
|
||||
function observeJava(ownerPid) {
|
||||
assert.ok(Number.isSafeInteger(ownerPid) && ownerPid > 0);
|
||||
// Retain actual process handles and creation times. The process table only
|
||||
// discovers the child; no PID or executable path grants termination authority.
|
||||
const source = `
|
||||
$ErrorActionPreference='Stop'
|
||||
$owner=Get-Process -Id ${ownerPid}
|
||||
$null=$owner.Handle; $ownerStart=$owner.StartTime
|
||||
$java=$null; $deadline=[DateTime]::UtcNow.AddSeconds(45)
|
||||
try {
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
if ($owner.HasExited) { throw 'REA owner exited before Java acquisition' }
|
||||
$rows=Get-CimInstance Win32_Process -Filter 'ParentProcessId = ${ownerPid}'
|
||||
foreach ($row in $rows) {
|
||||
if ($row.Name -eq 'java.exe' -and $row.CommandLine -like '*ReaJadxBridge.java*') {
|
||||
$candidate=Get-Process -Id $row.ProcessId
|
||||
$null=$candidate.Handle
|
||||
$sameCreation=$candidate.StartTime.ToUniversalTime().ToString('yyyyMMddHHmmssffffff') -eq $row.CreationDate.ToUniversalTime().ToString('yyyyMMddHHmmssffffff')
|
||||
if ($sameCreation -and $candidate.StartTime -ge $ownerStart -and !$candidate.HasExited) {
|
||||
$java=$candidate
|
||||
$resourceArgs=@([regex]::Matches($row.CommandLine,'-Xmx[0-9]+m|-XX:ActiveProcessorCount=[0-9]+') | ForEach-Object {$_.Value})
|
||||
@{affinity=[int64]$java.ProcessorAffinity; priority=[string]$java.PriorityClass; jvm_arguments=$resourceArgs} | ConvertTo-Json -Compress
|
||||
break
|
||||
}
|
||||
$candidate.Dispose()
|
||||
}
|
||||
}
|
||||
if ($null -ne $java) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if ($null -eq $java) { throw 'No real owned Java engine was acquired' }
|
||||
$null=[Console]::ReadLine()
|
||||
if (!$java.WaitForExit(15000)) { throw 'Retained Java process did not exit' }
|
||||
@{exited=$java.HasExited} | ConvertTo-Json -Compress
|
||||
} finally { if ($null -ne $java) {$java.Dispose()}; $owner.Dispose() }
|
||||
`;
|
||||
const observer = spawn(
|
||||
join(
|
||||
process.env.SystemRoot ?? "C:\\Windows",
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
),
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-EncodedCommand",
|
||||
Buffer.from(source, "utf16le").toString("base64"),
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"], windowsHide: true },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
observer.stdout.on("data", (chunk) => {
|
||||
stdout = (stdout + chunk.toString()).slice(-65536);
|
||||
});
|
||||
observer.stderr.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
observer.once("error", reject);
|
||||
observer.once("close", resolve);
|
||||
});
|
||||
const acquired = waitFor(
|
||||
() => stdout.includes("\n") || observer.exitCode !== null,
|
||||
50000,
|
||||
"Java process observer did not acquire a handle",
|
||||
).then(() => {
|
||||
assert.ok(stdout.includes("\n"), stderr);
|
||||
return JSON.parse(stdout.split(/\r?\n/u)[0]);
|
||||
});
|
||||
return {
|
||||
acquired,
|
||||
async verifyExit() {
|
||||
observer.stdin.end("verify\n");
|
||||
assert.equal(await deadline(exited, 20000), 0, stderr);
|
||||
assert.equal(
|
||||
JSON.parse(stdout.trim().split(/\r?\n/u).at(-1)).exited,
|
||||
true,
|
||||
);
|
||||
},
|
||||
async close() {
|
||||
if (observer.exitCode === null && observer.signalCode === null) {
|
||||
observer.stdin.end();
|
||||
observer.kill();
|
||||
}
|
||||
await deadline(exited, 20000);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runtimeRoots() {
|
||||
return (await readdir(tmpdir())).filter((name) =>
|
||||
name.startsWith("rea-android-"),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitFor(predicate, milliseconds, message) {
|
||||
const until = Date.now() + milliseconds;
|
||||
while (Date.now() < until) {
|
||||
if (await predicate()) return;
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function deadline(promise, milliseconds) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Windows lifecycle deadline exceeded")),
|
||||
milliseconds,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
|
@ -19,9 +19,12 @@ can select Java. REA setup does not install or configure that JAR, and doctor
|
|||
provider scopes cover Hopper/Ghidra rather than JADX. Use existing tools and
|
||||
report missing prerequisites for the Android task specifically.
|
||||
|
||||
Real verification covers Linux. macOS has the POSIX launch boundary but no
|
||||
claimed real-provider verification; Windows is unsupported. Calls use one
|
||||
worker, a bounded JVM heap, and provider deadlines. The APK is never launched,
|
||||
Linux and macOS use the POSIX owned-process boundary. Windows x64 requires the
|
||||
matching bundled native controls and uses an owned Job Object with bidirectional
|
||||
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. The APK is never launched,
|
||||
and no emulator is required.
|
||||
|
||||
Manifest entries and decompiled text are static observations, not execution or
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { constants } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
||||
import { systemWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
|
@ -116,7 +117,7 @@ const readJadxConfiguration = async (
|
|||
): Promise<JadxConfiguration> => {
|
||||
requireSupportedHost();
|
||||
const jar = await resolveJar(environment);
|
||||
const java = resolveJava(environment);
|
||||
const java = await resolveJava(environment, signal);
|
||||
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
|
||||
const jvmArguments = resolveJvmArguments(environment);
|
||||
await inspectJavaRuntime(java, environment, signal);
|
||||
|
|
@ -169,9 +170,26 @@ const requireEngineArchive = async (
|
|||
|
||||
const requireSupportedHost = (): void => {
|
||||
if (process.platform === "linux" || process.platform === "darwin") return;
|
||||
if (process.platform === "win32" && process.arch === "x64") {
|
||||
const loaded = systemWindowsNativeAuthority();
|
||||
if (loaded.available && loaded.authority.inspection.protocolStdin === true)
|
||||
return;
|
||||
throw configurationFailure(
|
||||
"runtime_missing",
|
||||
loaded.available
|
||||
? "The bundled Windows native artifact lacks owned protocol stdin; use a matching current Windows x64 REA package."
|
||||
: loaded.reason,
|
||||
{
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
phase: "process-ownership",
|
||||
owned_protocol_stdin: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw configurationFailure(
|
||||
"unsupported_host",
|
||||
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. The metadata bridge has real-provider verification on macOS arm64.`,
|
||||
`Android JADX subprocess ownership is unsupported on ${process.platform}/${process.arch}; use Linux, macOS, or Windows x64 with its bundled native controls.`,
|
||||
{ platform: process.platform },
|
||||
);
|
||||
};
|
||||
|
|
@ -206,12 +224,52 @@ const resolveJar = async (
|
|||
}
|
||||
};
|
||||
|
||||
const resolveJava = (
|
||||
const resolveJava = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): string => {
|
||||
if (environment.JAVA_HOME === undefined) return "java";
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const executable = process.platform === "win32" ? "java.exe" : "java";
|
||||
if (environment.JAVA_HOME === undefined) {
|
||||
if (process.platform !== "win32") return executable;
|
||||
const path =
|
||||
Object.entries(environment).find(
|
||||
([key]) => key.toLowerCase() === "path",
|
||||
)?.[1] ?? "";
|
||||
for (const directory of [process.cwd(), ...path.split(delimiter)]) {
|
||||
signal?.throwIfAborted();
|
||||
// Windows PATH commonly quotes individual entries containing spaces.
|
||||
const selected =
|
||||
directory.startsWith('"') && directory.endsWith('"')
|
||||
? directory.slice(1, -1)
|
||||
: directory;
|
||||
const candidate = resolve(selected, executable);
|
||||
try {
|
||||
await access(candidate, constants.R_OK | constants.X_OK);
|
||||
if ((await stat(candidate)).isFile()) return await realpath(candidate);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
)
|
||||
continue;
|
||||
throw configurationFailure(
|
||||
"runtime_missing",
|
||||
`Cannot inspect selected Java executable ${candidate}: ${errorText(cause)}`,
|
||||
{ java_executable: candidate },
|
||||
cause,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw configurationFailure(
|
||||
"runtime_missing",
|
||||
"Cannot find java.exe on the selected Windows PATH. Select a full JDK with JAVA_HOME or PATH, then verify it with java --list-modules. REA does not install Java.",
|
||||
{ java_executable: executable },
|
||||
);
|
||||
}
|
||||
if (isAbsolute(environment.JAVA_HOME))
|
||||
return join(environment.JAVA_HOME, "bin", "java");
|
||||
return join(environment.JAVA_HOME, "bin", executable);
|
||||
throw configurationFailure(
|
||||
"open_options_invalid",
|
||||
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
type Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { finished } from "node:stream/promises";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
SpawnedOwnedProviderProcess,
|
||||
|
|
@ -19,7 +20,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|||
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_OPERATION_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** Injectable process acquisition seam; the production launcher owns a POSIX group. */
|
||||
/** Injectable acquisition seam; production owns a POSIX group or Windows job. */
|
||||
export type JadxLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
|
@ -139,8 +140,19 @@ export class JadxMcpTransport implements Transport {
|
|||
process?.stderr?.off("data", this.#countStderr);
|
||||
process?.off("error", this.#error);
|
||||
process?.off("exit", this.#exit);
|
||||
process?.stdin?.off("error", this.#error);
|
||||
const input = process?.stdin;
|
||||
// A job termination can complete an in-flight pipe write asynchronously.
|
||||
// Observe its errors until the owned Writable has finished closing.
|
||||
const inputClosed =
|
||||
input === undefined || input === null
|
||||
? undefined
|
||||
: finished(input, { readable: false, cleanup: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const stopped = await this.#supervisor?.stop();
|
||||
input?.destroy();
|
||||
await inputClosed;
|
||||
input?.off("error", this.#error);
|
||||
this.onclose?.();
|
||||
if (stopped?.status === "incomplete")
|
||||
throw new ProviderCleanupError("jadx", [this.runId], {
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ const workflowAvailabilityFor = ({
|
|||
: "provider_missing",
|
||||
remediation:
|
||||
policy.androidAnalysisAvailability?.reason ??
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
|
||||
};
|
||||
const browser = browserProviderAvailability(name, kind, policy);
|
||||
if (browser !== null) return browser;
|
||||
|
|
|
|||
|
|
@ -151,16 +151,13 @@ export const spawnOwnedProviderProcess = async (
|
|||
REA_PROCESS_RUN_ID: options.runId,
|
||||
};
|
||||
if (platform === "win32" && process.platform === "win32") {
|
||||
if (options.stdin === "pipe")
|
||||
throw new Error(
|
||||
"Owned Windows provider processes do not support protocol stdin",
|
||||
);
|
||||
const child = new WindowsOwnedProcess(
|
||||
options.command,
|
||||
options.arguments,
|
||||
options.cwd,
|
||||
environment,
|
||||
options.windowsVerbatimArguments ?? false,
|
||||
options.stdin === "pipe",
|
||||
);
|
||||
return {
|
||||
process: child,
|
||||
|
|
|
|||
|
|
@ -311,6 +311,6 @@ const missingAndroidAvailability = async (): Promise<ProviderAvailability> => ({
|
|||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
reason:
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux or macOS.",
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
|
||||
diagnostics: { configured: false },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type WindowsNativeLoadHost,
|
||||
} from "./WindowsNativeLoader.js";
|
||||
|
||||
const fixture = () => {
|
||||
const fixture = (protocolStdin?: boolean) => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ");
|
||||
bytes.writeUInt32LE(64, 0x3c);
|
||||
|
|
@ -51,6 +51,7 @@ const fixture = () => {
|
|||
privateDacl: true,
|
||||
atomicJobAssignment: true,
|
||||
killOnOwnerClose: true,
|
||||
...(protocolStdin === undefined ? {} : { protocolStdin }),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
|
@ -59,6 +60,22 @@ const fixture = () => {
|
|||
};
|
||||
|
||||
describe("packaged Windows native admission", () => {
|
||||
it.each([true, false])(
|
||||
"preserves protocol stdin capability %s alongside existing job controls",
|
||||
(protocolStdin) => {
|
||||
const value = fixture(protocolStdin);
|
||||
expect(loadWindowsNativeAuthority(value.host)).toMatchObject({
|
||||
available: true,
|
||||
authority: {
|
||||
inspection: {
|
||||
protocolStdin,
|
||||
atomicJobAssignment: true,
|
||||
killOnOwnerClose: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
it("loads only after compatibility and digest checks, preserving producer identity", () => {
|
||||
const value = fixture();
|
||||
expect(loadWindowsNativeAuthority(value.host)).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const inspectionSchema = z.strictObject({
|
|||
privateDacl: z.literal(true),
|
||||
atomicJobAssignment: z.literal(true),
|
||||
killOnOwnerClose: z.literal(true),
|
||||
protocolStdin: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type NativeCall = (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ export const quoteWindowsProcessArgument = (value: string): string => {
|
|||
export class WindowsOwnedProcess extends EventEmitter {
|
||||
readonly stdout = new PassThrough();
|
||||
readonly stderr = new PassThrough();
|
||||
readonly stdin: Writable | null;
|
||||
readonly signalCode: NodeJS.Signals | null = null;
|
||||
exitCode: number | null = null;
|
||||
readonly pid: number;
|
||||
|
|
@ -55,9 +56,14 @@ export class WindowsOwnedProcess extends EventEmitter {
|
|||
cwd: string | undefined,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
verbatim: boolean,
|
||||
protocolStdin = false,
|
||||
) {
|
||||
super();
|
||||
this.#authority = requireWindowsNativeAuthority();
|
||||
if (protocolStdin && this.#authority.inspection.protocolStdin !== true)
|
||||
throw new Error(
|
||||
"The bundled Windows native artifact does not support owned protocol stdin; use a matching current Windows x64 REA package.",
|
||||
);
|
||||
const entries = Object.entries(environment)
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
.map(([key, value]) => `${key}=${value}`);
|
||||
|
|
@ -75,16 +81,70 @@ export class WindowsOwnedProcess extends EventEmitter {
|
|||
line,
|
||||
cwd ?? "",
|
||||
entries,
|
||||
...(protocolStdin ? [true] : []),
|
||||
]),
|
||||
);
|
||||
this.pid = launched.pid;
|
||||
this.#handle = launched.handle;
|
||||
this.stdin = protocolStdin
|
||||
? new Writable({
|
||||
write: (chunk: unknown, _encoding, callback) => {
|
||||
void this.#writeInput(chunk).then(
|
||||
() => callback(),
|
||||
(cause: unknown) =>
|
||||
callback(
|
||||
cause instanceof Error ? cause : new Error(String(cause)),
|
||||
),
|
||||
);
|
||||
},
|
||||
final: (callback) => {
|
||||
try {
|
||||
this.#authority.call("process_stdin_close", [this.#handle]);
|
||||
callback();
|
||||
} catch (cause: unknown) {
|
||||
callback(
|
||||
cause instanceof Error ? cause : new Error(String(cause)),
|
||||
);
|
||||
}
|
||||
},
|
||||
destroy: (cause, callback) => {
|
||||
try {
|
||||
if (!this.#closed)
|
||||
this.#authority.call("process_stdin_close", [this.#handle]);
|
||||
callback(cause);
|
||||
} catch (failure: unknown) {
|
||||
callback(
|
||||
failure instanceof Error ? failure : new Error(String(failure)),
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
: null;
|
||||
this.#settled = new Promise((resolve) => {
|
||||
this.#settle = resolve;
|
||||
});
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
async #writeInput(chunk: unknown): Promise<void> {
|
||||
const bytes = z.instanceof(Buffer).parse(chunk);
|
||||
const written = z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.parse(
|
||||
await this.#authority.call("process_stdin_write", [
|
||||
this.#handle,
|
||||
bytes,
|
||||
]),
|
||||
);
|
||||
if (written !== bytes.length)
|
||||
throw new Error(
|
||||
"Owned Windows input write did not preserve all requested bytes",
|
||||
);
|
||||
}
|
||||
|
||||
/** Terminate the retained job, never a PID supplied by another caller. */
|
||||
kill(signal?: NodeJS.Signals | number): boolean {
|
||||
if (signal === 0)
|
||||
|
|
@ -184,6 +244,7 @@ export class WindowsOwnedProcess extends EventEmitter {
|
|||
if (this.#timer !== undefined) clearTimeout(this.#timer);
|
||||
this.#authority.call("process_close", [this.#handle]);
|
||||
this.#closed = true;
|
||||
this.stdin?.destroy();
|
||||
this.stdout.end();
|
||||
this.stderr.end();
|
||||
this.#settle?.();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { PassThrough, Writable } from "node:stream";
|
||||
import {
|
||||
serializeMessage,
|
||||
type JSONRPCMessage,
|
||||
|
|
@ -14,7 +14,9 @@ import {
|
|||
class FixtureProcess extends EventEmitter implements ProviderProcessHandle {
|
||||
readonly pid = 1;
|
||||
readonly stdout = new PassThrough();
|
||||
readonly stdin = new PassThrough();
|
||||
constructor(readonly stdin: Writable = new PassThrough()) {
|
||||
super();
|
||||
}
|
||||
readonly stderr = new PassThrough();
|
||||
readonly exitCode = null;
|
||||
readonly signalCode = null;
|
||||
|
|
@ -24,6 +26,48 @@ class FixtureProcess extends EventEmitter implements ProviderProcessHandle {
|
|||
}
|
||||
}
|
||||
|
||||
it("joins pending input errors before detaching the owned protocol stream", async () => {
|
||||
let completeWrite: ((cause: Error) => void) | undefined;
|
||||
const failure = new Error("owned pipe closed during cancellation");
|
||||
const process = new FixtureProcess(
|
||||
new Writable({
|
||||
write: (_chunk, _encoding, callback) => {
|
||||
completeWrite = callback;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const transport = new JadxMcpTransport(
|
||||
{ command: "fixture", arguments: [] },
|
||||
async ({ runId }) => ({
|
||||
process,
|
||||
ownership: { runId, leaderPid: 1, processGroupId: 1 },
|
||||
cleanup: async () => {
|
||||
completeWrite?.(failure);
|
||||
process.emit("exit", 1, null);
|
||||
process.emit("close", 1, null);
|
||||
process.stdout.end();
|
||||
process.stderr.end();
|
||||
return { cleaned: true, signaled: true };
|
||||
},
|
||||
}),
|
||||
);
|
||||
const errors: Error[] = [];
|
||||
transport.onerror = (cause) => errors.push(cause);
|
||||
await transport.start();
|
||||
const pending = transport
|
||||
.send({ jsonrpc: "2.0", id: 1, method: "probe" })
|
||||
.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
expect(completeWrite).toBeTypeOf("function");
|
||||
await transport.close();
|
||||
expect(await pending).toBe(failure);
|
||||
expect(errors).toEqual([failure]);
|
||||
expect(process.stdin.closed).toBe(true);
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it("decodes split UTF-8 and newline bytes, then routes multiple complete frames", async () => {
|
||||
const process = new FixtureProcess();
|
||||
const launcher: JadxLauncher = async ({ runId }) => ({
|
||||
|
|
|
|||
Loading…
Reference in New Issue