Compare commits

...
Author SHA1 Message Date
morluto 655b3eff6f docs(lifecycle): define completion invariants and regression scenarios
Document distinct ownership/readiness/exit facts, late cancellation and atomic publication boundaries, partial child cleanup, and diagnostic retention. Link representative regressions and route contributor guidance through AGENTS.md.

Refs: #4227
2026-10-16 04:30:05 +08:00
morluto e78455338f fix(cli): preserve analysis results when provider cleanup fails
Reuse the existing one-shot finalizer across thirteen command families to retain completed Evidence and primary diagnostics on close failure. Cover success, denied cleanup, and combined primary/cleanup failure through the public CLI.

Refs: #3935
2026-10-16 04:25:42 +08:00
morluto b6d5479ff0 fix(native): honor cancellation after trace finalization
Check cancellation after runtime-root cleanup and retain collected trace observations as partial cancellation evidence.

Refs: #4009
2026-10-16 04:25:25 +08:00
morluto 5f6342db46 fix(native): preserve failed LLDB launch ownership and exit proof
Publish failed-launch process identity and observed exit separately from object validity. Retire only the exact owned process and retain uncertainty when exit cannot be established.

Refs: #4232
2026-10-16 04:25:18 +08:00
morluto 02d92e9830 fix(process): honor cancellation through capture cleanup
Check the request signal after cleanup and before final publication, retaining completed observations under typed cancellation.

Refs: #4005
2026-10-16 04:25:13 +08:00
morluto c4c9a23bef fix(providers): retain composite lifecycle facts across retries
Preserve observed child health with incomplete peer coverage, retry only unresolved clients, and retain all child cleanup recovery actions.

Refs: #4233
2026-10-16 04:24:57 +08:00
morluto 8fd495bf13 fix(hopper): recover late startup and retained cleanup
Authenticate initial and cleanup handshakes through the same identity owner. Expose startup and cleanup activity, retain health after target retirement, and expire abandoned unauthenticated listeners without timing out authenticated work.

Refs: #4227
2026-10-16 04:24:52 +08:00
morluto 760233c0b3 fix(cleanup): preserve owner recovery instructions
Keep cleanup remediation through combined errors and direct or managed one-shot analysis while retaining primary diagnostics.

Refs: #4227
2026-10-16 04:24:45 +08:00
morluto bde139c5d9 test(windows): hold sharing conflict through first close attempt
Keep the delete-sharing-denied reader held until the next event-loop turn and assert close is still pending before releasing it. This distinguishes an internal retry from a holder that releases before cleanup starts.
2026-10-16 03:38:28 +08:00
8b96354497 fix(packaging): distinguish tar line endings from filename bytes
Parse one listing separator so CRLF output matches packaged bridge paths while literal CR bytes in LF listings remain part of filenames. Retain generated-bytecode rejection.

Refs #4154

Co-authored-by: abdooo2235 <abdalrhmantheiv15@gmail.com>
Co-authored-by: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com>
2026-10-16 03:38:24 +08:00
morlutoandjackie_cqz c83de52762 fix(artifacts): classify ZIP offsets beyond EOF as malformed
Reject negative bounded reads before allocation while retaining short-read and exact-EOF behavior. Exercise the corrupted central-directory offset through the public CLI.

Refs #4006, #3953

Co-authored-by: jackie_cqz <88996311+jackie-cqz@users.noreply.github.com>
2026-10-16 03:38:20 +08:00
morlutoandVinícius DS Andrade 0ccc7572c7 fix(ida): share string-page failure interpretation
Preserve nullable producer errors in the shared page contract and reject explicit failures before returning matches. Retain both successful and failed replies as partial evidence.

Refs #4151, #3990

Co-authored-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>
2026-10-16 03:38:03 +08:00
98f22358e6 fix(sqlite): preserve snapshot admission failure semantics
Refuse oversized database and WAL inputs before acquisition with exact observed bytes and export guidance. Keep initial path failures separate from file-set changes and retain cleanup and partial observations on late growth.

Refs #4208, #4188, #4141, #4127

Co-authored-by: hima <1ep23ic023@eastpoint.ac.in>
Co-authored-by: Royce <roycelam@umich.edu>
2026-10-16 03:37:54 +08:00
65 changed files with 1980 additions and 359 deletions

No files matched your search

