mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7c218977 | ||
|
|
106ba1730f | ||
|
|
f7dc5489b3 | ||
|
|
3e8a5f76d0 |
No files matched your search
@@ -9,7 +9,7 @@
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "rea-agents",
|
||||
"version": "6.8.0"
|
||||
"version": "6.9.0"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "rea-agents",
|
||||
"version": "6.8.0"
|
||||
"version": "6.9.0"
|
||||
},
|
||||
"category": "development"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea",
|
||||
"version": "6.8.0",
|
||||
"version": "6.9.0",
|
||||
"description": "Reverse engineer anything with agents, from app behavior down to native binaries.",
|
||||
"author": {
|
||||
"name": "morluto",
|
||||
@@ -21,7 +21,7 @@
|
||||
"command": "node",
|
||||
"args": [
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-mcp-launcher.mjs",
|
||||
"6.8.0"
|
||||
"6.9.0"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"command": "node",
|
||||
"args": [
|
||||
"./scripts/plugin-mcp-launcher.mjs",
|
||||
"6.8.0"
|
||||
"6.9.0"
|
||||
],
|
||||
"cwd": ".",
|
||||
"startup_timeout_sec": 30
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea",
|
||||
"version": "6.8.0",
|
||||
"version": "6.9.0",
|
||||
"description": "Reverse engineer anything with agents, from app behavior down to native binaries.",
|
||||
"author": {
|
||||
"name": "morluto",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "6.8.0"
|
||||
".": "6.9.0"
|
||||
}
|
||||
+1296
File diff suppressed because it is too large.
Load diff
@@ -243,6 +243,20 @@ def _hex(value):
|
||||
return "0x%x" % value
|
||||
|
||||
|
||||
def _source_location(address):
|
||||
"""Preserve available line-entry facets without acquiring source files."""
|
||||
entry = address.GetLineEntry()
|
||||
if not entry.IsValid():
|
||||
return None
|
||||
file_spec = entry.GetFileSpec()
|
||||
file_path = str(file_spec) if file_spec.IsValid() else None
|
||||
line = entry.GetLine() or None
|
||||
column = entry.GetColumn() or None
|
||||
if not file_path and line is None and column is None:
|
||||
return None
|
||||
return {"file_path": file_path or None, "line": line, "column": column}
|
||||
|
||||
|
||||
def _location(address, target):
|
||||
"""Load and file address, module and symbol of one code address."""
|
||||
module = address.GetModule()
|
||||
@@ -256,6 +270,7 @@ def _location(address, target):
|
||||
"module": module.GetFileSpec().GetFilename() if module.IsValid() else None,
|
||||
"module_path": str(module.GetFileSpec()) if module.IsValid() else None,
|
||||
"symbol": symbol.GetName() if symbol.IsValid() else None,
|
||||
"source_location": _source_location(address),
|
||||
}
|
||||
return location
|
||||
|
||||
|
||||
@@ -25,6 +25,45 @@ spec.loader.exec_module(tracer)
|
||||
|
||||
|
||||
class NativeTraceBudgetTests(unittest.TestCase):
|
||||
def test_source_coordinates_preserve_partial_metadata_without_reading_source(self):
|
||||
def address(valid=True, file_valid=True, line=23, column=7):
|
||||
class SourceFileSpec:
|
||||
def IsValid(self):
|
||||
return file_valid
|
||||
|
||||
def __str__(self):
|
||||
return "/missing/é space/fixture.pas"
|
||||
|
||||
file_spec = SourceFileSpec()
|
||||
entry = types.SimpleNamespace(
|
||||
IsValid=lambda: valid,
|
||||
GetFileSpec=lambda: file_spec,
|
||||
GetLine=lambda: line,
|
||||
GetColumn=lambda: column,
|
||||
)
|
||||
return types.SimpleNamespace(GetLineEntry=lambda: entry)
|
||||
|
||||
self.assertEqual(
|
||||
tracer._source_location(address()),
|
||||
{"file_path": "/missing/é space/fixture.pas", "line": 23, "column": 7},
|
||||
)
|
||||
self.assertEqual(
|
||||
tracer._source_location(address(column=0)),
|
||||
{"file_path": "/missing/é space/fixture.pas", "line": 23, "column": None},
|
||||
)
|
||||
self.assertEqual(
|
||||
tracer._source_location(address(file_valid=False)),
|
||||
{"file_path": None, "line": 23, "column": 7},
|
||||
)
|
||||
self.assertEqual(
|
||||
tracer._source_location(address(line=0)),
|
||||
{"file_path": "/missing/é space/fixture.pas", "line": None, "column": 7},
|
||||
)
|
||||
self.assertIsNone(tracer._source_location(address(valid=False)))
|
||||
self.assertIsNone(
|
||||
tracer._source_location(address(file_valid=False, line=0, column=0))
|
||||
)
|
||||
|
||||
def test_invalid_launch_process_reports_explicit_no_creation(self):
|
||||
class InvalidProcess:
|
||||
def IsValid(self):
|
||||
@@ -390,6 +429,9 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
return FileSpec()
|
||||
|
||||
class Address:
|
||||
def GetLineEntry(self):
|
||||
return types.SimpleNamespace(IsValid=lambda: False)
|
||||
|
||||
def GetModule(self):
|
||||
return Module()
|
||||
|
||||
@@ -491,7 +533,24 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
def GetFileSpec(self):
|
||||
return FileSpec()
|
||||
|
||||
class SourceFileSpec:
|
||||
def IsValid(self):
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return source_path
|
||||
|
||||
class Address:
|
||||
def GetLineEntry(self):
|
||||
if source_path is None:
|
||||
return types.SimpleNamespace(IsValid=lambda: False)
|
||||
return types.SimpleNamespace(
|
||||
IsValid=lambda: True,
|
||||
GetFileSpec=SourceFileSpec,
|
||||
GetLine=lambda: 15,
|
||||
GetColumn=lambda: 3,
|
||||
)
|
||||
|
||||
def GetModule(self):
|
||||
return Module()
|
||||
|
||||
@@ -518,19 +577,35 @@ class NativeTraceBudgetTests(unittest.TestCase):
|
||||
breakpoint_count = 1000
|
||||
created = [[Breakpoint()] for _ in range(breakpoint_count)]
|
||||
config = {"breakpoints": [{"kind": "objc-method"}] * breakpoint_count}
|
||||
report, locations_truncated = tracer._breakpoint_report(
|
||||
object(), created, config
|
||||
)
|
||||
retained_bytes = sum(
|
||||
len(json.dumps(location).encode("utf-8"))
|
||||
+ (1 if index > 0 else 0)
|
||||
for row in report
|
||||
for index, location in enumerate(row["locations"])
|
||||
)
|
||||
self.assertTrue(locations_truncated)
|
||||
self.assertEqual(sum(row["location_count"] for row in report), 64_000)
|
||||
self.assertLess(sum(len(row["locations"]) for row in report), 64_000)
|
||||
self.assertLessEqual(retained_bytes, tracer.MAX_BREAKPOINT_LOCATION_BYTES)
|
||||
for source_path in (None, "/tmp/" + "源🚀" * 3000 + "/calls.pas"):
|
||||
with self.subTest(source_path_present=source_path is not None):
|
||||
report, locations_truncated = tracer._breakpoint_report(
|
||||
object(), created, config
|
||||
)
|
||||
retained_bytes = sum(
|
||||
len(json.dumps(location).encode("utf-8"))
|
||||
+ (1 if index > 0 else 0)
|
||||
for row in report
|
||||
for index, location in enumerate(row["locations"])
|
||||
)
|
||||
self.assertTrue(locations_truncated)
|
||||
self.assertEqual(
|
||||
sum(row["location_count"] for row in report), 64_000
|
||||
)
|
||||
self.assertLess(
|
||||
sum(len(row["locations"]) for row in report), 64_000
|
||||
)
|
||||
self.assertLessEqual(
|
||||
retained_bytes, tracer.MAX_BREAKPOINT_LOCATION_BYTES
|
||||
)
|
||||
if source_path is not None:
|
||||
self.assertTrue(
|
||||
any(
|
||||
location["source_location"]["file_path"] == source_path
|
||||
for row in report
|
||||
for location in row["locations"]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1853,7 +1853,7 @@ terminal, use the package runner at the version the plugin pins:
|
||||
<!-- x-release-please-start-version -->
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@6.8.0 doctor --provider ghidra --json
|
||||
npx -y rea-agents@6.9.0 doctor --provider ghidra --json
|
||||
```
|
||||
|
||||
<!-- x-release-please-end -->
|
||||
|
||||
+30
-3
@@ -318,6 +318,8 @@ Retained `xrefs` Evidence also supports `native-observation` address pages and
|
||||
default and does not accept `detail`; when its MCP response exceeds the budget,
|
||||
follow the returned continuation to page the retained result. These addresses
|
||||
remain untyped; paging does not add reference kinds or establish completeness.
|
||||
The page tells callers that any additional provider detail recorded in the parent
|
||||
requires retrieving or exporting the complete Evidence.
|
||||
|
||||
These views reference complete source context through `source_context.digest`
|
||||
and caveat counts. Read it using the same parent with
|
||||
@@ -1296,6 +1298,22 @@ Those raw observations are not recorded in the Evidence bundle. If the SDK
|
||||
suppresses the error reply after client cancellation, Evidence tools cannot
|
||||
recover them. An empty bundle does not prove that no partial work or side
|
||||
effect occurred; output receipts and cleanup observations remain distinct.
|
||||
If an authenticated Evidence record exceeds the investigation's retention
|
||||
allowance, the refusal carries it inline as
|
||||
`details.partial_observation: {kind: "unretained-evidence", evidence: ...}`
|
||||
when it fits the response budget. Its ID identifies the supplied content; it
|
||||
is not a retained-session reference. If authentication itself failed or lacked
|
||||
headroom, no authenticated Evidence is claimed. When the completed record does
|
||||
not fit the MCP response, the original retention error carries
|
||||
`details.partial_observation_omitted` with the response constraint if the
|
||||
remaining error fits; the observation remains unretained. Otherwise the usual
|
||||
oversized-error recovery applies.
|
||||
When an atomic derived-Evidence and Unknown write exceeds the allowance after
|
||||
authentication, the completed Evidence is returned the same way if absent from
|
||||
the investigation. If that exact Evidence was already present, the error
|
||||
instead carries `{kind: "retained-evidence", evidence_id: ...}` for the current
|
||||
session. The refused write adds neither a record nor an Unknown revision; a
|
||||
previously retained record or Unknown remains available.
|
||||
The broader retention decision is tracked in
|
||||
[#4276](https://github.com/morluto/rea/issues/4276).
|
||||
|
||||
@@ -1577,12 +1595,21 @@ cursor outside a procedure is represented as
|
||||
procedure resolution returns typed unreadable output; provider errors never
|
||||
become absence from their message text. `inspect_address_context` requires an explicit address and
|
||||
returns name, procedure, comment, inline-comment, and bookmark facets;
|
||||
unsupported facets are local `unavailable` outcomes. A facet whose input is
|
||||
rejected includes its canonical `error` projection, including any structured
|
||||
issues, alongside the other facet outcomes. Use `current_document`,
|
||||
unsupported facets are local `unavailable` outcomes. A facet rejected for
|
||||
invalid input or a resource constraint includes its canonical `error`
|
||||
projection, including structured issues or reported limits and recovery,
|
||||
alongside the other facet outcomes. Use `current_document`,
|
||||
`current_address`, and `current_procedure` for direct single-field lookups; use
|
||||
the aggregate tools when you need related context together.
|
||||
|
||||
If unretained facet Evidence makes the context response exceed an MCP delivery
|
||||
limit, REA omits those partial records in estimated size order until the reply
|
||||
fits. Each affected facet identifies its unretained omission and the delivery
|
||||
constraint; other facets and partials remain in the response when they fit. The
|
||||
size estimate is used only for ordering and can omit more records than a perfect
|
||||
wire-size ordering would. If the remaining context still cannot fit, call the
|
||||
needed address operations individually and follow their delivery diagnostics.
|
||||
|
||||
## Integrity attribution and reconstruction claims
|
||||
|
||||
Artifact comparison results include `integrity_mismatch_evidence_ids`, naming
|
||||
|
||||
@@ -1154,6 +1154,25 @@ arguments. The receiver class comes from LLDB's Objective-C runtime reader
|
||||
without running target code; no expression is evaluated, and REA never
|
||||
attaches to a process it did not launch.
|
||||
|
||||
Resolved breakpoint locations, entries and caller frames also retain
|
||||
`source_location` from LLDB's line tables when available. Its `file_path`,
|
||||
one-based `line` and one-based `column` are independently nullable. An invalid
|
||||
line entry is null; historical captures may omit the field. This is debug metadata
|
||||
attribution: REA does not read that source path or verify the source file bytes.
|
||||
Absent coordinates do not imply that the executable has no source code.
|
||||
|
||||
Free Pascal programs with retained native symbols and usable DWARF can use this
|
||||
same bounded call observation on macOS. Select an exact emitted symbol from the
|
||||
binary rather than assuming a source procedure name. The owned Pascal fixture
|
||||
uses an explicit C calling convention and a public symbol; its verification
|
||||
covers call entries, raw integer registers and source coordinates, without
|
||||
establishing typed Pascal locals or interactive stepping. After building REA,
|
||||
run the real lane with an existing caller-selected compiler:
|
||||
|
||||
```sh
|
||||
REA_VERIFY_FPC_COMMAND=/absolute/path/to/fpc npm run verify:native:pascal-calls
|
||||
```
|
||||
|
||||
The run ends when the process exits, `max_events` entries are recorded,
|
||||
`duration_ms` elapses, or the aggregate trace resource budget is exhausted.
|
||||
Bounded termination reports partial coverage. REA checks process termination
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Implementation plan
|
||||
|
||||
## Boundary and contract
|
||||
|
||||
Extend nativeCodeLocationSchema in src/domain/native/nativeCallObservation.ts.
|
||||
Reuse that shape for events, breakpoint locations, backtraces and retained journal
|
||||
events. Keep the field optional for older evidence and nullable for current LLDB
|
||||
observations with unavailable metadata. Each inner facet can be independently
|
||||
unavailable; avoid dropping a valid line number because a file path is absent.
|
||||
|
||||
Read SBAddress.GetLineEntry once in the existing Python _location producer. A
|
||||
valid SBFileSpec supplies its displayed path without filesystem access. Convert
|
||||
zero line/column to null. An invalid entry returns null. Existing estimation and
|
||||
serialization budget checks already traverse the location object recursively.
|
||||
|
||||
## Incremental work and validation
|
||||
|
||||
1. Define contracts and criteria before code; add failing producer tests.
|
||||
2. Extend the canonical schema and _location producer. Update existing fake
|
||||
address objects to implement the actual LLDB line-entry interface.
|
||||
3. Extend provider/MCP boundary coverage and retained-journal coverage for the
|
||||
additive field; preserve omission compatibility.
|
||||
4. Extend the existing real macOS verifier with source-coordinate assertions
|
||||
against its own Objective-C/C fixture built with debug information.
|
||||
5. Update the native investigation guide; run Python tests, focused TypeScript
|
||||
tests, repository checks and the real verifier if host authority permits.
|
||||
|
||||
## Effects and compatibility
|
||||
|
||||
The existing LLDB operation already owns its breakpoint callbacks and process
|
||||
lifecycle. This adds a metadata read within that observation. It does not introduce
|
||||
target execution or a new provider beyond existing behavior. No source file is
|
||||
acquired from a path in debug information. Consumers of historical evidence retain
|
||||
omission; consumers of new captures see either a partial/full object or null.
|
||||
|
||||
## References
|
||||
|
||||
- https://lldb.llvm.org/python_api/lldb.SBAddress.html
|
||||
- https://lldb.llvm.org/python_api/lldb.SBLineEntry.html
|
||||
@@ -0,0 +1,36 @@
|
||||
# Native call source coordinates
|
||||
|
||||
Issue: https://github.com/morluto/rea/issues/2382
|
||||
|
||||
## Problem and outcome
|
||||
|
||||
The existing bounded macOS LLDB call observation retains module, symbol and
|
||||
addresses, but loses compiler-provided source coordinates. Retain line-entry
|
||||
metadata at the same producer boundary for breakpoint locations, call entries
|
||||
and caller frames.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Add optional nullable source_location to the canonical native code location.
|
||||
A present object contains nullable file_path, line and column. Positive line
|
||||
and column values are one-based. Provider zero values mean unavailable.
|
||||
- Preserve partial facts from a valid line entry. Invalid entries or entries
|
||||
without any usable facet produce null. Historical captures may omit the field.
|
||||
- Read only SBAddress line metadata; do not resolve paths, read source contents,
|
||||
evaluate expressions or change the existing launch, stop or cleanup policy.
|
||||
- The field establishes LLDB line-table attribution, not verified source bytes.
|
||||
- Existing trace and breakpoint byte budgets include the new representation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Producer tests cover valid, invalid and partial entries, Unicode paths and absent
|
||||
coordinates. Existing evidence without the field remains accepted. CLI/MCP and
|
||||
partial retained-journal paths preserve the canonical field. The real native-call
|
||||
verifier checks source coordinates for its owned debug build, including caller
|
||||
frames and breakpoint locations, and reports actual host/provider coverage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New debugger adapters, interactive stepping, source breakpoints, source-file
|
||||
association verification, source reads, Pascal/Delphi compatibility claims and
|
||||
Java/Android/CLR runtime support.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Tasks
|
||||
|
||||
The checked items below record work completed on an earlier revision. The
|
||||
current revision's final caller-frame assertions, marker-derived line
|
||||
expectations, timeout handling, and Unicode budget case remain source-reviewed
|
||||
only; the full native-call verifier is incomplete.
|
||||
|
||||
- [x] Define the single-feature scope, contract and implementation plan.
|
||||
- [x] Reproduce lost source coordinates with producer regression tests.
|
||||
- [x] Retain line-entry metadata in the producer and canonical schema.
|
||||
- [x] Verify boundary/MCP propagation and historical omission.
|
||||
- [x] Historical: verify real CLI/MCP call coordinates on an owned Free Pascal 3.2.2 program.
|
||||
- [x] Historical: update documentation; pass Python, focused TypeScript, static and discovery checks.
|
||||
- [ ] Complete the full native-call verifier after investigating #2397.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Historical verification
|
||||
|
||||
The records below describe earlier revisions, before the final caller-frame
|
||||
assertions, marker-derived line expectations, forced compiler timeout termination,
|
||||
and large Unicode source-location budget coverage. They do not validate the
|
||||
current revision. Those final changes were reviewed from source only; tests and
|
||||
automated checks were not run at the user’s explicit request.
|
||||
|
||||
Host: macOS ARM64. Compiler: caller-installed Free Pascal 3.2.2, Darwin AArch64.
|
||||
Debugger: LLDB 2103.0.34.103 from Command Line Tools.
|
||||
|
||||
- Python producer/budget/output tests: 10 passed.
|
||||
- Focused native provider, retained-journal and MCP tests: 18 passed.
|
||||
- Real Pascal CLI and stdio MCP: three expected calls, integer register values
|
||||
0/1/2, source path and positive line attribution, successful target exit.
|
||||
- Repository static checks: six tasks and architecture guards passed.
|
||||
- Test discovery: all 982 test files discovered exactly once.
|
||||
|
||||
The complete native-call verifier did not pass. One run reported exited instead
|
||||
of event-limit for capped overlapping breakpoints. Another reported unavailable
|
||||
launch start identity in a later capped scenario. Investigation:
|
||||
https://github.com/morluto/rea/issues/2397. These results have not yet been isolated
|
||||
on unchanged upstream. The source-coordinate PR was draft at that historical checkpoint.
|
||||
|
||||
The first real native scenario and CLI/MCP source-coordinate comparisons completed
|
||||
before those broader failures. The Pascal lane verifies bounded native call
|
||||
observation; it does not establish typed Pascal local values, source breakpoints,
|
||||
interactive stepping, Delphi compatibility or a GDB provider.
|
||||
|
||||
## Unmeasured costs and outstanding execution
|
||||
|
||||
Repeated source paths expand event, caller-frame and breakpoint-location payloads.
|
||||
`nativeCallObservation.test.ts` measures that growth as UTF-8 JSON bytes: three
|
||||
copies of one location grow by three times the difference between the
|
||||
`source_location` object and `null`. That byte delta is not a token count.
|
||||
Equivalent before/after completed workflows have not been measured for agent tokens,
|
||||
elapsed time, CPU time or peak process-family memory, for either cold or warm runs.
|
||||
The first compiler probe is only `fpc -iV` with a 30s deadline. Darwin ownership-helper
|
||||
compilation uses `REA_SWIFTC_COMPILE_TIMEOUT_MS`, default 120s, inside the later
|
||||
180s `observe-native-calls` run.
|
||||
|
||||
The current caller-frame equality checks, marker-derived source lines, forced
|
||||
compiler timeout termination and Unicode source-path budget case remain unverified
|
||||
by execution. Earlier Pascal CLI/MCP observations establish only the historical
|
||||
coverage described above.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "6.8.0",
|
||||
"version": "6.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "6.8.0",
|
||||
"version": "6.9.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"mcpName": "io.github.morluto/rea",
|
||||
"version": "6.8.0",
|
||||
"version": "6.9.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -294,6 +294,7 @@
|
||||
"prepare": "node scripts/prepare.mjs",
|
||||
"verify:asset-catalog": "npm run build:cached && node scripts/verify/apple/asset-catalog.mjs",
|
||||
"verify:native-calls": "npm run build:cached && node scripts/verify-native-calls.mjs",
|
||||
"verify:native:pascal-calls": "npm run build:cached && node scripts/verify-native-pascal-calls.mjs",
|
||||
"verify:native-ui:permissions": "npm run build:cached && node scripts/verify-native-ui.mjs --allow-permission-denial",
|
||||
"test:focused": "node scripts/run-development-tests.mjs focused",
|
||||
"test:sqlite": "npm run test:prepare && npm run test:sqlite:run",
|
||||
|
||||
+124
-46
@@ -1,9 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { createVerifierCommands } from "./verify-commands.mjs";
|
||||
import { createOwnedMcpStdioTransport } from "./owned-mcp-stdio.mjs";
|
||||
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
|
||||
import { INLINE_EVIDENCE_MCP_ENVIRONMENT } from "./mcp-verification-environment.mjs";
|
||||
|
||||
@@ -75,6 +74,23 @@ export async function artifactMcpCall(client, parameters, options = {}) {
|
||||
return client.callTool(parameters, { ...options, toolDefinition });
|
||||
}
|
||||
|
||||
/** Retain the exact command owner when a verifier cannot finish cleanup. */
|
||||
class ArtifactVerifierCleanupError extends Error {
|
||||
constructor(cause, cleanupOwner) {
|
||||
super("Artifact verifier cleanup is incomplete", { cause });
|
||||
this.name = "ArtifactVerifierCleanupError";
|
||||
this.cleanupOwner = cleanupOwner;
|
||||
}
|
||||
}
|
||||
|
||||
const closeOwnedCommands = async (owner) => {
|
||||
try {
|
||||
await owner.close();
|
||||
} catch (cause) {
|
||||
throw new ArtifactVerifierCleanupError(cause, owner);
|
||||
}
|
||||
};
|
||||
|
||||
const environment = () => {
|
||||
const compileCache =
|
||||
process.env.NODE_COMPILE_CACHE ??
|
||||
@@ -109,68 +125,130 @@ export async function artifactCliEvidence(
|
||||
command,
|
||||
target,
|
||||
arguments_ = [],
|
||||
{ timeoutMs = 60000 } = {},
|
||||
{ timeoutMs = 60000, signal, commands } = {},
|
||||
) {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
command,
|
||||
target,
|
||||
...arguments_,
|
||||
"--json",
|
||||
],
|
||||
{ env: environment(), timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const owner = commands ?? (await createVerifierCommands());
|
||||
const execute = () =>
|
||||
owner.exec(
|
||||
process.execPath,
|
||||
[
|
||||
fileURLToPath(new URL("../rea.mjs", import.meta.url)),
|
||||
command,
|
||||
target,
|
||||
...arguments_,
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: environment(),
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const { stdout } =
|
||||
commands === undefined
|
||||
? await withVerifierCleanup(execute, [() => closeOwnedCommands(owner)])
|
||||
: await execute();
|
||||
signal?.throwIfAborted();
|
||||
const evidence = JSON.parse(stdout);
|
||||
assert.equal(evidence.error, undefined, stdout);
|
||||
assert.ok(evidence.normalized_result, "CLI omitted normalized result");
|
||||
return evidence;
|
||||
}
|
||||
|
||||
const withOpenedArtifactMcp = async (target, openTool, verify) => {
|
||||
const transport = new StdioClientTransport({
|
||||
const withOpenedArtifactMcp = async (
|
||||
target,
|
||||
openTool,
|
||||
verify,
|
||||
options = {},
|
||||
) => {
|
||||
options.signal?.throwIfAborted();
|
||||
const owner = options.commands ?? (await createVerifierCommands());
|
||||
const transport = await createOwnedMcpStdioTransport({
|
||||
command: process.execPath,
|
||||
args: [fileURLToPath(new URL("../rea.mjs", import.meta.url)), "mcp"],
|
||||
env: { ...environment(), ...INLINE_EVIDENCE_MCP_ENVIRONMENT },
|
||||
stderr: "pipe",
|
||||
arguments: [fileURLToPath(new URL("../rea.mjs", import.meta.url)), "mcp"],
|
||||
hostEnvironment: { ...environment(), ...INLINE_EVIDENCE_MCP_ENVIRONMENT },
|
||||
cwd: process.cwd(),
|
||||
expectedCommand: null,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
signal: options.signal,
|
||||
});
|
||||
owner.adopt(transport, "artifact MCP process");
|
||||
const client = new Client({ name: "artifact-real-e2e", version: "1" });
|
||||
return withVerifierCleanup(async () => {
|
||||
await client.connect(transport);
|
||||
const opened = await artifactMcpCall(client, {
|
||||
name: openTool,
|
||||
arguments: { path: target },
|
||||
});
|
||||
assert.notEqual(opened.isError, true, JSON.stringify(opened));
|
||||
if (openTool === "open_bundle_resources")
|
||||
assert.equal(opened.structuredContent?.result?.caller_path, target);
|
||||
await verify(client);
|
||||
}, [
|
||||
() => {
|
||||
toolBindings.delete(client);
|
||||
},
|
||||
() => client.close(),
|
||||
() => transport.close(),
|
||||
]);
|
||||
let clientClose;
|
||||
const closeClient = () =>
|
||||
(clientClose ??= Promise.resolve().then(() => client.close()));
|
||||
const abortTransport = () => {
|
||||
// Keep the same promise for finalization; concurrent SDK closes can return
|
||||
// before the first close has observed the child exit.
|
||||
void closeClient().catch(() => undefined);
|
||||
};
|
||||
options.signal?.addEventListener("abort", abortTransport, { once: true });
|
||||
try {
|
||||
const result = await withVerifierCleanup(async () => {
|
||||
options.signal?.throwIfAborted();
|
||||
await client.connect(transport);
|
||||
options.signal?.throwIfAborted();
|
||||
const opened = await artifactMcpCall(
|
||||
client,
|
||||
{
|
||||
name: openTool,
|
||||
arguments: { path: target },
|
||||
},
|
||||
{ signal: options.signal },
|
||||
);
|
||||
assert.notEqual(opened.isError, true, JSON.stringify(opened));
|
||||
if (openTool === "open_bundle_resources")
|
||||
assert.equal(opened.structuredContent?.result?.caller_path, target);
|
||||
await verify(client);
|
||||
}, [
|
||||
() => {
|
||||
toolBindings.delete(client);
|
||||
},
|
||||
() => closeClient(),
|
||||
() => transport.close(),
|
||||
...(options.commands === undefined
|
||||
? [() => closeOwnedCommands(owner)]
|
||||
: []),
|
||||
]);
|
||||
options.signal?.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
options.signal?.removeEventListener("abort", abortTransport);
|
||||
}
|
||||
};
|
||||
|
||||
/** Run a real stdio MCP subprocess with an explicitly opened binary target. */
|
||||
export async function withArtifactMcp(target, verify) {
|
||||
return withOpenedArtifactMcp(target, "open_binary", verify);
|
||||
export async function withArtifactMcp(target, verify, options = {}) {
|
||||
return withOpenedArtifactMcp(target, "open_binary", verify, options);
|
||||
}
|
||||
|
||||
/** Run a real stdio MCP subprocess with an explicitly opened bundle resource root. */
|
||||
export async function withBundleResourcesMcp(target, verify) {
|
||||
return withOpenedArtifactMcp(target, "open_bundle_resources", verify);
|
||||
export async function withBundleResourcesMcp(target, verify, options = {}) {
|
||||
return withOpenedArtifactMcp(
|
||||
target,
|
||||
"open_bundle_resources",
|
||||
verify,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Require a successful structured MCP tool result rather than its text rendering. */
|
||||
export async function artifactMcpResult(client, name, arguments_ = {}) {
|
||||
const result = await artifactMcpCall(client, {
|
||||
name,
|
||||
arguments: arguments_,
|
||||
});
|
||||
export async function artifactMcpResult(
|
||||
client,
|
||||
name,
|
||||
arguments_ = {},
|
||||
options = {},
|
||||
) {
|
||||
const result = await artifactMcpCall(
|
||||
client,
|
||||
{
|
||||
name,
|
||||
arguments: arguments_,
|
||||
},
|
||||
options,
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
assert.ok(
|
||||
result.structuredContent?.normalized_result,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Allocate under a caller-selected parent and return the host's canonical spelling. */
|
||||
export declare const createCanonicalTemporaryDirectory: (
|
||||
prefix: string,
|
||||
parent?: string,
|
||||
) => Promise<{ readonly path: string; readonly allocatedPath: string }>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Allocate under a caller-selected parent and return the host's canonical spelling. */
|
||||
export const createCanonicalTemporaryDirectory = async (
|
||||
prefix,
|
||||
parent = tmpdir(),
|
||||
) => {
|
||||
const allocatedPath = await mkdtemp(join(parent, prefix));
|
||||
try {
|
||||
return { path: await realpath(allocatedPath), allocatedPath };
|
||||
} catch (cause) {
|
||||
await rm(allocatedPath, { recursive: true, force: true });
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { importVerifierSource } from "./verifier-source-loader.mjs";
|
||||
|
||||
let implementation;
|
||||
|
||||
/** Create an SDK-compatible transport which retains the exact process owner. */
|
||||
export async function createOwnedMcpStdioTransport(options) {
|
||||
implementation ??= importVerifierSource(
|
||||
new URL("../../src/process/OwnedMcpStdioTransport.ts", import.meta.url)
|
||||
.href,
|
||||
import.meta.url,
|
||||
);
|
||||
const { OwnedMcpStdioTransport } = await implementation;
|
||||
return new OwnedMcpStdioTransport(options, {
|
||||
provider: "artifact-verifier",
|
||||
label: "Artifact verifier",
|
||||
// Match the SDK's default maximum buffered stdio message size.
|
||||
maxFrameBytes: 10 * 1024 * 1024,
|
||||
// Unlike the JADX adapter, artifact verifiers do not impose a cumulative
|
||||
// per-connection output limit; the SDK bounds each buffered message.
|
||||
maxOperationBytes: Infinity,
|
||||
gracefulInputCloseMs: 2000,
|
||||
maxDiagnosticBytes: 64 * 1024,
|
||||
});
|
||||
}
|
||||
@@ -61,6 +61,10 @@ const stable = (result) =>
|
||||
file_address: event.file_address,
|
||||
selector: event.selector,
|
||||
receiver_class: event.receiver_class,
|
||||
source_location: event.source_location,
|
||||
caller_source_locations: event.backtrace.map(
|
||||
(frame) => frame.source_location,
|
||||
),
|
||||
counter:
|
||||
event.symbol === GREET
|
||||
? event.registers[3].value
|
||||
@@ -96,6 +100,18 @@ const checkScenario = async (binary) => {
|
||||
});
|
||||
const greet = result.events.filter(({ symbol }) => symbol === GREET);
|
||||
const add = result.events.filter(({ symbol }) => symbol === "rea_call_add");
|
||||
for (const location of [
|
||||
...result.events,
|
||||
...result.events.flatMap((event) => event.backtrace),
|
||||
...result.breakpoints.flatMap((breakpoint) => breakpoint.locations),
|
||||
]) {
|
||||
assert.equal(location.source_location?.file_path, SOURCE);
|
||||
assert.ok(location.source_location.line > 0);
|
||||
assert.ok(
|
||||
location.source_location.column === null ||
|
||||
location.source_location.column > 0,
|
||||
);
|
||||
}
|
||||
assert.equal(greet.length, 3, JSON.stringify(result));
|
||||
assert.equal(add.length, 3, JSON.stringify(result));
|
||||
for (const [index, event] of greet.entries()) {
|
||||
@@ -269,6 +285,8 @@ try {
|
||||
const binary = join(directory, "ReaCalls");
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"clang",
|
||||
"-g",
|
||||
"-O0",
|
||||
"-fobjc-arc",
|
||||
"-framework",
|
||||
"Foundation",
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPackageVerifierCommands } from "./lib/verify-package-commands.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
|
||||
import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./lib/artifact-e2e.mjs";
|
||||
import { createCanonicalTemporaryDirectory } from "./lib/canonical-temporary-directory.mjs";
|
||||
|
||||
const cancellation = new AbortController();
|
||||
let shutdownSignal;
|
||||
const signalExitCodes = { SIGINT: 130, SIGTERM: 143 };
|
||||
const forwardCancellation = (signal) => {
|
||||
if (shutdownSignal !== undefined) return;
|
||||
shutdownSignal = signal;
|
||||
cancellation.abort(new Error(`Verification cancelled by ${signal}`));
|
||||
};
|
||||
const signalHandlers = Object.fromEntries(
|
||||
Object.keys(signalExitCodes).map((signal) => [
|
||||
signal,
|
||||
() => forwardCancellation(signal),
|
||||
]),
|
||||
);
|
||||
for (const [signal, handler] of Object.entries(signalHandlers))
|
||||
process.on(signal, handler);
|
||||
|
||||
const compiler = process.env.REA_VERIFY_FPC_COMMAND;
|
||||
if (process.platform !== "darwin" || !isAbsolute(compiler ?? ""))
|
||||
throw new Error(
|
||||
"Requires macOS and an absolute caller-selected REA_VERIFY_FPC_COMMAND; build the runtime first.",
|
||||
);
|
||||
const source = fileURLToPath(
|
||||
new URL("../tests/conformance/native/calls.pas", import.meta.url),
|
||||
);
|
||||
const fixtureLines = (await readFile(source, "utf8")).split(/\r?\n/u);
|
||||
const callerLines = new Set(
|
||||
["REA_VERIFY_CALLER_LOOP", "REA_VERIFY_CALLER_CALL"].map((marker) => {
|
||||
const matches = fixtureLines.flatMap((line, index) =>
|
||||
line.includes(marker) ? [index + 1] : [],
|
||||
);
|
||||
assert.equal(matches.length, 1, `fixture has one ${marker} marker`);
|
||||
return matches[0];
|
||||
}),
|
||||
);
|
||||
const allocatedDirectory =
|
||||
await createCanonicalTemporaryDirectory("rea-pascal-calls-");
|
||||
let directory = allocatedDirectory.path;
|
||||
const commands = await createPackageVerifierCommands({
|
||||
workspace: {
|
||||
path: allocatedDirectory.allocatedPath,
|
||||
close: () =>
|
||||
rm(allocatedDirectory.allocatedPath, { recursive: true, force: true }),
|
||||
},
|
||||
});
|
||||
const execute = (command, arguments_, { timeout }) =>
|
||||
commands.exec(command, arguments_, {
|
||||
timeout,
|
||||
diagnosticBytes: 1024 * 1024,
|
||||
signal: cancellation.signal,
|
||||
});
|
||||
const input = {
|
||||
breakpoints: [{ kind: "function", name: "rea_pascal_call" }],
|
||||
argument_registers: 1,
|
||||
backtrace_frames: 1,
|
||||
duration_ms: 30_000,
|
||||
};
|
||||
const inspect = (result) => {
|
||||
assert.equal(result.process.outcome, "exited");
|
||||
assert.equal(result.process.exit_status, 0);
|
||||
assert.equal(result.process.terminated, true);
|
||||
assert.equal(result.process.stdout.text, "pascal 0\npascal 1\npascal 2\n");
|
||||
assert.equal(result.events.length, 3);
|
||||
assert.deepEqual(
|
||||
result.events.map((event) => event.registers[0].value),
|
||||
["0x0", "0x1", "0x2"],
|
||||
);
|
||||
assert.equal(result.breakpoints[0].location_count, 1);
|
||||
for (const event of result.events) {
|
||||
assert.equal(event.backtrace.length, 1);
|
||||
assert.equal(event.backtrace[0].source_location?.file_path, source);
|
||||
assert.equal(event.backtrace[0].module_path, join(directory, "calls"));
|
||||
assert.ok(
|
||||
callerLines.has(event.backtrace[0].source_location.line),
|
||||
"caller is the Pascal loop/call site",
|
||||
);
|
||||
}
|
||||
for (const location of [
|
||||
...result.events,
|
||||
...result.breakpoints[0].locations,
|
||||
]) {
|
||||
assert.equal(location.source_location?.file_path, source);
|
||||
assert.ok(location.source_location.line > 0);
|
||||
}
|
||||
};
|
||||
const stable = (result) =>
|
||||
result.events.map((event) => ({
|
||||
file_address: event.file_address,
|
||||
source_location: event.source_location,
|
||||
registers: event.registers,
|
||||
callers: event.backtrace.map((frame) => ({
|
||||
file_address: frame.file_address,
|
||||
symbol: frame.symbol,
|
||||
source_location: frame.source_location,
|
||||
})),
|
||||
}));
|
||||
try {
|
||||
const result = await withVerifierCleanup(async () => {
|
||||
const version = (
|
||||
await execute(compiler, ["-iV"], { timeout: 30_000 })
|
||||
).stdout.trim();
|
||||
const architecture = (
|
||||
await execute(compiler, ["-iTP"], {
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
await execute(
|
||||
compiler,
|
||||
[
|
||||
"-dDEBUG",
|
||||
"-gw3",
|
||||
"-O-",
|
||||
"-Xs-",
|
||||
`-FE${directory}`,
|
||||
`-FU${directory}`,
|
||||
source,
|
||||
],
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
const binary = join(directory, "calls");
|
||||
const cli = await artifactCli(
|
||||
"observe-native-calls",
|
||||
binary,
|
||||
[JSON.stringify(input)],
|
||||
{ timeoutMs: 180_000, signal: cancellation.signal, commands },
|
||||
);
|
||||
cancellation.signal.throwIfAborted();
|
||||
inspect(cli);
|
||||
await withArtifactMcp(
|
||||
binary,
|
||||
async (client) => {
|
||||
const mcp = await artifactMcpResult(
|
||||
client,
|
||||
"observe_native_calls",
|
||||
input,
|
||||
{ signal: cancellation.signal },
|
||||
);
|
||||
cancellation.signal.throwIfAborted();
|
||||
inspect(mcp);
|
||||
assert.deepEqual(stable(mcp), stable(cli));
|
||||
},
|
||||
{ signal: cancellation.signal, commands },
|
||||
);
|
||||
cancellation.signal.throwIfAborted();
|
||||
return {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
compiler,
|
||||
version,
|
||||
architecture,
|
||||
debugger: cli.debugger,
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
scope:
|
||||
"bounded native call entries and compiler-provided source coordinates; no typed Pascal locals or interactive stepping",
|
||||
};
|
||||
}, [() => commands.close()]);
|
||||
cancellation.signal.throwIfAborted();
|
||||
console.log(JSON.stringify(result));
|
||||
} catch (cause) {
|
||||
const cancelled =
|
||||
cause === cancellation.signal.reason ||
|
||||
(cause?.reason === "cancelled" &&
|
||||
cause.cancellation?.reason === cancellation.signal.reason &&
|
||||
cause.cleanupFailure === null);
|
||||
if (shutdownSignal === undefined || !cancelled) throw cause;
|
||||
console.error(`Verification cancelled by ${shutdownSignal}.`);
|
||||
process.exitCode = signalExitCodes[shutdownSignal];
|
||||
} finally {
|
||||
for (const signal of Object.keys(signalExitCodes))
|
||||
process.off(signal, signalHandlers[signal]);
|
||||
}
|
||||
+2
-2
@@ -8,13 +8,13 @@
|
||||
"url": "https://github.com/morluto/rea",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "6.8.0",
|
||||
"version": "6.9.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "npm",
|
||||
"registryBaseUrl": "https://registry.npmjs.org",
|
||||
"identifier": "rea-agents",
|
||||
"version": "6.8.0",
|
||||
"version": "6.9.0",
|
||||
"runtimeHint": "npx",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
|
||||
+20
-266
@@ -1,275 +1,29 @@
|
||||
import {
|
||||
deserializeMessage,
|
||||
serializeMessage,
|
||||
type JSONRPCMessage,
|
||||
type Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { finished } from "node:stream/promises";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
OwnedMcpStdioTransport,
|
||||
type OwnedMcpLauncher,
|
||||
type OwnedMcpTransportFailure,
|
||||
} from "../process/OwnedMcpStdioTransport.js";
|
||||
import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js";
|
||||
|
||||
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_OPERATION_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** Injectable acquisition seam; production owns a POSIX group or Windows job. */
|
||||
export type JadxLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/** The first observed failure, recorded before notifying the SDK or beginning cleanup. */
|
||||
export type JadxTransportFailure =
|
||||
| { readonly kind: "process"; readonly cause: unknown }
|
||||
| { readonly kind: "exit" }
|
||||
| { readonly kind: "protocol"; readonly cause: Error }
|
||||
| { readonly kind: "interrupted"; readonly reason: unknown };
|
||||
|
||||
/** SDK-framed stdio with REA-owned process cleanup rather than an unowned child. */
|
||||
export class JadxMcpTransport implements Transport {
|
||||
onclose: Transport["onclose"];
|
||||
onerror: Transport["onerror"];
|
||||
onmessage: Transport["onmessage"];
|
||||
readonly runId = randomUUID();
|
||||
#spawned: SpawnedOwnedProviderProcess | undefined;
|
||||
#supervisor: ProviderProcessSupervisor | undefined;
|
||||
#frameBuffer = Buffer.alloc(0);
|
||||
#frameLength = 0;
|
||||
#bytes = 0;
|
||||
#started = false;
|
||||
#closed = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#starting: Promise<void> | undefined;
|
||||
#failure: JadxTransportFailure | undefined;
|
||||
export type JadxLauncher = OwnedMcpLauncher;
|
||||
export type JadxTransportFailure = OwnedMcpTransportFailure;
|
||||
|
||||
/** The JADX provider's bounded stdio protocol transport. */
|
||||
export class JadxMcpTransport extends OwnedMcpStdioTransport {
|
||||
constructor(
|
||||
readonly options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
|
||||
readonly launcher: JadxLauncher = spawnOwnedProviderProcess,
|
||||
) {}
|
||||
|
||||
/** Acquires exactly one protocol process after the SDK installs callbacks. */
|
||||
start(): Promise<void> {
|
||||
if (this.#started || this.#closed)
|
||||
throw new Error("JADX transport cannot be restarted");
|
||||
this.#started = true;
|
||||
this.#starting = this.#acquire().catch((cause: unknown) => {
|
||||
this.#failure ??= { kind: "process", cause };
|
||||
throw cause;
|
||||
});
|
||||
return this.#starting;
|
||||
}
|
||||
|
||||
async #acquire(): Promise<void> {
|
||||
const spawned = await this.launcher({
|
||||
...this.options,
|
||||
runId: this.runId,
|
||||
stdin: "pipe",
|
||||
});
|
||||
this.#spawned = spawned;
|
||||
this.#supervisor = new ProviderProcessSupervisor(
|
||||
options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
|
||||
launcher?: JadxLauncher,
|
||||
) {
|
||||
super(
|
||||
options,
|
||||
{
|
||||
...spawned,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup:
|
||||
spawned.cleanup ??
|
||||
(() => cleanupOwnedProcessGroup(spawned.ownership)),
|
||||
provider: "jadx",
|
||||
label: "JADX",
|
||||
maxFrameBytes: 8 * 1024 * 1024,
|
||||
maxOperationBytes: 32 * 1024 * 1024,
|
||||
notifyOnExit: true,
|
||||
},
|
||||
{ captureStdout: false },
|
||||
launcher,
|
||||
);
|
||||
if (
|
||||
spawned.process.stdin === undefined ||
|
||||
spawned.process.stdin === null ||
|
||||
spawned.process.stdout === null
|
||||
)
|
||||
throw new Error("JADX launcher did not supply protocol stdin/stdout");
|
||||
spawned.process.stdout.on("data", this.#read);
|
||||
spawned.process.stdout.on("error", this.#error);
|
||||
spawned.process.stderr?.on("data", this.#countStderr);
|
||||
spawned.process.on("error", this.#error);
|
||||
spawned.process.once("exit", this.#exit);
|
||||
spawned.process.stdin.on("error", this.#error);
|
||||
if (this.#closed) throw new Error("JADX transport closed during startup");
|
||||
}
|
||||
|
||||
/** Writes SDK-serialized messages and observes stream backpressure. */
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
const input = this.#spawned?.process.stdin;
|
||||
if (this.#closed || input === undefined || input === null)
|
||||
throw new Error("JADX protocol process is not writable");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
input.write(serializeMessage(message), (cause) => {
|
||||
if (cause === null || cause === undefined) resolve();
|
||||
else {
|
||||
this.#failure ??= { kind: "process", cause };
|
||||
reject(cause);
|
||||
}
|
||||
});
|
||||
} catch (cause) {
|
||||
this.#failure ??= { kind: "process", cause };
|
||||
reject(cause);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Verifies owned process exit before declaring the transport closed. */
|
||||
close(): Promise<void> {
|
||||
this.#closePromise ??= this.#stop().catch((cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
/** Keep the interruption reason distinct from errors produced by its teardown. */
|
||||
interrupt(reason: unknown): Promise<void> {
|
||||
this.#failure ??= { kind: "interrupted", reason };
|
||||
return this.close();
|
||||
}
|
||||
|
||||
/** Captured provider stderr without persisting inherited environment values. */
|
||||
diagnostics(): AnalysisCapturedOutput | undefined {
|
||||
const owner = this.#supervisor;
|
||||
if (owner === undefined) return undefined;
|
||||
const snapshot = owner.snapshot();
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: snapshot.stderr.text,
|
||||
stderr_bytes: snapshot.stderr.observedBytes,
|
||||
truncated:
|
||||
snapshot.diagnosticTruncated === true || !owner.observedOutputClose(),
|
||||
...(snapshot.exitCode === undefined
|
||||
? {}
|
||||
: { exit_code: snapshot.exitCode }),
|
||||
...(snapshot.signal === undefined ? {} : { signal: snapshot.signal }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve the failed protocol/resource constraint after the SDK closes requests. */
|
||||
observedFailure(): JadxTransportFailure | undefined {
|
||||
return this.#failure;
|
||||
}
|
||||
|
||||
/** Scope both the byte budget and diagnostic retention to the next serialized request. */
|
||||
beginOperation(): void {
|
||||
this.#bytes = 0;
|
||||
this.#supervisor?.resetOutput();
|
||||
}
|
||||
|
||||
async #stop(): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#frameBuffer = Buffer.alloc(0);
|
||||
this.#frameLength = 0;
|
||||
// A cancellation may race acquisition. Join it before inspecting ownership.
|
||||
await this.#starting?.catch(() => undefined);
|
||||
const process = this.#spawned?.process;
|
||||
process?.stdout?.off("data", this.#read);
|
||||
process?.stdout?.off("error", this.#error);
|
||||
process?.stderr?.off("data", this.#countStderr);
|
||||
process?.off("error", this.#error);
|
||||
process?.off("exit", this.#exit);
|
||||
const input = process?.stdin;
|
||||
// A job termination can complete an in-flight pipe write asynchronously.
|
||||
// Observe its errors until the owned Writable has finished closing.
|
||||
const inputClosed =
|
||||
input === undefined || input === null
|
||||
? undefined
|
||||
: finished(input, { readable: false, cleanup: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const stopped = await this.#supervisor?.stop();
|
||||
input?.destroy();
|
||||
await inputClosed;
|
||||
input?.off("error", this.#error);
|
||||
this.onclose?.();
|
||||
if (stopped?.status === "incomplete")
|
||||
throw new ProviderCleanupError("jadx", [this.runId], {
|
||||
reason: stopped.reason,
|
||||
stderr: this.diagnostics()?.stderr ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
readonly #error = (error: Error): void => {
|
||||
this.#failure ??= { kind: "process", cause: error };
|
||||
this.onerror?.(error);
|
||||
// Reject pending SDK work after joining the same owned teardown on every path.
|
||||
void this.close().catch((cause: unknown) =>
|
||||
this.onerror?.(cause instanceof Error ? cause : new Error(String(cause))),
|
||||
);
|
||||
};
|
||||
readonly #exit = (): void => {
|
||||
this.#failure ??= { kind: "exit" };
|
||||
this.onclose?.();
|
||||
};
|
||||
readonly #countStderr = (chunk: Buffer | string): void => {
|
||||
this.#bytes += Buffer.byteLength(chunk);
|
||||
if (this.#bytes > MAX_OPERATION_BYTES)
|
||||
this.#fail(
|
||||
new Error(
|
||||
`JADX operation output exceeds ${MAX_OPERATION_BYTES} bytes; no complete result is available`,
|
||||
),
|
||||
);
|
||||
};
|
||||
readonly #read = (chunk: Buffer | string): void => {
|
||||
if (this.#closed) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
this.#bytes += bytes.length;
|
||||
if (this.#bytes > MAX_OPERATION_BYTES) {
|
||||
this.#fail(
|
||||
new Error(
|
||||
`JADX operation output exceeds ${MAX_OPERATION_BYTES} bytes; no complete result is available`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const newline = bytes.indexOf(10, offset);
|
||||
const end = newline === -1 ? bytes.length : newline;
|
||||
const fragment = bytes.subarray(offset, end);
|
||||
const frameBytes = this.#frameLength + fragment.length;
|
||||
if (frameBytes > MAX_FRAME_BYTES)
|
||||
throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`);
|
||||
this.#appendFrame(fragment, frameBytes);
|
||||
if (newline === -1) return;
|
||||
const line = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
// Preserve a leading BOM so the JSON-RPC parser can reject it.
|
||||
ignoreBOM: true,
|
||||
}).decode(this.#frameBuffer.subarray(0, this.#frameLength));
|
||||
this.#frameBuffer = Buffer.alloc(0);
|
||||
this.#frameLength = 0;
|
||||
this.onmessage?.(deserializeMessage(line));
|
||||
offset = newline + 1;
|
||||
}
|
||||
} catch (cause) {
|
||||
this.#fail(cause instanceof Error ? cause : new Error(String(cause)));
|
||||
}
|
||||
};
|
||||
#appendFrame(fragment: Buffer, frameBytes: number): void {
|
||||
if (frameBytes > this.#frameBuffer.length) {
|
||||
const capacity = Math.min(
|
||||
MAX_FRAME_BYTES,
|
||||
Math.max(frameBytes, this.#frameBuffer.length * 2, 1),
|
||||
);
|
||||
const grown = Buffer.allocUnsafe(capacity);
|
||||
this.#frameBuffer.copy(grown, 0, 0, this.#frameLength);
|
||||
this.#frameBuffer = grown;
|
||||
}
|
||||
fragment.copy(this.#frameBuffer, this.#frameLength);
|
||||
this.#frameLength = frameBytes;
|
||||
}
|
||||
#fail(error: Error): void {
|
||||
this.#failure ??= { kind: "protocol", cause: error };
|
||||
this.#error(error);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
omitUnretainedEvidenceFromError,
|
||||
projectAnalysisError,
|
||||
type AnalysisErrorProjection,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
@@ -21,6 +23,7 @@ import { analysisBookmarkSchema } from "../domain/native/nativeInventory.js";
|
||||
import { parseContainingProcedureResolution } from "../domain/native/nativeObservation.js";
|
||||
import { nativeAddressCoordinateInputSchema } from "../domain/native/nativeAddress.js";
|
||||
import { resolveAnalysisDocument } from "./AnalysisDocument.js";
|
||||
import { estimateJsonStorage } from "./JsonAuthenticationHeadroom.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
type Facet =
|
||||
@@ -32,6 +35,16 @@ type Facet =
|
||||
readonly error?: AnalysisErrorProjection;
|
||||
};
|
||||
|
||||
type AddressContext = {
|
||||
readonly address: string;
|
||||
readonly document: string;
|
||||
readonly name: Facet;
|
||||
readonly procedure: Facet;
|
||||
readonly comment: Facet;
|
||||
readonly inline_comment: Facet;
|
||||
readonly bookmarks: Facet;
|
||||
};
|
||||
|
||||
/** Facets inspected after an explicit or provider-resolved document is known. */
|
||||
export const ADDRESS_CONTEXT_FACET_OPERATIONS = [
|
||||
"address_name",
|
||||
@@ -99,12 +112,55 @@ export const getNavigationContext = async (
|
||||
});
|
||||
};
|
||||
|
||||
/** Preserve other facets and smaller partials if unretained Evidence cannot fit. */
|
||||
export function* omitContextEvidenceForDelivery(
|
||||
context: AddressContext,
|
||||
limit: {
|
||||
readonly constraint: "receive-buffer" | "string-length";
|
||||
readonly bytesAtLeast: number;
|
||||
},
|
||||
budgetBytes: number,
|
||||
): Generator<AddressContext> {
|
||||
const names = [
|
||||
"name",
|
||||
"procedure",
|
||||
"comment",
|
||||
"inline_comment",
|
||||
"bookmarks",
|
||||
] as const;
|
||||
const candidates = names.flatMap((name) => {
|
||||
const facet = context[name];
|
||||
if (facet.state !== "unavailable" || facet.error === undefined) return [];
|
||||
const compact = omitUnretainedEvidenceFromError(facet.error, limit);
|
||||
if (compact === undefined) return [];
|
||||
const partial = facet.error.details?.partial_observation;
|
||||
const size = estimateJsonStorage(partial, {
|
||||
maximumValueBytes: budgetBytes,
|
||||
includeSerialized: false,
|
||||
}).valueBytes;
|
||||
return [{ name, facet, compact, size }];
|
||||
});
|
||||
candidates.sort((left, right) => right.size - left.size);
|
||||
let current = context;
|
||||
for (const { name, facet, compact } of candidates) {
|
||||
current = {
|
||||
...current,
|
||||
[name]: {
|
||||
...facet,
|
||||
remediation: compact.remediation.action,
|
||||
error: compact,
|
||||
},
|
||||
};
|
||||
yield current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspect bounded provider-neutral facets at one explicit reproducible address. */
|
||||
export const inspectAddressContext = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
input: { readonly address: string; readonly document?: string | undefined },
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
): Promise<Result<AddressContext, AnalysisError>> => {
|
||||
const document = await resolveAnalysisDocument(
|
||||
analysis,
|
||||
input.document,
|
||||
@@ -225,9 +281,15 @@ const unavailableFacet = (
|
||||
: "Review provider availability in binary_session with detail complete and select another supported provider if appropriate. After switching, if a name is known and search_procedures is available, use it to find candidates and verify their identity and coordinates.",
|
||||
};
|
||||
}
|
||||
if (error instanceof AnalysisInputError) {
|
||||
if (
|
||||
error instanceof AnalysisInputError ||
|
||||
error instanceof AnalysisResourceConstraintError
|
||||
) {
|
||||
const projected = projectAnalysisError(error);
|
||||
if (projected.code === "invalid_request")
|
||||
if (
|
||||
projected.code === "invalid_request" ||
|
||||
projected.code === "resource_constraint"
|
||||
)
|
||||
return {
|
||||
state: "unavailable",
|
||||
reason: projected.message,
|
||||
|
||||
@@ -82,6 +82,22 @@ export interface AnalysisExecution extends AnalysisObservation {
|
||||
* output.
|
||||
*/
|
||||
readonly referencesTruncated?: boolean;
|
||||
/** The producer needs this observation retained as source Evidence for composed workflows. */
|
||||
readonly requiresSourceEvidence?: true;
|
||||
/** Known reference-discovery gaps; absent means coverage was not reported. */
|
||||
readonly referenceCoverageGaps?: readonly {
|
||||
readonly code: string;
|
||||
readonly scope: "image" | "observation";
|
||||
readonly description: string;
|
||||
}[];
|
||||
/** Typed source facts for this live xref observation; absent means unreported. */
|
||||
readonly referenceClassifications?: readonly {
|
||||
readonly sourceAddress: string;
|
||||
readonly targetAddress: string;
|
||||
readonly kind: "stored_pointer";
|
||||
readonly provenance: string;
|
||||
readonly width: number;
|
||||
}[];
|
||||
/**
|
||||
* Sibling operations known to be unavailable at the provider level when this
|
||||
* operation answered from a standalone or fallback path.
|
||||
@@ -97,6 +113,9 @@ interface CreateAnalysisExecutionOptions {
|
||||
readonly limitations?: readonly string[];
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
readonly referencesTruncated?: boolean;
|
||||
readonly requiresSourceEvidence?: true;
|
||||
readonly referenceClassifications?: AnalysisExecution["referenceClassifications"];
|
||||
readonly referenceCoverageGaps?: AnalysisExecution["referenceCoverageGaps"];
|
||||
readonly siblingUnavailability?: AnalysisCapabilityUnavailableError;
|
||||
readonly locations?: readonly EvidenceLocation[];
|
||||
readonly sourceEvidence?: readonly Evidence[];
|
||||
@@ -156,6 +175,15 @@ export function createAnalysisExecution(
|
||||
...(options.referencesTruncated === undefined
|
||||
? {}
|
||||
: { referencesTruncated: options.referencesTruncated }),
|
||||
...(options.requiresSourceEvidence === undefined
|
||||
? {}
|
||||
: { requiresSourceEvidence: options.requiresSourceEvidence }),
|
||||
...(options.referenceCoverageGaps === undefined
|
||||
? {}
|
||||
: { referenceCoverageGaps: options.referenceCoverageGaps }),
|
||||
...(options.referenceClassifications === undefined
|
||||
? {}
|
||||
: { referenceClassifications: options.referenceClassifications }),
|
||||
...(options.siblingUnavailability === undefined
|
||||
? {}
|
||||
: { siblingUnavailability: options.siblingUnavailability }),
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
retainAnalysisResultSources,
|
||||
type AnalysisResultWithSources,
|
||||
} from "./investigation/recordEvidenceSources.js";
|
||||
import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js";
|
||||
import { createInvestigationEvidenceClosureChecker } from "./investigation/InvestigationEvidence.js";
|
||||
import {
|
||||
resolveInvestigationEvidenceClosure,
|
||||
createInvestigationEvidenceClosureChecker,
|
||||
} from "./investigation/InvestigationEvidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
|
||||
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
||||
|
||||
@@ -33,19 +33,24 @@ export const projectDirectAnalysisFailure = (
|
||||
callerInputPath: string,
|
||||
): ReturnType<typeof projectAnalysisError> => {
|
||||
const projection = projectInvocationFailure(error);
|
||||
const observation = tryParseEvidence(projection.details?.partial_observation);
|
||||
return observation === undefined
|
||||
? projection
|
||||
: {
|
||||
...projection,
|
||||
details: {
|
||||
...projection.details,
|
||||
partial_observation: withEvidenceCallerInputPath(
|
||||
observation,
|
||||
callerInputPath,
|
||||
),
|
||||
},
|
||||
};
|
||||
const partial = projection.details?.partial_observation;
|
||||
const wrapped =
|
||||
typeof partial === "object" &&
|
||||
partial !== null &&
|
||||
!Array.isArray(partial) &&
|
||||
partial.kind === "unretained-evidence";
|
||||
const observation = tryParseEvidence(wrapped ? partial.evidence : partial);
|
||||
if (observation === undefined) return projection;
|
||||
const rebound = withEvidenceCallerInputPath(observation, callerInputPath);
|
||||
return {
|
||||
...projection,
|
||||
details: {
|
||||
...projection.details,
|
||||
partial_observation: wrapped
|
||||
? { ...partial, evidence: rebound }
|
||||
: rebound,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Await one-shot cleanup and retain either the outcome or both failures. */
|
||||
|
||||
@@ -137,6 +137,7 @@ const traceReferences = async (
|
||||
const tracedTargets = new Set<string>();
|
||||
// A source address is resolved once however many matches reference it.
|
||||
const containing = new Map<string, TraceContainingProcedure>();
|
||||
const imageCoverageGaps = new Set<string>();
|
||||
for (const match of request.matches) {
|
||||
if (tracedTargets.has(match.address)) continue;
|
||||
tracedTargets.add(match.address);
|
||||
@@ -154,6 +155,17 @@ const traceReferences = async (
|
||||
`References to ${match.address} were truncated by the provider; additional references may exist.`,
|
||||
);
|
||||
}
|
||||
for (const gap of observed.value.referenceCoverageGaps ?? []) {
|
||||
if (gap.scope === "observation") {
|
||||
residual.push(`References to ${match.address}: ${gap.description}`);
|
||||
} else {
|
||||
const key = JSON.stringify([observed.value.provider.id, gap.code]);
|
||||
if (!imageCoverageGaps.has(key)) {
|
||||
imageCoverageGaps.add(key);
|
||||
residual.push(gap.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sources = observed.value.result;
|
||||
if (!Array.isArray(sources))
|
||||
return err(
|
||||
@@ -162,6 +174,20 @@ const traceReferences = async (
|
||||
"provider returned a non-array result",
|
||||
),
|
||||
);
|
||||
const classifications = new Map<
|
||||
string,
|
||||
{ kind: "stored_pointer"; provenance: string; width: number }[]
|
||||
>();
|
||||
for (const fact of observed.value.referenceClassifications ?? []) {
|
||||
if (fact.targetAddress !== match.address) continue;
|
||||
const values = classifications.get(fact.sourceAddress) ?? [];
|
||||
values.push({
|
||||
kind: fact.kind,
|
||||
provenance: fact.provenance,
|
||||
width: fact.width,
|
||||
});
|
||||
classifications.set(fact.sourceAddress, values);
|
||||
}
|
||||
for (const source of sources) {
|
||||
if (typeof source !== "string")
|
||||
return err(
|
||||
@@ -211,6 +237,9 @@ const traceReferences = async (
|
||||
target_address: match.address,
|
||||
source_address: source,
|
||||
containing_procedure: containingProcedure,
|
||||
...(classifications.has(source)
|
||||
? { source_classifications: classifications.get(source)! }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const workflowAnalysisProfile = (
|
||||
: operation === "batch_decompile"
|
||||
? { workflow_revision: 4 }
|
||||
: operation === "trace_feature"
|
||||
? { workflow_revision: 4 }
|
||||
? { workflow_revision: 8 }
|
||||
: operation === "get_call_graph"
|
||||
? { workflow_revision: 3 }
|
||||
: operation === "trace_native_values"
|
||||
|
||||
@@ -864,6 +864,73 @@ describe("document-scoped snapshot admission", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains producer-required xrefs without replaying a filesystem-writing query", () => {
|
||||
const descriptor: CapabilityDescriptor = {
|
||||
operation: "xrefs",
|
||||
provider: ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
available: true,
|
||||
reason: null,
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: true,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: [],
|
||||
};
|
||||
const xrefs = descriptor;
|
||||
expect(
|
||||
isSourceEvidenceRetainable("xrefs", xrefs, { address: "0x1000" }),
|
||||
).toBe(false);
|
||||
expect(isSnapshotCacheable("xrefs", xrefs, { address: "0x1000" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isSourceEvidenceRetainable("xrefs", xrefs, {})).toBe(false);
|
||||
const writingXrefs = {
|
||||
...xrefs,
|
||||
effects: { ...xrefs.effects, mayWriteFilesystem: true },
|
||||
};
|
||||
expect(
|
||||
isSourceEvidenceRetainable("xrefs", writingXrefs, { address: "0x1000" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSourceEvidenceRetainable(
|
||||
"xrefs",
|
||||
writingXrefs,
|
||||
{ address: "0x1000" },
|
||||
{ requiresSourceEvidence: true },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSourceEvidenceRetainable(
|
||||
"xrefs",
|
||||
writingXrefs,
|
||||
{},
|
||||
{ requiresSourceEvidence: true },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSourceEvidenceRetainable(
|
||||
"xrefs",
|
||||
{
|
||||
...writingXrefs,
|
||||
effects: { ...writingXrefs.effects, mutatesArtifact: true },
|
||||
},
|
||||
{ address: "0x1000" },
|
||||
{ requiresSourceEvidence: true },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSnapshotCacheable("xrefs", writingXrefs, {
|
||||
address: "0x1000",
|
||||
document: "fixture",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe("analysis snapshot cache partitioning", () => {
|
||||
it.each<{
|
||||
operation: Exclude<AnalysisOperation, "health">;
|
||||
@@ -1490,13 +1557,43 @@ describe("executions that report truncated references", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replay references when the snapshot would lose their classifications", () => {
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
const parameters = { address: "0x1000", document: "fixture" };
|
||||
cache.record({
|
||||
target: ANALYSIS_SNAPSHOT_TARGET,
|
||||
profile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
operation: "xrefs",
|
||||
parameters,
|
||||
execution: createAnalysisExecution(["0x2000"], ANALYSIS_SNAPSHOT_PROVIDER, {
|
||||
referenceClassifications: [
|
||||
{
|
||||
sourceAddress: "0x2000",
|
||||
targetAddress: "0x1000",
|
||||
kind: "stored_pointer",
|
||||
provenance: "relocation",
|
||||
width: 8,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
cache.lookup(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
"xrefs",
|
||||
parameters,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revises the trace_feature workflow profile so earlier results are not replayed", () => {
|
||||
const profile = ANALYSIS_SNAPSHOT_PROFILE;
|
||||
|
||||
expect(
|
||||
workflowAnalysisProfile(profile, "trace_feature").parameters
|
||||
.workflow_revision,
|
||||
).toBe(4);
|
||||
).toBe(8);
|
||||
|
||||
expect(workflowAnalysisProfile(profile, "trace_feature")).not.toEqual(
|
||||
workflowAnalysisProfile(profile),
|
||||
|
||||
@@ -117,25 +117,30 @@ const DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS: ReadonlySet<AnalysisOperation> =
|
||||
|
||||
/**
|
||||
* Whether a source observation is safe to retain as Evidence.
|
||||
* A missing document selector blocks snapshot replay. Only caller and callee
|
||||
* walks stay in the ledger without one.
|
||||
* A missing document selector blocks snapshot replay. A producer can require
|
||||
* its observation as Evidence independently of whether it can be replayed.
|
||||
*/
|
||||
export const isSourceEvidenceRetainable = (
|
||||
operation: AnalysisOperation,
|
||||
descriptor: CapabilityDescriptor | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
context?: ExecutionOptions["snapshotContext"],
|
||||
options: {
|
||||
readonly snapshotContext?: ExecutionOptions["snapshotContext"];
|
||||
readonly requiresSourceEvidence?: boolean;
|
||||
} = {},
|
||||
): descriptor is CapabilityDescriptor =>
|
||||
!STATEFUL_OPERATIONS.has(operation) &&
|
||||
!RESOURCE_SCOPED_OPERATIONS.has(operation) &&
|
||||
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
|
||||
typeof parameters.document === "string" ||
|
||||
DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation) ||
|
||||
isOneShotPseudocode(operation, descriptor, context)) &&
|
||||
options.requiresSourceEvidence === true ||
|
||||
isOneShotPseudocode(operation, descriptor, options.snapshotContext)) &&
|
||||
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
|
||||
typeof parameters.address === "string") &&
|
||||
descriptor?.effects.mutatesArtifact === false &&
|
||||
(descriptor.cachePolicy === "snapshot" ||
|
||||
(options.requiresSourceEvidence === true ||
|
||||
descriptor.cachePolicy === "snapshot" ||
|
||||
descriptor.effects.mayWriteFilesystem === false) &&
|
||||
descriptor.effects.changesPermissions === false;
|
||||
|
||||
@@ -146,7 +151,9 @@ export const isSnapshotCacheable = (
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
context?: ExecutionOptions["snapshotContext"],
|
||||
): descriptor is CapabilityDescriptor =>
|
||||
isSourceEvidenceRetainable(operation, descriptor, parameters, context) &&
|
||||
isSourceEvidenceRetainable(operation, descriptor, parameters, {
|
||||
snapshotContext: context,
|
||||
}) &&
|
||||
(replayDocumentSelected(operation, parameters) ||
|
||||
isOneShotPseudocode(operation, descriptor, context)) &&
|
||||
descriptor.cachePolicy !== "live";
|
||||
@@ -589,9 +596,15 @@ export class AnalysisSnapshotCache {
|
||||
): void {
|
||||
const { target, profile, operation, parameters, execution } = input;
|
||||
this.select(target, profile);
|
||||
// The snapshot format does not carry reference truncation; a replay would
|
||||
// present a capped read as complete, so it is not recorded.
|
||||
if (execution.referencesTruncated === true) return;
|
||||
// The snapshot format does not carry reference truncation or stored-pointer
|
||||
// classifications. Replaying either would present a capped or untyped read
|
||||
// as the live observation.
|
||||
if (
|
||||
execution.referencesTruncated === true ||
|
||||
execution.referenceClassifications !== undefined ||
|
||||
execution.referenceCoverageGaps !== undefined
|
||||
)
|
||||
return;
|
||||
let sourceBinding: SnapshotReplayBinding | undefined;
|
||||
if ((execution.sourceEvidence?.length ?? 0) > 0) {
|
||||
if (execution.evidenceId === undefined || lookup === undefined) return;
|
||||
|
||||
@@ -1539,12 +1539,10 @@ export class BinarySession
|
||||
}
|
||||
if (
|
||||
execution.ok &&
|
||||
isSourceEvidenceRetainable(
|
||||
name,
|
||||
capability,
|
||||
arguments_,
|
||||
options?.snapshotContext,
|
||||
) &&
|
||||
isSourceEvidenceRetainable(name, capability, arguments_, {
|
||||
snapshotContext: options?.snapshotContext,
|
||||
requiresSourceEvidence: execution.value.requiresSourceEvidence === true,
|
||||
}) &&
|
||||
profile !== undefined
|
||||
) {
|
||||
const evidence = createAnalysisExecutionEvidence(
|
||||
@@ -1561,7 +1559,11 @@ export class BinarySession
|
||||
"observed",
|
||||
);
|
||||
const recorded = this.recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
if (!recorded.ok) {
|
||||
// The provider finished. A full ledger must not discard that observation.
|
||||
recorded.error.retainPartialObservation(evidence);
|
||||
return recorded;
|
||||
}
|
||||
const retained = { ...execution.value, evidenceId: evidence.evidence_id };
|
||||
if (cacheable && execution.value.cachePolicy !== "live")
|
||||
this.recordSnapshot({
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
validateResidualUnknownAddition,
|
||||
type EvidenceBundle,
|
||||
} from "../../domain/evidenceBundle.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import {
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
evidenceDisplayMetadataAgree,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import { safeParseImmutableEvidence } from "../../domain/evidenceValidation.js";
|
||||
import {
|
||||
createResidualUnknown,
|
||||
@@ -111,7 +115,8 @@ export class EvidenceLedger {
|
||||
"record_evidence",
|
||||
new Map([[evidence.evidence_id, evidence]]),
|
||||
);
|
||||
if (!admitted.ok) return admitted;
|
||||
if (!admitted.ok)
|
||||
return err(this.#exposeRefusedEvidence(admitted.error, evidence));
|
||||
this.#records.set(evidence.evidence_id, evidence);
|
||||
this.#commitAccounting(admitted.value);
|
||||
return ok("added");
|
||||
@@ -366,14 +371,38 @@ export class EvidenceLedger {
|
||||
if (creationUnknown.revision_digest !== unknown.revision_digest)
|
||||
return err(new UnknownRegistryError("already-exists"));
|
||||
const checked = this.#appendIncremental([evidence]);
|
||||
if (!checked.ok) return checked;
|
||||
if (!checked.ok)
|
||||
return err(this.#exposeRefusedEvidence(checked.error, evidence));
|
||||
return ok(null);
|
||||
}
|
||||
const checked = this.#appendIncremental([evidence, mutation], unknown);
|
||||
if (!checked.ok) return checked;
|
||||
if (!checked.ok)
|
||||
return err(this.#exposeRefusedEvidence(checked.error, evidence));
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
/** Report authenticated completed Evidence without inferring membership from this attempt. */
|
||||
#exposeRefusedEvidence(
|
||||
error: AnalysisError,
|
||||
evidence: Evidence,
|
||||
): AnalysisError {
|
||||
if (
|
||||
!(error instanceof AnalysisResourceConstraintError) ||
|
||||
error.resource !== "capacity"
|
||||
)
|
||||
return error;
|
||||
return error.retainPartialObservation(
|
||||
freezeJsonSnapshot(
|
||||
this.#records.has(evidence.evidence_id)
|
||||
? {
|
||||
kind: "retained-evidence" as const,
|
||||
evidence_id: evidence.evidence_id,
|
||||
}
|
||||
: { kind: "unretained-evidence" as const, evidence },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply one approved full-state update using optimistic revision matching. */
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
|
||||
@@ -96,7 +96,9 @@ describe("investigation record ownership", () => {
|
||||
...unknownInput("Is the missing Evidence present?"),
|
||||
supporting_evidence_ids: [`ev_${"f".repeat(64)}`],
|
||||
};
|
||||
expect(records.recordEvidenceWithUnknown(evidence, invalid).ok).toBe(false);
|
||||
const refused = records.recordEvidenceWithUnknown(evidence, invalid);
|
||||
if (refused.ok) throw new Error("Expected invalid Unknown references");
|
||||
expect(refused.error.partialObservation).toBeUndefined();
|
||||
expect(records.hasEvidence(evidence.evidence_id)).toBe(false);
|
||||
expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ResidualUnknown,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import { InvestigationRecords } from "./InvestigationRecords.js";
|
||||
import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Retention fixture", version: "1" };
|
||||
const observe = (index: number, path = "/fixture") =>
|
||||
@@ -57,6 +58,39 @@ const recordCost = (evidence: Evidence): number => {
|
||||
return records.statistics().retainedRepresentationBytes;
|
||||
};
|
||||
|
||||
it("returns only authenticated Evidence when a completed record exceeds retention capacity", () => {
|
||||
const evidence = observe(7);
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const refused = records.recordEvidence(evidence);
|
||||
if (refused.ok) throw new Error("Expected retention refusal");
|
||||
expect(refused.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
expect(refused.error.partialObservation).toEqual({
|
||||
kind: "unretained-evidence",
|
||||
evidence,
|
||||
});
|
||||
expect(isImmutableJsonSnapshot(refused.error.partialObservation)).toBe(true);
|
||||
expect(records.hasEvidence(evidence.evidence_id)).toBe(false);
|
||||
const invalid = records.recordEvidence({
|
||||
...evidence,
|
||||
normalized_result: "tampered",
|
||||
});
|
||||
if (invalid.ok) throw new Error("Expected integrity refusal");
|
||||
expect(invalid.error._tag).toBe("EvidenceIntegrityError");
|
||||
expect(invalid.error.partialObservation).toBeUndefined();
|
||||
const mutable = createEvidence(undefined, provider, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: { value: 1 },
|
||||
});
|
||||
const noHeadroom = new InvestigationRecords({
|
||||
availableAuthenticationBytes: () => 0,
|
||||
}).recordEvidence(mutable);
|
||||
if (noHeadroom.ok)
|
||||
throw new Error("Expected authentication headroom refusal");
|
||||
expect(noHeadroom.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
expect(noHeadroom.error.partialObservation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("admits exact capacity, rejects cumulative growth atomically, and releases accounting on clear", () => {
|
||||
const first = observe(1);
|
||||
const second = observe(2);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { electronSemanticsState } from "./ElectronBoundaryAnalysis.js";
|
||||
import {
|
||||
electronSemanticsState,
|
||||
createElectronIpcContext,
|
||||
summarizeElectronBoundaries,
|
||||
type ElectronIpcContext,
|
||||
} from "./ElectronBoundaryAnalysis.js";
|
||||
import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import {
|
||||
@@ -47,11 +52,6 @@ import {
|
||||
} from "./JavaScriptArtifactGraphStructure.js";
|
||||
import { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js";
|
||||
import { addElectronBoundaries } from "./ElectronBoundaryGraph.js";
|
||||
import {
|
||||
createElectronIpcContext,
|
||||
summarizeElectronBoundaries,
|
||||
type ElectronIpcContext,
|
||||
} from "./ElectronBoundaryAnalysis.js";
|
||||
|
||||
/**
|
||||
* Build a validated application graph with cooperative immutable ownership.
|
||||
|
||||
@@ -36,6 +36,10 @@ const generic = <Code extends string>(code: Code) =>
|
||||
|
||||
/** The canonical invalid-input projection, also used for facet-local refusals. */
|
||||
export const analysisInputErrorProjectionSchema = generic("invalid_request");
|
||||
/** The canonical resource refusal, including its reported limits and recovery. */
|
||||
export const analysisResourceErrorProjectionSchema = generic(
|
||||
"resource_constraint",
|
||||
);
|
||||
|
||||
/** Stable discriminated schema shared by every CLI and MCP error surface. */
|
||||
export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
||||
@@ -47,7 +51,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
||||
generic("unsupported_target"),
|
||||
generic("provider_unavailable"),
|
||||
generic("provider_timeout"),
|
||||
generic("resource_constraint"),
|
||||
analysisResourceErrorProjectionSchema,
|
||||
generic("cancelled"),
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -187,7 +187,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
{
|
||||
...session(
|
||||
"inspect_address_context",
|
||||
"Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome. A facet rejected for invalid input retains its canonical error and issues alongside any successful facets. Use xrefs, assembly, or pseudocode for those deeper views.",
|
||||
"Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome. A facet rejected for invalid input or a resource constraint retains its canonical error and recovery alongside any successful facets. Use xrefs, assembly, or pseudocode for those deeper views.",
|
||||
addressContextInputSchema,
|
||||
),
|
||||
requiresActiveTarget: true,
|
||||
|
||||
@@ -101,6 +101,7 @@ import { decompileDirectoryManifestSchema } from "./decompileDirectory.js";
|
||||
import {
|
||||
analysisErrorProjectionSchema,
|
||||
analysisInputErrorProjectionSchema,
|
||||
analysisResourceErrorProjectionSchema,
|
||||
} from "./errorSchemas.js";
|
||||
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
|
||||
@@ -122,13 +123,17 @@ import {
|
||||
import { processCaptureSchema } from "../domain/process/processCapture.js";
|
||||
import { processScenarioPlanSchema } from "../domain/process/processScenario.js";
|
||||
import { electronFuseReportSchema } from "../domain/electron/electronFuses.js";
|
||||
const contextFacetErrorSchema = z.discriminatedUnion("code", [
|
||||
analysisInputErrorProjectionSchema,
|
||||
analysisResourceErrorProjectionSchema,
|
||||
]);
|
||||
const contextFacetSchema = z.discriminatedUnion("state", [
|
||||
z.object({ state: z.literal("available"), value: jsonValueSchema }),
|
||||
z.object({
|
||||
state: z.literal("unavailable"),
|
||||
reason: z.string(),
|
||||
remediation: z.string(),
|
||||
error: analysisInputErrorProjectionSchema.optional(),
|
||||
error: contextFacetErrorSchema.optional(),
|
||||
}),
|
||||
]);
|
||||
const bookmarkFacetSchema = z.discriminatedUnion("state", [
|
||||
@@ -140,7 +145,7 @@ const bookmarkFacetSchema = z.discriminatedUnion("state", [
|
||||
state: z.literal("unavailable"),
|
||||
reason: z.string(),
|
||||
remediation: z.string(),
|
||||
error: analysisInputErrorProjectionSchema.optional(),
|
||||
error: contextFacetErrorSchema.optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -402,6 +402,8 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
|
||||
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
|
||||
| GraphqlContract
|
||||
| PlaywrightTrace
|
||||
| SavedAnalysisSnapshotPartialObservation
|
||||
|
||||
@@ -67,13 +67,76 @@ import { invocationSettingLimit } from "./analysisSettingDefinitions.js";
|
||||
export const projectAnalysisError = (
|
||||
error: AnalysisError,
|
||||
callerLifetime: AnalysisCallerLifetime = "continuing",
|
||||
): AnalysisErrorProjection => projectError(error, true, callerLifetime);
|
||||
): AnalysisErrorProjection =>
|
||||
projectError(
|
||||
error,
|
||||
{ capturedOutput: true, partialObservation: true },
|
||||
callerLifetime,
|
||||
);
|
||||
|
||||
/** Cleanup history references output retained by its enclosing error owner. */
|
||||
export const projectAnalysisErrorWithoutCapturedOutput = (
|
||||
error: AnalysisError,
|
||||
callerLifetime: AnalysisCallerLifetime = "continuing",
|
||||
): AnalysisErrorProjection => projectError(error, false, callerLifetime);
|
||||
): AnalysisErrorProjection =>
|
||||
projectError(
|
||||
error,
|
||||
{ capturedOutput: false, partialObservation: true },
|
||||
callerLifetime,
|
||||
);
|
||||
|
||||
/** Bounded fallback when the completed observation itself cannot be delivered. */
|
||||
export const projectAnalysisErrorWithoutPartialObservation = (
|
||||
error: AnalysisError,
|
||||
callerLifetime: AnalysisCallerLifetime = "continuing",
|
||||
): AnalysisErrorProjection =>
|
||||
projectError(
|
||||
error,
|
||||
{ capturedOutput: true, partialObservation: false },
|
||||
callerLifetime,
|
||||
);
|
||||
|
||||
/** Omit only explicitly unretained Evidence when a complete reply cannot fit. */
|
||||
export const omitUnretainedEvidenceFromError = (
|
||||
projected: AnalysisErrorProjection,
|
||||
limit: {
|
||||
readonly constraint: "receive-buffer" | "string-length";
|
||||
readonly bytesAtLeast: number;
|
||||
},
|
||||
): AnalysisErrorProjection | undefined => {
|
||||
const partial = projected.details?.partial_observation;
|
||||
if (
|
||||
typeof partial !== "object" ||
|
||||
partial === null ||
|
||||
Array.isArray(partial) ||
|
||||
partial.kind !== "unretained-evidence"
|
||||
)
|
||||
return undefined;
|
||||
const { partial_observation: _partial, ...details } = projected.details!;
|
||||
const evidence = partial.evidence;
|
||||
const evidenceId =
|
||||
typeof evidence === "object" &&
|
||||
evidence !== null &&
|
||||
!Array.isArray(evidence)
|
||||
? evidence.evidence_id
|
||||
: undefined;
|
||||
return {
|
||||
...projected,
|
||||
remediation: {
|
||||
action: `${projected.remediation.action} The completed observation was not retained and could not fit within MCP delivery limits.`,
|
||||
},
|
||||
details: {
|
||||
...details,
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unretained",
|
||||
constraint: limit.constraint,
|
||||
original_response_bytes_at_least: limit.bytesAtLeast,
|
||||
...(typeof evidenceId === "string" ? { evidence_id: evidenceId } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** A child failure embedded in a composed error's diagnostics. */
|
||||
export interface AnalysisChildFailure {
|
||||
@@ -95,7 +158,15 @@ export const childFailureDiagnostics = (
|
||||
const project = (
|
||||
{ error, capturedOutput }: AnalysisChildFailure,
|
||||
callerLifetime: AnalysisCallerLifetime,
|
||||
) => projectError(error, capturedOutput !== "omit", callerLifetime);
|
||||
) =>
|
||||
projectError(
|
||||
error,
|
||||
{
|
||||
capturedOutput: capturedOutput !== "omit",
|
||||
partialObservation: true,
|
||||
},
|
||||
callerLifetime,
|
||||
);
|
||||
const continuing = {
|
||||
...diagnostics,
|
||||
...Object.fromEntries(
|
||||
@@ -137,7 +208,10 @@ const projectionDependsOnCallerLifetime = (error: AnalysisError): boolean => {
|
||||
|
||||
const projectError = (
|
||||
error: AnalysisError,
|
||||
includeCapturedOutput: boolean,
|
||||
options: {
|
||||
readonly capturedOutput: boolean;
|
||||
readonly partialObservation: boolean;
|
||||
},
|
||||
callerLifetime: AnalysisCallerLifetime,
|
||||
): AnalysisErrorProjection => {
|
||||
const composed = error;
|
||||
@@ -218,14 +292,14 @@ const projectError = (
|
||||
primaryDetails?.execution_failure ?? underlyingCode,
|
||||
}
|
||||
: {}),
|
||||
...(!includeCapturedOutput || capturedOutput === undefined
|
||||
...(!options.capturedOutput || capturedOutput === undefined
|
||||
? {}
|
||||
: {
|
||||
captured_output: {
|
||||
...capturedOutput,
|
||||
},
|
||||
}),
|
||||
...(partialObservation === undefined
|
||||
...(!options.partialObservation || partialObservation === undefined
|
||||
? {}
|
||||
: {
|
||||
partial_observation: isImmutableJsonSnapshot(partialObservation)
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from "./nativeObservationSchemas.js";
|
||||
import { projectNativeGraphRows } from "./nativeGraphView.js";
|
||||
|
||||
/** What an address page leaves in the complete parent, when recorded. */
|
||||
export const XREFS_ADDRESS_PAGE_LIMITATION =
|
||||
"This page contains source addresses only, not reference kinds or provenance. Retrieve or export the complete parent Evidence for any additional provider details it recorded.";
|
||||
|
||||
const readAddresses = createAnalysisViewResultReader(nativeAddressListSchema);
|
||||
const readAddress = createAnalysisViewResultReader(
|
||||
nativeObservationSourceSchemas.procedure_address,
|
||||
@@ -347,6 +351,9 @@ export const projectNativeObservationView = (
|
||||
const { total, ...payload } = page;
|
||||
const observation = {
|
||||
...common,
|
||||
...(source.value.operation === "xrefs"
|
||||
? { unknowns: [...common.unknowns, XREFS_ADDRESS_PAGE_LIMITATION] }
|
||||
: {}),
|
||||
...payload,
|
||||
summary: { observations: total },
|
||||
coverage: pageViewCoverage(selection.offset, page.items.rows.length, total),
|
||||
|
||||
@@ -7,6 +7,18 @@ export const literalFeatureReferenceSchema = z.object({
|
||||
target_address: z.string(),
|
||||
source_address: z.string(),
|
||||
containing_procedure: traceContainingProcedureSchema,
|
||||
source_classifications: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
kind: z.literal("stored_pointer"),
|
||||
provenance: z.string(),
|
||||
width: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.exactOptional()
|
||||
.describe(
|
||||
"Provider-reported source classifications; omission means none were supplied. The list need not exhaust all kinds.",
|
||||
),
|
||||
});
|
||||
|
||||
/** Complete result of a native literal feature trace. */
|
||||
|
||||
@@ -125,6 +125,18 @@ export const nativeCodeLocationSchema = z.strictObject({
|
||||
module: z.string().nullable(),
|
||||
module_path: z.string().nullable(),
|
||||
symbol: z.string().nullable(),
|
||||
/** LLDB line-table attribution; does not verify source file contents. */
|
||||
source_location: z
|
||||
.strictObject({
|
||||
file_path: z.string().min(1).nullable(),
|
||||
line: z.number().int().positive().nullable(),
|
||||
column: z.number().int().positive().nullable(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Compiler or debugger source attribution only; REA does not read or verify the named source-file bytes.",
|
||||
),
|
||||
});
|
||||
|
||||
/** One observed entry into a requested function or method. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Generated by scripts/generate-package-metadata.mjs; do not edit. */
|
||||
export const PACKAGE_METADATA = {
|
||||
name: "rea-agents",
|
||||
version: "6.8.0", // x-release-please-version
|
||||
version: "6.9.0", // x-release-please-version
|
||||
pinnedNodeVersion: "24.22.1",
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
|
||||
@@ -273,7 +273,12 @@ export class KunaProvider implements AnalysisProviderCandidate {
|
||||
bodies: false,
|
||||
edge_kind: "call",
|
||||
},
|
||||
xrefs: { command: "xrefs", direction: "to", exact_address: true },
|
||||
xrefs: {
|
||||
command: "xrefs",
|
||||
direction: "to",
|
||||
exact_address: true,
|
||||
stored_pointers: true,
|
||||
},
|
||||
strings: {
|
||||
command: "strings",
|
||||
encoding: "all",
|
||||
|
||||
@@ -224,6 +224,32 @@ const xrefRow = z.object({
|
||||
to_function: xrefFunction.nullable(),
|
||||
instruction: z.string(),
|
||||
});
|
||||
const xrefPointer = z.object({
|
||||
kind: z.literal("pointer"),
|
||||
from_address: address,
|
||||
from_address_hex: address,
|
||||
from_name: z.string().nullable(),
|
||||
to_address: address,
|
||||
to_address_hex: address,
|
||||
to_name: z.string().nullable(),
|
||||
width: z.int().positive(),
|
||||
source: z.string().min(1),
|
||||
});
|
||||
const xrefWalkCoverage = z.object({
|
||||
decoded: z.boolean(),
|
||||
section: z.string().nullable(),
|
||||
code_section: z.boolean().nullable(),
|
||||
executable: z.boolean().nullable(),
|
||||
unwalked: z.array(
|
||||
z.object({
|
||||
name: z.string().nullable(),
|
||||
address,
|
||||
address_hex: address,
|
||||
section: z.string(),
|
||||
}),
|
||||
),
|
||||
notes: z.array(z.string()),
|
||||
});
|
||||
const xrefsDocument = z.object({
|
||||
binary: z.string(),
|
||||
direction: z.literal("to"),
|
||||
@@ -235,6 +261,10 @@ const xrefsDocument = z.object({
|
||||
aliases: z.array(xrefFunction),
|
||||
}),
|
||||
xrefs: z.array(z.unknown()),
|
||||
coverage: xrefWalkCoverage.optional(),
|
||||
data_refs_coverage: z.string().optional(),
|
||||
data_refs_count: count.optional(),
|
||||
data_refs: z.array(xrefPointer).optional(),
|
||||
});
|
||||
|
||||
export interface KunaIncomingReferences {
|
||||
@@ -243,6 +273,15 @@ export interface KunaIncomingReferences {
|
||||
readonly bytes: number;
|
||||
readonly rows: number;
|
||||
readonly excludedAliasRows: number;
|
||||
readonly targetAliases: readonly {
|
||||
readonly name: string;
|
||||
readonly address: string;
|
||||
}[];
|
||||
readonly walkCoverage: z.infer<typeof xrefWalkCoverage> | null;
|
||||
readonly pointerCoverage: string | null;
|
||||
readonly pointers: Array<
|
||||
Omit<z.infer<typeof xrefPointer>, "from_address_hex" | "to_address_hex">
|
||||
> | null;
|
||||
}
|
||||
|
||||
/** Code from incoming-reference admission, plus the address field when one failed. */
|
||||
@@ -275,8 +314,34 @@ export const parseKunaIncomingReferences = (
|
||||
throw new KunaDocumentError("xref_alias_hex");
|
||||
aliases.add(alias.address);
|
||||
}
|
||||
if (
|
||||
(document.data_refs === undefined) !==
|
||||
(document.data_refs_count === undefined)
|
||||
)
|
||||
throw new KunaDocumentError("xref_count");
|
||||
if (
|
||||
document.data_refs !== undefined &&
|
||||
document.data_refs.length !== document.data_refs_count
|
||||
)
|
||||
throw new KunaDocumentError("xref_count");
|
||||
for (const item of document.coverage?.unwalked ?? [])
|
||||
if (item.address !== item.address_hex)
|
||||
throw new KunaDocumentError("xref_endpoints");
|
||||
for (const pointer of document.data_refs ?? []) {
|
||||
if (
|
||||
pointer.from_address !== pointer.from_address_hex ||
|
||||
pointer.to_address !== pointer.to_address_hex
|
||||
)
|
||||
throw new KunaDocumentError("xref_endpoints");
|
||||
if (!aliases.has(pointer.to_address))
|
||||
throw new KunaDocumentError("xref_target");
|
||||
}
|
||||
const addresses = new Set<string>();
|
||||
let excludedAliasRows = 0;
|
||||
for (const pointer of document.data_refs ?? []) {
|
||||
if (pointer.to_address === target) addresses.add(pointer.from_address);
|
||||
else excludedAliasRows += 1;
|
||||
}
|
||||
for (const raw of document.xrefs) {
|
||||
const row = xrefRow.parse(raw);
|
||||
if (
|
||||
@@ -296,8 +361,19 @@ export const parseKunaIncomingReferences = (
|
||||
}
|
||||
return ok({
|
||||
addresses: [...addresses].sort(compareKunaAddresses),
|
||||
rows: document.xrefs.length,
|
||||
rows: document.xrefs.length + (document.data_refs?.length ?? 0),
|
||||
excludedAliasRows,
|
||||
targetAliases: document.target.aliases.map(({ name, address }) => ({
|
||||
name,
|
||||
address,
|
||||
})),
|
||||
walkCoverage: document.coverage ?? null,
|
||||
pointerCoverage: document.data_refs_coverage ?? null,
|
||||
pointers:
|
||||
document.data_refs?.map(
|
||||
({ from_address_hex: _from, to_address_hex: _to, ...pointer }) =>
|
||||
pointer,
|
||||
) ?? null,
|
||||
bytes: Buffer.byteLength(text),
|
||||
sha256: createHash("sha256").update(text).digest("hex"),
|
||||
});
|
||||
|
||||
@@ -178,8 +178,11 @@ interface Answer {
|
||||
readonly locations: readonly EvidenceLocation[];
|
||||
readonly limitations: readonly string[];
|
||||
readonly referenceSource?: JsonValue;
|
||||
readonly requiresSourceEvidence?: true;
|
||||
readonly stringSource?: JsonValue;
|
||||
readonly relationshipCoverage?: RelationshipCoverage;
|
||||
readonly referenceClassifications?: AnalysisExecution["referenceClassifications"];
|
||||
readonly referenceCoverageGaps?: AnalysisExecution["referenceCoverageGaps"];
|
||||
readonly siblingUnavailability?: AnalysisCapabilityUnavailableError;
|
||||
}
|
||||
|
||||
@@ -725,6 +728,15 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
...(answer.relationshipCoverage === undefined
|
||||
? {}
|
||||
: { relationshipCoverage: answer.relationshipCoverage }),
|
||||
...(answer.requiresSourceEvidence === undefined
|
||||
? {}
|
||||
: { requiresSourceEvidence: answer.requiresSourceEvidence }),
|
||||
...(answer.referenceCoverageGaps === undefined
|
||||
? {}
|
||||
: { referenceCoverageGaps: answer.referenceCoverageGaps }),
|
||||
...(answer.referenceClassifications === undefined
|
||||
? {}
|
||||
: { referenceClassifications: answer.referenceClassifications }),
|
||||
...(answer.siblingUnavailability === undefined
|
||||
? {}
|
||||
: { siblingUnavailability: answer.siblingUnavailability }),
|
||||
@@ -1005,11 +1017,59 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
options.signal,
|
||||
);
|
||||
if (!references.ok) return references;
|
||||
const coverageGaps = [
|
||||
...(references.value.walkCoverage !== null &&
|
||||
references.value.walkCoverage.unwalked.length > 0
|
||||
? [
|
||||
{
|
||||
code: "unwalked-code",
|
||||
scope: "observation" as const,
|
||||
description:
|
||||
"Kuna reports unwalked code candidates whose outgoing references are not included. Their presence does not establish a reference to the selected target; the source observation identifies them.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(references.value.pointerCoverage === "none"
|
||||
? [
|
||||
{
|
||||
code: "pointer-relocations-unavailable",
|
||||
scope: "image" as const,
|
||||
description:
|
||||
"Kuna reports no pointer-relocation coverage for this image; stored pointers may still exist.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return ok({
|
||||
result: [...references.value.addresses],
|
||||
source: null,
|
||||
locations,
|
||||
limitations,
|
||||
limitations: [
|
||||
...limitations,
|
||||
...(references.value.pointerCoverage === null
|
||||
? []
|
||||
: [
|
||||
"Kuna pointer references come from relocation metadata. The reported metadata source does not establish successful or complete enumeration.",
|
||||
]),
|
||||
...coverageGaps.map(({ description }) => description),
|
||||
],
|
||||
...(coverageGaps.length === 0
|
||||
? {}
|
||||
: { referenceCoverageGaps: coverageGaps }),
|
||||
...(references.value.pointers === null
|
||||
? {}
|
||||
: {
|
||||
referenceClassifications: references.value.pointers
|
||||
.filter((pointer) => pointer.to_address === address)
|
||||
.map((pointer) => ({
|
||||
sourceAddress: pointer.from_address,
|
||||
targetAddress: pointer.to_address,
|
||||
kind: "stored_pointer" as const,
|
||||
provenance: pointer.source,
|
||||
width: pointer.width,
|
||||
})),
|
||||
}),
|
||||
requiresSourceEvidence: true,
|
||||
referenceSource: {
|
||||
kind: "kuna-xrefs",
|
||||
direction: "to",
|
||||
@@ -1018,6 +1078,12 @@ export class KunaSessionClient implements AnalysisClient {
|
||||
bytes: references.value.bytes,
|
||||
examined_rows: references.value.rows,
|
||||
excluded_alias_rows: references.value.excludedAliasRows,
|
||||
target_aliases: references.value.targetAliases.map(
|
||||
({ name, address }) => ({ name, address }),
|
||||
),
|
||||
walk_coverage: references.value.walkCoverage,
|
||||
pointer_coverage: references.value.pointerCoverage,
|
||||
pointers: references.value.pointers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export const projectLldbPartialObservation = (input: {
|
||||
limitations: [
|
||||
"The LLDB run did not produce a complete native call observation result.",
|
||||
"The target SHA-256 identifies the selected artifact; LLDB did not verify the bytes loaded into the process.",
|
||||
"Source paths and lines are LLDB line-table attribution; REA did not read or verify the referenced source bytes.",
|
||||
"Captured output byte counts are observed lower bounds because LLDB did not report final stream counters.",
|
||||
...input.journal.limitations,
|
||||
...(input.journalLimitations ?? []),
|
||||
|
||||
@@ -36,6 +36,7 @@ export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [
|
||||
"Registers are the raw integer argument registers at entry. For Objective-C methods the first two hold the receiver and the selector.",
|
||||
"Receiver classes come from LLDB's Objective-C runtime reader without running target code; they are null for class methods and for receivers LLDB cannot classify.",
|
||||
"Calls that never reach a symbol's entry, such as inlined code or objc_direct methods called directly, are not observed.",
|
||||
"Source locations come from LLDB line-table metadata; REA does not read or verify the named source-file contents.",
|
||||
"The target is launched directly under LLDB, not through LaunchServices; LLDB disables ASLR for it, so load addresses can equal file addresses.",
|
||||
"The observation window is checked about once a second, and recording pauses the process at every entry, which slows it.",
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
deserializeMessage,
|
||||
serializeMessage,
|
||||
type JSONRPCMessage,
|
||||
type Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { finished } from "node:stream/promises";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "./ProviderProcess.js";
|
||||
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "./ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "./ProcessOwnership.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
/** Injectable acquisition seam; production owns a POSIX group or Windows job. */
|
||||
export type OwnedMcpLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/** The first observed failure, recorded before notifying the SDK or beginning cleanup. */
|
||||
export type OwnedMcpTransportFailure =
|
||||
| { readonly kind: "process"; readonly cause: unknown }
|
||||
| { readonly kind: "exit" }
|
||||
| { readonly kind: "protocol"; readonly cause: Error }
|
||||
| { readonly kind: "interrupted"; readonly reason: unknown };
|
||||
|
||||
/** SDK-framed stdio with REA-owned process cleanup rather than an unowned child. */
|
||||
export class OwnedMcpStdioTransport implements Transport {
|
||||
onclose: Transport["onclose"];
|
||||
onerror: Transport["onerror"];
|
||||
onmessage: Transport["onmessage"];
|
||||
readonly runId = randomUUID();
|
||||
#spawned: SpawnedOwnedProviderProcess | undefined;
|
||||
#supervisor: ProviderProcessSupervisor | undefined;
|
||||
#frameBuffer = Buffer.alloc(0);
|
||||
#frameLength = 0;
|
||||
#bytes = 0;
|
||||
#started = false;
|
||||
#closed = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#starting: Promise<void> | undefined;
|
||||
#failure: OwnedMcpTransportFailure | undefined;
|
||||
|
||||
constructor(
|
||||
readonly options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
|
||||
readonly settings: {
|
||||
readonly provider: string;
|
||||
readonly label: string;
|
||||
readonly maxFrameBytes: number;
|
||||
readonly maxOperationBytes: number;
|
||||
readonly gracefulInputCloseMs?: number;
|
||||
readonly maxDiagnosticBytes?: number;
|
||||
readonly notifyOnExit?: boolean;
|
||||
},
|
||||
readonly launcher: OwnedMcpLauncher = spawnOwnedProviderProcess,
|
||||
) {}
|
||||
|
||||
/** Acquires exactly one protocol process after the SDK installs callbacks. */
|
||||
start(): Promise<void> {
|
||||
if (this.#started || this.#closed)
|
||||
throw new Error(`${this.settings.label} transport cannot be restarted`);
|
||||
this.#started = true;
|
||||
this.#starting = this.#acquire().catch((cause: unknown) => {
|
||||
this.#failure ??= { kind: "process", cause };
|
||||
throw cause;
|
||||
});
|
||||
return this.#starting;
|
||||
}
|
||||
|
||||
async #acquire(): Promise<void> {
|
||||
const spawned = await this.launcher({
|
||||
...this.options,
|
||||
runId: this.runId,
|
||||
stdin: "pipe",
|
||||
});
|
||||
this.#spawned = spawned;
|
||||
this.#supervisor = new ProviderProcessSupervisor(
|
||||
{
|
||||
...spawned,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup:
|
||||
spawned.cleanup ??
|
||||
(() => cleanupOwnedProcessGroup(spawned.ownership)),
|
||||
},
|
||||
{
|
||||
captureStdout: false,
|
||||
...(this.settings.maxDiagnosticBytes === undefined
|
||||
? {}
|
||||
: { maxDiagnosticBytes: this.settings.maxDiagnosticBytes }),
|
||||
},
|
||||
);
|
||||
if (
|
||||
spawned.process.stdin === undefined ||
|
||||
spawned.process.stdin === null ||
|
||||
spawned.process.stdout === null
|
||||
)
|
||||
throw new Error(
|
||||
`${this.settings.label} launcher did not supply protocol stdin/stdout`,
|
||||
);
|
||||
spawned.process.stdout.on("data", this.#read);
|
||||
spawned.process.stdout.on("error", this.#error);
|
||||
spawned.process.stderr?.on("data", this.#countStderr);
|
||||
spawned.process.on("error", this.#error);
|
||||
spawned.process.once("exit", this.#exit);
|
||||
spawned.process.once("close", this.#outputClosed);
|
||||
spawned.process.stdin.on("error", this.#error);
|
||||
if (this.#closed)
|
||||
throw new Error(`${this.settings.label} transport closed during startup`);
|
||||
}
|
||||
|
||||
/** Writes SDK-serialized messages and observes stream backpressure. */
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
const input = this.#spawned?.process.stdin;
|
||||
if (this.#closed || input === undefined || input === null)
|
||||
throw new Error(
|
||||
`${this.settings.label} protocol process is not writable`,
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
input.write(serializeMessage(message), (cause) => {
|
||||
if (cause === null || cause === undefined) resolve();
|
||||
else {
|
||||
this.#failure ??= { kind: "process", cause };
|
||||
reject(cause);
|
||||
}
|
||||
});
|
||||
} catch (cause) {
|
||||
this.#failure ??= { kind: "process", cause };
|
||||
reject(cause);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Verifies owned process exit before declaring the transport closed. */
|
||||
close(): Promise<void> {
|
||||
this.#closePromise ??= this.#stop().catch((cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
/** Keep the interruption reason distinct from errors produced by its teardown. */
|
||||
interrupt(reason: unknown): Promise<void> {
|
||||
this.#failure ??= { kind: "interrupted", reason };
|
||||
return this.close();
|
||||
}
|
||||
|
||||
/** Captured provider stderr without persisting inherited environment values. */
|
||||
diagnostics(): AnalysisCapturedOutput | undefined {
|
||||
const owner = this.#supervisor;
|
||||
if (owner === undefined) return undefined;
|
||||
const snapshot = owner.snapshot();
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: snapshot.stderr.text,
|
||||
stderr_bytes: snapshot.stderr.observedBytes,
|
||||
truncated:
|
||||
snapshot.diagnosticTruncated === true || !owner.observedOutputClose(),
|
||||
...(snapshot.exitCode === undefined
|
||||
? {}
|
||||
: { exit_code: snapshot.exitCode }),
|
||||
...(snapshot.signal === undefined ? {} : { signal: snapshot.signal }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve the failed protocol/resource constraint after the SDK closes requests. */
|
||||
observedFailure(): OwnedMcpTransportFailure | undefined {
|
||||
return this.#failure;
|
||||
}
|
||||
|
||||
/** Scope both the byte budget and diagnostic retention to the next serialized request. */
|
||||
beginOperation(): void {
|
||||
this.#bytes = 0;
|
||||
this.#supervisor?.resetOutput();
|
||||
}
|
||||
|
||||
async #stop(): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#frameBuffer = Buffer.alloc(0);
|
||||
this.#frameLength = 0;
|
||||
// A cancellation may race acquisition. Join it before inspecting ownership.
|
||||
await this.#starting?.catch(() => undefined);
|
||||
const process = this.#spawned?.process;
|
||||
process?.stdout?.off("data", this.#read);
|
||||
process?.stdout?.off("error", this.#error);
|
||||
process?.stderr?.off("data", this.#countStderr);
|
||||
process?.off("error", this.#error);
|
||||
process?.off("exit", this.#exit);
|
||||
process?.off("close", this.#outputClosed);
|
||||
const input = process?.stdin;
|
||||
// A job termination can complete an in-flight pipe write asynchronously.
|
||||
// Observe its errors until the owned Writable has finished closing.
|
||||
const inputClosed =
|
||||
input === undefined || input === null
|
||||
? undefined
|
||||
: finished(input, { readable: false, cleanup: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const gracefulInputCloseMs = this.settings.gracefulInputCloseMs ?? 0;
|
||||
if (input !== undefined && input !== null && gracefulInputCloseMs > 0) {
|
||||
input.end();
|
||||
await this.#supervisor?.waitForExit(gracefulInputCloseMs);
|
||||
}
|
||||
const stopped = await this.#supervisor?.stop();
|
||||
input?.destroy();
|
||||
await inputClosed;
|
||||
input?.off("error", this.#error);
|
||||
this.onclose?.();
|
||||
if (stopped?.status === "incomplete")
|
||||
throw new ProviderCleanupError(this.settings.provider, [this.runId], {
|
||||
reason: stopped.reason,
|
||||
stderr: this.diagnostics()?.stderr ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
readonly #error = (error: Error): void => {
|
||||
this.#failure ??= { kind: "process", cause: error };
|
||||
this.onerror?.(error);
|
||||
// Reject pending SDK work after joining the same owned teardown on every path.
|
||||
void this.close().catch((cause: unknown) =>
|
||||
this.onerror?.(cause instanceof Error ? cause : new Error(String(cause))),
|
||||
);
|
||||
};
|
||||
readonly #exit = (): void => {
|
||||
this.#failure ??= { kind: "exit" };
|
||||
if (this.settings.notifyOnExit === true) this.onclose?.();
|
||||
};
|
||||
readonly #outputClosed = (): void => {
|
||||
if (this.settings.notifyOnExit !== true) this.onclose?.();
|
||||
};
|
||||
readonly #countStderr = (chunk: Buffer | string): void => {
|
||||
this.#bytes += Buffer.byteLength(chunk);
|
||||
if (this.#bytes > this.settings.maxOperationBytes)
|
||||
this.#fail(
|
||||
new Error(
|
||||
`${this.settings.label} operation output exceeds ${this.settings.maxOperationBytes} bytes; no complete result is available`,
|
||||
),
|
||||
);
|
||||
};
|
||||
readonly #read = (chunk: Buffer | string): void => {
|
||||
if (this.#closed) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
this.#bytes += bytes.length;
|
||||
if (this.#bytes > this.settings.maxOperationBytes) {
|
||||
this.#fail(
|
||||
new Error(
|
||||
`${this.settings.label} operation output exceeds ${this.settings.maxOperationBytes} bytes; no complete result is available`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const newline = bytes.indexOf(10, offset);
|
||||
const end = newline === -1 ? bytes.length : newline;
|
||||
const fragment = bytes.subarray(offset, end);
|
||||
const frameBytes = this.#frameLength + fragment.length;
|
||||
if (frameBytes > this.settings.maxFrameBytes)
|
||||
throw new Error(
|
||||
`${this.settings.label} MCP frame exceeds ${this.settings.maxFrameBytes} bytes`,
|
||||
);
|
||||
this.#appendFrame(fragment, frameBytes);
|
||||
if (newline === -1) return;
|
||||
const line = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
// Preserve a leading BOM so the JSON-RPC parser can reject it.
|
||||
ignoreBOM: true,
|
||||
}).decode(this.#frameBuffer.subarray(0, this.#frameLength));
|
||||
this.#frameBuffer = Buffer.alloc(0);
|
||||
this.#frameLength = 0;
|
||||
this.onmessage?.(deserializeMessage(line));
|
||||
offset = newline + 1;
|
||||
}
|
||||
} catch (cause) {
|
||||
this.#fail(cause instanceof Error ? cause : new Error(String(cause)));
|
||||
}
|
||||
};
|
||||
#appendFrame(fragment: Buffer, frameBytes: number): void {
|
||||
if (frameBytes > this.#frameBuffer.length) {
|
||||
const capacity = Math.min(
|
||||
this.settings.maxFrameBytes,
|
||||
Math.max(frameBytes, this.#frameBuffer.length * 2, 1),
|
||||
);
|
||||
const grown = Buffer.allocUnsafe(capacity);
|
||||
this.#frameBuffer.copy(grown, 0, 0, this.#frameLength);
|
||||
this.#frameBuffer = grown;
|
||||
}
|
||||
fragment.copy(this.#frameBuffer, this.#frameLength);
|
||||
this.#frameLength = frameBytes;
|
||||
}
|
||||
#fail(error: Error): void {
|
||||
this.#failure ??= { kind: "protocol", cause: error };
|
||||
this.#error(error);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
AnalysisResourceConstraintError,
|
||||
type AnalysisInputIssue,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
projectAnalysisError,
|
||||
projectAnalysisErrorWithoutPartialObservation,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import { projectInputIssueGroupsWithinBudget } from "../domain/inputIssueProjection.js";
|
||||
import type { z } from "zod";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
@@ -936,7 +939,11 @@ class ErrorEvidenceTransport implements Transport {
|
||||
}
|
||||
: { retention: recorded === undefined ? "unavailable" : "failed" }),
|
||||
...(recorded !== undefined && !recorded.ok
|
||||
? { retention_error: projectAnalysisError(recorded.error) }
|
||||
? {
|
||||
retention_error: projectAnalysisErrorWithoutPartialObservation(
|
||||
recorded.error,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
targetContent,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BinaryOpenResult } from "../application/binary/BinarySessionPort.j
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { safeParseEvidence } from "../domain/evidenceValidation.js";
|
||||
import {
|
||||
omitUnretainedEvidenceFromError,
|
||||
projectAnalysisError,
|
||||
type AnalysisErrorProjection,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
TARGET_CONTENT_RESULT_META,
|
||||
type ToolResultEncoding,
|
||||
} from "./toolResultEncoding.js";
|
||||
|
||||
/** Project a response-budget refusal without labeling REA's reserved string limit as Node's ceiling. */
|
||||
@@ -128,12 +130,17 @@ export class ToolResultDelivery {
|
||||
result: Result<JsonValue, AnalysisError>,
|
||||
contract: ToolContract,
|
||||
recovery: Readonly<Record<string, JsonValue>> = {},
|
||||
alternatives?: (
|
||||
failure: Extract<ToolResultEncoding, { readonly ok: false }>,
|
||||
budgetBytes: number,
|
||||
) => Iterable<JsonValue>,
|
||||
): CallToolResult {
|
||||
return result.ok
|
||||
? this.successResult(
|
||||
contract.kind === "session" ? { result: result.value } : result.value,
|
||||
contract,
|
||||
recovery,
|
||||
alternatives,
|
||||
)
|
||||
: this.toErrorToolResult(result.error, contract.returnsTargetContent);
|
||||
}
|
||||
@@ -319,15 +326,31 @@ export class ToolResultDelivery {
|
||||
projected: AnalysisErrorProjection,
|
||||
targetContent: boolean,
|
||||
): CallToolResult {
|
||||
const structuredContent = { error: projected };
|
||||
let structuredContent = { error: projected };
|
||||
// Errors reach the wire as text only; the structured copy is a private
|
||||
// carrier the transport removes after its own budget check.
|
||||
const encoded = encodeToolResult(
|
||||
let encoded = encodeToolResult(
|
||||
structuredContent,
|
||||
this.resultBudgetBytes,
|
||||
"error-text",
|
||||
targetContent,
|
||||
);
|
||||
if (!encoded.ok) {
|
||||
const compact = omitUnretainedEvidenceFromError(projected, encoded);
|
||||
if (compact !== undefined) {
|
||||
const compactContent = { error: compact };
|
||||
const compactEncoded = encodeToolResult(
|
||||
compactContent,
|
||||
this.resultBudgetBytes,
|
||||
"error-text",
|
||||
targetContent,
|
||||
);
|
||||
if (compactEncoded.ok) {
|
||||
structuredContent = compactContent;
|
||||
encoded = compactEncoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The transport retains an oversized projected error before replacing it
|
||||
// with a recoverable delivery constraint, using this same budget.
|
||||
return {
|
||||
@@ -342,13 +365,31 @@ export class ToolResultDelivery {
|
||||
value: JsonValue,
|
||||
contract: ToolContract,
|
||||
recovery: Readonly<Record<string, JsonValue>> = {},
|
||||
alternatives?: (
|
||||
failure: Extract<ToolResultEncoding, { readonly ok: false }>,
|
||||
budgetBytes: number,
|
||||
) => Iterable<JsonValue>,
|
||||
): CallToolResult {
|
||||
const encoded = encodeToolResult(
|
||||
let encoded = encodeToolResult(
|
||||
value,
|
||||
this.resultBudgetBytes,
|
||||
"structured-and-text",
|
||||
contract.returnsTargetContent,
|
||||
);
|
||||
if (!encoded.ok && alternatives !== undefined)
|
||||
for (const candidate of alternatives(encoded, this.resultBudgetBytes)) {
|
||||
const candidateEncoding = encodeToolResult(
|
||||
candidate,
|
||||
this.resultBudgetBytes,
|
||||
"structured-and-text",
|
||||
contract.returnsTargetContent,
|
||||
);
|
||||
if (candidateEncoding.ok) {
|
||||
value = candidate;
|
||||
encoded = candidateEncoding;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (encoded.ok)
|
||||
return {
|
||||
content: [{ type: "text", text: encoded.text }],
|
||||
@@ -369,7 +410,9 @@ export class ToolResultDelivery {
|
||||
? "Call list_unknowns on this same connection with the same filters and offset, using a smaller limit, then follow next_offset. If a single unknown cannot fit, export the session with export_evidence_bundle to recover it."
|
||||
: contract.name === "get_evidence_bundle"
|
||||
? evidenceBundleOverflowStep(value)
|
||||
: "Use complete CLI JSON output to consume the analysis.";
|
||||
: contract.name === "inspect_address_context"
|
||||
? "Call get_evidence_bundle with detail summary to discover retained facet observations, then export_evidence_bundle to inspect complete records. Some facets may not have been retained; a new analysis can differ."
|
||||
: "Use complete CLI JSON output to consume the analysis.";
|
||||
const projected = mcpResponseOverflowProjection({
|
||||
completed: true,
|
||||
constraint: encoded.constraint,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { workflowUnknownInput } from "../application/investigation/WorkflowUnkno
|
||||
import {
|
||||
getNavigationContext,
|
||||
inspectAddressContext,
|
||||
omitContextEvidenceForDelivery,
|
||||
} from "../application/AnalysisContextQueries.js";
|
||||
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
|
||||
import {
|
||||
@@ -323,11 +324,30 @@ const registerContextTools = (
|
||||
const admitted = await admission(
|
||||
addressContract.name,
|
||||
context.mcpReq.signal,
|
||||
async (analysis) =>
|
||||
server.delivery.toCallToolResult(
|
||||
await inspectAddressContext(analysis, input, context.mcpReq.signal),
|
||||
async (analysis) => {
|
||||
const result = await inspectAddressContext(
|
||||
analysis,
|
||||
input,
|
||||
context.mcpReq.signal,
|
||||
);
|
||||
if (!result.ok)
|
||||
return server.delivery.toCallToolResult(result, addressContract);
|
||||
const value = result.value;
|
||||
return server.delivery.toCallToolResult(
|
||||
result,
|
||||
addressContract,
|
||||
),
|
||||
{},
|
||||
(failure, budgetBytes) =>
|
||||
(function* () {
|
||||
for (const alternative of omitContextEvidenceForDelivery(
|
||||
value,
|
||||
failure,
|
||||
budgetBytes,
|
||||
))
|
||||
yield { result: alternative };
|
||||
})(),
|
||||
);
|
||||
},
|
||||
);
|
||||
return admitted.ok
|
||||
? admitted.value
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type JSONRPCMessage,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { OwnedMcpStdioTransport } from "../../../src/process/OwnedMcpStdioTransport.js";
|
||||
import type { ProviderProcessHandle } from "../../../src/process/ProviderProcess.js";
|
||||
import {
|
||||
JadxMcpTransport,
|
||||
@@ -125,6 +126,47 @@ it("decodes split UTF-8 and newline bytes, then routes multiple complete frames"
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it("delivers buffered protocol output before reporting process close", async () => {
|
||||
const process = new FixtureProcess();
|
||||
const transport = new OwnedMcpStdioTransport(
|
||||
{ command: "fixture", arguments: [] },
|
||||
{
|
||||
provider: "fixture",
|
||||
label: "Fixture",
|
||||
maxFrameBytes: 1024,
|
||||
maxOperationBytes: 4096,
|
||||
},
|
||||
fixtureLauncher(process),
|
||||
);
|
||||
const events: string[] = [];
|
||||
transport.onmessage = () => events.push("message");
|
||||
transport.onclose = () => events.push("close");
|
||||
await transport.start();
|
||||
process.emit("exit", 0, null);
|
||||
expect(transport.observedFailure()).toEqual({ kind: "exit" });
|
||||
expect(events).toEqual([]);
|
||||
process.stdout.write(serializeMessage({ jsonrpc: "2.0", id: 1, result: {} }));
|
||||
process.emit("close", 0, null);
|
||||
expect(events).toEqual(["message", "close"]);
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it("reports JADX exit even when a descendant holds the protocol pipes", async () => {
|
||||
const process = new FixtureProcess();
|
||||
const transport = new JadxMcpTransport(
|
||||
{ command: "fixture", arguments: [] },
|
||||
fixtureLauncher(process),
|
||||
);
|
||||
let closed = false;
|
||||
transport.onclose = () => {
|
||||
closed = true;
|
||||
};
|
||||
await transport.start();
|
||||
process.emit("exit", 0, null);
|
||||
expect(closed).toBe(true);
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["invalid UTF-8 byte", Buffer.from([0xff])],
|
||||
["truncated UTF-8 sequence", Buffer.from([0xe2, 0x82])],
|
||||
@@ -221,3 +263,46 @@ it("retries an incomplete owned stop while joining concurrent close callers", as
|
||||
await transport.close();
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it("joins late acquisition before releasing the protocol process", async () => {
|
||||
const process = new FixtureProcess();
|
||||
let deliver:
|
||||
| ((launch: Awaited<ReturnType<JadxLauncher>>) => void)
|
||||
| undefined;
|
||||
let acquired = false;
|
||||
let cleaned = false;
|
||||
const transport = new JadxMcpTransport(
|
||||
{ command: "fixture", arguments: [] },
|
||||
async ({ runId }) =>
|
||||
new Promise((resolve) => {
|
||||
acquired = true;
|
||||
deliver = resolve;
|
||||
// The owned process is returned only when the test releases acquisition.
|
||||
void runId;
|
||||
}),
|
||||
);
|
||||
const startup = transport.start();
|
||||
expect(acquired).toBe(true);
|
||||
let finished = false;
|
||||
const closing = transport.close().then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(finished).toBe(false);
|
||||
deliver?.({
|
||||
process,
|
||||
ownership: { runId: transport.runId, leaderPid: 1, processGroupId: 1 },
|
||||
cleanup: async () => {
|
||||
cleaned = true;
|
||||
process.emit("exit", 0, null);
|
||||
process.emit("close", 0, null);
|
||||
process.stdout.end();
|
||||
process.stderr.end();
|
||||
process.stdin.end();
|
||||
return { cleaned: true, signaled: false };
|
||||
},
|
||||
});
|
||||
await expect(startup).rejects.toThrow("closed during startup");
|
||||
await closing;
|
||||
expect(cleaned).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createCanonicalTemporaryDirectory } from "../../../scripts/lib/canonical-temporary-directory.mjs";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("uses the canonical spelling for a verifier directory allocated through a symlink", async () => {
|
||||
const root = await createTestTempDirectory("rea-pascal-verifier-path-");
|
||||
const alias = join(root, "alias");
|
||||
await symlink(root, alias, process.platform === "win32" ? "junction" : "dir");
|
||||
const allocated = await createCanonicalTemporaryDirectory(
|
||||
"rea-pascal-calls-",
|
||||
alias,
|
||||
);
|
||||
try {
|
||||
expect(allocated.path).toBe(await realpath(allocated.allocatedPath));
|
||||
expect(allocated.path).not.toBe(
|
||||
join(alias, basename(allocated.allocatedPath)),
|
||||
);
|
||||
} finally {
|
||||
await rm(allocated.allocatedPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "darwin")(
|
||||
"forwards verifier SIGTERM to the owned compiler group and removes its temporary directory",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-pascal-verifier-cancel-");
|
||||
const fakeCompiler = join(root, "fake-fpc");
|
||||
const pidFile = join(root, "compiler-pids.txt");
|
||||
await writeFile(
|
||||
fakeCompiler,
|
||||
'#!/usr/bin/env node\nimport { spawn } from "node:child_process";\nimport { writeFileSync } from "node:fs";\nconst child = spawn(process.execPath, ["-e", "setInterval(() => undefined, 1000)"], { stdio: "ignore" });\nwriteFileSync(process.env.REA_VERIFY_TEST_PID_FILE, `${process.pid} ${child.pid}\\n`);\nawait new Promise(() => undefined);\n',
|
||||
);
|
||||
await chmod(fakeCompiler, 0o700);
|
||||
const verifier = spawn(
|
||||
process.execPath,
|
||||
["scripts/verify-native-pascal-calls.mjs"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: root,
|
||||
REA_VERIFY_FPC_COMMAND: fakeCompiler,
|
||||
REA_VERIFY_TEST_PID_FILE: pidFile,
|
||||
},
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
try {
|
||||
const deadline = Date.now() + 10_000;
|
||||
let pids: number[] | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
pids = (await readFile(pidFile, "utf8"))
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.map(Number);
|
||||
break;
|
||||
} catch (cause: unknown) {
|
||||
if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
expect(pids, "the fake compiler started").toHaveLength(2);
|
||||
verifier.kill("SIGTERM");
|
||||
const result = await new Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}>((resolve, reject) => {
|
||||
verifier.once("error", reject);
|
||||
verifier.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
expect(result).toEqual({ code: 143, signal: null });
|
||||
for (const pid of pids ?? [])
|
||||
expect(() => process.kill(pid, 0)).toThrow();
|
||||
expect(
|
||||
(await readdir(root)).filter((name) =>
|
||||
name.startsWith("rea-pascal-calls-"),
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
if (verifier.exitCode === null && verifier.signalCode === null) {
|
||||
verifier.kill("SIGTERM");
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
verifier.kill("SIGKILL");
|
||||
resolve(undefined);
|
||||
}, 1_000);
|
||||
verifier.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
try {
|
||||
const recorded = (await readFile(pidFile, "utf8"))
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.map(Number);
|
||||
for (const pid of recorded) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// The owned compiler group already exited.
|
||||
}
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,145 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "../../../src/contracts/sessionToolContracts.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"preserves retained siblings and capacity recovery (%s schema)",
|
||||
async (profile) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const directory = await createTestTempDirectory("rea-context-capacity-");
|
||||
const target = join(directory, "fixture.com");
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) => {
|
||||
if (operation === "address_name")
|
||||
return ok(createAnalysisExecution("entry", identity));
|
||||
if (operation === "resolve_containing_procedure")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
query_address: "0x100",
|
||||
found: true,
|
||||
procedure: {
|
||||
address: "0x100",
|
||||
name: "f".repeat(16000),
|
||||
classification: null,
|
||||
body: { available: false, reason: "not recorded" },
|
||||
},
|
||||
},
|
||||
identity,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
|
||||
},
|
||||
);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 8000,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
|
||||
},
|
||||
);
|
||||
const client = new Client({ name: "context-capacity", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const definitions = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const call = (name: string, args: Record<string, string>) => {
|
||||
const toolDefinition = definitions.get(name);
|
||||
if (toolDefinition === undefined)
|
||||
throw new Error(`Tool ${name} was not advertised`);
|
||||
return client.callTool({ name, arguments: args }, { toolDefinition });
|
||||
};
|
||||
expect(
|
||||
(
|
||||
await call("open_binary", {
|
||||
path: target,
|
||||
format: "dos-com",
|
||||
provider_id: "fixture",
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const arguments_ = { document: "fixture", address: "0x100" };
|
||||
const response = await call("inspect_address_context", arguments_);
|
||||
expect(response.isError).not.toBe(true);
|
||||
const contract = SESSION_TOOL_CONTRACTS.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<
|
||||
(typeof SESSION_TOOL_CONTRACTS)[number],
|
||||
{ readonly name: "inspect_address_context" }
|
||||
> => candidate.name === "inspect_address_context",
|
||||
);
|
||||
if (contract === undefined)
|
||||
throw new Error("inspect_address_context contract is missing");
|
||||
const context = contract.outputSchema.parse(
|
||||
response.structuredContent,
|
||||
).result;
|
||||
const direct = await call("resolve_containing_procedure", arguments_);
|
||||
expect(direct.isError).toBe(true);
|
||||
const expected = parseMcpToolError(direct).error;
|
||||
expect(expected.code).toBe("resource_constraint");
|
||||
expect(expected.details).toMatchObject({
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
operation: "resolve_containing_procedure",
|
||||
normalized_result: { found: true, query_address: "0x100" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(context.name).toEqual({ state: "available", value: "entry" });
|
||||
expect(context.procedure).toEqual({
|
||||
state: "unavailable",
|
||||
reason: expected.message,
|
||||
remediation: expected.remediation.action,
|
||||
error: expected,
|
||||
});
|
||||
expect(records.statistics().evidenceRecords).toBe(1);
|
||||
expect(session.immutableEvidenceBundle().records[0]).toMatchObject({
|
||||
operation: "address_name",
|
||||
normalized_result: "entry",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,331 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const verifyContext = (
|
||||
content: unknown,
|
||||
twoLarge: boolean,
|
||||
session: BinarySession,
|
||||
): void => {
|
||||
expect(content).toMatchObject({
|
||||
result: {
|
||||
name: { state: "available", value: "entry" },
|
||||
procedure: {
|
||||
state: "unavailable",
|
||||
error: {
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unretained",
|
||||
original_response_bytes_at_least: expect.any(Number),
|
||||
evidence_id: expect.stringMatching(/^ev_[a-f0-9]{64}$/u),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const procedureDetails = (
|
||||
content as {
|
||||
result: {
|
||||
procedure: {
|
||||
error: {
|
||||
details: {
|
||||
partial_observation_omitted: { evidence_id: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).result.procedure.error.details;
|
||||
expect(procedureDetails).not.toHaveProperty("partial_observation");
|
||||
expect(procedureDetails).not.toHaveProperty("evidence_reference");
|
||||
expect(
|
||||
session.hasEvidence(
|
||||
procedureDetails.partial_observation_omitted.evidence_id,
|
||||
),
|
||||
).toBe(false);
|
||||
const smallFacet = twoLarge ? "inline_comment" : "comment";
|
||||
expect(content).toMatchObject({
|
||||
result: {
|
||||
[smallFacet]: {
|
||||
state: "unavailable",
|
||||
error: {
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
operation: smallFacet,
|
||||
normalized_result: "short note",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(twoLarge
|
||||
? {
|
||||
comment: {
|
||||
state: "unavailable",
|
||||
error: {
|
||||
details: {
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
retention: "unretained",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
["compact", false],
|
||||
["full", false],
|
||||
["compact", true],
|
||||
] as const)(
|
||||
"keeps context and smaller partials when unretained observations exceed the MCP budget (%s, two large=%s)",
|
||||
async (profile, twoLarge) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const directory = await createTestTempDirectory("rea-context-capacity-");
|
||||
const target = join(directory, "fixture.com");
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) => {
|
||||
if (operation === "address_name")
|
||||
return ok(createAnalysisExecution("entry", identity));
|
||||
if (operation === "comment")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
twoLarge ? "g".repeat(6 * 1024 * 1024) : "short note",
|
||||
identity,
|
||||
),
|
||||
);
|
||||
if (operation === "inline_comment" && twoLarge)
|
||||
return ok(createAnalysisExecution("short note", identity));
|
||||
if (operation === "resolve_containing_procedure")
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
query_address: "0x100",
|
||||
found: true,
|
||||
procedure: {
|
||||
address: "0x100",
|
||||
name: "f".repeat(6 * 1024 * 1024),
|
||||
classification: null,
|
||||
body: { available: false, reason: "not recorded" },
|
||||
},
|
||||
},
|
||||
identity,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
);
|
||||
},
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({ profile: createAnalysisProfile(identity, { fixture: true }) }),
|
||||
},
|
||||
);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes: 8000,
|
||||
});
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_TOOL_FAMILIES: "session",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: profile,
|
||||
},
|
||||
},
|
||||
);
|
||||
const client = new Client({ name: "context-capacity", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const definitions = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const call = (name: string, args: Record<string, string>) => {
|
||||
const toolDefinition = definitions.get(name);
|
||||
if (toolDefinition === undefined)
|
||||
throw new Error(`Tool ${name} was not advertised`);
|
||||
return client.callTool({ name, arguments: args }, { toolDefinition });
|
||||
};
|
||||
expect(
|
||||
(
|
||||
await call("open_binary", {
|
||||
path: target,
|
||||
format: "dos-com",
|
||||
provider_id: "fixture",
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const arguments_ = { document: "fixture", address: "0x100" };
|
||||
const response = await call("inspect_address_context", arguments_);
|
||||
expect(response.isError).not.toBe(true);
|
||||
verifyContext(response.structuredContent, twoLarge, session);
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(bundle.structuredContent).toMatchObject({
|
||||
result: {
|
||||
records: [{ operation: "address_name", normalized_result: "entry" }],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([true, false])(
|
||||
"recovers retained context facets when possible (profile=%s)",
|
||||
async (profile) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-context-oversize-success-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) =>
|
||||
operation === "health"
|
||||
? ok(createAnalysisExecution(null, identity))
|
||||
: operation === "address_name"
|
||||
? ok(
|
||||
createAnalysisExecution(
|
||||
"x".repeat(6 * 1024 * 1024),
|
||||
identity,
|
||||
),
|
||||
)
|
||||
: err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"not provided",
|
||||
),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
profile
|
||||
? {
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({
|
||||
profile: createAnalysisProfile(identity, { fixture: true }),
|
||||
}),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
const session = new BinarySession(router);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ environment: { ...process.env, REA_MCP_TOOL_FAMILIES: "session" } },
|
||||
);
|
||||
const client = new Client({
|
||||
name: "context-oversize-success",
|
||||
version: "1",
|
||||
});
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_address_context",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { reported_limits: { boundary: "mcp-response" } },
|
||||
});
|
||||
expect(error.remediation.action).toContain("get_evidence_bundle");
|
||||
expect(error.remediation.action).toContain("export_evidence_bundle");
|
||||
expect(error.remediation.action).toContain("may not have been retained");
|
||||
const advertised = new Set(
|
||||
(await client.listTools()).tools.map((tool) => tool.name),
|
||||
);
|
||||
expect(advertised.has("get_evidence_bundle")).toBe(true);
|
||||
expect(advertised.has("export_evidence_bundle")).toBe(true);
|
||||
expect(advertised.has("address_name")).toBe(false);
|
||||
const summary = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {
|
||||
detail: "summary",
|
||||
filters: { operation: "address_name" },
|
||||
},
|
||||
});
|
||||
expect(summary.isError).not.toBe(true);
|
||||
expect(summary.structuredContent).toMatchObject({
|
||||
result: { matching_records: profile ? 1 : 0 },
|
||||
});
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-context-recover-"),
|
||||
"evidence.json",
|
||||
);
|
||||
const exported = await client.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(exported.isError).not.toBe(true);
|
||||
const bundle = JSON.parse(await readFile(path, "utf8")) as {
|
||||
records: { operation: string; normalized_result: unknown }[];
|
||||
};
|
||||
const record = bundle.records.find(
|
||||
(entry) => entry.operation === "address_name",
|
||||
);
|
||||
if (profile)
|
||||
expect(record?.normalized_result).toBe("x".repeat(6 * 1024 * 1024));
|
||||
else expect(record).toBeUndefined();
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -140,6 +140,106 @@ const trace = async (analysis: AnalysisOperationPort) => {
|
||||
return result.structuredContent as { normalized_result: unknown };
|
||||
};
|
||||
|
||||
it("reports known reference-discovery gaps without claiming truncation or a missing edge", async () => {
|
||||
const baseline = provider();
|
||||
const gap =
|
||||
"An unwalked candidate may have outgoing references; its presence does not establish a reference to the selected target.";
|
||||
const output = await trace({
|
||||
execute: (operation, input, options) =>
|
||||
operation === "xrefs" && input.address === "0x10"
|
||||
? Promise.resolve(
|
||||
ok(
|
||||
createAnalysisExecution([], PROVIDER, {
|
||||
referenceCoverageGaps: [
|
||||
{ code: "unwalked", scope: "observation", description: gap },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
: baseline.execute(operation, input, options),
|
||||
});
|
||||
expect(output.normalized_result).toMatchObject({
|
||||
truncated: false,
|
||||
residual_unknowns: [`References to 0x10: ${gap}`],
|
||||
references: [{ target_address: "0x11", source_address: "0x21" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates an image-wide gap without merging target-specific gaps", async () => {
|
||||
const baseline = provider();
|
||||
const output = await trace({
|
||||
execute: async (operation, input, options) => {
|
||||
const result = await baseline.execute(operation, input, options);
|
||||
if (operation !== "xrefs" || !result.ok) return result;
|
||||
return ok({
|
||||
...result.value,
|
||||
referenceCoverageGaps: [
|
||||
{
|
||||
code: "missing-relocations",
|
||||
scope: "image",
|
||||
description: "Image relocation coverage unavailable.",
|
||||
},
|
||||
{
|
||||
code: "unwalked",
|
||||
scope: "observation",
|
||||
description: "This observation has unwalked sources.",
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
expect(output.normalized_result).toMatchObject({
|
||||
truncated: false,
|
||||
residual_unknowns: [
|
||||
"Image relocation coverage unavailable.",
|
||||
"References to 0x10: This observation has unwalked sources.",
|
||||
"References to 0x11: This observation has unwalked sources.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("carries reported pointer classifications without inferring other reference kinds", async () => {
|
||||
const baseline = provider();
|
||||
const output = await trace({
|
||||
execute: (operation, input, options) =>
|
||||
operation === "xrefs" && input.address === "0x10"
|
||||
? Promise.resolve(
|
||||
ok(
|
||||
createAnalysisExecution(["0x20"], PROVIDER, {
|
||||
referenceClassifications: [
|
||||
{
|
||||
sourceAddress: "0x20",
|
||||
targetAddress: "0x10",
|
||||
kind: "stored_pointer",
|
||||
provenance: "chained-fixups",
|
||||
width: 8,
|
||||
},
|
||||
{
|
||||
sourceAddress: "0x20",
|
||||
targetAddress: "0x99",
|
||||
kind: "stored_pointer",
|
||||
provenance: "other",
|
||||
width: 4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
: baseline.execute(operation, input, options),
|
||||
});
|
||||
expect(output.normalized_result).toMatchObject({
|
||||
references: [
|
||||
{
|
||||
source_address: "0x20",
|
||||
source_classifications: [
|
||||
{ kind: "stored_pointer", provenance: "chained-fixups", width: 8 },
|
||||
],
|
||||
},
|
||||
{ source_address: "0x21" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves references and explicitly unknown owners without claiming truncation", async () => {
|
||||
const baseline = provider();
|
||||
const output = await trace({
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { compareFunctionEvidence } from "../../../src/application/investigation/FunctionComparisonEvidence.js";
|
||||
import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js";
|
||||
import { safeParseEvidence } from "../../../src/domain/evidenceValidation.js";
|
||||
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
|
||||
|
||||
it.each([
|
||||
{ profile: "compact", preexisting: false },
|
||||
{ profile: "compact", preexisting: true },
|
||||
{ profile: "full", preexisting: false },
|
||||
{ profile: "full", preexisting: true },
|
||||
] as const)(
|
||||
"preserves the completed comparison on atomic retention failure ($profile schema, preexisting: $preexisting)",
|
||||
async ({ profile, preexisting }) => {
|
||||
const input = { ...FUNCTION_COMPARISON_EXAMPLE };
|
||||
const computed = compareFunctionEvidence(input);
|
||||
if (!computed.ok) throw computed.error;
|
||||
expect(computed.value.unknown).toBeDefined();
|
||||
const initial = [
|
||||
input.left,
|
||||
input.right,
|
||||
...(preexisting ? [computed.value.evidence] : []),
|
||||
];
|
||||
const sizing = new InvestigationRecords();
|
||||
for (const evidence of initial)
|
||||
expect(sizing.recordEvidence(evidence).ok).toBe(true);
|
||||
const records = new InvestigationRecords({
|
||||
maximumRepresentationBytes:
|
||||
sizing.statistics().retainedRepresentationBytes,
|
||||
});
|
||||
for (const evidence of initial)
|
||||
expect(records.recordEvidence(evidence).ok).toBe(true);
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => {
|
||||
throw new Error("Comparison must not launch a provider");
|
||||
}),
|
||||
records,
|
||||
);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: {
|
||||
environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const reply = await harness.client.callTool({
|
||||
name: "compare_functions",
|
||||
arguments: input,
|
||||
});
|
||||
expect(reply.isError).toBe(true);
|
||||
const error = parseMcpToolError(reply).error;
|
||||
analysisErrorProjectionSchema.parse(error);
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: { operation: "record_unknown", resource: "capacity" },
|
||||
});
|
||||
const partial = error.details?.partial_observation;
|
||||
if (preexisting) {
|
||||
expect(partial).toEqual({
|
||||
kind: "retained-evidence",
|
||||
evidence_id: computed.value.evidence.evidence_id,
|
||||
});
|
||||
} else {
|
||||
expect(partial).toMatchObject({
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
evidence_id: computed.value.evidence.evidence_id,
|
||||
operation: "compare_functions",
|
||||
},
|
||||
});
|
||||
const authenticated = safeParseEvidence(
|
||||
(partial as { evidence: unknown }).evidence,
|
||||
);
|
||||
if (!authenticated.ok) throw authenticated.error;
|
||||
expect(authenticated.value).toEqual(computed.value.evidence);
|
||||
}
|
||||
expect(session.hasEvidence(computed.value.evidence.evidence_id)).toBe(
|
||||
preexisting,
|
||||
);
|
||||
expect(session.hasEvidence(input.left.evidence_id)).toBe(true);
|
||||
expect(session.hasEvidence(input.right.evidence_id)).toBe(true);
|
||||
expect(records.statistics().unknownRevisions).toBe(0);
|
||||
const bundle = await harness.client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(bundle.isError).not.toBe(true);
|
||||
const result = toolContract("get_evidence_bundle").outputSchema.parse(
|
||||
bundle.structuredContent,
|
||||
);
|
||||
const ids = parseEvidenceBundle(result.result).records.map(
|
||||
({ evidence_id }) => evidence_id,
|
||||
);
|
||||
expect(ids.includes(computed.value.evidence.evidence_id)).toBe(
|
||||
preexisting,
|
||||
);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,250 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { createTestProviderRouter } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { safeParseEvidence } from "../../../src/domain/evidenceValidation.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
|
||||
it.each(["compact", "full"] as const)(
|
||||
"delivers authenticated but unretained provider Evidence inline (%s schema)",
|
||||
async (profile) => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-evidence-refusal-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () =>
|
||||
ok(
|
||||
createAnalysisExecution("entry", identity, {
|
||||
limitations: ["fixture limitation"],
|
||||
}),
|
||||
),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{
|
||||
environment: { ...process.env, REA_MCP_OUTPUT_SCHEMA_PROFILE: profile },
|
||||
},
|
||||
);
|
||||
const client = new Client({ name: "unretained-evidence", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const definitions = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const definition = definitions.get("address_name");
|
||||
if (definition === undefined)
|
||||
throw new Error("address_name was not advertised");
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "address_name",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
},
|
||||
{ toolDefinition: definition },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error.code).toBe("resource_constraint");
|
||||
expect(error.details).toMatchObject({
|
||||
operation: "record_evidence",
|
||||
resource: "capacity",
|
||||
partial_observation: {
|
||||
kind: "unretained-evidence",
|
||||
evidence: {
|
||||
operation: "address_name",
|
||||
normalized_result: "entry",
|
||||
provider: identity,
|
||||
limitations: ["fixture limitation"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const partial = error.details?.partial_observation as {
|
||||
kind: string;
|
||||
evidence: unknown;
|
||||
};
|
||||
const authenticated = safeParseEvidence(partial.evidence);
|
||||
expect(authenticated.ok).toBe(true);
|
||||
if (!authenticated.ok) throw authenticated.error;
|
||||
expect(session.hasEvidence(authenticated.value.evidence_id)).toBe(false);
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
expect(error.details).not.toHaveProperty("evidence_reference");
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "complete" },
|
||||
});
|
||||
expect(bundle.isError).not.toBe(true);
|
||||
expect(bundle.structuredContent).toMatchObject({
|
||||
result: { records: [] },
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an oversized unretained observation failure bounded and the connection usable", async () => {
|
||||
const identity = {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
};
|
||||
const target = join(
|
||||
await createTestTempDirectory("rea-evidence-overflow-"),
|
||||
"fixture.com",
|
||||
);
|
||||
await writeFile(target, Buffer.from([0xb8, 0, 0x4c, 0xcd, 0x21]));
|
||||
const value = "x".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536);
|
||||
const router = createTestProviderRouter(() => ({
|
||||
execute: async () => ok(createAnalysisExecution(value, identity)),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const session = new BinarySession(router, records);
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({
|
||||
name: "unretained-evidence-overflow",
|
||||
version: "1",
|
||||
});
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: target, format: "dos-com" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: "address_name",
|
||||
arguments: { address: "0x100", document: "fixture" },
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
operation: "record_evidence",
|
||||
resource: "capacity",
|
||||
reported_limits: { maximum_representation_bytes: 0 },
|
||||
partial_observation_omitted: {
|
||||
reason: "mcp-response-limit",
|
||||
constraint: "receive-buffer",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(error.remediation.action).toContain("not retained");
|
||||
expect(error.details).not.toHaveProperty("partial_observation");
|
||||
expect(error.details).not.toHaveProperty("evidence_reference");
|
||||
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
);
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: { detail: "summary" },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("bounds fallback when the retention error itself has oversized diagnostics", async () => {
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "address_name", parameters: {}, result: "entry" },
|
||||
{ immutable: true },
|
||||
);
|
||||
const refused = records.recordEvidence(evidence);
|
||||
if (refused.ok) throw new Error("Expected retention refusal");
|
||||
refused.error.retainDiagnostics({
|
||||
oversized: "x".repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE + 65536),
|
||||
});
|
||||
const delivery = createToolResultDelivery(STDIO_DEFAULT_MAX_BUFFER_SIZE);
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "retention-overflow", version: "1" },
|
||||
{ capabilities: {} },
|
||||
(record) => records.recordEvidence(record),
|
||||
delivery,
|
||||
);
|
||||
server.registerTool(
|
||||
"large_retention_failure",
|
||||
{ inputSchema: z.object({}) },
|
||||
async () => delivery.toErrorToolResult(refused.error, true),
|
||||
);
|
||||
const client = new Client({ name: "retention-overflow", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const result = await client.callTool({
|
||||
name: "large_retention_failure",
|
||||
arguments: {},
|
||||
});
|
||||
const error = parseMcpToolError(result).error;
|
||||
expect(error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
reported_limits: {
|
||||
boundary: "mcp-response",
|
||||
original_error_code: "resource_constraint",
|
||||
retention: "failed",
|
||||
retention_error: { code: "resource_constraint" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
);
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}, 120000);
|
||||
@@ -67,7 +67,16 @@ const tracer: NativeCallTracer & { calls: number } = {
|
||||
breakpoints: request.input.breakpoints.map((_, index) => ({
|
||||
index,
|
||||
location_count: 1,
|
||||
locations: [],
|
||||
locations: [
|
||||
{
|
||||
load_address: "0x1000",
|
||||
file_address: "0x1000",
|
||||
module: "Tool",
|
||||
module_path: request.executable,
|
||||
symbol: "open",
|
||||
source_location: { file_path: null, line: 23, column: null },
|
||||
},
|
||||
],
|
||||
})),
|
||||
events: [],
|
||||
resource_limit_reached: false,
|
||||
@@ -133,6 +142,12 @@ it("routes observe_native_calls through MCP with schema-checked input and output
|
||||
.parse(called.structuredContent).normalized_result,
|
||||
);
|
||||
expect(result.process).toMatchObject({ pid: 99, outcome: "exited" });
|
||||
expect(result.breakpoints[0]?.locations[0]?.source_location).toEqual({
|
||||
file_path: null,
|
||||
line: 23,
|
||||
column: null,
|
||||
});
|
||||
|
||||
expect(result.target.environment).toEqual(
|
||||
JSON.parse(
|
||||
'{"__proto__":"selected literal value","constructor":"ordinary value"}',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
analysisViewResultSchema,
|
||||
inspectAnalysisViewInputSchema,
|
||||
} from "../../../src/domain/analysisView/analysisView.js";
|
||||
import { XREFS_ADDRESS_PAGE_LIMITATION } from "../../../src/domain/analysisView/nativeObservationView.js";
|
||||
import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../../../src/domain/native/nativeObservation.js";
|
||||
import { connectGhidraMcp } from "./ghidraMcpHarness.js";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
@@ -138,6 +139,9 @@ it("delivers native primitive and graph summaries with exact retained parents an
|
||||
},
|
||||
coverage: { total: 1, exhausted: true },
|
||||
});
|
||||
expect(
|
||||
analysisViewResultSchema.parse(page.normalized_result).unknowns,
|
||||
).toContain(XREFS_ADDRESS_PAGE_LIMITATION);
|
||||
const caveats = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "native-context", facet: "limitations" },
|
||||
|
||||
@@ -124,3 +124,15 @@ it.each(["duplicate-first", "duplicate-last"])(
|
||||
expect(result.error?.message).toMatch(/Duplicate MCP tool/u);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves cancellation received during final transport cleanup", async () => {
|
||||
const result = await probe("cancel-finalization");
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.error?.message).toBe("cancelled during final cleanup");
|
||||
});
|
||||
|
||||
it("retains a work failure when cancellation arrives during cleanup", async () => {
|
||||
const result = await probe("failure-cancel-finalization");
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.error?.message).toBe("work failed before cancellation");
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { access, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { expect, it } from "vitest";
|
||||
import { createPackageVerifierCommands } from "../../../scripts/lib/verify-package-commands.mjs";
|
||||
import { OwnedMcpStdioTransport } from "../../../src/process/OwnedMcpStdioTransport.js";
|
||||
|
||||
it("retains the workspace until an owned protocol cleanup can be retried", async () => {
|
||||
const path = await mkdtemp(join(tmpdir(), "rea-verifier-transport-"));
|
||||
const process = Object.assign(new EventEmitter(), {
|
||||
pid: 1,
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
stdin: new PassThrough(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: () => true,
|
||||
});
|
||||
let refuse = true;
|
||||
const transport = new OwnedMcpStdioTransport(
|
||||
{ command: "fixture", arguments: [] },
|
||||
{
|
||||
provider: "fixture",
|
||||
label: "Fixture",
|
||||
maxFrameBytes: 1024,
|
||||
maxOperationBytes: 4096,
|
||||
},
|
||||
async ({ runId }) => ({
|
||||
process,
|
||||
ownership: { runId, leaderPid: 1, processGroupId: 1 },
|
||||
cleanup: async () => {
|
||||
if (refuse)
|
||||
return { cleaned: false, reason: "injected cleanup failure" };
|
||||
process.emit("close", 0, null);
|
||||
process.stdout.end();
|
||||
process.stderr.end();
|
||||
process.stdin.end();
|
||||
return { cleaned: true, signaled: false };
|
||||
},
|
||||
}),
|
||||
);
|
||||
const commands = await createPackageVerifierCommands({
|
||||
workspace: {
|
||||
path,
|
||||
close: () => rm(path, { recursive: true, force: true }),
|
||||
},
|
||||
});
|
||||
commands.adopt(transport, "protocol child");
|
||||
try {
|
||||
await transport.start();
|
||||
// The group can still contain work after the leader's exit.
|
||||
process.emit("exit", 0, null);
|
||||
await expect(commands.close()).rejects.toMatchObject({
|
||||
path,
|
||||
cleanupOwner: commands,
|
||||
});
|
||||
await expect(access(path)).resolves.toBeUndefined();
|
||||
refuse = false;
|
||||
await commands.close();
|
||||
await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -286,6 +286,226 @@ it("keeps exact endpoints, deduplicates sites and counts excluded forwarding ali
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves pointer and walk coverage separately and rejects inconsistent pointer identity", () => {
|
||||
const pointer = {
|
||||
kind: "pointer",
|
||||
from_address: "@16384",
|
||||
from_address_hex: "0x4000",
|
||||
from_name: "slot",
|
||||
to_address: "@8192",
|
||||
to_address_hex: "0x2000",
|
||||
to_name: "callee",
|
||||
width: 8,
|
||||
source: "chained-fixups",
|
||||
};
|
||||
const source = {
|
||||
...xrefs(),
|
||||
coverage: {
|
||||
decoded: true,
|
||||
section: "__TEXT,__text",
|
||||
code_section: true,
|
||||
executable: true,
|
||||
unwalked: [
|
||||
{
|
||||
name: "middle",
|
||||
address: "@20480",
|
||||
address_hex: "0x5000",
|
||||
section: "__TEXT,__const",
|
||||
},
|
||||
],
|
||||
notes: ["middle was not walked"],
|
||||
},
|
||||
data_refs_coverage: "chained-fixups",
|
||||
data_refs_count: 1,
|
||||
data_refs: [pointer],
|
||||
};
|
||||
expect(
|
||||
parseKunaIncomingReferences(wire(source), input, "0x2000"),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
addresses: ["0x1004", "0x4000"],
|
||||
pointerCoverage: "chained-fixups",
|
||||
pointers: [
|
||||
{
|
||||
from_address: "0x4000",
|
||||
to_address: "0x2000",
|
||||
source: "chained-fixups",
|
||||
},
|
||||
],
|
||||
walkCoverage: { unwalked: [{ address: "0x5000" }] },
|
||||
},
|
||||
});
|
||||
pointer.to_address_hex = "0x2001";
|
||||
expect(
|
||||
parseKunaIncomingReferences(wire(source), input, "0x2000"),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "xref_endpoints" },
|
||||
});
|
||||
});
|
||||
|
||||
it("counts pointer rows excluded because they target a forwarding alias", () => {
|
||||
const pointer = (from: number, to: number) => ({
|
||||
kind: "pointer",
|
||||
from_address: `@${from}`,
|
||||
from_address_hex: `0x${from.toString(16)}`,
|
||||
from_name: null,
|
||||
to_address: `@${to}`,
|
||||
to_address_hex: `0x${to.toString(16)}`,
|
||||
to_name: null,
|
||||
width: 8,
|
||||
source: "dynamic-relocations",
|
||||
});
|
||||
const source = {
|
||||
...xrefs(),
|
||||
data_refs_coverage: "dynamic-relocations",
|
||||
data_refs_count: 2,
|
||||
data_refs: [pointer(16384, 8192), pointer(16392, 12288)],
|
||||
};
|
||||
expect(
|
||||
parseKunaIncomingReferences(wire(source), input, "0x2000"),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
addresses: ["0x1004", "0x4000"],
|
||||
rows: 5,
|
||||
excludedAliasRows: 2,
|
||||
targetAliases: [{ name: "slot", address: "0x3000" }],
|
||||
pointers: [
|
||||
{ from_address: "0x4000", to_address: "0x2000" },
|
||||
{ from_address: "0x4008", to_address: "0x3000" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves full-width pointer and unwalked addresses without numeric rounding", () => {
|
||||
const source = {
|
||||
binary: input,
|
||||
direction: "to",
|
||||
count: 0,
|
||||
target: {
|
||||
name: null,
|
||||
address: "@18446744073709551615",
|
||||
address_hex: "0xffffffffffffffff",
|
||||
aliases: [],
|
||||
},
|
||||
xrefs: [],
|
||||
coverage: {
|
||||
decoded: false,
|
||||
section: null,
|
||||
code_section: null,
|
||||
executable: null,
|
||||
unwalked: [
|
||||
{
|
||||
name: "candidate",
|
||||
address: "@9007199254740993",
|
||||
address_hex: "0x20000000000001",
|
||||
section: ".text",
|
||||
},
|
||||
],
|
||||
notes: [],
|
||||
},
|
||||
data_refs_coverage: "dynamic-relocations",
|
||||
data_refs_count: 1,
|
||||
data_refs: [
|
||||
{
|
||||
kind: "pointer",
|
||||
from_address: "@9007199254740995",
|
||||
from_address_hex: "0x20000000000003",
|
||||
from_name: null,
|
||||
to_address: "@18446744073709551615",
|
||||
to_address_hex: "0xffffffffffffffff",
|
||||
to_name: null,
|
||||
width: 8,
|
||||
source: "dynamic-relocations",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
parseKunaIncomingReferences(wire(source), input, "0xffffffffffffffff"),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
addresses: ["0x20000000000003"],
|
||||
rows: 1,
|
||||
excludedAliasRows: 0,
|
||||
pointers: [
|
||||
{ from_address: "0x20000000000003", to_address: "0xffffffffffffffff" },
|
||||
],
|
||||
walkCoverage: { unwalked: [{ address: "0x20000000000001" }] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes unreported pointer coverage from an explicitly empty pointer list", () => {
|
||||
const source = xrefs();
|
||||
expect(
|
||||
parseKunaIncomingReferences(wire(source), input, "0x2000"),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { pointerCoverage: null, pointers: null, walkCoverage: null },
|
||||
});
|
||||
expect(
|
||||
parseKunaIncomingReferences(
|
||||
wire({
|
||||
...source,
|
||||
data_refs_coverage: "none",
|
||||
data_refs_count: 0,
|
||||
data_refs: [],
|
||||
}),
|
||||
input,
|
||||
"0x2000",
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { pointerCoverage: "none", pointers: [], walkCoverage: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects partial or inconsistent pointer inventories rather than reporting them empty", () => {
|
||||
const source = xrefs();
|
||||
for (const fields of [
|
||||
{ data_refs: [] },
|
||||
{ data_refs_count: 0 },
|
||||
{ data_refs_count: 1, data_refs: [] },
|
||||
]) {
|
||||
expect(
|
||||
parseKunaIncomingReferences(
|
||||
wire({ ...source, ...fields }),
|
||||
input,
|
||||
"0x2000",
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "xref_count" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves unknown coverage for an address outside known sections", () => {
|
||||
const source = {
|
||||
...xrefs(),
|
||||
coverage: {
|
||||
decoded: false,
|
||||
section: null,
|
||||
code_section: null,
|
||||
executable: null,
|
||||
unwalked: [],
|
||||
notes: [],
|
||||
},
|
||||
};
|
||||
expect(
|
||||
parseKunaIncomingReferences(wire(source), input, "0x2000"),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
walkCoverage: { section: null, code_section: null, executable: null },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"count",
|
||||
|
||||
@@ -55,6 +55,26 @@ const observation = (
|
||||
};
|
||||
|
||||
describe("LLDB retained observations", () => {
|
||||
it("preserves partial source coordinates while accepting historical omission", () => {
|
||||
const attributed = {
|
||||
...event,
|
||||
source_location: {
|
||||
file_path: "/missing/fixture.pas",
|
||||
line: 23,
|
||||
column: null,
|
||||
},
|
||||
};
|
||||
const journal =
|
||||
[event, attributed]
|
||||
.map((entry) => JSON.stringify({ kind: "event", event: entry }))
|
||||
.join("\n") + "\n";
|
||||
const partial = observation("cancelled", journal);
|
||||
expect(partial.events).toEqual([event, attributed]);
|
||||
expect(partial.limitations).toContain(
|
||||
"Source paths and lines are LLDB line-table attribution; REA did not read or verify the referenced source bytes.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancellation", "cancelled"],
|
||||
["bridge failure", "tracer-failure"],
|
||||
|
||||
@@ -35,6 +35,11 @@ const LOCATION = {
|
||||
module: "Fixture",
|
||||
module_path: "/tmp/Fixture",
|
||||
symbol: "-[Greeter greet:times:]",
|
||||
source_location: {
|
||||
file_path: "/not-acquired/é space/fixture.pas",
|
||||
line: 23,
|
||||
column: null,
|
||||
},
|
||||
};
|
||||
|
||||
const run = (
|
||||
@@ -84,7 +89,7 @@ const run = (
|
||||
{ name: "x0", value: "0x6000" },
|
||||
{ name: "x1", value: "0x7000" },
|
||||
],
|
||||
backtrace: [],
|
||||
backtrace: [{ ...LOCATION, source_location: null }],
|
||||
},
|
||||
],
|
||||
other_stops: [],
|
||||
@@ -207,7 +212,12 @@ describe("observe_native_calls projection: projects completed observations", ()
|
||||
expect(result.events[0]).toMatchObject({
|
||||
receiver_class: "Greeter",
|
||||
selector: "greet:times:",
|
||||
source_location: LOCATION.source_location,
|
||||
backtrace: [{ ...LOCATION, source_location: null }],
|
||||
});
|
||||
expect(result.breakpoints[0]?.locations[0]?.source_location).toEqual(
|
||||
LOCATION.source_location,
|
||||
);
|
||||
expect(result.coverage).toEqual({
|
||||
status: "complete",
|
||||
event_limit_reached: false,
|
||||
@@ -581,3 +591,24 @@ it("retains completed call facts when cancellation arrives with the tracer resul
|
||||
reason: "cancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("grows repeated source-location JSON by the location object, not by a token estimate", () => {
|
||||
const path = "/not-acquired/é space/fixture.pas";
|
||||
const located = {
|
||||
...LOCATION,
|
||||
source_location: { file_path: path, line: 23, column: null },
|
||||
};
|
||||
const absent = { ...located, source_location: null };
|
||||
const copies = 3;
|
||||
const withPath = Buffer.byteLength(
|
||||
JSON.stringify(Array.from({ length: copies }, () => located)),
|
||||
);
|
||||
const withoutPath = Buffer.byteLength(
|
||||
JSON.stringify(Array.from({ length: copies }, () => absent)),
|
||||
);
|
||||
const perCopy =
|
||||
Buffer.byteLength(JSON.stringify(located.source_location)) -
|
||||
Buffer.byteLength("null");
|
||||
expect(withPath - withoutPath).toBe(copies * perCopy);
|
||||
expect(perCopy).toBeGreaterThan(Buffer.byteLength(path));
|
||||
});
|
||||
@@ -51,8 +51,11 @@ import { observed } from "../fixtures/analysisExecution.js";
|
||||
import {
|
||||
createBinarySessionTargets,
|
||||
createTestBinarySession,
|
||||
createTestProviderRouter,
|
||||
} from "../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { BinarySession } from "../../src/application/binary/BinarySession.js";
|
||||
import { InvestigationRecords } from "../../src/application/investigation/InvestigationRecords.js";
|
||||
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
|
||||
|
||||
const cleanupError = new ProviderCleanupError(
|
||||
@@ -278,6 +281,61 @@ it.each([false, true])(
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the caller path on an authenticated record refused by the ledger", async () => {
|
||||
const [path] = await createBinarySessionTargets();
|
||||
const callerPath = `${dirname(path)}/./${basename(path)}`;
|
||||
const records = new InvestigationRecords({ maximumRepresentationBytes: 0 });
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(
|
||||
() => ({
|
||||
execute: async (operation) =>
|
||||
observed(operation === "health" ? null : { byte: 42 }),
|
||||
close: async () => ok(null),
|
||||
}),
|
||||
{
|
||||
resolveAnalysisProfile: async () =>
|
||||
ok({
|
||||
profile: createAnalysisProfile(
|
||||
{
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
},
|
||||
{ fixture: true },
|
||||
),
|
||||
}),
|
||||
},
|
||||
),
|
||||
records,
|
||||
);
|
||||
const createSession = () => session;
|
||||
const result = analysisCliErrorEnvelopeSchema.parse(
|
||||
await runDirectAnalysis(
|
||||
{
|
||||
readConfiguration: () => parseConfig({}),
|
||||
createBinarySession: createSession,
|
||||
createArtifactBinarySession: createSession,
|
||||
createNativeDispatchBinarySession: createSession,
|
||||
createNativeBinarySession: createSession,
|
||||
createManagedBinarySession: createSession,
|
||||
},
|
||||
callerPath,
|
||||
"read_bytes",
|
||||
{ address: "0x100", document: "fixture" },
|
||||
).then(projectCliAnalysisResult),
|
||||
);
|
||||
expect(result.code).toBe("resource_constraint");
|
||||
const partial = jsonObjectSchema.parse(
|
||||
jsonObjectSchema.parse(result.details).partial_observation,
|
||||
);
|
||||
expect(partial.kind).toBe("unretained-evidence");
|
||||
const evidence = parseEvidence(partial.evidence);
|
||||
expect(evidence.caller_input_path).toBe(callerPath);
|
||||
expect(evidence.subject?.local_path).toBe(path);
|
||||
expect(evidence.normalized_result).toEqual({ byte: 42 });
|
||||
expect(records.statistics().evidenceRecords).toBe(0);
|
||||
});
|
||||
|
||||
describe("one-shot finalization cancellation", () => {
|
||||
it.each(["read_bytes", "analyze_function"] as const)(
|
||||
"retains completed %s Evidence when cancellation arrives during final close",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
program ReaPascalCalls;
|
||||
|
||||
{$mode objfpc}
|
||||
{$inline off}
|
||||
|
||||
procedure ReaPascalCall(Value: LongInt); cdecl; public name 'rea_pascal_call';
|
||||
begin
|
||||
WriteLn('pascal ', Value);
|
||||
end;
|
||||
|
||||
var
|
||||
Index: LongInt;
|
||||
begin
|
||||
for Index := 0 to 2 do { REA_VERIFY_CALLER_LOOP }
|
||||
ReaPascalCall(Index); { REA_VERIFY_CALLER_CALL }
|
||||
end.
|
||||
+39
-20
@@ -19,12 +19,24 @@ assert.ok(
|
||||
"duplicate-last",
|
||||
"override",
|
||||
"request-options",
|
||||
"cancel-finalization",
|
||||
"failure-cancel-finalization",
|
||||
].includes(mode),
|
||||
);
|
||||
assert.equal(typeof target, "string");
|
||||
|
||||
// Isolate the fault to this child client. The server, resource admission and
|
||||
// advertised schema are real; alter only the reply before tool validation.
|
||||
const controller = new AbortController();
|
||||
if (mode === "cancel-finalization" || mode === "failure-cancel-finalization") {
|
||||
const close = Client.prototype.close;
|
||||
Client.prototype.close = async function () {
|
||||
const settled = close.call(this);
|
||||
controller.abort(new Error("cancelled during final cleanup"));
|
||||
return settled;
|
||||
};
|
||||
}
|
||||
|
||||
const request = Client.prototype.request;
|
||||
let catalogRequests = 0;
|
||||
let corruptedReplies = 0;
|
||||
@@ -160,27 +172,34 @@ try {
|
||||
if (mode === "concurrent" || mode === "reconnect")
|
||||
await standaloneConnections();
|
||||
else
|
||||
await artifact.withBundleResourcesMcp(target, async (client) => {
|
||||
if (mode === "reuse") await Promise.all([status(client), status(client)]);
|
||||
if (mode === "override")
|
||||
await artifact.artifactMcpCall(
|
||||
client,
|
||||
{ name: "binary_session", arguments: {} },
|
||||
{
|
||||
toolDefinition: {
|
||||
name: "binary_session",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { type: "object" },
|
||||
await artifact.withBundleResourcesMcp(
|
||||
target,
|
||||
async (client) => {
|
||||
if (mode === "failure-cancel-finalization")
|
||||
throw new Error("work failed before cancellation");
|
||||
if (mode === "reuse")
|
||||
await Promise.all([status(client), status(client)]);
|
||||
if (mode === "override")
|
||||
await artifact.artifactMcpCall(
|
||||
client,
|
||||
{ name: "binary_session", arguments: {} },
|
||||
{
|
||||
toolDefinition: {
|
||||
name: "binary_session",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (mode === "request-options")
|
||||
await artifact.artifactMcpCall(
|
||||
client,
|
||||
{ name: "binary_session", arguments: {} },
|
||||
{ timeout: 12_345 },
|
||||
);
|
||||
});
|
||||
);
|
||||
if (mode === "request-options")
|
||||
await artifact.artifactMcpCall(
|
||||
client,
|
||||
{ name: "binary_session", arguments: {} },
|
||||
{ timeout: 12_345 },
|
||||
);
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
} catch (cause) {
|
||||
error = {
|
||||
name: cause instanceof Error ? cause.name : "unknown",
|
||||
|
||||
Reference in new issue
Block a user