Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655b3eff6f | ||
|
|
e78455338f | ||
|
|
b6d5479ff0 | ||
|
|
5f6342db46 | ||
|
|
02d92e9830 | ||
|
|
c4c9a23bef | ||
|
|
8fd495bf13 | ||
|
|
760233c0b3 | ||
|
|
bde139c5d9 | ||
|
|
8b96354497 | ||
|
|
c83de52762 | ||
|
|
0ccc7572c7 | ||
|
|
98f22358e6 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const analysisErrorWithCleanupFailure = (
|
||||
{ cause: primary },
|
||||
),
|
||||
userMessage: projectAnalysisError(primary).message,
|
||||
remediationAction: projected.remediation.action,
|
||||
...(primary.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: primary.capturedOutput }),
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export class ProviderCleanupError extends ProviderAdapterError {
|
||||
options?: ErrorOptions & {
|
||||
readonly operation?: string;
|
||||
readonly partialObservation?: AnalysisPartialObservation;
|
||||
readonly remediationAction?: string;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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
@@ -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();
|
||||
Reference in new issue
Block a user