Merge remote-tracking branch 'upstream/main' into fix/release-exposure-audit

This commit is contained in:
Matt Van Horn committed 2026-11-02 01:28:14 -08:00
commit 8f019c7f38
494 files changed
+20575 -3916

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ documentation, and live catalogs may describe different versions.
For provider-backed work, exercise the analyst's task through REA and compare
relevant native provider and independent artifact observations where available.
Check applicable partial results, recovery and session boundaries; report what
was not exercised. Use [workflow verification](../../../docs/testing.md#provider-backed-workflow-verification)
was not exercised. For failure guidance, follow [design recovery around the
failure](../../../docs/tool-design.md#design-recovery-around-the-failure).
Use [workflow verification](../../../docs/testing.md#provider-backed-workflow-verification)
to select evidence for the proposed contract instead of seeking provider API
parity.
@@ -378,8 +378,9 @@ unless the group overrides them. The grouped name union is the complete tool
inventory; use `tools/list` for canonical tool schemas. The default summary keeps
each provider candidate's availability and target-support status, code and
reason; `omitted_fields` lists what it leaves out. Request `{"detail": "complete"}`
only when you need provider diagnostics, candidate capability sets, or tool
surface, effect and annotation flags; in those closed flag vocabularies, listed
only when you need an unavailable candidate's remediation, provider
diagnostics, candidate capability sets, or tool surface, effect and annotation
flags; in those closed flag vocabularies, listed
flags are true and omitted known flags are false. Provider candidate facts
describe possible providers, while top-level session capabilities describe the
current route and `analysis_provider_binding` names the selected provider. Do
@@ -432,7 +433,10 @@ output also streams without a single MCP frame.
When the retained parent ID is unknown, use `get_evidence_bundle` with
`detail: "summary"` and exact filters for operation, target SHA-256,
analysis-profile digest or canonical procedure address. Select its returned
Evidence ID for the next view instead of requesting the complete bundle.
Evidence ID for the next view instead of requesting the complete bundle. When
that record has no supported view (for example an annotation), read it with
`detail: "records"` and an `evidence_id` filter; `missing_evidence_links`
names linked sources this session does not retain.
`retention: "complete-record"` means the whole record is stored; native
value-flow truncation and unknown metadata remain separate. For an exported
portable bundle, use `rea inspect-evidence-bundle` with JSON containing `path`,
@@ -73,6 +73,14 @@ Parent Evidence ID plus source index preserves same-address
alternatives. Follow `coverage.next_offset` only as far as the question needs;
exhausting retained rows does not establish complete program discovery.
`batch_decompile` returns every body unless you select `detail: "summary"` when
the connected tool advertises it. Use its parent Evidence ID with
`view: {kind: "batch-decompile", collection: "items"}` for body-free rows and
exact `where.indices`, `addresses`, `procedure_addresses` or `statuses`. Add
`include_pseudocode: true` for several chosen bodies, or use
`collection: "pseudocode"` with one row's `index` to page a large body. Batch
limitations aggregate every item, so do not attribute them to one successful row.
Use `record_unknown` to track unresolved questions in the user's investigation
and name the authority or environment still required. Supply supporting and
contradicting evidence IDs. It updates the session registry directly.
@@ -169,7 +169,10 @@ leaving it, and `both` when the question needs both. Trace traversal includes
containment relationships, so even a narrow seed can expand many nodes; a
bidirectional trace can reach unrelated sibling modules. A module item or page
is preferable when its observations already answer the question. Do not treat
all reached nodes as participants in the feature.
all reached nodes as participants in the feature. For a broad trace, pass
`view: {"kind": "feature-trace", "collection": "paths"}` (or `summary`) to
retain the complete trace and read terminal paths first; page `nodes` or `edges`
with exact `where.node_ids` through `inspect_analysis_view` as needed.
Reuse retained parent Evidence when the server advertises it, or supply complete
Evidence inline. Include Hopper or Ghidra Evidence only when its artifact digest
@@ -356,8 +356,9 @@ the complete tool inventory grouped by current status. Expand each group's
overrides them; availability, reasons, remediation, and modes remain inline.
Provider candidates keep availability and target-support status, code, and
reason. `omitted_fields` names what the summary leaves out; `{"detail":
"complete"}` adds provider diagnostics, candidate capability sets, and tool
surface, `effects`, and `annotations`. Those arrays name true flags from fixed
"complete"}` adds each unavailable candidate's `remediation`, provider
diagnostics, candidate capability sets, and tool surface, `effects`, and
`annotations`. Those arrays name true flags from fixed
vocabularies, so an omitted known flag is false. `tools/list` remains the
canonical source for full tool schemas when target or provider state changes.
+21
View File
@@ -0,0 +1,21 @@
name: REA production code
# Match the existing default-setup scan's local-input threat model.
threat-models: [local]
# Retain root traversal, including future production roots. Hidden directories
# need their own root; use a directory so Python can share this configuration.
paths:
- .
- docs/.vitepress
# Keep the default query suite and all production roots, including bridge/,
# install.sh, runtime scripts, release controllers, and GitHub Actions.
# Exclude test inputs at extraction time so they cannot taint production callers.
paths-ignore:
- tests
- scripts/fixtures
- "**/*.test.*"
- "**/*.fixture.*"
- "**/test_*.py"
- "**/*_test.py"
+61
View File
@@ -0,0 +1,61 @@
name: CodeQL production
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "37 5 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: codeql-production-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
analyze:
# A repository administrator must disable default setup before enabling
# this workflow; default setup rejects advanced CodeQL result uploads.
if: vars.REA_CODEQL_ADVANCED == 'true'
name: Analyze production (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
max-parallel: 1
matrix:
language: [javascript-typescript, python, actions]
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- name: Initialize production analysis
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
with:
languages: ${{ matrix.language }}
build-mode: none
config-file: .github/codeql/production.yml
threads: 2
ram: 4096
- name: Analyze production code
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
with:
category: /language:${{ matrix.language }}
output: ${{ runner.temp }}/codeql-production-results
threads: 2
ram: 4096
- name: Retain complete analysis evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: codeql-production-${{ matrix.language }}
path: ${{ runner.temp }}/codeql-production-results
if-no-files-found: error
retention-days: 7
+13
View File
@@ -28,6 +28,10 @@ Assess brittleness in touched code during implementation, debugging, review, and
When fixing a bug, inspect the producing boundary and affected consumers for duplicated interpretation or unclear ownership. Complete any directly related refactor in the same change and remove superseded code. Passing regression tests alone does not establish that the brittleness is resolved. If related cleanup remains, explain what remains and why.
After confirming a bug, search sibling adapters, consumers and similar workflows
for the same failure. Verify suspected matches, record working comparisons, and
group findings by root cause instead of filing duplicate reports.
When addressing a review thread, inspect the producing boundary and affected consumers for brittleness. Complete directly related refactoring in the same change. Before resolving the thread, explain the root cause, structural fix, validation, and any remaining cleanup.
Parse unknown boundary values and model expected failures with the tagged error algebra and `Result`. Preserve partial facts and meaningful failure reasons. Keep identity and lookup values separate from display formatting; preserve source values and explicit unknowns when normalization loses information. Absolute paths, file URLs, and HTTP paths have different semantics.
@@ -75,6 +79,9 @@ Use [.nvmrc](.nvmrc) and `package.json#packageManager` for the pinned toolchain.
Use relevant checks for the change. The complete local gate, `npm run check:pr`, is optional for broad changes or CI diagnosis. See [CONTRIBUTING.md](CONTRIBUTING.md) for development, generated-file ownership, and release conventions.
When changing installers or setup recovery, follow
[Checking installers and publisher downloads](docs/testing.md#checking-installers-and-publisher-downloads).
Coordinate expensive checks across agents. If a test unexpectedly consumes heavy CPU, stop its owned process family and profile a bounded representative case before retrying. Inspect production work, fixture generation, subprocesses, and repeated setup; preserve regression coverage when optimizing. Follow the [test resource guidance](docs/testing.md#developer-commands).
`docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored outputs. Edit source contracts and `.agents/skills/reverse-engineer-anything/`, then regenerate as needed. `.cache/mcp-tool-catalog.json` is test metadata; runtime builds and source checking do not consume it. Never commit binaries, provider project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts.
@@ -83,6 +90,12 @@ Coordinate expensive checks across agents. If a test unexpectedly consumes heavy
For tool design, follow [docs/tool-design.md](docs/tool-design.md): start from the analyst question, reuse existing contracts, preserve caller choices, and return useful evidence inline. Limits must follow real format, protocol, authority, or resource constraints. Account for representation expansion before allocation; derive completeness from examined coverage.
For user- or agent-facing tool problems, prioritize fixing the owning behavior,
contract, and actionable result or recovery at the point of use. Document the
result in product docs when explanation is needed. Add skill guidance only for
workflow judgment or context that the tool cannot reasonably convey; do not use
a skill as the primary repair for a discoverability or contract defect.
For test changes or provider claims, use [docs/testing.md](docs/testing.md). Select applicable [boundary regressions](docs/testing.md#boundary-regressions) for parser, inventory, snapshot, and projection fixes. Prefer real public CLI/MCP workflows, then production-boundary integration, then producer goldens. Keep module tests for distinct semantics stronger workflows cannot reliably reproduce. Preserve malformed-input, cancellation, cleanup, permission, capacity, and target-identity coverage when pruning. Report unverified coverage explicitly; simulated providers do not establish real-provider behavior.
Use Conventional Commit subjects and PR titles. Mark breaking changes with `!` or a `BREAKING CHANGE:` footer. Describe behavior changes, relevant validation, and real-provider verification scope.
+1 -1
View File
@@ -60,7 +60,7 @@ pin inspection sources; they are not included in the npm package.
| ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. |
| JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. |
| jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. |
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.986/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.986/NOTICE) | Bring your own `kuna`; REA verified 1.986 and never downloads, installs or bundles it. REA invokes it as a separate process. |
| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.988/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.988/NOTICE) | Bring your own `kuna`; REA verified 1.988 and never downloads, installs or bundles it. REA invokes it as a separate process. |
| Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. |
| Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. |
| Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. |
+60 -21
View File
@@ -693,6 +693,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
row.addProperty("status", relocation.getStatus().toString());
row.addProperty("type", relocation.getType());
row.add("symbol", GSON.toJsonTree(relocation.getSymbolName()));
row.add("values", GSON.toJsonTree(relocation.getValues() == null ? new long[0] : relocation.getValues()));
byte[] original = relocation.getBytes();
row.add("original_bytes_hex", original == null ? JsonNull.INSTANCE : GSON.toJsonTree(bytesHex(original)));
@@ -2274,9 +2275,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
throw new RequestFailure("invalid_request", "mode must be literal or regex");
}
boolean caseSensitive = requireBoolean(params, "case_sensitive");
List<InventoryItem> inventory = procedureSearch
? procedures().stream().map(FunctionEntry::item).toList()
: strings();
ValueMatcher matcher;
if (mode.equals("literal") && !caseSensitive) {
if (!params.has("literal_ascii_candidate"))
@@ -2290,7 +2288,26 @@ public final class ReaGhidraBridge extends HeadlessScript {
: regexMatcher(expression, caseSensitive);
}
JsonArray items = new JsonArray();
for (InventoryItem item : inventory) {
if (procedureSearch) {
// Search the names exact lookup accepts, keeping one row per
// procedure under its primary name.
for (FunctionEntry entry : procedures()) {
monitor.checkCancelled();
ProcedureNameMatch match = matchProcedureNames(entry, matcher);
if (!match.matched()) {
continue;
}
JsonObject item = resultItem(entry.item, null);
if (!match.aliases().isEmpty()) {
JsonArray aliases = new JsonArray();
for (String alias : match.aliases()) aliases.add(alias);
item.add("matched_aliases", aliases);
}
items.add(item);
}
return items;
}
for (InventoryItem item : strings()) {
monitor.checkCancelled();
if (!matcher.matches(item.value)) {
continue;
@@ -2300,6 +2317,36 @@ public final class ReaGhidraBridge extends HeadlessScript {
return items;
}
/**
* The one definition of the names that select a procedure, shared by exact
* lookup and search: its primary name and every other symbol at its exact
* entry, each in leaf or namespace-qualified spelling. A label inside the
* body never names the procedure. Matching secondary symbols are reported
* by their qualified names, sorted.
*/
private ProcedureNameMatch matchProcedureNames(FunctionEntry entry, ValueMatcher matcher) throws Exception {
Function function = entry.function;
boolean primary = spellingMatches(function.getName(), entry.item.value, matcher);
Set<String> aliases = null;
for (Symbol symbol : currentProgram.getSymbolTable().getSymbols(function.getEntryPoint())) {
monitor.checkCancelled();
String qualified = symbol.getName(true);
if (!spellingMatches(symbol.getName(), qualified, matcher)) continue;
if (qualified.equals(entry.item.value)) {
primary = true;
continue;
}
if (aliases == null) aliases = new TreeSet<>();
aliases.add(qualified);
}
return new ProcedureNameMatch(primary, aliases == null ? List.of() : List.copyOf(aliases));
}
/** A leaf is the suffix of its qualified spelling, so a literal found in it is found in both. */
private static boolean spellingMatches(String leaf, String qualified, ValueMatcher matcher) {
return matcher.matches(qualified) || (!leaf.equals(qualified) && matcher.matches(leaf));
}
private List<InventoryItem> names() throws Exception {
if (nameInventory != null) {
return nameInventory;
@@ -2402,26 +2449,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
// exact symbol name, even when every character is a hexadecimal digit.
boolean explicitAddress = isExplicitReaAddress(value);
if (!explicitAddress) {
// Imported labels (for example Mach-O _main) can share a function
// entry without becoming its primary symbol; search accepts the same.
ValueMatcher selector = value::equals;
List<Function> matches = new ArrayList<>();
for (FunctionEntry entry : procedures()) {
monitor.checkCancelled();
Function function = entry.function;
Symbol symbol = function.getSymbol();
String qualified = symbol == null ? function.getName() : symbol.getName(true);
boolean matchesName = function.getName().equals(value) || qualified.equals(value);
if (!matchesName) {
// Imported labels (for example Mach-O _main) can share a
// function entry without becoming its primary symbol. Only
// exact entries qualify; an interior label is not a selector.
for (Symbol entrySymbol : currentProgram.getSymbolTable().getSymbols(function.getEntryPoint())) {
monitor.checkCancelled();
if (entrySymbol.getName().equals(value) || entrySymbol.getName(true).equals(value)) {
matchesName = true;
break;
}
}
}
if (matchesName) matches.add(function);
if (matchProcedureNames(entry, selector).matched()) matches.add(entry.function);
}
if (matches.size() > 1)
throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value +
@@ -5138,6 +5172,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
private record AddressNameConflict(Namespace namespace, String leaf, List<Address> requesters, List<Address> holders) {}
private record AddressNameFailure(String reason, AddressNameConflict conflict) {}
private record FunctionEntry(Function function, InventoryItem item) {}
private record ProcedureNameMatch(boolean primary, List<String> aliases) {
boolean matched() {
return primary || !aliases.isEmpty();
}
}
private record FunctionAnalysisObservation(JsonObject dossier, List<String> limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {}
private record CallBoundaryObservation(List<String> limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {}
private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {}
+15 -1
View File
@@ -15,6 +15,8 @@ struct ProcessIdentityObservation: Encodable {
let effective_uid: UInt32?
let lifecycle: String?
let relation: ProcessRelationObservation?
/** Display-only kernel accounting name (p_comm, at most MAXCOMLEN bytes). */
let name: String?
let reason: String?
}
@@ -31,6 +33,7 @@ struct ProcessMetadata {
let parentPid: Int32
let processGroupId: Int32
let pointerSize: Int
let name: String
}
struct ProcessSnapshot {
@@ -92,7 +95,8 @@ func readProcessSnapshot(
lifecycle: process.kp_proc.p_stat == SZOMB ? "zombie" : "non-zombie",
parentPid: process.kp_eproc.e_ppid,
processGroupId: process.kp_eproc.e_pgid,
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4
pointerSize: process.kp_proc.p_flag & P_LP64 != 0 ? 8 : 4,
name: processName(process)
)
)
},
@@ -103,6 +107,13 @@ func readProcessSnapshot(
return ProcessSnapshot(metadata: [:], failure: "process_table_failed_\(ENOMEM)")
}
/** Decode the NUL-terminated accounting name; invalid UTF-8 is replaced for display. */
private func processName(_ process: kinfo_proc) -> String {
withUnsafeBytes(of: process.kp_proc.p_comm) { bytes in
String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
}
}
func readProcessIdentities(pids: [Int32]) -> [ProcessIdentityObservation] {
readProcessIdentities(pids: pids, snapshot: readProcessSnapshot())
}
@@ -130,6 +141,7 @@ func readProcessIdentities(
effective_uid: nil,
lifecycle: nil,
relation: nil,
name: nil,
reason: "not-in-snapshot"
)
}
@@ -144,6 +156,7 @@ func readProcessIdentities(
parent_pid: metadata.parentPid,
process_group_id: metadata.processGroupId
),
name: metadata.name,
reason: nil
)
}
@@ -157,6 +170,7 @@ private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityO
effective_uid: nil,
lifecycle: nil,
relation: nil,
name: nil,
reason: reason
)
}
+4 -4
View File
@@ -257,12 +257,12 @@ beyond already-connected devices (`adb connect`) are not exposed yet.
## Real verification
`npm run verify:adb` exercises every operation against a live device. It
`npm run verify:adb` exercises observation and transfer operations against a live device. It
requires `adb` (or `REA_ADB_PATH`) and any attached emulator or phone;
`--serial` selects one when several are attached, `--pull PACKAGE` pulls a
real package and verifies the returned digests against the files on disk,
and `--install-apk PATH` drives a full install → resolve → uninstall
lifecycle with a lane-owned probe.
real package and verifies the returned digests against the files on disk.
The optional `--install-apk` lifecycle is unavailable until the selected APK
identity and device cleanup ownership can be established safely.
Record: verified against adb 34.0.5 on Linux against an Android 14
(API 34) x86_64 emulator — all operations above, a 224-package inventory,
+4 -2
View File
@@ -424,8 +424,10 @@ rea inspect-native-exports ./libexample.so --detail summary --evidence_output ./
without an export directory is a complete empty inventory.
- ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique
binding and default or protected visibility. Each row keeps `value`
(`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`
and `binding`. ELF string tables do not declare a text encoding. Names that
(`st_value` as stored; bit 0 marks Thumb code on 32-bit ARM), `size`, `type`,
`binding` and `visibility` (`"default"` or `"protected"`; `"unknown"` in older
historical Evidence where visibility was not recorded). ELF string tables do
not declare a text encoding. Names that
contain non-ASCII bytes are decoded as UTF-8 when those bytes form valid
UTF-8; `name_utf8_decodable` records whether the non-ASCII name bytes are
valid UTF-8 and `name_bytes_base64` retains those exact bytes (`null` for
+44
View File
@@ -561,6 +561,14 @@ be verified. See [MCP contracts](mcp-contracts.md) for session lifecycle and
[Ghidra first-query deadlines](mcp-contracts.md#ghidra-first-query-deadlines-and-recovery)
for import, client timeouts and recovery.
A one-shot command releases its provider session and REA runtime before it
prints a failure, so it omits the same-session reuse and
`update_analysis_settings` guidance an MCP connection receives. Its
`details.limit` reports `scope: "rea-invocation"` and
`applies_at: "next_invocation"`; set the named `environment` (or the per-call
`parameter`) for a new invocation and rerun. For example, a Ghidra decompile
deadline names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS`.
If one-shot analysis fails and closing its session also fails, the original
error details remain inline and `details.cleanup_error` describes the later
close failure. Each diagnostic retains its own reported resources; the later
@@ -615,6 +623,30 @@ cached result. `close_binary` accepts `snapshot_path` and optional
`overwrite: true` to save before releasing provider resources. A failed save
leaves the session open so the caller can resolve the output failure.
When a validated snapshot does not match the selected binding, the error's
`details.binding_mismatch` reports confirmed target identities, provider
identities, or profile digests that differ. `snapshot` is the saved value and
`selected` is the requested value. Multiple differences can be reported. A
`profile_comparison: "not_compared"` means the profile was not available at
that boundary; it does not establish a match. A profileless snapshot can report
`workflow_scope: "incompatible"` even when its target and profile match.
Choose a matching target, provider, and profile, or analyze without the snapshot
and save a new one for the intended binding.
For a profile mismatch, the saved snapshot contains
`binding.analysis_profile.parameters`. If `binary_session` reports an active
profile whose digest matches `binding_mismatch.profile_digest.selected`, its
`analysis_provider_binding.analysis_profile.parameters` can be compared with
those saved parameters. A rejected switch leaves the previous session active,
so its profile may be different from the attempted selection; check the digest
before using it for this comparison. A configuration difference does not by
itself establish why analysis results differ.
If no target is open and you intend to inspect the selected binding, open that
target without the snapshot, then check its profile digest in `binary_session`
before comparing parameters. This may start the selected provider; the saved
snapshot remains a separate file.
If a selected snapshot's file descriptor cannot close, `open_binary` reports
`cleanup_incomplete`. `binary_session` reports `cleanup_pending` even when no
target opened. Retry `close_binary` to release the retained descriptor before
@@ -1263,6 +1295,18 @@ new file. A publication that has committed can win a later cancellation; the
after commit, the `cleanup_incomplete` error reports the same file as
`details.published_path` and `details.bytes`.
When a failure retains a completed observation and no bundle committed,
`details.source_closure` states what that observation cites. `publication` is
`not_published`. `records` holds authentic source Evidence this invocation
captured, within 64 KiB. `omitted` lists up to 64 other identities and
`omitted_count` the total: `not_captured` means the invocation holds no such
record, so nothing is shown for it or its ancestors; `not_inlined` means it was
captured but is not in this response. `complete` is true only when every cited
record is in `records` or embedded in the observation. If examining the closure
itself fails, `collection_failure` names the error type and the closure state is
unknown; the original failure is still returned. Nothing is retained after exit,
and running the command again is a new observation.
When the reader of piped output closes early, as `rea ... | head` does, the
CLI stops writing, prints no error and exits `141` (`128 + 13`, the status of a
SIGPIPE-terminated writer). The operation itself may have completed; only its
+28 -3
View File
@@ -222,6 +222,25 @@ Error copying, attribution, redaction, and delivery must preserve these facts.
Human-readable remediation explains the same failure; clients should not need
to parse that sentence to identify an editable setting.
`scope` and `applies_at` describe a continuing caller, such as an MCP
connection. A one-shot invocation, such as a CLI command, has released its
provider session and exited its runtime before its caller reads the failure,
so its adapter projects with an explicit `"invocation"` caller lifetime.
`details.limit` then keeps every other field and reports
`scope: "rea-invocation"`, `applies_at: "next_invocation"` and the launch
`environment` seed when one exists. `agent_editable: true` still holds, because
the caller chooses that environment value or per-call `parameter` for the new
invocation; `rea_restart_required: false` means no running REA process needs a
restart. A producer keeps guidance that holds only while its caller retains the
session, such as "the Ghidra session remains usable" or a same-session runtime
update, in `continuingCallerGuidance`; the shared presentation adds it only for
a continuing caller. The producer's own message and remediation must stay true
after the session is released. A composed error records each embedded child
failure through `childFailureDiagnostics`, which snapshots it at composition
for both lifetimes when they differ, so cleanup history keeps its state and
the final projection selects the caller's form. Failures retained inside
Evidence keep the continuing form, so CLI and MCP Evidence stay identical.
Cache policy is part of enforcement. Successful reusable analysis can survive
a budget update. Transient Ghidra decompile failures remain uncached so a raised
deadline can recover on the same Program. Admission checks still apply to reused
@@ -286,7 +305,9 @@ whether live repair is possible.
## What agents do
Use `details.limit` for a direct runtime update when it names an editable
setting. Follow `remediation.action` for per-call, fixed and external controls.
setting with `scope: "rea-runtime"`. For `scope: "rea-invocation"`, set its
`environment` or `parameter` for a new invocation instead.
Follow `remediation.action` for per-call, fixed and external controls.
Inspect settings only when bounds or application details are missing. Restart only when it says the value is
read at startup. A sentence that a control does not raise the limit is not a
request to change it.
@@ -316,8 +337,12 @@ request to change it.
`runtime-configuration:` check. Re-run `rea setup` for that client when the
setting is on the registration allowlist, then reconnect so the new process
sees it.
- `invalid_request`: correct the reported fields from the current schema or
CLI help. Do not repeat the unchanged call.
- `invalid_request`: follow the specific recovery and reported issue. Inspect
the schema or CLI help when the input violates the contract or the response
asks for `inspect_input_contract`. A valid selector can instead require a
different choice, such as selecting a reported address; an inventory miss
does not establish that the target lacks the item. Do not repeat the
unchanged call when the response says it cannot succeed unchanged.
A restart is required to adopt a changed launch environment or registration.
The curated runtime settings above require no REA restart. It is not the first step for a transient
+11
View File
@@ -147,6 +147,14 @@ window-targeted renderer reload/crash actions, and synthetic `open-url` or
WebContents identities, process metrics, and IPC channel and value-shape metadata.
Payload values are not retained.
For the exact Playwright launch sequence, the active hook removes only REA's
bootstrap arguments from the application-visible `process.argv`. The vector keeps
the selected entry point and every caller argument, in order, including repeated
hook paths, `.asar` filenames, and debug-like options. The private launch handoff
is consumed before application code runs. If the observed sequence cannot be
matched exactly, argv stays unchanged and capture coverage reports `hook_conflict`;
REA does not guess an entry point or remove arguments by filename patterns.
`window_index` selects from Playwright's current Page list at the start of each action. REA resolves that Page to its corresponding BrowserWindow once; the action's `target` uses the BrowserWindow's capture-scoped `window_id`, matching the window inventory. Reordering either enumeration after selection does not retarget the action. Handle cleanup failures retain the completed action records and remain owned for provider cleanup retry.
Run the real fixture verifier with an operator-provided Electron runtime:
@@ -193,6 +201,9 @@ neither result means the requested host operation occurred. The existing
`openExternal`, `openPath`, and `trashItem` blocks reject their promises.
The `trashItem` block prevents that call from reaching the OS trash operation;
it does not intercept other ways the application can remove files.
Process rejection and uncaught-exception events are observed at dispatch without
adding exception handlers. The runtime and application retain their warning,
handler, and fatal-exit policies, including for rejected shell-block promises.
CLI SIGINT/SIGTERM requests cancellation and awaits owned-process cleanup.
Window IDs, URLs, and cached titles come from one main-process Electron snapshot,
so a crashed renderer does not require a successful renderer-side title query.
+5
View File
@@ -172,6 +172,11 @@ have no file backing. ELF mappings are observed
without independent format-specific load-image verification, as the result's
status and limitations state.
Relocation rows retain Ghidra's `symbol` value, including an empty string or
null when returned by Ghidra. It is a provider observation, not proof of the
runtime-resolved address. Older Evidence may not have recorded this field;
a relocation page requesting it reports that uncertainty.
For large relocation inventories, select MCP `inspect_native_load_image` with
`detail: "summary"`. It returns verification state, observed identity and counts,
while retaining complete Evidence. Use `normalized_result.parent_evidence_id`
+2 -1
View File
@@ -43,7 +43,8 @@ rea decompile /absolute/path/to/program main --json
If the registration cannot be read or validated, provider diagnostics retain its
selected `registration_path` and the configuration owner's setting/constraint
facts in `configuration_details.settings`, plus `configuration_remediation`.
facts in `configuration_details.settings`. The availability's `remediation`, and
each operation it makes unavailable, carry the configuration owner's repair.
`configured` means a path was selected, not that it is valid or an IDA connection
was established; failed registrations remain unavailable. Correct
`REA_IDA_MCP_CONFIG` in the next CLI invocation's environment or the MCP server's
+54 -6
View File
@@ -666,7 +666,8 @@ Hopper is separate commercial software with its own license. Its free demo has
vendor-defined limits, and a paid license is optional. REA reuses any detected
installation and preserves Hopper during uninstall.
The automated Hopper installer supports macOS 12+ and x86_64 hosts running
The automated Hopper installer supports macOS 12+, subject to the minimum
macOS version of the current Hopper release, and x86_64 hosts running
Ubuntu 24.04+, Fedora 41+, Nobara 44+, Arch Linux, CachyOS, or Omarchy. The
official Linux packages are x86_64-only, so setup does not offer Hopper on other
Linux architectures; configure [Ghidra](#ghidra) there instead. REA itself runs on any
@@ -675,12 +676,33 @@ an existing Hopper launcher is selected through `HOPPER_LAUNCHER_PATH`. Ghidra
and IDA have their own provider-specific host requirements; Windows Ghidra uses
the [experimental P0 boundary](windows-ghidra-p0.md).
If the Linux package download receives an HTTP failure, setup includes the
observed HTTP status in its `download_failed` diagnostic. The status does not
establish the underlying cause or guarantee that retrying will succeed.
Bazzite uses RPM packages, but REA does not install Hopper automatically on it.
Setup skips the Hopper offer because Bazzite is outside the supported host list,
regardless of whether Xvfb is installed. Bazzite documents
[containers for installing Linux packages](https://docs.bazzite.gg/Installing_and_Managing_Software/Containers/).
If using a container, check its distribution and architecture against the list
above. Install REA and Hopper in that container, give it access to the files you
want to analyze, and configure your agent to launch REA there. Verify installation
and analysis before relying on that workflow.
`rea doctor` checks health; it does not offer to install Hopper. Run `rea setup`
for installation. Setup cannot prompt when its output is redirected or during a
container image build. To approve Hopper installation without prompts, run
`rea setup --yes --install-hopper`.
If a Hopper request fails on macOS or Linux, the setup result's `download`
field names the request (`release_feed`, `package` or `public_key`), its `url`,
and the observed outcome `kind`: `http_status` with `httpStatus`, `timeout`,
`network`, `response_too_large`, or `url_refused`. The remediation names the
same URL and outcome. HTTP 404 or 410 means the file is missing, so an
immediate retry is unlikely to help; 5xx, 408 and 429 indicate a temporary
service failure. The status does not establish the underlying cause or guarantee
that retrying will succeed. Doctor and Linux launches report the same request
and status when publisher verification cannot download the current package.
Hopper metadata and package downloads require HTTPS throughout the redirect
chain; a redirect to HTTP is refused before that destination is requested.
chain; a redirect to HTTP or to a URL carrying credentials is refused before
that destination is requested, and those credentials are not reported.
On macOS, approved setup downloads the official DMG, checks its published size
and digest, validates the application bundle, and atomically installs it to
@@ -688,6 +710,15 @@ and digest, validates the application bundle, and atomically installs it to
operator can choose its demo mode or activate an existing license. Homebrew and
administrator access are not used.
Each Hopper release declares its own minimum macOS version, so the setup plan
cannot confirm support before approval. Setup compares this Mac's version with
the release metadata's `deployment_target` before downloading the DMG, and with
the signed application's `LSMinimumSystemVersion` before copying it. If this Mac
is older, setup fails with `incompatible_host_version`, names both versions, and
installs nothing. Update macOS, or install a Hopper release that supports this
macOS version yourself and set `HOPPER_LAUNCHER_PATH`. An existing Hopper
installation is not checked against the current release's minimum.
The Hopper launcher action used by REA creates a document from an executable;
its supported command-line interface does not attach to an already-open
document. While another live REA session owns the same target and loader
@@ -728,6 +759,12 @@ Rotating signing subkeys remain bound to that primary identity.
The feed row itself is not PGP-signed; doctor labels its display version as
`reported_release_version`, separately from the authenticated launcher identity.
Even a correctly signed package can contain a launcher whose hash disagrees with
the feed. REA stops before installing that package. When reporting this error,
include the release version, package format and both hashes from the diagnostic.
Each format needs its own check: a matching Ubuntu DEB does not establish that
the Fedora RPM matches.
The approval plan includes the caller-selected cache location. Approved setup
retains the signed archive, public key and release metadata under
`${XDG_CACHE_HOME:-$HOME/.cache}/rea/hopper-releases/<launcher-sha256>`.
@@ -761,6 +798,16 @@ host mount and the rest of `/tmp` remain unchanged; this fallback never invokes
`sudo`. `rea doctor --provider hopper --json` reports the selected
strategy and both host and effective mount facts.
You do not need to start a separate `Xvfb :99` server. REA starts and manages its
own display. After installation, run `rea doctor --provider hopper --json` and
check `hopper-demo-runtime` for display and Python problems. Setup also runs these
health checks: installing the package is only one step toward a working provider.
If an image build ends with `exit status 1`, read the setup output immediately
before that summary to find the failing step. On minimal Ubuntu images, run
`apt-get update` before setup if an earlier layer deleted the package indexes.
REA's package installation commands do not refresh those indexes.
Hopper also embeds Python; the Python executable and standard library alone
are insufficient on Ubuntu. Approved setup checks the system interpreter
at `/usr/bin/python3` and, when necessary, installs its matching
@@ -1284,7 +1331,8 @@ fails with `provider_timeout` and that message; other Ghidra tools stay
available. If the caller also cancels while the timeout settles, the session
still closes and the message says so. Session reuse concerns an active MCP or
library session; a standalone CLI command still closes its own session when it
finishes. In MCP, `ghidra.decompile_timeout_seconds` can override the launch
finishes, so its timeout names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS` for a new
invocation instead. In MCP, `ghidra.decompile_timeout_seconds` can override the launch
value for newly accepted operations on the existing Program; explicit `null`
removes the deadline. A transient timeout remains uncached so the same function
can be retried after an update. See the
+8 -1
View File
@@ -173,7 +173,14 @@ Results follow retained node order and default to offset 0 and limit 64. Supply
to examine relationships from that node. Other typed seeds do not search labels.
The result contains the matching basis, complete reachable subgraph, terminal
paths, authority summaries, and native handoffs. A handoff binds
paths, authority summaries, and native handoffs. A bidirectional trace of a
large application can exceed one MCP response. Over MCP, pass
`view: {"kind":"summary"}` or a `feature-trace` page of `paths`, `nodes`,
`edges`, `seed_matches` or `native_handoffs` to retain the complete trace in
the session and return only selected rows with the trace's identity and
coverage. Page the retained trace with `inspect_analysis_view`; an oversized
complete reply offers its first `paths` page as a ready-to-call continuation.
See [MCP contracts](mcp-contracts.md) for filters. A handoff binds
the exact native artifact digest and requested exports from the application
graph. Trace direction selects the reachable addons; requested
exports for each selected addon come from the complete supplied graph, so an
+6 -1
View File
@@ -1002,7 +1002,12 @@ reason in the renderer observations.
HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR
line endings, preserving UTF-16 columns.
Inline JavaScript in local HTML, meaning classic or module `<script>` bodies
without a `src` attribute, is not analyzed. Each HTML file that has one gets an
without a `src` attribute, is not analyzed. Embedded SVG scripts use `href`
(or `xlink:href` when `href` is absent) instead: an existing reference, even
an empty one, suppresses the inline body. SVG's `src` and HTML-only `language`
attributes do not suppress its JavaScript body. SVG references and inline
bodies share the same inert parsing and source coordinates as HTML scripts;
template content stays inert. Each HTML file that has an inline body gets an
unknown `parse-html-inline-scripts` scope with the exact body count and the
script type and source range of the first 100 bodies; beyond that, coverage is
truncated under the `html_inline_script_locations_per_file` limit, and the
+3 -2
View File
@@ -65,8 +65,9 @@ confidence and basis, and does not prove a backend endpoint or reachability.
Pages obey the shared 1 MiB projection budget; MCP transport applies its own
wire-size guard. A byte-limited page returns a continuation; an indivisible
oversized record or metadata returns a typed resource error. Select fewer
fields or retrieve the complete retained parent through `get_evidence_bundle`,
`export_evidence_bundle`, or the CLI's exported bundle. Full Evidence is never
fields or retrieve the complete retained parent through `get_evidence_bundle`
(`detail: "records"` with its `evidence_id`), `export_evidence_bundle`, or the
CLI's exported bundle. Full Evidence is never
silently truncated.
Collections are `summary`, `routes`, `occurrences`, `sources`, and `unknowns`.
+3 -1
View File
@@ -1,6 +1,6 @@
# Kuna provider
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.986, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases, which it reports as unverified in `rea doctor` and in each analysis profile. The 1.986 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.988, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases, which it reports as unverified in `rea doctor` and in each analysis profile. The verified-release flag compares the reported version; it does not authenticate the executable. The 1.988 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release.
Kuna is static: it reads the selected image and never executes it.
@@ -47,6 +47,8 @@ Windows is not supported yet. Supported targets are ELF and thin Mach-O executab
| `xrefs` | Exact-address incoming reference sites, with forwarding aliases filtered explicitly |
| `analyze_function` | Pseudocode and assembly from the export, plus call relationships; typed references, comments, strings and CFG remain unavailable |
Kuna's inventory gives some entries aliases, such as ELF versioned-symbol names and generated `sub_` names. Name selectors accept an alias that names one entry and list every entry a shared alias names. `search_procedures` matches aliases too: each entry appears once under its primary name, with the matching aliases in `matched_aliases`. `list_procedures` shows primary names only.
Typed procedure references, basic blocks, raw-memory tools, annotations and type recovery are not available from this provider. Use another provider for those operations.
## How answers are produced
+131 -7
View File
@@ -318,6 +318,10 @@ offered for completed `analyze_function` results only after session recording is
acknowledged; the complete result and its retention reference remain intact.
Oversized native inventories likewise include a ready-to-call first
`native-inventory` page. Reduce its limit or select fields when a row is large.
An oversized `trace_application_feature` result includes a first `feature-trace`
page of its retained terminal `paths`; page `nodes` or `edges` from there.
An oversized `batch_decompile` result includes a first body-free
`batch-decompile` page of its retained `items`; read bodies from there.
A broad follow-up or `get_evidence_bundle` can also exceed the response budget; exporting
preserves the complete session without sending it through a single MCP frame.
@@ -331,7 +335,26 @@ a provider. `retention: "complete-record"` describes stored Evidence, while
`native_dossier.value_flow` independently reports truncation, unavailable or
unknown metadata. Missing omission counts remain null. Summary output carries
every matching record; actual transport admission still applies. Omitted
`detail` preserves complete canonical bundle delivery; filters require summary.
`detail` preserves complete canonical bundle delivery; filters require summary
or records.
To read a discovered record without transferring the whole bundle, call
`get_evidence_bundle` with `detail: "records"` and the same exact `filters`.
The result (`kind: "evidence-record-selection"`) pages the matching complete
records in canonical bundle (Evidence ID) order with `offset` (default 0) and
`limit` (default 8), and reports `matching_records`, `total_retained_records`
and `next_offset`. Each record is the retained Evidence itself, with an
unchanged ID, subject digest, provider, profile and readback, so it works for
any operation, including records that `inspect_analysis_view` does not support
such as annotations. `missing_evidence_links` lists direct `evidence_links` of
the returned records that this session or bundle does not retain; links are not
traversed. A selection is not an Evidence bundle: it omits manifests and
residual unknowns, cannot be imported, and describes historical observations
rather than current provider or database state. `offset` and `limit` require
`detail: "records"`. If one page exceeds the response budget, select fewer
records or one `evidence_id`; a single record larger than one response still
needs `export_evidence_bundle`, or `inspect_analysis_view` pages where its parent
supports views.
Residual-unknown mutations reject missing Evidence or unknown references and
unproven verified resolutions with nonretryable `invalid_request` diagnostics.
@@ -369,6 +392,7 @@ fit.
For a portable exported bundle, the CLI shares this projection:
`rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`.
`"detail":"records"` with an `evidence_id` filter returns that complete record.
The CLI validates and materializes that local bundle under its existing memory
budget; summary delivery does not make arbitrary large files cheap to import.
@@ -529,7 +553,11 @@ names, the native API record, and high-pcode facets without starting a provider.
and limit default to 0 and 64; pseudocode uses UTF-16 code units and never
splits surrogate pairs, while other pages count rows. Unavailable facts and
provider limitations remain explicit. Source is
an exact same-session retained reference or portable inline Evidence. Views are
an exact same-session retained reference, portable inline Evidence, or an
Evidence record or bundle file. Native function, inventory, and observation
views require the `rea.analysis` predicate; function-matching views require
`rea.native-function-matching`. A mismatched predicate returns an
`invalid_request` identifying `source.predicate_type`. Views are
a summary, a layout mitigations or linkage facet, one section/symbol/module, or
a stable page with a caller-selected positive `limit`. Module pages include
JavaScript assets, bundled modules, and source modules. Select an exact
@@ -568,6 +596,69 @@ the working directory). Save its complete `--json` Evidence, then use
`inspect-analysis-view` with an `evidence-file` source and the same view. Neither
adapter executes the target or repeats analysis for retained queries.
`trace_application_feature` follows the same delivery contract. Omitting `view`
returns the complete trace. `view: {"kind":"summary"}` or a `feature-trace` page
retains the complete trace in the session first and returns only the selected
facts; query further pages with `inspect_analysis_view` and the returned
`parent_evidence_id`. The summary carries the trace identity (`trace_id`,
`source_evidence_id`, `source_graph_id`, seed and direction), the producer's
`summary` counts, `trace_coverage`, the traced subgraph's `graph_coverage`,
`evidence_links` and any no-match `semantic_discovery`. A `feature-trace` page
selects one `collection` in retained order and repeats that context:
- `paths`: exact terminal paths; `where.node_ids` selects paths visiting any of
those nodes and `where.end_kinds` selects their terminal node kinds.
- `nodes`: compact rows with identity, `seed`, `observation_count` and distinct
authority/state/confidence `basis`; `where.node_ids` and `where.kinds` filter
exactly, and `include_observations: true` adds each node's complete retained
observations.
- `edges`: exact relationships; `where.node_ids` matches either endpoint and
`where.relations` the relation.
- `seed_matches` and `native_handoffs`: the producer's exact rows.
Omitted filters select all; empty arrays select none. `offset` and `limit`
default to 0 and 64. Page `coverage` counts the selected collection only; it
never upgrades `trace_coverage`, and graph connectivity does not prove runtime
reachability. The CLI keeps complete trace JSON; save it and use
`inspect-analysis-view` with an `evidence-file` source.
### Selected batch decompile results
`batch_decompile` returns every pseudocode body by default. `detail: "summary"`
retains the complete batch in the session first and returns its producer counts,
`source_provider` (the composing workflow), `source_analysis_profile_digest`,
`decompiling_provider`, procedure identity counts, total and largest
`pseudocode` size, and `limitation_count`. A server that retains no Evidence
refuses the summary before analysis. Further views use the returned
`parent_evidence_id` through `inspect_analysis_view`, or a saved Evidence file or
portable bundle, and never repeat decompilation.
A `batch-decompile` view selects one `collection` and repeats the batch counts
and provider identities:
- `items`: rows in retained order with their original `index`, so duplicate
selectors stay distinct. A row keeps the selector in `address`, its `procedure`
identity and `status`, plus `pseudocode_code_units` or its exact `error` and
`limitations`. `where.indices`, `where.addresses`, `where.procedure_addresses`
and `where.statuses` filter exactly and combine; an item with unknown identity
never matches `procedure_addresses`. Omitted filters select all, and empty
arrays select none. `offset` and `limit` default to 0 and 64.
`include_pseudocode: true` adds each selected body.
- `pseudocode`: one `index`'s text in UTF-16 code-unit pages. `offset` and `limit`
default to 0 and 16,384, and `coverage` measures that body. An offset inside a
surrogate pair is refused, and a limit ending inside one stops before it. An
error item has no text and is refused with a pointer to `items`.
Page `coverage` counts the selection only: exhausting it establishes nothing
about functions outside the batch. Evidence `limitations` aggregate every item's
provider observations and are not attributed to one successful item; only error
items keep their own. A page over the response budget offers a smaller selection
from the same `source`: fewer rows, or one body's `pseudocode` pages when a single
row does not fit. An inline source offers none, because the record cannot be
repeated in that reply; resend it with a smaller selection.
`export_evidence_bundle` remains the complete alternative. The CLI has no batch command; save the Evidence and use
`inspect-analysis-view` with an `evidence-file` source or `--evidence-bundle`.
Completed `inspect_artifact` and atomic `inventory_artifact` Evidence also
support summary and inventory views. A summary reports the manifest's expected
counts separately from available record counts, observed entry kinds, inventory
@@ -738,13 +829,27 @@ requirements. Compare the union of group names with `tools/list` when checking
that a client received the full catalog. An operation absent from all groups is
missing from the complete inventory; it is not an implicit unavailable entry.
Availability also reflects the active provider client's dispatched requests. A
refusal or provider failure observed for an operation keeps that operation
unavailable until a request for it dispatched later succeeds. A provider adapter
or process failure also marks that provider's other declared-available
operations unavailable until any request to that provider dispatched later
succeeds; their remediation states that the status was inferred rather than
observed for them. Recovery is not proof that each operation works, and failures
observed on other operations or providers remain. Outcomes are ordered by
dispatch to the provider client rather than reply completion, so a late reply
from an older request does not override a newer outcome. Request deadlines and
cancellations neither mark nor clear availability. A new provider client starts
without these outcomes.
`detail` selects the projection and the result echoes it. The default
`summary` keeps open state, target identity, the binding, current-route
`capabilities`, run, activity and health status, and each provider candidate's
identity, selection, and availability and target-support `status`, `code`, and
`reason`. It lists the fields it leaves out in `omitted_fields`: provider
`diagnostics`, candidate `capabilities`, and each tool group's static `surface`,
`effects`, and `annotations`. Omitted fields are not empty or false.
`reason`. It lists the fields it leaves out in `omitted_fields`: an unavailable
candidate's `remediation`, provider `diagnostics`, candidate `capabilities`, and
each tool group's static `surface`, `effects`, and `annotations`. Omitted fields
are not empty or false.
`detail: "complete"` returns them inline, groups tools by those flags too, and
has no `omitted_fields`. The output schema keys each result's candidate,
tool-group and omission fields by its `detail`. CLI `capabilities` and
@@ -761,6 +866,13 @@ finish without reading relationships, even when neither traversal mode is
available. Discovery does not block that direct invocation or replace its typed
partial/error outcomes.
`trace_native_ui_action` reports authored-UI and native-seed modes separately.
Apple dispatch metadata requires a Mach-O target; another provider may supply
the symbol-only alternative on other formats. If neither mode is available,
read each mode's missing operations and remediation: a remedy for an authored
app-bundle route does not by itself describe the native-seed route. Availability
does not guarantee that every selector or provider operation will succeed.
With complete detail, the `effects` and `annotations` arrays list the true
members of their fixed vocabularies. A known member omitted from either array is false. This applies
only to these closed flag sets; it does not make an omitted tool, provider, mode,
@@ -790,7 +902,9 @@ set, `shared_limitations` is the ordered common prefix; append a group's ordered
`effects` arrays use the same true-only closed-vocabulary rule as tool
availability. Expand every group operation while retaining its provider
identity, availability, reason, rejection code, effects, and ordered
limitations.
limitations. An unavailable group carries `remediation` when its readiness
owner supplied a repair instruction; `tool_availability` uses that instruction
rather than the failure description.
`binary_session.analysis_provider_candidates` is authoritative for deep-engine
discovery. Each candidate's capability sets, returned with complete detail,
@@ -865,6 +979,14 @@ is `unavailable` with a reason when ownership could not be revalidated, or
at that bounded check. These are historical snapshots, not live process
inventories, and do not claim that no short-lived descendant existed.
After a failed close retires the target, `binary_session.cleanup_retry` names
`close_binary` only while that session still retains the cleanup bound to the
close error. Target-dependent availability then gives the same conditional
cleanup guidance before suggesting `open_binary`. The hint disappears after
cleanup succeeds. A pending flag alone does not establish this retry: active
targets, an in-flight initial close, and external-mutation blockers keep their
own recovery and do not receive this hint.
`binary_session.cleanup_pending` reports whether the session retains a provider
client, session file resource, or provider-candidate resource whose cleanup
has not succeeded. It can be true with
@@ -1340,7 +1462,9 @@ 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. Use `current_document`,
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`,
`current_address`, and `current_procedure` for direct single-field lookups; use
the aggregate tools when you need related context together.
+18 -4
View File
@@ -68,7 +68,10 @@ function renamed to `dead` remains selectable by name. Use the names returned
by the inventory, including their namespaces and any platform symbol prefix.
Ghidra also accepts secondary symbols at a function entry, such as an imported
`_main` label. A label inside a function is not a procedure-name selector; use
its address when selecting the containing function.
its address when selecting the containing function. `search_procedures` matches
the same names, in leaf or qualified spelling: the function appears once with
its primary name as `value`, and `matched_aliases` lists the qualified names of
the secondary symbols that matched.
Overloads can share a fully qualified name; ambiguity errors return every
matching entry address so the caller can select the intended function directly.
@@ -198,6 +201,11 @@ remains valid. This tool
has no dedicated CLI command; the existing `decompile` command remains the
single-procedure CLI operation.
`detail: "summary"` retains the complete batch and returns only its counts,
identities and sizes. `inspect_analysis_view` then reads rows or one body from
the retained record, an Evidence file or a portable bundle without repeating
decompilation: see [selected batch results](mcp-contracts.md#selected-batch-decompile-results).
Each batch schedules one provider read at a time, but has no shared input-count
or aggregate retained-output allowance. It retains every requested row and full
pseudocode body through Evidence construction. The MCP response budget checks
@@ -425,7 +433,12 @@ Within one value trace, each exact procedure selector is attempted at most once,
including failed analyses. A failed seed returns the provider's original typed
failure and corrective guidance; it cannot establish a trace or target identity.
Failures while expanding a validated seed remain unknown observations and do not
establish canonical function identity. A successful seed without a native def-use
establish canonical function identity. A cancelled expansion read returns the
cancellation. An expansion read whose provider cannot confirm cleanup of
resources it owns stops the trace: the result is that provider's own
`cleanup_incomplete` failure with its resources and any bound cleanup retry, and the work
admitted before the stop is retained as partial Evidence with a located unknown
at the stopping read. A successful seed without a native def-use
model still returns its procedure and an explicit coverage unknown. A new trace
can retry failed selectors.
@@ -434,8 +447,9 @@ page omits retained nodes, and the edges they source, before `offset` or after
`next_offset`. Every page repeats `traversal`: `complete`, or `truncated` with
`reasons` naming each observed cause: the `max_depth`, `max_functions`,
`max_call_sites`, `max_nodes` or `max_edges` budget, the fixed
`node_payload_bytes` allowance, `provider_pcode_truncated`, or
`ambiguous_call_candidates`. Each cause also has a located unknown. A budget is
`node_payload_bytes` allowance, `provider_pcode_truncated`,
`ambiguous_call_candidates`, or, only in a stopped trace's partial Evidence,
`cleanup_incomplete`. Each cause also has a located unknown. A budget is
reported only after it rejects work, so an exactly filled budget is not
truncation; a depth or candidate limit is reported only when no other path
analyzes or queues that destination. `total_nodes` and `total_edges` count
File diff suppressed because it is too large. Load diff
+126 -1186
View File
File diff suppressed because it is too large. Load diff
+43
View File
@@ -99,6 +99,49 @@ include the smallest useful reproduction, impact and coverage limits. Do not
claim autonomous-agent selection from a scripted trace. Check related reports
before proposing another issue, and avoid treating lack of API parity as a bug.
## Design recovery around the failure
Distinguish a request that violates its input contract from a valid selection
that the current target, provider inventory, or project cannot resolve. The
same `invalid_request` code can represent either. Suggest schema inspection
only when the input contract is the problem; for a valid lookup, preserve the
caller's exact selector, the observed reason and any candidates, and give a
next step that addresses that outcome. An inventory miss does not prove that
the artifact lacks the requested item.
For prerequisite failures, identify what the requested operation actually needs.
A missing executable, a wrong path, an incompatible host or version, denied
permission, and a download timeout need different repairs. Preserve the observed
failure instead of calling all of them "not installed".
Derive the required engine from the operation's execution contract. For example,
`inspect_firmware_regions` needs Binwalk; `extract_firmware` needs Unblob.
Configuring the other tool cannot enable the requested operation. An installed
JDK outside the selected PATH may need `JAVA_HOME`, not another installation.
Say where a setting must change: a shell export does not change an already
running MCP server's environment.
Keep repair instructions with the contract that owns the failure, and preserve
them through provider readiness, capability discovery, doctor and CLI/MCP output.
An error description is not a repair instruction. Do not make each consumer
guess the fix from diagnostic text or invent another list of prerequisites.
Keep recovery within the selected target, provider, project and authority. If
another context could help, describe it as an explicit choice and state any
relevant loss of state; do not switch or select a candidate automatically.
Preserve uncertainty, partial observations, and session or cleanup state. A
suggested retry must be safe and explain any required change or wait.
Verify recovery through the public workflow: reproduce the failure, follow
the suggested next step when feasible, and check the result and session state.
Include a malformed-input control so schema guidance remains available when it
is actually needed. Compare CLI and MCP when both expose the operation.
For each confirmed bug, check sibling adapters and other consumers of the same
facts. Reproduce suspected matches and retain a working comparison where one
exists. Group reports and fixes around the shared cause; similar wording alone
does not establish another instance.
## Define the contract
- Use strict inputs with explicit target identity and required fields. Preserve
+1 -1
View File
@@ -119,7 +119,7 @@ Then rerun this installer.",implemented,verified by shell branch inspection
90,install.sh:version mismatch,Installed rea reports a different version,installed version ACTUAL does not match EXPECTED.,Low risk; gives exact integrity mismatch,Keep current copy,unchanged,verified by exact installer test
90.5,@modelcontextprotocol/server:tool input validation,Caller sends arguments that violate a declared tool schema,"Canonical invalid_request with the SDK diagnostic plus recovery.kind=inspect_input_contract and a bounded instruction to read the advertised schema or call tools/list",Keeps field/type correction while telling the agent how to recover; diagnostics over 4 KiB stay on the existing retained-error path,Normalize ordinary validation failures and retain oversized diagnostics,implemented,verified by real in-memory and stdio MCP client/server calls with wrong argument types
90.6,incur:unknown CLI command,Caller enters an unknown REA command,"'COMMAND' is not a command for 'rea'. Suggested command: rea --help",Low risk; identifies input and exact discovery action,Keep framework-generated copy,unchanged,verified by real packaged dispatcher invocation
90.7,src/ghidra/GhidraProviderClient.ts:projectPrimarySessionError,One procedure's decompile reaches REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS,"Ghidra decompilation of {entry} exceeded the {N}-second limit set by REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Only this procedure's decompilation was stopped; the Ghidra session remains usable.",Low risk; names the setting and keeps the session,Keep current copy,implemented,provider projection test and real-provider lane
90.7,src/ghidra/GhidraProviderClient.ts:projectPrimarySessionError,One procedure's decompile reaches REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS,"Ghidra decompilation of {entry} exceeded the {N}-second limit set by REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Only this procedure's decompilation was stopped. (MCP adds: The Ghidra session remains usable.)",Low risk; names the setting; only a continuing MCP caller is told the session remains usable,Keep current copy,implemented,"provider projection test, CLI/MCP fixture-bridge boundary test and real-provider lane"
90.8,src/ghidra/GhidraFileOffsetFailure.ts:ghidraFileOffsetUnavailable,"address_to_file_offset receives a well-formed address that is unmapped, uninitialized, without an authoritative target-file source, ambiguous or beyond the exact JSON integer range",{bridge reason}. REA establishes no single exact original-file offset for this well-formed address and does not derive or choose one.,Previous copy called the input invalid and directed schema repair for a valid address,"Keep the bridge reason as the headline, point to inspect_native_load_image, omit recovery.kind=inspect_input_contract and record details.issues[0].observed.file_mapping",implemented,provider projection tests; MCP and CLI analysis path through the real Ghidra client with a simulated bridge; real Ghidra not run
91,src/application/binaryDiagnostics/ByteEntropyService.ts (issues); src/domain/analysisErrorPresentation.ts (recovery),Invalid file or window,Expected a regular file.; Selected file could not be found.; Selected byte window starts beyond the end of the file.; Selected byte window extends beyond the end of the file.; File size exceeds exact numeric offset representation.,Invalid selection cannot produce complete entropy Evidence,"Read the declared input schema or CLI usage for inspect_byte_entropy; if using MCP and the catalog may be stale, call tools/list. Correct the listed fields, then retry. Do not repeat the unchanged call.",unchanged,Executed CLI/stdio MCP: path/offset/length refusals and tiny + system-binary controls; shared canonical recovery; service/MCP tests check classification and fields; remaining listed errors source-inspected
92,src/domain/analysisView/byteEntropyView.ts,Non-summary entropy view,Byte entropy Evidence supports the summary view; retrieve parent Evidence for the complete block series.,Unsupported view selection,Use the summary view or retrieve complete parent Evidence,implemented,typed input rejection
1 harm_rank location trigger current_copy user_risk proposed_replacement implementation_status verification_result
119
120
121
122
123
124
125
+2 -1
View File
@@ -83,7 +83,8 @@ valid: partial results state whether WABT validation is unknown, rejected the
module, or succeeded before a later inspection failure.
CLI errors include this partial Evidence inline. MCP errors can return its
`retained-evidence` reference in `details.partial_observation`; use
`get_evidence_bundle` with `detail: "complete"` to retrieve the matching record.
`get_evidence_bundle` with `detail: "records"` and that `evidence_id` filter to
retrieve the matching record.
Core validation remains authoritative when an optional custom section cannot
be interpreted. WABT warning output is preserved in `custom_section_warnings`;
+2 -1
View File
@@ -87,7 +87,8 @@ example under Smart App Control or an App Control policy, reports failure stage
invocation or invalid inspection uses `runtime_unavailable` and doctor
classification `runtime_failure`; it does not
mean Windows x64 itself is unsupported. Provider diagnostics retain the failure
stage under `windows_native_failure`, together with the corresponding remediation.
stage under `windows_native_failure`; the availability's `remediation` carries
the corresponding repair.
Self-test failures preserve the native operation and OS error text. Check the
process's writable local NTFS temporary directory and the reported filesystem,
DACL, Job Object or cleanup constraint, then rerun doctor after resolving it.
+4 -1
View File
@@ -12,6 +12,7 @@ import { planTestSelection } from "./test-selection.mjs";
// Actionlint validates workflow syntax only. The semantic suite asserts these
// workflows' release, publication and self-hosted runner safeguards.
const GUARDED_WORKFLOWS = new Set([
".github/workflows/codeql.yml",
".github/workflows/pr-title.yml",
".github/workflows/pr-readiness.yml",
".github/workflows/ci-full-pr.yml",
@@ -275,10 +276,12 @@ if (tier === "reused") {
for (const owner of owners) selected[owner] = true;
if (
file.startsWith(".github/workflows/") ||
file.startsWith(".github/codeql/") ||
file === ".github/actionlint.yaml"
) {
selected.workflows = true;
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
if (GUARDED_WORKFLOWS.has(file) || file.startsWith(".github/codeql/"))
selected.code = true;
if (file !== ".github/workflows/ci.yml") continue;
}
// The Notes example is also the source-owned agent evaluation fixture.
+4 -4
View File
@@ -60,11 +60,11 @@ function readinessLines(markdown) {
let comment = false;
for (const source of lines) {
if (fence) {
const closing = /^ {0,3}(`+|~+)\s*$/u.exec(source);
if (
new RegExp(
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
"u",
).test(source)
closing !== null &&
closing[1][0] === fence.character &&
closing[1].length >= fence.length
)
fence = undefined;
continue;
+79 -16
View File
@@ -28,6 +28,7 @@ const setAdd = bindBuiltinMethod(Set.prototype.add);
const hasOwnProperty = bindBuiltinMethod(Object.prototype.hasOwnProperty);
const { isProxy } = require("node:util").types;
const { createHash } = require("node:crypto");
const { EventEmitter: NodeEventEmitter } = require("node:events");
const nodeEventEmitterMethods = Object.create(null);
for (const method of [
@@ -77,7 +78,6 @@ let correlationSequence = 0;
const shape = (value, depth = 0) => {
if (depth > 2 || value === null) return value === null ? "null" : "nested";
if (arrayIsArray(value)) return "array";
switch (typeof value) {
case "string":
return "string";
@@ -90,7 +90,12 @@ const shape = (value, depth = 0) => {
case "function":
return "function";
case "object":
return "object";
try {
return arrayIsArray(value) ? "array" : "object";
} catch {
// Revoked proxies retain their typeof identity but reject IsArray.
return "object";
}
default:
return "unknown";
}
@@ -1241,6 +1246,59 @@ const boundaries = createElectronActiveBoundaryPatches({
shape,
});
// Admit only this provider's exact launch tail and Playwright's owned prefix.
// Caller arguments remain opaque, including repeated hook paths and debug flags.
const restoreApplicationArgv = () => {
const handoff = process.env.REA_ELECTRON_CAPTURE_ARGV;
if (handoff === undefined) return;
delete process.env.REA_ELECTRON_CAPTURE_ARGV;
const plan = JSON.parse(handoff);
if (
plan === null ||
typeof plan !== "object" ||
!Number.isSafeInteger(plan.length) ||
plan.length < 3 ||
typeof plan.sha256 !== "string" ||
!/^[a-f0-9]{64}$/u.test(plan.sha256)
)
throw new Error("Capture argument handoff is not this hook's launch plan");
const tailIndex = process.argv.length - plan.length;
const prefixIndex = process.argv[1] === "--no-sandbox" ? 2 : 1;
if (
tailIndex !== prefixIndex + 2 ||
process.argv[prefixIndex] !== "--inspect=0" ||
process.argv[prefixIndex + 1] !== "--remote-debugging-port=0" ||
process.argv[tailIndex] !== "-r" ||
process.argv[tailIndex + 1] !== __filename ||
typeof process.argv[tailIndex + 2] !== "string" ||
process.argv[tailIndex + 2].length === 0
)
throw new Error("Observed argv does not match Playwright's capture prefix");
const tail = sliceArguments(process.argv, tailIndex);
if (
createHash("sha256").update(JSON.stringify(tail)).digest("hex") !==
plan.sha256
)
throw new Error(
"Observed argv does not match the capture argument handoff",
);
for (let index = 2; index < tail.length; index++)
process.argv[index - 1] = tail[index];
process.argv.length = tail.length - 1;
};
try {
restoreApplicationArgv();
} catch (cause) {
globalThis.__reaElectronActiveHookError = true;
recordRuntime(
"error",
cause instanceof Error ? cause.message : String(cause),
"failed",
{ target: "active-hook-arguments", error: true },
);
}
try {
const electron = require("electron");
patchEmitter(electron.app, (event, args) =>
@@ -1267,20 +1325,25 @@ try {
boundaries.patchApplicationEffects(electron.app);
boundaries.patchNativeAddonLoading();
boundaries.patchChildProcess();
process.once("uncaughtException", (cause, origin) => {
recordRuntime("process-lifecycle", "uncaught-exception", "observed", {
argument_shape_values: [cause, origin],
error: true,
});
throw cause;
});
process.once("unhandledRejection", (reason, promise) => {
recordRuntime("process-lifecycle", "unhandled-rejection", "observed", {
argument_shape_values: [reason, promise],
error: true,
});
throw reason;
});
// Observe dispatch without adding listeners: their presence changes Node's
// rejection fallback and Electron's uncaught-exception listener-count policy.
const processEmit = process.emit;
process.emit = function observedProcessEmit(event, ...args) {
if (this === process) {
const kind =
event === "unhandledRejection"
? "unhandled-rejection"
: event === "uncaughtExceptionMonitor"
? "uncaught-exception"
: null;
if (kind !== null)
recordRuntime("process-lifecycle", kind, "observed", {
argument_shape_values: args,
error: true,
});
}
return applyFunction(processEmit, this, arguments);
};
} catch (cause) {
globalThis.__reaElectronActiveHookError = true;
recordRuntime(
+26
View File
@@ -0,0 +1,26 @@
import { createServer } from "node:http";
/** Interpret request targets once, before a fixture routes or forwards them. */
export const createBrowserFixtureHttpServer = (handleRequest) =>
createServer((request, response) => {
let target;
try {
// A leading // is an origin-form path, not a replacement authority.
const requested = new URL(
request.url?.startsWith("/")
? `http://fixture.invalid${request.url}`
: request.url,
);
if (requested.protocol !== "http:" && requested.protocol !== "https:")
throw new TypeError("Unsupported fixture request protocol");
target = { pathname: requested.pathname, search: requested.search };
} catch {
response.writeHead(400, {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
});
response.end("Invalid HTTP request target");
return;
}
handleRequest(request, response, target);
});
@@ -1,11 +1,12 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { createServer, request } from "node:http";
import { request } from "node:http";
import { connect } from "node:net";
import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js";
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import { PlaywrightScenarioServiceWorkers } from "../../dist/browser/PlaywrightScenarioServiceWorkers.js";
import { createBrowserFixtureHttpServer } from "./browser-fixture-http.mjs";
const beforeDeadline = async (promise, label, timeoutMs = 2_000) => {
let timer;
@@ -26,7 +27,7 @@ const beforeDeadline = async (promise, label, timeoutMs = 2_000) => {
// Real loopback proxy: Playwright's first transport is forwarded to Chrome,
// then only the policy owner's discovery/handshake is deliberately withheld.
const startProxy = async (endpoint, mode, ownerOnly = false) => {
export const startProxy = async (endpoint, mode, ownerOnly = false) => {
const upstream = new URL(endpoint);
const sockets = new Set();
let versions = 0;
@@ -47,38 +48,44 @@ const startProxy = async (endpoint, mode, ownerOnly = false) => {
socket.once("close", () => closed());
entered();
};
const server = createServer((incoming, response) => {
const versionRequest = incoming.url.replace(/\/$/, "") === "/json/version";
if (versionRequest) versions += 1;
if (
enabled &&
mode === "http-discovery" &&
versions === 2 &&
versionRequest
) {
stall(incoming.socket);
return;
}
const outgoing = request(new URL(incoming.url, upstream), (received) => {
const chunks = [];
received.on("data", (chunk) => chunks.push(chunk));
received.on("end", () => {
const content = Buffer.concat(chunks).toString("utf8");
response.writeHead(received.statusCode, {
"content-type": "application/json",
const server = createBrowserFixtureHttpServer(
(incoming, response, target) => {
const versionRequest =
target.pathname.replace(/\/$/, "") === "/json/version";
if (versionRequest) versions += 1;
if (
enabled &&
mode === "http-discovery" &&
versions === 2 &&
versionRequest
) {
stall(incoming.socket);
return;
}
const destination = new URL(upstream);
destination.pathname = target.pathname;
destination.search = target.search;
const outgoing = request(destination, (received) => {
const chunks = [];
received.on("data", (chunk) => chunks.push(chunk));
received.on("end", () => {
const content = Buffer.concat(chunks).toString("utf8");
response.writeHead(received.statusCode, {
"content-type": "application/json",
});
response.end(
content.replaceAll(
`127.0.0.1:${upstream.port}`,
`127.0.0.1:${server.address().port}`,
),
);
});
response.end(
content.replaceAll(
`127.0.0.1:${upstream.port}`,
`127.0.0.1:${server.address().port}`,
),
);
});
});
outgoing.on("error", () => response.destroy());
response.on("close", () => outgoing.destroy());
outgoing.end();
});
outgoing.on("error", () => response.destroy());
response.on("close", () => outgoing.destroy());
outgoing.end();
},
);
server.on("connection", (socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
@@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
import { chromium } from "playwright-core";
@@ -11,6 +10,7 @@ import { createBrowserScenarioProvider } from "../../dist/composition/browserSce
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
import { runScenarioCli } from "./browser-scenario-verifier.mjs";
import { verifyScenarioPolicySetup } from "./browser-scenario-service-worker-setup-e2e.mjs";
import { createBrowserFixtureHttpServer } from "./browser-fixture-http.mjs";
const diagnosticError = (error) =>
error instanceof Error
@@ -26,38 +26,38 @@ const diagnosticError = (error) =>
}
: { kind: typeof error };
const startSite = async () => {
export const startSite = async () => {
let otherOrigin;
const server = createServer((request, response) => {
const path = new URL(request.url, "http://localhost").pathname;
if (path === "/sw.js") {
response.setHeader("content-type", "application/javascript");
response.end(`self.addEventListener("install", () => self.skipWaiting());
const server = createBrowserFixtureHttpServer(
(_request, response, { pathname: path }) => {
if (path === "/sw.js") {
response.setHeader("content-type", "application/javascript");
response.end(`self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", event => event.waitUntil(clients.claim()));
self.addEventListener("fetch", event => {
if (new URL(event.request.url).pathname === "/probe")
event.respondWith(new Response("worker"));
});`);
} else if (path === "/probe") {
response.end("network");
} else if (path === "/failed-navigation") {
response.destroy();
} else if (path === "/stalled-image.png") {
response.writeHead(200, { "content-type": "image/png" });
response.flushHeaders();
} else if (path === "/pending-image" || path === "/pending-child") {
response.setHeader("content-type", "text/html");
response.end(
'<!doctype html><img id="pending-image" src="/stalled-image.png">',
);
} else if (path === "/pending-frame") {
response.setHeader("content-type", "text/html");
response.end(
'<!doctype html><iframe id="pending-frame" src="/pending-child"></iframe>',
);
} else {
response.setHeader("content-type", "text/html");
response.end(`<!doctype html><p id="state">ready</p>
} else if (path === "/probe") {
response.end("network");
} else if (path === "/failed-navigation") {
response.destroy();
} else if (path === "/stalled-image.png") {
response.writeHead(200, { "content-type": "image/png" });
response.flushHeaders();
} else if (path === "/pending-image" || path === "/pending-child") {
response.setHeader("content-type", "text/html");
response.end(
'<!doctype html><img id="pending-image" src="/stalled-image.png">',
);
} else if (path === "/pending-frame") {
response.setHeader("content-type", "text/html");
response.end(
'<!doctype html><iframe id="pending-frame" src="/pending-child"></iframe>',
);
} else {
response.setHeader("content-type", "text/html");
response.end(`<!doctype html><p id="state">ready</p>
<a id="navigate" href="${otherOrigin}/">Navigate</a>
${path === "/frame" ? `<iframe src="${otherOrigin}/"></iframe>` : ""}
${
@@ -72,8 +72,9 @@ const startSite = async () => {
</script>`
: ""
}`);
}
});
}
},
);
server.listen(0, "127.0.0.1");
await once(server, "listening");
return {
+23 -22
View File
@@ -1,5 +1,4 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { once } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
@@ -11,6 +10,7 @@ import {
scenarioProfiles,
} from "./browser-scenario-verifier.mjs";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
import { createBrowserFixtureHttpServer } from "./browser-fixture-http.mjs";
const scenarioFor = (browser, origin) =>
browserScenarioSchema.parse({
@@ -149,28 +149,29 @@ const storagePage = (kind, port) => {
</script></body></html>`;
};
const startStorageSite = async () => {
export const startStorageSite = async () => {
let port = 0;
const server = createServer((request, response) => {
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
const kind =
path === "/storage-main"
? "main"
: path === "/storage-frame"
? "frame"
: path === "/storage-debugger"
? "debugger"
: path === "/storage-top"
? "top"
: undefined;
if (kind === undefined) {
response.writeHead(404).end();
return;
}
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(storagePage(kind, port));
});
server.listen(0, "::");
const server = createBrowserFixtureHttpServer(
(_request, response, { pathname: path }) => {
const kind =
path === "/storage-main"
? "main"
: path === "/storage-frame"
? "frame"
: path === "/storage-debugger"
? "debugger"
: path === "/storage-top"
? "top"
: undefined;
if (kind === undefined) {
response.writeHead(404).end();
return;
}
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(storagePage(kind, port));
},
);
server.listen(0, "127.0.0.1");
await once(server, "listening");
port = server.address().port;
return {
+39
View File
@@ -87,11 +87,50 @@ export async function verifyGhidraEntryAliases({
(await call("procedure_info", { procedure: alias.value })).entrypoint,
alias.address,
);
// Search matches the names exact lookup accepts, as one procedure row
// under its primary name.
const escaped = alias.value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
for (const query of [
{ pattern: alias.value },
{ pattern: `^${escaped}$`, mode: "regex", case_sensitive: true },
]) {
assert.deepEqual(
(await call("search_procedures", query))
.filter(({ address }) => address === alias.address)
.map(({ value, matched_aliases }) => ({ value, matched_aliases })),
[{ value: canonical.value, matched_aliases: [alias.value] }],
`Procedure search missed the entry alias for ${JSON.stringify(query)}`,
);
}
assert.ok(
(await call("list_procedures")).every(
(row) => row.matched_aliases === undefined,
),
"Listings must not report search matches",
);
const traced = await call("trace_feature", { query: alias.value });
assert.ok(
traced.matches.some(
({ type, address, matched_aliases }) =>
type === "procedure" &&
address === alias.address &&
matched_aliases?.includes(alias.value) === true,
),
"trace_feature missed the procedure its entry alias names",
);
const interior = names.find(
({ value }) => value === `${prefix}rea_interior`,
);
assert.ok(interior);
assert.notEqual(interior.address, alias.address);
assert.ok(
(await call("search_procedures", { pattern: interior.value })).every(
({ address, matched_aliases = [] }) =>
address !== interior.address &&
!matched_aliases.includes(interior.value),
),
"An interior label must not match procedure search",
);
assert.equal(
interior.symbol.type,
"label",
@@ -68,6 +68,21 @@ export async function verifyGhidraNamespaceAnnotations({
assert.equal(ambiguous.code, "invalid_request");
for (const { address } of entries)
assert.ok(JSON.stringify(ambiguous.details.issues).includes(address));
// Search accepts the leaf spelling exact lookup accepts, keeping each
// procedure as one row under its qualified primary name.
const leafMatches = await call("search_procedures", {
pattern: "^same$",
mode: "regex",
case_sensitive: true,
});
for (const { namespace, address } of entries)
assert.deepEqual(
leafMatches
.filter((row) => row.address === address)
.map(({ value }) => value),
[`${namespace}::same`],
`Leaf-name search missed ${namespace}::same`,
);
for (const { namespace, address } of entries) {
const leaf = await call("annotate_native_function", {
procedure: address,
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { PrivateRuntimeRoot } from "../../dist/process/PrivateRuntimeRoot.js";
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
import { createVerifierCommands } from "./verify-commands.mjs";
const source = fileURLToPath(
new URL(
"../../tests/conformance/ghidra/relocation-symbol.c",
import.meta.url,
),
);
/** Verify named and unnamed ELF relocations through the shipped CLI and Ghidra. */
export async function verifyGhidraRelocationSymbols({ clang, lld, env }) {
const commands = await createVerifierCommands();
let root;
let commandsClosed = false;
return withVerifierCleanup(async () => {
root = await PrivateRuntimeRoot.create({ prefix: "rea-relocation-proof-" });
const object = join(root.path, "relocation-symbol.o");
const target = join(root.path, "relocation-symbol.so");
const commandEnv = {
...env,
TMPDIR: root.path,
TMP: root.path,
TEMP: root.path,
};
const run = (command, args) =>
commands.exec(command, args, {
env: commandEnv,
timeout: 120000,
maxBuffer: 16 * 1024 * 1024,
});
await run(clang, [
"--target=x86_64-linux-gnu",
"-fPIC",
"-O0",
"-c",
source,
"-o",
object,
]);
await run(lld, ["-shared", object, "-o", target]);
const hash = async () =>
createHash("sha256")
.update(await readFile(target))
.digest("hex");
const before = await hash();
const { stdout } = await run(process.execPath, [
"scripts/rea.mjs",
"inspect-native-load-image",
target,
"--provider",
"ghidra",
"--json",
]);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.subject?.digest.sha256, before);
assert.equal(evidence.provider.id, "ghidra");
const report = evidence.normalized_result;
assert.equal(report.status, "unsupported");
const relocations = report.observations.relocations;
assert.ok(
relocations.some(({ symbol }) => symbol === "rea_external"),
"The Ghidra relocation must retain its imported symbol",
);
assert.ok(
relocations.some(({ symbol }) => symbol === ""),
"The provider's empty symbol must remain distinct from an unrecorded field",
);
assert.equal(await hash(), before);
return {
source,
target_sha256: before,
named_symbol: "rea_external",
empty_symbol: true,
};
}, [
async () => {
await commands.close();
commandsClosed = true;
},
async () => {
if (commandsClosed && root !== undefined) await root.close();
},
]);
}
+5
View File
@@ -10,5 +10,10 @@ export function compareTestDiscovery(
readonly file: string;
readonly projects: readonly string[];
}[];
readonly wrongProjects: readonly {
readonly file: string;
readonly expected: string;
readonly actual: string;
}[];
readonly discovered: number;
};
+9
View File
@@ -1,4 +1,5 @@
import { relative, resolve } from "node:path";
import { projectForTest } from "./test-projects.mjs";
/** Compare actual Vitest discovery with repository-owned test paths. */
export const compareTestDiscovery = (expectedPaths, discovery, root) => {
@@ -36,6 +37,14 @@ export const compareTestDiscovery = (expectedPaths, discovery, root) => {
const projects = projectsByFile.get(file);
return projects.length > 1 ? [{ file, projects: sorted(projects) }] : [];
}),
wrongProjects: sorted(expected).flatMap((file) => {
const projects = projectsByFile.get(file);
if (!projects || projects.length !== 1) return [];
const expectedProject = projectForTest(file).name;
return projects[0] === expectedProject
? []
: [{ file, expected: expectedProject, actual: projects[0] }];
}),
discovered: projectsByFile.size,
};
};
+4 -2
View File
@@ -539,11 +539,13 @@ class WebsiteMaintenanceChecks(unittest.TestCase):
self.assertIn(b"color: black", response.read())
with self.assertRaises(HTTPError) as error:
urlopen(base + prefix + "missing")
self.assertEqual(error.exception.code, 404)
with contextlib.closing(error.exception) as response:
self.assertEqual(response.code, 404)
if prefix != "/":
with self.assertRaises(HTTPError) as error:
urlopen(base + "/guide/")
self.assertEqual(error.exception.code, 404)
with contextlib.closing(error.exception) as response:
self.assertEqual(response.code, 404)
finally:
server.shutdown()
worker.join()
+9 -78
View File
@@ -15,8 +15,10 @@ if (options.help) {
process.stdout.write(
"Usage: npm run verify:adb [-- --serial SERIAL --pull PACKAGE]\n" +
"Requires a caller-selected adb binary (REA_ADB_PATH or PATH) and a\n" +
"connected device or emulator. Verifies all five operations against\n" +
"the live device; --pull acquires a real package and re-digests the\n" +
"connected device or emulator. Verifies observation and transfer operations\n" +
"against the live device. --install-apk is unavailable until APK identity\n" +
"and device cleanup ownership can be established safely.\n" +
"--pull acquires a real package and re-digests the\n" +
"files on disk against the returned SHA-256 values.\n",
);
process.exit(0);
@@ -356,79 +358,6 @@ try {
`versionCode=${String(details.version_code)}`,
);
}
if (options.installApk !== null && options.installApk !== undefined) {
const probePackage = "com.rea.adb.probe";
const installedProbe = await run("install_adb_package", {
serial,
apk_path: options.installApk,
replace: false,
});
if (installedProbe !== null) {
check(
"install reports the APK digest",
typeof installedProbe.sha256 === "string" &&
installedProbe.sha256.length === 64,
`${installedProbe.bytes} bytes`,
);
const resolved = await run("resolve_adb_packages", {
serial,
query: "rea.adb.probe",
});
if (resolved !== null)
check(
"resolve finds the installed probe uniquely",
resolved.matches.length === 1 &&
resolved.matches[0]?.package_name === probePackage,
`${resolved.matches.length} match(es)`,
);
const started = await run("start_adb_app", {
serial,
package: probePackage,
});
if (started !== null)
check(
"app start resolves and launches the launcher activity",
typeof started.component === "string" &&
started.component.startsWith(probePackage),
String(started.component),
);
const observedProcesses = await run("list_adb_processes", { serial });
if (observedProcesses !== null)
check(
"started app appears in the process listing",
observedProcesses.processes.some(
(process) => process.name === probePackage,
),
);
const stopped = await run("stop_adb_app", {
serial,
package: probePackage,
});
if (stopped !== null)
check(
"force-stop completes the dynamic-analysis run",
stopped.package_name === probePackage,
);
const removed = await run("uninstall_adb_package", {
serial,
package: probePackage,
});
if (removed !== null)
check(
"uninstall completes the lifecycle",
removed.package_name === probePackage,
);
const afterRemoval = await run("resolve_adb_packages", {
serial,
query: "rea.adb.probe",
});
if (afterRemoval !== null)
check(
"resolve reports zero matches after removal",
afterRemoval.matches.length === 0,
);
}
}
} finally {
await rm(transferRoot, { recursive: true, force: true });
}
@@ -459,7 +388,7 @@ if (failures > 0) {
}
function parseArguments(argv) {
const parsed = { help: false, serial: null, pull: null, installApk: null };
const parsed = { help: false, serial: null, pull: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help" || argument === "-h") parsed.help = true;
@@ -470,8 +399,10 @@ function parseArguments(argv) {
parsed.pull = argv[index + 1] ?? null;
index += 1;
} else if (argument === "--install-apk") {
parsed.installApk = argv[index + 1] ?? null;
index += 1;
process.stderr.write(
"--install-apk is unavailable: APK identity and device cleanup ownership cannot be established safely\n",
);
process.exit(1);
} else {
process.stderr.write(`unknown argument: ${argument}\n`);
process.exit(2);
+3 -2
View File
@@ -8,7 +8,7 @@ import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
const root = await PrivateRuntimeRoot.create({ prefix: "rea-bun-verifier-" });
const commands = await createVerifierCommands();
let commands;
const run = (command, args, options = {}) =>
commands.exec(command, args, {
timeout: 120_000,
@@ -19,6 +19,7 @@ const run = (command, args, options = {}) =>
// only after every owned command family has settled.
let commandsClosed = false;
await withVerifierCleanup(async () => {
commands = await createVerifierCommands();
const source = join(root.path, "hello.ts");
await writeFile(source, 'console.log("REA_BUN_VERIFICATION");\n');
const docker = process.argv.includes("--docker");
@@ -104,7 +105,7 @@ await withVerifierCleanup(async () => {
);
}, [
async () => {
await commands.close();
if (commands !== undefined) await commands.close();
commandsClosed = true;
},
async () => {
+5 -2
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import { createCatalogIdentity } from "../dist/catalogIdentity.js";
@@ -15,6 +15,7 @@ import {
TOOL_CONTRACTS,
toolContract,
} from "../dist/contracts/toolContracts.js";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
@@ -306,7 +307,9 @@ await withVerifierCleanup(async () => {
assert(
parsed.plannedActions.every(
(a) =>
a.target === configPath || a.target.startsWith(skillDirectory),
a.target === configPath ||
(isAbsolute(a.target) &&
isPathWithinRoot(skillDirectory, a.target)),
),
);
}
+31 -8
View File
@@ -6,6 +6,7 @@ import { isAbsolute, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
@@ -54,6 +55,7 @@ const expectedNames = TOOL_CONTRACTS.map(
).sort();
let clientVersion = null;
let expectedSkill;
let skillCallId;
let inventoryChecked = false;
let catalogSize = null;
let skillLoaded = false;
@@ -180,10 +182,24 @@ await withVerifierCleanup(async () => {
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
}
if (stage === 0)
return call("activate_skill", { name: "reverse-engineer-anything" });
if (stage === 0) {
const request = call("activate_skill", {
name: "reverse-engineer-anything",
});
skillCallId = request.tool_calls[0].id;
return request;
}
assert(
strings.some((text) => text.includes(expectedSkill)),
skillCallId !== undefined &&
expectedSkill !== undefined &&
body.messages.some(
(message) =>
message.role === "tool" &&
message.tool_call_id === skillCallId &&
textValues(message.content).some((text) =>
text.includes(expectedSkill),
),
),
"The model must receive the complete activated skill body",
);
skillLoaded = true;
@@ -322,8 +338,8 @@ await withVerifierCleanup(async () => {
result.plannedActions.every(
(action) =>
action.target === configPath ||
action.target === skillDirectory ||
action.target.startsWith(skillDirectory + "/"),
(isAbsolute(action.target) &&
isPathWithinRoot(skillDirectory, action.target)),
),
"Refuse setup writes outside the disposable account's configuration and skill",
);
@@ -342,9 +358,16 @@ await withVerifierCleanup(async () => {
assert.deepEqual(repeated.appliedActions, []);
assert.equal(await readFile(configPath, "utf8"), configured);
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
expectedSkill = (await readFile(join(skillDirectory, "SKILL.md"), "utf8"))
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "")
.trim();
const installedSkill = await readFile(
join(skillDirectory, "SKILL.md"),
"utf8",
);
const frontmatter = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(
installedSkill,
);
assert(frontmatter, "Installed skill must have authored frontmatter");
expectedSkill = installedSkill.slice(frontmatter[0].length).trim();
assert(expectedSkill.length > 0, "Installed skill body must not be empty");
const response = await saveVerifierCommandOutput(
() =>
commands.exec(
+3 -2
View File
@@ -12,6 +12,7 @@ import {
import { advertisedInputJsonSchema } from "../dist/contracts/toolSchemaMetadata.js";
import { presentAdvertisedInputSchema } from "../dist/contracts/compactInputSchema.js";
import { compactPresentationForMcpInputSchemaProfile } from "../dist/config/mcpInputSchemaProfile.js";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
@@ -300,8 +301,8 @@ await withVerifierCleanup(async () => {
parsed.plannedActions.every(
(a) =>
a.target === configPath ||
a.target === skillDirectory ||
a.target.startsWith(skillDirectory + "/"),
(isAbsolute(a.target) &&
isPathWithinRoot(skillDirectory, a.target)),
),
);
}
+37 -9
View File
@@ -56,6 +56,7 @@ let catalog;
let patternCount = 0;
let skillRequested = false;
let skillLoaded = false;
let expectedSkillBody;
let evidenceSeen = false;
let finalSeen = false;
let artifactDigest;
@@ -169,11 +170,34 @@ await withVerifierCleanup(async () => {
const toolMessages = (body.messages ?? []).filter(
(message) => message.role === "tool",
);
const serialized = JSON.stringify(toolMessages);
skillLoaded ||=
serialized.includes(
'<skill_content name=\\"reverse-engineer-anything\\">',
) && serialized.includes(dirname(skillPath));
skillLoaded ||= toolMessages.some((message) => {
if (
typeof message.content !== "string" ||
expectedSkillBody === undefined
)
return false;
const open =
'<skill_content name="reverse-engineer-anything">\n<skill_resources>\n';
const start = message.content.indexOf(open);
if (start < 0) return false;
const resourcesStart = start + open.length;
const instructions = `\n</skill_resources>\n\n<skill_instructions>\n${expectedSkillBody}\n</skill_instructions>\n</skill_content>`;
const resourcesEnd = message.content.indexOf(
instructions,
resourcesStart,
);
if (resourcesEnd < 0) return false;
const expectedPath = dirname(skillPath)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
const resources = message.content.slice(resourcesStart, resourcesEnd);
return (
!resources.includes("</skill_content>") &&
!resources.includes("</skill_resources>") &&
resources.includes(expectedPath)
);
});
for (const message of toolMessages) {
const found = findClientResult(
message.content,
@@ -281,11 +305,15 @@ await withVerifierCleanup(async () => {
const configured = await readFile(configPath, "utf8");
assert.deepEqual(withoutRea(configured), withoutRea(original));
assert(configured.includes("!!js process.cwd()"));
assert(
(await readFile(skillPath, "utf8")).includes(
"name: reverse-engineer-anything",
),
const installedSkill = await readFile(skillPath, "utf8");
assert(installedSkill.includes("name: reverse-engineer-anything"));
const frontmatter = /^---\r?\n[\s\S]*?^---(?:\r?\n|$)/mu.exec(
installedSkill,
);
assert(frontmatter, "Installed skill must have its authored frontmatter");
expectedSkillBody = installedSkill.slice(frontmatter[0].length).trim();
assert(expectedSkillBody.length > 0);
lifecycle.push("setup");
const repeated = await runRea(
["setup", "--client", "dsh", "--yes"],
+4
View File
@@ -104,6 +104,10 @@ await withVerifierCleanup(
}
// The same checks apply to the default selection and each remaining ABI.
const preferred = await run("inspect_dart_aot", { path: apk });
assert.ok(
abis.some((abi) => abi.abi === preferred.abi),
"Default AOT selection must be one of the identified ABIs",
);
for (const abi of abis) {
const aot =
abi.abi === preferred.abi
+5 -1
View File
@@ -2,7 +2,7 @@ import { basename } from "node:path";
import { FridaInstrumentationService } from "../dist/application/frida/FridaInstrumentationService.js";
import { FridaInstrumentationManager } from "../dist/frida/FridaInstrumentationManager.js";
import { isValidFridaRemoteAddress } from "../dist/contracts/fridaRemoteAddress.js";
import { isValidFridaRemoteAddress } from "../dist/domain/fridaRemoteAddress.js";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
const options = parseArguments(process.argv.slice(2));
@@ -80,6 +80,10 @@ try {
!Array.isArray(observed.messages)
)
throw new Error("Frida returned no message observation");
if (!Number.isSafeInteger(observed.pid) || observed.pid <= 0)
throw new Error("Frida returned no valid target PID");
if (remote !== undefined && observed.pid !== options.pid)
throw new Error("Frida observed a different target PID");
const messageObserved = observed.messages.some(
(message) =>
typeof message === "object" &&
+49 -19
View File
@@ -2,13 +2,14 @@ import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve, sep } from "node:path";
import { isAbsolute, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import {
TOOL_CONTRACTS,
toolContract,
} from "../dist/contracts/toolContracts.js";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
@@ -65,6 +66,7 @@ let fixture;
let passed = false;
let evidenceSeen = false;
let skillLoaded = false;
let expectedSkillBody;
let skillRequested = false;
let finalSeen = false;
let catalogSize = null;
@@ -150,13 +152,21 @@ await withVerifierCleanup(async () => {
if (args.includes("--dry-run")) {
assert(parsed.plannedActions.some((a) => a.target === configPath));
assert(
parsed.plannedActions.some((a) =>
a.target.startsWith(join(profile, ".agents", "skills") + sep),
),
parsed.plannedActions.some((a) => {
const skills = join(profile, ".agents", "skills");
return (
isAbsolute(a.target) &&
relative(skills, a.target) !== "" &&
isPathWithinRoot(skills, a.target)
);
}),
);
assert(
parsed.plannedActions.every((a) =>
a.target.startsWith(profile + sep),
parsed.plannedActions.every(
(a) =>
isAbsolute(a.target) &&
relative(profile, a.target) !== "" &&
isPathWithinRoot(profile, a.target),
),
);
}
@@ -189,11 +199,15 @@ await withVerifierCleanup(async () => {
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
assert.equal(await readFile(configPath, "utf8"), configured);
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
assert(
(await readFile(skillPath, "utf8")).includes(
"name: reverse-engineer-anything",
),
const installedSkill = await readFile(skillPath, "utf8");
assert(installedSkill.includes("name: reverse-engineer-anything"));
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/u.exec(
installedSkill,
);
assert(frontmatter, "Installed skill must have authored frontmatter");
expectedSkillBody = (frontmatter[2] ?? "").trim();
assert(expectedSkillBody.length > 0);
const target = join(lab, "fixture α测试");
await mkdir(target);
await writeFile(join(target, "app.js"), CLIENT_JAVASCRIPT_FIXTURE_SOURCE);
@@ -268,16 +282,32 @@ await withVerifierCleanup(async () => {
.flatMap((c) => c.parts ?? [])
.filter((p) => p.functionResponse)
.map((p) => p.functionResponse);
skillLoaded ||= responses.some(
(r) =>
r.name === "activate_skill" &&
r.response?.output?.includes(
`<activated_skill name="reverse-engineer-anything">`,
) &&
r.response.output.includes(
skillLoaded ||= responses.some((response) => {
const output = response.response?.output;
if (
response.name !== "activate_skill" ||
typeof output !== "string" ||
expectedSkillBody === undefined
)
return false;
const start = `<activated_skill name="reverse-engineer-anything">\n <instructions>\n ${expectedSkillBody}\n </instructions>\n\n <available_resources>\n `;
const at = output.indexOf(start);
if (at < 0) return false;
const resourcesStart = at + start.length;
const resourcesEnd = output.indexOf(
"\n </available_resources>\n</activated_skill>",
resourcesStart,
);
if (resourcesEnd < 0) return false;
const resources = output.slice(resourcesStart, resourcesEnd);
return (
!resources.includes("</activated_skill>") &&
!resources.includes("</available_resources>") &&
resources.includes(
join(profile, ".agents", "skills", "reverse-engineer-anything"),
),
);
)
);
});
const observed = findClientResult(
responses,
isClientJavaScriptEvidence,
+3 -2
View File
@@ -6,6 +6,7 @@ import { isAbsolute, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
@@ -402,8 +403,8 @@ enabled = false
parsed.plannedActions.every(
(action) =>
action.target === configPath ||
action.target === skillDirectory ||
action.target.startsWith(skillDirectory + "/"),
(isAbsolute(action.target) &&
isPathWithinRoot(skillDirectory, action.target)),
),
"Refuse writes outside the owned profile/skill",
);
+7 -1
View File
@@ -373,6 +373,7 @@ try {
}
const report = {
run_id: run.run_id,
input_mode: inspectExisting ? "existing-captures" : "producer-attempt",
status: primaryError || cleanupFailures.length ? "failed" : "passed",
finished_at: new Date().toISOString(),
...(primaryError
@@ -386,8 +387,13 @@ const report = {
verifier,
producer_audit: producerAudit,
scope:
"Real Linux reverse HTTP/3, mitmproxy 12.2.3, aioquic 1.2.0 and guest cURL",
"Linux reverse HTTP/3 capture verification for mitmproxy 12.2.3, aioquic 1.2.0 and guest cURL",
limitations: [
...(inspectExisting
? [
"Existing capture producer provenance was not authenticated by this attempt.",
]
: []),
"No local/WireGuard, browser, QUIC v2, migration, 0-RTT, mTLS or replay claim.",
"No HAR, interruption, certificate-failure or body-completeness coverage in this profile.",
],
+6 -2
View File
@@ -17,7 +17,7 @@ import { hashVerifierFile } from "./lib/verifier-file-resources.mjs";
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
console.log(
"Usage: npm run verify:il2cpp:metadata -- [--metadata PATH]\nPATH is a caller-owned, unencrypted global-metadata.dat (version 24, 27, 29, 31 or 39). Without --metadata, only the advertised contract is verified.",
"Usage: npm run verify:il2cpp:metadata -- [--metadata PATH]\nPATH is a caller-owned, unencrypted global-metadata.dat (version 24, 27, 29, 31 or 39). Without --metadata, only the MCP tool name is checked.",
);
process.exit(0);
}
@@ -178,4 +178,8 @@ await withVerifierCleanup(async () => {
);
}
}, [() => client.close(), () => transport.close(), () => resources.close()]);
console.log("All asserted IL2CPP metadata CLI/MCP checks passed");
console.log(
path === null
? "IL2CPP metadata MCP tool presence check passed"
: "All asserted IL2CPP metadata CLI/MCP checks passed",
);
+7 -3
View File
@@ -68,7 +68,9 @@ try {
});
if (!decompiled.ok) throw new Error(decompiled.error.message);
const text = decompiled.value.normalized_result.text;
if (!text.includes(target.marker))
if (text.trim().length === 0)
throw new Error("Decompiled pseudo-code was empty");
if (target.marker !== undefined && !text.includes(target.marker))
throw new Error(
"Decompiled pseudo-code did not contain the fixture marker",
);
@@ -83,7 +85,7 @@ try {
open_project_observed: openRefusal === null,
...(openRefusal === null ? {} : { open_project_refusal: openRefusal }),
unit_count: units.value.normalized_result.units.length,
decompiled_marker_observed: true,
decompiled_marker_observed: target.marker !== undefined,
};
}, [
async () => {
@@ -110,7 +112,7 @@ async function resolveTarget(selected, directory) {
return {
path: "/engine-side/target",
itemAddress: selected.itemAddress,
marker: selected.marker ?? "",
marker: selected.marker,
};
await writeFile(
join(directory, "Hello.java"),
@@ -153,6 +155,8 @@ function parseArguments(args) {
values.set(flag, value);
index += 1;
}
if (values.get("--marker") === "")
throw new Error("--marker must not be empty");
return {
help: false,
...(values.has("--item-address")
+4 -3
View File
@@ -2,9 +2,10 @@ import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join, relative } from "node:path";
import { isAbsolute, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
@@ -314,8 +315,8 @@ await withVerifierCleanup(async () => {
parsed.plannedActions.every(
(action) =>
action.target === configPath ||
action.target === skillDirectory ||
action.target.startsWith(skillDirectory + "/"),
(isAbsolute(action.target) &&
isPathWithinRoot(skillDirectory, action.target)),
),
"Refuse setup writes outside the owned config/skill",
);
+11 -11
View File
@@ -2,10 +2,11 @@ import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, sep } from "node:path";
import { isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
@@ -49,19 +50,15 @@ const skillDirectory = join(
"skills",
"reverse-engineer-anything",
);
const isManagedSkillTarget = (target) => {
const value = relative(skillDirectory, target);
return (
value === "" ||
(value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value))
);
};
assert.equal(
isManagedSkillTarget(`${skillDirectory}-sibling/AGENTS.md`),
isPathWithinRoot(skillDirectory, `${skillDirectory}-sibling/AGENTS.md`),
false,
);
assert.equal(
isManagedSkillTarget(join(skillDirectory, "..", "outside", "AGENTS.md")),
isPathWithinRoot(
skillDirectory,
join(skillDirectory, "..", "outside", "AGENTS.md"),
),
false,
);
let clientVersion = null;
@@ -271,7 +268,10 @@ await withVerifierCleanup(async () => {
);
assert(
parsed.plannedActions.every(
(a) => a.target === configPath || isManagedSkillTarget(a.target),
(a) =>
a.target === configPath ||
(isAbsolute(a.target) &&
isPathWithinRoot(skillDirectory, a.target)),
),
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { PackageVerifierCommands } from "./lib/verify-package-commands.mjs";
export function verifyPackageUpdate(options: {
readonly prefix: string;
readonly packageRoot: string;
readonly tarball: string;
readonly workspace: string;
readonly environment: NodeJS.ProcessEnv;
readonly commands: Pick<PackageVerifierCommands, "exec">;
}): Promise<{
readonly installed_version: string;
readonly exact_release: true;
readonly maintenance_read_only: true;
}>;
+13 -3
View File
@@ -35,13 +35,20 @@ export async function verifyPackageUpdate({
response.destroy();
return;
}
response.writeHead(500);
response.writeHead(500, {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
});
response.end(String(error));
});
});
let registry;
async function serveRegistry(request, response) {
const pathname = new URL(request.url, registry).pathname;
// A leading // belongs to an origin-form HTTP path, not a URL authority.
const requested = new URL(
request.url.startsWith("/") ? `${registry}${request.url}` : request.url,
);
const { pathname } = requested;
if (pathname === "/rea-agents") {
response.setHeader("Content-Type", "application/json");
response.end(
@@ -65,7 +72,10 @@ export async function verifyPackageUpdate({
response.setHeader("Content-Type", "application/json");
response.end("{}");
} else {
const upstream = await fetch(`https://registry.npmjs.org${request.url}`);
const upstreamUrl = new URL("https://registry.npmjs.org");
upstreamUrl.pathname = pathname;
upstreamUrl.search = requested.search;
const upstream = await fetch(upstreamUrl);
const body = Buffer.from(await upstream.arrayBuffer());
response.writeHead(upstream.status, {
"Content-Type":
-1
View File
@@ -266,7 +266,6 @@ await withVerifierCleanup(async () => {
extractedPluginMcp: true,
targetFree: true,
targetLifecycle: true,
boundedRegexBridge: true,
update,
mcpStartup,
mcpModuleLoading,
+28 -11
View File
@@ -9,9 +9,10 @@ import {
symlink,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve, sep } from "node:path";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { isPathWithinRoot } from "../dist/domain/localPath.js";
import { createVerifierCommands } from "./lib/verify-commands.mjs";
import { saveVerifierCommandOutput } from "./lib/verifier-command-output.mjs";
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
@@ -58,9 +59,10 @@ let clientVersion = null;
let fixture;
let passed = false;
let evidenceSeen = false;
let skillSeen = false;
let finalSeen = false;
let skillRequested = false;
let skillCallId;
let expectedSkillBody;
let searchCallId;
let analysisCallId;
let artifactReadByAgent = false;
@@ -133,8 +135,11 @@ await withVerifierCleanup(async () => {
assert(
result.plannedActions.every(
(a) =>
a.target.startsWith(profile + sep) ||
a.target.startsWith(sharedSkills + sep),
isAbsolute(a.target) &&
((relative(profile, a.target) !== "" &&
isPathWithinRoot(profile, a.target)) ||
(relative(sharedSkills, a.target) !== "" &&
isPathWithinRoot(sharedSkills, a.target))),
),
);
await writeFile(
@@ -162,6 +167,20 @@ await withVerifierCleanup(async () => {
command: "unrelated-server",
disabled: true,
});
const skillFile = join(
sharedSkills,
"reverse-engineer-anything",
"SKILL.md",
);
const normalizedSkill = (await readFile(skillFile, "utf8"))
.replace(/^\uFEFF/u, "")
.replace(/\r\n|\r/gu, "\n");
const frontmatter = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/u.exec(
normalizedSkill,
);
assert(frontmatter, "Installed skill must have authored frontmatter");
expectedSkillBody = frontmatter[2].trim();
assert(expectedSkillBody.length > 0);
clientPath = baseEnvironment.PATH;
if (reducedPath) {
assert.equal(
@@ -200,9 +219,6 @@ await withVerifierCleanup(async () => {
directory: lab,
prefix: `${client}-${mode}`,
onRequest: async ({ body, tools, requestIndex }) => {
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
"reverse-engineer-anything",
);
const messages = (body.messages ?? []).map((message) => ({
role: message.role,
callId: message.tool_call_id,
@@ -213,14 +229,14 @@ await withVerifierCleanup(async () => {
.map((part) => part.text ?? "")
.join("\n"),
}));
const messageText = messages.map((message) => message.text).join("\n");
const toolResponseText = (callId) =>
messages.find(
(message) => message.role === "tool" && message.callId === callId,
)?.text ?? "";
selectedSkillLoaded ||= messageText.includes(
join(sharedSkills, "reverse-engineer-anything"),
);
const expectedSkill = `Base directory for this skill: ${join(sharedSkills, "reverse-engineer-anything")}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${expectedSkillBody}\n`;
selectedSkillLoaded ||=
skillCallId !== undefined &&
toolResponseText(skillCallId).includes(expectedSkill);
const toolText = JSON.stringify(
(body.messages ?? []).filter((m) => m.role === "tool"),
);
@@ -298,6 +314,7 @@ await withVerifierCleanup(async () => {
} else if (skill && !skillRequested) {
skillRequested = true;
message = call(skill.name, { skill: "reverse-engineer-anything" });
skillCallId = message.tool_calls[0].id;
} else if (persisted && shell) {
artifactPath = persisted[1];
assert(
+6 -3
View File
@@ -32,8 +32,6 @@ const runCli = async (...arguments_) => {
return JSON.parse(stdout);
};
const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-"));
const projectPath = join(projectRoot, "smoke.rzdb");
const rizinQuote = (value) =>
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
const unavailableAnalysis = {
@@ -44,16 +42,21 @@ const unavailableAnalysis = {
},
};
const expectedPlatform = {
linux: "linux",
Linux: "linux",
macOS: "darwin",
Windows: "win32",
}[process.env.EXPECTED_PLATFORM];
if (expectedPlatform !== undefined)
if (process.env.EXPECTED_PLATFORM !== undefined) {
assert.ok(expectedPlatform, "Unrecognized Cutter runner platform.");
assert.equal(
process.platform,
expectedPlatform,
"Cutter runner did not match its declared platform.",
);
}
const projectRoot = await mkdtemp(join(tmpdir(), "rea-cutter-smoke-"));
const projectPath = join(projectRoot, "smoke.rzdb");
try {
const discovery = await runCli("list-cutter-sessions");
assert.equal(
+5 -1
View File
@@ -79,7 +79,11 @@ const output = {
windows: capture.windows,
verified:
capture.coverage.status === "partial_attach" &&
capture.actions.every(({ status }) => status === "completed") &&
capture.actions.length === input.actions.length &&
capture.actions.every(
({ step_id, status }, index) =>
step_id === input.actions[index].step_id && status === "completed",
) &&
capture.actions.some(
({ kind, window_index }) =>
kind === "renderer-reload" && window_index === 1,
+5 -4
View File
@@ -1,6 +1,6 @@
import { hasTypedSwitchEvidence } from "./verify-real-ghidra-switch-assertions.mjs";
import { access } from "node:fs/promises";
import { lstat } from "node:fs/promises";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { SUPPORTED_GHIDRA_VERSION } from "../dist/ghidra/GhidraReleasePolicy.js";
@@ -437,10 +437,11 @@ export function summary(observed) {
async function exists(path) {
try {
await access(path);
await lstat(path);
return true;
} catch {
return false;
} catch (cause) {
if (cause?.code === "ENOENT") return false;
throw cause;
}
}
+32 -1
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
requireMcpLifecycleResult,
requireMcpOutput,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
@@ -183,7 +184,8 @@ try {
item.includes("did not attempt a decode"),
),
);
const advertised = (await client.listTools()).tools.find(
const tools = (await client.listTools()).tools;
const advertised = tools.find(
(tool) => tool.name === "inspect_native_instruction",
);
assert.ok(advertised?.outputSchema);
@@ -198,6 +200,34 @@ try {
nativeInstructionSchema.safeParse({ ...unlisted, status: "undecodable" })
.success,
);
const contextDefinition = tools.find(
(tool) => tool.name === "inspect_address_context",
);
assert.ok(contextDefinition?.outputSchema);
const documents = await call("list_documents");
assert.equal(documents.length, 1);
const document = documents[0];
for (const input of [
{ address: fixture.entry, document: `${document}-not-a-program` },
{ address: "not-an-address", document },
]) {
const direct = requireMcpToolError(
await client.callTool({ name: "address_name", arguments: input }),
);
const response = await client.callTool(
{ name: "inspect_address_context", arguments: input },
{ timeout: 240000, toolDefinition: contextDefinition },
);
const context = requireMcpLifecycleResult(
response,
"inspect_address_context",
);
assert.equal(context.name.state, "unavailable");
assert.equal(context.name.error.code, "invalid_request");
assert.deepEqual(context.name.error.details.issues, direct.details.issues);
assert.equal(context.name.reason, direct.message);
assert.equal(context.name.remediation, direct.remediation.action);
}
assert.equal(
(await call("resolve_native_call_targets", { address: unlistedAddress }))
.status,
@@ -357,6 +387,7 @@ try {
clear_comments: true,
cli_mcp_parity: true,
},
context_input_errors_preserved: true,
original_unchanged: true,
address_truncation_rejected: true,
limitations: image.limitations,
+7
View File
@@ -125,6 +125,13 @@ try {
assert.equal(image.module_bytes, fixture.module_bytes);
assert.equal(image.entry.linear_address, expected.entry);
assert.ok(image.checks.every((check) => check.matched));
assert.ok(image.observations.relocations.length > 0);
assert.ok(
image.observations.relocations.every(
({ symbol }) => symbol === null || typeof symbol === "string",
),
"Each real Ghidra relocation must record its symbol observation",
);
const cliImage = await cliEvidence("inspect-native-load-image");
// Snapshot filenames are session-specific. Every measured byte/range/check is stable.
const stableImage = (value) => ({
+2 -4
View File
@@ -415,7 +415,7 @@ async function verifyRequestCancellationAndTimeout(client, entry) {
}
async function verifyConcurrentRequests(client, entry, leaf) {
const concurrent = await Promise.all([
await Promise.all([
functionCall(client, "procedure_info", {
document: null,
procedure: entry.address,
@@ -425,9 +425,7 @@ async function verifyConcurrentRequests(client, entry, leaf) {
address: leaf.address,
}),
]);
if (concurrent.length !== 2)
throw new Error("Ghidra serial request queue lost a concurrent result");
return "serialized-two-results";
return "two-concurrent-requests-completed";
}
/** Prove dense jump-table completeness on the optimized jump-table fixture. */
+6 -1
View File
@@ -375,8 +375,13 @@ try {
JSON.stringify(report, null, 2) + "\n",
);
}
const outcome = report.typed_failure
? "MCP refusal"
: mode === "default-native"
? "native load image without metadata recovery"
: "MCP metadata";
console.log(
`PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: MCP metadata, source identity and owned cleanup${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`,
`PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: ${outcome}, source unchanged and runtime directory empty${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`,
);
} catch (error) {
console.error(stderr);
+10
View File
@@ -32,6 +32,7 @@ import {
} from "./verify-real-ghidra-inventory.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { hasTypedSwitchEvidence } from "./verify-real-ghidra-switch-assertions.mjs";
import { verifyGhidraRelocationSymbols } from "./lib/real-ghidra-relocation-symbols.mjs";
import {
verifyDenseDefaultReturn,
@@ -281,6 +282,14 @@ try {
}
await writeFile(malformedPath, Buffer.from("not-a-binary\n", "utf8"));
const relocationSymbols = crossFormat
? await verifyGhidraRelocationSymbols({
clang,
lld,
env: process.env,
})
: null;
const debug = await verifyTarget(debugPath, "debug", expectedNativeTarget);
assertDebugFixture(debug);
const layout = await verifyTarget(
@@ -331,6 +340,7 @@ try {
? (crossResults[0]?.native_api_cli ?? null)
: jumpTable.native_api_cli,
native_value_e2e: debug.native_values,
relocation_symbols: relocationSymbols,
custom_target: custom === null ? null : summary(custom),
cleanup: "complete",
};
+31
View File
@@ -35,6 +35,7 @@ import {
verifyKunaStrings,
} from "./lib/kuna-string-verifier.mjs";
import { nativeKunaIncomingSites } from "./lib/kuna-native-reference-verifier.mjs";
import { MULTI_MODE_REMEDIATION } from "../dist/application/CapabilityInventory.js";
import {
assertVerifierCleanupComplete,
completeVerifierRun,
@@ -298,6 +299,36 @@ const verifyKuna = async (
const answers = [];
let coldFailure;
try {
const status = await cold.call("binary_session", { detail: "complete" });
if (status.value.format === "elf") {
const availability = status.value.tool_availability;
const dispatch = availability.find(
({ name }) => name === "inspect_native_dispatch_metadata",
);
const trace = availability.find(
({ name }) => name === "trace_native_ui_action",
);
assert.equal(dispatch?.available, false);
assert.equal(
trace?.modes?.find(({ name }) => name === "native_seed_call_trace")
?.available,
false,
);
if (process.platform === "darwin") {
const authored = trace?.modes?.find(
({ name }) => name === "authored_ui_dispatch",
);
const nativeSeed = trace?.modes?.find(
({ name }) => name === "native_seed_call_trace",
);
assert.equal(trace?.remediation, MULTI_MODE_REMEDIATION);
assert.notEqual(authored?.remediation, nativeSeed?.remediation);
assert.ok(
authored?.missing_operations.includes("decode_interface_builder"),
);
assert.ok(nativeSeed?.missing_operations.includes("list_names"));
}
}
answers.push({
strings: await verifyKunaStrings({
call: cold.call,
+2 -1
View File
@@ -55,7 +55,8 @@ try {
if (
report.missing.length ||
report.unexpected.length ||
report.duplicates.length
report.duplicates.length ||
report.wrongProjects.length
) {
console.error(JSON.stringify(report, null, 2));
process.exitCode = 1;
@@ -41,10 +41,18 @@ const [leftPath, rightPath] = await Promise.all([
]);
if (leftPath === rightPath)
throw new Error("--left and --right must resolve to distinct paths");
const [left, right] = await Promise.all([
const analyses = await Promise.allSettled([
analyze(options.left),
analyze(options.right),
]);
const failures = analyses.filter((result) => result.status === "rejected");
if (failures.length === 1) throw failures[0].reason;
if (failures.length > 1)
throw new AggregateError(
failures.map((result) => result.reason),
"Application analyses failed",
);
const [left, right] = analyses.map((result) => result.value);
const comparison = compareApplicationVersionsEvidence({ left, right });
if (!comparison.ok) throw comparison.error;
const comparisonResult = comparison.value.result;
@@ -1,8 +1,17 @@
#!/usr/bin/env node
import { readFile, realpath } from "node:fs/promises";
import { realpath } from "node:fs/promises";
import { parseArgs } from "node:util";
import { readStableArtifact } from "../../../dist/artifacts/readStableArtifact.js";
import { ArtifactReaderFailure } from "../../../dist/artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../../dist/artifacts/ArtifactResourceScope.js";
import {
ArtifactFileCleanupFailure,
artifactFileCompletion,
artifactFilePrimaryFacts,
} from "../../../dist/artifacts/ArtifactFileOperation.js";
import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js";
import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs";
import {
@@ -52,9 +61,11 @@ for (const encoded of options.mapping) {
const mapping = parseMapping(encoded, staticLayers.length);
staticLayers[mapping.layer].runtime_mappings.push(mapping.value);
}
const runtimeObservations = (
await Promise.all(options["runtime-evidence"].map(readEvidenceFile))
).flat();
const runtimeObservations = [];
for (const path of options["runtime-evidence"]) {
const observations = await readEvidenceFile(path);
for (const observation of observations) runtimeObservations.push(observation);
}
const reconciled = reconcileJavaScriptRuntimeEvidence({
static_layers: staticLayers,
runtime_observations: runtimeObservations,
@@ -95,10 +106,47 @@ process.stdout.write(
);
async function readEvidenceFile(path) {
const bytes = await readFile(await realpath(path));
if (bytes.byteLength > 64 * 1024 * 1024)
throw new Error(`Runtime Evidence exceeds 64 MiB: ${path}`);
const value = JSON.parse(bytes.toString("utf8"));
const scope = new ArtifactResourceScope();
let outcome;
try {
outcome = {
ok: true,
value: await readStableArtifact(
await realpath(path),
64 * 1024 * 1024,
undefined,
undefined,
scope,
),
};
} catch (cause) {
outcome = { ok: false, cause };
}
try {
await scope.close();
} catch (cleanupCause) {
if (!outcome.ok)
throw new AggregateError(
[outcome.cause, cleanupCause],
"Verifier cleanup failed",
{ cause: outcome.cause },
);
throw cleanupCause;
}
if (!outcome.ok && outcome.cause instanceof ArtifactFileCleanupFailure) {
const completion = artifactFileCompletion(outcome.cause);
outcome =
completion === undefined
? { ok: false, cause: artifactFilePrimaryFacts(outcome.cause).cause }
: { ok: true, value: completion.value };
}
if (!outcome.ok) {
const cause = outcome.cause;
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
throw new Error(`Runtime Evidence exceeds 64 MiB: ${path}`, { cause });
throw cause;
}
const value = JSON.parse(outcome.value.bytes.toString("utf8"));
return Array.isArray(value) ? value : [value];
}
+21 -10
View File
@@ -23,6 +23,12 @@ import type { JsonValue } from "../domain/jsonValue.js";
export const ADB_BINARY_ENV = "REA_ADB_PATH";
const VERSION_TIMEOUT_MS = 10_000;
const ADB_VERSION_REMEDIATION = `Set ${ADB_BINARY_ENV} to an Android SDK Platform-Tools adb binary whose \`adb version\` reports its version, or put one on PATH.`;
/** Repair when the selected adb binary does not exist. */
export const ADB_MISSING_REMEDIATION = `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary.`;
/** Repair when execution of the selected adb binary is denied. */
export const ADB_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary.`;
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
/** How the adb binary was selected for this provider instance. */
@@ -61,6 +67,7 @@ export const resolveAdbBinary = async (
throw new AdbConfigurationFailure(
"not_absolute",
`${ADB_BINARY_ENV} must be an absolute path to the adb binary`,
`Set ${ADB_BINARY_ENV} to an absolute adb executable path, or unset it to use adb from PATH.`,
{ configured_path: configured },
);
return {
@@ -93,10 +100,10 @@ const resolveWindowsAdb = async (
throw new AdbConfigurationFailure(
failure.kind === "missing" ? "binary_missing" : "binary_unresolved",
"The adb binary could not be selected from the Windows PATH/PATHEXT",
`Set ${ADB_BINARY_ENV} to an absolute adb executable, or add it to PATH/PATHEXT.`,
{
selected_binary: "adb",
path_source: "path",
remediation: `Set ${ADB_BINARY_ENV} to an absolute adb executable, or add it to PATH/PATHEXT`,
...(failure.kind === "invalid_extension"
? { invalid_extension: failure.extension }
: {}),
@@ -123,10 +130,12 @@ export type AdbConfigurationFailureCode =
| "binary_not_executable"
| "version_unresolved";
/** Why the selected adb binary is unusable, with the owner's repair instruction. */
export class AdbConfigurationFailure extends Error {
constructor(
readonly code: AdbConfigurationFailureCode,
message: string,
readonly remediation: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
@@ -181,6 +190,7 @@ export const inspectAdbClient = async (
throw new AdbConfigurationFailure(
"version_unresolved",
"The selected adb binary exited successfully but did not report a recognizable protocol or build version",
ADB_VERSION_REMEDIATION,
{
selected_binary: selection.binary,
path_source: selection.pathSource,
@@ -237,10 +247,10 @@ export const inspectAdbClient = async (
throw new AdbConfigurationFailure(
"binary_missing",
`The selected adb binary was not found: ${selection.binary}`,
ADB_MISSING_REMEDIATION,
{
selected_binary: selection.binary,
path_source: selection.pathSource,
remediation: `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary`,
},
{ cause },
);
@@ -248,17 +258,18 @@ export const inspectAdbClient = async (
throw new AdbConfigurationFailure(
"binary_not_executable",
`Execution of the selected adb binary was denied (${failure.code}): ${selection.binary}`,
ADB_NOT_EXECUTABLE_REMEDIATION,
{
selected_binary: selection.binary,
path_source: selection.pathSource,
filesystem_error: failure.code,
remediation: `Check file type and execute permission for ${ADB_BINARY_ENV} or select an executable adb binary`,
},
{ cause },
);
throw new AdbConfigurationFailure(
"version_unresolved",
"The selected adb binary did not report a usable version",
ADB_VERSION_REMEDIATION,
{
selected_binary: selection.binary,
...(failure === undefined
@@ -287,10 +298,10 @@ const rejectionCodeOf = (
export const adbConfigurationAvailability = (
cause: AdbConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;
): ProviderAvailability => ({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
remediation: cause.remediation,
diagnostics: { ...cause.diagnostics },
});
+2 -1
View File
@@ -591,7 +591,8 @@ it("reports a missing selected binary as a capability gap with recovery", async
const availability = await provider.inspectAvailability();
expect(availability).toMatchObject({ status: "unavailable" });
if (availability.status !== "unavailable") return;
expect(availability.diagnostics.remediation).toContain("REA_ADB_PATH");
expect(availability.remediation).toContain("REA_ADB_PATH");
expect(availability.diagnostics).not.toHaveProperty("remediation");
const result = await provider.execute({
operation: "list_adb_devices",
input: {},
+40 -59
View File
@@ -72,10 +72,10 @@ type ConfigurationFailureCode =
| "open_options_invalid";
/**
* Failure kinds tagged at the JADX configuration boundary. Consumers (the
* doctor adapter) project the boundary's remediation and classification for
* a kind; they must not reinterpret the phase/setting evidence to derive
* them.
* Failure kinds tagged at the JADX configuration boundary. Availability
* carries the boundary's remediation for a kind and the doctor adapter
* projects its classification; neither reinterprets the phase/setting
* evidence to derive them.
*/
export type JadxFailureKind =
| "windows_native_artifact"
@@ -101,72 +101,52 @@ export const JADX_FAILURE_KINDS: readonly JadxFailureKind[] = [
"unsupported_host",
];
export interface JadxFailureOutcome {
readonly remediation: string;
readonly classification: DoctorProviderCheck["classification"];
}
/** The boundary-owned repair instruction for one tagged failure kind. */
const jadxFailureRemediation = (kind: JadxFailureKind): string => {
switch (kind) {
case "windows_native_artifact":
case "windows_native_runtime":
return JADX_WINDOWS_NATIVE_REMEDIATION;
case "java_runtime":
case "java_home":
return JADX_JAVA_RUNTIME_REMEDIATION;
case "jvm_options":
return JADX_JVM_OPTIONS_REMEDIATION;
case "operation_timeout":
return JADX_OPERATION_TIMEOUT_REMEDIATION;
case "jar":
return JADX_JAR_CONFIGURATION_REMEDIATION;
case "java_probe":
return JADX_JAVA_PROBE_REMEDIATION;
case "unsupported_host":
return JADX_UNSUPPORTED_HOST_REMEDIATION;
}
};
/**
* Project the boundary-owned remediation and classification for a failure
* kind. A null kind (missing or corrupt tag) keeps JAR guidance, matching
* the previous fallthrough for unclassified failures.
* Project the doctor classification for a failure kind. A null kind (an
* untagged readiness failure) is classified as configuration drift.
*/
export const projectJadxFailureOutcome = (
export const jadxFailureClassification = (
kind: JadxFailureKind | null,
configured: boolean,
): JadxFailureOutcome => {
): DoctorProviderCheck["classification"] => {
const drift = configured ? "config_drift" : "missing_analysis_engine";
switch (kind) {
case "windows_native_artifact":
return {
remediation: JADX_WINDOWS_NATIVE_REMEDIATION,
classification: "missing_dependency",
};
case "windows_native_runtime":
return {
remediation: JADX_WINDOWS_NATIVE_REMEDIATION,
classification: "runtime_failure",
};
case "java_runtime":
return {
remediation: JADX_JAVA_RUNTIME_REMEDIATION,
classification: "missing_dependency",
};
case "java_home":
return {
remediation: JADX_JAVA_RUNTIME_REMEDIATION,
classification: drift,
};
case "jvm_options":
return {
remediation: JADX_JVM_OPTIONS_REMEDIATION,
classification: drift,
};
case "operation_timeout":
return {
remediation: JADX_OPERATION_TIMEOUT_REMEDIATION,
classification: drift,
};
case "jar":
return {
remediation: JADX_JAR_CONFIGURATION_REMEDIATION,
classification: drift,
};
case "java_probe":
return {
remediation: JADX_JAVA_PROBE_REMEDIATION,
classification: drift,
};
return "missing_dependency";
case "windows_native_runtime":
return "runtime_failure";
case "unsupported_host":
return {
remediation: JADX_UNSUPPORTED_HOST_REMEDIATION,
classification: "unsupported_host",
};
return "unsupported_host";
case "java_home":
case "jvm_options":
case "operation_timeout":
case "jar":
case "java_probe":
case null:
return {
remediation: JADX_JAR_CONFIGURATION_REMEDIATION,
classification: drift,
};
return drift;
}
};
@@ -227,6 +207,7 @@ export const inspectJadxAvailability = async (
status: "unavailable",
code: cause.code,
reason: cause.message,
remediation: jadxFailureRemediation(cause.kind),
diagnostics: { ...cause.diagnostics, failure_kind: cause.kind },
};
const prerequisite = ownedProcessPrerequisiteAvailability(cause);
+11 -7
View File
@@ -8,9 +8,10 @@ import type { JsonValue } from "../domain/jsonValue.js";
import {
inspectJadxAvailability,
JADX_FAILURE_KINDS,
projectJadxFailureOutcome,
jadxFailureClassification,
type JadxFailureKind,
} from "./JadxConfiguration.js";
import { JADX_JAR_CONFIGURATION_REMEDIATION } from "./JadxRelease.js";
/** Project the validated JADX configuration into the doctor contract. */
export const inspectJadxProvider = async (
@@ -93,14 +94,14 @@ const configurationCheck = (
: {}),
};
}
// The failure kind is tagged at the configuration boundary; project its
// remediation and classification here instead of reinterpreting the
// phase/setting evidence. Every failed check keeps a remediation.
// Availability carries the configuration boundary's remediation and tags
// the failure kind; project its classification here instead of
// reinterpreting the phase/setting evidence. Every failed check keeps a
// remediation.
const kind =
availability.status === "unavailable"
? asFailureKind(availability.diagnostics["failure_kind"])
: null;
const outcome = projectJadxFailureOutcome(kind, configured);
return {
name: "configuration",
ok: false,
@@ -109,8 +110,11 @@ const configurationCheck = (
availability.status === "available"
? "Validated JADX configuration reported no JAR path."
: availability.reason,
remediation: outcome.remediation,
classification: outcome.classification,
remediation:
availability.status === "available"
? JADX_JAR_CONFIGURATION_REMEDIATION
: availability.remediation,
classification: jadxFailureClassification(kind, configured),
...(kind === "java_probe" && availability.status === "unavailable"
? { details: probeDetails(availability.diagnostics) }
: kind !== null &&
+1 -1
View File
@@ -271,9 +271,9 @@ describe("apktoolInvocation", () => {
expect(apktoolConfigurationAvailability(failure)).toMatchObject({
status: "unavailable",
code: "not_configured",
remediation: expect.stringContaining(APKTOOL_COMMAND_ENV),
diagnostics: {
selected_command: "C:\\tools & more\\apktool.cmd",
remediation: expect.stringContaining(APKTOOL_COMMAND_ENV),
},
});
});
+28 -14
View File
@@ -46,6 +46,12 @@ const apktoolVersionProbeTimeoutGuidance = (): {
remediationAction: `This ${String(VERSION_TIMEOUT_MS)} ms apktool version probe has no runtime override. decode_timeout_ms does not raise it. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
});
const APKTOOL_VERSION_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an Apktool 2.7.0 or newer launcher whose --version prints its version, or put one on PATH.`;
/** Repair when the selected apktool launcher does not exist. */
export const APKTOOL_MISSING_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script).`;
/** Repair when execution of the selected apktool launcher is denied. */
export const APKTOOL_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher.`;
/** How the apktool launcher was selected for this provider instance. */
export interface ApktoolCommandSelection {
readonly command: string;
@@ -77,6 +83,7 @@ export const resolveApktoolCommand = async (
throw new ApktoolConfigurationFailure(
"not_absolute",
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
`Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher path, or unset it to use apktool from PATH.`,
{ configured_command: configured },
);
return { command: configured, commandSource: "environment" };
@@ -106,6 +113,7 @@ export const resolveApktoolCommand = async (
throw new ApktoolConfigurationFailure(
"command_unresolved",
"Windows PATHEXT must contain filename extensions, not paths",
`Correct PATHEXT so it lists filename extensions only, or set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher.`,
{
selected_command: "apktool",
command_source: "path",
@@ -116,6 +124,7 @@ export const resolveApktoolCommand = async (
throw new ApktoolConfigurationFailure(
"command_unresolved",
`The PATH-selected apktool launcher could not be inspected: ${failure.path}`,
`Make the PATH-selected apktool launcher readable, or set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher.`,
{
selected_command: "apktool",
command_source: "path",
@@ -131,10 +140,10 @@ export const resolveApktoolCommand = async (
throw new ApktoolConfigurationFailure(
"command_missing",
"The apktool launcher was not found on the selected Windows PATH/PATHEXT",
`Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT.`,
{
selected_command: "apktool",
command_source: "path",
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher, or add its directory and extension to PATH and PATHEXT`,
},
);
};
@@ -148,10 +157,12 @@ export type ApktoolConfigurationFailureCode =
| "version_unresolved"
| "unsupported_version";
/** Why the selected apktool launcher is unusable, with the owner's repair instruction. */
export class ApktoolConfigurationFailure extends Error {
constructor(
readonly code: ApktoolConfigurationFailureCode,
message: string,
readonly remediation: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
@@ -287,10 +298,10 @@ export const inspectApktoolClient = async (
throw new ApktoolConfigurationFailure(
"launcher_unsupported",
`The selected apktool launcher cannot be run through cmd.exe: ${cause.message}`,
`Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable.`,
{
selected_command: selection.command,
command_source: selection.commandSource,
remediation: `Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable`,
},
{ cause },
);
@@ -349,6 +360,7 @@ export const inspectApktoolClient = async (
: new ApktoolConfigurationFailure(
recordedFailure.code,
recordedFailure.message,
recordedFailure.remediation,
{
...recordedFailure.diagnostics,
...(cancellation === undefined
@@ -376,6 +388,7 @@ export const inspectApktoolClient = async (
throw new ApktoolConfigurationFailure(
"version_unresolved",
"The owned version command completed without an output observation",
APKTOOL_VERSION_REMEDIATION,
{ selected_command: selection.command },
);
if (!interpreted.ok) throw interpreted.error;
@@ -393,6 +406,7 @@ const interpretApktoolVersion = (
throw new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher exited successfully but did not report a recognizable version",
APKTOOL_VERSION_REMEDIATION,
{
selected_command: selection.command,
command_source: selection.commandSource,
@@ -406,12 +420,12 @@ const interpretApktoolVersion = (
throw new ApktoolConfigurationFailure(
"unsupported_version",
`The selected Apktool version ${identity.version} is older than the supported minimum 2.7.0`,
`Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
{
selected_command: selection.command,
command_source: selection.commandSource,
reported_version: identity.version,
minimum_version: "2.7.0",
remediation: `Use Apktool 2.7.0 or newer; see docs/apktool-resource-analysis.md.`,
},
);
return identity;
@@ -434,13 +448,13 @@ const rejectionCodeOf = (
export const apktoolConfigurationAvailability = (
cause: ApktoolConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;
): ProviderAvailability => ({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
remediation: cause.remediation,
diagnostics: { ...cause.diagnostics },
});
/** Interpret the command owner's outcome, without inferring a new failure from a late signal. */
const versionProbeFailure = (
@@ -501,14 +515,13 @@ const versionProbeFailure = (
cause.code === "EACCES"
? `Execution of the selected apktool launcher was denied: ${selection.command}`
: `The selected apktool launcher was not found: ${selection.command}`,
cause.code === "EACCES"
? APKTOOL_NOT_EXECUTABLE_REMEDIATION
: APKTOOL_MISSING_REMEDIATION,
{
filesystem_error: cause.code,
selected_command: selection.command,
command_source: selection.commandSource,
remediation:
cause.code === "EACCES"
? `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher`
: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
},
{ cause },
);
@@ -535,6 +548,7 @@ const versionProbeFailure = (
return new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher did not report a usable version",
APKTOOL_VERSION_REMEDIATION,
{
selected_command: selection.command,
...(observed === undefined
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { createDeferred } from "../../tests/fixtures/binarySession.js";
import {
getNavigationContext,
@@ -9,10 +10,19 @@ import {
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisOutputError,
AnalysisProtocolError,
} from "../domain/analysisErrorCore.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
primaryAnalysisError,
type AnalysisError,
} from "../domain/analysisErrorBase.js";
import type { AnalysisExecution } from "./AnalysisProvider.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok } from "../domain/result.js";
@@ -437,3 +447,262 @@ it("does not start address facets after document selection completes under cance
expect(result.error.cause).toBe(reason);
expect(calls).toEqual(["current_document"]);
});
it("retains both facet cleanup owners until their independent retries succeed", async () => {
const first = new AnalysisCapabilityUnavailableError(
provider.id,
"comment",
"failed",
{
cleanup: { reason: "first release failed", resources: ["first-owner"] },
},
);
const second = new AnalysisCapabilityUnavailableError(
provider.id,
"inline_comment",
"failed",
{
cleanup: { reason: "second release failed", resources: ["second-owner"] },
},
);
let firstRetryAllowed = false;
const firstLease = new AnalysisCleanupLease(async () => {
if (!firstRetryAllowed) throw new Error("still owned");
}, ["first-owner"]);
const secondLease = new AnalysisCleanupLease(async () => {}, [
"second-owner",
]);
firstLease.bindCleanupError(first);
secondLease.bindCleanupError(second);
const result = await inspectAddressContext(
{
execute: async (operation) =>
operation === "comment"
? err(first)
: operation === "inline_comment"
? err(second)
: ok(createAnalysisExecution(null, provider)),
},
{ address: "0x401000", document: "fixture" },
);
if (result.ok) throw new Error("expected retained cleanup failures");
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
expect(result.error.cleanupResources).toEqual(
expect.arrayContaining(["first-owner", "second-owner"]),
);
await expect(firstLease.release()).rejects.toThrow("still owned");
expect(result.error.cleanupResources).toEqual(
expect.arrayContaining(["first-owner", "second-owner"]),
);
await secondLease.release();
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual(["first-owner"]);
firstRetryAllowed = true;
await firstLease.release();
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(projectAnalysisError(result.error).code).toBe(
"capability_unavailable",
);
});
it("waits for late facet cleanup after cancellation and retains its owner", async () => {
const controller = new AbortController();
const started = createDeferred<void>();
const late = createDeferred<Result<AnalysisExecution, AnalysisError>>();
const failure = new AnalysisCapabilityUnavailableError(
provider.id,
"comment",
"failed",
{
cleanup: { reason: "late cleanup failed", resources: ["late-owner"] },
},
);
const lease = new AnalysisCleanupLease(async () => {}, ["late-owner"]);
lease.bindCleanupError(failure);
const analysis: AnalysisOperationPort = {
execute: (operation) => {
if (operation === "comment") {
started.resolve();
return late.promise;
}
return Promise.resolve(ok(createAnalysisExecution(null, provider)));
},
};
let settled = false;
const pending = inspectAddressContext(
analysis,
{ address: "0x401000", document: "fixture" },
controller.signal,
).then((result) => {
settled = true;
return result;
});
await started.promise;
controller.abort();
await Promise.resolve();
expect(settled).toBe(false);
late.resolve(err(failure));
const result = await pending;
if (result.ok) throw new Error("expected cancellation with cleanup failure");
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
expect(result.error.cleanupResources).toEqual(["late-owner"]);
await lease.release();
expect(projectAnalysisError(result.error).code).toBe("cancelled");
});
it("reports cancellation after a late successful facet that ignored its signal", async () => {
const controller = new AbortController();
const started = createDeferred<void>();
const late = createDeferred<Result<AnalysisExecution, AnalysisError>>();
const pending = inspectAddressContext(
{
execute: (operation) => {
if (operation === "comment") {
started.resolve();
return late.promise;
}
return Promise.resolve(ok(createAnalysisExecution(null, provider)));
},
},
{ address: "0x401000", document: "fixture" },
controller.signal,
);
await started.promise;
controller.abort();
late.resolve(ok(createAnalysisExecution("finished", provider)));
const result = await pending;
if (result.ok) throw new Error("expected cancellation");
expect(projectAnalysisError(result.error).code).toBe("cancelled");
});
it("does not start facets for an already-cancelled explicit document", async () => {
const controller = new AbortController();
controller.abort();
const calls: string[] = [];
const result = await inspectAddressContext(
{
execute: async (operation) => {
calls.push(operation);
return ok(createAnalysisExecution(null, provider));
},
},
{ address: "0x401000", document: "fixture" },
controller.signal,
);
if (result.ok) throw new Error("expected cancellation");
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(calls).toEqual([]);
});
it("preserves a provider's cancellation for the same signal", async () => {
const controller = new AbortController();
const started = createDeferred<void>();
const reason = { request: "controlled cancellation" };
let providerCancellation: AnalysisCancelledError | undefined;
const pending = inspectAddressContext(
{
execute: (operation) => {
if (operation !== "address_name")
return Promise.resolve(ok(createAnalysisExecution(null, provider)));
started.resolve();
return new Promise((resolve) => {
controller.signal.addEventListener(
"abort",
() => {
providerCancellation = new AnalysisCancelledError(operation, {
cause: controller.signal.reason,
diagnostics: { owner: "controlled provider" },
});
resolve(err(providerCancellation));
},
{ once: true },
);
});
},
},
{ address: "0x401000", document: "fixture" },
controller.signal,
);
await started.promise;
controller.abort(reason);
const result = await pending;
if (result.ok) throw new Error("expected cancellation");
expect(result.error).toBe(providerCancellation);
expect(projectAnalysisError(result.error).details).toMatchObject({
diagnostics: { owner: "controlled provider" },
});
});
it.each([false, true])(
"keeps an earlier cleanup failure primary when cancellation arrives later (cancellation cleanup=%s)",
async (cancelCleanup) => {
const controller = new AbortController();
const firstDone = createDeferred<void>();
const failure = new AnalysisCapabilityUnavailableError(
provider.id,
"comment",
"failed",
{
cleanup: { reason: "cleanup failed", resources: ["first-owner"] },
},
);
const lease = new AnalysisCleanupLease(async () => {}, ["first-owner"]);
lease.bindCleanupError(failure);
const cancelled = new AnalysisCancelledError(
"address_name",
cancelCleanup
? {
cleanup: {
reason: "cancel cleanup failed",
resources: ["cancel-owner"],
},
}
: undefined,
);
const cancellationLease = new AnalysisCleanupLease(async () => {}, [
"cancel-owner",
]);
if (cancelCleanup) cancellationLease.bindCleanupError(cancelled);
const analysis: AnalysisOperationPort = {
execute: async (operation) => {
if (operation === "address_name")
return new Promise((resolve) => {
controller.signal.addEventListener(
"abort",
() => resolve(err(cancelled)),
{ once: true },
);
});
if (operation === "comment") {
firstDone.resolve();
return err(failure);
}
return ok(createAnalysisExecution(null, provider));
},
};
const pending = inspectAddressContext(
analysis,
{ address: "0x401000", document: "fixture" },
controller.signal,
);
await firstDone.promise;
await Promise.resolve();
await Promise.resolve();
controller.abort();
const result = await pending;
if (result.ok) throw new Error("expected retained cleanup failure");
expect(primaryAnalysisError(result.error)).toBe(failure);
expect(failure.secondaryFailures).toContain(cancelled);
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
await lease.release();
if (cancelCleanup) {
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual(["cancel-owner"]);
await cancellationLease.release();
}
expect(projectAnalysisError(result.error).code).toBe(
"capability_unavailable",
);
},
);
+114 -17
View File
@@ -4,7 +4,17 @@ import type {
AnalysisOperationPort,
} from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import {
projectAnalysisError,
type AnalysisErrorProjection,
} from "../domain/analysisErrorProjection.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { analysisBookmarkSchema } from "../domain/native/nativeInventory.js";
import { parseContainingProcedureResolution } from "../domain/native/nativeObservation.js";
@@ -18,6 +28,7 @@ type Facet =
readonly state: "unavailable";
readonly reason: string;
readonly remediation: string;
readonly error?: AnalysisErrorProjection;
};
const parameters = (
@@ -98,18 +109,39 @@ export const inspectAddressContext = async (
"inline_comment",
"list_bookmarks",
] as const satisfies readonly AnalysisOperation[];
const results = await Promise.all(
operations.map((operation) =>
analysis.execute(
operation,
operation === "list_bookmarks" ? parameters(document.value) : args,
executionOptions(signal),
),
),
);
for (const result of results) {
if (!result.ok && isRequestLevelFailure(result.error)) return result;
const completed: Result<AnalysisExecution, AnalysisError>[] = [];
if (signal?.aborted === true)
return err(
new AnalysisCancelledError("inspect_address_context", {
cause: signal.reason,
}),
);
let signalCancellation: AnalysisCancelledError | undefined;
const cancelled = () => {
signalCancellation = new AnalysisCancelledError("inspect_address_context", {
cause: signal?.reason,
});
completed.push(err(signalCancellation));
};
signal?.addEventListener("abort", cancelled, { once: true });
let results: Result<AnalysisExecution, AnalysisError>[];
try {
results = await Promise.all(
operations.map(async (operation) => {
const result = await analysis.execute(
operation,
operation === "list_bookmarks" ? parameters(document.value) : args,
executionOptions(signal),
);
completed.push(result);
return result;
}),
);
} finally {
signal?.removeEventListener("abort", cancelled);
}
const fatal = contextFailure(completed, signalCancellation);
if (fatal !== undefined) return err(fatal);
const name = facet(results[0], operations[0]);
const procedureResult = results[1];
const resolution = procedureResult?.ok
@@ -149,17 +181,82 @@ const facet = (
const unavailableFacet = (
error: AnalysisError | undefined,
operation: AnalysisOperation,
): Facet => ({
state: "unavailable",
reason: error?.message ?? `${operation} returned no result`,
remediation: `Call ${operation} directly to inspect provider availability and diagnostics.`,
});
): Facet => {
if (error instanceof AnalysisInputError) {
const projected = projectAnalysisError(error);
if (projected.code === "invalid_request")
return {
state: "unavailable",
reason: projected.message,
remediation: projected.remediation.action,
error: projected,
};
}
return {
state: "unavailable",
reason: error?.message ?? `${operation} returned no result`,
remediation: `Call ${operation} directly to inspect provider availability and diagnostics.`,
};
};
const isRequestLevelFailure = (error: AnalysisError): boolean =>
error._tag === "AnalysisCancelledError" ||
error._tag === "AnalysisTimeoutError" ||
error._tag === "NoBinaryOpenError";
/** Wait for all facets and retain every unresolved cleanup owner. */
const contextFailure = (
results: readonly Result<AnalysisExecution, AnalysisError>[],
signalCancellation?: AnalysisCancelledError,
): AnalysisError | undefined => {
const failures = [
...new Set(results.flatMap((result) => (result.ok ? [] : [result.error]))),
];
// A provider's cancellation for this exact signal carries its own diagnostic
// and cleanup facts. Attribute that returned error to the abort event rather
// than replacing it with our generic observation of the same signal.
const marker =
signalCancellation === undefined
? -1
: failures.indexOf(signalCancellation);
if (marker >= 0) {
const reported = failures.findIndex(
(failure, index) =>
index > marker &&
failure instanceof AnalysisCancelledError &&
failure.cause === signalCancellation?.cause,
);
if (reported >= 0) {
failures[marker] = failures[reported]!;
failures.splice(reported, 1);
}
}
const first = failures.find(
(failure) => isRequestLevelFailure(failure) || failure.cleanupIncomplete,
);
if (first === undefined) return undefined;
let combined = first;
for (const failure of failures) {
if (failure === first) continue;
if (failure instanceof AnalysisCancelledError)
combined = analysisErrorWithCancellation(combined, failure);
if (failure.cleanupIncomplete) {
const previous = combined;
combined = analysisErrorWithCleanupFailure(
previous,
failure,
"inspect_address_context",
).bindCleanupDependencies([previous, failure]);
} else if (
!(failure instanceof AnalysisCancelledError) &&
isRequestLevelFailure(failure)
) {
combined.retainSecondaryFailure(failure);
}
}
return combined;
};
const matchingBookmarks = (value: JsonValue, address: string): Facet => {
if (!Array.isArray(value))
return {
+127 -1
View File
@@ -1,6 +1,7 @@
import { readArtifactInspectionSources } from "../domain/artifactInspection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { Evidence } from "../domain/evidence.js";
import { tryParseEvidence, type Evidence } from "../domain/evidence.js";
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
import { err, ok, type Result } from "../domain/result.js";
/** Resolve the embedded publication closure without reinterpreting it in delivery adapters. */
@@ -15,3 +16,128 @@ export const analysisEvidenceSources = (
return err(embedded.error.retainPartialObservation(complete));
return ok([...embedded.value, complete]);
};
/** Matches the diagnostics allowance retained for other finalized one-shot work. */
const SOURCE_CLOSURE_INLINE_BYTES = 64 * 1024;
/** Identities beyond this stay counted; direct links remain on the observation. */
const SOURCE_CLOSURE_LISTED_OMISSIONS = 64;
const SOURCE_CLOSURE_RECOVERY =
"No bundle was written and this invocation retains nothing after exit. Keep any records shown here, or run the operation again after following remediation; that is a new observation and may differ.";
/** What a failed one-shot delivery holds for one record its observation cites. */
export type SourceClosureOmission = {
readonly evidence_id: string;
/**
* `not_captured`: this invocation holds no such record, so none can be shown.
* `not_inlined`: captured, but not part of this bounded failure response.
*/
readonly reason: "not_captured" | "not_inlined";
};
/** Bounded failure handoff for a completed observation whose bundle never committed. */
export type UndeliveredSourceClosure = {
readonly publication: "not_published";
/** Every cited record is in `records` or embedded in the observation itself. */
readonly complete: boolean;
readonly records: Evidence[];
readonly omitted_count: number;
readonly omitted: SourceClosureOmission[];
readonly recovery: string;
};
/** The closure state is unknown because examining it failed. */
export type UnavailableSourceClosure = {
readonly publication: "not_published";
readonly complete: false;
readonly collection_failure: string;
readonly recovery: string;
};
const collectSourceClosure = (
observation: Evidence,
captured: readonly Evidence[],
options: { readonly inlineRecords: boolean },
): UndeliveredSourceClosure | undefined => {
const inspection = analysisEvidenceSources(observation);
const embedded = new Map(
(inspection.ok ? inspection.value : []).map((record) => [
record.evidence_id,
record,
]),
);
const held = new Map(captured.map((record) => [record.evidence_id, record]));
// Set iteration reaches entries added while it runs, which makes this a
// deduplicating breadth-first queue: directly cited records spend the
// allowance first, and each link costs one lookup however often it repeats.
const closure = new Set([observation.evidence_id]);
for (const evidenceId of observation.evidence_links) closure.add(evidenceId);
for (const evidenceId of held.keys()) closure.add(evidenceId);
const records: Evidence[] = [];
const omitted: SourceClosureOmission[] = [];
let omittedCount = 0;
let inlineBytes = options.inlineRecords ? SOURCE_CLOSURE_INLINE_BYTES : 0;
const omit = (
evidenceId: string,
reason: SourceClosureOmission["reason"],
) => {
omittedCount += 1;
if (omitted.length < SOURCE_CLOSURE_LISTED_OMISSIONS)
omitted.push({ evidence_id: evidenceId, reason });
};
for (const evidenceId of closure) {
if (evidenceId === observation.evidence_id) continue;
const inObservation = embedded.get(evidenceId);
const record = inObservation ?? held.get(evidenceId);
if (record === undefined) {
omit(evidenceId, "not_captured");
continue;
}
for (const link of record.evidence_links) closure.add(link);
if (inObservation !== undefined) continue;
const bytes = jsonValueByteLength(record, inlineBytes);
if (bytes > inlineBytes) {
omit(evidenceId, "not_inlined");
continue;
}
records.push(record);
inlineBytes -= bytes;
}
if (closure.size === 1) return undefined;
return {
publication: "not_published",
complete: omittedCount === 0,
records,
omitted_count: omittedCount,
omitted,
recovery: SOURCE_CLOSURE_RECOVERY,
};
};
/**
* Describe the source closure of a completed observation after delivery failed
* before commit. Only already captured records are reported, so an unresolved
* link stays an explicit omission and its own ancestors remain unknown.
*
* This annotates a failure that must reach the caller, so it never throws. If
* examining the closure fails, the state is reported as unknown, naming only
* the bounded error type rather than a message that could quote analyzed data.
*/
export const undeliveredSourceClosure = (
partialObservation: unknown,
captured: readonly Evidence[],
options: { readonly inlineRecords: boolean },
): UndeliveredSourceClosure | UnavailableSourceClosure | undefined => {
try {
const observation = tryParseEvidence(partialObservation);
return observation === undefined
? undefined
: collectSourceClosure(observation, captured, options);
} catch (cause: unknown) {
return {
publication: "not_published",
complete: false,
collection_failure: cause instanceof Error ? cause.name : "unknown",
recovery: SOURCE_CLOSURE_RECOVERY,
};
}
};
+11
View File
@@ -2,6 +2,7 @@ import type { AnalysisWorkScope } from "./AnalysisWorkScope.js";
import type { AnalysisObservation } from "../domain/analysisObservation.js";
import type { ProviderInspectionScope } from "./ProviderInspectionScope.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { TargetApplicability } from "../domain/targetApplicability.js";
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
import type { NativeToolName } from "../contracts/native/nativeToolContracts.js";
@@ -257,9 +258,16 @@ type CapabilityAvailability =
readonly available: false;
readonly reason: string;
readonly availabilityCode?: ProviderRejectionCode;
/**
* Repair instruction from the readiness owner that made this operation
* unavailable. Static capability facts omit it.
*/
readonly remediation?: string;
};
export type CapabilityDescriptor = CapabilityAvailability & {
/** Provider-owned static target requirements, evaluated for an open target. */
readonly targetApplicability?: TargetApplicability;
/**
* Snapshot opts immutable, profile-bound reads into replay despite incidental
* private filesystem writes. Live state must always be re-observed. Omitting
@@ -301,7 +309,10 @@ export type ProviderAvailability =
| {
readonly status: "unavailable";
readonly code: ProviderRejectionCode;
/** What the owner observed; not a repair instruction. */
readonly reason: string;
/** The owner's repair instruction for this exact failure. */
readonly remediation: string;
readonly diagnostics: Readonly<Record<string, JsonValue>>;
};
+17 -16
View File
@@ -461,10 +461,11 @@ const resolveLayoutExecutable = async (
let name: string;
try {
name = hasBinaryPlistSignature(plist)
? await (fileSystem.decodeBinaryPlist?.(plistPath) ??
parseBinaryPlistExecutable(plist))
: parseXmlPlistExecutable(decodeXmlPlistText(plist));
name =
hasBinaryPlistSignature(plist) &&
fileSystem.decodeBinaryPlist !== undefined
? await fileSystem.decodeBinaryPlist(plistPath)
: executableName(decodeInfoPlistValue(plist));
} catch (cause: unknown) {
return err(
new BinaryTargetError(
@@ -546,12 +547,18 @@ const resolveLayoutExecutable = async (
}
};
/** Decode the top-level executable name with an XML parser, not a pattern. */
const parseXmlPlistExecutable = (plist: string): string => {
// An unrelated `__proto__` entry must not make the bundle unreadable.
const { value } = parseXmlPropertyList(plist);
return executableName(value);
};
/**
* Decode one Info.plist's top-level value in-process, binary or XML, so no
* host plist tool is required. Values are read with format-aware parsers, not
* patterns; an unrelated `__proto__` entry must not make the bundle unreadable.
*/
export const decodeInfoPlistValue = (plist: Buffer): unknown =>
hasBinaryPlistSignature(plist)
? decodeBinaryPlistValue(
plist,
preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES),
)
: parseXmlPropertyList(decodeXmlPlistText(plist)).value;
/** The Info.plist decoded but declares no string CFBundleExecutable. */
class UndeclaredBundleExecutable extends Error {}
@@ -565,12 +572,6 @@ const executableName = (value: unknown): string => {
const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
/** Decode a binary plist in-process, so no host plist tool is required. */
const parseBinaryPlistExecutable = (plist: Buffer): string => {
const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
return executableName(decodeBinaryPlistValue(plist, plan));
};
/** Names are one local path component; backslash is literal on POSIX hosts. */
const isSafeExecutableName = (name: string): boolean =>
name.length > 0 &&
+6 -8
View File
@@ -23,7 +23,7 @@ import {
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { childFailureDiagnostics } from "../domain/analysisErrorProjection.js";
import { constants, type Stats } from "node:fs";
import { createHash } from "node:crypto";
import { access, realpath, stat, type FileHandle } from "node:fs/promises";
@@ -607,14 +607,12 @@ const targetFileCleanupError = (
: String(failure.cleanupCause),
resources: [failure.path],
},
diagnostics: {
...(primary === undefined
...childFailureDiagnostics(
primary === undefined ? {} : { primary_error: { error: primary } },
completedTarget === undefined
? {}
: { primary_error: projectAnalysisError(primary) }),
...(completedTarget === undefined
? {}
: { completed_target: completedTarget }),
},
: { completed_target: completedTarget },
),
},
);
bindRegularFileCleanup(failure, error);
@@ -114,6 +114,38 @@ it.each([
},
);
it("retains completed scenario Evidence when cancellation arrives during capture", async () => {
const caller = new AbortController();
const reason = { requested: "stop after capture" };
const provider: BrowserScenarioCapturePort = {
identity: () => scenarioIdentity,
captureScenario: async (received, options) => {
expect(received).toBe(scenario);
expect(options?.signal).toBe(caller.signal);
caller.abort(reason);
return ok(capture);
},
};
const result = await captureBrowserScenario(provider, scenario, {
signal: caller.signal,
});
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
operation: "capture_browser_scenario",
provider: scenarioIdentity,
confidence: "observed",
authority: "controlled-replay",
parameters: {
browser: scenario.browser,
start_url: scenario.start_url,
actions: scenario.actions,
},
normalized_result: capture,
});
});
it("keeps startup cancellation and its incomplete capture", async () => {
const reason = { requested: "stop during initial navigation" };
const completed = scriptScenarioFixture();
+42 -39
View File
@@ -1,17 +1,22 @@
import type { CallRelationshipDirection } from "../contracts/enhancedInputs.js";
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
import {
cleanupStoppedTraversal,
readCallRelationships,
type CallRelationshipObserver,
} from "./CallRelationshipTraversal.js";
import type {
AnalysisExecution,
AnalysisOperation,
ExecutionOptions,
} from "./AnalysisProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { parseRelatedAddresses } from "../domain/native/nativeObservation.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type { EnhancedResult } from "./EnhancedToolTypes.js";
import {
failedWithPartialResult,
type EnhancedResult,
} from "./EnhancedToolTypes.js";
import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
@@ -22,13 +27,6 @@ type AnalysisCall = (
selectorOrigin?: ExecutionOptions["selectorOrigin"],
) => Promise<Result<JsonValue, AnalysisError>>;
type RelationshipCall = (
name: AnalysisOperation,
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
selectorOrigin?: ExecutionOptions["selectorOrigin"],
) => Promise<Result<AnalysisExecution, AnalysisError>>;
export interface CallPathTraceInput {
readonly start: string;
readonly goal?: string | undefined;
@@ -80,7 +78,7 @@ interface DiscoveredEdge {
/** Trace direct caller/callee relationships until the graph is exhausted or the goal is reached. */
export const traceCallPath = async (
call: AnalysisCall,
callRelationship: RelationshipCall,
callRelationship: CallRelationshipObserver,
input: CallPathTraceInput,
signal?: AbortSignal,
): EnhancedResult => {
@@ -123,7 +121,16 @@ export const traceCallPath = async (
current,
...(signal === undefined ? {} : { signal }),
});
if (!expanded.ok) return expanded;
if (!expanded.ok) {
if (!expanded.error.cleanupIncomplete) return expanded;
state.residual.add(
cleanupStoppedTraversal(address, state.queue.length - state.queueIndex),
);
return failedWithPartialResult(
expanded.error,
projectTrace(resolvedInput, state),
);
}
if (expanded.value) break;
}
if (isAborted(signal))
@@ -148,44 +155,40 @@ const createTraceState = (input: CallPathTraceInput): TraceState => ({
});
const expandAddress = async (
call: RelationshipCall,
call: CallRelationshipObserver,
expansion: AddressExpansion,
): Promise<Result<boolean, AnalysisError>> => {
const { input, state, address, current, signal } = expansion;
const { relation, operation: tool } =
CALL_RELATIONSHIP_TRAVERSALS[input.direction];
const result = await call(tool, { procedure: address }, signal, "provider");
if (!result.ok) {
if (
result.error._tag === "AnalysisCancelledError" ||
signal?.aborted === true
)
return result;
}
const read = await readCallRelationships(
call,
input.direction,
address,
signal,
);
// A provider failure delivered with cancellation remains the selected outcome.
if (
read.status === "stopped" ||
(read.status === "unavailable" && isAborted(signal))
)
return err(read.error);
if (isAborted(signal))
return err(
new AnalysisCancelledError("trace_call_path", { cause: signal?.reason }),
);
if (!result.ok) {
if (read.status !== "observed") {
state.failures.push({
address,
error: projectAnalysisError(result.error),
error: projectAnalysisError(read.error),
});
state.residual.add(`Call relationships were unavailable for ${address}.`);
state.residual.add(
`Call relationships were ${read.status} for ${address}.`,
);
return ok(false);
}
const related = parseRelatedAddresses(result.value.result, relation, tool);
if (!related.ok) {
state.failures.push({
address,
error: projectAnalysisError(related.error),
});
state.residual.add(`Call relationships were unreadable for ${address}.`);
return ok(false);
}
for (const limitation of result.value.limitations)
const { execution, related } = read;
for (const limitation of execution.limitations)
state.limitations.add(limitation);
const coverage = result.value.relationshipCoverage ?? {
const coverage = execution.relationshipCoverage ?? {
status: "unknown",
reason: "Provider did not report relationship coverage.",
};
@@ -204,7 +207,7 @@ const expandAddress = async (
state.residual.add(
`Call relationship coverage is unknown for ${address}: ${coverage.reason}`,
);
for (const relatedAddress of sortedUniqueAddresses(related.value)) {
for (const relatedAddress of sortedUniqueAddresses(related)) {
recordEdge(input, state, {
address,
relatedAddress,
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { err, ok } from "../domain/result.js";
import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { EnhancedTools } from "./EnhancedTools.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
const tools = ["get_call_graph", "trace_call_path"] as const;
const input = (tool: (typeof tools)[number]) =>
tool === "get_call_graph"
? { address: "0x1", direction: "forward" }
: { start: "0x1", goal: "0x4", direction: "forward" };
const cleanupFailure = () =>
new AnalysisCapabilityUnavailableError(
provider.id,
"procedure_callees",
"child failed",
{ cleanup: { reason: "worker cleanup failed", resources: ["worker"] } },
);
/** 0x1 calls 0x2 and 0x3; 0x2's read is supplied by the test. */
const port = (
reads: string[],
second: (signal?: AbortSignal) => AnalysisError,
): AnalysisOperationPort => ({
execute: async (operation, parameters, options) => {
const procedure = String(parameters.procedure);
if (operation === "procedure_address")
return ok(createAnalysisExecution(procedure, provider));
reads.push(procedure);
if (procedure === "0x2") return err(second(options?.signal));
return ok(
createAnalysisExecution(
procedure === "0x1" ? ["0x2", "0x3"] : ["0x4"],
provider,
{ relationshipCoverage: { status: "complete" } },
),
);
},
});
describe.each(tools)("%s cleanup ownership", (tool) => {
it("returns the provider's cleanup failure so its owner resolves the outcome", async () => {
const failure = cleanupFailure();
let released = false;
const lease = new AnalysisCleanupLease(async () => {
if (!released) throw new Error("still owned");
}, ["worker"]);
lease.bindCleanupError(failure);
failure.bindCleanupRetry({ operation: "close_binary" });
const reads: string[] = [];
const result = await new EnhancedTools(port(reads, () => failure)).execute(
tool,
input(tool),
);
if (result.ok) throw new Error("expected incomplete cleanup");
expect(result.error).toBe(failure);
expect("partialResult" in result).toBe(true);
expect(reads).toEqual(["0x1", "0x2"]);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
recovery: { kind: "retry_cleanup", operation: "close_binary" },
});
await expect(lease.release()).rejects.toThrow("still owned");
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
released = true;
await lease.release();
expect(projectAnalysisError(result.error).code).toBe(
"capability_unavailable",
);
});
it("keeps a cleanup failure primary when cancellation arrives with it", async () => {
const controller = new AbortController();
const failure = cleanupFailure();
const reads: string[] = [];
const result = await new EnhancedTools(
port(reads, () => {
controller.abort({ requested: "stop" });
return failure;
}),
).execute(tool, input(tool), controller.signal);
if (result.ok) throw new Error("expected incomplete cleanup");
expect(result.error).toBe(failure);
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
expect(reads).toEqual(["0x1", "0x2"]);
});
it("stops at a provider cancellation whose own cleanup is unconfirmed", async () => {
const controller = new AbortController();
const reads: string[] = [];
let cancellation: AnalysisCancelledError | undefined;
const result = await new EnhancedTools(
port(reads, (signal) => {
controller.abort();
cancellation = new AnalysisCancelledError("procedure_callees", {
cause: signal?.reason,
cleanup: { reason: "cancel cleanup failed", resources: ["worker"] },
});
return cancellation;
}),
).execute(tool, input(tool), controller.signal);
if (result.ok) throw new Error("expected cancellation");
expect(result.error).toBe(cancellation);
expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete");
expect(reads).toEqual(["0x1", "0x2"]);
});
});
+72 -1
View File
@@ -1,5 +1,14 @@
import type { CallRelationshipDirection } from "../contracts/enhancedInputs.js";
import type { AnalysisOperation } from "./AnalysisProvider.js";
import type {
AnalysisExecution,
AnalysisOperation,
ExecutionOptions,
} from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { parseRelatedAddresses } from "../domain/native/nativeObservation.js";
import type { Result } from "../domain/result.js";
/** The provider read and response relation for each supported traversal direction. */
export const CALL_RELATIONSHIP_TRAVERSALS = {
@@ -14,3 +23,65 @@ export const CALL_RELATIONSHIP_TRAVERSALS = {
}
>
>;
/** One observed provider operation, as composed workflows dispatch it. */
export type CallRelationshipObserver = (
name: AnalysisOperation,
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
selectorOrigin?: ExecutionOptions["selectorOrigin"],
) => Promise<Result<AnalysisExecution, AnalysisError>>;
/**
* One procedure's relationship read. An unavailable or unreadable read leaves
* the rest of the traversal usable; a stopped read must end it.
*/
export type CallRelationshipRead =
| {
readonly status: "observed";
readonly execution: AnalysisExecution;
readonly related: readonly string[];
}
| { readonly status: "unavailable"; readonly error: AnalysisError }
| { readonly status: "unreadable"; readonly error: AnalysisOutputError }
| { readonly status: "stopped"; readonly error: AnalysisError };
/**
* Whether a failed child read must end a traversal rather than become one of
* its unknowns. A cancelled read ends it; so does a read whose provider cannot
* confirm release of resources it owns, because that read has not finished and
* dispatching more reads would build on that state.
*/
export const stopsTraversal = (error: AnalysisError): boolean =>
error._tag === "AnalysisCancelledError" || error.cleanupIncomplete;
/** Read and parse one procedure's direct relationships for a traversal. */
export const readCallRelationships = async (
observe: CallRelationshipObserver,
direction: CallRelationshipDirection,
procedure: string,
signal?: AbortSignal,
): Promise<CallRelationshipRead> => {
const { operation, relation } = CALL_RELATIONSHIP_TRAVERSALS[direction];
const execution = await observe(operation, { procedure }, signal, "provider");
if (!execution.ok)
return {
status: stopsTraversal(execution.error) ? "stopped" : "unavailable",
error: execution.error,
};
const related = parseRelatedAddresses(
execution.value.result,
relation,
operation,
);
return related.ok
? { status: "observed", execution: execution.value, related: related.value }
: { status: "unreadable", error: related.error };
};
/** The admitted traversal's caveat when unconfirmed cleanup stopped it. */
export const cleanupStoppedTraversal = (
procedure: string,
unexpanded: number,
): string =>
`Traversal stopped at ${procedure} because the provider did not confirm cleanup of resources it owns; ${unexpanded === 1 ? "1 discovered procedure was" : `${unexpanded} discovered procedures were`} not expanded.`;
@@ -8,6 +8,7 @@ import {
compactToolAvailability,
} from "./CapabilityInventory.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { cleanupRetryRemediation } from "../domain/analysisErrorRecovery.js";
import {
expandToolAvailability,
toolAvailabilityInventory,
@@ -52,6 +53,29 @@ const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
v8InspectorObservationEnabled: true,
};
it("adds the retaining close owner's prerequisite without guessing from a pending flag", () => {
const retry = { operation: "close_binary" } as const;
const input = { open: false, capabilities: [], cleanup_pending: true };
const unavailable = buildCapabilityInventory(
{ ...input, cleanup_retry: retry },
enabledPolicy,
).find(({ name }) => name === "procedure_assembly");
expect(unavailable).toMatchObject({
available: false,
reason: "target_required",
remediation: expect.stringContaining(cleanupRetryRemediation(retry)),
});
const unknownOwner = buildCapabilityInventory(input, enabledPolicy).find(
({ name }) => name === "procedure_assembly",
);
const original = buildCapabilityInventory(
{ open: false, capabilities: [] },
enabledPolicy,
).find(({ name }) => name === "procedure_assembly");
expect(unknownOwner).toEqual(original);
expect(unknownOwner?.reason).toBe("target_required");
});
const status = (
options: {
readonly open?: boolean;
@@ -184,6 +208,7 @@ describe("ADB and Apktool host availability", () => {
status: "unavailable" as const,
code: "not_configured" as const,
reason: "REA_ADB_PATH is invalid",
remediation: "Set REA_ADB_PATH to an absolute adb executable path.",
diagnostics: {},
};
const readyInventory = buildCapabilityInventory(status(), {
@@ -205,6 +230,7 @@ describe("ADB and Apktool host availability", () => {
status: "unavailable",
code: "unsupported_version",
reason: "Apktool 2.6.3 is older than 2.7.0",
remediation: "Repair the fixture provider.",
diagnostics: {},
},
});
@@ -227,6 +253,7 @@ describe("capability failure reasons", () => {
status: "unavailable",
code: "runtime_unavailable",
reason: "The JADX native self-test failed.",
remediation: "Repair the fixture provider.",
diagnostics: {},
},
});
+51 -14
View File
@@ -42,6 +42,11 @@ import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
analysisCleanupRetrySchema,
cleanupRetryRemediation,
type AnalysisCleanupRetry,
} from "../domain/analysisErrorRecovery.js";
import { isBundleResourceOperation } from "./binary/BundleResourceSession.js";
const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
@@ -50,6 +55,7 @@ const FRIDA_OPERATION_NAMES: ReadonlySet<string> = new Set(
const statusSchema = z.object({
open: z.boolean(),
cleanup_retry: analysisCleanupRetrySchema.optional(),
session_kind: z.literal("bundle_resources").optional(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
@@ -61,6 +67,7 @@ const statusSchema = z.object({
type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
type UnavailableDescriptor = Extract<ProviderDescriptor, { available: false }>;
/** Optional integrations whose verified hosts exclude some platforms. */
export const HOST_RESTRICTED_FEATURES = [
@@ -126,10 +133,15 @@ type AvailabilityModeResult =
| (Extract<ToolAvailabilityMode, { available: false }> & {
readonly reason: ToolUnavailabilityReason;
});
/** Recovery when unavailable modes require different actions. */
export const MULTI_MODE_REMEDIATION =
"No mode is currently advertised as available. Inspect each mode's missing operations and remediation.";
type AvailabilityContext = {
readonly name: string;
readonly kind: ToolKind;
readonly targetOpen: boolean;
readonly cleanupRetry: AnalysisCleanupRetry | undefined;
readonly resourceSession: boolean;
readonly requiresActiveTarget: boolean;
readonly targetKind:
@@ -258,6 +270,7 @@ export const buildCapabilityInventory = (
name: contract.name,
kind: contract.kind,
targetOpen: status.open,
cleanupRetry: status.cleanup_retry,
resourceSession: status.session_kind === "bundle_resources",
requiresActiveTarget:
"requiresActiveTarget" in contract &&
@@ -572,7 +585,7 @@ const workflowAvailabilityFor = ({
if (profile.reason !== "available") return profile;
const limiter = policy.evmLimiterAvailability;
if (limiter?.status === "unavailable")
return { reason: "provider_missing", remediation: limiter.reason };
return { reason: "provider_missing", remediation: limiter.remediation };
return { reason: "available", remediation: null };
}
if (name === "inspect_recorded_crash")
@@ -606,7 +619,7 @@ const workflowAvailabilityFor = ({
? "provider_unavailable"
: "provider_missing",
remediation:
policy.androidAnalysisAvailability?.reason ??
policy.androidAnalysisAvailability?.remediation ??
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide a full JDK on Linux, macOS, or Windows x64 with its matching bundled native controls.",
};
if (kind === "adb-provider")
@@ -698,7 +711,7 @@ const ownerProbeAvailability = (
: observation.code === "not_configured"
? "provider_missing"
: "provider_unavailable",
remediation: observation.reason,
remediation: observation.remediation,
};
};
@@ -762,7 +775,10 @@ const hostAvailability = ({
const descriptor = descriptors.get(name);
return descriptor?.available === false &&
descriptor.availability_code === "unsupported_host"
? { reason: "unsupported_host", remediation: descriptor.reason }
? {
reason: "unsupported_host",
remediation: descriptorRemediation(descriptor),
}
: null;
};
@@ -772,22 +788,30 @@ const targetAvailability = ({
targetKind,
targetFormat,
targetOpen,
cleanupRetry,
requiresActiveTarget,
descriptors,
}: AvailabilityContext): Availability | null => {
if (!targetOpen && requiresActiveTarget)
return {
reason: "target_required",
remediation: "Call open_binary with a supported local target.",
remediation:
cleanupRetry === undefined
? "Call open_binary with a supported local target."
: `${cleanupRetryRemediation(cleanupRetry)} After cleanup succeeds, call open_binary with a supported local target.`,
};
const descriptor = descriptors.get(name);
if (
targetOpen &&
kind !== "enhanced" &&
descriptor?.available === false &&
(descriptor.availability_code === "target_kind_unsupported" ||
descriptor.availability_code === "target_format_unsupported")
)
return { reason: "target_unsupported", remediation: descriptor.reason };
return {
reason: "target_unsupported",
remediation: descriptorRemediation(descriptor),
};
if (
targetKind !== undefined &&
targetKind !== "executable" &&
@@ -836,11 +860,19 @@ const providerAvailability = ({
? { reason: "available", remediation: null }
: {
reason: "provider_unavailable",
remediation:
descriptor.reason ?? "Choose another target or configured provider.",
remediation: descriptorRemediation(descriptor),
};
};
/**
* Prefer the readiness owner's repair instruction. A static capability fact
* declares no separate repair, so its reason remains the only guidance.
*/
const descriptorRemediation = (descriptor: UnavailableDescriptor): string =>
descriptor.remediation ??
descriptor.reason ??
"Choose another target or configured provider.";
const composedAvailability = (
name: string,
descriptors: ReadonlyMap<string, ProviderDescriptor>,
@@ -887,7 +919,8 @@ const composedAvailabilityFor = (
(descriptor) => descriptor?.available === true,
),
unavailable: observations.find(
(descriptor) => descriptor?.available === false,
(descriptor): descriptor is UnavailableDescriptor =>
descriptor?.available === false,
),
};
})
@@ -918,8 +951,7 @@ const composedAvailabilityFor = (
unavailable.availability_code === "unsupported_host"
? "unsupported_host"
: "provider_unavailable",
remediation:
unavailable.reason ?? "Choose another target or configured provider.",
remediation: descriptorRemediation(unavailable),
},
};
return {
@@ -980,6 +1012,10 @@ const availabilityForModes = (
)[0];
const specificFailureRemediation =
failure?.reason === "provider_missing" ? undefined : failure?.remediation;
const distinctModeRemediations =
defaultMode === undefined &&
failures.length > 1 &&
new Set(failures.map((mode) => mode.remediation)).size > 1;
const facts = {
...(defaultMode === undefined
? {}
@@ -997,9 +1033,10 @@ const availabilityForModes = (
return {
...facts,
reason: failure?.reason ?? "provider_missing",
remediation:
specificFailureRemediation ??
`Configure a provider for one of: ${missingOperations.join(", ")}.`,
remediation: distinctModeRemediations
? MULTI_MODE_REMEDIATION
: (specificFailureRemediation ??
`Configure a provider for one of: ${missingOperations.join(", ")}.`),
};
};
+21 -5
View File
@@ -35,15 +35,14 @@ import {
AnalysisSnapshotMismatchError,
type PublishedEvidenceFile,
} from "../domain/evidenceErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { access, stat } from "node:fs/promises";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js";
import { classifyBinaryTargetFilesystemFailure } from "./BinaryTargetResolver.js";
import {
snapshotBindingMismatch,
snapshotEvidenceForQuery,
snapshotMatchesTarget,
} from "../domain/analysisSnapshot.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import { profilelessNativeDispatchProfile } from "./binary/ProfilelessNativeWorkflowSnapshot.js";
@@ -60,6 +59,7 @@ import {
composedLimitationBudget,
executeComposedWorkflow,
createWorkflowEvidence,
retainPartialWorkflowEvidence,
workflowSnapshotRecord,
recordWorkflowUnknowns,
} from "./WorkflowEvidence.js";
@@ -74,6 +74,7 @@ import type { BinaryTargetError } from "../domain/configurationErrors.js";
import {
directAnalysisCleanupFailure,
projectDirectAnalysisFailure,
projectInvocationFailure,
managedAnalysisCleanupFailure,
withSessionCleanup,
} from "./DirectAnalysisCleanup.js";
@@ -119,7 +120,10 @@ const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet<string> = new Set(
/**
* Open one binary, execute one tool, and always release provider resources.
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
* retain a target or provider client for a subsequent command.
* retain a target, provider session or runtime setting for a subsequent
* command. Failures are projected for that invocation lifetime, so they never
* offer same-session reuse or runtime updates; a new invocation's launch
* environment or arguments carry any changed limit.
*/
export const runDirectAnalysis = async (
dependencies: DirectAnalysisDependencies,
@@ -624,6 +628,12 @@ const executeAnalysisTool = async (input: {
new EnhancedTools(observed).execute(tool, input.arguments, signal),
composedLimitationBudget(tool),
);
retainPartialWorkflowEvidence(result, {
target: input.openedTarget,
operation: tool,
parameters: input.evidenceParameters,
upstreamProfile: evidenceProfile,
});
result = retainAnalysisResultSources(
tool,
(source) => session.recordEvidence(source),
@@ -742,10 +752,16 @@ const prepareSnapshot = async (options: {
...targetSelection(options),
});
if (!target.ok) return target;
if (!snapshotMatchesTarget(loaded.value.target, target.value))
const mismatch = snapshotBindingMismatch(
loaded.value,
target.value,
undefined,
);
if (mismatch?.target !== undefined)
return err(
new AnalysisSnapshotMismatchError(
"Analysis snapshot target does not match the requested binary",
mismatch,
),
);
return ok({ snapshot: loaded.value, target: target.value });
@@ -849,6 +865,6 @@ const cliError = (
): JsonValue => ({
error: "Analysis failed",
...(callerInputPath === undefined
? projectAnalysisError(error)
? projectInvocationFailure(error)
: projectDirectAnalysisFailure(error, callerInputPath)),
});
+27 -9
View File
@@ -1,5 +1,12 @@
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
childFailureDiagnostics,
projectAnalysisError,
} from "../domain/analysisErrorProjection.js";
import {
analysisErrorUserMessage,
composedCallerGuidance,
} from "../domain/analysisErrorPresentation.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, type Result } from "../domain/result.js";
@@ -11,12 +18,21 @@ import {
withEvidenceCallerInputPath,
} from "../domain/evidence.js";
/**
* Project a one-shot failure for its invocation, which has released its
* provider session and runtime settings by the time the caller reads it.
*/
export const projectInvocationFailure = (
error: AnalysisError,
): ReturnType<typeof projectAnalysisError> =>
projectAnalysisError(error, "invocation");
/** Add the actual outer request path to authenticated partial Evidence. */
export const projectDirectAnalysisFailure = (
error: AnalysisError,
callerInputPath: string,
): ReturnType<typeof projectAnalysisError> => {
const projection = projectAnalysisError(error);
const projection = projectInvocationFailure(error);
const observation = tryParseEvidence(projection.details?.partial_observation);
return observation === undefined
? projection
@@ -63,7 +79,7 @@ export const directAnalysisCleanupFailure = (
): JsonValue => {
const project = (failure: AnalysisError) =>
callerInputPath === undefined
? projectAnalysisError(failure)
? projectInvocationFailure(failure)
: projectDirectAnalysisFailure(failure, callerInputPath);
const cleanup = project(error);
if (!result.ok) {
@@ -114,18 +130,20 @@ export const managedAnalysisCleanupFailure = (
: failure,
);
}
const cleanup = projectAnalysisError(error);
const failure = new ProviderAdapterError(
error instanceof ProviderAdapterError ? error.providerId : "rea",
"close_binary",
{
cause: error,
remediationAction: cleanup.remediation.action,
cleanup: { reason: cleanup.message, resources: error.cleanupResources },
diagnostics: {
cleanup_error: cleanup,
partial_observation: jsonValueSchema.parse(result.value),
...composedCallerGuidance({ remediation: error }),
cleanup: {
reason: analysisErrorUserMessage(error, "invocation"),
resources: error.cleanupResources,
},
...childFailureDiagnostics(
{ cleanup_error: { error } },
{ partial_observation: jsonValueSchema.parse(result.value) },
),
},
);
return err(
+2 -2
View File
@@ -1,4 +1,3 @@
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
import { createServerIdentity } from "../serverIdentity.js";
import { silentLogger } from "../logger.js";
@@ -12,6 +11,7 @@ import { catalogIdentityForToolFamilies } from "../catalogIdentity.js";
import { compactPresentationForMcpInputSchemaProfile } from "../config/mcpInputSchemaProfile.js";
import {
directAnalysisCleanupFailure,
projectInvocationFailure,
withSessionCleanup,
} from "./DirectAnalysisCleanup.js";
import { ok } from "../domain/result.js";
@@ -25,7 +25,7 @@ export const runSessionStatus = async (
logger: Logger = silentLogger,
): Promise<JsonValue> => {
const config = dependencies.readConfiguration();
if (!config.ok) return { error: projectAnalysisError(config.error) };
if (!config.ok) return { error: projectInvocationFailure(config.error) };
const session = await dependencies.createBinarySession(config.value, logger);
return withSessionCleanup<JsonValue>(
session,
+17 -1
View File
@@ -10,7 +10,23 @@ import {
type EnhancedToolName,
} from "../contracts/enhancedInputs.js";
export type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
/** A failure that stopped a workflow after it admitted part of its result. */
export interface PartialEnhancedFailure {
readonly ok: false;
readonly error: AnalysisError;
/** The workflow's own result shape, holding only work admitted before the stop. */
readonly partialResult: JsonValue;
}
export type EnhancedOutcome =
| Result<JsonValue, AnalysisError>
| PartialEnhancedFailure;
export type EnhancedResult = Promise<EnhancedOutcome>;
/** Keep the stopping failure primary while retaining the admitted result. */
export const failedWithPartialResult = (
error: AnalysisError,
partialResult: JsonValue,
): PartialEnhancedFailure => ({ ok: false, error, partialResult });
export type TraceReference = {
readonly target_address: string;
readonly source_address: string;
+37 -56
View File
@@ -1,8 +1,15 @@
import { permitsCapabilityFallback } from "./CapabilityFallback.js";
import { withOverviewInventoryCoverage } from "../domain/native/inventoryCoverage.js";
import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js";
import type {
BatchDecompileItem,
BatchProcedureIdentity,
} from "../domain/native/batchDecompile.js";
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
import {
cleanupStoppedTraversal,
readCallRelationships,
} from "./CallRelationshipTraversal.js";
import { z } from "zod";
import type {
@@ -28,7 +35,6 @@ import {
parseNativeInventoryCount,
addressedValueSchema,
} from "../domain/native/nativeInventory.js";
import { parseRelatedAddresses } from "../domain/native/nativeObservation.js";
import {
parseSegments,
projectBinaryOverview,
@@ -59,6 +65,7 @@ import {
} from "../domain/apple/machoTargetSelection.js";
import {
failedWithPartialResult,
invalidEnhancedInput,
type EnhancedResult,
type ValidatedEnhancedCall,
@@ -73,18 +80,6 @@ import {
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
type BatchProcedureIdentity =
| {
readonly status: "resolved";
readonly address: string;
readonly name: string | null;
readonly name_error?: ReturnType<typeof projectAnalysisError>;
}
| {
readonly status: "unknown";
readonly error: ReturnType<typeof projectAnalysisError>;
};
type BatchResolution = {
readonly address: string;
readonly result: Result<string, AnalysisError>;
@@ -96,16 +91,6 @@ type BatchReadyItem = {
readonly providerProcedure: string;
};
type BatchDecompileItem = {
readonly address: string;
readonly procedure: BatchProcedureIdentity;
readonly status: "ok" | "error";
readonly pseudocode?: string;
readonly error?: ReturnType<typeof projectAnalysisError>;
/** Caveats of this item's own observation when it supplied no pseudocode. */
readonly limitations?: string[];
};
/**
* Guidance for a batch item whose provider returned null pseudocode, by
* whether that observation reported caveats for the item to carry.
@@ -615,8 +600,6 @@ export class EnhancedTools {
},
signal?: AbortSignal,
): EnhancedResult {
const { relation, operation: tool } =
CALL_RELATIONSHIP_TRAVERSALS[input.direction];
const limitations = new Set<string>();
const resolved = await this.#observe(
"procedure_address",
@@ -635,40 +618,41 @@ export class EnhancedTools {
];
let queueIndex = 0;
const graph: Record<string, JsonValue[]> = {};
const sortedLimitations = () =>
[...limitations].sort(compareUnicodeCodePoints);
while (queueIndex < queue.length) {
const current = queue[queueIndex++];
if (current === undefined) continue;
const level = String(current.depth);
graph[level] ??= [];
const result = await this.#observe(
tool,
{ procedure: current.address },
const read = await readCallRelationships(
this.#observe.bind(this),
input.direction,
current.address,
signal,
"provider",
);
if (!result.ok) {
if (isAnalysisCancellation(result.error)) return result;
graph[level].push({
if (read.status === "stopped") {
if (!read.error.cleanupIncomplete) return err(read.error);
limitations.add(
cleanupStoppedTraversal(current.address, queue.length - queueIndex),
);
return failedWithPartialResult(read.error, {
graph,
limitations: sortedLimitations(),
});
}
const nodes = (graph[String(current.depth)] ??= []);
if (read.status !== "observed") {
nodes.push({
address: current.address,
status: "error",
error: projectAnalysisError(result.error),
error: projectAnalysisError(read.error),
} satisfies NativeCallGraph["graph"][string][number]);
continue;
}
const related = parseRelatedAddresses(result.value.result, relation);
if (!related.ok) {
graph[level].push({
address: current.address,
status: "error",
error: projectAnalysisError(related.error),
} satisfies NativeCallGraph["graph"][string][number]);
continue;
}
for (const limitation of result.value.limitations)
const { execution, related } = read;
for (const limitation of execution.limitations)
limitations.add(limitation);
const reportedCoverage = result.value.relationshipCoverage;
const reportedCoverage = execution.relationshipCoverage;
const coverage = reportedCoverage ?? {
status: "unknown" as const,
reason: "Provider did not report relationship coverage.",
@@ -676,7 +660,7 @@ export class EnhancedTools {
const relationshipLimitations =
reportedCoverage === undefined
? [
...result.value.limitations,
...execution.limitations,
"Provider did not report relationship coverage.",
]
: [
@@ -692,26 +676,23 @@ export class EnhancedTools {
const nodeLimitations = [...new Set(relationshipLimitations)].sort(
compareUnicodeCodePoints,
);
graph[level].push({
nodes.push({
address: current.address,
status: "ok",
calls: [...related.value],
calls: [...related],
relationship_coverage: coverage,
...(nodeLimitations.length === 0
? {}
: { limitations: nodeLimitations }),
} satisfies NativeCallGraph["graph"][string][number]);
for (const address of related.value) {
for (const address of related) {
if (!discovered.has(address)) {
discovered.add(address);
queue.push({ address, depth: current.depth + 1 });
}
}
}
return ok({
graph,
limitations: [...limitations].sort(compareUnicodeCodePoints),
});
return ok({ graph, limitations: sortedLimitations() });
}
async #analyzeSwiftTypes(
+8 -1
View File
@@ -212,6 +212,11 @@ export const publishEvidenceDelivery = async (input: {
return ok({ delivered: input.delivered, evidenceBundle: written.value });
};
/** A delivery failure can follow commit; its receipt then owns the source closure. */
export const evidenceDeliveryCommitted = (error: AnalysisError): boolean =>
error.diagnostics?.evidence_bundle !== undefined ||
(error instanceof EvidenceFileError && error.published !== undefined);
/** Publish a completed bundle and retry its exact staged-output cleanup owner. */
const persistEvidenceBundleCommand = async (
bundle: EvidenceBundle,
@@ -292,8 +297,10 @@ const projectWrite = (
export const inspectEvidenceBundleCommand = async (
input: {
readonly path: string;
readonly detail: "complete" | "summary";
readonly detail: "complete" | "summary" | "records";
readonly filters?: EvidenceRecordFilters | undefined;
readonly offset?: number | undefined;
readonly limit?: number | undefined;
},
signal?: AbortSignal,
) => {
+191 -36
View File
@@ -1,14 +1,51 @@
/** Download result; HTTP failures may carry their observed status for diagnostics. */
export interface HopperDownload {
readonly ok: boolean;
readonly bytes: Uint8Array;
readonly httpStatus?: number;
}
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
import { err, ok, type Result } from "../domain/result.js";
/** Why a URL was refused before any vendor response could be admitted. */
export type HopperUrlRefusal =
| "invalid_url"
| "non_https"
| "credentials"
| "redirect_without_location"
| "redirect_limit";
/**
* Transport facts for one Hopper request that yielded no admitted bytes.
* `url` is the caller-selected request with any URL userinfo removed, never a
* redirect target. URLs carrying credentials are refused before any request,
* so no transport detail can contain them.
*/
export type HopperDownloadFailure = { readonly url: string } & (
| {
readonly kind: "http_status";
/** Absent when the response did not carry a valid HTTP failure status. */
readonly httpStatus?: number;
}
| { readonly kind: "timeout"; readonly timeoutMs: number }
| { readonly kind: "network"; readonly detail: string }
| { readonly kind: "response_too_large"; readonly maxBytes: number }
| { readonly kind: "url_refused"; readonly refusal: HopperUrlRefusal }
);
/** Admitted response bytes, or the observed reason none were admitted. */
export type HopperDownload = Result<Uint8Array, HopperDownloadFailure>;
/** The vendor request a Hopper workflow made; identifies the failed stage. */
export type HopperVendorRequest = "release_feed" | "package" | "public_key";
/** A failed download attributed to the workflow stage that requested it. */
export type HopperRequestFailure = HopperDownloadFailure & {
readonly request: HopperVendorRequest;
};
/** Maximum admitted vendor response bytes, shared by Hopper setup adapters. */
export const HOPPER_DOWNLOAD_BYTES = 100_000_000;
/** Bound observed response bytes before retaining them, including chunked bodies. */
/**
* Bound observed response bytes before retaining them, including chunked bodies.
* Expected transport outcomes are returned; caller cancellation still rejects
* with the caller's abort reason so cancellation stays distinct from failure.
*/
export const downloadHopperPackage = async (
url: string,
options: {
@@ -20,7 +57,6 @@ export const downloadHopperPackage = async (
const timeoutMs = options.timeoutMs ?? 120_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000)
throw new RangeError("Invalid Hopper response time budget");
const deadline = AbortSignal.timeout(timeoutMs);
const maxBytes = options.maxBytes ?? HOPPER_DOWNLOAD_BYTES;
if (
!Number.isSafeInteger(maxBytes) ||
@@ -28,37 +64,69 @@ export const downloadHopperPackage = async (
maxBytes > HOPPER_DOWNLOAD_BYTES
)
throw new RangeError("Invalid Hopper response byte budget");
const response = await fetchHopperResponse(
url,
options.signal === undefined
? deadline
: AbortSignal.any([options.signal, deadline]),
);
const refused = { ok: false, bytes: new Uint8Array() };
if (response === undefined) return refused;
const deadline = AbortSignal.timeout(timeoutMs);
// Remove transport credentials once, where every failure is created.
const reported = sanitizeBrowserUrl(url).url;
try {
return await readHopperResponse(
url,
reported,
options.signal === undefined
? deadline
: AbortSignal.any([options.signal, deadline]),
maxBytes,
);
} catch (cause: unknown) {
if (options.signal?.aborted === true) throw cause;
if (deadline.aborted)
return err({ url: reported, kind: "timeout", timeoutMs });
// Fetch reports network errors (DNS, TLS, reset, truncated body) as TypeError.
if (cause instanceof TypeError)
return err({
url: reported,
kind: "network",
detail: networkDetail(cause),
});
throw cause;
}
};
const readHopperResponse = async (
requested: string,
url: string,
signal: AbortSignal,
maxBytes: number,
): Promise<HopperDownload> => {
const fetched = await fetchHopperResponse(requested, signal);
if (!fetched.ok)
return err({ url, kind: "url_refused", refusal: fetched.error });
const response = fetched.value;
if (!response.ok) {
await response.body?.cancel();
return Number.isInteger(response.status) &&
response.status >= 300 &&
response.status <= 599
? { ...refused, httpStatus: response.status }
: refused;
await releaseBody(response.body);
return err(
Number.isInteger(response.status) &&
response.status >= 300 &&
response.status <= 599
? { url, kind: "http_status", httpStatus: response.status }
: { url, kind: "http_status" },
);
}
const tooLarge = { url, kind: "response_too_large", maxBytes } as const;
if (Number(response.headers.get("content-length")) > maxBytes) {
await response.body?.cancel();
return refused;
await releaseBody(response.body);
return err(tooLarge);
}
if (response.body === null) return { ok: true, bytes: new Uint8Array() };
if (response.body === null) return ok(new Uint8Array());
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) return { ok: true, bytes: Buffer.concat(chunks, bytes) };
if (chunk.done) return ok(Buffer.concat(chunks, bytes));
if (chunk.value.byteLength > maxBytes - bytes) {
await reader.cancel();
return refused;
await releaseBody(reader);
return err(tooLarge);
}
chunks.push(chunk.value);
bytes += chunk.value.byteLength;
@@ -68,6 +136,29 @@ export const downloadHopperPackage = async (
}
};
/**
* Release a body REA will not admit. A stream that already failed (for example
* a truncated error body) has nothing left to release, and its error must not
* replace the response outcome that was already observed.
*/
const releaseBody = async (
body: { cancel(): Promise<void> } | null,
): Promise<void> => {
try {
await body?.cancel();
} catch (cause: unknown) {
// best-effort cleanup: an errored stream is already released.
void cause;
}
};
const networkDetail = (cause: TypeError): string => {
const underlying = cause.cause instanceof Error ? cause.cause.message : "";
return underlying === "" || underlying === cause.message
? cause.message
: `${cause.message}: ${underlying}`;
};
// HTTP-redirect fetch admits at most 20 redirects; retain that existing Fetch
// bound while checking each destination before the next request.
const FETCH_REDIRECT_LIMIT = 20;
@@ -76,31 +167,34 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const fetchHopperResponse = async (
url: string,
signal: AbortSignal,
): Promise<Response | undefined> => {
): Promise<Result<Response, HopperUrlRefusal>> => {
let selected: URL;
try {
selected = new URL(url);
} catch {
return undefined;
return err("invalid_url");
}
let requested = url;
for (let redirects = 0; ; redirects++) {
if (selected.protocol !== "https:") return undefined;
if (selected.protocol !== "https:") return err("non_https");
// Fetch cannot request userinfo and would echo it in its TypeError message.
if (selected.username !== "" || selected.password !== "")
return err("credentials");
const response = await fetch(requested, {
redirect: "manual",
headers: { accept: "application/json", "user-agent": "rea-installer" },
signal,
});
if (!REDIRECT_STATUSES.has(response.status)) return response;
if (!REDIRECT_STATUSES.has(response.status)) return ok(response);
const location = response.headers.get("location");
await response.body?.cancel();
if (location === null || redirects === FETCH_REDIRECT_LIMIT)
return undefined;
await releaseBody(response.body);
if (location === null) return err("redirect_without_location");
if (redirects === FETCH_REDIRECT_LIMIT) return err("redirect_limit");
let next: URL;
try {
next = new URL(location, selected);
} catch {
return undefined;
return err("invalid_url");
}
// Fetch inherits the previous fragment when Location has none.
if (!location.includes("#")) next.hash = selected.hash;
@@ -108,3 +202,64 @@ const fetchHopperResponse = async (
requested = next.href;
}
};
const requestSubjects: Readonly<Record<HopperVendorRequest, string>> = {
release_feed: "Hopper release feed",
package: "Hopper package",
public_key: "Hopper public key",
};
const refusals: Readonly<Record<HopperUrlRefusal, string>> = {
invalid_url: "a request or redirect URL was not a valid absolute URL",
non_https: "a request or redirect URL did not use HTTPS",
credentials: "a request or redirect URL contained credentials",
redirect_without_location: "a redirect response omitted its Location",
redirect_limit: `the response redirected more than ${FETCH_REDIRECT_LIMIT} times`,
};
/** One sentence naming the failed request, its URL and the observed outcome. */
export const describeHopperRequestFailure = (
failure: HopperRequestFailure,
): string => {
const subject = `The official ${requestSubjects[failure.request]} request to ${failure.url}`;
switch (failure.kind) {
case "http_status":
return failure.httpStatus === undefined
? `${subject} received an unsuccessful HTTP response without a valid status.`
: `${subject} returned HTTP ${failure.httpStatus}.`;
case "timeout":
return `${subject} did not complete within ${failure.timeoutMs / 1000} seconds.`;
case "network":
return `${subject} failed with a network error (${failure.detail}).`;
case "response_too_large":
return `${subject} exceeded REA's ${failure.maxBytes}-byte response limit.`;
case "url_refused":
return `${subject} was refused because ${refusals[failure.refusal]}.`;
}
};
/** Next step for a failed request, owned with the transport facts that select it. */
export const hopperRequestRecovery = (
failure: HopperDownloadFailure,
): string => {
switch (failure.kind) {
case "http_status": {
const status = failure.httpStatus;
if (status === 404 || status === 410)
return "The vendor reports this file as missing, so an immediate retry is unlikely to help; check for a newer REA release, then retry.";
if (
status === 408 ||
status === 429 ||
(status !== undefined && status >= 500)
)
return "The vendor service reported a temporary failure; wait, then retry.";
return "Check proxy, firewall or network policy for this request, then retry.";
}
case "timeout":
case "network":
return "Check network, DNS, proxy and TLS access to this URL, then retry.";
case "response_too_large":
case "url_refused":
return "The vendor response did not meet REA's download limits; check for a newer REA release, then retry.";
}
};
Loaded 100 of 494 files, more files were not shown because too many files have changed in this diff. Show more