mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
Merge remote-tracking branch 'upstream/main' into fix/release-exposure-audit
This commit is contained in:
494 files changed
+20575
-3916
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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":
|
||||
|
||||
@@ -266,7 +266,6 @@ await withVerifierCleanup(async () => {
|
||||
extractedPluginMcp: true,
|
||||
targetFree: true,
|
||||
targetLifecycle: true,
|
||||
boundedRegexBridge: true,
|
||||
update,
|
||||
mcpStartup,
|
||||
mcpModuleLoading,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(", ")}.`),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
) => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user