+2
View File
@@ -20,6 +20,8 @@ Give each interpretation and resource a clear owner. Consumers must use that own
Keep proof of ownership separate from validity, editability, and readiness. Malformed owned data may still be safely removable. Unresolved identity or applicability must remain unknown rather than be treated as absent, foreign, or aligned.
For lifecycle changes, follow [ownership and completion invariants](docs/tool-design.md#own-the-lifecycle) and select the applicable [lifecycle regressions](docs/testing.md#lifecycle-regressions). Include late handshakes, cancellation during final cleanup, mixed child outcomes, and cleanup retry where those stages exist; active-execution cancellation alone does not cover finalization.
Assess brittleness in touched code during implementation, debugging, review, and maintenance. Fix the producing representation or ownership rule before adding downstream repairs. Normalize once at the owning boundary using format-aware parsers. Remove superseded representations, aliases, wrappers, and redundant tests after verifying affected consumers. Introduce abstractions only when they remove observed complexity.
When fixing a bug, inspect the producing boundary and affected consumers for duplicated interpretation or unclear ownership. Complete any directly related refactor in the same change and remove superseded code. Passing regression tests alone does not establish that the brittleness is resolved. If related cleanup remains, explain what remains and why.
+9 -1
View File
@@ -1057,6 +1057,10 @@ def _serve_connection(connection):
):
raise PermissionError("Invalid bridge capability")
authenticated = True
# The connection grace period only bounds unauthenticated idle
# clients. Native analysis and a connected owner's idle time
# remain unbounded after capability validation.
connection.settimeout(None)
_write_message(file, {"id": request_id, "event": {
"type": "progress",
"phase": "hopper_bridge",
@@ -1148,13 +1152,17 @@ def _run():
server.bind(REA_SOCKET)
os.chmod(REA_SOCKET, 0o600)
server.listen(1)
# A bridge can start after its caller's startup/cleanup deadline. Bound
# the first accept as well as reconnects so an orphan cannot pin Hopper's
# dedicated Python thread and prevent later documents from starting REA.
server.settimeout(REA_RECONNECT_TIMEOUT_SECONDS)
try:
while True:
try:
connection, _ = server.accept()
except socket.timeout:
break
connection.settimeout(None)
connection.settimeout(REA_RECONNECT_TIMEOUT_SECONDS)
if _serve_connection(connection):
break
# Match REA's cleanup handshake budget without pinning Hopper's
+48 -4
View File
@@ -555,6 +555,38 @@ def _failed_launch_description(process):
return None
def _retire_failed_launch(process, config, listener, stdout, stderr):
"""Reconcile an SBProcess returned alongside a failed Launch error."""
if not process.IsValid():
stdout.close()
stderr.close()
return False
pid = process.GetProcessID()
if pid > 0:
# Even an already-exited process needs its PID published so the host
# can distinguish a completed launch failure from an unknown target.
with open(config["pid_path"], "w") as handle:
handle.write(str(pid))
if process.GetState() != lldb.eStateExited:
stdout.mark_launched()
stderr.mark_launched()
if pid > 0:
# Hold a surviving target until the host has captured its start
# identity. If that proof is unavailable, Kill still addresses
# this exact SBProcess created by the current launch request.
_wait_for_identity_ack(config)
process.Kill()
_wait_for_exit(listener, process, 5)
exit_observed = process.GetState() == lldb.eStateExited
expected_exit = exit_observed
stdout.close(expected_exit=expected_exit)
stderr.close(expected_exit=expected_exit)
return exit_observed
def trace(debugger, config):
global _TRACE_CONTEXT
debugger.SetAsync(True)
@@ -630,8 +662,9 @@ def trace(debugger, config):
)
process = target.Launch(_launch_info(target, config, listener), error)
if not error.Success() or not process.IsValid():
stdout.close()
stderr.close()
exit_observed = _retire_failed_launch(
process, config, listener, stdout, stderr
)
return {
"status": "launch-error",
"error": error.GetCString(),
@@ -640,15 +673,26 @@ def trace(debugger, config):
if process.IsValid()
else {}
),
**(
{"target_process_exit_observed": True}
if exit_observed
else {}
),
}
launch_failure = _failed_launch_description(process)
if launch_failure is not None:
stdout.close(expected_exit=True)
stderr.close(expected_exit=True)
exit_observed = _retire_failed_launch(
process, config, listener, stdout, stderr
)
return {
"status": "launch-error",
"error": launch_failure,
"target_process_created": True,
**(
{"target_process_exit_observed": True}
if exit_observed
else {}
),
}
stdout.mark_launched()
stderr.mark_launched()
+132
View File
@@ -15,6 +15,8 @@ fake_lldb = sys.modules.setdefault("lldb", types.ModuleType("lldb"))
fake_lldb.LLDB_INVALID_ADDRESS = -1
fake_lldb.eLaunchFlagDebug = 2
fake_lldb.eLaunchFlagStopAtEntry = 4
fake_lldb.eStateExited = 10
fake_lldb.SBEvent = lambda: object()
spec = importlib.util.spec_from_file_location(
"rea_lldb_tracer", os.path.join(os.path.dirname(__file__), "rea_lldb_tracer.py")
)
@@ -23,6 +25,136 @@ spec.loader.exec_module(tracer)
class NativeTraceBudgetTests(unittest.TestCase):
def test_failed_launch_does_not_infer_exit_from_a_failed_kill(self):
class Process:
def IsValid(self):
return True
def GetProcessID(self):
return 12345
def GetState(self):
return 6
def Kill(self):
return None
class Sink:
close_exit = None
def mark_launched(self):
pass
def close(self, expected_exit=False):
self.close_exit = expected_exit
with tempfile.TemporaryDirectory() as directory:
stdout, stderr = Sink(), Sink()
with patch.object(tracer, "_wait_for_identity_ack", return_value="unavailable"), patch.object(
tracer, "_wait_for_exit", return_value=None
):
exited = tracer._retire_failed_launch(
Process(), {"pid_path": os.path.join(directory, "pid")}, object(), stdout, stderr
)
self.assertFalse(exited)
self.assertFalse(stdout.close_exit)
self.assertFalse(stderr.close_exit)
def test_failed_launch_publishes_pid_and_retires_live_process(self):
class Process:
state = 6
killed = False
def IsValid(self):
return True
def GetProcessID(self):
return 12345
def GetState(self):
return self.state
def Kill(self):
self.killed = True
self.state = tracer.lldb.eStateExited
class Sink:
launched = False
close_exit = None
def mark_launched(self):
self.launched = True
def close(self, expected_exit=False):
self.close_exit = expected_exit
with tempfile.TemporaryDirectory() as directory:
pid_path = os.path.join(directory, "pid")
process = Process()
stdout, stderr = Sink(), Sink()
def acknowledge(config):
with open(config["pid_path"], "r") as pid_file:
self.assertEqual(pid_file.read(), "12345")
return "readable"
with patch.object(tracer, "_wait_for_identity_ack", acknowledge):
exited = tracer._retire_failed_launch(
process,
{"pid_path": pid_path},
object(),
stdout,
stderr,
)
self.assertTrue(exited)
self.assertTrue(process.killed)
self.assertTrue(stdout.launched)
self.assertTrue(stderr.launched)
self.assertTrue(stdout.close_exit)
self.assertTrue(stderr.close_exit)
def test_failed_launch_records_when_valid_process_is_already_exited(self):
class Process:
def IsValid(self):
return True
def GetProcessID(self):
return 12345
def GetState(self):
return tracer.lldb.eStateExited
def Kill(self):
raise AssertionError("an exited process must not be killed")
class Sink:
close_exit = None
def mark_launched(self):
raise AssertionError("an exited process has no output to drain")
def close(self, expected_exit=False):
self.close_exit = expected_exit
with tempfile.TemporaryDirectory() as directory:
pid_path = os.path.join(directory, "pid")
stdout, stderr = Sink(), Sink()
with patch.object(
tracer,
"_wait_for_identity_ack",
side_effect=AssertionError("exit is already observed"),
):
exited = tracer._retire_failed_launch(
Process(), {"pid_path": pid_path}, object(), stdout, stderr
)
with open(pid_path, "r") as pid_file:
self.assertEqual(pid_file.read(), "12345")
self.assertTrue(exited)
self.assertTrue(stdout.close_exit)
self.assertTrue(stderr.close_exit)
def test_pre_target_integrity_failure_reports_no_process_created(self):
original_debugger = getattr(tracer.lldb, "SBDebugger", None)
+22 -6
View File
@@ -635,6 +635,11 @@ bridge readiness. For large targets, set `REA_HOPPER_STARTUP_TIMEOUT_MS` to a
decimal integer from 1 to 2,147,483,647 milliseconds in the environment that
starts REA. CLI and MCP use the same setting; invalid values fail configuration
validation. An MCP client's own request timeout must also allow enough time.
`open_binary` validates and selects a target without waiting for native provider
readiness. During Hopper startup, `binary_session.provider_operation_health`
reports `busy` at stage `launch`; `analysis_activity` describes bridge requests
and can remain idle until the socket is ready. Cleanup work reports `busy` at
stage `connection`.
If macOS denies Apple Events, allow the application that starts REA under
**System Settings → Privacy & Security → Automation**, then retry. REA releases
@@ -644,9 +649,12 @@ Arguments containing quotes, backslashes, or line breaks remain unchanged but
cannot establish that a failed handoff opened no document. Other failed handoffs
retain ownership until document closure is confirmed. If a timed-out bridge
becomes ready later, `close_binary` connects to that run, authenticates its
identity, and requests document shutdown. After a connection drops, the bridge
allows 30 seconds for an authenticated cleanup reconnect, then releases Hopper's
Python execution thread. Expiry does not establish document closure, so REA
identity, and requests document shutdown. Once its listener is ready, the bridge
allows 30 seconds for the first connection and capability validation. After a
connection drops, it allows the same grace period for an authenticated cleanup
reconnect, then releases Hopper's Python execution thread. Initial analysis is
outside this listener grace period, and authenticated native work is not bounded
by it. Expiry does not establish document closure, so REA
retains unconfirmed ownership. Retry close while the bridge remains available.
If a Linux launcher exists but cannot start, inspect its shared libraries:
@@ -736,9 +744,17 @@ Once document and process closure are confirmed, the lease can be released even
if temporary-file cleanup fails. Another client can then launch, but the owning
client must finish its retained cleanup before starting again.
An unconfirmed external document can be retried while its authenticated bridge
remains connected. If that bridge has disconnected, another close cannot confirm
the document: inspect and close the reported document in Hopper before ending
the owning REA connection.
remains available. The same authentication and process-identity capture apply
when the first successful handshake occurs during cleanup after a startup
timeout. REA can confirm closure if that exact authenticated process exits,
including while a shutdown request is in flight. A helper exit alone is
insufficient, and an application that never authenticated has no verified
process identity. If no matching bridge becomes available, inspect and close
the reported document in Hopper before ending the owning REA connection.
Export the Evidence bundle before `close_binary` clears session records.
`binary_session.cleanup_pending` remains true while provider cleanup is retained,
and provider health remains visible even with `open: false`; that flag alone
does not establish successful cleanup.
### Hopper in CI
+11
View File
@@ -402,6 +402,13 @@ is `unavailable` with a reason when ownership could not be revalidated, or
at that bounded check. These are historical snapshots, not live process
inventories, and do not claim that no short-lived descendant existed.
`binary_session.cleanup_pending` reports whether the session retains a provider
client whose cleanup has not succeeded. It can be true with `open: false`;
target retirement does not establish resource release. Health, activity, and
lineage remain available from that retained client where the provider supplies
them. Follow the cleanup error's reported resources and recovery action before
reopening; a missing health observation does not establish an idle provider.
## Progress and cancellation
REA accepts ordinary `tools/call` progress tokens. The numeric `progress` counts
@@ -428,6 +435,10 @@ Native call tracing and process capture retain available observations in
`details.partial_observation` on failure, including when cleanup succeeds.
The observation reports its partial coverage; cleanup details describe host
state separately from the execution failure.
For these tools, cancellation observed during result finalization or owned
cleanup also returns typed cancellation with the available observations.
A normal target exit does not establish request completion. Incomplete cleanup
still takes precedence and retains its resource diagnostics.
A process capture that fails during a finalization interval keeps every signal
attempt and its delivery result in `details.partial_observation`, with
`elapsed_ms` and `exit_code` null when no exit was observed.
+5
View File
@@ -451,6 +451,11 @@ The run ends when the process exits, `max_events` entries are recorded,
Bounded termination reports partial coverage. REA checks process termination
using a launch-time OS start identity; if ownership or cleanup cannot be
verified, the result or error preserves that uncertainty. The result reports:
Launch errors keep the fact that LLDB created a process separate from whether
it remains alive. An exited `SBProcess` is reported as exited only after LLDB
confirms that state; unresolved ownership remains retained for cleanup retry.
Cancellation observed while output and Evidence are finalized returns partial
cancellation Evidence, even if the target has already exited.
- the outcome, exit status and captured stdout/stderr (1 MiB kept per stream
while excess bytes are drained and counted without growing capture files).
+8
View File
@@ -105,6 +105,14 @@ overall native heap and total resident memory are not capped. Row limits do not
override these byte and resource limits. Resource-limit failures do not return
an incomplete schema as successful complete evidence.
If the combined database and WAL exceed the snapshot budget, the refusal is a
`resource_constraint` and reports both the configured byte limit and the exact
observed combined size as a decimal string. The remediation directs callers to
create a smaller consistent offline snapshot containing only relevant data
through the application's supported export workflow. A generic backup or copy
does not guarantee a smaller result; REA does not truncate or modify the
selected source.
MCP also applies its configured response budget to the complete Evidence
envelope. If delivery exceeds that budget, it reports a transport constraint
with guidance for exporting retained Evidence, requesting a matching larger
+38
View File
@@ -199,6 +199,44 @@ Measure slow cases before pruning capacity coverage. Retain inputs beyond the
formerly failing size/depth and distinguish cold runs, warm builds, coverage,
and toolchain conditions when comparing timings.
## Lifecycle regressions
For changes to resource acquisition, readiness, cancellation, or cleanup, select
the affected owners and applicable scenarios below. Include temporary snapshots
and one-shot commands. Use public CLI/MCP workflows where practical and a
controlled production seam for interleavings that cannot be reproduced reliably.
| Scenario | Assertion and control |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Target admitted; startup still pending | Status preserves the readiness distinction and observed startup activity; readiness requires the provider's documented proof. |
| First handshake is late, absent, invalid, or recovered during close | Initial and recovery paths retain the same authenticated identity facts. Foreign run/token controls cannot authorize cleanup; authenticated idle/native work survives the unauthenticated listener deadline. |
| Launch returns an error with a valid process handle | Cover already-exited and still-live owned processes. Verify observed exit separately from creation; failed termination or unavailable identity remains unresolved. |
| Cancellation arrives after workload exit, during result reads or temporary-root removal | Return typed cancellation with available partial observations and actual cleanup status. The non-aborted control succeeds; existing primary/cleanup failures keep their precedence. |
| Children have mixed health or cleanup outcomes | Missing peer health does not erase an observed failure or activity. Idle plus unknown stays unknown. Retry a failed child without closing a successful peer again, including after a close-option change. |
| Cleanup fails after success or primary failure | Public output retains the completed observation or primary diagnostic, remaining resources, and owner recovery action. Restore the denied resource and verify the owning cleanup retry. |
| Target exits without an acknowledgement, or transport disappears while it stays alive | Positive owned exit proof resolves target lifetime; other resources still need cleanup. Lost transport alone does not prove exit. Verify unrelated/attached targets survive and same-connection recovery works when supported. |
Synchronize cancellation with the finalization boundary being tested; a fixed
delay after dispatch does not establish that phase. Use markers, barriers, or
owned cleanup seams while preserving real cleanup where feasible. Check server
settlement and retained resources even if the MCP SDK rejects the client promise
before the handler finishes. A cancellation during active execution does not
cover cancellation during final cleanup.
For atomic writes, exercise cancellation on both sides of the documented commit
point and preserve any publication receipt. A late interruption does not prove
that no output was published.
Representative regressions include
[process cleanup-time SIGINT](https://github.com/morluto/rea/blob/e78455338fe2d06c4cd03d59cbf33473d754998b/tests/boundary/process/processCaptureCancellationAdapters.test.ts),
[late authenticated cleanup](https://github.com/morluto/rea/blob/e78455338fe2d06c4cd03d59cbf33473d754998b/tests/boundary/providers/hopper/hopperClient.handshakeRecovery.test.ts),
[LLDB launch and finalization](https://github.com/morluto/rea/blob/e78455338fe2d06c4cd03d59cbf33473d754998b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts),
[composed child ownership](https://github.com/morluto/rea/blob/e78455338fe2d06c4cd03d59cbf33473d754998b/src/application/binary/CompositeProvider.test.ts),
and [public CLI cleanup denial](https://github.com/morluto/rea/blob/e78455338fe2d06c4cd03d59cbf33473d754998b/tests/boundary/cli/wasmCleanupCli.test.ts).
These controlled probes do not replace the matching real-provider lane. Record
the base failure, passing control, exact host/provider, verified cleanup, and
unavailable coverage; a missing engine is not a pass.
## Behavioral depths
| Depth | Location | What it proves |
+39
View File
@@ -68,6 +68,45 @@ satisfy. A plan without execution does not establish runtime behavior.
constraints. Report pagination, truncation, cancellation, and partial failure.
An assumed agent budget is insufficient justification for a limit.
## Own the lifecycle
For tools that acquire resources, identify the owner and the observations that
establish each transition. Include temporary files and one-shot commands, as
well as persistent engine sessions. Use the existing provider contracts;
providers need not expose identical state machines.
- Keep target admission, provider readiness, active work, observed process exit,
and resource release separate. A valid process handle, successful launcher,
or selected target establishes only its own fact. Publish available identity
and exit observations even when launch fails; retain unknown ownership.
- Authenticate and capture identity through the same boundary on initial and
recovery handshakes. Bound abandoned unauthenticated listeners where the
protocol permits it; that deadline must not expire authenticated idle sessions
or long-running native work. Listener expiry does not prove target closure.
- For cancellable observations, carry cancellation through result consumption
and awaited cleanup. Before returning success, check the signal after the
last awaited finalization step.
Retain collected observations under typed cancellation. Preserve an existing
primary failure and let incomplete cleanup retain its own classification.
Client cancellation alone does not prove native work stopped.
For atomic publication, document the cancellation cutoff and preserve any
committed output's publication receipt.
- Release only owned resources. Attaching owns a connection or session unless
the provider contract explicitly establishes target ownership. A helper exit,
missing reply, or missing PID does not establish target exit.
- Retain successful child cleanup and retry only unresolved children, including
when close options change. Preserve observed child failure or activity when
another child's health is unavailable; idle plus missing coverage stays
unknown.
- Await one-shot cleanup without discarding the completed result or primary
diagnostic. Preserve partial observations, remaining resources, and the cleanup
owner's recovery instructions. CLI adapters should reuse
[`finishAnalysisCommand`](https://github.com/morluto/rea/blob/main/src/cli/finishAnalysisCommand.ts) where its contract
applies rather than duplicate close/finally handling.
Exercise the applicable [lifecycle regression scenarios](testing.md#lifecycle-regressions)
through the producing owner and affected CLI/MCP consumers.
## Implement and evaluate
Put provider protocols/parsing in adapters and shared CLI/MCP workflows in the
+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,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts",
"src/{apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts",
],
},
{
+4 -1
View File
@@ -17,7 +17,10 @@ export async function verifyPackagePack({ root, workspace }) {
})
).stdout.trim(),
);
const packedFiles = (await exec("tar", ["-tf", tarball])).stdout.split("\n");
const listing = (await exec("tar", ["-tf", tarball])).stdout;
// A bare LF identifies LF output; preceding CRs then belong to filenames.
const lineEnding = /(^|[^\r])\n/u.test(listing) ? "\n" : "\r\n";
const packedFiles = listing.split(lineEnding);
const packedManifest = JSON.parse(
(await exec("tar", ["-xOf", tarball, "package/package.json"])).stdout,
);
+14 -3
View File
@@ -616,10 +616,21 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe
try {
await transientRuntime.snapshot(source, "snapshot.bin");
transientHolder = await startDeleteSharingDeniedReader(transientHeldPath);
// The first native close is synchronous and must fail while this reader
// is held. Releasing it while the owner waits allows its own retry to
// complete without the caller issuing another close.
// Keep the reader held through the first close attempt. Without retries,
// close rejects before the next event-loop turn; with retries, it remains
// pending until the caller releases this sharing conflict.
let closeSettled = false;
const closing = transientRuntime.close();
void closing.then(
() => {
closeSettled = true;
},
() => {
closeSettled = true;
},
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(closeSettled, false);
transientHolder.child.stdin.end("\n");
const [, [exitCode]] = await Promise.all([
closing,
+2
View File
@@ -46,6 +46,7 @@ export const directAnalysisCleanupFailure = (
...primary.data,
code: "cleanup_incomplete",
retryable: false,
remediation: cleanup.remediation,
details: {
...primary.data.details,
cleanup: "incomplete",
@@ -86,6 +87,7 @@ export const managedAnalysisCleanupFailure = (
"close_binary",
{
cause: error,
remediationAction: cleanup.remediation.action,
cleanup: { reason: cleanup.message, resources: error.cleanupResources },
diagnostics: {
cleanup_error: cleanup,
+5 -6
View File
@@ -596,18 +596,17 @@ export class BinarySession
/** Describe the active binary session. */
status(): JsonValue {
const client = this.#active?.client ?? this.#pendingCleanup?.client;
return binarySessionStatus({
target: this.#active?.target,
route: this.#currentRoute(),
router: this.#providerRouter,
runtimeUnavailability: this.#runtimeUnavailability,
runId: this.#active?.runId,
runtimeLineageSnapshots:
this.#active?.client.runtimeLineageSnapshots?.() ?? [],
requestActivitySnapshots:
this.#active?.client.requestActivitySnapshots?.() ?? [],
providerOperationHealth:
this.#active?.client.operationHealthSnapshot?.() ?? null,
runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [],
requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [],
providerOperationHealth: client?.operationHealthSnapshot?.() ?? null,
cleanupPending: this.#pendingCleanup !== undefined,
});
}
@@ -30,6 +30,7 @@ interface BinarySessionStatusInput {
readonly runtimeLineageSnapshots: readonly ProviderRuntimeLineageSnapshot[];
readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[];
readonly providerOperationHealth?: ProviderOperationHealth | null;
readonly cleanupPending?: boolean;
}
/** Project internal provider routing state into the caller-visible session status. */
@@ -42,6 +43,7 @@ export const binarySessionStatus = ({
runtimeLineageSnapshots,
requestActivitySnapshots,
providerOperationHealth,
cleanupPending = false,
}: BinarySessionStatusInput): JsonValue => {
const routeCapabilities = [...route.capabilities.values()]
.filter(({ operation }) => operation !== "inventory_artifact")
@@ -84,6 +86,7 @@ export const binarySessionStatus = ({
),
}));
const common = {
cleanup_pending: cleanupPending,
capabilities,
analysis_provider_binding: analysisProviderBinding,
analysis_provider_candidates: analysisProviderCandidates,
@@ -9,6 +9,7 @@ import {
type ProviderIdentity,
} from "../AnalysisProvider.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { err, ok } from "../../domain/result.js";
import { CompositeProvider } from "./CompositeProvider.js";
@@ -44,10 +45,15 @@ const failingProvider = (
resource: string,
) => {
const providerIdentity = identity(id);
const cleanupError = new ProviderCleanupError(id, [resource], {
reason: `${id} cleanup is unresolved`,
child_marker: `${id}-detail`,
});
const cleanupError = new ProviderCleanupError(
id,
[resource],
{
reason: `${id} cleanup is unresolved`,
child_marker: `${id}-detail`,
},
{ remediationAction: `Resolve ${resource} before retrying ${id}.` },
);
const close = vi.fn(async () => err(cleanupError));
const provider: AnalysisProvider = {
identity: () => providerIdentity,
@@ -98,6 +104,13 @@ it("retains every failed child resource across composite cleanup retries", async
},
});
expect(closed.error).toBeInstanceOf(ProviderCleanupError);
const projected = projectAnalysisError(closed.error);
expect(projected.remediation.action).toContain(
"Resolve left-db before retrying provider-left.",
);
expect(projected.remediation.action).toContain(
"Resolve right-db before retrying provider-right.",
);
expect(closed.error.diagnostics).toMatchObject({
failures: [
{
@@ -127,3 +140,97 @@ it("preserves one child's typed cleanup error unchanged", async () => {
if (closed.ok) throw new Error("Expected child cleanup failure");
expect(closed.error).toBe(child.cleanupError);
});
it("does not close a child again after its cleanup succeeded", async () => {
const firstIdentity = identity("provider-first");
const secondIdentity = identity("provider-second");
let secondCloseAttempts = 0;
const firstClose = vi.fn(async () => ok(null));
const secondClose = vi.fn(async () => {
secondCloseAttempts += 1;
return secondCloseAttempts === 1
? err(new ProviderCleanupError("provider-second", ["second-db"], {}))
: ok(null);
});
const makeProvider = (
providerIdentity: ProviderIdentity,
operation: Exclude<AnalysisOperation, "health">,
close: AnalysisClient["close"],
): AnalysisProvider => ({
identity: () => providerIdentity,
capabilities: () => [operationCapability(providerIdentity, operation)],
createClient: () => ({
execute: async () => ok(createAnalysisExecution(null, providerIdentity)),
close,
}),
});
const client = new CompositeProvider([
makeProvider(firstIdentity, "address_name", firstClose),
makeProvider(secondIdentity, "procedure_info", secondClose),
]).createClient(executableTarget);
await client.execute("address_name", {});
await client.execute("procedure_info", {});
expect((await client.close({ retainDocument: true })).ok).toBe(false);
expect((await client.close()).ok).toBe(true);
expect(firstClose).toHaveBeenCalledTimes(1);
expect(secondClose).toHaveBeenCalledTimes(2);
expect((await client.close({ retainDocument: true })).ok).toBe(true);
expect(firstClose).toHaveBeenCalledTimes(1);
expect(secondClose).toHaveBeenCalledTimes(2);
});
it.each(["busy", "unknown", "idle", undefined] as const)(
"keeps partial health truthful with child state %s and an unobserved peer",
async (state) => {
const observedIdentity = identity("provider-observed");
const unobservedIdentity = identity("provider-unobserved");
const observed: AnalysisProvider = {
identity: () => observedIdentity,
capabilities: () => [
operationCapability(observedIdentity, "address_name"),
],
createClient: () => ({
execute: async () =>
ok(createAnalysisExecution(null, observedIdentity)),
operationHealthSnapshot: () =>
state === undefined
? undefined
: {
state,
stage: state === "idle" ? null : "analysis",
retryAction:
state === "busy"
? "wait"
: state === "idle"
? null
: "unknown",
exitCode: null,
requests: [],
},
close: async () => ok(null),
}),
};
const unobserved: AnalysisProvider = {
identity: () => unobservedIdentity,
capabilities: () => [
operationCapability(unobservedIdentity, "procedure_info"),
],
createClient: () => ({
execute: async () =>
ok(createAnalysisExecution(null, unobservedIdentity)),
close: async () => ok(null),
}),
};
const client = new CompositeProvider([observed, unobserved]).createClient(
executableTarget,
);
await client.execute("address_name", {});
await client.execute("procedure_info", {});
if (state === "idle" || state === undefined)
expect(client.operationHealthSnapshot?.()).toBeUndefined();
else expect(client.operationHealthSnapshot?.()).toMatchObject({ state });
expect((await client.close()).ok).toBe(true);
},
);
+35 -16
View File
@@ -1,6 +1,7 @@
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { err, ok } from "../../domain/result.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
@@ -27,18 +28,20 @@ const compositeProviderIdentity = (
version: null,
});
const closeCompositeClients =
(
identity: ProviderIdentity,
clients: ReadonlyMap<AnalysisProvider, AnalysisClient>,
): AnalysisClient["close"] =>
async (options) => {
const closeCompositeClients = (
identity: ProviderIdentity,
clients: ReadonlyMap<AnalysisProvider, AnalysisClient>,
): AnalysisClient["close"] => {
const completed = new Set<AnalysisClient>();
return async (options) => {
const outcomes = await Promise.all(
[...clients.entries()].map(([provider, client]) =>
closeAnalysisClient(client, provider.identity().id, options).then(
(result) => ({ providerId: provider.identity().id, result }),
),
),
[...clients.entries()].map(async ([provider, client]) => {
const providerId = provider.identity().id;
if (completed.has(client)) return { providerId, result: ok(null) };
const result = await closeAnalysisClient(client, providerId, options);
if (result.ok) completed.add(client);
return { providerId, result };
}),
);
const failures = outcomes.flatMap(({ providerId, result }) =>
result.ok ? [] : [{ providerId, error: result.error }],
@@ -68,6 +71,13 @@ const closeCompositeClients =
})),
},
{
remediationAction: [
...new Set(
failures.map(
({ error }) => projectAnalysisError(error).remediation.action,
),
),
].join("\n"),
cause: new AggregateError(
failures.map(({ error }) => error),
"Composed provider cleanup failed.",
@@ -76,6 +86,7 @@ const closeCompositeClients =
),
);
};
};
const executeCompositeHealth = async (
identity: ProviderIdentity,
@@ -213,13 +224,21 @@ export class CompositeProvider implements AnalysisProvider {
const snapshots = [...clients.values()].map((client) =>
client.operationHealthSnapshot?.(),
);
const failed = snapshots.find(
(snapshot) =>
snapshot !== undefined &&
(snapshot.state === "exited" ||
snapshot.state === "unreachable" ||
snapshot.state === "not_started"),
);
if (failed !== undefined) return failed;
const nonIdle = snapshots.find(
(snapshot) => snapshot !== undefined && snapshot.state !== "idle",
);
if (nonIdle !== undefined) return nonIdle;
if (snapshots.some((snapshot) => snapshot === undefined))
return undefined;
return (
snapshots.find(
(snapshot) => snapshot !== undefined && snapshot.state !== "idle",
) ?? snapshots[0]
);
return snapshots[0];
},
close,
};
+7
View File
@@ -127,6 +127,13 @@ class NodeFileReader extends Reader<string> {
const handle = this.#handle;
if (handle === undefined) throw new Error("ZIP reader is not initialized");
const readBytes = Math.min(length, this.size - index);
// A central-directory offset can point past EOF. Type that malformed ZIP
// read before a negative size reaches Buffer.alloc and becomes an I/O error.
if (readBytes < 0)
throw new ArtifactReaderFailure(
"format",
`ZIP archive is malformed: read at ${index} extends beyond the ${this.size}-byte archive`,
);
if (
this.maximumReadBytes !== undefined &&
readBytes > this.maximumReadBytes
+9 -13
View File
@@ -1,16 +1,15 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AdbOperation } from "../domain/adb/adbDeviceAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Expose the same explicit ADB requests through one-shot CLI commands. */
export const registerAdbCommands = (
@@ -24,17 +23,14 @@ export const registerAdbCommands = (
const service = new AdbDeviceAnalysisService(provider);
const execute = (name: string, operation: AdbOperation, input: unknown) =>
withCommandCancellation((signal) =>
logCliCommand(logger, name, async () => {
const result = await service.execute(operation, input, { signal });
try {
await provider.close();
} catch (cause) {
if (cause instanceof AnalysisError)
return projectAnalysisError(cause);
throw cause;
}
return result.ok ? result.value : projectAnalysisError(result.error);
}),
logCliCommand(logger, name, () =>
finishAnalysisCommand(
() => service.execute(operation, input, { signal }),
{ close: () => provider.close() },
"adb",
operation,
),
),
);
const serial = z
+9 -13
View File
@@ -1,14 +1,13 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js";
import { createAndroidAnalysisProvider } from "../composition/android.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Expose the same explicit APK requests through one-shot CLI commands. */
export const registerAndroidCommands = (
@@ -20,17 +19,14 @@ export const registerAndroidCommands = (
const service = new AndroidAnalysisService(provider);
const execute = (name: string, operation: AndroidOperation, input: unknown) =>
withCommandCancellation((signal) =>
logCliCommand(logger, name, async () => {
const result = await service.execute(operation, input, { signal });
try {
await provider.close();
} catch (cause) {
if (cause instanceof AnalysisError)
return projectAnalysisError(cause);
throw cause;
}
return result.ok ? result.value : projectAnalysisError(result.error);
}),
logCliCommand(logger, name, () =>
finishAnalysisCommand(
() => service.execute(operation, input, { signal }),
{ close: () => provider.close() },
"jadx",
operation,
),
),
);
const path = z.string().describe("Local standalone APK path");
const className = z.string().describe("Exact fully qualified class name");
+9 -13
View File
@@ -1,11 +1,9 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
apktoolInputSchemas,
type ApktoolOperation,
@@ -13,6 +11,7 @@ import {
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Expose the same explicit Apktool requests through one-shot CLI commands. */
export const registerApktoolCommands = (
@@ -26,17 +25,14 @@ export const registerApktoolCommands = (
const service = new ApktoolResourceAnalysisService(provider);
const execute = (name: string, operation: ApktoolOperation, input: unknown) =>
withCommandCancellation((signal) =>
logCliCommand(logger, name, async () => {
const result = await service.execute(operation, input, { signal });
try {
await provider.close();
} catch (cause) {
if (cause instanceof AnalysisError)
return projectAnalysisError(cause);
throw cause;
}
return result.ok ? result.value : projectAnalysisError(result.error);
}),
logCliCommand(logger, name, () =>
finishAnalysisCommand(
() => service.execute(operation, input, { signal }),
{ close: () => provider.close() },
"apktool",
operation,
),
),
);
cli.command(CLI_COMMANDS.inspectApktoolClient, {
+13 -18
View File
@@ -1,11 +1,11 @@
import { z } from "incur";
import { evmInterfaceModule } from "../composition/evm.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Resolve operator paths and preserve explicit encoding through the shared workflow. */
export const registerEvmCommands = (
@@ -23,27 +23,22 @@ export const registerEvmCommands = (
.describe("Selected carrier representation"),
}),
run: ({ args }) =>
withCommandCancellation(async (signal) => {
try {
return await logCliCommand(
logger,
evmInterfaceModule.cliCommand,
async () => {
const result = await service.inspect(
withCommandCancellation((signal) =>
logCliCommand(logger, evmInterfaceModule.cliCommand, () =>
finishAnalysisCommand(
() =>
service.inspect(
{
path: absoluteCallerPath(args.path),
encoding: args.encoding,
},
{ signal },
);
return result.ok
? result.value
: projectAnalysisError(result.error);
},
);
} finally {
await service.close();
}
}),
),
service,
"evmole-interface",
"inspect_evm_interface",
),
),
),
});
};
+9 -9
View File
@@ -5,12 +5,12 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { firmwareInputSchemas } from "../domain/firmware/firmwareAnalysis.js";
import { localPathStringSchema } from "../domain/localPath.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { FirmwareOperation } from "../domain/firmware/firmwareAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** One-shot firmware CLI commands backed by the same workflows as MCP. */
export const registerFirmwareCommands = (
@@ -29,14 +29,14 @@ export const registerFirmwareCommands = (
input: unknown,
) =>
withCommandCancellation((signal) =>
logCliCommand(logger, name, async () => {
try {
const result = await service.execute(operation, input, { signal });
return result.ok ? result.value : projectAnalysisError(result.error);
} finally {
await service.close();
}
}),
logCliCommand(logger, name, () =>
finishAnalysisCommand(
() => service.execute(operation, input, { signal }),
service,
operation === "inspect_firmware_regions" ? "binwalk" : "unblob",
operation,
),
),
);
const path = localPathStringSchema.describe(
"Local firmware path; never executed",
+14 -19
View File
@@ -1,15 +1,14 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js";
import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js";
import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { FlutterOperation } from "../domain/flutter/flutterBuildAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** One flutter command: CLI shape and the request it produces. */
interface FlutterCommandSpec {
@@ -74,23 +73,19 @@ export const registerFlutterCommands = (
: { args: spec.argumentSchema, options: spec.optionSchema }),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, spec.command, async () => {
const settled = await new FlutterBuildAnalysisService(
provider,
).execute(spec.operation, spec.toInput(args, options ?? {}), {
signal,
});
try {
await provider.close();
} catch (cause) {
if (cause instanceof AnalysisError)
return projectAnalysisError(cause);
throw cause;
}
return settled.ok
? settled.value
: projectAnalysisError(settled.error);
}),
logCliCommand(logger, spec.command, () =>
finishAnalysisCommand(
() =>
new FlutterBuildAnalysisService(provider).execute(
spec.operation,
spec.toInput(args, options ?? {}),
{ signal },
),
{ close: () => provider.close() },
"flutter",
spec.operation,
),
),
),
});
}
+13 -18
View File
@@ -3,10 +3,10 @@ import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptR
import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Share source recovery with MCP while keeping CLI spelling at this boundary. */
export const registerJavaScriptRecoveryCommands = (
@@ -47,13 +47,11 @@ export const registerJavaScriptRecoveryCommands = (
),
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
try {
return await logCliCommand(
logger,
CLI_COMMANDS.recoverJavaScriptSources,
async () => {
const result = await service.recover(
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.recoverJavaScriptSources, () =>
finishAnalysisCommand(
() =>
service.recover(
{
path: args.path,
output_directory: args.outputDirectory,
@@ -68,15 +66,12 @@ export const registerJavaScriptRecoveryCommands = (
: { rewrite_level: options.rewriteLevel }),
},
{ signal },
);
return result.ok
? result.value
: projectAnalysisError(result.error);
},
);
} finally {
await service.close();
}
}),
),
service,
"wakaru",
"recover_javascript_sources",
),
),
),
});
};
+9 -13
View File
@@ -1,15 +1,14 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { JebAnalysisService } from "../application/jeb/JebAnalysisService.js";
import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js";
import { createJebAnalysisProvider } from "../composition/jeb.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { JebOperation } from "../domain/jeb/jebAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Expose the same explicit JEB requests through one-shot CLI commands. */
export const registerJebCommands = (
@@ -21,17 +20,14 @@ export const registerJebCommands = (
const service = new JebAnalysisService(provider);
const execute = (name: string, operation: JebOperation, input: unknown) =>
withCommandCancellation((signal) =>
logCliCommand(logger, name, async () => {
const result = await service.execute(operation, input, { signal });
try {
await provider.close();
} catch (cause) {
if (cause instanceof AnalysisError)
return projectAnalysisError(cause);
throw cause;
}
return result.ok ? result.value : projectAnalysisError(result.error);
}),
logCliCommand(logger, name, () =>
finishAnalysisCommand(
() => service.execute(operation, input, { signal }),
{ close: () => provider.close() },
"jeb",
operation,
),
),
);
cli.command(CLI_COMMANDS.inspectJebClient, {
+9 -24
View File
@@ -1,16 +1,14 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
import { createPcapAnalysisProvider } from "../composition/pcap.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { PcapOperation } from "../domain/pcap/pcapAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Expose the same explicit pcap requests through one-shot CLI commands. */
export const registerPcapCommands = (
@@ -22,27 +20,14 @@ export const registerPcapCommands = (
const service = new PcapAnalysisService(provider);
const execute = (name: string, operation: PcapOperation, input: unknown) =>
withCommandCancellation((signal) =>
logCliCommand(logger, name, async () => {
const result = await service.execute(operation, input, { signal });
try {
await provider.close();
} catch (cause) {
if (cause instanceof AnalysisError) {
if (result.ok) cause.retainPartialObservation(result.value);
return projectAnalysisError(
result.ok
? cause
: analysisErrorWithCleanupFailure(
result.error,
cause,
operation,
),
);
}
throw cause;
}
return result.ok ? result.value : projectAnalysisError(result.error);
}),
logCliCommand(logger, name, () =>
finishAnalysisCommand(
() => service.execute(operation, input, { signal }),
{ close: () => provider.close() },
"pcap",
operation,
),
),
);
cli.command(CLI_COMMANDS.inspectPcapClient, {
+27 -58
View File
@@ -2,13 +2,11 @@ import { z } from "incur";
import { createSqliteDatabaseService } from "../composition/sqlite.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Resolve an operator-selected SQLite snapshot through the shared inspection workflow. */
export const registerSqliteCommands = (
@@ -51,61 +49,32 @@ export const registerSqliteCommands = (
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectSqliteDatabase, async () => {
const inspection = await service
.inspect(
{
path: absoluteCallerPath(args.path),
...(options.table === undefined
? {}
: { table: options.table }),
...(options.rowLimit === undefined
? {}
: { row_limit: options.rowLimit }),
...(options.column === undefined
? {}
: { columns: options.column }),
...(options.schemaScope === undefined
? {}
: { schema_scope: options.schemaScope }),
},
{ signal },
)
.then(
(result) => ({ result }),
(cause: unknown) => ({ cause }),
);
try {
await service.close();
} catch (cause: unknown) {
const primary =
"cause" in inspection
? inspection.cause
: inspection.result.ok
? undefined
: inspection.result.error;
if (
primary instanceof AnalysisError &&
cause instanceof AnalysisError
)
throw analysisErrorWithCleanupFailure(
primary,
cause,
"inspect_sqlite_database",
);
if ("cause" in inspection)
throw new AggregateError(
[inspection.cause, cause],
"SQLite inspection and provider cleanup both failed",
{ cause: inspection.cause },
);
throw cause;
}
if ("cause" in inspection) throw inspection.cause;
return inspection.result.ok
? inspection.result.value
: projectAnalysisError(inspection.result.error);
}),
logCliCommand(logger, CLI_COMMANDS.inspectSqliteDatabase, () =>
finishAnalysisCommand(
() =>
service.inspect(
{
path: absoluteCallerPath(args.path),
...(options.table === undefined
? {}
: { table: options.table }),
...(options.rowLimit === undefined
? {}
: { row_limit: options.rowLimit }),
...(options.column === undefined
? {}
: { columns: options.column }),
...(options.schemaScope === undefined
? {}
: { schema_scope: options.schemaScope }),
},
{ signal },
),
service,
"sqlite",
"inspect_sqlite_database",
),
),
),
});
};
+13 -17
View File
@@ -9,6 +9,7 @@ import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Resolve all caller-selected artifact paths through the shared workflow. */
export const registerWasmCommands = (
@@ -56,13 +57,11 @@ export const registerWasmCommands = (
.describe("Explicit local WASM candidates"),
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
try {
return await logCliCommand(
logger,
wasmArtifactModule.cliCommand,
async () => {
const result = await service.inspect(
withCommandCancellation((signal) =>
logCliCommand(logger, wasmArtifactModule.cliCommand, () =>
finishAnalysisCommand(
() =>
service.inspect(
{
path: absoluteCallerPath(args.path),
glue_paths: options.glue_paths?.map((path) =>
@@ -73,15 +72,12 @@ export const registerWasmCommands = (
),
},
{ signal },
);
return result.ok
? result.value
: projectAnalysisError(result.error);
},
);
} finally {
await service.close();
}
}),
),
service,
"wabt-artifact",
"inspect_wasm_artifact",
),
),
),
});
};
+13 -18
View File
@@ -2,12 +2,12 @@ import { z } from "incur";
import { createWebModuleTraceService } from "../composition/webModules.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { webModuleTraceInputSchema } from "../domain/webModuleTrace.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
const importMapInputSchema =
webModuleTraceInputSchema.shape.import_map.unwrap();
@@ -40,13 +40,11 @@ export const registerWebModuleCommands = (
.describe("HTTP(S) import-map base URL; requires --import-map-path"),
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
try {
return await logCliCommand(
logger,
CLI_COMMANDS.traceWebModuleImports,
async () => {
const result = await service.trace(
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.traceWebModuleImports, () =>
finishAnalysisCommand(
() =>
service.trace(
{
manifest_path: args.manifestPath,
script_index: args.scriptIndex,
@@ -64,15 +62,12 @@ export const registerWebModuleCommands = (
}),
},
{ signal },
);
return result.ok
? result.value
: projectAnalysisError(result.error);
},
);
} finally {
await service.close();
}
}),
),
service,
"web-module-trace",
"trace_web_module_imports",
),
),
),
});
};
+13 -18
View File
@@ -2,11 +2,11 @@ import { z } from "incur";
import { createWebSourceLocationService } from "../composition/webSourceLocations.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
/** Expose the same explicit selected map/point contract through the CLI. */
export const registerWebSourceLocationCommands = (
@@ -35,13 +35,11 @@ export const registerWebSourceLocationCommands = (
),
}),
run: ({ args }) =>
withCommandCancellation(async (signal) => {
try {
return await logCliCommand(
logger,
CLI_COMMANDS.traceWebSourceLocation,
async () => {
const result = await service.trace(
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.traceWebSourceLocation, () =>
finishAnalysisCommand(
() =>
service.trace(
{
manifest_path: args.manifestPath,
script_index: args.scriptIndex,
@@ -52,15 +50,12 @@ export const registerWebSourceLocationCommands = (
generated_position: { line: args.line, column: args.column },
},
{ signal },
);
return result.ok
? result.value
: projectAnalysisError(result.error);
},
);
} finally {
await service.close();
}
}),
),
service,
"web-source-location",
"trace_web_source_location",
),
),
),
});
};
@@ -463,6 +463,7 @@ export const expandToolAvailability = (
export const sessionProvider = z
.object({
cleanup_pending: z.boolean(),
capabilities: z.array(providerCapabilitySet),
analysis_run: analysisRun,
analysis_activity: analysisActivity,
+1
View File
@@ -19,6 +19,7 @@ export const analysisErrorWithCleanupFailure = (
{ cause: primary },
),
userMessage: projectAnalysisError(primary).message,
remediationAction: projected.remediation.action,
...(primary.capturedOutput === undefined
? {}
: { capturedOutput: primary.capturedOutput }),
+8 -2
View File
@@ -27,6 +27,7 @@ import {
HopperTimeoutError,
} from "./hopperErrors.js";
import { ProviderSelectionError } from "./providerSelectionError.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { UnknownRegistryError } from "./unknownRegistryError.js";
import { providerRetryAction } from "./providerOperationHealth.js";
import type { AnalysisError, AnalysisErrorTag } from "./analysisErrorBase.js";
@@ -35,6 +36,11 @@ import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
export const analysisErrorRemediationAction = (
error: AnalysisError,
): string => {
if (
error instanceof ProviderAdapterError &&
error.remediationAction !== undefined
)
return error.remediationAction;
if (
error instanceof BrowserObservationError &&
error.reason === "service_worker_blocked"
@@ -66,7 +72,7 @@ export const analysisErrorRemediationAction = (
);
if (error instanceof HopperTimeoutError)
return error.operation === undefined
? "Inspect Hopper for a loader or license dialog and review details.launcher. Correct the loader configuration or complete Hopper setup, then open the target again."
? "Review details.launcher and inspect Hopper for ongoing initial analysis or a pending loader, demo, or license dialog. For a large target, increase REA_HOPPER_STARTUP_TIMEOUT_MS in the environment that starts REA and allow at least that long in the MCP client. Resolve retained cleanup before reopening."
: error.providerState === "busy"
? "Check binary_session.analysis_activity, wait for the active Hopper request to finish, then retry."
: "Check binary_session for Hopper health, then retry the operation.";
@@ -287,7 +293,7 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
const hopperErrorUserMessage = (error: AnalysisError): string | undefined => {
if (error instanceof HopperTimeoutError) {
if (error.operation === undefined)
return "REA timed out waiting for Hopper bridge readiness. Hopper may be waiting for a loader or license dialog; inspect its window and the captured launcher outcome before retrying.";
return "REA timed out waiting for Hopper bridge readiness, which includes initial analysis. Hopper may still be analyzing the target or waiting for a loader, demo, or license dialog. Review the captured launcher outcome and resolve retained cleanup before retrying.";
const request = error.operation;
return error.providerState === "busy"
? `Hopper timed out during ${request} while the provider remained busy. Check binary_session.analysis_activity, wait for the active request to finish, then retry.`
+3
View File
@@ -6,12 +6,14 @@ import {
/** A provider adapter failed outside its more precise typed variants. */
export interface ProviderAdapterErrorOptions extends AnalysisErrorOptions {
readonly userMessage?: string;
readonly remediationAction?: string;
}
/** A provider adapter failed outside its more precise typed variants. */
export class ProviderAdapterError extends AnalysisError {
readonly _tag = "ProviderAdapterError";
override readonly userMessage: string | undefined;
readonly remediationAction: string | undefined;
constructor(
readonly providerId: string,
@@ -20,5 +22,6 @@ export class ProviderAdapterError extends AnalysisError {
) {
super(`Provider ${providerId} adapter failed during ${operation}`, options);
this.userMessage = options.userMessage;
this.remediationAction = options.remediationAction;
}
}
+1
View File
@@ -17,6 +17,7 @@ export class ProviderCleanupError extends ProviderAdapterError {
options?: ErrorOptions & {
readonly operation?: string;
readonly partialObservation?: AnalysisPartialObservation;
readonly remediationAction?: string;
},
) {
const {
+33 -9
View File
@@ -99,6 +99,12 @@ export const cleanupHopperSession = async (
)
input.releaseTransport(input.socket);
await stopProcess(input, state);
// A late authenticated application may exit while shutdown is in flight.
await confirmExternalApplicationExit(
input.resources,
input.socket,
input.releaseTransport,
);
recordUnconfirmedDocument(input, state);
await report(input.progress, 0.75, "removing Hopper private runtime files");
if (
@@ -379,15 +385,33 @@ const cleanupOutcome = (
): Result<null, AnalysisError> => {
if (state.issues.length === 0) return ok(null);
return err(
new ProviderCleanupError("hopper", [...state.resources], {
issues: cleanupIssueDetails(state.issues),
...(input.resources.runtimeRoot === undefined
? {}
: { runtime_root: input.resources.runtimeRoot.path }),
...(input.activeRequest === null
? {}
: { active_request: activityDetails(input.activeRequest) }),
}),
new ProviderCleanupError(
"hopper",
[...state.resources],
{
issues: cleanupIssueDetails(state.issues),
...(input.resources.runtimeRoot === undefined
? {}
: { runtime_root: input.resources.runtimeRoot.path }),
...(input.activeRequest === null
? {}
: { active_request: activityDetails(input.activeRequest) }),
...(state.resources.has("hopper-document")
? {
authenticated_application:
input.resources.externalApplication === undefined
? "not_observed"
: input.resources.externalApplication.identity.state,
}
: {}),
},
state.resources.has("hopper-document")
? {
remediationAction:
"Export the Evidence bundle before close_binary clears session records. Let Hopper finish initial analysis or complete its pending dialog, then retry close_binary on this connection so REA can authenticate and close the retained document. If no matching bridge becomes available, manually close the reported document in Hopper, then restart the MCP connection. A helper exit or open:false does not establish that retained cleanup is complete.",
}
: undefined,
),
);
};
+53 -4
View File
@@ -142,7 +142,12 @@ export class HopperClient {
this.#rememberProcessFailure(error, []);
};
readonly #onSocketClose = (): void => {
if (this.#closing) return;
if (this.#closing) {
this.#failAll(
new HopperProcessError(null, { providerState: "unreachable" }),
);
return;
}
const launch = this.#resources.launch;
if (
launch?.providerLifetime === "launcher-process" &&
@@ -215,6 +220,24 @@ export class HopperClient {
* A recorded exit remains visible after request activity returns to idle.
*/
operationHealth(): ProviderOperationHealth {
if (
this.#closing ||
this.#closePromise !== undefined ||
(!this.#ready &&
this.#startupController !== undefined &&
this.#operationFailure === undefined)
) {
return {
state: "busy",
stage:
this.#closing || this.#closePromise !== undefined
? "connection"
: "launch",
retryAction: "wait",
exitCode: null,
requests: [],
};
}
const failure = this.#operationFailure;
if (failure !== undefined) {
const requests: readonly ProviderOperationRequest[] =
@@ -340,6 +363,7 @@ export class HopperClient {
this.#operationFailure = undefined;
this.#startupFailureResult = undefined;
this.#resources.shutdownConfirmed = false;
this.#resources.externalApplication = undefined;
const deadline = new ProviderStartupDeadline(
this.#options.startupTimeoutMs,
signal,
@@ -437,9 +461,31 @@ export class HopperClient {
: health.error,
);
}
const parsed = parseHopperServerInfo(health.value, runId);
const parsed = await this.#authenticateServerInfo(health.value, runId);
if (!parsed.ok) return this.#startupFailure(parsed.error);
await this.#lineage.observe(this.#resources.launch);
return parsed;
}
/** Bind process identity for both initial and late cleanup handshakes. */
async #authenticateServerInfo(
value: JsonValue,
runId: string,
): Promise<Result<HopperServerInfo, HopperProtocolError>> {
const parsed = parseHopperServerInfo(value, runId);
if (!parsed.ok) return parsed;
if (this.#resources.launch?.providerLifetime === "external-application") {
const application = this.#resources.externalApplication;
if (
application !== undefined &&
application.pid !== parsed.value.processId
)
return err(
new HopperProtocolError(
"Hopper bridge process changed within the same run",
),
);
if (application?.identity.state === "readable") return parsed;
let identity;
try {
identity = await observeProcessStartIdentity(parsed.value.processId);
@@ -460,7 +506,6 @@ export class HopperClient {
},
};
}
await this.#lineage.observe(this.#resources.launch);
return parsed;
}
@@ -631,7 +676,11 @@ export class HopperClient {
timeoutMs: HOPPER_SHUTDOWN_TIMEOUT_MS,
},
);
if (health.ok && parseHopperServerInfo(health.value, runId).ok) return;
if (
health.ok &&
(await this.#authenticateServerInfo(health.value, runId)).ok
)
return;
this.#releaseTransport();
} finally {
this.#closing = false;
-12
View File
@@ -5,7 +5,6 @@ import {
disassemblySchema,
idaAddressSchema,
modernCalleesSchema,
modernStringsSchema,
modernXrefsSchema,
} from "./IdaProtocolValues.js";
@@ -83,17 +82,6 @@ export const nativeXrefsSchema = z
.array(modernXrefsSchema.element.extend({ error: nativeErrorSchema }))
.length(1);
/** Native regex failures carry an empty page, which must not imply absence. */
export const nativeStringsSchema = modernStringsSchema
.extend({ error: z.string().optional() })
.transform((page) => {
if (page.error !== undefined)
throw new AnalysisProtocolError(
`Native IDA string search failed: ${page.error}`,
);
return page;
});
/**
* Native regexes fold bytes, while REA's insensitive literal search folds
* Unicode strings. Include every non-ASCII value as a candidate, then apply
+6 -5
View File
@@ -5,7 +5,6 @@ import {
nativeCalleesSchema,
nativeXrefsSchema,
nativeLiteralSearchPattern,
nativeStringsSchema,
} from "./IdaNativeProtocol.js";
import {
analysisSearchPredicate,
@@ -35,6 +34,7 @@ import {
disassemblySchema,
idaAddressSchema,
idaFunctionSchema,
idaStringsPageSchema,
legacyCalleesSchema,
legacyPageSchema,
legacyStringSchema,
@@ -45,7 +45,6 @@ import {
modernLookupSchema,
modernLookupBatchSchema,
modernPageSchema,
modernStringsSchema,
modernXrefsSchema,
nextOffset,
surveySegmentsSchema,
@@ -321,9 +320,7 @@ export class IdaOperationRunner {
}
offset = page.next_offset;
} else {
const page = (
this.protocol === "native" ? nativeStringsSchema : modernStringsSchema
).parse(
const page = idaStringsPageSchema.parse(
await this.call("find_regex", {
pattern:
this.protocol === "native"
@@ -333,6 +330,10 @@ export class IdaOperationRunner {
limit: 100,
}),
);
if (page.error !== undefined && page.error !== null)
throw new AnalysisProtocolError(
`${this.protocol === "native" ? "Native IDA" : "IDA"} string search failed: ${page.error}`,
);
result.push(
...page.matches.map((s) => ({ address: s.addr, value: s.string })),
);
+2 -1
View File
@@ -135,9 +135,10 @@ export const legacyStringSchema = z.object({
string: z.string(),
length: z.number().int().nonnegative(),
});
export const modernStringsSchema = z.object({
export const idaStringsPageSchema = z.object({
matches: z.array(z.object({ addr: idaAddressSchema, string: z.string() })),
cursor: cursorSchema,
error: z.string().nullish(),
});
export const legacyXrefsSchema = z.array(
z.object({
+71
View File
@@ -9,6 +9,7 @@ import {
import { IdaSessionClient } from "./IdaSessionClient.js";
import { functionDossierSchema } from "../domain/hopperValues.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { WindowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js";
@@ -153,6 +154,76 @@ describe("headless IDA producer pagination", () => {
});
});
describe("headless IDA modern string search failures", () => {
it("rejects a producer error before returning page matches and retains both replies as partial evidence", async () => {
const { producer, client } = await fixture("headless");
producer.overrideCall = (name, args) => {
if (name !== "find_regex") return undefined;
if (args.offset === 0)
return {
matches: [{ addr: "0x3000", string: "complete page" }],
cursor: { next: 1 },
error: null,
};
return {
matches: [{ addr: "0x3001", string: "untrusted partial page" }],
cursor: { done: true },
error: "Search fixture failed",
};
};
try {
const failed = await client.execute("search_strings", {
pattern: "needle",
});
expect(failed.ok).toBe(false);
if (!failed.ok) {
expect(failed.error).toBeInstanceOf(AnalysisProtocolError);
expect(failed.error.message).toContain("Search fixture failed");
expect(failed.error.partialObservation).toMatchObject({
normalized_result: {
status: "partial",
failed_operation: "search_strings",
observations: expect.arrayContaining([
expect.objectContaining({
tool: "find_regex",
result: expect.objectContaining({
matches: [{ addr: "0x3000", string: "complete page" }],
error: null,
}),
}),
expect.objectContaining({
tool: "find_regex",
result: expect.objectContaining({
matches: [
{ addr: "0x3001", string: "untrusted partial page" },
],
error: "Search fixture failed",
}),
}),
]),
},
});
}
expect(
producer.calls
.filter(({ name }) => name === "find_regex")
.map(({ args }) => args.offset),
).toEqual([0, 1]);
producer.overrideCall = (name) =>
name === "find_regex"
? { matches: [], cursor: { done: true }, error: null }
: undefined;
const empty = await client.execute("search_strings", {
pattern: "needle",
});
expect(empty.ok && empty.value.result).toEqual([]);
} finally {
expect((await client.close()).ok).toBe(true);
}
});
});
describe("attached IDA readiness and queued cancellation", () => {
it("allows ready-only work after an attached provider has initialized", async () => {
const { producer, client } = await fixture("attached");
+23 -6
View File
@@ -124,6 +124,7 @@ const tracerOutputSchema = z.discriminatedUnion("status", [
]),
error: z.string().nullable(),
target_process_created: z.boolean().optional(),
target_process_exit_observed: z.boolean().optional(),
}),
]);
@@ -285,6 +286,16 @@ export class LldbCallTracer implements NativeCallTracer {
),
);
}
if (result.ok && signal?.aborted === true)
return err(
new AnalysisCancelledError(OPERATION, {
partialObservation: partialObservationFromTrace(
request,
result.value,
"cancelled",
),
}),
);
return result;
}
@@ -421,11 +432,16 @@ export class LldbCallTracer implements NativeCallTracer {
exited.kind === "exited"
? await readTracerOutput(paths.result)
: undefined;
let targetTermination: TargetTermination = exited.targetLaunched
? await ensureTerminated(paths.pid, exited.targetIdentity, true)
: "terminated";
// Only an explicit bridge phase fact may prove that no target process was
// created. A missing PID or an integrity-error status alone is ambiguous.
// An explicit bridge observation can prove no process was created or that
// the created process exited. A missing PID alone remains ambiguous.
const bridgeObservedExit =
output !== undefined &&
output.status !== "traced" &&
output.target_process_exit_observed === true;
let targetTermination: TargetTermination =
exited.targetLaunched && !bridgeObservedExit
? await ensureTerminated(paths.pid, exited.targetIdentity, true)
: "terminated";
if (
output !== undefined &&
output.status !== "traced" &&
@@ -692,6 +708,7 @@ const partialNativeObservation = async (options: {
const partialObservationFromTrace = (
request: Parameters<NativeCallTracer["trace"]>[0],
trace: NativeCallTrace,
reason: NativeCallPartialObservation["coverage"]["reason"] = "cleanup-failure",
): NativeCallPartialObservation =>
projectLldbPartialObservation({
target: {
@@ -711,7 +728,7 @@ const partialObservationFromTrace = (
otherStops: trace.run.other_stops,
limitations: [],
},
reason: "cleanup-failure",
reason,
});
const lldbRuntimePaths = (directory: string) => ({
+10 -5
View File
@@ -377,6 +377,7 @@ const finishProcessRun = async (options: {
readonly progress: ProcessCaptureProgressTracker;
readonly progressCounts: () => ProcessCaptureProgressCounts;
readonly resourceScope?: ProcessCaptureResourceScope;
readonly signal?: AbortSignal;
}): Promise<ProcessCapture> => {
options.progress.closeLive();
await options.stopSampler();
@@ -401,6 +402,9 @@ const finishProcessRun = async (options: {
options.resourceScope === undefined
? await releaseProcessResources(cleanupOptions)
: await options.resourceScope.release(cleanupOptions);
let { capture, executionFailure } = options;
if (executionFailure === undefined && options.signal?.aborted)
executionFailure = processCaptureCancelled();
const observedExit =
options.observations?.exit.state === "available"
? options.observations.exit.value.reason
@@ -409,14 +413,10 @@ const finishProcessRun = async (options: {
phase: "cleanup",
counts: options.progressCounts(),
terminal: true,
disposition: captureProgressDisposition(
observedExit,
options.executionFailure,
),
disposition: captureProgressDisposition(observedExit, executionFailure),
cleanup,
});
const cleanupFailure = cleanupReportFailure(cleanup);
let { capture, executionFailure } = options;
let verifiedCapture: ProcessCapture | undefined;
if (capture !== undefined && cleanupFailure === undefined) {
const settlement: UnverifiedProcessCapture["settlement"] =
@@ -445,6 +445,10 @@ const finishProcessRun = async (options: {
rootPid === undefined
? { scenario: options.scenario }
: { scenario: options.scenario, rootPid };
// Cancellation remains authoritative until finalization has settled, even
// when the workload exited normally before cleanup began.
if (executionFailure === undefined && options.signal?.aborted)
executionFailure = processCaptureCancelled();
return resolveProcessResult(
cleanupFailure === undefined ? verifiedCapture : capture,
executionFailure,
@@ -830,6 +834,7 @@ const runProcessScenario = async (
progress: progressTracker,
progressCounts,
...(resourceScope === undefined ? {} : { resourceScope }),
...(signal === undefined ? {} : { signal }),
});
};
+7 -2
View File
@@ -79,7 +79,7 @@ export const sqliteDatabaseFailure = (
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: cause.reason === "limit" ? "out_of_range" : "invalid_format",
reason: "invalid_format",
message: cause.message,
},
]);
@@ -110,7 +110,12 @@ export const sqliteDatabaseFailure = (
"The selected SQLite database is held open by another process without read sharing. Close that application or inspect a copy, then retry.",
},
);
if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
if (
cause.code === "ENOENT" ||
cause.code === "ENOTDIR" ||
cause.code === "ELOOP" ||
cause.code === "ENAMETOOLONG"
)
return new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
+42 -8
View File
@@ -3,7 +3,10 @@ import { constants, type BigIntStats } from "node:fs";
import { join } from "node:path";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
readStableArtifact,
@@ -50,13 +53,35 @@ const duringAcquisition = async <T>(
if (
cause instanceof Error &&
"code" in cause &&
["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes(
String(cause.code),
)
)
throw new ArtifactReaderFailure(
"integrity",
`SQLite snapshot file set changed during acquisition: ${path}`,
{ cause },
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") {
const changed = new ArtifactReaderFailure(
"integrity",
`SQLite snapshot file set exceeded its observed size during acquisition: ${path}`,
{
cause,
...(cause.partialObservation === undefined
? {}
: { partialObservation: cause.partialObservation }),
},
cause.details,
);
if (cause.cleanup !== undefined)
throw ArtifactReaderFailure.withCleanup(
changed,
cause.cleanup,
cause.partialObservation,
);
throw changed;
}
throw cause;
}
};
@@ -195,6 +220,21 @@ export const captureSqliteDatabaseSnapshot = async (
if (database === undefined || wal === undefined || before[2] === undefined)
throw new Error("Incomplete database file-set observation");
if (database === null) throw missingDatabase(path);
const selectedSize = database.size + (wal?.size ?? 0n);
if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes))
throw new AnalysisResourceConstraintError(
"inspect_sqlite_database",
"file-size",
`Selected SQLite database and WAL total ${selectedSize.toString()} bytes, exceeding the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte offline snapshot budget: ${path}`,
{
sqlite_snapshot_input_bytes: SQLITE_DATABASE_LIMITS.inputBytes,
observed_sqlite_snapshot_input_bytes: selectedSize.toString(),
},
{
remediationAction:
"Use the application's supported export workflow to create a consistent, smaller offline SQLite snapshot containing only the relevant data, then inspect that snapshot. REA will not truncate or modify the selected source.",
},
);
if (before[2] !== null)
await verifyInactiveJournal(
`${path}-journal`,
@@ -204,12 +244,6 @@ export const captureSqliteDatabaseSnapshot = async (
"initial",
signal,
);
const selectedSize = database.size + (wal?.size ?? 0n);
if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes))
throw new ArtifactReaderFailure(
"limit",
`Selected SQLite database and WAL exceed the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte snapshot budget: ${path}`,
);
const snapshotPath = join(root, "database.snapshot");
const captured = await readObservedArtifact(
path,
@@ -0,0 +1,194 @@
import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { StableArtifactFileSystem } from "../artifacts/readStableArtifact.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js";
import { captureSqliteDatabaseSnapshot } from "./SqliteDatabaseSnapshot.js";
import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js";
import { sqliteDatabaseFailure } from "./SqliteDatabaseFailures.js";
it("reports filesystem sizes above the safe-integer range exactly before opening files", async () => {
const databaseBytes = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
const walBytes = 17n;
const workspace = await mkdtemp(join(tmpdir(), "rea-sqlite-budget-"));
const databasePath = join(workspace, "selected.db");
const walPath = `${databasePath}-wal`;
const journalPath = `${databasePath}-journal`;
const privateRoot = join(workspace, "private");
await writeFile(databasePath, "tiny fixture");
const stat = await lstat(databasePath, { bigint: true });
const open = async (): Promise<never> => {
throw new Error("An over-budget source must be refused before open");
};
const fileSystem: StableArtifactFileSystem = {
lstat: async (path) => {
if (path === journalPath)
throw Object.assign(new Error("journal absent"), { code: "ENOENT" });
const size =
path === databasePath
? databaseBytes
: path === walPath
? walBytes
: stat.size;
return Object.assign(
Object.create(Object.getPrototypeOf(stat)) as typeof stat,
stat,
{ size },
);
},
open,
};
const resourceScope = new ArtifactResourceScope();
try {
const failure = await captureSqliteDatabaseSnapshot(
databasePath,
privateRoot,
resourceScope,
undefined,
fileSystem,
).then(
() => {
throw new Error("Expected the oversized snapshot to be refused");
},
(cause: unknown) => cause,
);
const projected = projectAnalysisError(
sqliteDatabaseFailure(failure, databasePath, "artifact-read"),
);
expect(failure).toMatchObject({
_tag: "AnalysisResourceConstraintError",
operation: "inspect_sqlite_database",
resource: "file-size",
reportedLimits: {
sqlite_snapshot_input_bytes: SQLITE_DATABASE_LIMITS.inputBytes,
observed_sqlite_snapshot_input_bytes: (
databaseBytes + walBytes
).toString(),
},
remediationAction: expect.stringContaining(
"smaller offline SQLite snapshot",
),
});
expect(projected).toMatchObject({
code: "resource_constraint",
category: "resource_constraint",
retryable: false,
details: {
reported_limits: {
sqlite_snapshot_input_bytes: SQLITE_DATABASE_LIMITS.inputBytes,
observed_sqlite_snapshot_input_bytes: (
databaseBytes + walBytes
).toString(),
},
},
remediation: {
action: expect.stringContaining("supported export workflow"),
},
});
await expect(readdir(privateRoot)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
await resourceScope.close();
await rm(workspace, { force: true, recursive: true });
}
});
it.each([
{ label: "incomplete cleanup", retainCleanup: true },
{ label: "partial observation without cleanup", retainCleanup: false },
])(
"preserves $label when a late read-limit failure becomes changed evidence",
async ({ retainCleanup }) => {
const workspace = await mkdtemp(join(tmpdir(), "rea-sqlite-read-cleanup-"));
const databasePath = join(workspace, "selected.db");
await writeFile(databasePath, "tiny fixture");
const stat = await lstat(databasePath, { bigint: true });
const cleanup = {
reason: "fixture file-handle close failed",
resources: [databasePath],
};
const partialObservation = {
operation: "address_to_file_offset",
address: "0x1000",
provider_file_offset: 16,
provider_source_path: null,
provider_image_header_hex: null,
original_file_offset: {
available: false,
reason: "fixture observation",
},
} satisfies AnalysisPartialObservation;
const details = {
logicalPath: "selected.db",
declaredSha256: null,
calculatedSha256: null,
unpacked: false,
};
const primary = new ArtifactReaderFailure(
"limit",
"Selected artifact grew beyond its byte limit",
{ partialObservation },
details,
);
const limitFailure = retainCleanup
? ArtifactReaderFailure.withCleanup(primary, cleanup)
: primary;
const fileSystem: StableArtifactFileSystem = {
lstat: async (path) => {
if (path === `${databasePath}-journal`)
throw Object.assign(new Error("journal absent"), { code: "ENOENT" });
return stat;
},
open: async () => {
throw limitFailure;
},
};
const resourceScope = new ArtifactResourceScope();
try {
const failure = await captureSqliteDatabaseSnapshot(
databasePath,
join(workspace, "private"),
resourceScope,
undefined,
fileSystem,
).then(
() => {
throw new Error("Expected the changed snapshot to be rejected");
},
(cause: unknown) => cause,
);
expect(failure).toMatchObject({ reason: "integrity" });
if (retainCleanup)
expect(failure).toMatchObject({
cleanup,
cause: expect.any(ArtifactReaderFailure),
});
expect(failure).toMatchObject({ partialObservation, details });
const projectedFailure = sqliteDatabaseFailure(
failure,
databasePath,
"artifact-read",
);
if (retainCleanup) {
expect(projectedFailure).toBeInstanceOf(ProviderCleanupError);
expect(projectedFailure).toMatchObject({
cleanupIncomplete: true,
cleanupResources: cleanup.resources,
});
}
} finally {
await resourceScope.close();
await rm(workspace, { force: true, recursive: true });
}
},
);
@@ -13,6 +13,37 @@ import { parseEvidence } from "../../src/domain/evidence.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest(
"classifies a ZIP local-header offset beyond EOF as malformed input",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-zip-beyond-eof-");
const archivePath = join(root, "beyond-eof.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("member.bin", new TextReader("valid member bytes"));
const bytes = Buffer.from(await writer.close());
const central = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
if (central < 0) throw new Error("Expected a ZIP central directory");
bytes.writeUInt32LE(bytes.length + 1024, central + 42);
await writeFile(archivePath, bytes);
const result = await cli.run({
arguments: ["inspect-artifact", archivePath, "--json"],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(result.json).toMatchObject({
code: "artifact_operation_failed",
category: "execution_failure",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
details: {
operation: "inspect_artifact",
reason: "format",
detail: expect.stringContaining("extends beyond"),
},
});
},
);
cliTest(
"inspects and extracts ZIP members with repeated current-directory prefixes",
async ({ cli }) => {
+109 -1
View File
@@ -1,5 +1,12 @@
import { createHash } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import {
lstat,
readFile,
stat,
symlink,
truncate,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { expect, onTestFinished } from "vitest";
@@ -20,6 +27,107 @@ import { cliTest } from "../support/cli/cliFixture.js";
const digest = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
for (const expectedErrno of ["ELOOP", "ENAMETOOLONG"] as const) {
cliTest.skipIf(expectedErrno === "ELOOP" && process.platform === "win32")(
`reports an initial ${expectedErrno} path failure through CLI and MCP`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-sqlite-path-failure-");
const path =
expectedErrno === "ELOOP"
? join(root, "cycle-a", "selected.db")
: join(root, "a".repeat(300), "selected.db");
if (expectedErrno === "ELOOP") {
await symlink("cycle-b", join(root, "cycle-a"));
await symlink("cycle-a", join(root, "cycle-b"));
}
const result = await cli.run({
arguments: ["inspect-sqlite-database", path, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).toBe(1);
const expectedError = {
code: "invalid_request",
details: {
issues: [
{
path: ["path"],
reason: "invalid_value",
message: expect.stringContaining(expectedErrno),
},
],
},
};
expect(result.json).toMatchObject(expectedError);
const { call } = await connectLocalToolsMcp();
const response = await call("inspect_sqlite_database", { path });
expect(parseMcpToolError(response).error).toMatchObject(expectedError);
},
);
}
cliTest(
"reports the combined database and WAL byte budget through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-sqlite-size-budget-");
const path = join(root, "selected.db");
const walPath = `${path}-wal`;
const maximum = 256 * 1024 * 1024;
const databaseBytes = maximum / 2;
const walBytes = databaseBytes + 1;
await writeFile(path, "");
await writeFile(walPath, "");
await truncate(path, databaseBytes);
await truncate(walPath, walBytes);
const before = await Promise.all(
[path, walPath].map(async (filePath) => ({
path: filePath,
state: await lstat(filePath, { bigint: true }),
})),
);
const cliResult = await cli.run({
arguments: ["inspect-sqlite-database", path, "--json"],
environment: { REA_LOG_LEVEL: "silent" },
});
const { call } = await connectLocalToolsMcp();
const mcpResponse = await call("inspect_sqlite_database", { path });
const mcpError = parseMcpToolError(mcpResponse).error;
const expectedError = {
code: "resource_constraint",
category: "resource_constraint",
retryable: false,
details: {
reported_limits: {
sqlite_snapshot_input_bytes: maximum,
observed_sqlite_snapshot_input_bytes: String(maximum + 1),
},
},
remediation: {
action: expect.stringContaining("supported export workflow"),
},
};
expect(cliResult.exitCode).toBe(1);
expect(cliResult.json).toMatchObject(expectedError);
expect(mcpError).toMatchObject(expectedError);
const after = await Promise.all(
before.map(async ({ path: filePath }) => ({
path: filePath,
state: await lstat(filePath, { bigint: true }),
})),
);
for (const [index, original] of before.entries()) {
const current = after[index];
expect(current?.state).toMatchObject({
dev: original.state.dev,
ino: original.state.ino,
mode: original.state.mode,
size: original.state.size,
mtimeNs: original.state.mtimeNs,
ctimeNs: original.state.ctimeNs,
});
}
},
);
cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)(
"bounds default context and projects exact columns without evaluating unrelated cells",
async ({ cli }) => {
+125
View File
@@ -0,0 +1,125 @@
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { runCliJson } from "../../fixtures/cliJsonProcess.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const canDenyCleanup = process.platform !== "win32" && process.getuid?.() !== 0;
it.runIf(canDenyCleanup)(
"retains wasm evidence and primary diagnostics when real scratch cleanup is denied",
async () => {
const directory = await createTestTempDirectory("rea-wasm-cleanup-cli-");
const tools = join(directory, "tools");
const artifact = join(directory, "module.wasm");
const locked = join(directory, "locked-paths.txt");
await mkdir(tools);
const wasm = Buffer.from(
"0061736d01000000010401600000020b0103656e7603666f6f0000030201000503010001071002066d656d6f727902000372756e00010a0601040001010b000c04746573747061796c6f6164",
"hex",
);
expect(wasm).toHaveLength(76);
await writeFile(artifact, wasm);
execFileSync(process.execPath, [
"-e",
"WebAssembly.compile(require('node:fs').readFileSync(process.argv[1])).catch(() => { process.exitCode = 1; });",
artifact,
]);
const objdump = fileURLToPath(
new URL("../../fixtures/wasm/objdump.txt", import.meta.url),
);
const makeTool = async (name: string, body: string) => {
const path = join(tools, name);
await writeFile(path, `#!/bin/sh\n${body}\n`, { mode: 0o700 });
await chmod(path, 0o700);
};
const deny = `mkdir denied\nprintf '%s\\n' "$PWD/denied" >> "$REA_LOCKED_PATHS"\nprintf marker > denied/marker\nchmod 000 denied`;
await makeTool(
"wasm-validate",
`if [ "$1" = "--version" ]; then echo '1.0.42'; exit 0; fi\nif [ "$REA_PRIMARY_FAILURE" = "1" ]; then ${deny}\n echo 'fixture validation rejected' >&2\n exit 1\nfi\nexit 0`,
);
await makeTool(
"wasm-objdump",
`if [ "$1" = "--version" ]; then echo '1.0.42'; exit 0; fi\ncat "$REA_FIXTURE_OBJDUMP"`,
);
await makeTool(
"wasm2wat",
`if [ "$1" = "--version" ]; then echo '1.0.42'; exit 0; fi\nif [ "$REA_DENY_CLEANUP" = "1" ]; then ${deny}; fi\nprintf '%s\\n' '(module (@custom "test" "payload"))'`,
);
const environment = {
...process.env,
REA_WABT_BIN_DIRECTORY: tools,
REA_FIXTURE_OBJDUMP: objdump,
REA_LOCKED_PATHS: locked,
TMPDIR: directory,
TMP: directory,
TEMP: directory,
};
const invoke = (extra: NodeJS.ProcessEnv = {}) =>
runCliJson(["inspect-wasm-artifact", artifact, "--format", "json"], {
...environment,
...extra,
});
const restoreDeniedDirectories = async () => {
let paths: string[] = [];
try {
paths = (await readFile(locked, "utf8")).trim().split("\n");
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
cause.code === "ENOENT"
)
return;
throw cause;
}
for (const path of paths) await chmod(path, 0o700);
};
try {
const control = parseEvidence(await invoke());
expect(control.normalized_result).toMatchObject({ validation: "valid" });
const completedThenCleanupFails = analysisErrorProjectionSchema.parse(
await invoke({ REA_DENY_CLEANUP: "1" }),
);
expect(completedThenCleanupFails).toMatchObject({
code: "cleanup_incomplete",
details: {
cleanup: "incomplete",
partial_observation: {
normalized_result: { validation: "valid" },
},
},
});
await restoreDeniedDirectories();
await writeFile(locked, "");
const primaryAndCleanupFail = analysisErrorProjectionSchema.parse(
await invoke({ REA_PRIMARY_FAILURE: "1" }),
);
expect(primaryAndCleanupFail).toMatchObject({
code: "cleanup_incomplete",
details: {
execution_failure: "execution_failure",
diagnostics: {
primary_error: { code: "cleanup_incomplete" },
cleanup_error: { code: "cleanup_incomplete" },
},
},
});
expect(JSON.stringify(primaryAndCleanupFail.details)).toContain(
"fixture validation rejected",
);
} finally {
await restoreDeniedDirectories();
}
},
);
@@ -0,0 +1,99 @@
import { execFile } from "node:child_process";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execFileAsync = promisify(execFile);
const packerUrl = pathToFileURL(
resolve("scripts/verify-package-pack.mjs"),
).href;
const catalogUrl = pathToFileURL(resolve("dist/catalogIdentity.js")).href;
const bridge = "package/bridge/hopper_bridge.py";
const members = [
bridge,
"package/bridge/cutter/rea_bridge.py",
"package/bridge/ghidra/ReaGhidraBridge.java",
"package/bridge/ghidra/ReaGhidraBundleActivator.java",
"package/bridge/ghidra/ReaGhidraExtensions.java",
"package/bridge/ghidra/ReaGhidraPrepareCom.java",
"package/bridge/ghidra/ReaGhidraPrepareRaw.java",
"package/bridge/ghidra/ReaGhidraApplySeeds.java",
"package/bridge/ghidra/ReaGhidraAnalysisOptions.java",
"package/bridge/ghidra/ReaGhidraNoReturnFix.java",
"package/bridge/ghidra/ReaGhidraMipsSoftFloat.java",
];
async function verifyListing(listing: string) {
const workspace = await createTestTempDirectory("rea-tar-listing-");
const { stdout } = await execFileAsync(process.execPath, [
"--input-type=module",
"--eval",
`import childProcess from "node:child_process";
import { syncBuiltinESMExports } from "node:module";
import { promisify } from "node:util";
import { CATALOG_IDENTITY } from ${JSON.stringify(catalogUrl)};
let bridgeParsed = false;
childProcess.execFile = () => { throw new Error("Expected promisified execution"); };
childProcess.execFile[promisify.custom] = async (command, args) => {
let stdout;
if ((command === "npm" && args[0] === "pack") || (command === process.execPath && args[1] === "pack")) stdout = "fixture.tgz\\n";
else if (command === "tar" && args[0] === "-tf") stdout = ${JSON.stringify(listing)};
else if (command === "tar" && args[0] === "-xOf" && args[2] === "package/package.json")
stdout = JSON.stringify({ bin: { rea: "scripts/rea.mjs", "rea-agents": "scripts/rea.mjs" }, mcpName: "io.github.morluto/rea", dependencies: { "@lydell/node-pty": "1.1.0" } });
else if (command === "tar" && args[0] === "-xOf" && args[2] === "package/skills/reverse-engineer-anything/SKILL.md")
stdout = " tool_count: " + CATALOG_IDENTITY.counts.mcp_tools + "\\n";
else if (command === "tar" && args[0] === "-xf") stdout = "";
else if (command === "python3") { bridgeParsed = true; stdout = ""; }
else throw new Error("Unexpected command: " + command + " " + JSON.stringify(args));
return { stdout, stderr: "" };
};
syncBuiltinESMExports();
const { verifyPackagePack } = await import(${JSON.stringify(packerUrl)});
try {
const result = await verifyPackagePack({ root: process.cwd(), workspace: ${JSON.stringify(workspace)} });
process.stdout.write(JSON.stringify({ ...result, bridgeParsed }));
} catch (cause) {
process.stdout.write(JSON.stringify({ error: cause.message, bridgeParsed }));
}`,
]);
return { result: JSON.parse(stdout), workspace };
}
it("preserves a literal trailing CR in an LF-listed member", async () => {
const listing =
members.map((path) => (path === bridge ? `${path}\r` : path)).join("\n") +
"\n";
const { result } = await verifyListing(listing);
expect(result).toEqual({
error: `package omitted ${bridge}`,
bridgeParsed: false,
});
});
it("accepts exact bridge entries from a CRLF tar listing and still rejects generated bytecode", async () => {
const ending = "\r\n";
const { result, workspace } = await verifyListing(
members.join(ending) + ending,
);
expect(result).toEqual({
tarball: join(workspace, "pack/fixture.tgz"),
bridgeParsed: true,
});
const rejected = await verifyListing(
[...members, "package/bridge/stale.pyc"].join(ending) + ending,
);
expect(rejected.result).toEqual({
error: "package contained generated Python bytecode",
bridgeParsed: false,
});
});
it("accepts exact bridge entries from an LF tar listing", async () => {
const { result } = await verifyListing(members.join("\n") + "\n");
expect(result.bridgeParsed).toBe(true);
});
@@ -134,6 +134,92 @@ itWithCaptureCapability.each(["SIGINT", "SIGTERM"] as const)(
20_000,
);
itWithCaptureCapability(
"preserves completed observations as partial evidence when SIGINT arrives during CLI cleanup",
async () => {
const root = await createTestTempDirectory("rea-process-cleanup-cancel-");
const scenario = join(root, "scenario.json");
const cleanupMarker = join(root, "cleanup.json");
await writeFile(
scenario,
JSON.stringify({
executable: process.execPath,
arguments: ["-e", "console.log('completed-before-cleanup')"],
timeout_ms: 5_000,
}),
);
const hook = fileURLToPath(
new URL("../../fixtures/processCleanupCancellation.mjs", import.meta.url),
);
for (const cancel of [false, true]) {
const cli = spawn(
process.execPath,
[
"--import",
hook,
"scripts/rea.mjs",
"capture-process",
scenario,
"--format",
"json",
],
{
env: {
...process.env,
TMPDIR: root,
REA_TEST_CANCEL_DURING_CLEANUP: cancel ? "1" : "0",
REA_TEST_CLEANUP_MARKER: cleanupMarker,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stdout = "";
cli.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
cli.stderr.resume();
const closed = new Promise<number | null>((resolve, reject) => {
cli.once("error", reject);
cli.once("close", resolve);
});
onTestFinished(async () => {
if (cli.exitCode === null && cli.signalCode === null)
cli.kill("SIGKILL");
await closed;
});
expect(await closed).toBe(cancel ? 130 : 0);
const marker = z
.object({ path: z.string(), removed: z.literal(true) })
.parse(JSON.parse(await readFile(cleanupMarker, "utf8")));
await expect(access(marker.path)).rejects.toMatchObject({
code: "ENOENT",
});
const output: unknown = JSON.parse(stdout);
if (cancel) {
expect(analysisCliErrorEnvelopeSchema.parse(output)).toMatchObject({
code: "cancelled",
details: {
cleanup: "complete",
partial_observation: {
capture: { exit: { code: 0 } },
cleanup: { temporary_root: { state: "cleaned" } },
},
},
});
} else {
expect(output).toMatchObject({
operation: "capture_process_scenario",
normalized_result: {
exit: { code: 0 },
cleanup: { temporary_root: "removed" },
},
});
}
}
},
30_000,
);
itWithCaptureCapability(
"stops the owned descendant when an MCP request is cancelled during post-exit settlement",
async () => {
@@ -52,12 +52,30 @@ it.skipIf(process.platform === "win32")(
listener_removed: true,
thread_released: true,
})),
{
mode: "authenticated-idle",
requests: [
"health",
"health",
"health",
"shutdown_document",
"shutdown_document",
],
listener_removed: true,
thread_released: true,
},
{
mode: "expiry",
requests: ["health"],
listener_removed: true,
thread_released: true,
},
...["initial-expiry", "silent-expiry"].map((mode) => ({
mode,
requests: [],
listener_removed: true,
thread_released: true,
})),
]);
},
);
@@ -36,6 +36,7 @@ export class HopperFixtureLauncher implements BridgeLauncher {
readonly tokenOverride?: string,
readonly diagnosticMode: "quiet" | "noisy" | "noisy_no_socket" = "quiet",
readonly healthMode: "ready" | "gated" = "ready",
readonly shutdownMode: "acknowledge" | "exit-without-ack" = "acknowledge",
) {}
launch(session: BridgeSession) {
@@ -50,7 +51,7 @@ export class HopperFixtureLauncher implements BridgeLauncher {
session.socketPath,
this.tokenOverride ?? session.token,
session.runId,
"acknowledge",
this.shutdownMode,
this.diagnosticMode,
this.healthMode,
],
@@ -32,11 +32,13 @@ class ExternalBridgeLauncher implements BridgeLauncher {
constructor(
readonly identity: "matching" | "wrong-run" | "wrong-token" = "matching",
healthMode: "ready" | "gated" = "ready",
shutdownMode: "acknowledge" | "exit-without-ack" = "acknowledge",
) {
this.fixture = new HopperFixtureLauncher(
identity === "wrong-token" ? "foreign-token" : undefined,
"quiet",
healthMode,
shutdownMode,
);
}
@@ -62,8 +64,13 @@ class ExternalBridgeLauncher implements BridgeLauncher {
const setup = (
identity?: "matching" | "wrong-run" | "wrong-token",
healthMode?: "ready" | "gated",
shutdownMode?: "acknowledge" | "exit-without-ack",
) => {
const launcher = new ExternalBridgeLauncher(identity, healthMode);
const launcher = new ExternalBridgeLauncher(
identity,
healthMode,
shutdownMode,
);
const client = new HopperClient({ launcher, startupTimeoutMs: 2_000 });
onTestFinished(async () => {
await client.close();
@@ -245,3 +252,67 @@ it("preserves launcher diagnostics after a health deadline and reauthenticates f
"shutdown_document",
]);
});
it("recovers an application exit after the first valid handshake occurs during cleanup", async () => {
const { client, launcher } = setup("matching", "gated", "exit-without-ack");
const starting = client.start();
const first = await launcher.fixture.waitForRequest("health");
const started = await starting;
if (started.ok) throw new Error("Expected the first handshake to time out");
expect(projectAnalysisError(started.error)).toMatchObject({
code: "cleanup_incomplete",
remediation: {
action: expect.stringContaining("restart the MCP connection"),
},
details: {
diagnostics: {
primary_error: { code: "provider_timeout" },
cleanup_error: {
details: {
diagnostics: { authenticated_application: "not_observed" },
},
},
},
},
});
const closing = client.close();
const next = await vi.waitFor(() => {
const request = launcher.fixture.requests.find(
({ method, id }) => method === "health" && id !== first.id,
);
if (request === undefined)
throw new Error("Waiting for cleanup authentication");
return request;
});
await launcher.fixture.release(next);
const closed = await closing;
if (!closed.ok) throw closed.error;
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
"health",
"health",
"shutdown_document",
]);
const session = launcher.sessions[0];
if (session === undefined) throw new Error("Fixture did not launch");
await expect(access(session.directory)).rejects.toMatchObject({
code: "ENOENT",
});
const restarted = client.start();
const health = await vi.waitFor(() => {
const request = launcher.fixture.requests.find(
({ method, id }) => method === "health" && id > next.id,
);
if (request === undefined)
throw new Error("Waiting for restarted authentication");
return request;
});
await launcher.fixture.release(health);
const ready = await restarted;
if (!ready.ok) throw ready.error;
const observed = await client.callTool("current_document");
if (!observed.ok) throw observed.error;
expect(observed.value).toBe("fixture");
const reclosed = await client.close();
if (!reclosed.ok) throw reclosed.error;
});
@@ -64,6 +64,55 @@ const start = async (owned: boolean) => {
return { client, launcher };
};
it("reports launch work until the first authenticated handshake completes", async () => {
const fixture = new HopperFixtureLauncher(undefined, "quiet", "gated");
let enterLaunch: (() => void) | undefined;
let releaseLaunch: (() => void) | undefined;
const entered = new Promise<void>((resolve) => {
enterLaunch = resolve;
});
const release = new Promise<void>((resolve) => {
releaseLaunch = resolve;
});
const client = new HopperClient({
startupTimeoutMs: 10_000,
launcher: {
async launch(session) {
enterLaunch?.();
await release;
return fixture.launch(session);
},
},
});
onTestFinished(async () => {
releaseLaunch?.();
await client.close();
});
const starting = client.callTool("current_document");
await entered;
expect(client.requestActivity()).toBeNull();
expect(client.operationHealth()).toMatchObject({
state: "busy",
stage: "launch",
retryAction: "wait",
});
releaseLaunch?.();
const handshake = await fixture.waitForRequest("health");
expect(client.operationHealth()).toMatchObject({
state: "busy",
stage: "launch",
retryAction: "wait",
});
await fixture.release(handshake);
const result = await starting;
if (!result.ok) throw result.error;
expect(result.value).toBe("fixture");
expect(client.operationHealth()).toMatchObject({
state: "idle",
stage: null,
});
});
describe("Hopper provider lifecycle health", () => {
it("settles disconnected requests without attributing the helper exit to the GUI", async () => {
const { client, launcher } = await start(false);
@@ -1,7 +1,7 @@
import { access, readFile, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
@@ -14,6 +14,7 @@ import { err, ok } from "../../../../src/domain/result.js";
import { NativeCommandFailure } from "../../../../src/native/CommandRunner.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { createDeferred } from "../../../fixtures/binarySession.js";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import {
NativeFixtureRunner,
nativeMachoTarget,
@@ -47,10 +48,43 @@ const request: Parameters<NativeCallTracer["trace"]>[0] = {
};
const fakeTool = { path: "/usr/bin/lldb", sha256: "b".repeat(64) };
const successfulTracerOutput = {
status: "traced",
version: "lldb-fixture",
pid: 12345,
outcome: "exited",
resource_limit_reached: false,
target_output: {
stdout_bytes: 0,
stderr_bytes: 0,
stdout_truncated: false,
stderr_truncated: false,
stdout_complete: true,
stderr_complete: true,
},
exit_status: 0,
exit_description: null,
killed: false,
terminated: true,
target_identity: {
loaded_image_sha256: null,
selected_file_sha256: request.expectedSha256,
file_device: "1",
file_inode: "2",
module_path: request.executable,
module_uuid: "fixture",
stable: true,
},
elapsed_ms: 1,
breakpoints: [],
events: [],
other_stops: [],
};
const configSchema = z.object({
result_path: z.string(),
observation_path: z.string(),
stdout_capture_path: z.string(),
stderr_capture_path: z.string(),
});
const configFromArguments = async (arguments_: readonly string[]) => {
@@ -116,6 +150,55 @@ const fixtureTracer = (
};
describe("LLDB failure retention through the production tracer", () => {
it.each(["after launch", "during runtime cleanup"])(
"returns cancellation when the request is aborted %s",
async (phase) => {
const controller = new AbortController();
const close = PrivateRuntimeRoot.prototype.close;
const closeSpy =
phase === "during runtime cleanup"
? vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockImplementation(async function (this: PrivateRuntimeRoot) {
await close.call(this);
controller.abort();
})
: undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const { config } = await configFromArguments(arguments_);
await writeFile(config.observation_path, "");
await writeFile(config.stdout_capture_path, "");
await writeFile(config.stderr_capture_path, "");
await writeFile(
config.result_path,
JSON.stringify(successfulTracerOutput),
);
if (phase === "after launch") controller.abort();
return {
kind: "exited",
targetLaunched: false,
exitCode: 0,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
const result = await tracer
.trace(request, controller.signal)
.finally(() => closeSpy?.mockRestore());
if (result.ok) throw new Error("expected cancellation after LLDB exit");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.partialObservation).toMatchObject({
coverage: { status: "partial", reason: "cancelled" },
});
},
);
it("waits for an active trace before client close removes its runtime root", async () => {
const launchGate = createDeferred<void>();
const launchStarted = createDeferred<void>();
@@ -277,6 +360,45 @@ describe("LLDB pre-target phase proof", () => {
});
describe("LLDB retains ambiguous target ownership", () => {
it("releases a launch failure after the bridge proves the process exited", async () => {
let configPath: string | undefined;
const tracer = new LldbCallTracer(
{},
async (_executable, arguments_) => {
const resolved = await configFromArguments(arguments_);
configPath = resolved.configPath;
await writeFile(
resolved.config.result_path,
JSON.stringify({
status: "launch-error",
error: "attach failed after the launched process exited",
target_process_created: true,
target_process_exit_observed: true,
}),
);
return {
kind: "exited",
targetLaunched: true,
exitCode: 1,
output: "",
targetIdentity: undefined,
cleanupFailure: undefined,
};
},
async () => ok(fakeTool),
);
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected LLDB launch failure");
expect(projectAnalysisError(result.error).code).toBe(
"capability_unavailable",
);
expect(result.error.cleanup).toBeUndefined();
expect(configPath).toBeDefined();
if (configPath !== undefined)
await expect(access(dirname(configPath))).rejects.toThrow();
});
it("keeps a post-target integrity rejection unresolved when its PID is missing", async () => {
let runtime: string | undefined;
const tracer = new LldbCallTracer(
@@ -121,6 +121,29 @@ it.runIf(process.platform !== "win32").each([
},
);
it.each(["ELOOP", "ENAMETOOLONG"] as const)(
"treats %s during file acquisition as changed evidence, not an invalid input path",
async (code) => {
const { database, capture } = await fixture();
const fileSystem: StableArtifactFileSystem = {
lstat: (path) => lstat(path, { bigint: true }),
open: async (path, flags) => {
if (path === database.path)
throw Object.assign(
new Error(`${code}: path changed after observation`),
{ code },
);
return open(path, flags);
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
message: expect.stringContaining(database.path),
});
},
);
it("refuses an active journal even before SQLite writes its magic header", async () => {
const { database, capture } = await fixture();
const writer = new DatabaseSync(database.path);
@@ -33,6 +33,13 @@ const retryFixture = async (
});
const client: AnalysisClient = {
execute,
operationHealthSnapshot: () => ({
state: "unreachable",
stage: "connection",
retryAction: "restart_provider",
exitCode: null,
requests: [],
}),
close: async (options) => {
if (!allowed) return err(cleanupError);
await rm(runtime, { recursive: true, force: true });
@@ -83,6 +90,11 @@ it.each(["close", "switch", "open after close"] as const)(
transition === "switch" ? await attempt() : await session.close();
expect(initial).toEqual(err(cleanupError));
expect(session.activeTarget()).toBeUndefined();
expect(session.status()).toMatchObject({
open: false,
cleanup_pending: true,
provider_operation_health: { state: "unreachable", stage: "connection" },
});
expect(await session.execute("address_name", {})).toMatchObject({
ok: false,
});
@@ -94,6 +106,7 @@ it.each(["close", "switch", "open after close"] as const)(
fixture.allowCleanup();
expect((await attempt()).ok).toBe(true);
expect(session.status()).toMatchObject({ cleanup_pending: false });
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
expect(fixture.created()).toBe(transition === "close" ? 1 : 2);
},
@@ -2,7 +2,10 @@ import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import {
createBinarySessionTargets,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { CompositeProvider } from "../../../src/application/binary/CompositeProvider.js";
import type {
AnalysisOperation,
@@ -133,6 +136,65 @@ describe("composite analysis provider", () => {
});
});
describe("composite provider health status", () => {
it("keeps a known failed child visible in public status when another child has no health snapshot", async () => {
const [targetPath] = await createBinarySessionTargets();
const failedIdentity: ProviderIdentity = {
id: "failed-child",
name: "Failed child",
version: "1",
};
const unobservedIdentity: ProviderIdentity = {
id: "unobserved-child",
name: "Unobserved child",
version: "1",
};
let failed = false;
const failedProvider: AnalysisProvider = {
identity: () => failedIdentity,
capabilities: () => [capability(failedIdentity, "address_name")],
createClient: () => ({
execute: async () => ok(createAnalysisExecution(null, failedIdentity)),
operationHealthSnapshot: () => ({
state: failed ? "exited" : "idle",
stage: failed ? "connection" : null,
retryAction: failed ? "restart_provider" : null,
exitCode: failed ? 7 : null,
requests: [],
}),
close: async () => ok(null),
}),
};
const unobservedProvider: AnalysisProvider = {
identity: () => unobservedIdentity,
capabilities: () => [capability(unobservedIdentity, "procedure_info")],
createClient: () => ({
execute: async () =>
ok(createAnalysisExecution(null, unobservedIdentity)),
close: async () => ok(null),
}),
};
const session = createTestBinarySession(
new CompositeProvider([failedProvider, unobservedProvider]),
);
expect((await session.open(targetPath)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(true);
expect((await session.execute("procedure_info", {})).ok).toBe(true);
failed = true;
expect(session.status()).toMatchObject({
provider_operation_health: {
state: "exited",
stage: "connection",
retry_action: "restart_provider",
exit_code: 7,
},
});
await session.close();
});
});
const dynamicProvider = (
id: string,
operation: Exclude<AnalysisOperation, "health">,
@@ -24,6 +24,7 @@ const cleanupError = new ProviderCleanupError(
"fixture",
["fixture-provider-process"],
{ reason: "process exit could not be confirmed" },
{ remediationAction: "Close the retained fixture process before retrying." },
);
const primaryError = new AnalysisTimeoutError("read_bytes", 100, {
@@ -111,6 +112,9 @@ describe("one-shot analysis cleanup outcomes", () => {
code: "cleanup_incomplete",
category: primary.category,
message: primary.message,
remediation: {
action: "Close the retained fixture process before retrying.",
},
details: {
execution_failure: primary.code,
captured_output: primary.details?.captured_output,
@@ -190,6 +194,9 @@ describe("managed one-shot cleanup outcomes", () => {
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
remediation: {
action: "Close the retained fixture process before retrying.",
},
details: {
resources: ["fixture-provider-process"],
diagnostics: {
+1
View File
@@ -229,6 +229,7 @@ const server = createServer((socket) => {
request.method === "shutdown" ||
request.method === "shutdown_document"
) {
if (shutdownMode === "exit-without-ack") process.exit(0);
send({ id: request.id, result: shutdownResult() });
if (shutdownMode !== "cleanup-required") setTimeout(closeServer, 2);
} else if (request.method === "hang") {
+22 -7
View File
@@ -73,8 +73,24 @@ def probe(path, socket_path, mode):
if "event" not in value:
return value
def finish(shutdown_count):
worker.join(2)
assert not worker.is_alive(), "Bridge pinned its execution thread"
assert not failures, failures
assert not Path(socket_path).exists(), "Bridge retained its closed listener"
assert requests.count("shutdown_document") == shutdown_count
return {"mode": mode, "requests": requests, "listener_removed": True, "thread_released": True}
if mode == "initial-expiry":
return finish(0)
# bind() creates the socket path before listen() makes it connectable.
client, file = connect(wait_until_listening=True)
if mode == "silent-expiry":
result = finish(0)
file.close()
client.close()
return result
send(file, "health", "foreign-token" if mode == "unauthorized" else "owned-token")
if mode == "lost-reply":
assert entered.wait(2)
@@ -82,6 +98,10 @@ def probe(path, socket_path, mode):
assert reply(file)["error"]["type"] == "authorization"
else:
assert reply(file)["result"]["run_id"] == "owned-run"
if mode == "authenticated-idle":
time.sleep(0.3)
send(file, "health")
assert reply(file)["result"]["run_id"] == "owned-run"
file.close()
client.close()
release.set()
@@ -95,14 +115,9 @@ def probe(path, socket_path, mode):
assert reply(file)["result"]["document_closed"] is True
file.close()
client.close()
worker.join(2)
assert not worker.is_alive(), "Bridge pinned its execution thread"
assert not failures, failures
assert not Path(socket_path).exists(), "Bridge retained its closed listener"
assert requests.count("shutdown_document") == (0 if mode == "expiry" else 2)
return {"mode": mode, "requests": requests, "listener_removed": True, "thread_released": True}
return finish(0 if mode == "expiry" else 2)
if __name__ == "__main__":
print(json.dumps([probe(sys.argv[1], str(Path(sys.argv[2]) / (mode + ".sock")), mode)
for mode in ("unauthorized", "disconnect", "lost-reply", "expiry")]))
for mode in ("unauthorized", "disconnect", "lost-reply", "authenticated-idle", "expiry", "initial-expiry", "silent-expiry")]))
+26
View File
@@ -0,0 +1,26 @@
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { basename } from "node:path";
const remove = fs.promises.rm;
let observed = false;
fs.promises.rm = async function (path, ...args) {
const name = basename(String(path));
if (
observed ||
!name.startsWith("rea-process-") ||
name.startsWith("rea-process-token-")
)
return remove.call(this, path, ...args);
observed = true;
if (process.env.REA_TEST_CANCEL_DURING_CLEANUP === "1")
process.kill(process.pid, "SIGINT");
// Give the CLI signal handler a turn while real resource cleanup is pending.
await new Promise((resolve) => setTimeout(resolve, 100));
await remove.call(this, path, ...args);
await fs.promises.writeFile(
process.env.REA_TEST_CLEANUP_MARKER,
JSON.stringify({ path: String(path), removed: true }),
);
};
syncBuiltinESMExports();