Compare commits

...
Author SHA1 Message Date
N0zoM1z0 c2e2ed9aa2 test(firmware): retain unverified workers before further launches 2026-10-11 02:38:48 +08:00
N0zoM1z0 1ae4bec45b Merge remote-tracking branch 'origin/main' into test/maintenance-boundary-contracts-20261011 2026-10-11 02:38:48 +08:00
N0zoM1z0 058bc0af35 test: verify current maintenance boundary behavior 2026-10-11 02:34:45 +08:00
morluto dce81ca400 perf(javascript): count candidate relevance without a second adjacency
Scan eligible candidate relations directly for query ambiguity instead of allocating and sorting another traversal index. Preserve direction, relation filters and cycle handling.

Semantic graph and builder suites plus public application workflow CLI tests passed. Full query outputs matched on a generated 20000-node graph; the single repeated-query timing run is indicative only.
2026-10-11 02:32:11 +08:00
morluto bb764d26bd perf(artifacts): stop parsing parent paths after a missing branch
Walk delimiter-terminated parent segments lazily instead of splitting full paths before discovering that no deeper observed parent exists. Preserve canonical paths and observed ancestry.

Verified real ZIP inventory with multiple 30000-component siblings and the full entry-order suite. A 96-member deep ZIP benchmark retained an identical full inventory digest and reduced median scan time from 517.5 to 464.5 ms across five measured inventories.
2026-10-11 02:32:04 +08:00
morluto acd8d86898 perf(android): validate prerequisites once per retained JADX session
Resolve current paths and JVM options and rehash source identities for each request. Probe Java modules and engine capabilities only when creating a new session; remove the superseded full configuration resolver.

Focused configuration, session retirement, cancellation, cleanup and Android MCP boundaries passed. The 34-operation retained session now performs one prerequisite probe. Protocol fixtures do not establish real JADX or JDK performance.
2026-10-11 02:31:57 +08:00
morluto e6d0b3993d perf(process): batch liveness rechecks within ownership phases
Recheck unreadable token and identity observations against one fresh live process snapshot per phase. Preserve token mismatches, fail closed on inspection errors, and retain liveness failure diagnostics.

Validated with focused process ownership, native capture, CLI and MCP boundaries. An eight-member injected-unreadable workload using native ps reduced snapshots from nine to two with equivalent failures; real permission-denied provider behavior was not exercised.
2026-10-11 02:31:07 +08:00
morluto 92a901e0db perf(process): share settlement probes across captured groups 2026-10-11 02:21:50 +08:00
morluto cef6d6d3c4 perf(artifacts): reuse trie membership for case-folded siblings 2026-10-11 02:21:40 +08:00
morluto bd972bf80b perf(javascript): reuse capture completeness during load classification 2026-10-11 02:21:35 +08:00
morluto ac9709a80a perf(reconstruction): index semantic evidence contexts per derivation 2026-10-11 02:21:28 +08:00
morluto d1e6fe2eca test(artifacts): close owned Mach-O readers after each case 2026-10-11 02:11:13 +08:00
morluto a147ecd4b4 perf(artifacts): index observed parents for deep archive paths 2026-10-11 02:10:50 +08:00
morluto 22fdcb8ab7 perf(process): share batched token observation across ownership checks 2026-10-11 02:10:44 +08:00
morluto b22d817073 perf(process): batch ownership tokens during rooted cleanup
Use the host bulk token reader for each cleanup validation while preserving identity checks before and after token reads. Fall back for unsupported readers or omitted rows, retain explicit unavailable observations, and preserve batch and individual failures. Source ownership regressions and real owned-process boundary tests pass; a warm Darwin validation probe reduced median time from 90 ms to 52 ms.
2026-10-11 01:59:30 +08:00
morluto 3d876c5c1a perf(javascript): validate semantic factory inputs once
Construct typed semantic records directly from cloned, normalized input and derived commitments instead of reparsing the same fields. Keep full graph integrity validation and verify caller mutation isolation. Four alternating cold runs preserved complete result digests while median projection time fell about 8 percent and 17 percent for the two 20,000-node capacity workloads.
2026-10-11 01:54:46 +08:00
morluto cf346368c4 perf(javascript): reuse graph buckets and query indexes
Append overlapping observations to node buckets instead of recopying groups, and share one node index across semantic seed admission and traversal. Preserve merged provenance and absent-seed behavior.
2026-10-11 01:54:36 +08:00
morluto e52c0a72c5 fix(browser): retain incomplete WebMCP replay coverage
Mark malformed WebMCP event envelopes, tool lists and entries as invalid protocol values while preserving valid siblings and out-of-scope classifications. Verify toolsAdded and toolsRemoved replay uncertainty through CDP discovery and capture comparison.
2026-10-11 01:54:26 +08:00
morluto 999e3e2098 fix(android): cancel admitted snapshot copies with their operation
Forward caller and timeout cancellation through engine and APK snapshots, release stream handles before removing partial output, and retain the existing root cleanup authority. Verify cancellation after destination bytes appear with a sparse filesystem fixture.
2026-10-11 01:54:20 +08:00
dakjdakdandYsopwnable c1217e711b fix(javascript): retain runtime scope in bundled module relationships 2026-10-11 01:36:12 +08:00
morluto 0d89978333 fix(android): release snapshot streams before closing file handles
Retain both non-auto-closing copy streams and destroy them after verification, before awaiting their FileHandle closure. This releases stream-held descriptor references on success and failure instead of deadlocking Android snapshot cleanup.

Validation: all 33 JADX subprocess integration tests passed on macOS with Node 24.18.0; check:fast and formatting passed. No real JADX provider execution or Linux/Windows verification was performed locally.
2026-10-11 01:12:48 +08:00
morluto 11e3fae697 fix(ci): repair boundary regressions and evidence assertions
Detach cancelled CDP sessions within the cleanup budget and retain event-finalization failures across retries. Honor Git filesystem encodings and exclude the promise-API probe from source-read failures.

Align existing integration regressions with retained process resources, filesystem-order-independent truncation, owned skill files, explicit native access facts and streamed JSON errors.

Validation: 132 acceptance tests passed (2 skipped), 83 affected boundary tests passed, Git/JSON/CLI boundary regressions passed, check:ci, docs:check and test discovery passed. Local verification used Node 24.18.0 on macOS; cross-platform CI remains pending.
2026-10-11 01:00:30 +08:00
morluto 603d04ee3a fix(ci): repair format gate and pin maintenance skill destinations
format:check failed on ApktoolProvider; existingMaintenanceScope now
returns skillDestinations (consumed by maintenance planning), so pin
the field in the exact-shape scope assertions.
2026-10-11 00:33:36 +08:00
morluto 472f313232 refactor(native): match UI controls by explicit object identity 2026-10-11 00:28:39 +08:00
morluto 1f7e279aa2 fix(adb): retain rollback ownership and cancelled acquisition facts 2026-10-11 00:28:39 +08:00
morluto db8ccaa251 fix(android): bind JADX snapshots and hashes to admitted sources 2026-10-11 00:28:25 +08:00
morluto 5daff4d67e fix(artifacts): retain Mach-O source identity through inventory 2026-10-11 00:28:25 +08:00
morluto a9fd1aecae fix(application): verify configuration and reference input reads 2026-10-11 00:28:24 +08:00
morluto abb8e81d3a fix(filesystem): bound and verify admitted file and JSON reads 2026-10-11 00:28:24 +08:00
YsopwnableandGitHub 6d8f95e38d fix(clients): preserve portable MCP schemas in native workflows (#1580)
* test(clients): verify native Command Code workflows

* docs: synchronize Italian provider guide links

* fix(contracts): make ADB identifier patterns portable

* fix(contracts): preserve portable ADB patterns after formatting
2026-10-11 00:09:43 +08:00
morluto c1b5ccf0a4 refactor(server): admit complete evidence workflows and share handlers
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:24 +08:00
morluto 5f30ea6ff2 fix(engines): serialize JEB acquisition and respect IDA cancellation
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:23 +08:00
morluto d438675677 fix(process): retain capture resources and bound filesystem snapshots
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:23 +08:00
morluto a84dea5500 fix(apktool): join active work and retain cleanup authority
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:22 +08:00
morluto 2a66ec9094 fix(browser): retain startup owners and coordinate retryable close
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:22 +08:00
morluto d1235fadc9 fix(providers): retain worker cleanup owners through shutdown
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:21 +08:00
morluto a01148553e fix(setup): preserve recovery backups and retain installer cleanup
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:21 +08:00
morluto a6f76f5da9 fix(graphs): separate source identities from display representations
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:20 +08:00
morluto 2ca3ad2386 fix(reference): preserve exact paths and unify symlink lookup
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:20 +08:00
morluto 5d0a883605 fix(native): bound metadata projection and compact Mach-O bind runs
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:19 +08:00
morluto 0f0d6e4d43 fix(managed): budget decoding before representation expansion
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:19 +08:00
morluto 6d4cefc4f7 fix(artifacts): admit bounded reads and preserve source identity
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:18 +08:00
morluto 2a0b58e440 refactor(cleanup): share failure composition and retain native UI owners
Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
2026-10-11 00:03:18 +08:00
morluto 6543405252 fix(adb): escape portable patterns for unicodeSets consumers 2026-10-10 23:40:37 +08:00
morluto cca7bf0022 fix(ci): repair option alias, pattern portability, and Italian doc links 2026-10-10 23:34:57 +08:00
morluto bb742110a4 fix(ghidra): build bridge script paths with the target platform syntax 2026-10-10 23:22:44 +08:00
morluto a97bd5ed29 fix(ci): share provider workflows to restore static gates 2026-10-10 23:15:02 +08:00
Jay-Alexander ElliotGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
bddddccdee feat(native): admit bounded MIPS ELF analysis through Ghidra (#1362)
* feat(native): admit bounded MIPS ELF analysis through Ghidra

Retain MIPS ELF identity and interpretation through Evidence and snapshots.
Keep provider admission bounded to ELF32 ET_EXEC o32 MIPS32r2, without
implicitly widening Hopper, IDA or Windows P0 coverage. Add source-owned
fixtures and an optional real-provider verifier; real Ghidra verification
remains pending in this contribution environment.

Refs #718

* fix(ghidra): bound MIPS floating-point mode admission

Reject explicit EF_MIPS_FP64 and EF_MIPS_NAN2008 declarations until their
semantics have a real-provider verification lane. Add PSP PRX/Allegrex,
o64/EABI and floating-point-mode refusal regressions. Document that the
header gate is not a full .MIPS.abiflags check and advance the profile lane
identity to invalidate older interpretation bindings.

Validation: 24 standalone checks against the transpiled production profile
module passed; the two new flag cases failed before this change. Clang 17
and LLD compiled the source-owned fixture for both endian targets. The
complete Vitest/typecheck suite and real Ghidra verification remain pending.

Refs morluto/rea#718; PSP support remains follow-up morluto/rea#1330.

* fix(native): validate MIPS ABI records before Ghidra admission

Read bounded ELF32 PT_MIPS_ABIFLAGS and SHT_MIPS_ABIFLAGS records through
the already-open target handle. Preserve declared ABI fields, reject
conflicting/duplicate/truncated records, and keep missing records unknown.
Wire inspection into target resolution before provider selection. Require
compatible declarations for the deliberately narrow Ghidra lane and bind
all ABI fields into profile identity (v3) for snapshot invalidation.

Clang 17 produced eight source-owned fixtures spanning both byte orders
and FPXX/FP32/FP64/soft-float modes with identical ELF header flags. The
new reader and policy distinguish them. 24 focused regression test bodies
passed after TypeScript transpilation using node:test in place of Vitest;
strict isolated TypeScript checking of the reader and target/result types
passed. These are limited checks, not full Vitest/project typechecking or
real-Ghidra verification, which remain pending.

Advances morluto/rea#718. PSP/Allegrex remains morluto/rea#1330 follow-up.

* fix(native): repair MIPS integration boundaries and test resolver behavior

Keep ELF targets out of Hopper Mach-O preparation and scope existing PE fixture types correctly. Move ABI tests into the filesystem boundary lane, use shared cleanup, add resolver regressions, apply the pinned formatter, and register the real MIPS verifier through the existing package-command pattern. Validated with repository-pinned Node/npm, check:ci, actual focused Vitest, discovery and docs:check. Real Ghidra conformance remains pending.

* fix(test): honor caller-selected JDK in real MIPS verification

* test(native): strengthen MIPS Ghidra conformance

Cross-check the source-owned fixture with bounded GNU readelf inspection, compare production ELF/ABI interpretation and independent symbol addresses, and verify effective Ghidra language/compiler identity through existing public operations. Add a source-owned fixed-encoding probe for immediate and conditional branch checks plus global/string byte assertions.

Add 16 assertion/producer-boundary regression cases without widening production admission, changing provider contracts, or introducing proprietary dependencies. Local focused suite: 99 passed, one existing root-permission test skipped. Typecheck, lint/module boundaries, formatting and 700-file discovery pass; full hosted checks and real Ghidra verification remain pending for this checkpoint.

* fix(ghidra): preserve native-tool architecture checks after MIPS integration

The conflict-free upstream merge failed TypeScript compilation because includes on the union of MIPS-only and other architecture arrays requires never. Use equality-based membership without casts or changing accepted native hosts. Extend the existing native-decompiler rejection table with a MIPS executable at a Linux x64 tool path; retain upstream FAT and unknown-header behavior. Hosted verification follows this commit; no success is claimed yet.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-10 23:04:27 +08:00
96fd98cce0 feat(apktool): decode Android resource tables through a caller-supplied launcher (#1556)
* feat(apktool): decode Android resource tables through a caller-supplied launcher

Add an Apktool family with two operations over the caller's apktool
launcher (REA_APKTOOL_COMMAND, absolute, or apktool on PATH):

- inspect_apktool_client: resolved launcher, selection source, and
  version; decodes nothing.
- decode_android_resources: apktool d --no-src in a provider-owned
  workspace that is removed before the result returns. Projects the
  decoded AndroidManifest.xml (bounded at 512 KiB), apktool.yml version
  and SDK facts exactly as printed, string resources verbatim (default or
  one locale, bounded at 2000 entries, nested-markup entries counted as
  unparsed), available string locales, and the decoded file count with a
  hard cap. The target APK is digested with SHA-256 before decoding so
  Evidence binds the observation to the exact input.

Smali production is skipped by design: Java-source recovery belongs to
the JADX family. Workspace cleanup happens before results return; an
unremovable workspace is named in the limitations instead of abandoned.

Verified against apktool 2.7.0-dirty (Debian packaging) on Linux with
OpenJDK 25 against a signed aapt2-built probe APK with two resource
locales: launcher version, on-disk digest agreement, metadata projection,
manifest package agreement, and both string tables (default and de).
Parser goldens come from the same decode. READMEs carry the new
'Android resources' row in every supported language.

* fix(apktool): own snapshots and bound decoded resource reads

* docs(apktool): describe owned resources and bounded projection

---------

Co-authored-by: REA Contributor <contributor@rea.tools>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:04:12 +08:00
749e58772a feat(jeb): add JEB engine provider over its MCP interface (#1536)
* feat(jeb): add JEB engine provider over its MCP interface

Add a bring-your-own JEB provider that serves project, unit, and
decompilation inspection from a caller-started JEB client speaking MCP
(streamable HTTP, selected with REA_JEB_MCP_URL). Expose four provider-neutral
operations through shared CLI/MCP workflows: inspect_jeb_client,
open_jeb_project, list_jeb_units, and decompile_jeb_item, each returning
Evidence with engine identity, endpoint, and preserved refusal reasons.

The adapter keeps JEB's envelope semantics: success fields are parsed
format-aware at the boundary, message-only refusals become unsupported-target
errors with the engine's own reason, transport failures become availability
failures with recovery guidance, and unit pagination derives coverage from the
engine's 100-item page cap. Explicit unknowns (implicitly selected unit,
omitted filters) are reported instead of guessed.

Verified against a real JEB 5.48.0 licensed build serving jeb-mcp-server 1.3.0
headless: client inspection, unit listing, and method decompilation were
exercised through the CLI, MCP boundary, and scripts/verify-jeb.mjs.
JEB 5.48.0 headless instances do not advertise open_project; the refusal is
preserved and documented.

* fix(jeb): verify against a real signed APK and fix CLI option surface

Verify the provider against a locally built, v1/v2/v3-signed probe APK
(binary manifest, classes.dex with a launcher activity) on JEB 5.48.0: the
full unit tree (apk, manifest, three certificate units, dex bytecode,
decompiler), wildcard-filtered and paginated unit listing with derived
coverage, parent-restricted listing, and launcher-activity method
decompilation through both CLI and MCP, including dexdec's inline
constant folding of the probe's obfuscated tag.

Move list-jeb-units filter/pagination and decompile-jeb-item unit selection
from positional arguments to options; the MCP schemas are unchanged.

* fix(jeb): preserve engine refusals through the real MCP boundary

Testing against the published Signal 8.30.3 universal APK exposed a boundary
bug the scripted provider tests missed: decodeJebToolResult raised a protocol
error for isError results before the provider could map JEB's
{success:false, message} envelope, so real engine refusals surfaced as
unreadable output instead of the engine's own reason. Decode refusal
envelopes like any structured result and let the provider preserve the
reason; cover the decoder directly, including contentless and unparseable
results.

Signal verification: full 109 MB R8/Kotlin/Compose APK with four signature
schemes and native arm64 ELF units parsed; MainActivity.onCreate and R8
synthetic lambdas decompiled through CLI and MCP; paginated, filtered, and
parent-restricted unit listing; refusal path confirmed on the live engine.

* docs(jeb): record Flutter Gallery verification on the real-engine lane

Verify the provider against the official Flutter Gallery 2.9.2 release APK
(112 MB): per-ABI libapp.so Dart AOT snapshot images analyzed as native code
with 75k methods on arm64, snapshot internals and wrapper methods decompiled
through both CLI and MCP with explicit unit selection, decoded manifest, and
filtered native-library unit discovery. Note that JEB 5.48.0 headless does
not expose the dedicated Dart snapshot processor or run_script; both remain
GUI-only engine surfaces.

* docs(jeb): translate the README JEB row for every supported language

The JEB row was inserted in English across all translated READMEs; each now
carries its own label, capability summary, requirements, and guide link
following that language's Android-row conventions.

* fix(jeb): enforce local endpoints and preserve cancellation

* fix(jeb): refuse redirects across the local MCP boundary

---------

Co-authored-by: REA Contributor <contributor@rea.tools>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:03:27 +08:00
ded59689ce fix(javascript): accept the Wakaru 1.x release line and verify 1.14.0 (#1540)
* fix(javascript): accept the Wakaru 1.x release line and verify 1.14.0

Exact --version equality rejected every Wakaru build except 1.13.0, and
strict report schemas rejected any added field, so each upstream release
broke recovery until REA re-pinned. Wakaru now documents that --version,
--json and provenance.json change only by addition within a major version.

Admit ^1.13.0 with the verified/compatible split used for Binwalk and
Unblob, report the observed version instead of the pinned constant, and
add a limitation for unverified releases. Parse reports with loose
objects and keep the reported module status as a string; path, identity,
range and file-set checks stay exact.

Move the audited pin, submodule, CI binary and verifier to 1.14.0, and
document where to get the binary from the refusal message.

* fix(javascript): project known Wakaru warning fields

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:02:15 +08:00
0363e5a8e1 fix(reference): join internal symlink targets with the inventory NFC normalizer (#1535)
* fix(reference): join internal symlink targets with the inventory NFC normalizer

checkSymlinks resolved an internal symlink target with bare
posix.normalize and looked the result up in the inventoried path set.
A target that a filesystem reports in NFD (macOS readlink commonly
does) joined to an NFD spelling while the inventory holds NFC, so
validation reported "Internal symlink target must resolve to an
inventoried entry" for a target the inventory actually contains.

Route the join through normalizeJoinedLogicalPath, the same inventory
identity normalizer #1513 applied to the runtime, HTML/module, and
import joins from #1488. The feature-seed NFKC query fold stays a
query.

Refs #1488

* fix(reference): preserve source spelling during symlink lookup

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:02:10 +08:00
905e4866c4 feat(artifacts): inspect PE resource identities and payload ranges (#1482)
* feat(artifacts): inspect PE resource identities and payload ranges

* fix(artifacts): reject header payloads overlapping PE sections

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:02:06 +08:00
f6009d0077 fix(browser): keep the first DOM attribute when the name is repeated (#1516)
* fix(browser): keep the first DOM attribute when the name is repeated

When an element in a captured DOM tree carries duplicate attribute
declarations (e.g. repeated href, action, or src), domMetadata folded
the attributes into a Map using pairs.set(), overwriting earlier
declarations with the last one.

HTML specifies that the first declaration is kept when an attribute
name is repeated. This aligns domMetadata and attribute_names to retain
the first occurrence case-insensitively, matching HTML and ensuring that
attribute_names and dom_urls reflect the same attributes.

Fixes #1496

* fix(browser): preserve case-sensitive DOM attribute names

* test(browser): align repeated attribute coverage with deduplication

---------

Co-authored-by: taljeri <318237806+taljeri@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:02:00 +08:00
4d8d76b253 fix(electron): support packaged app.asar pages and rewritten unpacked companions (#1400)
* fix(electron): resolve pages served from inside app.asar

Packaged Electron applications load their renderer pages from file URLs
inside app.asar. The host filesystem cannot stat those paths, so
list_electron_targets excluded every such page as unsupported_url and
inspect_electron_page could not select it.

Resolve these URLs the way Electron's file protocol does: the first .asar
path component that is a regular file is the archive, and the remainder
must name a regular file in its header. Directories, links, and missing
members stay rejected. Parsed headers are cached by archive identity.

* fix(artifacts): extract and explain rewritten ASAR unpacked companions

Packaging tools commonly sign or strip .asar.unpacked native binaries after
writing the archive header, so their declared hashes no longer match.
analyze_javascript_application already accepts integrity_policy, but
extract_artifact did not, and inspect_artifact and extract_artifact still
told callers to "get a fresh copy", which cannot help for a shipped app.

- Accept integrity_policy on extract_artifact (MCP and CLI). The default
  still fails closed; record-and-continue writes the observed bytes and
  returns the extracted files' integrity_contradictions records.
- Name the unpacked entry and the post-packaging cause in integrity
  failures, and point every operation that accepts integrity_policy at
  record-and-continue.
- Report an active unpacked entry whose companion file is absent as
  unavailable with its logical path instead of a format failure.
- Share one integrity_policy schema across the inventory, extraction, and
  JavaScript application inputs.

* fix(electron): bound ASAR headers before allocation

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:01:54 +08:00
162f9bf7ff fix(ghidra): clear wrong no-return flags before serving a session (#1419)
* fix(ghidra): clear wrong no-return flags before serving a session

Auto-analysis can flag ordinary functions as no-return (stream readers,
__cxa_guard_acquire, ...). Their callers then end at the call and the
decompiler truncates them. Run a postScript before the bridge that clears the
flag on functions containing a real RET and re-disassembles the call sites that
were terminators.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(ghidra): refit in place and scope no-return call-site repair

Keep function types by refitting the owning functions with fixupFunctionBody
instead of removing and recreating them. Only redo call sites whose callee was
actually cleared (including thunks to it), skip sites followed by a function
start or x86 padding so real end calls do not run on, and always log the result.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(ghidra): constrain no-return repair to admitted return evidence

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:01:50 +08:00
fef6f73064 feat(adb): complete ADB device support for Android reverse engineering (#1552)
* feat(adb): acquire installed APK sets from connected Android devices

Add a read-only ADB device-acquisition family over the caller's adb binary:
inspect_adb_client, list_adb_devices, inspect_adb_device, list_adb_packages,
and pull_adb_package, exposed through the CLI and MCP with shared admission,
Evidence provenance (binary, version, serial, transport), and fixed argv
vectors that never reach a shell.

pull_adb_package acquires the complete split set reported by pm path into a
package-named directory that must not exist, digesting every file with
SHA-256 and keeping per-file failure reasons and partial coverage. Device
kind is reported as an inference with its basis; getprop whitelisting keeps
build identity bounded. REA installs nothing and never manages devices or
emulators; the first device query may start the caller's local adb server,
recorded when observed.

Verified against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64
emulator: full 224-package inventory, a real two-APK split set pulled with
byte-exact digests, and a 73.9 MB system-package pull. npm run verify:adb
records the lane; parser goldens come from the same device. READMEs carry
the new row in every supported language.

* feat(adb): complete the adb surface with observation, transfer, and lifecycle tools

Extend the ADB family from five to twenty-four operations, all through
fixed adb argument vectors with no shell exposure:

Observation: read_adb_logcat (bounded dump, count/buffer/pid), inspect_adb_package
(dumpsys package projection), list_adb_processes (ps -A), list_adb_directory
(ls -la with kinds and symlink targets), list_adb_features (hex GL versions),
list_adb_services (AIDL slash-suffixed names), inspect_adb_display (wm size and
density), inspect_adb_window (focus declarations), read_adb_setting (device-side
nulls reported as such), capture_adb_screen (exec-out screencap with PNG
dimensions), collect_adb_bugreport (digested device archive).

Transfer and search: pull_adb_file and push_adb_file (device-side sha256sum
verification, explicit overwrite authority), resolve_adb_packages (substring
package search with exact-match flag — the find step of an easy get).

Lifecycle for dynamic analysis, each declaring mutatesTarget:
install_adb_package and uninstall_adb_package preserving the device's
Success/Failure verdict, start_adb_app (resolve-activity then am start -W),
start_adb_activity (typed extras as --es/--ez/--ei/--el/--ef), and stop_adb_app
(am force-stop).

Verified end-to-end against adb 34.0.5 on Linux with an Android 14 (API 34)
x86_64 emulator: 287 services, 92 features, split-set pulls with byte-exact
digests, verified push/pull roundtrip, sized screen capture, and the full
install → resolve → start → observe-in-ps → force-stop → uninstall lifecycle.
Fixed a v-mode regex incompatibility in the settings key pattern and added
argument descriptions required by the CLI catalog audit.

* fix(adb): quote remote shell operands and protect captured output

* test(adb): cover bugreport destinations containing spaces

---------

Co-authored-by: REA Contributor <contributor@rea.tools>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-10 23:01:24 +08:00
YsopwnableandGitHub efd81f9776 test(clients): verify native Grok Build workflows (#1571) 2026-10-10 22:59:43 +08:00
5156ba44c5 feat(native): return a retained summary view of binary layouts on request (#1570)
inspect_binary_layout accepts the same optional detail input as
analyze_javascript_application. The default complete delivery is
unchanged; detail "summary" retains the complete layout Evidence in the
session and returns its inspect_analysis_view summary, whose
parent_evidence_id selects sections, symbols, mitigations or linkage
later. Both tools share one detail schema and output projection.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:57:42 -07:00
c19e15a2b3 feat(javascript): return a retained summary view on request (#1568)
analyze_javascript_application returns the complete application graph
inline, which repeats tens of kilobytes even for a few source lines and
exceeds the default 10 MiB MCP frame for a 300-function source file.

The new optional detail input keeps complete delivery by default. With
detail "summary" the server records the complete analysis Evidence in
the session and returns its inspect_analysis_view summary, whose
parent_evidence_id lets callers page modules, select items, or pass the
retained reference to application workflows. A server without Evidence
retention refuses summary detail with capability_unavailable rather
than returning a summary whose analysis cannot be reached.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:37:37 -07:00
ClaudeandYsopwnable 649661dc8b fix(javascript): stop reading media preload attributes as Electron preloads
HTML audio and video elements have their own `preload` attribute ("none",
"metadata", "auto"). A React renderer compiles `<audio preload="metadata">`
to `jsx("audio", { preload: "metadata" })`, and the `property:preload`
heuristic reported it as an Electron preload script named `metadata`.

Skip `preload` properties in the props object of a call whose first
argument is the static tag `audio` or `video`. The callee is not checked,
because minified element factories have arbitrary names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPY2GmufMNGQKoEgRUx1pd
2026-10-10 22:12:56 +08:00
YsopwnableandGitHub e4cc0f09de fix(setup): reject invalid OMP profiles before fallback writes (#1562) 2026-10-10 22:11:14 +08:00
a53689c55d fix(mcp): stop advertising complete Evidence in input examples (#1560)
Hosts forward every tool's input schema to the model on each request.
Seventeen tools advertised a canonical example that embedded a complete
producer Evidence record, up to 22 KB each, accounting for 116 KB of the
450 KB of advertised input schemas.

Advertised examples now show an embedded Evidence record as its exact
retained-evidence reference when the canonical input accepts one, and
omit an example that can only carry inline Evidence. Canonical contract
examples, their executable tests, and validation are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:10:38 -07:00
419 changed files with 27029 additions and 3058 deletions
@@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "35"
version: "36"
---
# REA
@@ -121,6 +121,9 @@ explicit path or endpoint and do not need it.
Historical PIDs do not select live processes. Request `include_debugger_context`
only when core-only mapping candidates help; see the
[recorded crash guide](https://github.com/morluto/rea/blob/main/docs/recorded-crashes.md).
- PE resource type/name/language trees and static icon group references:
`inspect_pe_resources` with an explicit path. This portable operation requires
no active target or engine; opaque payloads retain file ranges and SHA-256.
- Native executable, library, or analysis database: `open_binary`, then
use focused analysis tools directly; call `binary_overview` when metadata or
inventory context is useful and available from the selected provider.
@@ -150,6 +153,10 @@ list stays complete; availability depends on the operation, target, and host.
## Work summary-first
Start with the default result and use its inline Evidence and graph context.
For a JavaScript application or ELF layout whose size is unknown or large,
call `analyze_javascript_application` or `inspect_binary_layout` with
`"detail": "summary"`, then inspect the retained `parent_evidence_id` with
`inspect_analysis_view`.
Do not repeat an identical tool call. Make a focused follow-up only when the
returned result leaves a specific question unanswered.
@@ -153,3 +153,15 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
### Function annotations in Ghidra
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations.
## Offline PE resource inventory
Use `inspect_pe_resources` with an absolute `path`, or
`rea inspect-pe-resources ./selected.exe --json`, for portable PE32/PE32+
resource inspection without opening a native target. Preserve returned Evidence:
type/name/language IDs and UTF-16 names, original directory/data/payload ranges,
code pages, payload hashes and static icon-group references. Same-language icon
resolution is explicit; missing references and size mismatch remain visible.
Other payloads are opaque, and this operation does not decode images or simulate
Windows resource loading. Consult the budgets and supported tree profile in
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md).
@@ -56,9 +56,9 @@ jobs:
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/rea-wakaru"
curl --fail --location --retry 3 https://github.com/pionxzh/wakaru/releases/download/v1.13.0/wakaru-linux-x64.tar.gz -o "$RUNNER_TEMP/rea-wakaru/release.tar.gz"
curl --fail --location --retry 3 https://github.com/pionxzh/wakaru/releases/download/v1.14.0/wakaru-linux-x64.tar.gz -o "$RUNNER_TEMP/rea-wakaru/release.tar.gz"
tar -xzf "$RUNNER_TEMP/rea-wakaru/release.tar.gz" -C "$RUNNER_TEMP/rea-wakaru" wakaru
printf '%s %s\n' e8c7ca052974604197389f6ace7464e4dd24dce64e2730971521bd6606ec9c9e "$RUNNER_TEMP/rea-wakaru/wakaru" | sha256sum --check
printf '%s %s\n' 664fa06782a61eda7ed15885134b52525fd565dd77607e642062ceb6cd927c38 "$RUNNER_TEMP/rea-wakaru/wakaru" | sha256sum --check
- name: Install isolated fixture compilers
run: npm install --prefix "$RUNNER_TEMP/rea-fixture-compilers" --ignore-scripts --no-audit --no-fund esbuild@0.25.10 webpack@5.101.3
- run: npm run build:cached
+3
View File
@@ -158,6 +158,9 @@ Additional tools and host support depend on the target:
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
| .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/managed-code-analysis.md) |
| Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) |
| Android devices | Devices, packages, processes, logs, dumps, screen captures, and APK/file transfers | Caller-supplied adb (emulator or hardware); [ADB guide](docs/adb-device-analysis.md) |
| JEB projects | Engine-backed project units, artifact digests and type/method pseudo-code | A caller-started JEB client serving MCP, selected with REA_JEB_MCP_URL; [JEB guide](docs/jeb-analysis.md) |
| Android resources | Decoded manifest, string tables, locales and version facts | Caller-supplied Apktool launcher; [resource guide](docs/apktool-resource-analysis.md) |
| Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) |
| Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) |
| Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ npx rea-agents@latest setup
| تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](docs/web-network-captures.md) |
| تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](docs/managed-code-analysis.md) |
| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](docs/android-analysis.md) |
| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-analysis.md) |
| مشاريع JEB | وحدات المشروع من المحرك وبصمات المصنّفات والكود الزائف للأنواع/الدوال | عميل JEB يبدأه المُستدعي ويقدّم MCP، ويُختار عبر REA_JEB_MCP_URL؛ [دليل JEB](docs/jeb-analysis.md) |
| موارد Android | البيان المفكوك الترميز وجداول السلاسل واللغات وحقائق الإصدار | مُشغّل Apktool يوفّره المُستدعي؛ [دليل الموارد](docs/apktool-resource-analysis.md) |
| البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | Binwalk / Unblob على Linux؛ [دليل البرامج الثابتة](docs/firmware-analysis.md) |
| الحزم والموارد | قوائم الملفات، والبصمات، وملفات plist، وبنية حزم Apple، والموارد المستخرجة | [دليل تحليل الملفات وJavaScript](docs/javascript-artifact-reconstruction.md)، [تطبيقات Apple](docs/apple-application-analysis.md) |
| سلوك العمليات | مخرجات الطرفية، والتفاعلات، وملاحظات الخروج ونظام الملفات، ومقارنات التشغيل | Linux/macOS مع PTY أصلي؛ [تسجيل سلوك العمليات](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
| Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](docs/web-network-captures.md) |
| .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](docs/managed-code-analysis.md) |
| Android-APKs | Manifest-Deklarationen, Klassen, dekompilierte Methoden und Verweise | JADX ohne grafische Oberfläche und ein vollständiges JDK unter Linux/macOS/Windows x64; [Android-Leitfaden](docs/android-analysis.md) |
| Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](docs/adb-device-analysis.md) |
| JEB-Projekte | Engine-gestützte Projekteinheiten, Artefakt-Prüfsummen und Pseudokode von Typen/Methoden | Ein vom Aufrufer gestarteter JEB-Client mit MCP, ausgewählt über REA_JEB_MCP_URL; [JEB-Leitfaden](docs/jeb-analysis.md) |
| Android-Ressourcen | Dekodiertes Manifest, String-Tabellen, Sprachen und Versionsfakten | Vom Aufrufer bereitgestellter Apktool-Starter; [Ressourcen-Leitfaden](docs/apktool-resource-analysis.md) |
| Firmware | Bereiche, Extraktionsergebnisse und Übergaben an die native Analyse | Binwalk / Unblob unter Linux; [Firmware-Leitfaden](docs/firmware-analysis.md) |
| Pakete und Ressourcen | Dateiinventare, Hashwerte, plists, Aufbau von Apple-Bundles und extrahierte Ressourcen | [Artefakt- und JavaScript-Leitfaden](docs/javascript-artifact-reconstruction.md), [Apple-Anwendungen](docs/apple-application-analysis.md) |
| Prozessverhalten | Terminalausgabe, Interaktionen, Beobachtungen zu Prozessende und Dateisystem sowie Laufvergleiche | Linux/macOS mit nativer PTY; [Prozessaufzeichnung](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
| Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](docs/web-network-captures.md) |
| Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](docs/managed-code-analysis.md) |
| APK de Android | Declaraciones del manifiesto, clases, métodos descompilados y referencias | JADX sin interfaz gráfica y un JDK completo en Linux/macOS/Windows x64; [guía de Android](docs/android-analysis.md) |
| Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](docs/adb-device-analysis.md) |
| Proyectos JEB | Unidades de proyecto del motor, resúmenes de artefactos y pseudocódigo de tipos/métodos | Un cliente JEB iniciado por el autor de la llamada que sirve MCP, seleccionado con REA_JEB_MCP_URL; [guía de JEB](docs/jeb-analysis.md) |
| Recursos de Android | Manifiesto decodificado, tablas de cadenas, idiomas y datos de versión | Lanzador de Apktool suministrado por el autor de la llamada; [guía de recursos](docs/apktool-resource-analysis.md) |
| Firmware | Regiones, resultados de extracción y derivaciones al análisis nativo | Binwalk / Unblob en Linux; [guía de firmware](docs/firmware-analysis.md) |
| Paquetes y recursos | Inventarios de archivos, resúmenes criptográficos, plists, estructura de paquetes de Apple y recursos extraídos | [Guía de artefactos y JavaScript](docs/javascript-artifact-reconstruction.md), [aplicaciones de Apple](docs/apple-application-analysis.md) |
| Comportamiento de procesos | Salida de la terminal, interacciones, observaciones de salida y del sistema de archivos, y comparaciones entre ejecuciones | Linux/macOS con una PTY nativa; [captura de procesos](docs/process-capture.md) |
+17 -14
View File
@@ -123,20 +123,23 @@ npx rea-agents@latest setup
REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه npm نیاز دارد. ابزارهای اضافی و پشتیبانی سیستم میزبان به نوع هدف بستگی دارند:
| هدف | خروجی REA | پیش‌نیازها و راهنما |
| --------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/guides/native/) |
| ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](docs/binary-diagnostics.md) |
| بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](docs/evm-bytecode.md) |
| کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](docs/recorded-crashes.md) |
| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/guides/javascript/) |
| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/guides/browser/) |
| داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](docs/web-network-captures.md) |
| اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](docs/managed-code-analysis.md) |
| فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](docs/android-analysis.md) |
| Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) |
| بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) |
| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) |
| هدف | خروجی REA | پیش‌نیازها و راهنما |
| --------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/guides/native/) |
| ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](docs/binary-diagnostics.md) |
| بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](docs/evm-bytecode.md) |
| کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](docs/recorded-crashes.md) |
| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/guides/javascript/) |
| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/guides/browser/) |
| داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](docs/web-network-captures.md) |
| اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](docs/managed-code-analysis.md) |
| فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](docs/android-analysis.md) |
| دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-analysis.md) |
| پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](docs/jeb-analysis.md) |
| منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](docs/apktool-resource-analysis.md) |
| Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) |
| بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) |
| رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) |
بررسی ایستای JavaScript و .NET فایل‌های ارائه‌شده را بدون اجرای برنامه می‌خواند. ضبط زمان اجرا، هدف انتخاب‌شده را با مجوزهای کاربری شما اجرا می‌کند یا با آن تعامل دارد؛ آثار این عملیات در راهنمای مربوط به هر نوع ضبط توضیح داده شده‌اند.
+3
View File
@@ -143,6 +143,9 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
| Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](docs/web-network-captures.md) |
| Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](docs/managed-code-analysis.md) |
| APK Android | Déclarations du manifeste, classes, méthodes décompilées et références | JADX headless et un JDK complet sous Linux/macOS/Windows x64 ; [guide Android](docs/android-analysis.md) |
| Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](docs/adb-device-analysis.md) |
| Projets JEB | Unités de projet fournies par le moteur, condensés d'artefacts et pseudo-code de types/méthodes | Un client JEB démarré par l'appelant servant MCP, sélectionné via REA_JEB_MCP_URL ; [guide JEB](docs/jeb-analysis.md) |
| Ressources Android | Manifeste décodé, tables de chaînes, langues et faits de version | Lanceur Apktool fourni par l'appelant ; [guide des ressources](docs/apktool-resource-analysis.md) |
| Firmware | Régions, résultats d'extraction et passage de relais vers l'analyse native | Binwalk / Unblob sous Linux ; [guide firmware](docs/firmware-analysis.md) |
| Paquets et ressources | Inventaires de fichiers, empreintes, plists, anatomie des bundles Apple et ressources extraites | [Guide artefacts et JavaScript](docs/javascript-artifact-reconstruction.md), [applications Apple](docs/apple-application-analysis.md) |
| Comportement des processus | Sortie du terminal, interactions, code de sortie, observations du système de fichiers et comparaisons | Linux/macOS avec un PTY natif ; [capture de processus](docs/process-capture.md) |
+3
View File
@@ -146,6 +146,9 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](docs/web-network-captures.md) |
| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](docs/managed-code-analysis.md) |
| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](docs/android-analysis.md) |
| Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](docs/adb-device-analysis.md) |
| Proyek JEB | Unit proyek bertenaga mesin, hash artefak, dan pseudokode tipe/metode | Klien JEB yang dijalankan pemanggil dan menyajikan MCP, dipilih dengan REA_JEB_MCP_URL; [panduan JEB](docs/jeb-analysis.md) |
| Resource Android | Manifest terdekode, tabel string, lokal, dan fakta versi | Peluncur Apktool buatan pemanggil; [panduan resource](docs/apktool-resource-analysis.md) |
| Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](docs/firmware-analysis.md) |
| Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](docs/javascript-artifact-reconstruction.md), [aplikasi Apple](docs/apple-application-analysis.md) |
| Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](docs/process-capture.md) |
+3
View File
@@ -143,6 +143,9 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
| Acquisizioni di rete salvate | Richieste, risposte, payload esposti e posizioni sorgente | HAR; mitmdump su Linux per acquisizioni mitmproxy native; [guida alle acquisizioni](docs/web-network-captures.md) |
| Assembly .NET | Metadati, istruzioni CIL, dipendenze native dichiarate e confronti di build | Nessuno (solo statico); [guida managed-code](docs/managed-code-analysis.md) |
| APK Android | Dichiarazioni manifest, classi, metodi decompilati e riferimenti | JADX headless e un JDK completo su Linux/macOS/Windows x64; [guida Android](docs/android-analysis.md) |
| Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](docs/adb-device-analysis.md) |
| Progetti JEB | Unità di progetto dal motore, digest degli artifact e pseudocodice di tipi/metodi | Client JEB avviato dal chiamante via MCP, selezionato con REA_JEB_MCP_URL; [guida JEB](docs/jeb-analysis.md) |
| Risorse Android | Manifest decodificato, tabelle stringhe, lingue e dati di versione | Launcher Apktool fornito dal chiamante; [guida alle risorse](docs/apktool-resource-analysis.md) |
| Firmware | Regioni, risultati di estrazione e passaggi all'analisi nativa | Binwalk / Unblob su Linux; [guida firmware](docs/firmware-analysis.md) |
| Pacchetti e risorse | Inventari file, digest, plist, anatomia dei bundle Apple e risorse estratte | [Guida artifact e JavaScript](docs/javascript-artifact-reconstruction.md), [Applicazioni Apple](docs/apple-application-analysis.md) |
| Comportamento dei processi | Output del terminale, interazioni, osservazioni su uscita e filesystem ed confronti di esecuzione | Linux/macOS con un PTY nativo; [cattura dei processi](docs/process-capture.md) |
+17 -14
View File
@@ -131,20 +131,23 @@ npx rea-agents@latest setup
REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm が必要です。追加のツールや対応ホストは、対象によって異なります。
| 対象 | REA が返すもの | 要件とガイド |
| -------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/guides/native/) |
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](docs/binary-diagnostics.md) |
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](docs/evm-bytecode.md) |
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](docs/recorded-crashes.md) |
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/guides/javascript/) |
| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/guides/browser/) |
| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](docs/web-network-captures.md) |
| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](docs/managed-code-analysis.md) |
| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) |
| ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](docs/firmware-analysis.md) |
| パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](docs/javascript-artifact-reconstruction.md)、[Apple アプリケーション](docs/apple-application-analysis.md) |
| プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](docs/process-capture.md) |
| 対象 | REA が返すもの | 要件とガイド |
| -------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/guides/native/) |
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](docs/binary-diagnostics.md) |
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](docs/evm-bytecode.md) |
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](docs/recorded-crashes.md) |
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/guides/javascript/) |
| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/guides/browser/) |
| 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](docs/web-network-captures.md) |
| .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](docs/managed-code-analysis.md) |
| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) |
| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-analysis.md) |
| JEBプロジェクト | エンジンによるプロジェクトユニット、アーティファクトのダイジェスト、型/メソッドの擬似コード | 呼び出し側が起動し MCP を提供する JEB クライアント(REA_JEB_MCP_URL で選択);[JEB ガイド](docs/jeb-analysis.md) |
| Android リソース | デコードされたマニフェスト、文字列テーブル、ロケール、バージョン情報 | 呼び出し側が用意した Apktool ランチャー;[リソースガイド](docs/apktool-resource-analysis.md) |
| ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](docs/firmware-analysis.md) |
| パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](docs/javascript-artifact-reconstruction.md)、[Apple アプリケーション](docs/apple-application-analysis.md) |
| プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](docs/process-capture.md) |
静的な JavaScript と .NET の検査は、指定されたファイルを読み取り、アプリケーションを実行しません。実行時キャプチャは、あなたのユーザー権限で対象を実行したり、対象とやり取りしたりします。それぞれの実行時ガイドに、どのような操作が行われるかを記載しています。
+3
View File
@@ -142,6 +142,9 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](docs/web-network-captures.md) |
| .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](docs/managed-code-analysis.md) |
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](docs/android-analysis.md) |
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-analysis.md) |
| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](docs/jeb-analysis.md) |
| Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](docs/apktool-resource-analysis.md) |
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](docs/firmware-analysis.md) |
| 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](docs/javascript-artifact-reconstruction.md), [Apple 애플리케이션](docs/apple-application-analysis.md) |
| 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
| Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](docs/web-network-captures.md) |
| Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](docs/managed-code-analysis.md) |
| Pakiety APK dla Androida | Deklaracje manifestu, klasy, zdekompilowane metody i odwołania | JADX bez interfejsu graficznego i pełny JDK w systemach Linux/macOS/Windows x64; [przewodnik Androida](docs/android-analysis.md) |
| Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](docs/adb-device-analysis.md) |
| Projekty JEB | Jednostki projektu obsługiwane przez silnik, skróty artefaktów i pseudokod typów/metod | Uruchomiony przez wywołującego klient JEB udostępniający MCP, wybrany przez REA_JEB_MCP_URL; [przewodnik JEB](docs/jeb-analysis.md) |
| Zasoby Androida | Zdekodowany manifest, tablice ciągów, języki i informacje o wersji | Należący do wywołującego launcher Apktool; [przewodnik zasobów](docs/apktool-resource-analysis.md) |
| Oprogramowanie układowe | Regiony, wyniki wyodrębniania i przekazanie do analizy kodu natywnego | Binwalk / Unblob w systemie Linux; [przewodnik oprogramowania układowego](docs/firmware-analysis.md) |
| Pakiety i zasoby | Spisy plików, skróty, pliki plist, struktura pakietów Apple i wyodrębnione zasoby | [Przewodnik artefaktów i JavaScript](docs/javascript-artifact-reconstruction.md), [aplikacje Apple](docs/apple-application-analysis.md) |
| Zachowanie procesów | Wyjście terminala, interakcje, obserwacje zakończenia i systemu plików oraz porównania uruchomień | Linux/macOS z natywnym PTY; [przechwytywanie procesów](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
| Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](docs/web-network-captures.md) |
| Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](docs/managed-code-analysis.md) |
| APKs Android | Declarações do manifesto, classes, métodos descompilados e referências | JADX sem interface gráfica e um JDK completo no Linux/macOS/Windows x64; [guia de Android](docs/android-analysis.md) |
| Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](docs/adb-device-analysis.md) |
| Projetos JEB | Unidades de projeto do motor, hashes de artefatos e pseudocódigo de tipos/métodos | Um cliente JEB iniciado pelo chamador servindo MCP, selecionado com REA_JEB_MCP_URL; [guia de JEB](docs/jeb-analysis.md) |
| Recursos do Android | Manifesto decodificado, tabelas de strings, idiomas e fatos de versão | Lançador do Apktool fornecido pelo chamador; [guia de recursos](docs/apktool-resource-analysis.md) |
| Firmware | Regiões, resultados de extração e encaminhamentos à análise nativa | Binwalk / Unblob no Linux; [guia de firmware](docs/firmware-analysis.md) |
| Pacotes e recursos | Inventários de arquivos, digests, plists, estrutura de bundles Apple e recursos extraídos | [Guia de artefatos e JavaScript](docs/javascript-artifact-reconstruction.md), [aplicativos Apple](docs/apple-application-analysis.md) |
| Comportamento de processos | Saída do terminal, interações, observações de encerramento e do sistema de arquivos, e comparações entre execuções | Linux/macOS com PTY nativo; [captura de processos](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
| Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](docs/web-network-captures.md) |
| Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](docs/managed-code-analysis.md) |
| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](docs/android-analysis.md) |
| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-analysis.md) |
| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](docs/jeb-analysis.md) |
| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](docs/apktool-resource-analysis.md) |
| Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) |
| Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) |
| Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
| ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](docs/web-network-captures.md) |
| แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](docs/managed-code-analysis.md) |
| APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](docs/android-analysis.md) |
| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-analysis.md) |
| โปรเจกต์ JEB | หน่วยโปรเจกต์จากเอนจิน, แฮชของอาร์ติแฟกต์ และซูโดโค้ดของไทป์/เมท็อด | ไคลเอนต์ JEB ที่ผู้เรียกเริ่มและให้บริการ MCP เลือกด้วย REA_JEB_MCP_URL; [คู่มือ JEB](docs/jeb-analysis.md) |
| ทรัพยากร Android | ไฟล์ manifest ที่ถอดรหัสแล้ว ตารางสตริง ภาษา และข้อมูลเวอร์ชัน | ตัวเรียก Apktool ที่ผู้เรียกจัดเตรียม; [คู่มือทรัพยากร](docs/apktool-resource-analysis.md) |
| เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | Binwalk / Unblob บน Linux; [คู่มือเฟิร์มแวร์](docs/firmware-analysis.md) |
| แพ็กเกจและทรัพยากร | รายการไฟล์ ค่าแฮช plist โครงสร้างบันเดิลของ Apple และทรัพยากรที่แตกออกมา | [คู่มืออาร์ติแฟกต์และ JavaScript](docs/javascript-artifact-reconstruction.md), [แอปพลิเคชัน Apple](docs/apple-application-analysis.md) |
| พฤติกรรมของโปรเซส | เอาต์พุตเทอร์มินัล การโต้ตอบ ข้อมูลการสิ้นสุดและระบบไฟล์ที่สังเกตได้ และการเปรียบเทียบการรัน | Linux/macOS ที่มี PTY เนทีฟ; [การบันทึกโปรเซส](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR; mitmproxy'nin kendi biçimindeki yakalamalar için Linux üzerinde mitmdump; [ağ yakalama rehberi](docs/web-network-captures.md) |
| .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](docs/managed-code-analysis.md) |
| Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](docs/android-analysis.md) |
| Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](docs/adb-device-analysis.md) |
| JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](docs/jeb-analysis.md) |
| Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](docs/apktool-resource-analysis.md) |
| Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](docs/firmware-analysis.md) |
| Paketler ve kaynaklar | Dosya envanterleri, özet değerleri, plist'ler, Apple paket yapısı ve çıkarılmış kaynaklar | [Artefakt ve JavaScript rehberi](docs/javascript-artifact-reconstruction.md), [Apple uygulamaları](docs/apple-application-analysis.md) |
| Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
| Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](docs/web-network-captures.md) |
| Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](docs/managed-code-analysis.md) |
| Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](docs/android-analysis.md) |
| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-analysis.md) |
| Проєкти JEB | Єдиниці проєкту від рушія, дайджести артефактів і псевдокод типів/методів | Запущений викликаною стороною клієнт JEB з MCP, обирається через REA_JEB_MCP_URL; [посібник JEB](docs/jeb-analysis.md) |
| Ресурси Android | Декодований маніфест, таблиці рядків, мови та відомості про версію | Засіб запуску Apktool, наданий викликаючою стороною; [підручник ресурсів](docs/apktool-resource-analysis.md) |
| Прошивки | Області, результати видобування й передавання до нативного аналізу | Binwalk / Unblob у Linux; [посібник прошивок](docs/firmware-analysis.md) |
| Пакети й ресурси | Переліки файлів, хеші, plist, структура пакетів Apple та видобуті ресурси | [Посібник артефактів і JavaScript](docs/javascript-artifact-reconstruction.md), [застосунки Apple](docs/apple-application-analysis.md) |
| Поведінка процесів | Вивід термінала, взаємодії, спостереження за завершенням і файловою системою та порівняння запусків | Linux/macOS із нативним PTY; [захоплення процесів](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
| Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](docs/web-network-captures.md) |
| Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](docs/managed-code-analysis.md) |
| APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](docs/android-analysis.md) |
| Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](docs/adb-device-analysis.md) |
| Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](docs/jeb-analysis.md) |
| Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](docs/apktool-resource-analysis.md) |
| Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](docs/firmware-analysis.md) |
| Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](docs/javascript-artifact-reconstruction.md), [ứng dụng Apple](docs/apple-application-analysis.md) |
| Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](docs/web-network-captures.md) |
| .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](docs/managed-code-analysis.md) |
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-analysis.md) |
| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](docs/jeb-analysis.md) |
| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](docs/apktool-resource-analysis.md) |
| 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](docs/firmware-analysis.md) |
| 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 應用](docs/apple-application-analysis.md) |
| 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](docs/process-capture.md) |
+3
View File
@@ -142,6 +142,9 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](docs/web-network-captures.md) |
| .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](docs/managed-code-analysis.md) |
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-analysis.md) |
| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](docs/jeb-analysis.md) |
| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](docs/apktool-resource-analysis.md) |
| 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](docs/firmware-analysis.md) |
| 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 应用](docs/apple-application-analysis.md) |
| 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](docs/process-capture.md) |
+89
View File
@@ -0,0 +1,89 @@
import ghidra.app.cmd.function.CreateFunctionCmd;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.*;
import ghidra.program.model.symbol.FlowType;
import ghidra.program.model.pcode.PcodeOp;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
/**
* Undo wrong no-return flags from auto-analysis. A function flagged no-return that contains a
* real RET is an ordinary function (stream readers, cxa_guard_acquire, ...). Callers treated the
* call as a terminator and the decompiler truncated them. Clear the flag, then re-disassemble only
* the call sites of the functions that were cleared and refit their owning functions in place.
*/
public final class ReaGhidraNoReturnFix extends GhidraScript {
@Override
public void run() throws Exception {
// Windows P0 does not admit analysis-database mutation.
if (System.getProperty("os.name", "").startsWith("Windows")) return;
FunctionManager fm = currentProgram.getFunctionManager();
Listing li = currentProgram.getListing();
Set<Address> cleared = new HashSet<>();
for (Function f : fm.getFunctions(true)) {
monitor.checkCancelled();
if (!f.hasNoReturn() || f.isThunk()) continue;
for (Instruction ins : li.getInstructions(f.getBody(), true)) {
FlowType ft = ins.getFlowType();
boolean decodedReturn = false;
for (PcodeOp operation : ins.getPcode()) {
if (operation.getOpcode() == PcodeOp.RETURN) decodedReturn = true;
}
if (decodedReturn && !ft.isJump() && !ft.isCall()) {
f.setNoReturn(false);
cleared.add(f.getEntryPoint());
break;
}
}
}
// A thunk inherits the flag of the function it forwards to.
for (Function f : fm.getFunctions(true)) {
monitor.checkCancelled();
if (!f.isThunk()) continue;
Function target = f.getThunkedFunction(true);
if (target != null && cleared.contains(target.getEntryPoint())) {
cleared.add(f.getEntryPoint());
}
}
int redone = 0;
TreeSet<Address> owners = new TreeSet<>();
if (!cleared.isEmpty()) {
List<Address> sites = new ArrayList<>();
for (Instruction ins : li.getInstructions(true)) {
monitor.checkCancelled();
FlowType ft = ins.getFlowType();
if (!(ft.isCall() && ft.isTerminal())) continue;
Address[] flows = ins.getFlows();
if (flows.length == 0 || !cleared.contains(flows[0])) continue;
// Do not redisassemble into an independently identified function.
Address next = ins.getMaxAddress().add(1);
if (fm.getFunctionAt(next) != null || !currentProgram.getMemory().contains(next)) continue;
sites.add(ins.getAddress());
}
for (Address s : sites) {
monitor.checkCancelled();
Instruction ins = li.getInstructionAt(s);
if (ins == null) continue;
Function owner = fm.getFunctionContaining(s);
if (owner != null && !owner.isThunk()) owners.add(owner.getEntryPoint());
clearListing(ins.getMinAddress(), ins.getMaxAddress());
disassemble(s);
redone++;
}
for (Address e : owners) {
monitor.checkCancelled();
Function f = fm.getFunctionAt(e);
// Refit the body in place so the name, signature and calling convention survive.
if (f != null) CreateFunctionCmd.fixupFunctionBody(currentProgram, f, monitor);
}
// No analyzeChanges here: on libpl2.so it adds about 70 s, which pushes startup past the 330 s deadline.
}
println(
"REA no-return fix: cleared=" + cleared.size() + " sites=" + redone + " refit=" + owners.size());
}
}
+170
View File
@@ -0,0 +1,170 @@
# ADB device inspection and acquisition
REA inspects connected Android devices, reads device state, and transfers
files and packages through the caller's `adb` binary. Emulators, USB
devices, and network devices participate alike: REA neither starts nor
manages them and never installs platform tools. Every operation composes a
fixed adb argument vector — caller values occupy positional arguments only
and are quoted when adb forwards them to the device shell.
The adb client may start its local server on first device query; that is adb
client behavior, recorded in the tool result when observed.
`inspect_adb_client` probes only the binary and never contacts the server.
## Supply tools
Use any Android platform-tools `adb` (verified with 34.0.5). Select it
through the environment:
```sh
# Optional: absolute path to the adb binary; without it, adb resolves from PATH.
export REA_ADB_PATH=/absolute/path/to/adb
```
REA never installs the Android SDK, an emulator, or a system image.
Readiness is `rea inspect-adb-client`: it runs `adb version` and reports the
resolved binary, its version, and where it was selected from. A missing
binary reports `capability_unavailable` with the `REA_ADB_PATH` remediation
rather than attempting an installation.
## Answer an analyst question
```sh
# Device identity
rea inspect-adb-client
rea list-adb-devices
rea inspect-adb-device emulator-5554
rea inspect-adb-display emulator-5554
rea list-adb-features emulator-5554
rea list-adb-services emulator-5554
# Find and acquire an app (the easy-get flow)
rea resolve-adb-packages emulator-5554 whatsapp
rea list-adb-packages emulator-5554 --scope third_party
rea inspect-adb-package emulator-5554 com.whatsapp
rea pull-adb-package emulator-5554 com.whatsapp --output-directory /tmp/acq
rea inspect-android-package /tmp/acq/com.whatsapp/base.apk
# Device state and files
rea list-adb-processes emulator-5554
rea inspect-adb-window emulator-5554
rea read-adb-setting emulator-5554 global adb_enabled
rea read-adb-logcat emulator-5554 --count 200
rea list-adb-directory emulator-5554 /data/data/com.whatsapp
rea pull-adb-file emulator-5554 /data/local/tmp/notes.txt --output-directory /tmp/acq
rea push-adb-file emulator-5554 /tools/frida-server /data/local/tmp/frida-server
rea capture-adb-screen emulator-5554 --output-directory /tmp/shots
rea collect-adb-bugreport emulator-5554 --output-directory /tmp/bugs
# Package lifecycle and dynamic analysis (device-mutating)
rea install-adb-package emulator-5554 /tmp/app.apk --replace
rea start-adb-app emulator-5554 com.example.app
rea read-adb-logcat emulator-5554 --pid 12345
rea start-adb-activity emulator-5554 android.intent.action.VIEW --data-uri https://example.com
rea stop-adb-app emulator-5554 com.example.app
rea uninstall-adb-package emulator-5554 com.example.app
```
Equivalent MCP methods use the snake_case names (`list_adb_devices`,
`pull_adb_package`, `resolve_adb_packages`, …). Device operations take
`serial` exactly as `adb devices` reports it. The pulled APK set feeds the
existing analysis families directly, for example
`rea inspect-android-package /tmp/acq/<package>/base.apk`.
## Operations
| Tool | Kind | What it does |
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inspect_adb_client` | read | Resolved binary, version, install path; never contacts the server |
| `list_adb_devices` | read | Serial, state, transport, model metadata, inferred emulator/physical kind with basis |
| `inspect_adb_device` | read | Fixed `getprop` whitelist: build, SDK, patch, fingerprint, product, ABIs, debuggable, `ro.kernel.qemu` |
| `inspect_adb_display` | read | `wm size` and `wm density` as declared |
| `list_adb_features` | read | `pm list features` (hardware/software features, hex GL versions) |
| `list_adb_services` | read | `service list` binder services and interfaces |
| `list_adb_processes` | read | `ps -A`: user, pid, ppid, rss, state, name |
| `inspect_adb_window` | read | `dumpsys window` focus declarations (what is on screen) |
| `read_adb_setting` | read | One `settings get` value; device-side null is reported as such |
| `read_adb_logcat` | read | Bounded `logcat -d -t` dump for one buffer, optional pid filter |
| `list_adb_packages` | read | `pm list packages -f` with scope; base APK device paths |
| `resolve_adb_packages` | read | Case-insensitive package-name substring search with exact-match flag |
| `inspect_adb_package` | read | `dumpsys package` projection: versions, install times, installer, flags, permission count |
| `list_adb_directory` | read | `ls -la`: kinds, permissions, owner, group, sizes, symlink targets |
| `pull_adb_package` | read | Complete split APK set from `pm path`, each file digested |
| `pull_adb_file` | read | One absolute device path pulled and digested |
| `capture_adb_screen` | read | One `screencap -p` frame (exec-out, no device-side file) with dimensions |
| `collect_adb_bugreport` | read | Device-generated bugreport zip, digested |
| `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); refuses existing targets without explicit overwrite; verifies via the device's own sha256sum |
| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves the device's Success/Failure verdict |
| `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 |
| `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) |
| `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict |
| `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run |
## Interpret the result
- **Inferences carry their basis.** Device `kind` reports
`kind_basis` (serial prefix or USB transport); APK `role` reports
`role_basis: file_name`; `emulator_observed` is the build's own
`ro.kernel.qemu` value, `null` when absent.
- **Device paths are not host paths.** `base_apk_device_path` values live on
the device filesystem. Only pulls and captures produce host-local paths,
each paired with byte size and SHA-256 digest.
- **Splits are part of the answer.** `pm path` enumerates the base APK and
every installed split; pulls cover the whole set. System packages whose
APKs keep other names report the `unknown` role rather than guessing.
- **Partial results keep facts.** Failed individual pulls return successful
artifacts with `coverage: "partial"` and per-file `failures`. Listings
that contain unrecognized lines report them as `unparsed_lines` and mark
partial coverage instead of dropping rows.
- **Mutations are explicit.** `push_adb_file`, `install_adb_package`, and
`uninstall_adb_package` declare `mutatesTarget` and never run implicitly:
pushes refuse existing device files unless `overwrite` is set and verify
the transfer with the device's own `sha256sum`; installs preserve the
device's `Failure [...]` reason verbatim.
- **Producer identity stays attached.** Every result embeds the producing
adb client (`binary_path`, `path_source`, `version`, `installed_path`).
## Resource and lifecycle limits
- Caller values reach adb only as positional arguments in fixed vectors;
no tool accepts an arbitrary shell command line, and UI interaction
(`input`, `am`, `monkey`) is deliberately not exposed.
- Per-invocation budgets: client/devices 10-20 s, shell reads 20-60 s with
64 KiB-8 MiB capture caps (logcat 4 MiB, screen 32 MiB), transfers and
bugreports 600 s. Exceeded budgets report `resource_constraint`; stopped
commands report `provider_timeout`.
- Pull and capture outputs are additive: the destination must not already
exist. Re-running a completed pull refuses the existing directory instead
of overwriting evidence.
- Cancellation during a package pull preserves caller-selected output. The
error includes partial observations identifying completed artifacts and the
interrupted local path; remaining APKs were not attempted.
- Failed package acquisition rollback retains its directory identity and retries
cleanup before later work and during shutdown. Cleanup refuses a replacement
directory. Terminal shutdown stops admission and drains active operations.
- The provider holds no long-lived processes; every invocation is one
bounded adb run. The adb server belongs to the caller.
## Related capability and follow-up lanes
UI interaction beyond intent starts (tapping, typing, monkey fuzzing, screen
recording) remains a separate lane: candidates such as
[mobile-device-mcp](https://github.com/srmorete/mobile-device-mcp) install
driver software on the device and pair with the Frida instrumentation lane
(#1359) behind explicit mutation-authority contracts. Network transports
beyond already-connected devices (`adb connect`) are not exposed yet.
## Real verification
`npm run verify:adb` exercises every operation 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.
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,
287 services, 92 features, a real two-APK split set pulled with byte-exact
digests, a verified push/pull roundtrip, a 1.3 MB screen capture, and the
complete package lifecycle; see `docs/testing.md` for the lane summary.
+86
View File
@@ -0,0 +1,86 @@
# Apktool resource decoding
REA decodes Android resource tables and the binary manifest through the
caller's Apktool launcher. This completes the static triad for Android
targets: Java/Kotlin sources through the JADX family, engine-grade
decompilation through JEB, and resources through this family. REA never
installs Apktool or a Java runtime.
## Supply tools
Use Apktool 2.7.0 or newer (verified with 2.7.0) and a Java runtime the
launcher can find. Select the launcher through the environment:
```sh
# Optional: absolute path to the apktool launcher (upstream wrapper or a
# java -jar script); without it, apktool resolves from PATH.
export REA_APKTOOL_COMMAND=/absolute/path/apktool
```
Readiness is `rea inspect-apktool-client`: it runs `apktool --version` and
reports the resolved launcher, its selection source, and its version. A
missing launcher reports `capability_unavailable` with the
`REA_APKTOOL_COMMAND` remediation rather than attempting an installation.
## Answer an analyst question
```sh
rea inspect-apktool-client
rea decode-android-resources /targets/Example.apk
rea decode-android-resources /targets/Example.apk --locale de
rea decode-android-resources /targets/Example.apk --no-strings
```
Equivalent MCP methods are `inspect_apktool_client` and
`decode_android_resources`. The decode request takes `path`, an optional
`locale` (project `res/values-<locale>/strings.xml` instead of the default
table), and `include_strings` (default true). Regional locales such as `pt-BR`
select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
`b+zh+Hans+CN` may also be supplied directly.
## Interpret the result
- **An owned APK snapshot is digested and decoded.** `target.sha256` and
`target.bytes` bind the observation to the exact APK that was decoded.
- **Metadata comes from apktool.yml as apktool printed it.** Version codes
keep their own spelling (`'7'` versus `7`); `package_name` is read from
the decoded manifest, not inferred.
- **The manifest is the decoded text**, bounded at 512 KiB and reported as
truncated when larger.
- **Strings are projected verbatim.** Escaped entities stay escaped, and
entries containing nested markup (`<b>`, `<xliff:g>`) are counted as
unparsed rather than flattened — the limitation names the count. The
default table is bounded to 2000 entries with partial coverage when more
exist; `locales` lists every `values-<locale>` that has a strings table.
- **Smali is skipped by design** (`apktool d --no-src`): Java-source
recovery belongs to the JADX family. Resource smali edits and rebuild are
a follow-up lane with mutation authority.
- **Workspaces are owned and short-lived.** Decoding happens in a
provider-owned temporary directory that is removed before the result is
returned. The APK snapshot and framework cache both live inside this directory.
If removal fails, the result names the residual path and the provider retains
ownership for a cleanup retry on close, including failed decodes.
## Resource and lifecycle limits
- The decode runs with a 600 s deadline and a 4 MiB output capture budget;
a stopped decode reports `provider_timeout` and exceeded budgets report
`resource_constraint`.
- Manifest reads are bounded to 512 KiB before decoding UTF-8. Strings XML
reads are bounded to 8 MiB; larger documents are skipped with partial
coverage and an explicit limit, rather than reported as missing.
- `decoded_file_count` is capped at 100,000 files; the cap is reported in
the limitations when hit.
- Apktool's own decode failures (`AndrolibException`, directory traversal
refusals) surface as `unsupported_target` with apktool's reason preserved
verbatim.
## Real verification
`npm run verify:apktool -- --apk PATH` exercises both operations against a
real launcher. Record: apktool 2.7.0-dirty (Debian packaging) on Linux with
OpenJDK 25 against a signed aapt2-built probe APK carrying two resource
locales — launcher version, on-disk digest agreement, apktool.yml metadata
(1.2.3, SDK 24–34), manifest package agreement, default and `de` string
tables projected. Parser goldens in the test suite come from the same
decode. See `docs/testing.md` for the lane summary.
+51
View File
@@ -91,6 +91,12 @@ one-shot invocations pass portable inline Evidence JSON; an MCP session uses
the retained `evidence_id`. Page `limit` is required and bounded by the
measured MCP stdio budget documented on the tool contract.
Over MCP, `inspect_binary_layout` with `"detail": "summary"` retains the
complete layout Evidence in the session and returns only its summary view.
Its `normalized_result.parent_evidence_id` names the retained layout for later
section, symbol, mitigation or linkage views. The default `complete` detail and
CLI output are unchanged.
Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics
and 30-second owned command deadline. The reply budget applies to the decoder
record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The
@@ -136,3 +142,48 @@ malformed limit reports remain unknown with their read failure preserved. A
received signal alone does not establish its exact cause. Dynamic tags require
a complete DT_NULL inside PT_DYNAMIC; interpreter names and ranges end at the
first NUL, with any padding still represented by the original segment range.
## Portable PE resources
`inspect_pe_resources` / `inspect-pe-resources` inspects an explicit PE32 or
PE32+ file without an active target, native engine, Python, or Windows SDK:
```sh
rea inspect-pe-resources ./selected.exe --json
```
```json
{
"name": "inspect_pe_resources",
"arguments": { "path": "/artifacts/selected.exe" }
}
```
The complete Evidence identifies the stable artifact snapshot by path, byte
count and SHA-256. Resources retain separate type, name and language identities:
numeric IDs are distinct from UTF-16 names, whose original bytes are also
reported. Directory headers, entries, data entries and payloads retain original
file offsets and lengths. Each opaque payload has its own SHA-256; code page and
reserved data-entry fields are preserved.
RT_GROUP_ICON records expose original image descriptors and RT_ICON candidates.
A reference resolves only when an icon with the same numeric ID and language
exists. Missing references, other-language candidates and declared-size mismatch
remain explicit. Encoded dimensions are preserved (zero denotes 256 pixels).
This is static reference inspection; no image decoding, language fallback,
resource loading or selected-file execution occurs.
An absent resource data directory is a complete empty inventory. Malformed
headers, truncated tables, cycles, duplicate sibling identities, unbacked or
ambiguous RVA mappings, nonidentical overlapping ranges and unsupported tree
shapes fail explicitly, rather than returning partial inventories. Exact shared
payload ranges are supported. The supported tree has three levels: type, name,
and language. Other resource payload formats remain opaque.
`max_file_bytes` defaults to 64 MiB (maximum 512 MiB); `max_entries` defaults to
4096 (maximum 65536). CLI options are `--max-file-bytes` and `--max-entries`.
Metadata is bounded to 8 MiB; exceeding any budget fails without truncation.
Large Evidence envelopes may require a caller-configured MCP transport receive
buffer because envelope copies add overhead. Requests honor cancellation during
snapshot reading, directory traversal and payload hashing. The operation reads
regular files only and rejects symbolic links.
+7 -1
View File
@@ -15,7 +15,11 @@ when both Evidence sets already exist.
The request supplies a literal-port loopback endpoint and, for inspection, a
target ID. REA accepts local hostless `file://` URLs that resolve to regular
files; remote hosts, encoded path separators, and nonexistent paths are
files, including pages a packaged application serves from inside `app.asar`.
For those, the first `.asar` path component that is a regular file is the
archive, the remainder must name a regular file in its header, and the reported
path is the canonical archive path joined with that member. Remote hosts,
encoded path separators, ASAR directories or links, and nonexistent paths are
rejected. The endpoint exposes every eligible target and its local path
metadata.
@@ -120,3 +124,5 @@ projection is intentionally target-only and partial: it binds the selected
application path and capture outcome to the static graph, while frames, scripts,
workers, and execution claims remain unavailable until a separate passive runtime
capture provides them.
ASAR observation reads the header through one open file handle, validates its declared lengths against the archive size, and limits encoded JSON headers to 16 MiB before parsing. The header cache also retains at most 16 MiB of encoded headers across eight archives. Oversized headers produce a scope refusal.
+6
View File
@@ -25,6 +25,12 @@ extraction have independent prerequisites; one does not require the other.
Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its
absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed.
REA retains at most 8 MiB of combined stdout and stderr per command. Exceeding
that diagnostic budget stops analysis with an explicit truncated-output error;
child process limits do not bound parent-side pipe retention. Completed commands
drain their output streams before recording diagnostics. Failed worker cleanup
retains the worker and workspace for retry before another request or at shutdown.
Unblob needs the external extractor for the selected format on its inherited
`PATH`: for example, `debugfs` for ext2/ext3/ext4. Its default Landlock sandbox
and skip rules stay enabled. Sandbox startup failures remain actionable errors;
+100
View File
@@ -0,0 +1,100 @@
# MIPS ELF analysis through Ghidra
This lane extends native ELF analysis; it does not execute MIPS code or rehost
firmware. It advances the generic-MIPS portion of
[#718](https://github.com/morluto/rea/issues/718), not the whole firmware roadmap.
PSP/Allegrex remains a separate specialization tracked in
[#1330](https://github.com/morluto/rea/issues/1330).
## Admission boundary
ELF `EM_MIPS` is retained as the provider-neutral `mips` family. The target also
retains class, byte order, `e_type`, raw `e_flags`, and inspected ABI declarations.
Recognition does not mean every provider or MIPS variant is supported. Existing
PE, managed and Mach-O architecture sets are unchanged.
The first Ghidra lane admits ELF32 `ET_EXEC`, little or big endian, with explicit
o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE encoding flags.
These are header declarations, not proof that every instruction conforms to
that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS, machine-specific
variants, shared/relocatable objects and PSP PRX are not covered by this lane.
Their family may be identified, but provider selection returns the specific
unsupported constraint rather than guessing a compatible profile.
Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their
absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and
soft-float targets. Target resolution also reads the 24-byte ABI flags record
identified by `PT_MIPS_ABIFLAGS` and/or `SHT_MIPS_ABIFLAGS`, without relying on
section names or the record being inside the initial 4 KiB header probe.
Program and section declarations must agree; duplicate, conflicting, truncated
or out-of-file records are rejected. Stripped section tables and section-only
records are supported. Extended table numbering is explicitly unsupported.
The field layout follows
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
Provider admission requires record version 0, MIPS32r2, 32-bit GPR/CPR1, no CPR2,
and a double-precision or FPXX ABI declaration with no ISA extension, ASE or
unknown general flags. Missing records remain unknown rather than defaulting
to a compatible ABI, and this initial lane refuses them. Unknown record values
are retained for diagnosis but do not gain provider support. These checks bind
the declared interpretation; the integer-only fixture does not establish full
floating-point behavioral conformance.
MIPS header and ABI record facts are committed in the analysis profile so
interpretation changes invalidate profile-bound snapshots. The support-lane
identity is versioned independently of file identity. Evidence, lifecycle
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
## Use
With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host:
```bash
rea analyze /absolute/path/to/mips.elf --provider ghidra --json
rea function /absolute/path/to/mips.elf function_name --provider ghidra --json
```
MCP uses the existing tools, including `open_binary` with an absolute path and
`provider_id: "ghidra"`. No MIPS-specific tool catalog or emulator is introduced.
Inspect instructions, caller relationships and data alongside pseudocode. In
particular, delay-slot semantics are supplied by Ghidra, not reconstructed by
REA's ELF parser.
## Verification
The source-owned freestanding fixture is `tests/conformance/c/mips.c`. The
optional cross-target lane requires a caller-supplied Clang with MIPS targets,
LLD, GNU `readelf`, Ghidra and its compatible JDK; ordinary host-native checks
do not acquire these tools. `REA_MIPS_CLANG` selects Clang and
`REA_MIPS_READELF` selects GNU readelf (for example `greadelf` on macOS).
These are test-time prerequisites, not REA runtime providers.
```bash
GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:mips
```
The lane compiles both byte orders and never executes the targets. GNU readelf
independently supplies header/ABI facts and symbol addresses, compared with the
production REA target resolver before provider startup. Its version, bounded
command output and selected tool path are retained in the verification report.
The parser deliberately recognizes only this fixture's GNU output, rejecting
missing, ambiguous or unexpected fields rather than substituting defaults.
Existing public MCP operations check Ghidra's **observed** language/compiler
specification and loaded-file hashes. The source-owned `mips-probe.S` supplies
fixed ADDIU and conditional-branch encodings: verification checks bytes, length,
immediate and direct destination, independent of decompiler variable names.
Global and string bytes are checked at independently reported symbol addresses.
ELF load-image results retain observations even though REA's independent
load-image comparison currently covers DOS, not ELF; this lane checks those
observations and does not relabel the upstream result as verified.
CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence
identity, snapshot round-trip and final process cleanup remain covered.
Pseudocode presence is a liveness check, not proof of semantic equivalence.
Instruction decoding and reader agreement likewise do not establish runtime,
delay-slot execution or complete floating-point semantics. Assertion regressions
reject incorrect byte order, constants, destinations and loaded identities.
Parser/profile tests are narrower checks, not substitutes for this real-provider
run. Report the actual lanes run and leave unexecuted verification pending.
@@ -129,6 +129,16 @@ documents. Their runtime string-limit failures report a resource constraint
with the selected path, observed bytes and UTF-16 limit, rather than malformed
JSON.
`analyze_javascript_application` returns the complete analysis Evidence by
default. Select `"detail": "summary"` to retain that complete Evidence in the
current session and receive only its `inspect_analysis_view` summary: artifact
identity, statistics, Electron surface counts, application and semantic
coverage, limitations, and `normalized_result.parent_evidence_id`. Pass that ID
to `inspect_analysis_view` for module pages or items, or to the application
workflows as a retained reference. The summary does not repeat or truncate the
analysis; a server without session retention refuses summary detail with
`capability_unavailable`. CLI output is unchanged and always complete.
MCP prepares the complete repeated response incrementally against the pinned
SDK's 10 MiB stdio receive-buffer budget. Oversized results return an actionable
transport constraint and the exact same-session Evidence reference. Use
+34 -7
View File
@@ -7,19 +7,46 @@ never executes the selected or recovered application code.
## Caller-supplied engine
The initial adapter is verified on Linux x64 with Wakaru **1.13.0**. Set
`REA_WAKARU_COMMAND` to the absolute path of an executable supplied by the caller.
The adapter uses util-linux `prlimit` (default `/usr/bin/prlimit`; override with
an absolute `REA_JAVASCRIPT_PRLIMIT_COMMAND`). REA does not install either tool.
Engine configuration is checked only when recovery is requested; other REA
capabilities remain available without it.
REA accepts Wakaru **^1.13.0** on Linux x64. The report parser is verified with
Wakaru **1.14.0**. Another release in that range is accepted, the observed
version is reported, and a limitation records that the parser was not verified
against that release. Wakaru changes `--version`, `--json` and `provenance.json`
only by addition within a major version
([compatibility policy](https://github.com/pionxzh/wakaru/blob/v1.14.0/docs/cli.md#machine-readable-output-compatibility)),
so the parser keeps unknown report fields and reported module statuses.
Set `REA_WAKARU_COMMAND` to the absolute path of an executable supplied by the
caller. The adapter uses util-linux `prlimit` (default `/usr/bin/prlimit`;
override with an absolute `REA_JAVASCRIPT_PRLIMIT_COMMAND`). REA does not install
either tool. Engine configuration is checked only when recovery is requested;
other REA capabilities remain available without it.
Unmodified upstream source is retained in `third_party/wakaru`, pinned to
`6070266d24b32951fa2fe1d2dad5b4313bd5c81b` (v1.13.0, Apache-2.0).
`8219bf5016a063d7fb215101a8c45d5959ec733f` (v1.14.0, Apache-2.0).
The result reports the executed binary's exact path, SHA-256 and observed version.
The audited source revision is distinct from `executed_source_revision: null`:
a version string cannot prove which source produced a caller-supplied binary.
### Install Wakaru
Use the official release binary, the same one CI verifies:
```sh
curl -fL https://github.com/pionxzh/wakaru/releases/download/v1.14.0/wakaru-linux-x64.tar.gz | tar -xz wakaru
export REA_WAKARU_COMMAND="$PWD/wakaru"
```
Or install it from npm. Point at the platform binary, not the Node.js shim in
`node_modules/.bin`:
```sh
npm install wakaru@1.14.0
export REA_WAKARU_COMMAND="$(realpath node_modules/@wakaru/cli-linux-x64/wakaru)"
```
Both give the same binary (SHA-256
`664fa06782a61eda7ed15885134b52525fd565dd77607e642062ceb6cd927c38`).
## Recover and analyze
```sh
+36
View File
@@ -0,0 +1,36 @@
# JEB instrumentation and analysis
REA exposes JEB-backed project, unit, and decompilation inspection through the CLI and MCP, served by JEB's own MCP interface. JEB is a bring-your-own engine: REA never installs it, launches it, or opens its project databases itself.
## Bring-your-own engine
Start a JEB client that serves MCP and point REA at it:
- Default endpoint: `http://127.0.0.1:8425/mcp`. Override with `REA_JEB_MCP_URL` (absolute `http`/`https` URL with literal host `127.0.0.1` or `[::1]`; redirects, remote hosts, credentials, query strings, and fragments are rejected rather than silently dropped).
- In the JEB GUI client, start the MCP server from the _File_ menu (or via the VIBRE assistant).
- Headless, use JEB's documented programmatic route (`JebMcpServerInstance` from the JEB API) with the JEB client on your classpath; JEB 5.48's `-c` headless mode does not accept `--mcp` directly.
Verified against JEB 5.48.0 (Linux x64, licensed build) serving `jeb-mcp-server` 1.3.0 over streamable HTTP.
## Operations
| MCP tool / CLI command | Purpose |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `inspect_jeb_client` / `inspect-jeb-client` | Engine version, GUI or headless mode, startup time |
| `open_jeb_project` / `open-jeb-project` | Open or create a project from an artifact or `.jdb2` on the engine host; returns input-file SHA-256 digests and top-level units |
| `list_jeb_units` / `list-jeb-units` | Page units by stable path and type (`filter`, `parent-unit-path`, `index`, `count`) |
| `decompile_jeb_item` / `decompile-jeb-item` | Decompile one type or method to engine pseudo-code |
All four are read-only with respect to the target file; only `open_jeb_project` changes engine-side project state, and Evidence records the endpoint, the caller-selected parameters, and the engine-reported digests. Mutation workflows the engine also offers (renames, comments, `save_project`, `close_project`) are intentionally not exposed yet.
**Engine coverage note (verified):** JEB 5.48.0 headless MCP instances do not advertise `open_project` — the target must be selected when the engine client starts (`--infile` on the programmatic launcher, or the GUI's file opening). REA preserves that refusal with the engine's reason instead of failing silently; `open_jeb_project` completes only against engine builds that advertise the tool, such as GUI clients.
## Semantics
- Paths in `open_jeb_project` are resolved by the JEB process, not by REA; REA does not require the artifact to exist locally.
- The engine caps `list_units` pages at 100; a full page is reported as `coverage: "partial"` because more units may exist. An empty remainder is `complete` only when fewer units than requested were returned.
- When `decompile_jeb_item` omits `unit_path`, the engine implicitly selects the project's first code unit; REA reports `unit_path: null` and a limitation instead of guessing the effective unit.
- Engine refusals (`success: false` envelopes) are preserved as unsupported-target errors carrying the engine's own message, for example a nonexistent item address.
- An unreachable endpoint is a provider-availability failure with recovery guidance; REA does not retry or spawn the engine.
Real-engine verification lives in the JEB lane described in [testing.md](testing.md); mock transport tests do not establish real-engine compatibility.
+14
View File
@@ -38,6 +38,20 @@ Inspect separately obtained components explicitly and preserve their identities.
A valid implementation marker establishes a candidate native boundary, not
recovered native semantics or runtime behavior.
### Resource admission
The static provider admits regular input files up to 128 MiB before retaining
their bytes. Reads remain bounded if a file grows after admission. Metadata,
CIL decoding, and projected inspection records have a separate conservative
64 MiB representation budget; shared heap strings are cached, but repeated
output occurrences still consume that budget. These are inspection resource
policies, not PE format limits or a hard bound on total V8 heap use.
Input overflow returns a resource constraint. Decode or projection overflow
reports `resource-limit` with unavailable or partial coverage; omitted facts do
not become empty observations. Ambiguous PE section mappings are rejected
rather than interpreted according to section order.
## Analysis objective
REA's managed-code track is intended to answer five different questions without
+31 -1
View File
@@ -26,7 +26,16 @@ health transition leaves that catalog unchanged and does not emit
Advertised input and output schemas contain no reachable recursive references.
Schemas share repeated definitions through schema-local references while
preserving complete nested fields and validation rules. Input properties retain
their descriptions and literal examples. Root object unions are presented as
their descriptions and literal examples.
Hosts forward input schemas to the model with every request, so advertised
examples never copy complete producer Evidence. An embedded Evidence record is
shown as its exact `{"kind":"retained-evidence","evidence_id":"ev_..."}`
reference where the tool accepts one. An example that can only carry inline
Evidence stays in the canonical contract and its executable tests, but is not
advertised. Every advertised example is a distinct valid canonical input.
Root object unions are presented as
one object without root `anyOf`, `oneOf`, or `allOf` for model API compatibility.
The projection merges properties, retains shared required fields and
group-specific field dependencies, and describes the accepted input groups.
@@ -309,6 +318,15 @@ Ordinary responses keep their existing complete result contract.
## Retained application Evidence inputs
`analyze_javascript_application` and `inspect_binary_layout` accept `detail`:
`complete` (default) returns the complete analysis Evidence; `summary` records
that complete Evidence in the session and returns the derived
`inspect_analysis_view` summary Evidence instead. Its
`normalized_result.parent_evidence_id` and `evidence_links` name the retained
analysis, so later views and application workflows read it without repeating
analysis or moving the complete record through the response. A server without
session retention refuses summary detail with `capability_unavailable`.
`inspect_analysis_view` projects a caller-selected view of already completed
`inspect_binary_layout`, `analyze_javascript_application`, or `analyze_function`
Evidence. Native views select procedure, pseudocode, assembly, basic blocks,
@@ -431,3 +449,15 @@ and reconstruction cannot treat it as unchanged.
canonical `integrity_contradictions` records and marks application graph
coverage partial when any mismatch is continued. Contradicted nested ASARs
remain opaque.
`extract_artifact` accepts the same policy in MCP and as `--integrity-policy`
on `rea extract-artifact`. With `record-and-continue` it writes the observed
bytes and returns the `integrity_contradictions` records for the extracted
files. An active ASAR unpacked entry whose companion file is absent cannot be
materialized; extraction fails as `unavailable` with that logical path.
Packaging tools commonly sign or strip `.asar.unpacked` native binaries after
writing the archive header, so their declared hashes no longer match. Electron
does not check unpacked companions against those hashes at runtime. Integrity
failures for such entries name this cause and the operation to rerun with
`record-and-continue`; REA does not exempt unpacked entries silently.
+4
View File
@@ -187,6 +187,10 @@ Every capture requires `truncation_details`, with separate accounting for:
input, so raw omissions also limit rendered coverage.
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
enumeration failures, whole-file hash budget and bytes successfully hashed.
Directory names are read incrementally within the remaining entry capacity.
When a directory exceeds that capacity, the retained subset follows filesystem
enumeration order and is then sorted; it is explicitly incomplete and cannot
establish that an omitted path is absent.
Each retained regular file without a digest has an aliased path, size,
remaining budget and reason: `file_bytes_budget`,
`file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is
+129 -2
View File
@@ -167,6 +167,67 @@ fix for that client/model limit. It preserves the complete catalog and schemas.
Live model APIs, native Windows and default OS-home skill discovery remain
unverified.
`verify:grok-client` requires installed Grok Build (verified with the official
Linux x64 1.0.50 binary); select it with `REA_VERIFY_GROK_COMMAND`. This optional
POSIX lane isolates `GROK_HOME`, an independent Git workspace and additional
skill roots, disables foreign configuration discovery, guards setup writes,
preserves unrelated registrations/backups and checks idempotence. Call mode
loads the full skill through native `read_file`, queries all REA names and input
schemas through native `search_tool`, and calls analysis through `use_tool`.
If discovery reports `partial`, the native agent diagnoses REA registration and
retries discovery. No startup-timeout override or fixed readiness delay is used.
A larger Unicode-path fixture exercises native result offloading: the full
retained Evidence is validated against its named schema, then native terminal
queries recover the selected export facts, subject and full artifact digest in
the next model request. This verifies useful artifact recovery without claiming
that every offloaded graph fact enters model context. Native line-number and
truncation envelopes remain in the request artifacts. Use `-- chat` for ordinary
chat, or `REA_VERIFY_RUNTIME_ROOT` for a production-only installed package.
The loopback OpenAI completions/SSE custom model declares a one-million-token
context and synthetic usage. Live xAI APIs, native Windows and default OS-home
skill discovery remain unverified.
`verify:commandcode-client` requires installed Command Code (verified with npm
1.79.2); select it with `REA_VERIFY_COMMANDCODE_COMMAND`. Auth, MCP tokens and
sessions use the actual OS home, so provision a disposable POSIX account rather
than overriding `HOME`. Set `REA_VERIFY_COMMANDCODE_ACCOUNT_HOME` to that account's
actual home and create `.rea-client-verification` there with exactly
`Disposable REA client verification account` followed by a newline. The lane
owns that account's `.commandcode` configuration and shared REA skill. Build the
runtime as the checkout owner, then run `node scripts/verify-commandcode-client.mjs`
as the disposable account; the npm shortcut also needs a writable checkout.
The verifier does not create accounts or install clients. It guards setup
targets, preserves an unrelated registration and backups, checks idempotence,
and verifies native default shared-skill discovery and complete activation.
Default deferred-schema delivery stays enabled: the native prompt advertises
every REA tool, exact-name `search_tools` returns every input schema, and a native
shell query recovers the saved catalog's count and digest when the client spills
it. Actual analysis of a Unicode-path fixture delivers full named-schema Evidence
to the next model request. Use `-- chat` for ordinary chat or
`REA_VERIFY_RUNTIME_ROOT` for a production-only installed package. The loopback
OpenAI completions/SSE model is a keyless BYOK endpoint declaring a million-token
context with synthetic usage. A synthetic account-key value satisfies the
client's print-mode gate; native local-only mode refuses hosted API calls.
Updates, telemetry and cron are disabled. Hosted authentication, live models
and Windows remain unverified.
`verify:omp-client` requires installed OMP (verified with the official Linux x64
18.8.7 binary); select it with `REA_VERIFY_OMP_COMMAND`. This optional POSIX lane
isolates the default agent, global config and XDG roots, guards setup targets,
preserves unrelated registrations and backups, and checks idempotence. It
compares native rejection of an invalid profile with REA's refusal to plan
fallback writes. OMP's default `xd://` interface mounts the complete REA catalog
as devices: call mode reads the complete installed skill and tool documentation,
then dispatches analysis through native `write`. It checks full named-schema
JavaScript Evidence with a Unicode path in the next model request. Device
metadata and documentation are distinct from forwarding all JSON schemas as
model functions. Native print-mode MCP readiness uses its defaults.
Use `-- chat` for ordinary chat or `REA_VERIFY_RUNTIME_ROOT` for a production-only
installed package. The loopback OpenAI completions/SSE model fixture declares a
one-million-token context and synthetic usage. Live model APIs, native Windows,
named-profile native execution and default OS-home skill discovery are
unverified; skill discovery uses an explicit isolated custom directory.
`verify:deepseek-client` requires an installed DeepSeek Harness (`dsh`;
verified with `@deepseek-ai/dsh@0.2.0-rc.2`) and Git. Set
`REA_VERIFY_DEEPSEEK_COMMAND` to select its executable. This optional POSIX lane
@@ -499,6 +560,25 @@ kept under ignored `_reference/`. No Gradle build, Android SDK, emulator or
application execution is required. The lane compares real CLI/MCP package,
class search, class inventory, method decompilation and incoming references.
See [Android analysis](android-analysis.md) for boundaries and resource budgets.
`verify:jeb` requires a caller-started JEB client serving MCP at
`REA_JEB_MCP_URL` (default `http://127.0.0.1:8425/mcp`) with a project
already open, and verifies real CLI client inspection, unit listing coverage,
and method decompilation. The script records the engine's exact response to
`open_jeb_project`; JEB 5.48.0 headless instances do not advertise that tool.
REA does not install or launch JEB; see [JEB analysis](jeb-analysis.md) for
the bring-your-own boundary. Verified against JEB 5.48.0 serving
`jeb-mcp-server` 1.3.0 at four scales: a compiled Java class fixture, a
locally built signed probe APK with a launcher activity (manifest, v1/v2/v3
certificates, dex bytecode, filtered and paginated listing), the published
Signal 8.30.3 universal release APK (109 MB, R8-processed Kotlin/Compose,
four signature schemes, native arm64 ELF units) with MainActivity method
decompilation through both CLI and MCP, and the official Flutter Gallery
2.9.2 release APK (112 MB): per-ABI `libapp.so` Dart AOT snapshot images
analyzed as native code (75k methods on arm64) and decompiled through both
CLI and MCP with explicit unit selection, plus the thin Java wrapper and
decoded manifest. The dedicated Dart snapshot processor and `run_script`
remain GUI-only surfaces in JEB 5.48.0 headless.
Authenticated IPA and macOS application inventory projection is documented in
[Apple application analysis](apple-application-analysis.md).
@@ -508,6 +588,51 @@ Synthetic producer regressions run independently:
npm run test:focused -- tests/boundary/process/jadxIntegration.test.ts tests/boundary/process/androidAnalysisMcp.test.ts
```
## Apktool resource decoding
`npm run verify:apktool -- --apk PATH` exercises both Apktool operations
against a real launcher (`REA_APKTOOL_COMMAND` or PATH) and one APK. No
download or build step is involved. Record: apktool 2.7.0-dirty (Debian
packaging) on Linux with OpenJDK 25, against a signed aapt2-built probe APK
carrying two resource locales — launcher version, on-disk digest agreement
with the reported target identity, apktool.yml metadata projection
(1.2.3, SDK 24–34), manifest package agreement, and both the default and
`de` string tables projected through the locale option. Parser goldens in
`src/apktool/ApktoolDecodeOutput.test.ts` come from the same decode. See
[Apktool resource analysis](apktool-resource-analysis.md) for boundaries and
budgets.
## ADB device acquisition
`npm run verify:adb` exercises every ADB operation against a live device or
emulator through the caller's adb binary (`REA_ADB_PATH` or PATH). No SDK
installation or emulator management is involved; the device-mutating tools
run only in the explicit lifecycle section (`--install-apk` drives a
lane-owned install → resolve → start → observe → force-stop → uninstall
probe). `--pull` acquires a real package and re-digests the pulled files on
disk against the returned SHA-256 values; `--serial` selects a device when
several are attached.
Record: adb 34.0.5-debian on Linux against an Android 14 (API 34) x86_64
emulator. The lane covered the complete observation surface — 337-process
listing, 287 binder services including AIDL `/`-suffixed names, 92 features
including hex GL versions, display size/density, window focus (legitimately
null on headless devices), `settings get global adb_enabled`, bounded
logcat, directory listings — plus a real two-APK split set
(`base.apk` plus `split_probe.apk`, built and installed through
`install-multiple`) pulled with byte-exact digests, a push/pull roundtrip
verified by the device's own sha256sum, a 1.3 MB screen capture with PNG
dimensions, `dumpsys package` projection, and the full lifecycle: unique
resolution before launch, launcher-activity start through
`cmd package resolve-activity` with `am start -W`, the started app visible
in the process listing, force-stop, uninstall, and zero matches after
removal. System-package pulls whose APKs keep non-`base.apk` names report
the `unknown` role with the file-name basis, verified with
`com.android.settings` (single 73.9 MB APK). Parser goldens for
`adb devices -l`, `getprop`, `pm list packages -f`, and `pm path` come from
the same device. See [ADB device analysis](adb-device-analysis.md) for
boundaries and budgets.
## Optional NativeAOT Ghidra analysis
This lane is separate from the default native lane. `build:fixtures:nativeaot`
@@ -808,12 +933,14 @@ Neither optional toolchain is a base-lane prerequisite. See
Build once with `npm run build:cached`, then run `npm run verify:javascript:recovery`.
This focused lane requires Linux x64, util-linux `prlimit`,
`REA_WAKARU_COMMAND` pointing to the official Wakaru 1.13.0 Linux x64 binary,
`REA_WAKARU_COMMAND` pointing to the official Wakaru 1.14.0 Linux x64 binary,
and `REA_JAVASCRIPT_FIXTURE_TOOLS` pointing to an isolated npm prefix containing
esbuild 0.25.10 and webpack 5.101.3. No global installation is required.
The lane compiles source-owned fixtures, exercises CLI and stdio MCP, verifies
published bytes and provenance, feeds recovered modules into existing analysis,
and compares a finite set of known fixture results. It does not establish
and compares a finite set of known fixture results. The provider also accepts
other ^1.13.0 releases and reports them as unverified; this lane proves the
audited release. It does not establish
arbitrary recovered-application equivalence. CI installs these prerequisites only
in `.github/workflows/real-javascript-recovery.yml`; the existing `real-browser`
lane uses real Chrome for browser capture and website workflows.
+8
View File
@@ -50,6 +50,7 @@
"bridge/ghidra/ReaGhidraBridge.java",
"bridge/ghidra/ReaGhidraExtensions.java",
"bridge/ghidra/ReaGhidraPrepareCom.java",
"bridge/ghidra/ReaGhidraNoReturnFix.java",
"bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py",
"scripts/rea.mjs",
@@ -146,7 +147,10 @@
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
"verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs",
"verify:architecture-guards": "node scripts/verify/architecture-guards.mjs",
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
@@ -169,6 +173,7 @@
"verify:firmware": "npm run build:cached && node scripts/verify/firmware/analysis.mjs",
"fixtures:firmware": "node scripts/fixtures/firmware/generate.mjs",
"verify:android": "npm run build:cached && node scripts/verify/android/analysis.mjs",
"verify:jeb": "npm run build:cached && node scripts/verify-jeb.mjs",
"fixtures:android": "node scripts/fixtures/android/fetch.mjs",
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
@@ -182,6 +187,9 @@
"verify:qwen-client": "npm run build:cached && node scripts/verify-qwen-client.mjs",
"verify:gemini-client": "npm run build:cached && node scripts/verify-gemini-client.mjs",
"verify:opencode-client": "npm run build:cached && node scripts/verify-opencode-client.mjs",
"verify:grok-client": "npm run build:cached && node scripts/verify-grok-client.mjs",
"verify:commandcode-client": "npm run build:cached && node scripts/verify-commandcode-client.mjs",
"verify:omp-client": "npm run build:cached && node scripts/verify-omp-client.mjs",
"verify:copilot-client": "npm run build:cached && node scripts/verify-copilot-client.mjs",
"verify:claude-client": "npm run build:cached && node scripts/verify-claude-client.mjs",
"verify:pi-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs pi",
+37 -1
View File
@@ -47,6 +47,21 @@ export const toolFamilyCatalog = (sources) => {
surface: "android-provider",
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
{
id: "apktool",
surface: "apktool-provider",
contracts: sources.apktoolContracts.APKTOOL_TOOL_CONTRACTS,
},
{
id: "jeb",
surface: "jeb-provider",
contracts: sources.jebContracts.JEB_TOOL_CONTRACTS,
},
{
id: "adb",
surface: "adb-provider",
contracts: sources.adbContracts.ADB_TOOL_CONTRACTS,
},
{
id: "browser",
surface: "browser-provider",
@@ -153,7 +168,16 @@ export const providerCatalog = (sources) => {
{
identity: sources.pwntoolsProvider.PWNTOOLS_PROVIDER_IDENTITY,
contracts:
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
({ name }) => name !== "inspect_pe_resources",
),
},
{
identity: sources.artifactProviders.PE_RESOURCES_PROVIDER,
contracts:
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
({ name }) => name === "inspect_pe_resources",
),
},
{
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
@@ -188,6 +212,18 @@ export const providerCatalog = (sources) => {
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
{
identity: sources.apktoolProvider.APKTOOL_PROVIDER_IDENTITY,
contracts: sources.apktoolContracts.APKTOOL_TOOL_CONTRACTS,
},
{
identity: sources.jebProvider.JEB_PROVIDER_IDENTITY,
contracts: sources.jebContracts.JEB_TOOL_CONTRACTS,
},
{
identity: sources.adbProvider.ADB_PROVIDER_IDENTITY,
contracts: sources.adbContracts.ADB_TOOL_CONTRACTS,
},
{
identity: sources.webNetworkCaptureProvider.HAR_CAPTURE_PROVIDER_IDENTITY,
contracts:
+6
View File
@@ -53,6 +53,12 @@ const SOURCE_PATHS = {
firmwareProvider: "dist/firmware/FirmwareRelease.js",
androidContracts: "dist/contracts/android/androidToolContracts.js",
androidProvider: "dist/android/JadxRelease.js",
apktoolContracts: "dist/contracts/apktool/apktoolToolContracts.js",
apktoolProvider: "dist/apktool/ApktoolProvider.js",
jebContracts: "dist/contracts/jeb/jebToolContracts.js",
jebProvider: "dist/jeb/JebProvider.js",
adbContracts: "dist/contracts/adb/adbToolContracts.js",
adbProvider: "dist/adb/AdbProvider.js",
managedWorkflowContracts:
"dist/contracts/managed/managedWorkflowToolContracts.js",
browserContracts: "dist/contracts/browserToolContracts.js",
+400
View File
@@ -0,0 +1,400 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AdbDeviceAnalysisService } from "../dist/application/adb/AdbDeviceAnalysisService.js";
import { createAdbDeviceAnalysisProvider } from "../dist/composition/adb.js";
const options = parseArguments(process.argv.slice(2));
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" +
"files on disk against the returned SHA-256 values.\n",
);
process.exit(0);
}
const provider = createAdbDeviceAnalysisProvider(process.env);
const service = new AdbDeviceAnalysisService(provider);
let failures = 0;
const check = (name, condition, detail = "") => {
const mark = condition ? "ok" : "FAIL";
process.stdout.write(
`[${mark}] ${name}${detail === "" ? "" : ` — ${detail}`}\n`,
);
if (!condition) failures += 1;
};
const run = async (operation, input) => {
const result = await service.execute(operation, input);
if (!result.ok) {
process.stdout.write(`[FAIL] ${operation} — ${result.error.message}\n`);
failures += 1;
return null;
}
return result.value.normalized_result;
};
const client = await run("inspect_adb_client", {});
if (client !== null) {
check(
"adb client reports a version",
typeof client.client.version === "string" && client.client.version !== "",
`binary=${client.client.binary_path} version=${client.client.version}`,
);
}
const listing = await run("list_adb_devices", {});
let serial = options.serial ?? null;
if (listing !== null) {
check(
"device listing parsed",
Array.isArray(listing.devices),
`${listing.devices.length} device(s)`,
);
const usable = listing.devices.filter((device) => device.state === "device");
if (serial === null && usable.length > 0) serial = usable[0].serial;
if (serial === null) {
process.stdout.write(
"[FAIL] no usable device is attached; start an emulator or connect a phone\n",
);
failures += 1;
} else {
const selected = listing.devices.find((device) => device.serial === serial);
check(
"selected device present with a kind inference",
selected !== undefined &&
["emulator", "physical", "unknown"].includes(selected.kind),
`serial=${serial} kind=${selected?.kind} basis=${selected?.kind_basis}`,
);
}
}
if (serial !== null) {
const device = await run("inspect_adb_device", { serial });
if (device !== null) {
check(
"device build identity projected",
device.properties.some(
(property) => property.name === "ro.build.version.sdk",
),
`sdk=${
device.properties.find((p) => p.name === "ro.build.version.sdk")?.value
} emulator_observed=${String(device.emulator_observed)}`,
);
}
// Observation tools: processes, window, display, settings, logcat.
const processes = await run("list_adb_processes", { serial });
if (processes !== null) {
check(
"process listing is non-empty with a header",
processes.processes.length > 0 && typeof processes.columns === "string",
`${processes.processes.length} process(es)`,
);
}
const window = await run("inspect_adb_window", { serial });
if (window !== null)
check(
"window focus observation completed",
typeof window.focused_window === "string" ||
window.focused_window === null,
`focused_window=${String(window.focused_window).slice(0, 60)} (null is legitimate on headless devices)`,
);
const display = await run("inspect_adb_display", { serial });
if (display !== null)
check(
"display reports a physical size",
display.physical_size !== null &&
display.physical_size.width > 0 &&
display.physical_size.height > 0,
`${display.physical_size?.width}x${display.physical_size?.height} @${String(display.physical_density)}`,
);
const setting = await run("read_adb_setting", {
serial,
namespace: "global",
key: "adb_enabled",
});
if (setting !== null)
check(
"settings read returns the device's own value",
setting.value === "1",
`adb_enabled=${String(setting.value)}`,
);
const logcat = await run("read_adb_logcat", {
serial,
count: 20,
buffer: "main",
});
if (logcat !== null)
check(
"logcat dump returns bounded lines",
logcat.lines.length <= 20,
`${logcat.lines.length} line(s), coverage=${logcat.coverage}`,
);
const features = await run("list_adb_features", { serial });
if (features !== null)
check(
"feature inventory is non-empty",
features.coverage === "complete" && features.features.length > 0,
`${features.features.length} feature(s)`,
);
const services = await run("list_adb_services", { serial });
if (services !== null)
check(
"service inventory is non-empty",
services.coverage === "complete" && services.services.length > 0,
`${services.services.length} service(s)`,
);
const directory = await run("list_adb_directory", {
serial,
device_path: "/data/local/tmp",
});
if (directory !== null)
check(
"directory listing completed",
directory.coverage === "complete",
`${directory.entries.length} entr(ies)`,
);
const packages = await run("list_adb_packages", {
serial,
scope: "third_party",
});
let pullCandidate = options.pull ?? null;
if (packages !== null) {
// A third-party scope may legitimately be empty (clean emulator images);
// completeness is about unparsed lines, not row count.
check(
"third-party package listing reports complete coverage",
packages.coverage === "complete",
`${packages.packages.length} package(s)`,
);
const allPackages = await run("list_adb_packages", {
serial,
scope: "all",
});
if (allPackages !== null) {
check(
"full package inventory is non-empty",
allPackages.coverage === "complete" && allPackages.packages.length > 0,
`${allPackages.packages.length} package(s)`,
);
}
if (pullCandidate === null && packages.packages.length > 0)
pullCandidate = packages.packages[0].package_name;
}
if (pullCandidate !== null) {
const directory = await mkdtemp(join(tmpdir(), "rea-adb-verify-"));
try {
const pull = await run("pull_adb_package", {
serial,
package: pullCandidate,
output_directory: directory,
});
if (pull !== null) {
// Installed /data/app packages name their APKs base.apk and
// split_*.apk; system packages keep arbitrary names, so the lane
// checks a non-empty artifact set and verifies each file on disk.
check(
"pull returned a non-empty artifact set",
pull.artifacts.length > 0,
`${pull.artifacts.length} artifact(s), coverage=${pull.coverage}, roles=${pull.artifacts.map((a) => a.role).join(",")}`,
);
for (const artifact of pull.artifacts) {
const bytes = await stat(artifact.local_path).then(
(info) => info.size,
);
const digest = createHash("sha256")
.update(await readFile(artifact.local_path))
.digest("hex");
check(
`artifact digest matches on disk: ${artifact.file_name}`,
bytes === artifact.bytes && digest === artifact.sha256,
`${bytes} bytes`,
);
}
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}
// File transfer: push one lane-owned file, verify both digests, pull it
// back, then clean up the device copy through the same fixed argv surface.
const transferRoot = await mkdtemp(join(tmpdir(), "rea-adb-transfer-"));
try {
const payload = join(transferRoot, "rea-verify.txt");
const payloadBytes = "rea adb transfer verification\n";
await import("node:fs/promises").then((fs) =>
fs.writeFile(payload, payloadBytes),
);
const deviceTarget = `/data/local/tmp/rea-verify-${Date.now()}.txt`;
const pushed = await run("push_adb_file", {
serial,
local_path: payload,
device_path: deviceTarget,
overwrite: false,
});
if (pushed !== null) {
check(
"push reports matching device-side digest",
pushed.device_digest_source === "device_sha256sum" &&
pushed.device_sha256 === pushed.sha256,
`${pushed.bytes} bytes`,
);
const pulled = await run("pull_adb_file", {
serial,
device_path: deviceTarget,
output_directory: transferRoot,
});
if (pulled !== null)
check(
"pulled file digest matches the pushed digest",
pulled.sha256 === pushed.sha256,
`${pulled.bytes} bytes`,
);
}
const screenRoot = await mkdtemp(join(tmpdir(), "rea-adb-screen-"));
try {
const screen = await run("capture_adb_screen", {
serial,
output_directory: screenRoot,
});
if (screen !== null)
check(
"screen capture is a sized PNG",
screen.width !== null && screen.height !== null && screen.bytes > 0,
`${screen.width}x${screen.height}, ${screen.bytes} bytes`,
);
} finally {
await rm(screenRoot, { recursive: true, force: true });
}
if (options.pull !== null) {
const details = await run("inspect_adb_package", {
serial,
package: options.pull,
});
if (details !== null)
check(
"dumpsys package projection completed",
details.coverage === "complete",
`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 });
}
}
try {
await provider.close();
} catch {
// The provider owns nothing long-lived; closing is best-effort.
}
if (failures > 0) {
process.stdout.write(`\n${failures} check(s) failed\n`);
process.exit(1);
}
process.stdout.write("\nall adb device checks passed\n");
function parseArguments(argv) {
const parsed = { help: false, serial: null, pull: null, installApk: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help" || argument === "-h") parsed.help = true;
else if (argument === "--serial") {
parsed.serial = argv[index + 1] ?? null;
index += 1;
} else if (argument === "--pull") {
parsed.pull = argv[index + 1] ?? null;
index += 1;
} else if (argument === "--install-apk") {
parsed.installApk = argv[index + 1] ?? null;
index += 1;
} else {
process.stderr.write(`unknown argument: ${argument}\n`);
process.exit(2);
}
}
return parsed;
}
+127
View File
@@ -0,0 +1,127 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ApktoolResourceAnalysisService } from "../dist/application/apktool/ApktoolResourceAnalysisService.js";
import { createApktoolResourceAnalysisProvider } from "../dist/composition/apktool.js";
const options = parseArguments(process.argv.slice(2));
if (options.help) {
process.stdout.write(
"Usage: npm run verify:apktool [-- --apk PATH]\n" +
"Requires a caller-selected apktool launcher (REA_APKTOOL_COMMAND or\n" +
"PATH) and a Java runtime. Without --apk, a small signed probe APK is\n" +
"expected at the path given by --apk; nothing is downloaded or built.\n",
);
process.exit(0);
}
const provider = createApktoolResourceAnalysisProvider(process.env);
const service = new ApktoolResourceAnalysisService(provider);
let failures = 0;
const check = (name, condition, detail = "") => {
const mark = condition ? "ok" : "FAIL";
process.stdout.write(
`[${mark}] ${name}${detail === "" ? "" : ` — ${detail}`}\n`,
);
if (!condition) failures += 1;
};
const run = async (operation, input) => {
const result = await service.execute(operation, input);
if (!result.ok) {
process.stdout.write(`[FAIL] ${operation} — ${result.error.message}\n`);
failures += 1;
return null;
}
return result.value.normalized_result;
};
const client = await run("inspect_apktool_client", {});
if (client !== null) {
check(
"apktool launcher reports a version",
typeof client.client.apktool_version === "string" &&
client.client.apktool_version !== "",
`command=${client.client.command} version=${client.client.apktool_version}`,
);
}
if (options.apk !== null) {
const decoded = await run("decode_android_resources", {
path: options.apk,
include_strings: true,
});
if (decoded !== null) {
const bytes = await stat(options.apk).then((info) => info.size);
const digest = createHash("sha256")
.update(await readFile(options.apk))
.digest("hex");
check(
"decoded target identity matches the file on disk",
decoded.target.bytes === bytes && decoded.target.sha256 === digest,
`${bytes} bytes`,
);
check(
"metadata carries version and SDK facts",
typeof decoded.metadata.version_name === "string" &&
decoded.metadata.min_sdk_version !== null &&
decoded.metadata.target_sdk_version !== null,
`version=${String(decoded.metadata.version_name)} sdk=${String(decoded.metadata.min_sdk_version)}..${String(decoded.metadata.target_sdk_version)}`,
);
check(
"decoded manifest names the package",
typeof decoded.metadata.package_name === "string" &&
decoded.manifest !== null &&
decoded.manifest.includes(decoded.metadata.package_name),
String(decoded.metadata.package_name),
);
check(
"string resources projected",
decoded.strings.length > 0,
`${decoded.strings.length} string(s)`,
);
if (decoded.locales.length > 0) {
const locale = await run("decode_android_resources", {
path: options.apk,
include_strings: true,
locale: decoded.locales[0],
});
if (locale !== null)
check(
`locale table projected for ${String(decoded.locales[0])}`,
Array.isArray(locale.strings),
`${locale.strings.length} string(s)`,
);
}
}
}
try {
await provider.close();
} catch {
// The provider owns per-call workspaces only; closing is best-effort.
}
if (failures > 0) {
process.stdout.write(`\n${failures} check(s) failed\n`);
process.exit(1);
}
process.stdout.write("\nall apktool checks passed\n");
function parseArguments(argv) {
const parsed = { help: false, apk: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help" || argument === "-h") parsed.help = true;
else if (argument === "--apk") {
parsed.apk = argv[index + 1] ?? null;
index += 1;
} else {
process.stderr.write(`unknown argument: ${argument}\n`);
process.exit(2);
}
}
return parsed;
}
+452
View File
@@ -0,0 +1,452 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative } 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 { exec } from "./lib/verify-package-core.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
assert(
process.platform !== "win32",
"This verifier requires a disposable POSIX account; Windows is unverified",
);
const account = process.env.REA_VERIFY_COMMANDCODE_ACCOUNT_HOME;
assert(
account && isAbsolute(account) && account === homedir(),
"Run as a disposable account whose actual OS home matches REA_VERIFY_COMMANDCODE_ACCOUNT_HOME; do not override HOME",
);
assert.equal(
await readFile(join(account, ".rea-client-verification"), "utf8"),
"Disposable REA client verification account\n",
);
const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const command = process.env.REA_VERIFY_COMMANDCODE_COMMAND ?? "cmd";
const mode = process.argv[2] ?? "call";
assert(
["chat", "call"].includes(mode),
"Usage: verify-commandcode-client.mjs [chat|call]",
);
const verifier = createVerifierRun();
const lab = await mkdtemp(join(account, "rea-commandcode-client-"));
const workspace = join(lab, "workspace");
const target = join(workspace, "fixture α测试");
const profile = join(account, ".commandcode");
const configPath = join(profile, "mcp.json");
const skillDirectory = join(
account,
".agents",
"skills",
"reverse-engineer-anything",
);
await Promise.all([
mkdir(target, { recursive: true }),
mkdir(profile, { recursive: true }),
mkdir(join(lab, "tmp")),
]);
await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
await writeFile(
join(target, "app.js"),
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
);
const environment = {
PATH: process.env.PATH,
NODE_OPTIONS: process.env.NODE_OPTIONS,
USERPROFILE: account,
TMPDIR: join(lab, "tmp"),
CMD_LOCAL_ONLY: "1",
// Native print mode checks for an account key even with a keyless BYOK model.
// This synthetic value is never a real credential; local-only refuses hosted API calls.
COMMAND_CODE_API_KEY: "local-fixture-only",
COMMANDCODE_SKIP_UPDATES: "1",
COMMANDCODE_DISABLE_CRON: "1",
OTEL_SDK_DISABLED: "true",
REA_PROCESS_RUN_ID: verifier.run_id,
};
const expectedNames = TOOL_CONTRACTS.map(
(tool) => "mcp__rea__" + tool.name,
).sort();
const schemaValidator = new Ajv2020({ strict: false, validateFormats: false });
const textValues = (value) =>
typeof value === "string"
? [value]
: value !== null && typeof value === "object"
? Object.values(value).flatMap(textValues)
: [];
let clientVersion;
let expectedSkill;
let inventoryChecked = false;
let skillLoaded = false;
let schemasChecked = 0;
let catalogArtifact;
let catalogDigest;
let catalogProjection;
let artifactProjectionSeen = false;
let evidenceSeen = false;
let evidenceBytes;
let finalSeen = false;
let stage = 0;
let failure;
const call = (name, args) => ({
role: "assistant",
content: null,
tool_calls: [
{
id: `call_commandcode_${++stage}`,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
],
});
const validateCatalog = async (text) => {
const entries = [
...text.matchAll(
/^### (mcp__rea__[A-Za-z0-9_]+)\n[\s\S]*?\nParameters:\n```json\n([\s\S]*?)\n```/gm,
),
];
assert.deepEqual(
entries.map((entry) => entry[1]).sort(),
expectedNames,
"Native exact-name search must return every REA input schema",
);
for (const entry of entries) {
const schema = JSON.parse(entry[2]);
assert(
schemaValidator.validateSchema(schema),
`${entry[1]}: invalid native schema: ${JSON.stringify(schemaValidator.errors)}`,
);
}
schemasChecked = entries.length;
catalogDigest = createHash("sha256").update(text).digest("hex");
await writeFile(join(lab, "native-catalog.md"), text);
};
const fixture = await createOpenAiModelFixture({
directory: lab,
prefix: "commandcode",
onRequest: async ({ body, tools }) => {
assert(tools.some((tool) => tool.name === "search_tools"));
const strings = textValues(body.messages);
if (!inventoryChecked) {
const names = [
...new Set(
strings.flatMap(
(text) => text.match(/mcp__rea__[A-Za-z0-9_]+/g) ?? [],
),
),
].sort();
assert.deepEqual(
names,
expectedNames,
"Default native deferred-tool prompt must advertise the complete REA inventory",
);
assert.equal(
tools.filter((tool) => tool.name.startsWith("mcp__rea__")).length,
0,
"Keep native lazy schema delivery enabled",
);
assert(
tools
.find((tool) => tool.name === "activate_skill")
?.parameters.properties.name.enum.includes(
"reverse-engineer-anything",
),
);
inventoryChecked = true;
}
if (mode === "chat") {
assert.equal(
body.messages.filter((message) => message.role === "tool").length,
0,
);
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
}
if (stage === 0)
return call("activate_skill", { name: "reverse-engineer-anything" });
assert(
strings.some((text) => text.includes(expectedSkill)),
"The model must receive the complete activated skill body",
);
skillLoaded = true;
const last = body.messages
.filter((message) => message.role === "tool")
.at(-1)?.content;
assert.equal(typeof last, "string");
if (stage === 1)
return call("search_tools", {
query: "select:" + expectedNames.join(","),
});
if (stage === 2) {
const spill = last.match(
/\[full output saved to: (.+?) — read it with read_file/,
);
if (spill) {
catalogArtifact = spill[1];
const child = relative(environment.TMPDIR, catalogArtifact);
assert(
isAbsolute(catalogArtifact) &&
child &&
!child.startsWith("..") &&
!isAbsolute(child),
"Native spill must remain inside this run's temporary directory",
);
const text = await readFile(catalogArtifact, "utf8");
await validateCatalog(text);
catalogProjection = { schemas: schemasChecked, sha256: catalogDigest };
const query =
"const fs=require('node:fs'),crypto=require('node:crypto');const text=fs.readFileSync(process.argv[1],'utf8');const schemas=[...text.matchAll(/^### (mcp__rea__[A-Za-z0-9_]+)\\n/gm)].length;console.log(JSON.stringify({rea_catalog:{schemas,sha256:crypto.createHash('sha256').update(text).digest('hex')}}));";
return call("shell_command", {
command: process.execPath,
args: ["-e", query, catalogArtifact],
});
}
await validateCatalog(last);
return call("mcp__rea__analyze_javascript_application", {
input_path: target,
format: "directory",
});
}
if (catalogArtifact && !artifactProjectionSeen) {
const line = last
.split("\n")
.find((item) => item.startsWith('{"rea_catalog":'));
assert(line, "Native shell must return the selected saved-catalog facts");
assert.deepEqual(JSON.parse(line).rea_catalog, catalogProjection);
artifactProjectionSeen = true;
return call("mcp__rea__analyze_javascript_application", {
input_path: target,
format: "directory",
});
}
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(last));
assert.equal(evidence.subject.local_path, target);
const shapes = evidence.normalized_result.graph.nodes
.flatMap((node) => node.observations)
.map((observation) => observation.properties)
.filter(
(properties) =>
properties.semantic_role === "export-return-shapes" &&
properties.exported_name === "result",
);
assert(
shapes.length > 0 &&
shapes.every(
(shape) => shape.return_shape_coverage?.projection_complete,
),
);
const fields = shapes.flatMap((shape) =>
shape.static_return_shapes.flatMap((item) => item.fields),
);
assert(
fields.some(
(field) =>
field.path === "/clientCompatibility" &&
field.state === "literal" &&
field.value === "REA_CLIENT_FIXTURE",
),
);
assert(
fields.some(
(field) =>
field.path === "/count" &&
field.state === "literal" &&
field.value === 7,
),
);
evidenceBytes = Buffer.byteLength(last);
await writeFile(join(lab, "native-evidence.json"), last);
evidenceSeen = true;
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
},
});
await writeFile(
join(profile, "providers.json"),
JSON.stringify({
provider: {
"rea-local": {
baseURL: fixture.baseUrl,
apiKey: false,
models: {
"rea-client-fixture": { contextWindow: 1_000_000, maxOutput: 4096 },
},
},
},
}),
);
const original =
JSON.stringify(
{
mcpServers: { other: { command: "unrelated-server", enabled: false } },
preserved: "keep unrelated settings",
},
null,
2,
) + "\n";
await writeFile(configPath, original);
const rea = (args) =>
exec(
process.execPath,
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
{ env: environment, cwd: workspace, timeout: 60_000 },
);
try {
clientVersion = (
await exec(command, ["--version"], {
env: environment,
cwd: workspace,
timeout: 30_000,
})
).stdout.trim();
for (const args of [
["setup", "--client", "commandcode", "--dry-run"],
["setup", "--client", "commandcode", "--yes"],
["doctor", "--client", "commandcode", "--skill"],
]) {
const response = await rea(args);
await writeFile(
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
response.stdout,
);
const result = JSON.parse(response.stdout);
if (args[0] === "doctor") assert.equal(result.healthy, true);
else
assert.equal(
result.status,
args.includes("--dry-run") ? "planned" : "ready",
);
if (args.includes("--dry-run")) {
assert(
result.plannedActions.some((action) => action.target === configPath),
);
assert(
result.plannedActions.every(
(action) =>
action.target === configPath ||
action.target === skillDirectory ||
action.target.startsWith(skillDirectory + "/"),
),
"Refuse setup writes outside the disposable account's configuration and skill",
);
}
}
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
const configured = await readFile(configPath, "utf8");
assert.equal(JSON.parse(configured).preserved, "keep unrelated settings");
assert.equal(
JSON.parse(configured).mcpServers.other.command,
"unrelated-server",
);
const repeated = JSON.parse(
(await rea(["setup", "--client", "commandcode", "--yes"])).stdout,
);
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 execution = exec(
command,
[
"-p",
mode === "chat"
? "Say hello briefly."
: `Use the REA skill to analyze ${target}.`,
"-m",
"rea-local/rea-client-fixture",
"--local-only",
"--skip-onboarding",
"--trust",
"--output-format",
"json",
"--max-turns",
"8",
"--yolo",
],
{
env: environment,
cwd: workspace,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
},
);
execution.child.stdin.end();
const response = await execution;
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
assert.equal(fixture.failure, undefined);
const events = response.stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
assert(
events.some(
(event) =>
event.type === "result" &&
event.subtype === "success" &&
event.finalText === "REA_CLIENT_COMPATIBILITY_OK",
),
);
assert(inventoryChecked && finalSeen);
if (mode === "call")
assert(
skillLoaded &&
schemasChecked === expectedNames.length &&
evidenceSeen &&
(!catalogArtifact || artifactProjectionSeen),
);
} catch (cause) {
failure = cause;
if (typeof cause?.stdout === "string")
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
if (typeof cause?.stderr === "string")
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
} finally {
await fixture.close();
const receipt = {
client: "Command Code",
clientVersion,
mode,
passed: failure === undefined,
artifacts: lab,
runtimeRoot,
host: {
platform: process.platform,
architecture: process.arch,
node: process.versions.node,
},
modelFixture:
"loopback OpenAI completions/SSE, keyless BYOK, synthetic account-key gate and usage; no hosted authentication or live-model claim",
discovery:
"actual disposable OS account home; native user MCP configuration and default shared skill discovery; Windows unverified",
interface:
"default deferred schemas, complete exact-name search, native saved-catalog query when needed, actual JavaScript MCP call",
inventoryChecked,
catalogSize: inventoryChecked ? expectedNames.length : 0,
schemasChecked,
skillLoaded,
catalogArtifact,
catalogDigest,
artifactProjectionSeen,
evidenceSeen,
evidenceBytes,
finalSeen,
requests: fixture.requests,
probes: fixture.probes,
fixtureFailure: fixture.failure,
verifier: await completeVerifierRun(verifier),
};
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
}
if (failure !== undefined) throw failure;
+539
View File
@@ -0,0 +1,539 @@
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 { isAbsolute, join, relative } 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 { exec } from "./lib/verify-package-core.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
assert(
process.platform !== "win32",
"Native Grok verifier requires POSIX; Windows is unverified",
);
const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const command = process.env.REA_VERIFY_GROK_COMMAND ?? "grok";
const mode = process.argv[2] ?? "call";
assert(
["chat", "call"].includes(mode),
"Usage: verify-grok-client.mjs [chat|call]",
);
const verifier = createVerifierRun();
const lab = await mkdtemp(join(tmpdir(), "rea-grok-client-"));
const account = join(lab, "account");
const profile = join(lab, "selected profile");
const workspace = join(lab, "workspace");
const configPath = join(profile, "config.toml");
const skillDirectory = join(
account,
".agents",
"skills",
"reverse-engineer-anything",
);
await Promise.all([mkdir(account), mkdir(profile), mkdir(workspace)]);
await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
// Native compatibility discovery is independent of GROK_HOME. Disable those
// sources and ignore the real shared skill root instead of changing HOME.
const environment = {
PATH: process.env.PATH,
NODE_OPTIONS: process.env.NODE_OPTIONS,
USERPROFILE: account,
GROK_HOME: profile,
GROK_DISABLE_AUTOUPDATER: "1",
...Object.fromEntries(
["CLAUDE", "CURSOR"].flatMap((vendor) =>
["SKILLS", "RULES", "AGENTS", "MCPS", "HOOKS", "SESSIONS"].map((cell) => [
`GROK_${vendor}_${cell}_ENABLED`,
"false",
]),
),
),
XDG_DATA_HOME: join(lab, "data"),
XDG_CONFIG_HOME: join(lab, "config"),
XDG_CACHE_HOME: join(lab, "cache"),
REA_PROCESS_RUN_ID: verifier.run_id,
};
const target = join(lab, "fixture α测试");
await mkdir(target);
await writeFile(
join(target, "app.js"),
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n' +
// A real analysis result large enough to exercise native MCP offloading.
Array.from(
{ length: 60 },
(_, index) =>
`export function extra${index}() { return { index: ${index}, tag: "GROK_LARGE_FIXTURE" }; }`,
).join("\n"),
);
const expectedNames = TOOL_CONTRACTS.map((tool) => "rea__" + tool.name).sort();
assert(
expectedNames.length <= 255,
"Native search_tool limit is uint8; this full-catalog query cannot cover more than 255 tools",
);
const schemaValidator = new Ajv2020({ strict: false, validateFormats: false });
const shellQuote = (value) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
const containsText = (value, text) => {
if (typeof value === "string") return value.includes(text);
return (
value !== null &&
typeof value === "object" &&
Object.values(value).some((item) => containsText(item, text))
);
};
let clientVersion;
let expectedSkill;
let turn = 0;
let skillRequested = false;
let skillLoaded = false;
let diagnosticsRequested = false;
let diagnosticsVerified = false;
let lastTool;
let catalogChecked = false;
let schemasChecked = 0;
let analysisRequested = false;
let evidenceSeen = false;
let spillQueryRequested = false;
let projectionSeen = false;
let artifactDigest;
let artifactBytes;
let retainedArtifact;
let expectedProjection;
let finalSeen = false;
let failure;
const call = (name, args) => {
lastTool = name;
return {
role: "assistant",
content: null,
tool_calls: [
{
id: `call_grok_${turn}`,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
],
};
};
const verifyEvidence = (bytes) => {
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(bytes.toString("utf8")));
assert.equal(evidence.subject.local_path, target);
const shapes = evidence.normalized_result.graph.nodes
.flatMap((node) => node.observations)
.map((observation) => observation.properties)
.filter(
(properties) =>
properties.semantic_role === "export-return-shapes" &&
properties.exported_name === "result",
);
assert(
shapes.length > 0 &&
shapes.every((shape) => shape.return_shape_coverage?.projection_complete),
);
const fields = shapes.flatMap((shape) =>
shape.static_return_shapes.flatMap((item) => item.fields),
);
assert(
fields.some(
(field) =>
field.path === "/clientCompatibility" &&
field.state === "literal" &&
field.value === "REA_CLIENT_FIXTURE",
),
);
assert(
fields.some(
(field) =>
field.path === "/count" &&
field.state === "literal" &&
field.value === 7,
),
);
artifactDigest = createHash("sha256").update(bytes).digest("hex");
artifactBytes = bytes.length;
evidenceSeen = true;
return {
evidence_id: evidence.evidence_id,
subject: evidence.subject,
operation: evidence.operation,
artifact_sha256: artifactDigest,
byte_length: artifactBytes,
shapes,
};
};
const diagnosticFile = join(lab, "native-doctor.json");
const diagnosticCode =
"const fs=require('node:fs');const d=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));console.log(JSON.stringify({rea_client_diagnostic:{healthy:d.healthy,registrations:d.identity.registrations.filter(r=>r.client==='grok_build'),skill:d.identity.skill.state}}));";
// Query the saved result through a native agent command. Only the facts needed
// for this analyst question enter context; the complete named-schema Evidence
// and its digest remain available in the native artifact.
const recoveryCode =
"const fs=require('node:fs'),crypto=require('node:crypto');const bytes=fs.readFileSync(process.argv[1]),e=JSON.parse(bytes);const shapes=e.normalized_result.graph.nodes.flatMap(n=>n.observations).map(o=>o.properties).filter(p=>p.semantic_role==='export-return-shapes'&&p.exported_name==='result');console.log(JSON.stringify({rea_recovered:{evidence_id:e.evidence_id,subject:e.subject,operation:e.operation,artifact_sha256:crypto.createHash('sha256').update(bytes).digest('hex'),byte_length:bytes.length,shapes}}));";
const fixture = await createOpenAiModelFixture({
directory: lab,
prefix: "grok",
onRequest: async ({ body, tools }) => {
// Native title/summary requests cannot execute the analyst workflow.
if (!tools.some((tool) => tool.name === "search_tool"))
return { role: "assistant", content: "REA fixture auxiliary response" };
turn++;
assert(
containsText(body.messages, skillDirectory),
"Grok must discover the isolated installed skill",
);
assert(
!containsText(body.messages, join(homedir(), ".agents", "skills")),
"Do not inherit real personal skills",
);
if (mode === "chat") {
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
}
const replies = body.messages.filter((message) => message.role === "tool");
if (!skillRequested) {
skillRequested = true;
return call("read_file", {
target_file: join(skillDirectory, "SKILL.md"),
});
}
assert(
expectedSkill !== undefined && containsText(replies, expectedSkill),
"Native read_file must return the full skill with its documented line-number rendering",
);
skillLoaded = true;
const last = replies.at(-1)?.content;
assert.equal(typeof last, "string");
if (!catalogChecked) {
const discovery =
lastTool === "search_tool" ? JSON.parse(last) : undefined;
if (discovery?.status === "partial" && !diagnosticsRequested) {
diagnosticsRequested = true;
const diagnostic = [
process.execPath,
join(runtimeRoot, "scripts/rea.mjs"),
"doctor",
"--client",
"grok_build",
"--skill",
"--json",
]
.map(shellQuote)
.join(" ");
const projection = [
process.execPath,
"-e",
diagnosticCode,
diagnosticFile,
]
.map(shellQuote)
.join(" ");
return call("run_terminal_command", {
command: `${diagnostic} > ${shellQuote(diagnosticFile)} && ${projection}`,
description:
"Diagnose REA registration while native MCP discovery is pending",
});
}
if (lastTool === "run_terminal_command") {
const line = last
.split("\n")
.find((item) => item.startsWith('{"rea_client_diagnostic":'));
assert(
line,
"Native REA diagnostics failed or did not return the selected status",
);
const diagnostic = JSON.parse(line).rea_client_diagnostic;
assert.equal(diagnostic.healthy, true);
assert.equal(diagnostic.skill, "aligned");
assert(
diagnostic.registrations.some(
(registration) =>
registration.state === "aligned" &&
registration.config_path === configPath,
),
);
diagnosticsVerified = true;
}
if (discovery?.status !== "ready")
return call("search_tool", { query: "rea", limit: 255 });
const inventory = discovery.results
.filter((result) => result.server === "rea")
.flatMap((result) => result.tools);
assert.deepEqual(
inventory.map((tool) => tool.tool_name).sort(),
expectedNames,
"Native search_tool must discover the complete REA catalog",
);
for (const tool of inventory) {
assert(tool.input_schema, `${tool.tool_name}: missing input schema`);
assert(
schemaValidator.validateSchema(tool.input_schema),
`${tool.tool_name}: invalid advertised schema: ${JSON.stringify(schemaValidator.errors)}`,
);
schemasChecked++;
}
await writeFile(join(lab, "native-discovery.json"), last);
catalogChecked = true;
analysisRequested = true;
return call("use_tool", {
tool_name: "rea__analyze_javascript_application",
tool_input: { input_path: target, format: "directory" },
});
}
if (!spillQueryRequested) {
const marker = "Full output written to: ";
const start = last.indexOf(marker);
if (start < 0) {
verifyEvidence(Buffer.from(last, "utf8"));
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
}
// The native hint explicitly calls for a query rather than read_file on
// the saved JSON's very long line. Preserve the original hint in requests.
const file = last
.slice(start + marker.length)
.split(". The full output ")[0];
assert(isAbsolute(file));
const childPath = relative(join(profile, "sessions"), file);
assert(
childPath !== "" &&
!childPath.startsWith("..") &&
!isAbsolute(childPath) &&
file.endsWith(".json"),
"Unexpected native MCP spill path",
);
retainedArtifact = file;
expectedProjection = verifyEvidence(await readFile(file));
spillQueryRequested = true;
return call("run_terminal_command", {
command: [process.execPath, "-e", recoveryCode, file]
.map(shellQuote)
.join(" "),
description:
"Query full saved REA Evidence while preserving artifact identity",
});
}
const line = last
.split("\n")
.find((item) => item.startsWith('{"rea_recovered":'));
assert(
line,
"The next model request must contain native artifact query output",
);
const projection = JSON.parse(line).rea_recovered;
assert.deepEqual(
projection,
expectedProjection,
"Native query must recover the observed facts and complete artifact digest",
);
projectionSeen = true;
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
},
});
const q = JSON.stringify;
const original = `# Keep unrelated settings and server state.
disabled_mcp_servers = ["rea", "other"]
[cli]
auto_update = false
[features]
telemetry = "off"
[skills]
paths = [${q(join(account, ".agents", "skills"))}]
ignore = [${q(join(homedir(), ".agents", "skills"))}]
[model.rea-client-fixture]
model = "rea-client-fixture"
base_url = ${q(fixture.baseUrl)}
api_key = "local-fixture-key"
api_backend = "chat_completions"
context_window = 1000000
max_completion_tokens = 4096
[mcp_servers.other]
command = "unrelated-server"
enabled = false
`;
await writeFile(configPath, original);
const rea = async (args) =>
exec(
process.execPath,
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
{ env: environment, cwd: workspace, timeout: 60_000 },
);
try {
clientVersion = (
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
).stdout.trim();
for (const args of [
["setup", "--client", "grok_build", "--dry-run"],
["setup", "--client", "grok_build", "--yes"],
["doctor", "--client", "grok_build", "--skill"],
]) {
const result = await rea(args);
await writeFile(
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
result.stdout,
);
const parsed = JSON.parse(result.stdout);
if (args[0] === "doctor") assert.equal(parsed.healthy, true);
else {
assert.equal(
parsed.status,
args.includes("--dry-run") ? "planned" : "ready",
);
if (args.includes("--dry-run")) {
assert(
parsed.plannedActions.some((action) => action.target === configPath),
);
assert(
parsed.plannedActions.some(
(action) => action.target === skillDirectory,
),
);
assert(
parsed.plannedActions.every(
(action) =>
action.target === configPath ||
action.target === skillDirectory ||
action.target.startsWith(skillDirectory + "/"),
),
"Refuse writes outside the owned profile/skill",
);
}
}
}
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
const configured = await readFile(configPath, "utf8");
assert(
configured.includes("unrelated-server") &&
configured.includes("# Keep unrelated settings"),
);
const repeated = JSON.parse(
(await rea(["setup", "--client", "grok_build", "--yes"])).stdout,
);
assert.deepEqual(repeated.appliedActions, []);
assert.equal(await readFile(configPath, "utf8"), configured);
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
const skill = await readFile(join(skillDirectory, "SKILL.md"), "utf8");
expectedSkill = skill
.trimEnd()
.split("\n")
.map((line, index) =>
index === 0 || (index + 1) % 10 === 0 ? `${index + 1}→${line}` : line,
)
.join("\n");
const execution = exec(
command,
[
"-p",
mode === "chat"
? "Say hello briefly."
: `Use the REA skill to analyze ${target}.`,
"-m",
"rea-client-fixture",
"--output-format",
"streaming-messages-json",
"--always-approve",
"--no-subagents",
],
{
env: environment,
cwd: workspace,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
},
);
execution.child.stdin.end();
const response = await execution;
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
assert.equal(fixture.failure, undefined);
const events = response.stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
const init = events.find(
(event) => event.type === "system" && event.subtype === "init",
);
assert(init?.mcp_servers?.some((server) => server.name === "rea"));
assert(
init.mcp_servers.every((server) => ["rea", "other"].includes(server.name)),
"Do not inherit foreign MCP configuration",
);
assert(
events.some(
(event) =>
event.type === "result" &&
event.subtype === "success" &&
event.is_error === false &&
event.result === "REA_CLIENT_COMPATIBILITY_OK",
),
);
assert(finalSeen);
if (mode === "call")
assert(
skillLoaded &&
catalogChecked &&
analysisRequested &&
evidenceSeen &&
(!spillQueryRequested || projectionSeen) &&
(!diagnosticsRequested || diagnosticsVerified),
);
} catch (cause) {
failure = cause;
if (typeof cause?.stdout === "string")
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
if (typeof cause?.stderr === "string")
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
} finally {
await fixture.close();
const lineage = await completeVerifierRun(verifier);
const receipt = {
client: "Grok Build",
clientVersion,
mode,
passed: failure === undefined,
artifacts: lab,
runtimeRoot,
host: {
platform: process.platform,
architecture: process.arch,
node: process.versions.node,
},
modelFixture:
"loopback OpenAI completions/SSE, declared one-million-token custom model and synthetic usage; no live model claim",
discovery:
"native GROK_HOME, isolated additional skill directory, disabled foreign compatibility sources; default OS-home skill discovery and Windows unverified",
interface:
"default native search_tool/use_tool with a complete explicit inventory query; asynchronous partial discovery may trigger native REA diagnosis; large MCP outputs are queried via native terminal",
catalogChecked,
catalogSize: catalogChecked ? expectedNames.length : 0,
schemasChecked,
skillLoaded,
diagnosticsRequested,
diagnosticsVerified,
analysisRequested,
evidenceSeen,
spillQueryRequested,
projectionSeen,
retainedArtifact,
artifactBytes,
artifactDigest,
finalSeen,
evidenceScope:
"complete saved Evidence validated with its named schema; the model receives the selected export facts, subject and full artifact digest, not every offloaded graph fact",
requests: fixture.requests,
probes: fixture.probes,
fixtureFailure: fixture.failure,
verifier: lineage,
};
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
}
if (failure !== undefined) throw failure;
+153
View File
@@ -0,0 +1,153 @@
import { execFile } from "node:child_process";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { JebAnalysisService } from "../dist/application/jeb/JebAnalysisService.js";
import { createJebAnalysisProvider } from "../dist/composition/jeb.js";
const run = promisify(execFile);
const options = parseArguments(process.argv.slice(2));
if (options.help) {
process.stdout.write(
"Usage: npm run verify:jeb [-- --item-address ADDRESS --marker TEXT]\n" +
"Requires a running JEB client serving MCP at REA_JEB_MCP_URL\n" +
"(default http://127.0.0.1:8425/mcp) with a project already open.\n" +
"JEB 5.48 headless instances open their target at start (--infile or\n" +
"the documented programmatic launcher); open_jeb_project is verified\n" +
"only when the engine advertises it. Without --item-address, a tiny\n" +
"Java fixture is compiled and its known method is decompiled.\n",
);
process.exit(0);
}
const provider = createJebAnalysisProvider(process.env);
const service = new JebAnalysisService(provider);
const fixtureDirectory = await mkdtemp(join(tmpdir(), "rea-jeb-"));
try {
const client = await service.execute("inspect_jeb_client", {});
if (!client.ok) throw new Error(client.error.message);
const engine = client.value.normalized_result.engine;
if (engine.version === null)
throw new Error("The JEB client did not report its version");
// JEB 5.48 headless clients do not advertise open_project; a project opened
// at engine start is the supported target-selection route there. Attempt the
// tool and record the engine's exact refusal when it is unavailable.
let openRefusal = null;
const target = await resolveTarget(options, fixtureDirectory);
const opened = await service.execute("open_jeb_project", {
path: target.path,
});
if (opened.ok) {
const artifact = opened.value.normalized_result.input_files.find(
(file) => typeof file === "object" && file !== null,
);
if (
artifact === undefined ||
!/^[0-9a-f]{64}$/u.test(artifact.contents_sha256_hash)
)
throw new Error("The engine did not report an input artifact digest");
} else {
openRefusal = opened.error.message;
}
const units = await service.execute("list_jeb_units", { count: 100 });
if (!units.ok) throw new Error(units.error.message);
if (units.value.normalized_result.units.length === 0)
throw new Error("The engine reported no project units");
const decompiled = await service.execute("decompile_jeb_item", {
item_address: target.itemAddress,
item_kind: "method",
});
if (!decompiled.ok) throw new Error(decompiled.error.message);
const text = decompiled.value.normalized_result.text;
if (!text.includes(target.marker))
throw new Error(
"Decompiled pseudo-code did not contain the fixture marker",
);
const report = {
ok: true,
provider: "jeb",
host_platform: process.platform,
engine_version: engine.version,
engine_gui: engine.gui_client,
endpoint: engine.endpoint,
open_project_observed: openRefusal === null,
...(openRefusal === null ? {} : { open_project_refusal: openRefusal }),
unit_count: units.value.normalized_result.units.length,
decompiled_marker_observed: true,
};
process.stdout.write(`${JSON.stringify(report)}\n`);
} catch (cause) {
process.stderr.write(
`JEB real-provider verification failed: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
process.exitCode = 1;
} finally {
await provider.close().catch(() => undefined);
await rm(fixtureDirectory, { recursive: true, force: true }).catch(
() => undefined,
);
}
async function resolveTarget(selected, directory) {
if (selected.itemAddress !== undefined)
return {
path: "/engine-side/target",
itemAddress: selected.itemAddress,
marker: selected.marker ?? "",
};
await writeFile(
join(directory, "Hello.java"),
[
"public class Hello {",
" public static String greet(String name) {",
' return "Hello, " + name + "!";',
" }",
"}",
"",
].join("\n"),
);
try {
await run("javac", [join(directory, "Hello.java")]);
} catch {
throw new Error(
"javac is required to build the default verification fixture",
);
}
return {
path: join(directory, "Hello.class"),
itemAddress: "LHello;->greet(Ljava/lang/String;)Ljava/lang/String;",
marker: "Hello, ",
};
}
function parseArguments(args) {
if (args.length === 1 && args[0] === "--help") return { help: true };
const values = new Map();
for (let index = 0; index < args.length; index += 1) {
const flag = args[index];
const value = args[index + 1];
if (
(flag !== "--item-address" && flag !== "--marker") ||
value === undefined
)
throw new Error(
"Expected no arguments, or --item-address ADDRESS [--marker TEXT]",
);
values.set(flag, value);
index += 1;
}
return {
help: false,
...(values.has("--item-address")
? { itemAddress: values.get("--item-address") }
: {}),
...(values.has("--marker") ? { marker: values.get("--marker") } : {}),
};
}
+441
View File
@@ -0,0 +1,441 @@
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 { fileURLToPath } from "node:url";
import {
TOOL_CONTRACTS,
toolContract,
} from "../dist/contracts/toolContracts.js";
import { exec } from "./lib/verify-package-core.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
assert(
process.platform !== "win32",
"Native OMP verifier requires POSIX; Windows is unverified",
);
const repo = fileURLToPath(new URL("..", import.meta.url));
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
const command = process.env.REA_VERIFY_OMP_COMMAND ?? "omp";
const mode = process.argv[2] ?? "call";
assert(
["chat", "call"].includes(mode),
"Usage: verify-omp-client.mjs [chat|call]",
);
const verifier = createVerifierRun();
const lab = await mkdtemp(join(tmpdir(), "rea-omp-client-"));
const account = join(lab, "account");
const agent = join(lab, "selected agent");
const workspace = join(lab, "workspace");
const configPath = join(agent, "mcp.json");
const skillDirectory = join(
account,
".agents",
"skills",
"reverse-engineer-anything",
);
await Promise.all([
mkdir(account),
mkdir(agent),
mkdir(workspace),
...["data", "state", "cache"].map((name) =>
mkdir(join(lab, name, "omp"), { recursive: true }),
),
]);
await exec("git", ["init", "--quiet", workspace], { timeout: 30_000 });
// OMP's global config root uses native homedir independently of the agent override.
// A documented relative PI_CONFIG_DIR redirects it without changing HOME.
const environment = {
PATH: process.env.PATH,
NODE_OPTIONS: process.env.NODE_OPTIONS,
USERPROFILE: account,
PI_CODING_AGENT_DIR: agent,
PI_CONFIG_DIR: relative(homedir(), join(lab, "native-config")),
OMP_PROFILE: "",
XDG_DATA_HOME: join(lab, "data"),
XDG_STATE_HOME: join(lab, "state"),
XDG_CACHE_HOME: join(lab, "cache"),
REA_PROCESS_RUN_ID: verifier.run_id,
};
const target = join(lab, "fixture α测试");
await mkdir(target);
await writeFile(
join(target, "app.js"),
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
);
const expectedDevices = TOOL_CONTRACTS.map(
(tool) => "mcp__rea_" + tool.name,
).sort();
const analysisDevice = "xd://mcp__rea_analyze_javascript_application";
let clientVersion;
let expectedSkillBody;
let skillLoaded = false;
let contractRead = false;
let evidenceSeen = false;
let artifactDigest;
let finalSeen = false;
let catalogMounted = false;
let invalidProfileRejected = false;
let failure;
const containsText = (value, text) => {
if (typeof value === "string") return value.includes(text);
return (
value !== null &&
typeof value === "object" &&
Object.values(value).some((item) => containsText(item, text))
);
};
const call = (index, name, args) => ({
role: "assistant",
content: null,
tool_calls: [
{
id: `call_omp_${index}`,
type: "function",
function: { name, arguments: JSON.stringify(args) },
},
],
});
const fixture = await createOpenAiModelFixture({
directory: lab,
prefix: "omp",
onRequest: async ({ body, tools, requestIndex }) => {
assert(tools.some((tool) => tool.name === "read"));
assert(tools.some((tool) => tool.name === "write"));
const instructions = body.messages.filter(
(message) => message.role === "system",
);
for (const device of expectedDevices)
assert(
containsText(instructions, device),
`OMP must advertise mounted device ${device}`,
);
if (mode === "chat") {
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
}
const replies = body.messages.filter((message) => message.role === "tool");
if (requestIndex === 1)
return call(requestIndex, "read", {
i: "Load REA skill",
path: "skill://reverse-engineer-anything:raw",
});
assert(
expectedSkillBody !== undefined &&
containsText(replies, expectedSkillBody),
"OMP must return the complete installed skill body",
);
skillLoaded = true;
if (requestIndex === 2)
return call(requestIndex, "read", {
i: "Inspect REA native tool contract",
path: analysisDevice,
});
assert(containsText(replies, "# mcp__rea_analyze_javascript_application"));
assert(
containsText(replies, "input_path") &&
containsText(replies, '"directory"'),
);
contractRead = true;
if (requestIndex === 3)
return call(requestIndex, "write", {
path: analysisDevice,
content: JSON.stringify({ input_path: target, format: "directory" }),
});
const evidenceReply = replies.find(
(reply) =>
typeof reply.content === "string" &&
reply.content.startsWith('{"evidence_id":'),
);
assert(
evidenceReply,
"OMP must deliver the full analysis result to the next model turn",
);
const evidence = toolContract(
"analyze_javascript_application",
).outputSchema.parse(JSON.parse(evidenceReply.content));
assert.equal(evidence.subject.local_path, target);
const shape = evidence.normalized_result.graph.nodes
.flatMap((node) => node.observations)
.map((observation) => observation.properties)
.find(
(properties) =>
properties.semantic_role === "export-return-shapes" &&
properties.exported_name === "result",
);
assert(shape?.return_shape_coverage?.projection_complete);
const fields = shape.static_return_shapes.flatMap((item) => item.fields);
assert(
fields.some(
(field) =>
field.path === "/clientCompatibility" &&
field.state === "literal" &&
field.value === "REA_CLIENT_FIXTURE",
),
);
assert(
fields.some(
(field) =>
field.path === "/count" &&
field.state === "literal" &&
field.value === 7,
),
);
const bytes = JSON.stringify(evidence);
artifactDigest = createHash("sha256").update(bytes).digest("hex");
await writeFile(join(lab, "omp-evidence.json"), bytes);
evidenceSeen = true;
finalSeen = true;
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
},
});
const original = JSON.stringify(
{
mcpServers: { other: { command: "unrelated-server", enabled: false } },
disabledServers: ["rea", "other"],
},
null,
2,
);
await writeFile(configPath, original);
await writeFile(
join(agent, "config.yml"),
JSON.stringify({
skills: { customDirectories: [join(account, ".agents", "skills")] },
retry: { fallbackChains: { default: [] } },
}),
);
await writeFile(
join(agent, "models.yml"),
JSON.stringify({
providers: {
"rea-fixture": {
baseUrl: fixture.baseUrl,
apiKey: "local-fixture-key",
api: "openai-completions",
models: [
{
id: "rea-client-fixture",
name: "REA local fixture",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 4096,
},
],
},
},
}),
);
const rea = async (args, env = environment) =>
exec(
process.execPath,
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
{ env, cwd: workspace, timeout: 60_000 },
);
try {
clientVersion = (
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
).stdout.trim();
const invalidEnvironment = { ...environment, OMP_PROFILE: "Bad Name" };
const nativeRejected = await exec(
command,
["--print", "--no-session", "--no-title", "Hello"],
{ env: invalidEnvironment, cwd: workspace, timeout: 30_000 },
).then(
() => undefined,
(cause) => cause,
);
assert(
nativeRejected?.code === 1 &&
nativeRejected.stderr.includes('Invalid OMP profile "Bad Name"'),
);
await writeFile(
join(lab, "invalid-profile-native.stderr"),
nativeRejected.stderr,
);
const setupRejected = await rea(
["setup", "--client", "omp", "--dry-run"],
invalidEnvironment,
).then(
() => undefined,
(cause) => cause,
);
assert(typeof setupRejected?.code === "number" && setupRejected.code !== 0);
const rejected = JSON.parse(setupRejected.stdout);
assert.equal(rejected.status, "needs_human");
assert.deepEqual(rejected.plannedActions, []);
assert.deepEqual(rejected.appliedActions, []);
assert(rejected.remediation.includes('Invalid OMP profile "Bad Name"'));
assert.equal(await readFile(configPath, "utf8"), original);
await writeFile(
join(lab, "invalid-profile-setup.json"),
setupRejected.stdout,
);
invalidProfileRejected = true;
for (const args of [
["setup", "--client", "omp", "--dry-run"],
["setup", "--client", "omp", "--yes"],
["doctor", "--client", "omp", "--skill"],
]) {
const result = await rea(args);
await writeFile(
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
result.stdout,
);
const parsed = JSON.parse(result.stdout);
if (args[0] === "doctor") assert.equal(parsed.healthy, true);
else {
assert.equal(
parsed.status,
args.includes("--dry-run") ? "planned" : "ready",
);
if (args.includes("--dry-run")) {
assert(
parsed.plannedActions.some((action) => action.target === configPath),
);
assert(
parsed.plannedActions.some(
(action) => action.target === skillDirectory,
),
);
assert(
parsed.plannedActions.every(
(action) =>
action.target === configPath ||
action.target === skillDirectory ||
action.target.startsWith(skillDirectory + "/"),
),
"Refuse setup writes outside the owned config/skill",
);
}
}
}
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
const configured = await readFile(configPath, "utf8");
const registration = JSON.parse(configured);
assert.equal(registration.mcpServers.other.command, "unrelated-server");
assert.deepEqual(registration.disabledServers, ["other"]);
const repeated = JSON.parse(
(await rea(["setup", "--client", "omp", "--yes"])).stdout,
);
assert.deepEqual(repeated.appliedActions, []);
assert.equal(await readFile(configPath, "utf8"), configured);
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
expectedSkillBody = await readFile(join(skillDirectory, "SKILL.md"), "utf8");
const execution = exec(
command,
[
"--mode",
"json",
"--print",
"--no-session",
"--no-title",
"--no-lsp",
"--no-pty",
"--approval-mode",
"yolo",
"--model",
"rea-fixture/rea-client-fixture",
mode === "chat"
? "Say hello briefly."
: `Use the REA skill to analyze ${target}.`,
],
{
env: environment,
cwd: workspace,
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
},
);
execution.child.stdin.end();
const response = await execution;
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
assert.equal(fixture.failure, undefined);
const events = response.stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
const mounted = events
.filter(
(event) =>
event.type === "notice" && event.message?.startsWith("xd://: mounted "),
)
.flatMap((event) =>
event.message.slice("xd://: mounted ".length).split(", "),
)
.filter((name) => name.startsWith("mcp__rea_"));
assert.deepEqual(
mounted.sort(),
expectedDevices,
"Native OMP must mount the complete REA catalog",
);
catalogMounted = true;
assert(
!events.some(
(event) => event.type === "tool_execution_end" && event.isError,
),
);
assert(
events.some(
(event) =>
event.type === "message_end" &&
event.message?.role === "assistant" &&
event.message.stopReason === "stop" &&
containsText(event.message.content, "REA_CLIENT_COMPATIBILITY_OK"),
),
"OMP must finish with the expected native assistant message",
);
assert(
events.some((event) => event.type === "agent_end" && event.isTerminal),
);
assert(finalSeen);
if (mode === "call") assert(skillLoaded && contractRead && evidenceSeen);
} catch (cause) {
failure = cause;
if (typeof cause?.stdout === "string")
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
if (typeof cause?.stderr === "string")
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
} finally {
await fixture.close();
const lineage = await completeVerifierRun(verifier);
const receipt = {
client: "OMP",
clientVersion,
mode,
passed: failure === undefined,
artifacts: lab,
runtimeRoot,
host: {
platform: process.platform,
architecture: process.arch,
node: process.versions.node,
},
modelFixture:
"loopback OpenAI completions/SSE, caller-declared one-million-token model and synthetic usage; no live model claim",
discovery:
"explicit isolated default agent/global config/XDG roots and custom skill directory; named profiles, default OS-home skill discovery and Windows unverified",
interface:
"default native xd:// device metadata with read/write dispatch; complete JSON schemas are not forwarded as model functions",
catalogMounted,
catalogSize: expectedDevices.length,
invalidProfileRejected,
skillLoaded,
contractRead,
evidenceSeen,
artifactDigest,
finalSeen,
requests: fixture.requests,
probes: fixture.probes,
fixtureFailure: fixture.failure,
verifier: lineage,
};
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
}
if (failure !== undefined) throw failure;
+5 -1
View File
@@ -172,7 +172,11 @@ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const runtimeParent = join(workspace, "runtime with spaces");
await mkdir(runtimeParent);
const callerDirectory = join(workspace, "caller cwd with spaces");
const scriptCollisions = ["ReaGhidraBridge.java", "ReaGhidraPrepareCom.java"];
const scriptCollisions = [
"ReaGhidraBridge.java",
"ReaGhidraPrepareCom.java",
"ReaGhidraNoReturnFix.java",
];
await mkdir(callerDirectory);
for (const name of scriptCollisions) await mkdir(join(callerDirectory, name));
const environment = {
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import {
inspectMipsReadelf,
assertMipsLoadedImage,
assertMipsProbe,
assertMipsGlobal,
} from "../tests/conformance/ghidra/mips-checks.mjs";
import { parseEvidence } from "../dist/domain/evidence.js";
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
// Optional cross-target lane. No tool acquisition and no target execution.
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-mips.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"MIPS verification requires a supported Linux/macOS Ghidra host",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
if (installation.status !== "available")
throw new Error(
`Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`,
);
const execute = promisify(execFile);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-mips-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
GHIDRA_INSTALL_DIR: installation.installDir,
JAVA_HOME: installation.javaHome,
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
};
const reports = [];
let primaryFailure;
try {
for (const byteOrder of ["little", "big"]) {
const fixture = await buildMipsFixture(workspace, byteOrder);
const independent = await inspectMipsReadelf(fixture.path, byteOrder);
const resolved = await parseBinaryTarget(fixture.path);
if (!resolved.ok) throw resolved.error;
assert.deepEqual(
resolved.value.mips,
independent.mips,
"REA ELF/ABI interpretation disagrees with GNU readelf",
);
assert.equal(resolved.value.sha256, fixture.sha256);
const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-mips-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
undefined,
{ timeout: 180000 },
);
return requireMcpOperationResult(reply, name);
};
try {
await client.connect(transport);
const target = await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
assert.equal(target.architecture, "mips");
assert.equal(target.sha256, fixture.sha256);
// ELF load-image verification is not the DOS comparison lane. Its retained
// observations still expose the actual Ghidra language and loaded bytes.
const image = await call("inspect_native_load_image");
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
const procedures = await call("list_procedures");
const entry = procedures.find((p) => p.value === "rea_mips_entry");
const leaf = procedures.find((p) => p.value === "rea_mips_leaf");
assert.ok(entry, "Entry function missing from real Ghidra inventory");
assert.ok(leaf, "Leaf function missing from real Ghidra inventory");
assert.equal(
BigInt(entry.address),
BigInt(independent.symbols.rea_mips_entry),
);
assert.equal(
BigInt(leaf.address),
BigInt(independent.symbols.rea_mips_leaf),
);
const probe = independent.symbols.rea_mips_probe;
assert.ok(
procedures.some(
(p) =>
p.value === "rea_mips_probe" && BigInt(p.address) === BigInt(probe),
),
);
const move = await call("inspect_native_instruction", { address: probe });
const branch = await call("inspect_native_instruction", {
address: `0x${(BigInt(probe) + 4n).toString(16)}`,
});
assertMipsProbe(move, branch, byteOrder, probe);
const global = await call("read_bytes", {
address: independent.symbols.rea_mips_global,
length: 4,
});
assert.equal(global.returned_bytes, 4);
assertMipsGlobal(global.bytes_hex, byteOrder);
const marker = await call("read_bytes", {
address: independent.symbols.rea_mips_marker,
length: 30,
});
assert.equal(
marker.bytes_hex,
Buffer.from("rea-mips-source-owned-fixture\0").toString("hex"),
);
const callees = await call("procedure_callees", {
procedure: entry.address,
});
assert.ok(
callees.includes(leaf.address),
"Expected direct call not recovered",
);
const assembly = await call("procedure_assembly", {
procedure: entry.address,
});
assert.ok(assembly.trim().length > 0);
const dossier = functionDossierSchema.parse(
await call("analyze_function", { procedure: entry.address }),
);
assert.ok(dossier.pseudocode.trim().length > 0);
const mapping = await call("address_to_file_offset", {
address: entry.address,
});
const memory = await call("read_bytes", {
address: entry.address,
length: 16,
});
const sourceBytes = await readFile(fixture.path);
assert.equal(memory.returned_bytes, 16);
assert.equal(
memory.bytes_hex,
sourceBytes
.subarray(mapping.file_offset, mapping.file_offset + 16)
.toString("hex"),
);
const saved = await call("close_binary", {
snapshot_path: snapshotPath,
});
assert.ok(saved.evidence_records > 0);
const snapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(snapshotPath, "utf8")),
);
assert.equal(snapshot.target.architecture, "mips");
assert.equal(
snapshot.binding.analysis_profile.parameters.mips_elf.byte_order,
byteOrder,
);
assert.ok(snapshot.evidence_bundle.records.length > 0);
for (const evidence of snapshot.evidence_bundle.records)
assert.equal(parseEvidence(evidence).subject?.architecture, "mips");
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
});
assert.deepEqual(await call("list_procedures"), procedures);
await call("close_binary");
// Same public operation through CLI, not a private injected provider.
const cli = await execute(
process.execPath,
[
entrypoint,
"function",
fixture.path,
"rea_mips_entry",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(cli.stdout));
assert.equal(evidence.subject?.architecture, "mips");
assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
const cliDossier = functionDossierSchema.parse(
evidence.normalized_result,
);
assert.equal(cliDossier.procedure.address, dossier.procedure.address);
assert.ok(cliDossier.pseudocode.trim().length > 0);
assert.equal(
createHash("sha256")
.update(await readFile(fixture.path))
.digest("hex"),
fixture.sha256,
"Analysis modified the source executable",
);
reports.push({
...fixture,
independent_reader: independent,
effective_language: image.observations.language_id,
effective_compiler_spec: image.observations.compiler_spec_id,
checks: [
"independent ELF/ABI and symbols",
"loaded Ghidra identity",
"fixed instruction bytes and immediate",
"conditional branch destination",
"global word and marker bytes",
"CLI/MCP and snapshot identity",
],
procedures: procedures.length,
status: "passed",
});
} catch (cause) {
throw new Error(
`MIPS ${byteOrder} verification failed; MCP stderr: ${stderr}`,
{ cause },
);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
}
} catch (cause) {
primaryFailure = cause;
}
const completed = await completeVerifierRun(run);
if (
completed.process_lineage.status !== "verified" ||
completed.process_lineage.descendants.length !== 0
)
throw new Error(
`Cleanup unverified; retained diagnostic workspace: ${workspace}`,
{ cause: primaryFailure },
);
if (primaryFailure !== undefined)
throw new Error(
`MIPS verification failed; retained workspace: ${workspace}`,
{
cause: primaryFailure,
},
);
await rm(workspace, { recursive: true, force: true });
console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));
+3 -3
View File
@@ -15,10 +15,10 @@ import { buildRecoveryFixtures } from "../../fixtures/javascript-recovery/build.
import { mcpTextValue } from "../../lib/mcp-verifier-results.mjs";
const OFFICIAL_BINARY_SHA256 =
"e8c7ca052974604197389f6ace7464e4dd24dce64e2730971521bd6606ec9c9e";
"664fa06782a61eda7ed15885134b52525fd565dd77607e642062ceb6cd927c38";
if (!process.env.REA_WAKARU_COMMAND)
throw new Error(
"Provide REA_WAKARU_COMMAND for the official Wakaru 1.13.0 Linux x64 binary",
"Provide REA_WAKARU_COMMAND for the official Wakaru 1.14.0 Linux x64 binary",
);
const entrypoint =
process.argv[2] ?? fileURLToPath(new URL("../../rea.mjs", import.meta.url));
@@ -130,7 +130,7 @@ try {
}
}
finalReport = {
engine: "wakaru 1.13.0",
engine: "wakaru 1.14.0",
toolchains: fixtures.toolchains,
results: counts,
verified: true,
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
ADB_BINARY_ENV,
parseAdbVersionOutput,
resolveAdbBinary,
} from "./AdbConfiguration.js";
describe("resolveAdbBinary", () => {
it("prefers an absolute REA_ADB_PATH", () => {
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: "/opt/tools/adb" })).toEqual({
binary: "/opt/tools/adb",
pathSource: "environment",
});
});
it("falls back to PATH resolution without the environment key", () => {
expect(resolveAdbBinary({})).toEqual({
binary: "adb",
pathSource: "path",
});
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: " " })).toEqual({
binary: "adb",
pathSource: "path",
});
});
it("rejects a relative environment path explicitly", () => {
expect(() => resolveAdbBinary({ [ADB_BINARY_ENV]: "tools/adb" })).toThrow(
/must be an absolute path/u,
);
});
});
describe("parseAdbVersionOutput", () => {
it("reads the build version and install path from real output", () => {
expect(
parseAdbVersionOutput(
[
"Android Debug Bridge version 1.0.41",
"Version 34.0.5-debian",
"Installed as /usr/lib/android-sdk/platform-tools/adb",
"",
].join("\n"),
),
).toEqual({
version: "34.0.5-debian",
installedPath: "/usr/lib/android-sdk/platform-tools/adb",
});
});
it("reports null identity fields when the named lines are absent", () => {
expect(parseAdbVersionOutput("unexpected output")).toEqual({
version: null,
installedPath: null,
});
});
});
+144
View File
@@ -0,0 +1,144 @@
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Environment key selecting the caller's adb binary. */
export const ADB_BINARY_ENV = "REA_ADB_PATH";
const VERSION_TIMEOUT_MS = 10_000;
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
/** How the adb binary was selected for this provider instance. */
export interface AdbBinarySelection {
readonly binary: string;
readonly pathSource: "environment" | "path";
}
/**
* Resolve the caller-supplied adb binary. `REA_ADB_PATH` must be absolute so
* the selection stays explicit; without it, adb is resolved from `PATH` by
* the platform exec at run time, like any bring-your-own tool.
*/
export const resolveAdbBinary = (
environment: Readonly<Record<string, string | undefined>>,
): AdbBinarySelection => {
const configured = environment[ADB_BINARY_ENV];
if (configured === undefined || configured.trim() === "")
return { binary: "adb", pathSource: "path" };
if (!isAbsolute(configured))
throw new AdbConfigurationFailure(
"not_absolute",
`${ADB_BINARY_ENV} must be an absolute path to the adb binary`,
{ configured_path: configured },
);
return { binary: configured, pathSource: "environment" };
};
export type AdbConfigurationFailureCode =
| "not_absolute"
| "binary_missing"
| "version_unresolved";
export class AdbConfigurationFailure extends Error {
constructor(
readonly code: AdbConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
super(message, options);
}
}
export interface AdbClientIdentity {
readonly version: string | null;
readonly installedPath: string | null;
}
/**
* Parse `adb version` output. The first line carries the protocol version,
* `Version ` the build version (no colon), and `Installed as` the binary path.
*/
export const parseAdbVersionOutput = (stdout: string): AdbClientIdentity => {
const build = /^Version\s+(\S.*)$/mu.exec(stdout);
const installed = /^Installed as\s+(.+)$/mu.exec(stdout);
return {
version: build === null ? null : build[1]!.trim(),
installedPath: installed === null ? null : installed[1]!.trim(),
};
};
/**
* Probe the selected adb binary without contacting devices. `adb version`
* never starts the adb server, so this stays a pure client check.
*/
export const inspectAdbClient = async (
selection: AdbBinarySelection,
signal?: AbortSignal,
): Promise<AdbClientIdentity> => {
try {
const { stdout } = await execFileOutput(selection.binary, ["version"], {
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
});
return parseAdbVersionOutput(stdout);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
throw new AnalysisCancelledError("inspect_adb_client", { cause });
if (failure?.code === "ENOENT")
throw new AdbConfigurationFailure(
"binary_missing",
`The selected adb binary was not found: ${selection.binary}`,
{
selected_binary: selection.binary,
path_source: selection.pathSource,
remediation: `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary`,
},
{ cause },
);
throw new AdbConfigurationFailure(
"version_unresolved",
"The selected adb binary did not report a usable version",
{
selected_binary: selection.binary,
...(failure === undefined
? {}
: {
exit_code: failure.code,
stderr_excerpt: failure.stderr.slice(0, 512),
}),
},
{ cause },
);
}
};
/** Availability in the shape shared by every provider. */
const rejectionCodeOf = (
code: AdbConfigurationFailureCode,
): ProviderRejectionCode =>
code === "binary_missing"
? "executable_missing"
: code === "not_absolute"
? "not_configured"
: "version_unresolved";
export const adbConfigurationAvailability = (
cause: AdbConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from "vitest";
import {
parseAdbDevicesOutput,
parseGetpropOutput,
parsePackageListOutput,
parsePmPathOutput,
} from "./AdbDeviceOutput.js";
describe("parseAdbDevicesOutput", () => {
it("parses emulator, USB, and network devices with their metadata", () => {
const output = [
"List of devices attached",
"emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1",
"07ef9c2a device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2",
"192.168.1.42:5555 device product:mock model:mock transport_id:3",
"",
].join("\n");
const { devices, unparsedLines, daemonStarted } =
parseAdbDevicesOutput(output);
expect(unparsedLines).toEqual([]);
expect(daemonStarted).toBe(false);
expect(devices).toEqual([
{
serial: "emulator-5554",
state: "device",
transport: "tcp",
product: "sdk_gphone64_x86_64",
model: "sdk_gphone64_x86_64",
device: "emu64xa",
transport_id: 1,
kind: "emulator",
kind_basis: "emulator_serial_prefix",
},
{
serial: "07ef9c2a",
state: "device",
transport: "usb",
product: "raven",
model: "Pixel_6_Pro",
device: "raven",
transport_id: 2,
kind: "physical",
kind_basis: "usb_transport",
},
{
serial: "192.168.1.42:5555",
state: "device",
transport: "tcp",
product: "mock",
model: "mock",
device: null,
transport_id: 3,
kind: "unknown",
kind_basis: "none",
},
]);
});
it("recognizes daemon startup notices instead of failing the listing", () => {
const output = [
"* daemon not running; starting now at tcp:5037",
"* daemon started successfully",
"List of devices attached",
"emulator-5554 device transport_id:1",
"",
].join("\n");
const { devices, daemonStarted } = parseAdbDevicesOutput(output);
expect(daemonStarted).toBe(true);
expect(devices).toHaveLength(1);
});
it("keeps non-device states such as unauthorized and offline", () => {
const output = [
"List of devices attached",
"emulator-5556 offline transport_id:2",
"HT84L0240512 unauthorized usb:1-2 transport_id:3",
"",
].join("\n");
const { devices } = parseAdbDevicesOutput(output);
expect(devices.map((device) => [device.serial, device.state])).toEqual([
["emulator-5556", "offline"],
["HT84L0240512", "unauthorized"],
]);
});
it("returns unrecognized lines instead of dropping them", () => {
const output = [
"List of devices attached",
"this is not a device line",
"",
].join("\n");
const { unparsedLines } = parseAdbDevicesOutput(output);
expect(unparsedLines).toEqual(["this is not a device line"]);
});
it("reports a non-numeric transport_id as unknown", () => {
const output = [
"List of devices attached",
"emulator-5554 device transport_id:not-a-number",
"",
].join("\n");
const { devices } = parseAdbDevicesOutput(output);
expect(devices[0]?.transport_id).toBeNull();
});
});
describe("parseGetpropOutput", () => {
it("projects the whitelisted build identity from a real dump", () => {
const dump = [
"[ro.build.version.release]: [14]",
"[ro.build.version.sdk]: [34]",
"[ro.build.version.security_patch]: [2024-06-05]",
"[ro.build.id]: [AP21.240617.014]",
"[ro.build.fingerprint]: [google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys]",
"[ro.product.brand]: [google]",
"[ro.product.device]: [emu64xa]",
"[ro.product.manufacturer]: [Google]",
"[ro.product.model]: [sdk_gphone64_x86_64]",
"[ro.product.cpu.abilist]: [x86_64,x86,arm64-v8a,armeabi-v7a,armeabi]",
"[ro.kernel.qemu]: [1]",
"[ro.debuggable]: [1]",
"[sys.unlisted.property]: [dropped]",
"",
].join("\n");
const { properties, emulatorObserved } = parseGetpropOutput(dump);
expect(emulatorObserved).toBe(true);
expect(properties).not.toContainEqual({
name: "sys.unlisted.property",
value: "dropped",
});
expect(properties).toEqual([
{ name: "ro.build.version.release", value: "14" },
{ name: "ro.build.version.sdk", value: "34" },
{ name: "ro.build.version.security_patch", value: "2024-06-05" },
{ name: "ro.build.id", value: "AP21.240617.014" },
{
name: "ro.build.fingerprint",
value:
"google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys",
},
{ name: "ro.debuggable", value: "1" },
{ name: "ro.kernel.qemu", value: "1" },
{ name: "ro.product.brand", value: "google" },
{ name: "ro.product.device", value: "emu64xa" },
{ name: "ro.product.manufacturer", value: "Google" },
{ name: "ro.product.model", value: "sdk_gphone64_x86_64" },
{
name: "ro.product.cpu.abilist",
value: "x86_64,x86,arm64-v8a,armeabi-v7a,armeabi",
},
]);
});
it("reports an explicit non-emulator build without inventing a value", () => {
const dump = [
"[ro.build.version.release]: [13]",
"[ro.kernel.qemu]: [0]",
"",
].join("\n");
const { emulatorObserved } = parseGetpropOutput(dump);
expect(emulatorObserved).toBe(false);
});
it("reports the emulator indicator unknown when the build omits it", () => {
const { emulatorObserved } = parseGetpropOutput(
"[ro.build.version.sdk]: [33]\n",
);
expect(emulatorObserved).toBeNull();
});
});
describe("parsePackageListOutput", () => {
it("parses package lines with their base APK device paths", () => {
const output = [
"package:/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk=com.example",
"package:/system/app/WebView/WebView.apk=com.android.webview",
"",
].join("\n");
const { packages, unparsedLines } = parsePackageListOutput(output);
expect(unparsedLines).toEqual([]);
expect(packages).toEqual([
{
package_name: "com.example",
base_apk_device_path:
"/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk",
},
{
package_name: "com.android.webview",
base_apk_device_path: "/system/app/WebView/WebView.apk",
},
]);
});
it("keeps unrecognized lines for partial coverage reporting", () => {
const { packages, unparsedLines } = parsePackageListOutput(
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
);
expect(packages).toHaveLength(1);
expect(unparsedLines).toEqual(["garbage line"]);
});
});
describe("parsePmPathOutput", () => {
it("classifies base and split APKs from their file names", () => {
const output = [
"package:/data/app/~~a==/com.example.b==/base.apk",
"package:/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk",
"package:/data/app/~~a==/com.example.b==/split_config.en.apk",
"",
].join("\n");
const { paths, unparsedLines } = parsePmPathOutput(output);
expect(unparsedLines).toEqual([]);
expect(paths).toEqual([
{
device_path: "/data/app/~~a==/com.example.b==/base.apk",
file_name: "base.apk",
role: "base",
},
{
device_path:
"/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk",
file_name: "split_config.arm64_v8a.apk",
role: "split",
},
{
device_path: "/data/app/~~a==/com.example.b==/split_config.en.apk",
file_name: "split_config.en.apk",
role: "split",
},
]);
});
it("reports an unknown role for names that follow neither convention", () => {
const { paths } = parsePmPathOutput("package:/system/app/X/Y.apk\n");
expect(paths[0]).toEqual({
device_path: "/system/app/X/Y.apk",
file_name: "Y.apk",
role: "unknown",
});
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* Parsers for the exact adb and Package Manager output formats REA consumes.
*
* Each parser preserves what the tool actually printed: unparseable lines are
* retained rather than silently dropped, and derived classifications carry
* their evidence basis.
*/
/** One device exactly as `adb devices -l` reports it. */
export interface AdbDeviceEntry {
readonly serial: string;
readonly state: string;
readonly transport: "usb" | "tcp" | "unknown";
readonly product: string | null;
readonly model: string | null;
readonly device: string | null;
readonly transport_id: number | null;
readonly kind: "emulator" | "physical" | "unknown";
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
}
/**
* Device states adb prints after the serial, longest first so two-word
* states match before their single-word prefixes. A line whose remainder
* starts with none of them is not a device line.
*/
const DEVICE_STATES = [
"no permissions",
"unauthorized",
"authorizing",
"offline",
"recovery",
"sideload",
"connecting",
"bootloader",
"device",
"unknown",
"host",
] as const;
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
const metadataValue = (remainder: string, key: string): string | null => {
const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder);
return match === null ? null : match[1]!;
};
/**
* Parse `adb devices -l` device lines. The banner, blank lines, and the
* daemon startup notices adb prints on first use are recognized separately;
* every other unrecognized line is returned for honest reporting.
*/
export const parseAdbDevicesOutput = (
stdout: string,
): {
devices: readonly AdbDeviceEntry[];
unparsedLines: readonly string[];
daemonStarted: boolean;
} => {
const devices: AdbDeviceEntry[] = [];
const unparsedLines: string[] = [];
let daemonStarted = false;
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line === "" || line.startsWith("List of devices attached")) continue;
if (line.startsWith("* daemon")) {
if (line.includes("started successfully")) daemonStarted = true;
continue;
}
const match = DEVICE_LINE.exec(line);
const state = DEVICE_STATES.find((candidate) =>
match?.[2]?.startsWith(candidate),
);
if (match === null || state === undefined) {
if (line.trim() !== "") unparsedLines.push(line);
continue;
}
const serial = match[1]!;
const remainder = match[2]!.slice(state.length);
const usb = metadataValue(remainder, "usb");
const transportIdText = metadataValue(remainder, "transport_id");
const parsedTransportId =
transportIdText === null || !/^\d+$/u.test(transportIdText)
? null
: Number(transportIdText);
const isEmulator = /^emulator-\d+$/u.test(serial);
const kind = isEmulator
? ("emulator" as const)
: usb !== null
? ("physical" as const)
: ("unknown" as const);
devices.push({
serial,
state,
transport:
usb !== null
? "usb"
: isEmulator || serial.includes(":")
? "tcp"
: "unknown",
product: metadataValue(remainder, "product"),
model: metadataValue(remainder, "model"),
device: metadataValue(remainder, "device"),
transport_id: parsedTransportId,
kind,
kind_basis:
kind === "emulator"
? "emulator_serial_prefix"
: kind === "physical"
? "usb_transport"
: "none",
});
}
return { devices, unparsedLines, daemonStarted };
};
/**
* Properties projected from a `getprop` dump. The whitelist keeps device
* inspection to build identity facts; a full dump is out of scope.
*/
const DEVICE_PROPERTY_WHITELIST = [
"ro.build.version.release",
"ro.build.version.sdk",
"ro.build.version.security_patch",
"ro.build.id",
"ro.build.fingerprint",
"ro.build.characteristics",
"ro.debuggable",
"ro.kernel.qemu",
"ro.product.brand",
"ro.product.device",
"ro.product.manufacturer",
"ro.product.model",
"ro.product.cpu.abilist",
] as const;
export interface AdbDeviceProperty {
readonly name: string;
readonly value: string;
}
/** Parse a full `getprop` dump into `[name]: [value]` pairs. */
export const parseGetpropOutput = (
stdout: string,
): {
properties: readonly AdbDeviceProperty[];
emulatorObserved: boolean | null;
} => {
const values = new Map<string, string>();
for (const rawLine of stdout.split(/\r?\n/u)) {
const match = /^\[([^\]]+)\]:\s*\[(.*)\]$/u.exec(rawLine.trim());
if (match === null) continue;
values.set(match[1]!, match[2]!);
}
const properties = DEVICE_PROPERTY_WHITELIST.flatMap((name) => {
const value = values.get(name);
return value === undefined || value === ""
? []
: [{ name, value } satisfies AdbDeviceProperty];
});
const qemu = values.get("ro.kernel.qemu");
return {
properties,
emulatorObserved: qemu === undefined ? null : qemu === "1",
};
};
/** One installed package exactly as `pm list packages -f` reports it. */
export interface AdbPackageEntry {
readonly package_name: string;
readonly base_apk_device_path: string;
}
const PACKAGE_LINE = /^package:(.+)=([^\s=]+)$/u;
/** Parse `pm list packages -f` lines, retaining unrecognized lines. */
export const parsePackageListOutput = (
stdout: string,
): {
packages: readonly AdbPackageEntry[];
unparsedLines: readonly string[];
} => {
const packages: AdbPackageEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line === "") continue;
const match = PACKAGE_LINE.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
packages.push({
package_name: match[2]!,
base_apk_device_path: match[1]!,
});
}
return { packages, unparsedLines };
};
/** One APK of an installed package exactly as `pm path` reports it. */
export interface AdbPackagePathEntry {
readonly device_path: string;
readonly file_name: string;
readonly role: "base" | "split" | "unknown";
}
const fileNameOf = (devicePath: string): string => {
const parts = devicePath.split(/\//u);
return parts[parts.length - 1] ?? devicePath;
};
/**
* Parse `pm path PACKAGE` lines. The base/split role is derived from the file
* name (`base.apk`, `split_*.apk`), which is the only signal the output
* carries; any other name reports an unknown role.
*/
export const parsePmPathOutput = (
stdout: string,
): {
paths: readonly AdbPackagePathEntry[];
unparsedLines: readonly string[];
} => {
const paths: AdbPackagePathEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line === "") continue;
const match = /^package:(\S+)$/u.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
const devicePath = match[1]!;
const fileName = fileNameOf(devicePath);
paths.push({
device_path: devicePath,
file_name: fileName,
role:
fileName === "base.apk"
? "base"
: fileName.startsWith("split_") && fileName.endsWith(".apk")
? "split"
: "unknown",
});
}
return { paths, unparsedLines };
};
+960
View File
@@ -0,0 +1,960 @@
import { createHash } from "node:crypto";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { AdbProvider } from "./AdbProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
const it = test.skipIf(process.platform === "win32");
const VERSION_BLOCK =
"printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' \"$0\"";
const writeAdbStub = async (
body: (root: string) => string,
): Promise<{ readonly binary: string; readonly root: string }> => {
const root = await mkdtemp(join("/tmp", "rea-adb-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const binary = join(root, "adb");
await writeFile(binary, `#!/bin/sh\n${body(root)}\n`, { mode: 0o755 });
await chmod(binary, 0o755);
return { binary, root };
};
const providerOver = (binary: string): AdbProvider =>
new AdbProvider({ environment: { REA_ADB_PATH: binary } });
const evidenceResult = async (
provider: AdbProvider,
request: AdbRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw new Error(result.error.message);
return result.value.result as Record<string, unknown>;
};
const refusedBy = async (
provider: AdbProvider,
request: AdbRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
it("inspects the adb client without contacting the server", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_client",
input: {},
});
expect(result.client).toMatchObject({
path_source: "environment",
version: "34.0.5-stub",
installed_path: binary,
});
expect(result.contacted_adb_server).toBe(false);
});
it("lists devices with inferred kinds and observed daemon startup", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "devices" ]; then
printf '* daemon not running; starting now at tcp:5037\\n* daemon started successfully\\n'
printf 'List of devices attached\\n'
printf 'emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\\n'
exit 0
fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_devices",
input: {},
});
expect(result.may_have_started_adb_server).toBe(true);
expect(result.devices).toEqual([
{
serial: "emulator-5554",
state: "device",
transport: "tcp",
product: "sdk_gphone64_x86_64",
model: "sdk_gphone64_x86_64",
device: "emu64xa",
transport_id: 1,
kind: "emulator",
kind_basis: "emulator_serial_prefix",
},
]);
});
it("inspects a device through the getprop whitelist", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ]; then
printf '[ro.build.version.sdk]: [34]\\n[ro.kernel.qemu]: [1]\\n[sys.private]: [x]\\n'
exit 0
fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_device",
input: { serial: "emulator-5554" },
});
expect(result.emulator_observed).toBe(true);
expect(result.properties).toEqual([
{ name: "ro.build.version.sdk", value: "34" },
{ name: "ro.kernel.qemu", value: "1" },
]);
});
it("passes the caller scope as an exact fixed argv vector", async () => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")}
if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then printf 'package:/data/app/x/base.apk=com.example\\n'; exit 0; fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_packages",
input: { serial: "emulator-5554", scope: "third_party" },
});
expect(result.coverage).toBe("complete");
expect(result.packages).toEqual([
{
package_name: "com.example",
base_apk_device_path: "/data/app/x/base.apk",
},
]);
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
expect(logged).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"pm",
"list",
"packages",
"-f",
"-3",
]);
});
const pullStub = (
paths: string,
pullBody: string,
): string => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then
printf '%s\\n' ${paths}
exit 0
fi
if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then
${pullBody}
exit 0
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
/** Stub dispatching one canned stdout per first shell word (and optional second). */
const shellStub = (
dispatch: Readonly<Record<string, string>>,
): ((root: string) => string) => {
const cases = Object.entries(dispatch)
.map(([key, output]) => {
const [first, second] = key.split(":");
const guard =
second === undefined
? `[ "$4" = "${first}" ]`
: `[ "$4" = "${first}" ] && [ "$5" = "${second}" ]`;
return ` if ${guard}; then printf '%b' '${output.replace(/'/gu, "'\\''")}'; exit 0; fi`;
})
.join("\n");
return () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then
${cases}
printf 'unexpected shell command: %s %s %s\\n' "$4" "$5" "$6" >&2
exit 1
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
};
it("pulls a complete split set with digests into a new package directory", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub(
"'package:/data/app/~~a==/com.example.b==/base.apk' 'package:/data/app/~~a==/com.example.b==/split_config.en.apk'",
` printf 'apk-bytes-for-testing' > "$5"
printf '1 file pulled, 0 skipped.\\n'`,
),
);
const output = join(root, "pulls");
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.example",
output_directory: output,
},
});
expect(result.coverage).toBe("complete");
expect(result.failures).toEqual([]);
const artifacts = result.artifacts as {
file_name: string;
local_path: string;
sha256: string;
role: string;
}[];
expect(
artifacts.map((artifact) => [artifact.file_name, artifact.role]),
).toEqual([
["base.apk", "base"],
["split_config.en.apk", "split"],
]);
const digest = createHash("sha256")
.update("apk-bytes-for-testing")
.digest("hex");
for (const artifact of artifacts) expect(artifact.sha256).toBe(digest);
const pulled = await readFile(
join(output, "com.example", "base.apk"),
"utf8",
);
expect(pulled).toBe("apk-bytes-for-testing");
});
it("keeps successful artifacts when one split pull fails", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub(
"'package:/data/app/y/base.apk' 'package:/data/app/y/split_dpi.apk'",
`case "$4" in
*/split_dpi.apk) printf 'error: closed\\n' >&2; exit 1;;
*) printf 'apk-bytes-for-testing' > "$5"; printf '1 file pulled.\\n';;
esac`,
),
);
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.partial",
output_directory: join(root, "pulls"),
},
});
expect(result.coverage).toBe("partial");
expect(result.artifacts).toHaveLength(1);
expect(result.failures).toEqual([
{
device_path: "/data/app/y/split_dpi.apk",
stage: "pull",
message: expect.stringContaining("closed") as unknown,
},
]);
});
it("refuses to reuse an existing package output directory", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub("'package:/data/app/z/base.apk'", ` printf 'x' > "$5"`),
);
const output = join(root, "existing");
await mkdir(join(output, "com.example"), { recursive: true });
const provider = providerOver(binary);
const result = await provider.execute({
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.example",
output_directory: output,
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
});
expect(result.error.message).toContain("already exists");
});
it("reports an uninstalled package from empty pm path output", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then exit 0; fi`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.absent",
output_directory: "/tmp/rea-adb-absent",
},
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("no APK paths");
});
it.each([
["not found", "error: device 'emulator-9' not found", "not found"],
["offline", "error: device offline", "offline"],
["unauthorized", "error: device unauthorized", "unauthorized"],
] as const)(
"reports the %s device state as a target refusal",
async (_, stderr, excerpt) => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ]; then printf '%s\\n' "${stderr.replace(/'/gu, "'\\''")}" >&2; exit 1; fi`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "inspect_adb_device",
input: { serial: "emulator-5554" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain(excerpt);
},
);
it("reports a missing selected binary as a capability gap with recovery", async () => {
const provider = new AdbProvider({
environment: { REA_ADB_PATH: "/nonexistent/adb" },
});
const availability = await provider.inspectAvailability();
expect(availability).toMatchObject({ status: "unavailable" });
if (availability.status !== "unavailable") return;
expect(availability.diagnostics.remediation).toContain("REA_ADB_PATH");
const result = await provider.execute({
operation: "list_adb_devices",
input: {},
});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({ code: "capability_unavailable" });
expect(projected.details).toMatchObject({
provider_id: "adb",
reason: expect.stringContaining("not found") as unknown,
});
});
it("cancels before spawning anything", async () => {
const { binary, root } = await writeAdbStub(
(root) => `: > ${join(root, "spawned")}
if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
);
const controller = new AbortController();
controller.abort();
const cancelled = await providerOver(binary).execute(
{ operation: "list_adb_devices", input: {} },
{ signal: controller.signal },
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok) return;
expect(projectAnalysisError(cancelled.error)).toMatchObject({
code: "cancelled",
});
expect(
await readFile(join(root, "spawned")).then(
() => true,
() => false,
),
).toBe(false);
});
it("reads a bounded logcat dump with the caller count and buffer", async () => {
const { binary } = await writeAdbStub(
shellStub({
logcat:
"08-10 13:00:00.000 I/Tag( 123): first\\n08-10 13:00:01.000 I/Tag( 123): second\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "read_adb_logcat",
input: { serial: "emulator-5554", count: 500, buffer: "main" },
});
expect(result.lines).toEqual([
"08-10 13:00:00.000 I/Tag( 123): first",
"08-10 13:00:01.000 I/Tag( 123): second",
]);
});
it("projects package details from dumpsys package output", async () => {
const { binary } = await writeAdbStub(
shellStub({
"dumpsys:package": `Packages:
Package [com.example] (abcdef):
userId=10234
versionName=1.2.3
versionCode=45 minSdk=24 targetSdk=34
firstInstallTime=2026-01-02 03:04:05
lastUpdateTime=2026-02-03 04:05:06
installerPackageName=com.android.vending
pkgFlags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ]
requested permissions:
android.permission.INTERNET
android.permission.CAMERA
`,
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_package",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(result).toMatchObject({
version_name: "1.2.3",
version_code: 45,
first_install_time: "2026-01-02 03:04:05",
last_update_time: "2026-02-03 04:05:06",
installer_package_name: "com.android.vending",
user_id: 10234,
requested_permissions_count: 2,
coverage: "complete",
});
});
it("reports an uninstalled package from dumpsys refusal output", async () => {
const { binary } = await writeAdbStub(
shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }),
);
const projected = await refusedBy(providerOver(binary), {
operation: "inspect_adb_package",
input: { serial: "emulator-5554", package: "com.absent" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
});
it("pulls one device file with a digest", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then printf 'file-bytes' > "$5"; exit 0; fi
exit 1`,
);
const output = join(root, "pulls");
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_file",
input: {
serial: "emulator-5554",
device_path: "/data/local/tmp/notes.txt",
output_directory: output,
},
});
const digest = createHash("sha256").update("file-bytes").digest("hex");
expect(result).toMatchObject({
device_path: "/data/local/tmp/notes.txt",
local_path: join(output, "notes.txt"),
bytes: 10,
sha256: digest,
});
});
it("pushes a file and verifies the device-side digest", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "8748b5d17d1ebcec7d46674fa6a8b92c0f95598a552f27c1c8a132eb58c04043" "$5"; exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "push-payload");
const digest = createHash("sha256").update("push-payload").digest("hex");
const pushed = await evidenceResult(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(pushed).toMatchObject({
bytes: 12,
sha256: digest,
device_sha256: digest,
overwritten: false,
device_digest_source: "device_sha256sum",
});
});
it("reports a digest mismatch after push as a changed artifact", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "$(printf 'a%.0s' 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64)" "$5"; exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "push-payload");
const projected = await refusedBy(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(projected).toMatchObject({ code: "artifact_changed" });
});
it("refuses a push onto an existing device path without overwrite", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "x");
const result = await providerOver(binary).execute({
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/exists.bin",
overwrite: false,
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
});
expect(result.error.message).toContain("overwrite");
});
it("reports a local push source that does not exist", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: "/nonexistent/payload.bin",
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("captures the screen as a digested PNG with its dimensions", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "exec-out" ]; then
printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'
exit 0
fi
exit 1`,
);
const output = join(root, "captures");
const result = await evidenceResult(providerOver(binary), {
operation: "capture_adb_screen",
input: { serial: "emulator-5554", output_directory: output },
});
expect(result).toMatchObject({ width: 4, height: 3 });
expect(
await readFile(join(output, "screen.png")).then(
(bytes) => bytes.subarray(1, 4).toString("latin1") === "PNG",
() => false,
),
).toBe(true);
});
it("lists processes, features, and services through fixed commands", async () => {
const { binary } = await writeAdbStub(
shellStub({
ps: "USER PID PPID VSZ RSS WCHAN ADDR S NAME\nu0_a1 1234 567 123456 7890 0 0 S com.example.app\n",
"pm:list":
"feature:android.hardware.camera\nfeature:android.hardware.nfc=1\n",
"service:list":
"Found 2 services:\n 1 package: [android.content.pm.IPackageManager]\n 2 activity: [android.app.IActivityManager]\n",
}),
);
const processes = await evidenceResult(providerOver(binary), {
operation: "list_adb_processes",
input: { serial: "emulator-5554" },
});
expect(processes.processes).toEqual([
{
user: "u0_a1",
pid: 1234,
ppid: 567,
rss: 7890,
state: "S",
name: "com.example.app",
},
]);
const features = await evidenceResult(providerOver(binary), {
operation: "list_adb_features",
input: { serial: "emulator-5554" },
});
expect(features.features).toEqual([
{ name: "android.hardware.camera", version: null },
{ name: "android.hardware.nfc", version: 1 },
]);
const services = await evidenceResult(providerOver(binary), {
operation: "list_adb_services",
input: { serial: "emulator-5554" },
});
expect(services.services).toEqual([
{ name: "package", interface: "android.content.pm.IPackageManager" },
{ name: "activity", interface: "android.app.IActivityManager" },
]);
});
it("lists a device directory with kinds and symlink targets", async () => {
const { binary } = await writeAdbStub(
shellStub({
ls: "total 4\n-rw-rw---- u0_a1 u0_a1 4096 2026-01-02 11:00 app.db\nlrwxrwxrwx root root 2026-01-01 10:00 lib -> /apex/com.android.art/lib\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_directory",
input: {
serial: "emulator-5554",
device_path: "/data/data/com.example.app",
},
});
expect(result.entries).toEqual([
{
name: "app.db",
kind: "file",
permissions: "-rw-rw----",
owner: "u0_a1",
group: "u0_a1",
bytes: 4096,
date: "2026-01-02 11:00",
link_target: null,
},
{
name: "lib",
kind: "symlink",
permissions: "lrwxrwxrwx",
owner: "root",
group: "root",
bytes: null,
date: "2026-01-01 10:00",
link_target: "/apex/com.android.art/lib",
},
]);
});
it("inspects the display and window focus", async () => {
const { binary } = await writeAdbStub(
shellStub({
"wm:size": "Physical size: 1080x2340\n",
"wm:density": "Physical density: 420\n",
"dumpsys:window":
" mCurrentFocus=Window{abcdef u0 com.example.app/com.example.app.MainActivity}\n",
}),
);
const display = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_display",
input: { serial: "emulator-5554" },
});
expect(display.physical_size).toEqual({ width: 1080, height: 2340 });
expect(display.physical_density).toBe(420);
const window = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(window.focused_window).toBe(
"Window{abcdef u0 com.example.app/com.example.app.MainActivity}",
);
});
it("reads one settings value and reports device-side nulls", async () => {
const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" }));
const enabled = await evidenceResult(providerOver(binary), {
operation: "read_adb_setting",
input: {
serial: "emulator-5554",
namespace: "secure",
key: "adb_enabled",
},
});
expect(enabled.value).toBe("1");
const { binary: nullBinary } = await writeAdbStub(
shellStub({ "settings:get": "null\n" }),
);
const absent = await evidenceResult(providerOver(nullBinary), {
operation: "read_adb_setting",
input: {
serial: "emulator-5554",
namespace: "global",
key: "absent_key",
},
});
expect(absent.value).toBeNull();
expect(absent.device_reported_null).toBe(true);
});
it("resolves packages by case-insensitive substring", async () => {
const { binary } = await writeAdbStub(
shellStub({
"pm:list":
"package:/data/app/x/base.apk=com.whatsapp\\npackage:/data/app/y/base.apk=com.whatsapp.w4b\\npackage:/data/app/z/base.apk=com.example\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "resolve_adb_packages",
input: { serial: "emulator-5554", query: "WhatsApp" },
});
expect(result.matches).toEqual([
{
package_name: "com.whatsapp",
base_apk_device_path: "/data/app/x/base.apk",
},
{
package_name: "com.whatsapp.w4b",
base_apk_device_path: "/data/app/y/base.apk",
},
]);
expect(result.exact_match).toBe(false);
expect(result.coverage).toBe("complete");
});
it("installs a local APK and preserves the device's failure verdict", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Success\\n'; exit 0; fi
exit 1`,
);
const apk = join(root, "app.apk");
await writeFile(apk, "apk");
const digest = createHash("sha256").update("apk").digest("hex");
const installed = await evidenceResult(providerOver(binary), {
operation: "install_adb_package",
input: { serial: "emulator-5554", apk_path: apk, replace: false },
});
expect(installed).toMatchObject({
bytes: 3,
sha256: digest,
replaced: false,
});
const { binary: failing } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Failure [INSTALL_FAILED_ALREADY_EXISTS]\\n'; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(failing), {
operation: "install_adb_package",
input: {
serial: "emulator-5554",
apk_path: apk,
replace: false,
},
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("INSTALL_FAILED_ALREADY_EXISTS");
});
it("uninstalls a package and reports the device's refusal for absent ones", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Success\\n'; exit 0; fi
exit 1`,
);
const removed = await evidenceResult(providerOver(binary), {
operation: "uninstall_adb_package",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(removed.package_name).toBe("com.example");
const { binary: refusing } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(refusing), {
operation: "uninstall_adb_package",
input: { serial: "emulator-5554", package: "com.absent" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("not installed for 0");
});
it("starts an app through resolved launcher component and force-stops it", async () => {
const { binary } = await writeAdbStub(
shellStub({
"cmd:package":
"priority=0 preferredOrder=0 match=0x00108000 specificIndex=-1 isDefault=true\ncom.example/.Main",
"am:start":
"Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example/.Main }\n",
"am:force-stop": "",
}),
);
const provider = providerOver(binary);
const started = await evidenceResult(provider, {
operation: "start_adb_app",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(started).toMatchObject({
package_name: "com.example",
component: "com.example/.Main",
started_activity: expect.stringContaining(
"cmp=com.example/.Main",
) as unknown,
});
const stopped = await evidenceResult(provider, {
operation: "stop_adb_app",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(stopped.package_name).toBe("com.example");
});
it("reports an app without a launcher activity as unsupported", async () => {
const { binary } = await writeAdbStub(
shellStub({ "cmd:package": "No activity found\n" }),
);
const projected = await refusedBy(providerOver(binary), {
operation: "start_adb_app",
input: { serial: "emulator-5554", package: "com.library.only" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("No launcher activity");
});
it("starts an intent with typed extras as a fixed argv vector", async () => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")}
${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat=https://example.com/ }\n" })(root)}`,
);
const started = await evidenceResult(providerOver(binary), {
operation: "start_adb_activity",
input: {
serial: "emulator-5554",
action: "android.intent.action.VIEW",
data_uri: "https://example.com/",
extras: [
{ key: "level", type: "int", value: "3" },
{ key: "label", type: "string", value: "probe" },
],
},
});
expect(started.action).toBe("android.intent.action.VIEW");
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
expect(logged).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
"https://example.com/",
"--ei",
"level",
"3",
"--es",
"label",
"probe",
]);
});
it("rejects mistyped intent extras before touching the device", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
: > should-not-exist`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "start_adb_activity",
input: {
serial: "emulator-5554",
action: "com.example.ACTION",
extras: [{ key: "flag", type: "boolean", value: "yes" }],
},
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("collects a device bugreport archive with its digest", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then
printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}"
printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n'
exit 0
fi
exit 1`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "collect_adb_bugreport",
input: { serial: "emulator-5554", output_directory: root },
});
const digest = createHash("sha256").update("bugreport-bytes").digest("hex");
expect(result).toMatchObject({
local_path: join(root, "bugreport test.zip"),
bytes: 15,
sha256: digest,
adb_reported_path: join(root, "bugreport test.zip"),
});
});
it("quotes caller text before adb joins the remote shell command", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
shift 3
# Model adb's remote command joining, then execute the resulting shell string.
printf '#!/bin/sh\nprintf "%%s" "$@" > "${join(root, "observed")}"\n' > "${join(root, "ls")}"
chmod +x "${join(root, "ls")}"
PATH="${root}:$PATH" sh -c "$*"
`,
);
const value = `/sdcard/a 'quoted'; touch ${join(root, "injected")}`;
await providerOver(binary).execute({
operation: "list_adb_directory",
input: { serial: "device", device_path: value },
});
expect(await readFile(join(root, "observed"), "utf8")).toContain(value);
await expect(readFile(join(root, "injected"))).rejects.toThrow();
});
it("preserves a screen output created while capture is running", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
printf 'existing evidence' > "${join(root, "captures", "screen.png")}"
printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'
`,
);
const result = await providerOver(binary).execute({
operation: "capture_adb_screen",
input: { serial: "device", output_directory: join(root, "captures") },
});
expect(result.ok).toBe(false);
expect(await readFile(join(root, "captures", "screen.png"), "utf8")).toBe(
"existing evidence",
);
});
it("reports cancellation arriving during the version probe", async () => {
const { binary } = await writeAdbStub(() => "exec sleep 30");
const controller = new AbortController();
const pending = providerOver(binary).execute(
{ operation: "inspect_adb_client", input: {} },
{ signal: controller.signal },
);
const timer = setTimeout(() => controller.abort(), 50);
try {
const result = await pending;
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error).code).toBe("cancelled");
} finally {
clearTimeout(timer);
}
});
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
/**
* Parsers for fixed observation commands REA runs through `adb shell`.
*
* Formats follow toybox output on Android 8+ (verified against an Android 14
* emulator). Unparseable lines are retained rather than dropped so callers
* can see the format drift instead of silently losing rows.
*/
export interface AdbProcessEntry {
readonly user: string;
readonly pid: number;
readonly ppid: number | null;
readonly rss: number | null;
readonly state: string | null;
readonly name: string;
}
const PROCESS_LINE =
/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/u;
/** Parse `ps -A` output; the header line is preserved verbatim as `columns`. */
export const parseProcessListOutput = (
stdout: string,
): {
columns: string | null;
processes: readonly AdbProcessEntry[];
unparsedLines: readonly string[];
} => {
const processes: AdbProcessEntry[] = [];
const unparsedLines: string[] = [];
let columns: string | null = null;
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line === "") continue;
if (/^USER\s+PID\s+PPID\b/u.test(line)) {
columns = line.trim();
continue;
}
const match = PROCESS_LINE.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
processes.push({
user: match[1]!,
pid: Number(match[2]!),
ppid: Number.isSafeInteger(Number(match[3]!)) ? Number(match[3]!) : null,
rss: Number.isSafeInteger(Number(match[5]!)) ? Number(match[5]!) : null,
state: match[8]!,
name: match[9]!.trim(),
});
}
return { columns, processes, unparsedLines };
};
export interface AdbDirectoryEntry {
readonly name: string;
readonly kind: "directory" | "file" | "symlink" | "other";
readonly permissions: string | null;
readonly owner: string | null;
readonly group: string | null;
readonly bytes: number | null;
readonly date: string | null;
readonly link_target: string | null;
}
const LS_LINE =
/^([bcdlps-][rwxstST-]{9})\s+(?:(\d+)\s+)?(\S+)\s+(\S+)\s+(?:(\d+)\s+)?([\d-]{10})\s+([\d:]{4,8})\s+(.+)$/u;
/** Parse `ls -la` output, including `total` headers and symlink targets. */
export const parseLsOutput = (
stdout: string,
): {
entries: readonly AdbDirectoryEntry[];
unparsedLines: readonly string[];
} => {
const entries: AdbDirectoryEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line === "" || line.startsWith("total ")) continue;
const match = LS_LINE.exec(line);
if (match === null) {
if (line.trim() !== "") unparsedLines.push(line);
continue;
}
const permissions = match[1]!;
let name = match[8]!.trim();
let linkTarget: string | null = null;
const linkArrow = name.indexOf(" -> ");
if (linkArrow !== -1 && permissions.startsWith("l")) {
linkTarget = name.slice(linkArrow + 4);
name = name.slice(0, linkArrow);
}
entries.push({
name,
kind: permissions.startsWith("d")
? "directory"
: permissions.startsWith("l")
? "symlink"
: permissions.startsWith("-")
? "file"
: "other",
permissions,
owner: match[3] ?? null,
group: match[4] ?? null,
bytes: match[5] === undefined ? null : Number(match[5]),
date: `${match[6] ?? ""} ${match[7] ?? ""}`.trim() || null,
link_target: linkTarget,
});
}
return { entries, unparsedLines };
};
export interface AdbFeatureEntry {
readonly name: string;
readonly version: number | null;
}
const FEATURE_LINE = /^feature:([^=\s]+)(?:=(0x[0-9a-fA-F]+|\d+))?$/u;
const parseFeatureVersion = (text: string): number =>
text.startsWith("0x") ? Number.parseInt(text.slice(2), 16) : Number(text);
/** Parse `pm list features` output, including hex GL versions. */
export const parseFeatureListOutput = (
stdout: string,
): {
features: readonly AdbFeatureEntry[];
unparsedLines: readonly string[];
} => {
const features: AdbFeatureEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line === "") continue;
const match = FEATURE_LINE.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
features.push({
name: match[1]!,
version: match[2] === undefined ? null : parseFeatureVersion(match[2]!),
});
}
return { features, unparsedLines };
};
export interface AdbServiceEntry {
readonly name: string;
readonly interface: string | null;
}
const SERVICE_LINE = /^\s*(?:\d+\s+)?([A-Za-z0-9._$/-]+):\s*\[([^\]]*)\]\s*$/u;
/** Parse `service list` output, tolerating numbered and plain formats. */
export const parseServiceListOutput = (
stdout: string,
): {
services: readonly AdbServiceEntry[];
unparsedLines: readonly string[];
} => {
const services: AdbServiceEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim()))
continue;
const match = SERVICE_LINE.exec(line);
if (match === null) {
if (line.trim() !== "") unparsedLines.push(line.trim());
continue;
}
services.push({
name: match[1]!,
interface: match[2] === "" ? null : match[2]!,
});
}
return { services, unparsedLines };
};
const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u;
const SIZE_OVERRIDE = /^Override size:\s+(\d+)x(\d+)$/u;
const DENSITY_PHYSICAL = /^Physical density:\s+(\d+)$/u;
const DENSITY_OVERRIDE = /^Override density:\s+(\d+)$/u;
/** Parse `wm size` output into physical and optional override sizes. */
export const parseWmSizeOutput = (
stdout: string,
): {
physical: { readonly width: number; readonly height: number } | null;
override: { readonly width: number; readonly height: number } | null;
} => {
let physical = null;
let override = null;
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
const physicalMatch = SIZE_PAIR.exec(trimmed);
if (physicalMatch !== null)
physical = {
width: Number(physicalMatch[1]!),
height: Number(physicalMatch[2]!),
};
const overrideMatch = SIZE_OVERRIDE.exec(trimmed);
if (overrideMatch !== null)
override = {
width: Number(overrideMatch[1]!),
height: Number(overrideMatch[2]!),
};
}
return { physical, override };
};
/** Parse `wm density` output. Older builds print a bare number. */
export const parseWmDensityOutput = (
stdout: string,
): {
physical: number | null;
override: number | null;
} => {
let physical = null;
let override = null;
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
const physicalMatch = DENSITY_PHYSICAL.exec(trimmed);
if (physicalMatch !== null) physical = Number(physicalMatch[1]!);
const overrideMatch = DENSITY_OVERRIDE.exec(trimmed);
if (overrideMatch !== null) override = Number(overrideMatch[1]!);
}
return { physical, override };
};
/** Parse the focus declarations from `dumpsys window windows`. */
export const parseWindowFocusOutput = (
stdout: string,
): {
focusedWindow: string | null;
focusedApp: string | null;
} => {
let focusedWindow = null;
let focusedApp = null;
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
if (focusedWindow === null && trimmed.startsWith("mCurrentFocus="))
focusedWindow = trimmed.slice("mCurrentFocus=".length).trim();
if (focusedApp === null && trimmed.startsWith("mFocusedApp="))
focusedApp = trimmed.slice("mFocusedApp=".length).trim();
}
return { focusedWindow, focusedApp };
};
export interface AdbPackageDetails {
readonly versionName: string | null;
readonly versionCode: number | null;
readonly firstInstallTime: string | null;
readonly lastUpdateTime: string | null;
readonly installerPackageName: string | null;
readonly userId: number | null;
readonly pkgFlags: string | null;
readonly requestedPermissions: number | null;
}
const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u;
/**
* Project `dumpsys package PACKAGE` output. Only the listed fields are
* extracted; the input is version-dependent and everything else is ignored
* by design rather than guessed.
*/
export const parseDumpsysPackageOutput = (
stdout: string,
): AdbPackageDetails => {
const details: {
versionName: string | null;
versionCode: number | null;
firstInstallTime: string | null;
lastUpdateTime: string | null;
installerPackageName: string | null;
userId: number | null;
pkgFlags: string | null;
} = {
versionName: null,
versionCode: null,
firstInstallTime: null,
lastUpdateTime: null,
installerPackageName: null,
userId: null,
pkgFlags: null,
};
let inPermissions = false;
let permissions = 0;
for (const line of stdout.split(/\r?\n/u)) {
if (line.trim().startsWith("requested permissions:")) {
inPermissions = true;
continue;
}
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) {
permissions += 1;
continue;
}
inPermissions = false;
const match = DETAIL_LINE.exec(line);
if (match === null) continue;
const key = match[1]!;
const value = match[2]!.trim();
if (key === "versionName" && details.versionName === null)
details.versionName = value === "" ? null : value;
else if (key === "versionCode" && details.versionCode === null) {
const parsed = /^(\d+)/u.exec(value);
if (parsed !== null) details.versionCode = Number(parsed[1]!);
} else if (key === "firstInstallTime" && details.firstInstallTime === null)
details.firstInstallTime = value === "" ? null : value;
else if (key === "lastUpdateTime" && details.lastUpdateTime === null)
details.lastUpdateTime = value === "" ? null : value;
else if (
key === "installerPackageName" &&
details.installerPackageName === null
)
details.installerPackageName = value === "" ? null : value;
else if (key === "userId" && details.userId === null) {
const parsed = /^(\d+)/u.exec(value);
if (parsed !== null) details.userId = Number(parsed[1]!);
} else if (key === "pkgFlags" && details.pkgFlags === null)
details.pkgFlags = value === "" ? null : value;
}
return { ...details, requestedPermissions: permissions };
};
/**
* Parse `settings get` output. Android prints the literal string `null` for
* absent keys; that is reported as a device-side null observation.
*/
export const parseSettingsGetOutput = (
stdout: string,
): {
value: string | null;
deviceReportedNull: boolean;
} => {
const trimmed = stdout.replace(/\r?\n$/u, "");
if (trimmed === "null") return { value: null, deviceReportedNull: true };
return { value: trimmed, deviceReportedNull: false };
};
/** Parse the host-side completion line from `adb bugreport`. */
export const parseBugreportCopiedPath = (stdout: string): string | null => {
const match = /^Bug report copied to (.+)\r?$/mu.exec(stdout.trim());
return match === null ? null : match[1]!;
};
/**
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
* with a zero exit code, so the text decides, not the exit status.
*/
export const parsePackageChangeOutput = (
stdout: string,
): {
readonly status: "success" | "failure";
readonly reason: string | null;
} => {
const text = stdout.trim();
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
const failure = /Failure \[([^\]]+)\]/u.exec(text);
return {
status: "failure",
reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
};
};
/**
* Parse `cmd package resolve-activity --brief` output: the component is the
* last non-empty line.
*/
export const parseResolveActivityOutput = (stdout: string): string | null => {
const lines = stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line !== "" && !/^No activity found$/iu.test(line));
return lines.length === 0 ? null : lines[lines.length - 1]!;
};
/**
* Parse `am start` output. Success prints `Starting: Intent { ... }`;
* refusals print lines starting with `Error:` or `Warning:` and activity
* classes that do not exist name the component.
*/
export const parseAmStartOutput = (
stdout: string,
): {
readonly status: "started" | "refused";
readonly startedActivity: string | null;
readonly reason: string | null;
} => {
const text = stdout.trim();
const starting = /^Starting:\s+Intent\s*\{(.*)\}\s*$/mu.exec(text);
if (starting !== null)
return {
status: "started",
startedActivity: starting[1]!.trim(),
reason: null,
};
const error = /^(?:Error|Warning|Exception)(?::|\b)(.*)$/mu.exec(text);
return {
status: "refused",
startedActivity: null,
reason:
error?.[1]?.trim() ??
(text === "" ? "am start returned no activity" : text.slice(0, 512)),
};
};
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
/** Read image dimensions from a PNG header, or null for other payloads. */
export const parsePngDimensions = (
bytes: Uint8Array,
): {
width: number | null;
height: number | null;
} => {
if (
bytes.length < 24 ||
!Buffer.from(bytes.subarray(0, 8)).equals(PNG_SIGNATURE)
)
return { width: null, height: null };
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return {
width: view.getUint32(16),
height: view.getUint32(20),
};
};
+129 -21
View File
@@ -1,40 +1,43 @@
import { constants, createReadStream } from "node:fs";
import { chmod, copyFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import type { ReadStream, Stats, WriteStream } from "node:fs";
import { chmod, open, rm, stat, type FileHandle } from "node:fs/promises";
import { join } from "node:path";
import { Transform, type TransformCallback } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
AnalysisInputError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import {
openRegularFile,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
/** Stream a local file digest without retaining the APK or JAR in memory. */
export const hashAndroidFile = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
/** Bind decompiler input to the admitted artifact, even if the original changes. */
export const snapshotAndroidTarget = async (
path: string,
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<string> => {
const snapshot = join(root, "target.apk");
await copyFile(path, snapshot, constants.COPYFILE_EXCL);
await chmod(snapshot, 0o400);
if ((await hashAndroidFile(snapshot)) !== sha256)
throw new AnalysisInputError(operation, undefined, [
try {
await copyAndroidSnapshot(path, snapshot, sha256, signal);
signal?.throwIfAborted();
await chmod(snapshot, 0o400);
return snapshot;
} catch (cause: unknown) {
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
throw new AnalysisInputError(operation, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message:
"APK bytes changed after admission; retry against a stable file.",
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
},
]);
return snapshot;
}
};
/** Fingerprint the actual immutable engine bytes executed for this observation. */
@@ -43,15 +46,120 @@ export const snapshotAndroidEngine = async (
sha256: string,
root: string,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<{ path: string; sha256: string }> => {
const path = join(root, "engine.jar");
await copyFile(source, path, constants.COPYFILE_EXCL);
await chmod(path, 0o400);
if ((await hashAndroidFile(path)) !== sha256)
try {
const observed = await copyAndroidSnapshot(source, path, sha256, signal);
signal?.throwIfAborted();
await chmod(path, 0o400);
return { path, sha256: observed };
} catch (cause: unknown) {
if (!(cause instanceof AndroidSnapshotChangedError)) throw cause;
throw new AnalysisCapabilityUnavailableError(
"jadx",
operation,
"REA_JADX_MCP_JAR bytes changed during admission; retry with a stable engine file.",
`REA_JADX_MCP_JAR bytes changed during admission at ${source}; retry with a stable engine file.`,
{ cause },
);
return { path, sha256 };
}
};
const copyAndroidSnapshot = async (
sourcePath: string,
snapshotPath: string,
expectedSha256: string,
signal?: AbortSignal,
): Promise<string> => {
signal?.throwIfAborted();
const source = await openRegularFile(sourcePath, {
symlinks: "follow",
signal,
});
let destination: FileHandle | undefined;
let inputStream: ReadStream | undefined;
let outputStream: WriteStream | undefined;
let removeSnapshot = false;
let copiedBytes = 0;
const hash = createHash("sha256");
try {
const initial = await source.stat();
signal?.throwIfAborted();
const digest = new Transform({
transform(
chunk: Buffer,
_encoding: BufferEncoding,
callback: TransformCallback,
) {
copiedBytes += chunk.length;
if (copiedBytes > initial.size) {
callback(
new AndroidSnapshotChangedError(
`Android input grew while being copied: ${sourcePath}`,
),
);
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
try {
destination = await open(snapshotPath, "wx", 0o600);
signal?.throwIfAborted();
inputStream = source.createReadStream({
start: 0,
end: initial.size,
autoClose: false,
});
outputStream = destination.createWriteStream({ autoClose: false });
await pipeline(inputStream, digest, outputStream, { signal });
signal?.throwIfAborted();
await verifySourceState(source, sourcePath, initial, copiedBytes);
const observedSha256 = hash.digest("hex");
if (observedSha256 !== expectedSha256)
throw new AndroidSnapshotChangedError(
`Android snapshot digest mismatch for ${sourcePath}`,
);
return observedSha256;
} catch (cause: unknown) {
removeSnapshot = destination !== undefined;
throw cause;
}
} finally {
// Non-auto-closing streams retain a reference to their FileHandle even
// after pipeline completion. Release those references before awaiting
// descriptor closure, after the admitted source state has been verified.
inputStream?.destroy();
outputStream?.destroy();
try {
await Promise.all([source.close(), destination?.close()]);
} finally {
if (removeSnapshot)
await rm(snapshotPath, { force: true }).catch(() => undefined);
}
}
};
const verifySourceState = async (
handle: Awaited<ReturnType<typeof openRegularFile>>,
path: string,
initial: Stats,
observedBytes: number,
): Promise<void> => {
const [opened, currentPath] = await Promise.all([handle.stat(), stat(path)]);
if (
observedBytes !== initial.size ||
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath)
)
throw new AndroidSnapshotChangedError(
`Android input changed while being read: ${path}`,
);
};
class AndroidSnapshotChangedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
+64 -29
View File
@@ -52,7 +52,11 @@ export const inspectJadxAvailability = async (
signal?: AbortSignal,
): Promise<ProviderAvailability> => {
try {
const configuration = await readJadxConfiguration(environment, signal);
const configuration = await readJadxConfigurationInputs(
environment,
signal,
);
await validateJadxConfigurationContents(configuration, environment, signal);
return {
status: "available",
code: null,
@@ -77,52 +81,83 @@ export const inspectJadxAvailability = async (
}
};
/** Resolve caller-selected tools and preserve the same prerequisite diagnostics. */
export const resolveJadxConfiguration = async (
/** Resolve caller-selected paths and JVM options without probing their contents. */
export const resolveJadxConfigurationInputs = async (
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
try {
return await readJadxConfiguration(environment, signal);
return await readJadxConfigurationInputs(environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
const output = cause.diagnostics;
throw new AnalysisCapabilityUnavailableError(
"jadx",
operation,
cause.message,
{
cause,
userMessage: cause.message,
...(typeof output.stdout === "string" &&
typeof output.stderr === "string"
? {
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.output_truncated === true,
},
}
: {}),
},
);
throw projectConfigurationFailure(operation, cause);
}
};
const readJadxConfiguration = async (
/** Probe Java and archive prerequisites before starting a new engine session. */
export const validateJadxConfiguration = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
operation: AndroidOperation,
signal?: AbortSignal,
): Promise<void> => {
try {
await validateJadxConfigurationContents(configuration, environment, signal);
} catch (cause) {
if (signal?.aborted === true) throw cause;
if (!(cause instanceof JadxConfigurationFailure)) throw cause;
throw projectConfigurationFailure(operation, cause);
}
};
const readJadxConfigurationInputs = async (
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<JadxConfiguration> => {
signal?.throwIfAborted();
requireSupportedHost();
const jar = await resolveJar(environment);
const java = await resolveJava(environment, signal);
await requireJavaHomeExecutable(environment.JAVA_HOME, java);
const jvmArguments = resolveJvmArguments(environment);
await inspectJavaRuntime(java, environment, signal);
await requireEngineArchive(jar, signal);
return { jar, java, jvmArguments };
signal?.throwIfAborted();
return { jar, java, jvmArguments: resolveJvmArguments(environment) };
};
const validateJadxConfigurationContents = async (
configuration: JadxConfiguration,
environment: Readonly<Record<string, string | undefined>>,
signal?: AbortSignal,
): Promise<void> => {
signal?.throwIfAborted();
await inspectJavaRuntime(configuration.java, environment, signal);
await requireEngineArchive(configuration.jar, signal);
};
const projectConfigurationFailure = (
operation: AndroidOperation,
cause: JadxConfigurationFailure,
): AnalysisCapabilityUnavailableError => {
const output = cause.diagnostics;
return new AnalysisCapabilityUnavailableError(
"jadx",
operation,
cause.message,
{
cause,
userMessage: cause.message,
...(typeof output.stdout === "string" && typeof output.stderr === "string"
? {
capturedOutput: {
stdout: output.stdout,
stderr: output.stderr,
truncated: output.output_truncated === true,
},
}
: {}),
},
);
};
const requireEngineArchive = async (
+98 -28
View File
@@ -21,12 +21,14 @@ import { err, ok, type Result } from "../domain/result.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import {
snapshotAndroidEngine,
hashAndroidFile,
snapshotAndroidTarget,
} from "./AndroidTargetSnapshot.js";
import { hashStableFile } from "../filesystem/StableFileHash.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import {
inspectJadxAvailability,
resolveJadxConfiguration,
resolveJadxConfigurationInputs,
validateJadxConfiguration,
} from "./JadxConfiguration.js";
import type { JadxLauncher } from "./JadxMcpTransport.js";
@@ -104,6 +106,50 @@ const executionError = (context: {
});
};
const androidTargetChanged = (
operation: string,
path: string,
cause?: unknown,
): AnalysisInputError =>
new AnalysisInputError(
operation,
cause === undefined ? undefined : { cause },
[
{
path: ["path"],
reason: "invalid_value",
message: `APK bytes changed after admission at ${path}; retry against a stable file.`,
},
],
);
const androidSourceChanged = (
operation: string,
source: string,
cause?: unknown,
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(
"jadx",
operation,
`Selected JADX input bytes changed during admission at ${source}; retry with a stable installation.`,
cause === undefined ? undefined : { cause },
);
const hashAndroidSource = async (
path: string,
operation: string,
signal: AbortSignal,
): Promise<string> => {
try {
return (await hashStableFile(path, signal)).sha256;
} catch (cause: unknown) {
if (signal.aborted) throw cause;
if (cause instanceof RegularFileChangedError)
throw androidSourceChanged(operation, path, cause);
throw cause;
}
};
interface PendingCleanup {
session: JadxSession | undefined;
root: PrivateRuntimeRoot | undefined;
@@ -147,17 +193,18 @@ const cleanup = async (
await root?.close();
resources.root = undefined;
} catch (cause) {
return err(
new ProviderCleanupError(
"jadx",
[root?.path ?? "unknown"],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause: previousError ?? cause },
),
const error = new ProviderCleanupError(
"jadx",
[root?.path ?? "unknown"],
{
...diagnostics,
reason: cause instanceof Error ? cause.message : String(cause),
},
{ cause: previousError ?? cause },
);
if (previousError?.partialObservation !== undefined)
error.retainPartialObservation(previousError.partialObservation);
return err(error);
}
return ok(undefined);
};
@@ -287,23 +334,33 @@ export class JadxProvider implements AndroidAnalysisPort {
request.operation,
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
);
const configuration = await resolveJadxConfiguration(
const configuration = await resolveJadxConfigurationInputs(
this.environment,
request.operation,
signal,
);
signal.throwIfAborted();
const jarHash = await hashAndroidFile(configuration.jar);
const bridgeHash = await hashAndroidFile(BRIDGE_SOURCE);
if ((await hashAndroidFile(target.path)) !== target.sha256)
throw new AnalysisInputError(request.operation, undefined, [
{
path: ["path"],
reason: "invalid_value",
message:
"APK bytes changed after admission; retry against a stable file.",
},
]);
const jarHash = await hashAndroidSource(
configuration.jar,
request.operation,
signal,
);
const bridgeHash = await hashAndroidSource(
BRIDGE_SOURCE,
request.operation,
signal,
);
let targetHash: string;
try {
targetHash = (await hashStableFile(target.path, signal)).sha256;
} catch (cause: unknown) {
if (signal.aborted) throw cause;
if (cause instanceof RegularFileChangedError)
throw androidTargetChanged(request.operation, target.path, cause);
throw cause;
}
if (targetHash !== target.sha256)
throw androidTargetChanged(request.operation, target.path);
const key = JSON.stringify({
path: target.path,
sha256: target.sha256,
@@ -326,27 +383,40 @@ export class JadxProvider implements AndroidAnalysisPort {
}
let retained = this.#retained;
if (retained === undefined) {
await validateJadxConfiguration(
configuration,
this.environment,
request.operation,
signal,
);
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
const engine = await snapshotAndroidEngine(
configuration.jar,
jarHash,
root.path,
request.operation,
signal,
);
const snapshot = await snapshotAndroidTarget(
target.path,
target.sha256,
root.path,
request.operation,
signal,
);
const bridge = join(root.path, "ReaJadxBridge.java");
await copyFile(BRIDGE_SOURCE, bridge);
await chmod(bridge, 0o400);
if ((await hashAndroidFile(bridge)) !== bridgeHash)
throw new AnalysisCapabilityUnavailableError(
"jadx",
const copiedBridgeHash = await hashAndroidSource(
bridge,
request.operation,
signal,
);
if (copiedBridgeHash !== bridgeHash)
throw androidSourceChanged(
request.operation,
"REA Android bridge bytes changed during admission; retry with a stable installation.",
BRIDGE_SOURCE,
undefined,
);
signal.throwIfAborted();
session = new JadxSession(
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
APKTOOL_COMMAND_ENV,
parseApktoolVersionOutput,
resolveApktoolCommand,
} from "./ApktoolConfiguration.js";
describe("resolveApktoolCommand", () => {
it("prefers an absolute REA_APKTOOL_COMMAND", () => {
expect(
resolveApktoolCommand({
[APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool",
}),
).toEqual({
command: "/usr/local/bin/apktool",
commandSource: "environment",
});
});
it("falls back to PATH resolution without the environment key", () => {
expect(resolveApktoolCommand({})).toEqual({
command: "apktool",
commandSource: "path",
});
});
it("rejects a relative environment path explicitly", () => {
expect(() =>
resolveApktoolCommand({ [APKTOOL_COMMAND_ENV]: "bin/apktool" }),
).toThrow(/must be an absolute path/u);
});
});
describe("parseApktoolVersionOutput", () => {
it("reads the version from real apktool output", () => {
expect(parseApktoolVersionOutput("2.7.0-dirty\n")).toEqual({
version: "2.7.0-dirty",
});
});
it("reports null when the launcher prints nothing usable", () => {
expect(parseApktoolVersionOutput(" \n")).toEqual({ version: null });
});
});
+142
View File
@@ -0,0 +1,142 @@
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Environment key selecting the caller's apktool launcher. */
export const APKTOOL_COMMAND_ENV = "REA_APKTOOL_COMMAND";
const VERSION_TIMEOUT_MS = 20_000;
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
/** How the apktool launcher was selected for this provider instance. */
export interface ApktoolCommandSelection {
readonly command: string;
readonly commandSource: "environment" | "path";
}
/**
* Resolve the caller-supplied apktool launcher. `REA_APKTOOL_COMMAND` must
* be absolute so the selection stays explicit; without it, apktool is
* resolved from `PATH` at run time. Both the upstream wrapper script and
* `java -jar` launcher scripts work: REA only runs `<command> --version`
* and `<command> d ...`.
*/
export const resolveApktoolCommand = (
environment: Readonly<Record<string, string | undefined>>,
): ApktoolCommandSelection => {
const configured = environment[APKTOOL_COMMAND_ENV];
if (configured === undefined || configured.trim() === "")
return { command: "apktool", commandSource: "path" };
if (!isAbsolute(configured))
throw new ApktoolConfigurationFailure(
"not_absolute",
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
{ configured_command: configured },
);
return { command: configured, commandSource: "environment" };
};
export type ApktoolConfigurationFailureCode =
| "not_absolute"
| "command_missing"
| "version_unresolved";
export class ApktoolConfigurationFailure extends Error {
constructor(
readonly code: ApktoolConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
super(message, options);
}
}
export interface ApktoolClientIdentity {
readonly version: string | null;
}
/** Read the version from `apktool --version` output (first non-empty line). */
export const parseApktoolVersionOutput = (
stdout: string,
): ApktoolClientIdentity => {
const line = stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
.find((entry) => entry !== "");
return { version: line === undefined ? null : line };
};
/** Probe the selected apktool launcher without decoding anything. */
export const inspectApktoolClient = async (
selection: ApktoolCommandSelection,
signal?: AbortSignal,
environment: Readonly<Record<string, string | undefined>> = process.env,
): Promise<ApktoolClientIdentity> => {
try {
const { stdout } = await execFileOutput(selection.command, ["--version"], {
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
env: { ...process.env, ...environment },
...(signal === undefined ? {} : { signal }),
});
return parseApktoolVersionOutput(stdout);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
throw new AnalysisCancelledError("inspect_apktool_client", { cause });
if (failure?.code === "ENOENT")
throw new ApktoolConfigurationFailure(
"command_missing",
`The selected apktool launcher was not found: ${selection.command}`,
{
selected_command: selection.command,
command_source: selection.commandSource,
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
},
{ cause },
);
throw new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher did not report a usable version",
{
selected_command: selection.command,
...(failure === undefined
? {}
: {
exit_code: failure.code,
stderr_excerpt: failure.stderr.slice(0, 512),
}),
},
{ cause },
);
}
};
/** Availability in the shape shared by every provider. */
const rejectionCodeOf = (
code: ApktoolConfigurationFailureCode,
): ProviderRejectionCode =>
code === "command_missing"
? "executable_missing"
: code === "not_absolute"
? "not_configured"
: "version_unresolved";
export const apktoolConfigurationAvailability = (
cause: ApktoolConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
parseApktoolYml,
parseDecodedStrings,
parseManifestPackage,
} from "./ApktoolDecodeOutput.js";
const REAL_YML = [
"!!brut.androlib.meta.MetaInfo",
"apkFileName: probe.apk",
"compressionType: false",
"doNotCompress:",
"- resources.arsc",
"isFrameworkApk: false",
"packageInfo:",
" forcedPackageId: '127'",
" renameManifestPackage: null",
"sdkInfo:",
" minSdkVersion: '24'",
" targetSdkVersion: '34'",
"sharedLibrary: false",
"sparseResources: false",
"unknownFiles: {}",
"usesFramework:",
" ids:",
" - 1",
" tag: null",
"version: 2.7.0-dirty",
"versionInfo:",
" versionCode: '7'",
" versionName: 1.2.3",
"",
].join("\n");
describe("parseApktoolYml", () => {
it("projects version and SDK facts from a real apktool.yml", () => {
expect(parseApktoolYml(REAL_YML)).toEqual({
versionName: "1.2.3",
versionCode: "7",
minSdkVersion: "24",
targetSdkVersion: "34",
});
});
it("accepts quoted version values some builds emit", () => {
expect(parseApktoolYml("versionName: '2.0.0'\nversionCode: 45\n")).toEqual({
versionName: "2.0.0",
versionCode: "45",
minSdkVersion: null,
targetSdkVersion: null,
});
});
it("reports null facts for unrelated YAML", () => {
expect(parseApktoolYml("version: 2.7.0\n")).toEqual({
versionName: null,
versionCode: null,
minSdkVersion: null,
targetSdkVersion: null,
});
});
});
describe("parseManifestPackage", () => {
it("reads the package attribute from a decoded manifest", () => {
const manifest =
'<?xml version="1.0" encoding="utf-8" standalone="no"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" android:compileSdkVersion="34" package="com.rea.apktool.probe" platformBuildVersionCode="34">';
expect(parseManifestPackage(manifest)).toBe("com.rea.apktool.probe");
});
it("reports null when no package attribute exists", () => {
expect(parseManifestPackage("<manifest>\n</manifest>\n")).toBeNull();
});
});
describe("parseDecodedStrings", () => {
it("projects entries from a real decoded strings.xml", () => {
const stringsXml = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="api_endpoint">https://api.example.com/v1</string>',
' <string name="app_name">ApktoolProbe</string>',
' <string name="debug_flag">true</string>',
"</resources>",
"",
].join("\n");
expect(parseDecodedStrings(stringsXml)).toEqual({
entries: [
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
{ name: "debug_flag", value: "true" },
],
unparsedCount: 0,
});
});
it("counts entries with nested markup instead of flattening them", () => {
const stringsXml =
'<resources><string name="styled">Hello <b>world</b></string><string name="plain">ok</string></resources>';
expect(parseDecodedStrings(stringsXml)).toEqual({
entries: [{ name: "plain", value: "ok" }],
unparsedCount: 1,
});
});
it("preserves escaped entities verbatim", () => {
const stringsXml =
'<resources><string name="escaped">a &amp; b &lt;c&gt;</string></resources>';
expect(parseDecodedStrings(stringsXml).entries[0]?.value).toBe(
"a &amp; b &lt;c&gt;",
);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Parsers for the decoded workspace `apktool d --no-src` produces.
*
* Apktool's apktool.yml is a small YAML document; the facts REA projects
* are flat enough that line-oriented extraction stays honest without a
* YAML dependency, and unrecognized content is ignored rather than guessed.
*/
export interface ApktoolMetadata {
readonly versionName: string | null;
readonly versionCode: string | null;
readonly minSdkVersion: string | null;
readonly targetSdkVersion: string | null;
}
/**
* Project version and SDK facts from apktool.yml. Values keep apktool's
* own spelling (version codes can print as `45` or `45.1`).
*/
export const parseApktoolYml = (yml: string): ApktoolMetadata => {
const metadata: {
versionName: string | null;
versionCode: string | null;
minSdkVersion: string | null;
targetSdkVersion: string | null;
} = {
versionName: null,
versionCode: null,
minSdkVersion: null,
targetSdkVersion: null,
};
for (const line of yml.split(/\r?\n/u)) {
const versionName = /^\s*versionName:\s*(?:'([^']*)'|(.*))$/u.exec(line);
if (versionName !== null && metadata.versionName === null)
metadata.versionName = (versionName[1] ?? versionName[2] ?? "").trim();
const versionCode = /^\s*versionCode:\s*(?:'([^']*)'|(.*))$/u.exec(line);
if (versionCode !== null && metadata.versionCode === null)
metadata.versionCode = (versionCode[1] ?? versionCode[2] ?? "").trim();
const minSdk = /minSdkVersion:\s*'?([0-9]+)'?/u.exec(line);
if (minSdk !== null && metadata.minSdkVersion === null)
metadata.minSdkVersion = minSdk[1]!;
const targetSdk = /targetSdkVersion:\s*'?([0-9]+)'?/u.exec(line);
if (targetSdk !== null && metadata.targetSdkVersion === null)
metadata.targetSdkVersion = targetSdk[1]!;
}
return metadata;
};
/** Read the package attribute from decoded AndroidManifest.xml text. */
export const parseManifestPackage = (manifest: string): string | null => {
const match = /<manifest[^>]*\spackage="([^"]+)"/u.exec(manifest);
return match === null ? null : match[1]!;
};
export interface DecodedStringEntry {
readonly name: string;
readonly value: string;
}
/**
* Project `<string name="...">value</string>` entries from one decoded
* strings.xml. Values are preserved exactly as apktool printed them —
* escaped entities stay escaped — and entries containing nested markup
* are counted as unparsed rather than flattened.
*/
export const parseDecodedStrings = (
stringsXml: string,
): {
entries: readonly DecodedStringEntry[];
unparsedCount: number;
} => {
const entries: DecodedStringEntry[] = [];
let unparsedCount = 0;
const pattern = /<string\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/string>/gu;
for (
let match = pattern.exec(stringsXml);
match !== null;
match = pattern.exec(stringsXml)
) {
const value = match[2]!;
if (value.includes("<")) {
unparsedCount += 1;
continue;
}
entries.push({ name: match[1]!, value: value.trim() });
}
return { entries, unparsedCount };
};
+370
View File
@@ -0,0 +1,370 @@
import { createHash } from "node:crypto";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { ApktoolProvider } from "./ApktoolProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { apktoolRequestSchema } from "../domain/apktool/apktoolResourceAnalysis.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
const it = test.skipIf(process.platform === "win32");
const VERSION_BLOCK = "printf '2.7.0-stub\\n'";
const REAL_YML = [
"!!brut.androlib.meta.MetaInfo",
"sdkInfo:",
" minSdkVersion: '24'",
" targetSdkVersion: '34'",
"versionInfo:",
" versionCode: '7'",
" versionName: 1.2.3",
"",
].join("\n");
const REAL_MANIFEST = [
'<?xml version="1.0" encoding="utf-8" standalone="no"?>',
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.rea.apktool.probe">',
' <application android:label="@string/app_name" android:debuggable="true"/>',
"</manifest>",
"",
].join("\n");
const REAL_STRINGS = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="api_endpoint">https://api.example.com/v1</string>',
' <string name="app_name">ApktoolProbe</string>',
"</resources>",
"",
].join("\n");
const DE_STRINGS = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="app_name">ApktoolProbe DE</string>',
"</resources>",
"",
].join("\n");
const writeApktoolStub = async (
body: (root: string) => string,
): Promise<{ readonly command: string; readonly root: string }> => {
const root = await mkdtemp(join("/tmp", "rea-apktool-stub-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const command = join(root, "apktool");
await writeFile(command, `#!/bin/sh\n${body(root)}\n`, { mode: 0o755 });
await chmod(command, 0o755);
return { command, root };
};
const providerOver = (command: string): ApktoolProvider =>
new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: command },
});
const evidenceResult = async (
provider: ApktoolProvider,
request: ApktoolRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw new Error(result.error.message);
return result.value.result as Record<string, unknown>;
};
const refusedBy = async (
provider: ApktoolProvider,
request: ApktoolRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
/** Stub writing a real-shaped decoded workspace into the -o directory. */
const decodeStub = (
options: {
readonly withStrings?: boolean;
readonly withGerman?: boolean;
readonly failDecode?: boolean;
} = {},
): ((root: string) => string) => {
const withStrings = options.withStrings ?? true;
const withGerman = options.withGerman ?? true;
return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "d" ]; then
${
options.failDecode === true
? `printf 'Exception in thread "main" brut.androlib.AndrolibException: Could not decode the APK\\n' >&2; exit 1;`
: `printf '%s\\n' '${REAL_YML.replace(/'/gu, "'\\''")}' > "$5/apktool.yml"
printf '%s\\n' '${REAL_MANIFEST.replace(/'/gu, "'\\''")}' > "$5/AndroidManifest.xml"
mkdir -p "$5/res/values"${withGerman ? ' "$5/res/values-de"' : ""}
${withStrings ? `printf '%s\\n' '${REAL_STRINGS.replace(/'/gu, "'\\''")}' > "$5/res/values/strings.xml"` : ""}
${withGerman ? `printf '%s\\n' '${DE_STRINGS.replace(/'/gu, "'\\''")}' > "$5/res/values-de/strings.xml"` : ""}
printf 'I: Using Apktool on stub\\n'
exit 0`
}
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
};
const apkFixture = async (
root: string,
): Promise<{
readonly path: string;
readonly bytes: number;
readonly sha256: string;
}> => {
const path = join(root, "probe.apk");
const bytes = "apk-bytes-for-apktool";
await writeFile(path, bytes);
return {
path,
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
};
};
it("inspects the apktool launcher", async () => {
const { command } = await writeApktoolStub(
() => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi`,
);
const result = await evidenceResult(providerOver(command), {
operation: "inspect_apktool_client",
input: {},
});
expect(result.client).toEqual({
command,
command_source: "environment",
apktool_version: "2.7.0-stub",
});
});
it("decodes resources with target identity, metadata, and strings", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.target).toEqual({
path: apk.path,
bytes: apk.bytes,
sha256: apk.sha256,
});
expect(result.metadata).toEqual({
version_name: "1.2.3",
version_code: "7",
min_sdk_version: "24",
target_sdk_version: "34",
package_name: "com.rea.apktool.probe",
});
expect(result.manifest).toContain('package="com.rea.apktool.probe"');
expect(result.strings).toEqual([
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
]);
expect(result.locales).toEqual(["de"]);
expect(result.locale).toBeNull();
expect(result.decoded_file_count).toBeGreaterThan(2);
expect(result.coverage).toBe("complete");
});
it("projects one locale's strings on request", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "de" },
});
expect(result.locale).toBe("de");
expect(result.strings).toEqual([
{ name: "app_name", value: "ApktoolProbe DE" },
]);
});
it("notes a missing locale table instead of failing", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "fr" },
});
expect(result.strings).toEqual([]);
});
it("notes a missing default strings table without strings", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ withStrings: false }),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.strings).toEqual([]);
});
it("reports apktool's own refusal for undecodable targets", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
);
const apk = await apkFixture(root);
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("Could not decode");
});
it("reports a missing launcher as a capability gap with recovery", async () => {
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: "/nonexistent/apktool" },
});
const availability = await provider.inspectAvailability();
expect(availability).toMatchObject({
status: "unavailable",
code: "executable_missing",
});
const projected = await refusedBy(provider, {
operation: "inspect_apktool_client",
input: {},
});
expect(projected).toMatchObject({ code: "capability_unavailable" });
expect(projected.details).toMatchObject({
provider_id: "apktool",
reason: expect.stringContaining("REA_APKTOOL_COMMAND") as unknown,
});
});
it("rejects a target that is not a readable file", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: join(root, "absent.apk"), include_strings: true },
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("cancels before spawning anything", async () => {
const { command, root } = await writeApktoolStub(
(root) => `: > ${join(root, "spawned")}
if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi`,
);
const controller = new AbortController();
controller.abort();
const result = await providerOver(command).execute(
{
operation: "decode_android_resources",
input: { path: "/targets/x.apk", include_strings: true },
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
expect(
await readFile(join(root, "spawned")).then(
() => true,
() => false,
),
).toBe(false);
});
it("uses an owned APK snapshot and framework cache and resolves regional locales", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
[ "$6" = "--frame-path" ] || exit 7
[ -d "$7" ] || exit 8
printf 'changed original' > "${join(root, "probe.apk")}"
cat "$8" > "${join(root, "snapshot-bytes")}"
mkdir -p "$5/res/values-pt-rBR"
printf '<resources><string name="greeting">Olá</string></resources>' > "$5/res/values-pt-rBR/strings.xml"`,
),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "pt-BR" },
});
expect(result.target).toMatchObject({
sha256: apk.sha256,
bytes: apk.bytes,
path: apk.path,
});
expect(await readFile(join(root, "snapshot-bytes"), "utf8")).toBe(
"apk-bytes-for-apktool",
);
expect(result.strings).toEqual([{ name: "greeting", value: "Olá" }]);
});
it("marks an oversized strings document partial without claiming it is missing", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
" printf 'I: Using Apktool on stub",
' dd if=/dev/zero of="$5/res/values/strings.xml" bs=1048576 count=9 2>/dev/null\n printf \'I: Using Apktool on stub',
),
);
const apk = await apkFixture(root);
const result = await providerOver(command).execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.result).toMatchObject({
coverage: "partial",
strings: [],
});
expect(result.value.limitations.join(" ")).toContain("exceeds");
expect(result.value.limitations.join(" ")).not.toContain("No default");
});
it("retains failed workspace cleanup and retries it on close", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
);
const apk = await apkFixture(root);
let attempts = 0;
let owned: string | undefined;
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: command },
removeWorkspace: async (path) => {
owned = path;
if (++attempts === 1) throw new Error("busy");
await rm(path, { recursive: true, force: true });
},
});
const result = await provider.execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cleanup?.resources).toContain(owned);
await provider.close();
expect(attempts).toBe(2);
});
it("accepts a BCP47 resource qualifier through the public request schema", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
" printf 'I: Using Apktool on stub",
' mkdir -p "$5/res/values-b+zh+Hans+CN"\n printf \'<resources><string name="greeting">你好</string></resources>\' > "$5/res/values-b+zh+Hans+CN/strings.xml"\n printf \'I: Using Apktool on stub',
),
);
const apk = await apkFixture(root);
const request = apktoolRequestSchema.parse({
operation: "decode_android_resources",
input: { path: apk.path, locale: "b+zh+Hans+CN" },
});
const result = await evidenceResult(providerOver(command), request);
expect(result.strings).toEqual([{ name: "greeting", value: "你好" }]);
});
+754
View File
@@ -0,0 +1,754 @@
import { createHash, randomUUID } from "node:crypto";
import { createWriteStream, constants as fsConstants } from "node:fs";
import {
access,
chmod,
mkdir,
mkdtemp,
opendir,
open,
rm,
stat,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Transform, type TransformCallback } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { FileHandle } from "node:fs/promises";
import type {
ExecutionOptions,
ProviderAvailability,
} from "../application/AnalysisProvider.js";
import {
createAnalysisExecution,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisProtocolError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { err, ok, type Result } from "../domain/result.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
openRegularFile,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
ApktoolConfigurationFailure,
apktoolConfigurationAvailability,
inspectApktoolClient,
resolveApktoolCommand,
type ApktoolCommandSelection,
type ApktoolClientIdentity,
} from "./ApktoolConfiguration.js";
import {
parseApktoolYml,
parseDecodedStrings,
parseManifestPackage,
} from "./ApktoolDecodeOutput.js";
/** Stable identity for the Apktool resource-decoding provider. */
export const APKTOOL_PROVIDER_IDENTITY = {
id: "apktool",
name: "Apktool",
version: null,
} as const;
const DECODE_TIMEOUT_MS = 600_000;
const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
const MANIFEST_MAX_BYTES = 512 * 1024;
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
const MAX_STRINGS = 2_000;
const MAX_DECODED_ENTRIES_VISITED = 100_000;
const STDERR_EXCERPT_BYTES = 512;
const throwIfCancelled = (
operation: string,
signal: AbortSignal | undefined,
): void => {
if (signal?.aborted === true) throw new AnalysisCancelledError(operation);
};
/** Read at most the byte budget plus one byte, before decoding UTF-8. */
const readBoundedText = async (
path: string,
budget: number,
operation: string,
signal: AbortSignal | undefined,
): Promise<{ text: string | null; truncated: boolean }> => {
const file = await open(path, "r").catch((cause: unknown) => {
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
throw cause;
});
if (file === null) return { text: null, truncated: false };
try {
throwIfCancelled(operation, signal);
const bytes = Buffer.alloc(budget + 1);
let length = 0;
while (length < bytes.length) {
throwIfCancelled(operation, signal);
const result = await file.read(
bytes,
length,
bytes.length - length,
null,
);
if (result.bytesRead === 0) break;
length += result.bytesRead;
}
throwIfCancelled(operation, signal);
const truncated = length > budget;
// A prefix may end within a UTF-8 character; omit those trailing bytes.
const decoder = new TextDecoder("utf-8");
return {
text: decoder.decode(bytes.subarray(0, Math.min(length, budget)), {
stream: truncated,
}),
truncated,
};
} finally {
await file.close();
}
};
const localeQualifier = (locale: string): string =>
locale.replace(/^([a-z]{2,3})-([A-Z]{2}|[0-9]{3})$/u, "$1-r$2");
interface DecodeLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
}
const runApktool = async (
operation: string,
command: string,
arguments_: readonly string[],
limits: DecodeLimits,
signal: AbortSignal | undefined,
environment: Readonly<Record<string, string | undefined>>,
onCleanupOwner: (owner: ProviderProcessSupervisor) => void,
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
try {
const output = await runOwnedCommand(
{
command,
arguments: arguments_,
runId: randomUUID(),
hostEnvironment: { ...process.env, ...environment },
},
{
timeoutMs: limits.timeoutMs,
diagnosticBytes: limits.maxOutputBytes,
},
signal === undefined ? {} : { signal },
);
return ok({ stdout: output.stdout.text, stderr: output.stderr.text });
} catch (cause) {
if (!(cause instanceof OwnedCommandFailure))
return err(
new AnalysisProtocolError(`apktool failed for ${operation}`, { cause }),
);
if (cause.cleanupOwner !== undefined) onCleanupOwner(cause.cleanupOwner);
const output = cause.snapshot;
const capturedOutput =
output === null
? undefined
: {
stdout: output.stdout.text,
stderr: output.stderr.text,
truncated: output.diagnosticTruncated === true,
stdout_bytes: output.stdout.bytes,
stderr_bytes: output.stderr.bytes,
exit_code: output.exitCode ?? null,
signal: output.signal ?? null,
};
const cleanup =
cause.cleanupFailure === null
? undefined
: {
reason: cause.cleanupFailure,
resources: cause.resources,
};
if (cause.reason === "cancelled")
return err(
new AnalysisCancelledError(operation, {
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
}),
);
if (cause.reason === "timeout")
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, {
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
}),
);
if (cause.reason === "output-limit")
return err(
new AnalysisResourceConstraintError(
operation,
"memory",
`apktool output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`,
{ max_output_bytes: limits.maxOutputBytes },
{
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
),
);
return err(
new AnalysisUnsupportedTargetError(
operation,
command,
output?.stderr.text.trim().slice(0, STDERR_EXCERPT_BYTES) ||
`apktool exited with code ${String(output?.exitCode ?? "unknown")} for ${operation}`,
{
cause,
...(cleanup === undefined ? {} : { cleanup }),
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
),
);
}
};
/** Count files while bounding streamed directory-entry traversal. */
const countDecodedFiles = async (
root: string,
signal: AbortSignal | undefined,
operation: string,
): Promise<{
count: number;
capped: boolean;
locales: readonly string[];
}> => {
let count = 0;
let capped = false;
let visited = 0;
const directories = [root];
const locales: string[] = [];
while (directories.length > 0 && !capped) {
throwIfCancelled(operation, signal);
const directory = directories.pop()!;
for await (const entry of await opendir(directory)) {
throwIfCancelled(operation, signal);
if (visited >= MAX_DECODED_ENTRIES_VISITED) {
capped = true;
break;
}
visited += 1;
const path = join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(path);
if (directory === join(root, "res")) {
const match = /^values-(.+)$/u.exec(entry.name);
if (
match !== null &&
(await access(join(path, "strings.xml")).then(
() => true,
() => false,
))
)
locales.push(match[1]!);
}
} else if (entry.isFile()) count += 1;
}
}
return { count, capped, locales: locales.sort() };
};
export interface ApktoolProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
readonly removeWorkspace?: (path: string) => Promise<void>;
}
/**
* Apktool resource decoding over a caller-selected launcher.
*
* Decoding happens in a provider-owned temporary workspace that is removed
* once the requested facts are projected; smali production is skipped
* (`--no-src`) because Java-source recovery belongs to the JADX family.
*/
export class ApktoolProvider {
private readonly environment: Readonly<Record<string, string | undefined>>;
private readonly workspaces = new Set<string>();
private readonly removeWorkspace: (path: string) => Promise<void>;
readonly #pendingCommandOwners = new Map<ProviderProcessSupervisor, string>();
readonly #shutdown = new AbortController();
readonly #active = new Set<Promise<void>>();
#closing = false;
#closePromise: Promise<void> | undefined;
constructor(options: ApktoolProviderOptions = {}) {
this.environment = snapshotEnvironment(options.environment ?? process.env);
this.removeWorkspace =
options.removeWorkspace ??
((path) => rm(path, { recursive: true, force: true }));
}
private selection(): ApktoolCommandSelection {
return resolveApktoolCommand(this.environment);
}
/** Probe the selected apktool launcher; decodes nothing. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability> {
if (this.#closing)
return Promise.reject(
new AnalysisCancelledError("inspect_apktool_client"),
);
const operationSignal =
signal === undefined
? this.#shutdown.signal
: AbortSignal.any([signal, this.#shutdown.signal]);
return this.#track(this.#inspectAvailability(operationSignal));
}
async #inspectAvailability(
signal: AbortSignal,
): Promise<ProviderAvailability> {
const selection = this.selection();
try {
await inspectApktoolClient(selection, signal, this.environment);
throwIfCancelled("inspect_apktool_client", signal);
return {
status: "available",
code: null,
reason: null,
diagnostics: {
selected_command: selection.command,
command_source: selection.commandSource,
},
};
} catch (cause) {
if (cause instanceof ApktoolConfigurationFailure)
return apktoolConfigurationAvailability(cause);
throw cause;
}
}
/** Retain failed cleanup ownership and retry it on close. */
async close(): Promise<void> {
this.#closing = true;
this.#shutdown.abort();
this.#closePromise ??= this.#drainAndClean().catch((cause: unknown) => {
this.#closePromise = undefined;
throw cause;
});
return this.#closePromise;
}
execute(
request: ApktoolRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (this.#closing)
return Promise.resolve(
err(new AnalysisCancelledError(request.operation)),
);
const signal =
options?.signal === undefined
? this.#shutdown.signal
: AbortSignal.any([options.signal, this.#shutdown.signal]);
return this.#track(this.#execute(request, signal));
}
async #execute(
request: ApktoolRequest,
signal: AbortSignal,
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(request.operation));
const selection = this.selection();
let identity: ApktoolClientIdentity;
try {
identity = await inspectApktoolClient(
selection,
signal,
this.environment,
);
} catch (cause) {
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof ApktoolConfigurationFailure)
return err(
new AnalysisCapabilityUnavailableError(
APKTOOL_PROVIDER_IDENTITY.id,
request.operation,
typeof cause.diagnostics.remediation === "string"
? `${cause.message} ${cause.diagnostics.remediation}`
: cause.message,
{ cause },
),
);
return err(
new AnalysisProtocolError(
`apktool version probe failed for ${request.operation}`,
{ cause },
),
);
}
if (signal.aborted)
return err(new AnalysisCancelledError(request.operation));
const client = {
command: selection.command,
command_source: selection.commandSource,
apktool_version: identity.version,
};
const provider = {
...APKTOOL_PROVIDER_IDENTITY,
version: identity.version,
};
if (request.operation === "inspect_apktool_client")
return ok(createAnalysisExecution({ client }, provider));
return this.decodeResources(
request.operation,
selection,
client,
provider,
request.input,
signal,
);
}
#track<T>(operation: Promise<T>): Promise<T> {
const settled = operation.then(
() => undefined,
() => undefined,
);
this.#active.add(settled);
void settled.then(() => this.#active.delete(settled));
return operation;
}
async #drainAndClean(): Promise<void> {
await Promise.all(this.#active);
const failures: unknown[] = [];
for (const [owner, root] of this.#pendingCommandOwners) {
try {
const stopped = await owner.stop();
if (stopped.status === "incomplete") throw new Error(stopped.reason);
this.#pendingCommandOwners.delete(owner);
} catch (cause) {
failures.push(
new Error(
`apktool process cleanup remains incomplete for ${root}: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
);
}
}
if (failures.length > 0)
throw new AggregateError(failures, "Apktool process cleanup failed");
for (const path of this.workspaces) {
try {
await this.removeWorkspace(path);
this.workspaces.delete(path);
} catch (cause) {
failures.push(cause);
}
}
if (failures.length > 0)
throw new AggregateError(failures, "Apktool workspace cleanup failed");
}
private async decodeResources(
operation: "decode_android_resources",
selection: ApktoolCommandSelection,
client: {
command: string;
command_source: string;
apktool_version: string | null;
},
provider: { id: string; name: string; version: string | null },
input: {
readonly path: string;
readonly include_strings: boolean;
readonly locale?: string | undefined;
},
signal: AbortSignal | undefined,
): Promise<Result<AnalysisExecution, AnalysisError>> {
const root = await mkdtemp(join(tmpdir(), "rea-apktool-"));
this.workspaces.add(root);
const workspace = join(root, "decoded");
const snapshot = join(root, "input.apk");
const framework = join(root, "framework");
let cleanupFailure: string | null = null;
// The workspace is removed before the result is returned, so an
// unremovable workspace is reported instead of silently abandoned.
const removeWorkspace = async (): Promise<void> => {
if ([...this.#pendingCommandOwners.values()].includes(root)) {
cleanupFailure = `The decode workspace is retained until its apktool process stops: ${root}`;
return;
}
try {
await this.removeWorkspace(root);
this.workspaces.delete(root);
} catch {
cleanupFailure = `The decode workspace could not be removed: ${root}`;
}
};
const failureWithCleanup = (failure: AnalysisError): AnalysisError =>
cleanupFailure === null
? failure
: analysisErrorWithCleanupFailure(
failure,
new ProviderCleanupError(
APKTOOL_PROVIDER_IDENTITY.id,
[root],
{ reason: cleanupFailure },
{ operation },
),
operation,
);
let source: FileHandle | undefined;
let sourceAdmissionFailed = false;
try {
throwIfCancelled(operation, signal);
sourceAdmissionFailed = true;
source = await openRegularFile(input.path, {
symlinks: "follow",
...(signal === undefined ? {} : { signal }),
});
const sourceInfo = await source.stat();
sourceAdmissionFailed = false;
const sourceSize = sourceInfo.size;
let copiedBytes = 0;
const sourceHash = createHash("sha256");
const boundedSource = new Transform({
transform(
chunk: Buffer,
_encoding: BufferEncoding,
callback: TransformCallback,
) {
copiedBytes += chunk.length;
if (copiedBytes > sourceSize) {
callback(
new AnalysisInputError(operation, {
cause: new Error(
`The selected APK changed while being copied: ${input.path}`,
),
}),
);
return;
}
sourceHash.update(chunk);
callback(null, chunk);
},
});
await pipeline(
source.createReadStream({
start: 0,
end: sourceSize,
autoClose: false,
...(signal === undefined ? {} : { signal }),
}),
boundedSource,
createWriteStream(snapshot, { flags: "wx", mode: 0o600 }),
signal === undefined ? {} : { signal },
);
const finalSourceInfo = await source.stat();
if (
copiedBytes !== sourceSize ||
!sameRegularFileState(sourceInfo, finalSourceInfo)
)
throw new AnalysisInputError(operation, {
cause: new Error(
`The selected APK changed while being copied: ${input.path}`,
),
});
const sha256 = sourceHash.digest("hex");
await source.close();
source = undefined;
await chmod(snapshot, 0o400);
throwIfCancelled(operation, signal);
const snapshotInfo = await stat(snapshot);
await mkdir(workspace);
await mkdir(framework);
const decoded = await runApktool(
operation,
selection.command,
[
"d",
"--no-src",
"-f",
"-o",
workspace,
"--frame-path",
framework,
snapshot,
],
{
timeoutMs: DECODE_TIMEOUT_MS,
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
},
signal,
this.environment,
(owner) => this.#pendingCommandOwners.set(owner, root),
);
if (!decoded.ok) {
await removeWorkspace();
return err(failureWithCleanup(decoded.error));
}
throwIfCancelled(operation, signal);
const yml = await readBoundedText(
join(workspace, "apktool.yml"),
MANIFEST_MAX_BYTES,
operation,
signal,
);
if (yml.text === null || yml.truncated) {
throw new AnalysisProtocolError(
`apktool produced missing or oversized apktool.yml: ${input.path}`,
);
}
const metadata = parseApktoolYml(yml.text);
const manifestRead = await readBoundedText(
join(workspace, "AndroidManifest.xml"),
MANIFEST_MAX_BYTES,
operation,
signal,
);
const manifest = manifestRead.text;
const packageName =
manifest === null ? null : parseManifestPackage(manifest);
const stringsDirectory =
input.locale === undefined
? "values"
: `values-${localeQualifier(input.locale)}`;
const stringsRead = input.include_strings
? await readBoundedText(
join(workspace, "res", stringsDirectory, "strings.xml"),
STRINGS_XML_MAX_BYTES,
operation,
signal,
)
: { text: null, truncated: false };
// Do not parse an incomplete XML document as though its projection were complete.
const stringsXml = stringsRead.truncated ? null : stringsRead.text;
const parsedStrings =
stringsXml === null
? { entries: [], unparsedCount: 0 }
: parseDecodedStrings(stringsXml);
const bounded = parsedStrings.entries.slice(0, MAX_STRINGS);
const files = await countDecodedFiles(workspace, signal, operation);
throwIfCancelled(operation, signal);
const coverage =
(stringsXml !== null && parsedStrings.entries.length > MAX_STRINGS) ||
manifestRead.truncated ||
stringsRead.truncated ||
parsedStrings.unparsedCount > 0 ||
files.capped
? "partial"
: "complete";
await removeWorkspace();
return ok(
createAnalysisExecution(
{
client,
target: { path: input.path, bytes: snapshotInfo.size, sha256 },
metadata: {
version_name: metadata.versionName,
version_code: metadata.versionCode,
min_sdk_version: metadata.minSdkVersion,
target_sdk_version: metadata.targetSdkVersion,
package_name: packageName,
},
manifest,
strings: bounded,
locale: input.locale ?? null,
locales: files.locales,
decoded_file_count: files.count,
coverage,
},
provider,
{
limitations: [
"Decoding skips smali (--no-src); Java-source recovery belongs to the JADX family",
...(input.locale !== undefined &&
stringsRead.text === null &&
input.include_strings
? [`No strings.xml was found for locale ${input.locale}`]
: []),
...(input.include_strings &&
stringsRead.text === null &&
input.locale === undefined
? [
"No default res/values/strings.xml was found in the decoded workspace",
]
: []),
...(parsedStrings.unparsedCount > 0
? [
`${String(parsedStrings.unparsedCount)} string entries with nested markup were not projected`,
]
: []),
...(parsedStrings.entries.length > MAX_STRINGS
? [
`strings reports the first ${String(MAX_STRINGS)} of ${String(parsedStrings.entries.length)} entries`,
]
: []),
...(manifestRead.truncated
? [
`manifest is truncated at ${String(MANIFEST_MAX_BYTES)} bytes`,
]
: []),
...(stringsRead.truncated
? [
`strings.xml exceeds the ${String(STRINGS_XML_MAX_BYTES)}-byte projection budget`,
]
: []),
...(files.capped
? [
`Decoded workspace enumeration stopped after ${String(MAX_DECODED_ENTRIES_VISITED)} entries; decoded_file_count and locales may be partial`,
]
: []),
...(cleanupFailure === null ? [] : [cleanupFailure]),
],
},
),
);
} catch (cause) {
await removeWorkspace();
return err(
failureWithCleanup(
signal?.aborted === true
? new AnalysisCancelledError(operation, { cause })
: sourceAdmissionFailed
? new AnalysisInputError(operation, { cause })
: cause instanceof AnalysisInputError
? cause
: cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisProtocolError
? cause
: new AnalysisProtocolError(
"Apktool resource projection failed",
{
cause,
},
),
),
);
} finally {
await source?.close().catch(() => undefined);
}
}
}
+9 -8
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -156,13 +156,14 @@ const wrappedBundle = async (bundle: string): Promise<string | undefined> => {
if (isAbsence(cause)) return undefined;
throw cause;
}
const apps = (await readdir(wrapper, { withFileTypes: true })).filter(
(entry) => entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"),
);
const [app] = apps;
return apps.length === 1 && app !== undefined
? join(wrapper, app.name)
: undefined;
let app: string | undefined;
for await (const entry of await opendir(wrapper)) {
if (!entry.isDirectory() || !entry.name.toLowerCase().endsWith(".app"))
continue;
if (app !== undefined) return undefined;
app = join(wrapper, entry.name);
}
return app;
};
const resolveLayoutExecutable = async (
+26 -4
View File
@@ -1,4 +1,4 @@
import { constants } from "node:fs";
import { constants, type Stats } from "node:fs";
import { createHash } from "node:crypto";
import {
access,
@@ -17,6 +17,7 @@ import {
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
import { readMipsElfAbiFlags } from "./MipsElfAbiFlags.js";
import {
resolveAppBundleExecutable,
type ResolvedAppBundle,
@@ -247,7 +248,7 @@ const isArchiveFormat = (
const sha256Handle = async (
handle: FileHandle,
path: string,
initial: Awaited<ReturnType<FileHandle["stat"]>>,
initial: Stats,
signal?: AbortSignal,
): Promise<string> => {
const hash = createHash("sha256");
@@ -255,11 +256,18 @@ const sha256Handle = async (
let position = 0;
while (true) {
throwIfTargetResolutionCancelled(signal);
const observed = await handle.read(chunk, 0, chunk.length, position);
const observed = await handle.read(
chunk,
0,
Math.min(chunk.length, initial.size - position + 1),
position,
);
throwIfTargetResolutionCancelled(signal);
if (observed.bytesRead === 0) break;
hash.update(chunk.subarray(0, observed.bytesRead));
position += observed.bytesRead;
if (position > initial.size)
throw new BinaryTargetError(path, "target grew while being read");
}
throwIfTargetResolutionCancelled(signal);
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
@@ -352,7 +360,21 @@ const readExecutableMetadata = async (
}
}
}
return parseExecutableHeader(bytes, hostArchitecture, fileSize);
const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize);
if (
!parsed.ok ||
parsed.value.architecture !== "mips" ||
parsed.value.mips.elfClass !== 32
)
return parsed;
const abiFlags = await readMipsElfAbiFlags(handle, parsed.value.mips, () =>
throwIfTargetResolutionCancelled(signal),
);
if (!abiFlags.ok) return abiFlags;
return ok({
...parsed.value,
mips: { ...parsed.value.mips, abiFlags: abiFlags.value },
});
};
const readPeMetadata = async (
@@ -6,6 +6,7 @@ import type { Result } from "../domain/result.js";
/** Provider-neutral application boundary for controlled browser scenarios. */
export interface BrowserScenarioCapturePort {
close?(): Promise<void>;
identity(): ProviderIdentity;
captureScenario(
scenario: BrowserScenario,
+13 -1
View File
@@ -40,6 +40,7 @@ export type AvailabilityPolicy = {
readonly firmwareInspectionEnabled?: boolean;
readonly firmwareExtractionEnabled?: boolean;
readonly androidAnalysisAvailability?: ProviderAvailability;
readonly jebAnalysisAvailability?: ProviderAvailability;
readonly javascriptRecoveryEnabled?: boolean;
readonly webModuleResolutionEnabled?: boolean;
readonly browserObservationEnabled?: boolean;
@@ -303,7 +304,7 @@ const workflowAvailabilityFor = ({
: {
reason: "provider_missing",
remediation:
"On Linux x64, provide an absolute REA_WAKARU_COMMAND for Wakaru 1.13.0 and util-linux prlimit. No binary target is required.",
"On Linux x64, provide an absolute REA_WAKARU_COMMAND for Wakaru ^1.13.0 (verified with 1.14.0) and util-linux prlimit; see docs/javascript-recovery.md#install-wakaru. No binary target is required.",
};
if (name === "inspect_binary_layout")
return policy.binaryLayoutEnabled === true
@@ -313,6 +314,8 @@ const workflowAvailabilityFor = ({
remediation:
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
};
if (name === "inspect_pe_resources")
return { reason: "available", remediation: null };
if (name === "inspect_evm_interface")
return policy.evmInterfaceEnabled === true
? { reason: "available", remediation: null }
@@ -354,6 +357,15 @@ const workflowAvailabilityFor = ({
policy.androidAnalysisAvailability?.reason ??
"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 === "jeb-provider")
return policy.jebAnalysisAvailability?.status === "available"
? { reason: "available", remediation: null }
: {
reason: "provider_missing",
remediation:
policy.jebAnalysisAvailability?.reason ??
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
};
const browser = browserProviderAvailability(name, kind, policy);
if (browser !== null) return browser;
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
+2 -2
View File
@@ -1,5 +1,5 @@
import { err, ok, type Result } from "../domain/result.js";
import { readFile } from "node:fs/promises";
import { readRegularFileText } from "./RegularFileRead.js";
import {
effectiveClientServer,
parseClientConfiguration,
@@ -19,7 +19,7 @@ export const readConfigurationText = async (
path: string,
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
try {
return ok(await readFile(path, "utf8"));
return ok(await readRegularFileText(path));
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
+1 -1
View File
@@ -6,7 +6,7 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, type Result } from "../domain/result.js";
import type { AnalysisExecution } from "./AnalysisProvider.js";
import type { BinarySession } from "./binary/BinarySession.js";
import { analysisErrorWithCleanupFailure } from "./binary/AnalysisClientCleanup.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
/** Await one-shot cleanup and retain either the outcome or both failures. */
export const withSessionCleanup = async <Value>(
+43
View File
@@ -0,0 +1,43 @@
/** 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. */
export const downloadHopperPackage = async (
url: string,
options: { readonly signal?: AbortSignal } = {},
): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => {
const deadline = AbortSignal.timeout(120_000);
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
signal:
options.signal === undefined
? deadline
: AbortSignal.any([options.signal, deadline]),
});
const refused = { ok: false, bytes: new Uint8Array() };
if (
!response.ok ||
Number(response.headers.get("content-length")) > HOPPER_DOWNLOAD_BYTES
) {
await response.body?.cancel();
return refused;
}
if (response.body === null) return { ok: true, bytes: 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.value.byteLength > HOPPER_DOWNLOAD_BYTES - bytes) {
await reader.cancel();
return refused;
}
chunks.push(chunk.value);
bytes += chunk.value.byteLength;
}
} finally {
reader.releaseLock();
}
};
@@ -37,6 +37,13 @@ export const MANAGED_STATIC_PROVIDER = Object.freeze({
version: "1",
} as const);
/** Provider identity for portable, execution-free PE resource decoding. */
export const PE_RESOURCES_PROVIDER = Object.freeze({
id: "rea-pe-resources",
name: "REA PE resource parser",
version: "1",
} as const);
/** Provider identity for deterministic managed-code comparison workflows. */
export const MANAGED_WORKFLOW_PROVIDER = {
id: "rea-dotnet-workflows",
+12 -5
View File
@@ -16,8 +16,7 @@ import {
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import { parseUtf8Json } from "./Utf8JsonInput.js";
import { readRegularFile } from "./RegularFileRead.js";
import { readJsonInputFile } from "./JsonInputFile.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
/** Request control and its owning operation for an interruptible atomic write. */
@@ -37,12 +36,20 @@ export const readJsonFile = async (
> => {
const requestedPath = resolve(path);
try {
const encoded = await readRegularFile(requestedPath);
const decoded = parseUtf8Json(encoded, "read_evidence_file", requestedPath);
const decoded = await readJsonInputFile(
requestedPath,
"read_evidence_file",
);
if (!decoded.ok) {
if (decoded.error instanceof AnalysisResourceConstraintError)
return err(decoded.error);
return err(
new EvidenceFileError("read", "invalid-json", {
cause: decoded.cause,
cause:
decoded.error.cause ??
new SyntaxError(
decoded.error.issues[0]?.message ?? decoded.error.message,
),
path: requestedPath,
}),
);
@@ -6,61 +6,76 @@ import { getHeapStatistics } from "node:v8";
import parser from "stream-json/parser.js";
import streamValues from "stream-json/streamers/stream-values.js";
import { withRegularFile } from "./application/RegularFileRead.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import { withRegularFile } from "./RegularFileRead.js";
import {
JSON_BYTE_ORDER_MARK_MESSAGE,
JSON_INPUT_RESOURCE_REMEDIATION,
parseUtf8Json,
} from "./application/Utf8JsonInput.js";
} from "./Utf8JsonInput.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "./domain/analysisErrorCore.js";
import { err, ok, type Result } from "./domain/result.js";
} from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
type JsonFileFailure = AnalysisInputError | AnalysisResourceConstraintError;
type JsonInputFileFailure =
| AnalysisInputError
| AnalysisResourceConstraintError;
const READ_CHUNK_BYTES = 64 * 1024;
const PACKED_STRING_PART_CODE_UNITS = 64 * 1024;
const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
// Retain native-parser speed for modest inputs while bounding its extra copies.
// This selects an implementation; larger files remain accepted through streaming.
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
/** Keep native parsing only when input copies and value expansion fit live headroom. */
const nativeParseFits = (inputBytes: number): boolean =>
inputBytes <= NATIVE_PARSE_READ_BUDGET &&
// The assembler estimate reserves up to 128 bytes per two-byte container.
// Allow that 64x expansion plus byte/text copies before native JSON.parse,
// whose individual container allocations cannot be intercepted.
inputBytes * 72 + READ_CHUNK_BYTES <=
getHeapStatistics().total_available_size;
/** Parse strict UTF-8 JSON without requiring a whole-document string. */
export const readCliJsonFile = (
export const readJsonInputFile = (
path: string,
operation: string,
signal?: AbortSignal,
): Promise<Result<unknown, JsonFileFailure>> =>
): Promise<Result<unknown, JsonInputFileFailure>> =>
withRegularFile(
path,
async (handle, stats) => {
let found = false;
let value: unknown;
try {
const prefix =
stats.size <= NATIVE_PARSE_READ_BUDGET
? await readPrefix(handle, stats.size + 1, signal)
: undefined;
if (prefix?.complete === true) {
const prefix = nativeParseFits(stats.size)
? await readPrefix(handle, stats.size + 1, signal)
: undefined;
if (prefix !== undefined) {
if (!prefix.complete || prefix.bytes.length !== stats.size)
throw new RegularFileChangedError(path);
const parsed = parseUtf8Json(
prefix.bytes,
operation,
path,
"cli-json-input",
"json-file-input",
);
return parsed.ok
? ok(parsed.value)
: err(invalidJson(operation, parsed.error, parsed.cause));
}
await pipeline(
decodedChunks(handle, signal, prefix?.bytes),
decodedChunks(handle, stats.size, path, signal),
parser.asStream({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonStrings(operation, stats.size),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(operation, stats.size, path),
streamValues.asStream(),
async (rows: AsyncIterable<unknown>) => {
for await (const row of rows) {
@@ -107,8 +122,9 @@ export const readCliJsonFile = (
new AnalysisResourceConstraintError(
operation,
"memory",
"An individual JSON string exceeds the runtime string limit",
"An individual JSON string or number token exceeds the runtime string limit",
{
input_path: path,
input_file_bytes: stats.size,
max_string_code_units: constants.MAX_STRING_LENGTH,
},
@@ -121,12 +137,16 @@ export const readCliJsonFile = (
throw cause;
}
},
signal,
{ signal },
);
const packJsonStrings = (operation: string, inputFileBytes: number) =>
const packJsonScalars = (
operation: string,
inputFileBytes: number,
path: string,
) =>
async function* (tokens: AsyncIterable<unknown>): AsyncGenerator<unknown> {
let kind: "keyValue" | "stringValue" | undefined;
let kind: "keyValue" | "stringValue" | "numberValue" | undefined;
let length = 0;
let value = "";
let parts: string[] = [];
@@ -139,23 +159,32 @@ const packJsonStrings = (operation: string, inputFileBytes: number) =>
case "startString":
kind = token.name === "startKey" ? "keyValue" : "stringValue";
break;
case "startNumber":
kind = "numberValue";
break;
case "numberChunk":
case "stringChunk":
if (
kind === undefined ||
!("value" in token) ||
typeof token.value !== "string"
)
throw new Error("Unexpected JSON string fragment");
throw new Error("Unexpected JSON scalar fragment");
length += token.value.length;
if (length > constants.MAX_STRING_LENGTH)
throw new RangeError("Invalid string length");
parts.push(token.value);
partLength += token.value.length;
if (partLength >= PACKED_STRING_PART_CODE_UNITS) {
if (partLength >= PACKED_TOKEN_PART_CODE_UNITS) {
// The tokenizer emits short fragments. Coalesce them before retaining
// a string so millions of substring/rope nodes cannot exhaust the heap
// a scalar so millions of substring/rope nodes cannot exhaust the heap
// before the native string-length constraint can be reported.
requireStringAssemblyHeadroom(operation, inputFileBytes, length);
requireStringAssemblyHeadroom(
operation,
inputFileBytes,
path,
length,
);
value += parts.join("");
parts = [];
partLength = 0;
@@ -163,8 +192,14 @@ const packJsonStrings = (operation: string, inputFileBytes: number) =>
break;
case "endKey":
case "endString":
if (kind === undefined) throw new Error("Unexpected JSON string end");
requireStringAssemblyHeadroom(operation, inputFileBytes, length);
case "endNumber":
if (kind === undefined) throw new Error("Unexpected JSON scalar end");
requireStringAssemblyHeadroom(
operation,
inputFileBytes,
path,
length,
);
yield { name: kind, value: value + parts.join("") };
kind = undefined;
length = 0;
@@ -178,22 +213,78 @@ const packJsonStrings = (operation: string, inputFileBytes: number) =>
}
};
/** Admit the retained value graph before the upstream assembler grows it. */
const admitJsonAssembly = (
operation: string,
inputFileBytes: number,
path: string,
) =>
async function* (tokens: AsyncIterable<unknown>): AsyncGenerator<unknown> {
let projectedBytes = 0;
let nextCheck = 0;
for await (const token of tokens) {
if (typeof token !== "object" || token === null || !("name" in token))
throw new Error("Unexpected JSON assembly token");
if (token.name === "startObject" || token.name === "startArray") {
projectedBytes += 128;
} else if (token.name === "keyValue" || token.name === "stringValue") {
if (!("value" in token) || typeof token.value !== "string")
throw new Error("Unexpected packed JSON string");
projectedBytes += 64 + token.value.length * 2;
} else if (
token.name === "numberValue" ||
token.name === "nullValue" ||
token.name === "trueValue" ||
token.name === "falseValue"
) {
projectedBytes += 64;
}
if (projectedBytes >= nextCheck) {
const heap = getHeapStatistics();
// Conservative capacity for containers, property/array slots and UTF-16
// values. Keep another whole projection available for container growth,
// plus the next chunk of estimates between samples. This is admission
// against live runtime headroom, not a fixed document-size policy.
const requiredBytes = projectedBytes + READ_CHUNK_BYTES;
if (requiredBytes > heap.total_available_size)
throw new AnalysisResourceConstraintError(
operation,
"memory",
"Insufficient heap headroom to materialize the complete JSON value",
{
input_path: path,
input_file_bytes: inputFileBytes,
projected_value_bytes: projectedBytes,
assembly_headroom_bytes: requiredBytes,
available_heap_bytes: heap.total_available_size,
heap_size_limit_bytes: heap.heap_size_limit,
},
{ remediationAction: JSON_INPUT_RESOURCE_REMEDIATION },
);
nextCheck = projectedBytes + READ_CHUNK_BYTES;
}
yield token;
}
};
const requireStringAssemblyHeadroom = (
operation: string,
inputFileBytes: number,
path: string,
stringCodeUnits: number,
): void => {
if (stringCodeUnits < PACKED_STRING_PART_CODE_UNITS) return;
if (stringCodeUnits < PACKED_TOKEN_PART_CODE_UNITS) return;
const heap = getHeapStatistics();
// Concatenation or key interning may flatten the retained rope. Reserve its
// UTF-16 representation and the next coalesced part before that allocation.
const requiredBytes = 2 * (stringCodeUnits + PACKED_STRING_PART_CODE_UNITS);
const requiredBytes = 2 * (stringCodeUnits + PACKED_TOKEN_PART_CODE_UNITS);
if (requiredBytes > heap.total_available_size)
throw new AnalysisResourceConstraintError(
operation,
"memory",
"Insufficient heap headroom to assemble a complete JSON string",
{
input_path: path,
input_file_bytes: inputFileBytes,
string_code_units: stringCodeUnits,
string_assembly_headroom_bytes: requiredBytes,
@@ -208,8 +299,9 @@ const requireStringAssemblyHeadroom = (
async function* decodedChunks(
handle: FileHandle,
admittedBytes: number,
path: string,
signal: AbortSignal | undefined,
prefix?: Uint8Array,
): AsyncGenerator<string> {
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
let leading = true;
@@ -220,25 +312,25 @@ async function* decodedChunks(
}
return text;
};
if (prefix !== undefined)
for (let offset = 0; offset < prefix.length; offset += READ_CHUNK_BYTES) {
signal?.throwIfAborted();
yield checked(
decoder.decode(prefix.subarray(offset, offset + READ_CHUNK_BYTES), {
stream: true,
}),
);
}
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let observedBytes = 0;
while (true) {
signal?.throwIfAborted();
const read = await handle.read(bytes, 0, bytes.length, null);
const read = await handle.read(
bytes,
0,
Math.min(bytes.length, admittedBytes - observedBytes + 1),
null,
);
signal?.throwIfAborted();
if (read.bytesRead === 0) break;
observedBytes += read.bytesRead;
if (observedBytes > admittedBytes) throw new RegularFileChangedError(path);
yield checked(
decoder.decode(bytes.subarray(0, read.bytesRead), { stream: true }),
);
}
if (observedBytes !== admittedBytes) throw new RegularFileChangedError(path);
yield checked(decoder.decode());
}
@@ -264,8 +356,7 @@ const readPrefix = async (
return { bytes: bytes.subarray(0, offset), complete: true };
offset += read.bytesRead;
}
// The file grew after admission. Continue from this same handle, preserving
// the prefix already read rather than reopening or dropping its bytes.
// One byte beyond the admitted extent proves the selected file changed.
return { bytes, complete: false };
};
+6 -12
View File
@@ -7,10 +7,13 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { execFileOutput } from "../process/ExecFileOutput.js";
import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
} from "./HopperDownload.js";
const execFileAsync = promisify(execFile);
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
const MAX_PACKAGE_BYTES = 100_000_000;
const SUPPORTED_HOPPER_SHA256 = new Set([
"0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded",
"1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13",
@@ -231,16 +234,7 @@ export const systemLinuxHopperInstallHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): LinuxHopperInstallHost => ({
distribution: readLinuxDistribution,
async download(url, options) {
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
...(options.signal === undefined ? {} : { signal: options.signal }),
});
return {
ok: response.ok,
bytes: new Uint8Array(await response.arrayBuffer()),
};
},
download: downloadHopperPackage,
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
installPackage: (family, archive) =>
@@ -350,7 +344,7 @@ const packageIntegrityMatches = (
release: LinuxHopperRelease,
): boolean =>
bytes.byteLength === Number(release.file_length) &&
bytes.byteLength <= MAX_PACKAGE_BYTES &&
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
const packageFamilyFor = (
+213 -167
View File
@@ -15,12 +15,15 @@ import { promisify } from "node:util";
import { z } from "zod";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
} from "./HopperDownload.js";
const execFileAsync = promisify(execFile);
const RELEASES_URL =
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
const MAX_PACKAGE_BYTES = 100_000_000;
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
/** Stable network and command effects disclosed before macOS Hopper setup. */
@@ -36,7 +39,11 @@ export const macHopperInstallDisclosure = {
/** Result of installing the official Hopper macOS application. */
export type MacHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
| {
readonly status: "installed";
readonly launcherPath: string;
readonly cleanupFailure?: string;
}
| {
readonly status: "failed";
readonly reason:
@@ -48,6 +55,7 @@ export type MacHopperInstallResult =
| "destination_exists"
| "copy"
| "launcher_missing";
readonly cleanupFailure?: string;
};
/** External effects used by the bounded macOS Hopper installer. */
@@ -72,6 +80,8 @@ export interface MacHopperInstallHost {
launcherReady(path: string): Promise<boolean>;
openApplication(path: string): Promise<void>;
cleanup(path: string): Promise<void>;
/** Retain the cleanup capability until the owning setup host retries it. */
retainCleanup?(cleanup: () => Promise<void>): void;
destination(): string;
}
@@ -85,187 +95,221 @@ export const installMacHopper = async (
): Promise<MacHopperInstallResult> => {
let temporary: string | undefined;
let mounted = false;
let result: MacHopperInstallResult;
try {
const metadata = await host.download(RELEASES_URL);
if (!metadata.ok) return { status: "failed", reason: "release_metadata" };
const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
if (!parsed.success)
return { status: "failed", reason: "release_metadata" };
const release = parsed.data["OS X"];
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
return { status: "failed", reason: "release_metadata" };
const archive = await host.download(release.filename);
if (!archive.ok) return { status: "failed", reason: "download" };
if (!integrityMatches(archive.bytes, release))
return { status: "failed", reason: "integrity" };
result = await (async (): Promise<MacHopperInstallResult> => {
const metadata = await host.download(RELEASES_URL);
if (!metadata.ok) return { status: "failed", reason: "release_metadata" };
const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
if (!parsed.success)
return { status: "failed", reason: "release_metadata" };
const release = parsed.data["OS X"];
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
return { status: "failed", reason: "release_metadata" };
const archive = await host.download(release.filename);
if (!archive.ok) return { status: "failed", reason: "download" };
if (!integrityMatches(archive.bytes, release))
return { status: "failed", reason: "integrity" };
const destination = host.destination();
const destinationExists = await host.destinationExists(destination);
if (destinationExists && options.replaceExisting !== true)
return { status: "failed", reason: "destination_exists" };
temporary = await host.createTemporaryDirectory();
const packagePath = join(temporary, "hopper.dmg");
const mountPath = join(temporary, "mounted");
await host.createMountDirectory(mountPath);
await host.writePackage(packagePath, archive.bytes);
if (!(await host.mount(packagePath, mountPath)))
return { status: "failed", reason: "mount" };
mounted = true;
const source = await host.appBundle(mountPath);
if (source === undefined || !(await host.validBundle(source)))
return { status: "failed", reason: "bundle" };
const stage = `${destination}.rea-stage`;
if (
!(await host.installBundle(source, destination, stage, destinationExists))
)
return { status: "failed", reason: "copy" };
const launcherPath = join(destination, "Contents/MacOS/hopper");
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
await host.openApplication(destination);
return { status: "installed", launcherPath };
const destination = host.destination();
const destinationExists = await host.destinationExists(destination);
if (destinationExists && options.replaceExisting !== true)
return { status: "failed", reason: "destination_exists" };
temporary = await host.createTemporaryDirectory();
const packagePath = join(temporary, "hopper.dmg");
const mountPath = join(temporary, "mounted");
await host.createMountDirectory(mountPath);
await host.writePackage(packagePath, archive.bytes);
if (!(await host.mount(packagePath, mountPath)))
return { status: "failed", reason: "mount" };
mounted = true;
const source = await host.appBundle(mountPath);
if (source === undefined || !(await host.validBundle(source)))
return { status: "failed", reason: "bundle" };
const stage = `${destination}.rea-stage`;
if (
!(await host.installBundle(
source,
destination,
stage,
destinationExists,
))
)
return { status: "failed", reason: "copy" };
const launcherPath = join(destination, "Contents/MacOS/hopper");
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
await host.openApplication(destination);
return { status: "installed", launcherPath };
})();
} catch (cause: unknown) {
// Install-stage failures share the download reason; the stage is cleaned below.
void cause;
return { status: "failed", reason: "download" };
} finally {
if (temporary !== undefined) {
const mountPath = join(temporary, "mounted");
if (mounted) await host.unmount(mountPath);
await host.cleanup(temporary);
result = { status: "failed", reason: "download" };
}
let cleanupFailure: string | undefined;
if (temporary !== undefined) {
const temporaryPath = temporary;
const mountPath = join(temporary, "mounted");
const cleanup = async (): Promise<void> => {
if (mounted) {
await host.unmount(mountPath);
mounted = false;
}
await host.cleanup(temporaryPath);
};
try {
await cleanup();
} catch (cause: unknown) {
cleanupFailure = `Hopper DMG cleanup failed: ${failureMessage(cause)}`;
host.retainCleanup?.(cleanup);
}
}
return cleanupFailure === undefined ? result : { ...result, cleanupFailure };
};
/** Compose installer paths and subprocesses from one selected caller context. */
export const systemMacHopperInstallHost = (
homeDirectory: string,
environment: Readonly<NodeJS.ProcessEnv>,
): MacHopperInstallHost => ({
download: downloadPackage,
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
createMountDirectory: (path) => mkdir(path),
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
async mount(packagePath, mountPath) {
return commandSucceeds(
"hdiutil",
[
"attach",
"-readonly",
"-nobrowse",
"-mountpoint",
mountPath,
packagePath,
],
environment,
);
},
async unmount(mountPath) {
await commandSucceeds(
"hdiutil",
["detach", mountPath, "-force"],
environment,
);
},
async appBundle(mountPath) {
const entries = await readdir(mountPath, { withFileTypes: true });
const app = entries.find(
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
);
return app === undefined ? undefined : join(mountPath, app.name);
},
async validBundle(appPath) {
if (
!(await commandSucceeds(
"codesign",
["--verify", "--deep", "--strict", appPath],
): MacHopperInstallHost & { close(): Promise<string | undefined> } => {
const pendingCleanup = new Set<() => Promise<void>>();
return {
download: downloadHopperPackage,
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
createMountDirectory: (path) => mkdir(path),
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
async mount(packagePath, mountPath) {
return commandSucceeds(
"hdiutil",
[
"attach",
"-readonly",
"-nobrowse",
"-mountpoint",
mountPath,
packagePath,
],
environment,
))
)
return false;
try {
const identifier = (
await execFileAsync(
"/usr/libexec/PlistBuddy",
[
"-c",
"Print :CFBundleIdentifier",
join(appPath, "Contents/Info.plist"),
],
{ env: environment },
)
).stdout.trim();
return identifier === BUNDLE_IDENTIFIER;
} catch (cause: unknown) {
// best-effort cleanup: optional bundle probing; unreadable means invalid.
void cause;
return false;
}
},
async destinationExists(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional existence probing; absence means missing.
void cause;
return false;
}
},
async installBundle(source, destination, stage, replaceExisting) {
const previous = `${stage}-previous`;
try {
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
await rm(stage, { recursive: true, force: true });
await rm(previous, { recursive: true, force: true });
await execFileAsync("ditto", [source, stage], { env: environment });
await access(join(stage, "Contents/MacOS/hopper"));
if (replaceExisting) await rename(destination, previous);
await rename(stage, destination);
if (replaceExisting) await rm(previous, { recursive: true, force: true });
return true;
} catch (cause: unknown) {
// Install failure rolls back below; the false return reports it.
void cause;
await rm(stage, { recursive: true, force: true });
if (replaceExisting) {
);
},
async unmount(mountPath) {
if (
!(await commandSucceeds(
"hdiutil",
["detach", mountPath, "-force"],
environment,
))
)
throw new Error(
`hdiutil detach failed for ${JSON.stringify(mountPath)}`,
);
},
async appBundle(mountPath) {
const entries = await readdir(mountPath, { withFileTypes: true });
const app = entries.find(
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
);
return app === undefined ? undefined : join(mountPath, app.name);
},
async validBundle(appPath) {
if (
!(await commandSucceeds(
"codesign",
["--verify", "--deep", "--strict", appPath],
environment,
))
)
return false;
try {
const identifier = (
await execFileAsync(
"/usr/libexec/PlistBuddy",
[
"-c",
"Print :CFBundleIdentifier",
join(appPath, "Contents/Info.plist"),
],
{ env: environment },
)
).stdout.trim();
return identifier === BUNDLE_IDENTIFIER;
} catch (cause: unknown) {
// best-effort cleanup: optional bundle probing; unreadable means invalid.
void cause;
return false;
}
},
async destinationExists(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional existence probing; absence means missing.
void cause;
return false;
}
},
async installBundle(source, destination, stage, replaceExisting) {
const previous = `${stage}-previous`;
try {
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
await rm(stage, { recursive: true, force: true });
await rm(previous, { recursive: true, force: true });
await execFileAsync("ditto", [source, stage], { env: environment });
await access(join(stage, "Contents/MacOS/hopper"));
if (replaceExisting) await rename(destination, previous);
await rename(stage, destination);
if (replaceExisting)
await rm(previous, { recursive: true, force: true });
return true;
} catch (cause: unknown) {
// Install failure rolls back below; the false return reports it.
void cause;
await rm(stage, { recursive: true, force: true });
if (replaceExisting) {
try {
await rename(previous, destination);
} catch (rollbackCause: unknown) {
// The original destination was not moved, or rollback is impossible.
void rollbackCause;
}
}
return false;
}
},
async launcherReady(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional launcher probing; absence means not ready.
void cause;
return false;
}
},
async openApplication(path) {
await execFileAsync("open", [path], { env: environment });
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
retainCleanup: (cleanup) => pendingCleanup.add(cleanup),
async close() {
let failure: string | undefined;
for (const cleanup of pendingCleanup) {
try {
await rename(previous, destination);
} catch (rollbackCause: unknown) {
// The original destination was not moved, or rollback is impossible.
void rollbackCause;
await cleanup();
pendingCleanup.delete(cleanup);
} catch (cause: unknown) {
failure ??= `Hopper DMG cleanup remains incomplete: ${failureMessage(cause)}`;
}
}
return false;
}
},
async launcherReady(path) {
try {
await access(path);
return true;
} catch (cause: unknown) {
// best-effort cleanup: optional launcher probing; absence means not ready.
void cause;
return false;
}
},
async openApplication(path) {
await execFileAsync("open", [path], { env: environment });
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
destination: () =>
join(homeDirectory, "Applications/Hopper Disassembler.app"),
});
const downloadPackage = async (url: string) => {
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
signal: AbortSignal.timeout(120_000),
});
const length = Number(response.headers.get("content-length") ?? "0");
if (length > MAX_PACKAGE_BYTES) return { ok: false, bytes: new Uint8Array() };
const bytes = new Uint8Array(await response.arrayBuffer());
return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
return failure;
},
destination: () =>
join(homeDirectory, "Applications/Hopper Disassembler.app"),
};
};
const parseJson = (bytes: Uint8Array): unknown =>
@@ -275,7 +319,7 @@ const integrityMatches = (
release: z.infer<typeof releaseSchema>,
): boolean =>
bytes.byteLength === Number(release.file_length) &&
bytes.byteLength <= MAX_PACKAGE_BYTES &&
bytes.byteLength <= HOPPER_DOWNLOAD_BYTES &&
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
const commandSucceeds = async (
command: string,
@@ -291,6 +335,8 @@ const commandSucceeds = async (
return false;
}
};
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
const releaseSchema = z.object({
filename: z.string().url(),
file_length: z.string().regex(/^\d+$/u),
+153
View File
@@ -0,0 +1,153 @@
import type { FileHandle } from "node:fs/promises";
import type {
MipsAbiFlags,
MipsElfMetadata,
} from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
/**
* Read ELF32 MIPS ABI declarations from the caller's already-open file.
* Inspect both typed tables (not section names), including records beyond the
* initial header probe. A missing record is unknown, not a default FP ABI.
* The caller supplies its cancellation check so its tagged error is preserved.
*/
export const readMipsElfAbiFlags = async (
handle: FileHandle,
metadata: MipsElfMetadata,
checkCancelled: () => void,
): Promise<Result<MipsAbiFlags | null, string>> => {
checkCancelled();
if (metadata.elfClass !== 32)
return err("MIPS ABI record inspection currently supports ELF32 only");
const fileSize = (await handle.stat()).size;
checkCancelled();
const read = async (
offset: number,
length: number,
label: string,
): Promise<Result<Buffer, string>> => {
checkCancelled();
if (
!Number.isSafeInteger(fileSize) ||
offset < 0 ||
length < 0 ||
offset > fileSize - length
)
return err(`MIPS ${label} lies outside the file`);
const bytes = Buffer.alloc(length);
let total = 0;
while (total < length) {
checkCancelled();
const observed = await handle.read(
bytes,
total,
length - total,
offset + total,
);
checkCancelled();
if (observed.bytesRead === 0) return err(`truncated MIPS ${label}`);
total += observed.bytesRead;
}
return ok(bytes);
};
const header = await read(0, 52, "ELF32 header");
if (!header.ok) return header;
const bytes = header.value;
const little = metadata.byteOrder === "little";
const u16 = (data: Buffer, offset: number): number =>
little ? data.readUInt16LE(offset) : data.readUInt16BE(offset);
const u32 = (data: Buffer, offset: number): number =>
little ? data.readUInt32LE(offset) : data.readUInt32BE(offset);
if (
bytes.toString("hex", 0, 4) !== "7f454c46" ||
bytes[4] !== 1 ||
bytes[5] !== (little ? 1 : 2) ||
u16(bytes, 18) !== 8 ||
u16(bytes, 16) !== metadata.type ||
u32(bytes, 36) !== metadata.flags
)
return err("MIPS ELF header no longer matches the resolved identity");
if (bytes[6] !== 1 || u32(bytes, 20) !== 1 || u16(bytes, 40) !== 52)
return err("invalid MIPS ELF32 version or header size");
const tables = [
{
label: "program header table",
offset: u32(bytes, 28),
count: u16(bytes, 44),
stride: u16(bytes, 42),
entrySize: 32,
typeOffset: 0,
dataOffset: 4,
sizeOffset: 16,
recordType: 0x70000003, // PT_MIPS_ABIFLAGS
},
{
label: "section header table",
offset: u32(bytes, 32),
count: u16(bytes, 48),
stride: u16(bytes, 46),
entrySize: 40,
typeOffset: 4,
dataOffset: 16,
sizeOffset: 20,
recordType: 0x7000002a, // SHT_MIPS_ABIFLAGS
},
];
// Extended numbering needs a separate section-zero reader. Refuse it rather
// than silently treating an unexamined table as having no ABI declarations.
if (u16(bytes, 44) === 0xffff || u16(bytes, 48) >= 0xff00)
return err("extended or reserved MIPS ELF table numbering is unsupported");
let record: Buffer | undefined;
for (const table of tables) {
if (table.count === 0) {
if (table.offset !== 0)
return err(`unsupported extended or inconsistent MIPS ${table.label}`);
continue;
}
if (table.offset < 52 || table.stride !== table.entrySize)
return err(`invalid MIPS ${table.label} offset or entry size`);
// Counts are bounded by the ELF16 fields and strides by their exact ELF32
// structure sizes. Never allocate according to an untrusted section size.
const contents = await read(
table.offset,
table.count * table.entrySize,
table.label,
);
if (!contents.ok) return contents;
let found = false;
for (let i = 0; i < table.count; i += 1) {
checkCancelled();
const position = i * table.entrySize;
if (u32(contents.value, position + table.typeOffset) !== table.recordType)
continue;
if (found) return err(`duplicate MIPS ABI records in ${table.label}`);
found = true;
const offset = u32(contents.value, position + table.dataOffset);
const size = u32(contents.value, position + table.sizeOffset);
if (size !== 24)
return err("unsupported MIPS ABI record size; expected 24 bytes");
const candidate = await read(offset, 24, "ABI record");
if (!candidate.ok) return candidate;
if (record !== undefined && !record.equals(candidate.value))
return err("conflicting MIPS program/section ABI declarations");
record = candidate.value;
}
}
if (record === undefined) return ok(null);
// Preserve declarations verbatim, including unknown versions/values. Provider
// admission, not parsing, decides which interpretations have been verified.
return ok({
version: u16(record, 0),
isaLevel: record.readUInt8(2),
isaRevision: record.readUInt8(3),
gprSize: record.readUInt8(4),
cpr1Size: record.readUInt8(5),
cpr2Size: record.readUInt8(6),
fpAbi: record.readUInt8(7),
isaExtension: u32(record, 8),
ases: u32(record, 12),
flags1: u32(record, 16),
flags2: u32(record, 20),
});
};
@@ -0,0 +1,65 @@
import { z } from "zod";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type {
AnalysisExecution,
ExecutionOptions,
} from "./AnalysisProvider.js";
/** Validated caller-selected request carrying its operation input. */
export interface ProviderOperationRequest {
readonly input: unknown;
}
/** Provider boundary executing one validated caller-selected request. */
export interface ProviderOperationPort<
TRequest extends ProviderOperationRequest,
> {
execute(
request: TRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
}
/**
* Shared CLI/MCP admission, execution, and Evidence composition for
* single-request providers. The caller retains its operation vocabulary and
* confidence; this normalizes cancellation, input validation, and Evidence
* assembly once instead of per provider.
*/
export const executeProviderOperation = async <
TRequest extends ProviderOperationRequest,
>(
operation: string,
input: unknown,
options: ExecutionOptions | undefined,
schema: z.ZodType<TRequest>,
provider: ProviderOperationPort<TRequest>,
confidence: "observed" | "derived",
): Promise<Result<Evidence, AnalysisError>> => {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
const request = schema.safeParse({ operation, input });
if (!request.success)
return err(new AnalysisInputError(operation, { cause: request.error }));
const executed = await provider.execute(request.data, options);
if (!executed.ok) return executed;
const execution = executed.value;
return ok(
createEvidence(execution.subject ?? undefined, execution.provider, {
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
locations: execution.locations,
confidence,
}),
);
};
@@ -1,3 +1,11 @@
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascript/javascriptApplicationAnalysis.js";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import {
createJavaScriptSemanticGraph,
createJavaScriptSemanticGraphNode,
JavaScriptSemanticEvidenceContextRegistry,
} from "../domain/javascript/javascriptSemanticGraph.js";
import { proofEvidence } from "./ReconstructionObligationLedger.fixture.js";
import { describe, expect, it } from "vitest";
import { createEvidence } from "../domain/evidence.js";
@@ -171,3 +179,115 @@ describe("reconstruction obligation candidates", () => {
);
});
});
it("keeps each semantic candidate bound to its own file and authority context", () => {
const contexts = new JavaScriptSemanticEvidenceContextRegistry();
const base = javascriptApplicationAnalysisResultSchema.parse(
JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE.normalized_result,
);
const sourceRecords = [
proofEvidence("first-file"),
proofEvidence("second-file"),
];
const module = createJavaScriptSemanticGraphNode(
{
kind: "module",
identity: {
artifact_sha256: base.root_artifact_sha256,
module_path: "app.js",
source_range: null,
role_key: "module",
},
function_node_id: null,
application_node_ids: [],
label: "app.js",
properties: {},
evidence: artifactEvidence(base.root_artifact_sha256, "app.js"),
},
contexts,
);
const requests = sourceRecords.flatMap((source, fileIndex) =>
[1, 2].map((line) => {
const sha256 = String(fileIndex + 4).repeat(64);
const path = `file-${String(fileIndex)}.js`;
return createJavaScriptSemanticGraphNode(
{
kind: "request",
identity: {
artifact_sha256: sha256,
module_path: path,
source_range: {
start: { line, column: 0 },
end: { line, column: 8 },
},
role_key: `request-${String(line)}`,
},
function_node_id: null,
application_node_ids: [],
label: `${path}:${String(line)}`,
properties: {},
evidence: {
...artifactEvidence(sha256, path, "ast-static-analysis"),
evidence_ids: [source.evidence_id],
},
},
contexts,
);
}),
);
const { graph_id: _graphId, ...semantic } = base.semantic_graph;
const semanticGraph = createJavaScriptSemanticGraph({
...semantic,
root_node_ids: [module.node_id],
nodes: [module, ...requests],
evidence_contexts: contexts.contexts,
});
const example = JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE;
const application = createEvidence(
{
path: base.input_path,
sha256: base.root_artifact_sha256,
format: base.format,
},
example.provider,
{
predicateType: example.predicate_type,
operation: example.operation,
parameters: example.parameters,
result: jsonValueSchema.parse({ ...base, semantic_graph: semanticGraph }),
confidence: "derived",
authority: "shipped-artifact",
evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id),
},
);
const generated = deriveReconstructionObligationCandidates(
createEvidenceBundle([...sourceRecords, application]),
[],
);
expect(generated.limitations).toEqual([]);
expect(generated.candidates).toHaveLength(requests.length);
for (const [index, node] of requests.entries()) {
const source = sourceRecords[Math.floor(index / 2)];
expect(
generated.candidates.find(
({ target }) => target.semantic_node_id === node.node_id,
),
).toMatchObject({
title: node.label,
family: "request",
source_state: "candidate",
target: {
artifact_sha256: node.identity.artifact_sha256,
semantic_node_id: node.node_id,
},
authority_references: [
{
evidence_id: source?.evidence_id,
authority: "controlled-replay",
state: "candidate",
location: `${semanticGraph.graph_id}/node/${node.node_id}`,
},
],
});
}
});
@@ -12,7 +12,6 @@ import type {
ReconstructionObligation,
ReviewedReconstructionObligation,
} from "../domain/reconstructionObligationLedgerSchemas.js";
import { resolveJavaScriptSemanticEvidence } from "../domain/javascript/javascriptSemanticGraph.js";
import { parseApplicationGraphEvidence } from "./javascript/JavaScriptApplicationEvidenceGraph.js";
import {
applicationObligationPolicy,
@@ -115,14 +114,21 @@ const deriveApplicationCandidates = (
}),
);
}
if (source.semanticGraph !== null)
if (source.semanticGraph !== null) {
const evidenceContexts = new Map(
source.semanticGraph.evidence_contexts.map((context) => [
context.context_id,
context,
]),
);
for (const node of source.semanticGraph.nodes) {
const candidatePolicy = semanticObligationPolicy(node.kind);
if (candidatePolicy === undefined) continue;
const evidenceContext = resolveJavaScriptSemanticEvidence(
source.semanticGraph,
node.evidence,
);
const evidenceContext = evidenceContexts.get(node.evidence.context_id);
if (evidenceContext === undefined)
throw new TypeError(
"Semantic evidence reference names an absent context",
);
addCandidate(
candidates,
generatedCandidate({
@@ -144,6 +150,7 @@ const deriveApplicationCandidates = (
}),
);
}
}
if (source.graph.coverage.status !== "complete")
limitations.add(
`Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`,
+22 -12
View File
@@ -1,10 +1,12 @@
import { createHash } from "node:crypto";
import { posix } from "node:path";
import { logicalPathEscapesRoot } from "../domain/artifactIdentity.js";
import {
logicalPathEscapesRoot,
normalizeJoinedLogicalPath,
} from "../domain/artifactIdentity.js";
createReferenceSourcePathLookup,
isPortableAbsoluteReferenceTarget,
type ReferenceSourcePathLookup,
} from "../domain/referenceSourcePathIdentity.js";
import {
classifyReferenceSourcePath,
detectReferenceSourceLanguage,
@@ -83,11 +85,11 @@ const failedEntry = (
const resolveInternalSpecifier = (
fromPath: string,
specifier: string,
filePaths: ReadonlySet<string>,
lookupPath: ReferenceSourcePathLookup,
): { to: string; resolution: "internal" | "unresolved" } => {
if (posix.isAbsolute(specifier))
return { to: specifier, resolution: "unresolved" };
const normalized = normalizeJoinedLogicalPath(
const normalized = posix.normalize(
posix.join(posix.dirname(fromPath), specifier),
);
if (logicalPathEscapesRoot(normalized))
@@ -113,7 +115,7 @@ const resolveInternalSpecifier = (
const stem = normalized.slice(0, -posix.extname(normalized).length);
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
}
const match = candidates.find((candidate) => filePaths.has(candidate));
const match = lookupPath.resolveCandidates(candidates);
return match === undefined
? { to: normalized, resolution: "unresolved" }
: { to: match, resolution: "internal" };
@@ -121,7 +123,7 @@ const resolveInternalSpecifier = (
const appendFile = (
entry: Extract<ReferenceSourceEntry, { status: "read"; kind: "file" }>,
filePaths: ReadonlySet<string>,
lookupPath: ReferenceSourcePathLookup,
output: ParsedReferenceSourceEntries,
): void => {
const language = detectReferenceSourceLanguage(entry.path);
@@ -146,7 +148,7 @@ const appendFile = (
const resolved = resolveInternalSpecifier(
relationship.from_path,
relationship.to,
filePaths,
lookupPath,
);
output.relationships.push({
...relationship,
@@ -159,11 +161,11 @@ const appendFile = (
const appendReadEntry = (
entry: Extract<ReferenceSourceEntry, { status: "read" }>,
filePaths: ReadonlySet<string>,
lookupPath: ReferenceSourcePathLookup,
output: ParsedReferenceSourceEntries,
): void => {
if (entry.kind === "file") {
appendFile(entry, filePaths, output);
appendFile(entry, lookupPath, output);
return;
}
const classifications = classifyReferenceSourcePath(entry.path);
@@ -180,7 +182,14 @@ const appendReadEntry = (
output.entries.push({
path: entry.path,
kind: "symlink",
target: entry.target,
// The reader resolves inventory identities from its root; the persisted
// graph represents relative symlink targets from the link's directory.
target:
entry.targetState === "internal" ||
(entry.targetState === "missing" &&
!isPortableAbsoluteReferenceTarget(entry.target))
? posix.relative(posix.dirname(entry.path), entry.target) || "."
: entry.target,
target_state: entry.targetState,
classifications,
limitations: [],
@@ -199,10 +208,11 @@ export const parseReferenceSourceEntries = (
parseFailures: [],
limitations: [...read.limitations],
};
const lookupPath = createReferenceSourcePathLookup(filePaths);
for (const entry of read.entries) {
if (signal?.aborted === true) break;
if (entry.status === "failed") output.entries.push(failedEntry(entry));
else appendReadEntry(entry, filePaths, output);
else appendReadEntry(entry, lookupPath, output);
}
return output;
};
+36 -9
View File
@@ -1,7 +1,8 @@
import { readFile, realpath, stat } from "node:fs/promises";
import { lstat, realpath, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import ignore from "ignore";
import { readRegularFileText } from "./RegularFileRead.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -47,14 +48,27 @@ const resolveRoot = async (
const buildIgnored = async (
root: string,
excludePaths: readonly string[],
signal?: AbortSignal,
): Promise<ReturnType<typeof ignore>> => {
const ignored = ignore();
const policyPath = join(root, ".gitignore");
let present = true;
try {
ignored.add(await readFile(join(root, ".gitignore"), "utf8"));
await lstat(policyPath);
} catch (cause: unknown) {
// Missing or unreadable ignore file does not authorize broader access.
void cause;
if (
!(
typeof cause === "object" &&
cause !== null &&
"code" in cause &&
cause.code === "ENOENT"
)
)
throw cause;
present = false;
}
signal?.throwIfAborted();
if (present) ignored.add(await readRegularFileText(policyPath, { signal }));
ignored.add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]);
for (const path of excludePaths) {
ignored.add(path);
@@ -71,9 +85,22 @@ export const prepareReferenceSourceImport = async (
> => {
const root = await resolveRoot(options.root);
if (!root.ok) return root;
return ok({
root: root.value,
ignored: await buildIgnored(root.value, options.excludePaths ?? []),
secrets: ignore().add([...options.policy.secretPatterns]),
});
try {
return ok({
root: root.value,
ignored: await buildIgnored(
root.value,
options.excludePaths ?? [],
options.signal,
),
secrets: ignore().add([...options.policy.secretPatterns]),
});
} catch (cause: unknown) {
return err(
failure(
options.signal?.aborted === true ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${cause instanceof Error ? cause.message : String(cause)}`,
),
);
}
};
+62 -17
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import { lstat, readFile } from "node:fs/promises";
import { lstat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { readRegularFile, readRegularFileText } from "./RegularFileRead.js";
export type ReferenceSourceVcsInfo =
| {
@@ -29,7 +30,7 @@ export const readReferenceSourceVcs = async (
: { kind: "unknown", head: null, dirty: null };
}
try {
const head = await resolveSourceHead(root);
const head = await resolveSourceHead(root, signal);
if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null };
return { kind: "git", head, dirty: null };
} catch (cause: unknown) {
@@ -39,13 +40,59 @@ export const readReferenceSourceVcs = async (
}
};
const resolveSourceHead = async (root: string): Promise<string> => {
const resolveSourceHead = async (
root: string,
signal?: AbortSignal,
): Promise<string> => {
// isomorphic-git is loaded on first use so CLI and MCP startup skip it.
const { resolveRef } = await import("isomorphic-git");
let failedRead: { readonly cause: unknown } | undefined;
const referenceFs = {
...fs,
promises: {
...fs.promises,
// isomorphic-git treats every read failure as absence. Preserve real
// failures so a readable packed ref cannot hide an unreadable loose ref.
readFile: async (
path: string | undefined,
options:
| BufferEncoding
| { readonly encoding?: BufferEncoding | null }
| null = null,
) => {
// The library probes readFile() without a path to detect promise APIs.
// Reject that invocation without attributing it to a source read.
if (path === undefined)
throw new TypeError("Git filesystem read requires a path");
try {
const bytes = await readRegularFile(path, { signal });
const encoding =
typeof options === "string" ? options : options?.encoding;
return encoding === undefined || encoding === null
? bytes
: bytes.toString(encoding);
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") failedRead ??= { cause };
throw cause;
}
},
},
};
const readRef = async (options: {
readonly gitdir?: string;
readonly ref: string;
}) => {
const head = await resolveRef({ fs: referenceFs, dir: root, ...options });
signal?.throwIfAborted();
if (failedRead !== undefined) throw failedRead.cause;
return head;
};
const dotgit = join(root, ".git");
if (!(await lstat(dotgit)).isFile())
return resolveRef({ fs, dir: root, ref: "HEAD" });
const pointer = (await readFile(dotgit, "utf8")).replace(/\r?\n$/u, "");
if (!(await lstat(dotgit)).isFile()) return readRef({ ref: "HEAD" });
const pointer = (await readRegularFileText(dotgit, { signal })).replace(
/\r?\n$/u,
"",
);
if (!pointer.startsWith("gitdir: ") || pointer.length === 8)
throw new Error("Invalid Git directory pointer");
const gitdir = resolve(root, pointer.slice(8));
@@ -53,25 +100,23 @@ const resolveSourceHead = async (root: string): Promise<string> => {
try {
commonDirectory = resolve(
gitdir,
(await readFile(join(gitdir, "commondir"), "utf8")).replace(
/\r?\n$/u,
"",
),
(
await readRegularFileText(join(gitdir, "commondir"), { signal })
).replace(/\r?\n$/u, ""),
);
} catch (cause: unknown) {
if (errorCode(cause) !== "ENOENT") throw cause;
return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" });
return readRef({ gitdir, ref: "HEAD" });
}
const head = (await readFile(join(gitdir, "HEAD"), "utf8")).trim();
if (!head.startsWith("ref: "))
return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" });
const head = (
await readRegularFileText(join(gitdir, "HEAD"), { signal })
).trim();
if (!head.startsWith("ref: ")) return readRef({ gitdir, ref: "HEAD" });
const ref = head.slice(5);
const privateRef = ["refs/bisect/", "refs/rewritten/", "refs/worktree/"].some(
(prefix) => ref.startsWith(prefix),
);
return resolveRef({
fs,
dir: root,
return readRef({
gitdir: privateRef ? gitdir : commonDirectory,
ref,
});
+49 -6
View File
@@ -1,20 +1,44 @@
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { lstat, stat, type FileHandle } from "node:fs/promises";
import { openRegularFile } from "../filesystem/RegularFile.js";
import {
openRegularFile,
sameRegularFileState,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
/** Filesystem admission policy for one selected regular input. */
export interface RegularFileReadOptions {
readonly signal?: AbortSignal | undefined;
readonly symlinks?: "follow" | "reject";
}
/** Admit one regular-file handle, retain it through the consumer, and always close it. */
export const withRegularFile = async <Value>(
path: string,
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
signal?: AbortSignal,
options: RegularFileReadOptions = {},
): Promise<Value> => {
const handle = await openRegularFile(path, { symlinks: "follow", signal });
const { signal } = options;
const handle = await openRegularFile(path, {
symlinks: options.symlinks ?? "follow",
signal,
});
try {
const stats = await handle.stat();
signal?.throwIfAborted();
const value = await read(handle, stats);
signal?.throwIfAborted();
const [opened, currentPath] = await Promise.all([
handle.stat(),
options.symlinks === "reject" ? lstat(path) : stat(path),
]);
if (
!sameRegularFileState(stats, opened) ||
!sameRegularFileState(stats, currentPath)
)
throw new RegularFileChangedError(path);
return value;
} finally {
await handle.close();
@@ -24,6 +48,25 @@ export const withRegularFile = async <Value>(
/** Admit a regular file without waiting for a pipe, then read its verified handle. */
export const readRegularFile = (
path: string,
signal?: AbortSignal,
options: RegularFileReadOptions = {},
): Promise<Buffer> =>
withRegularFile(path, (handle) => handle.readFile({ signal }), signal);
withRegularFile(
path,
async (handle, stats) => {
const bytes = await readBoundedFileBytes(
handle,
stats.size,
options.signal,
);
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return bytes;
},
options,
);
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
export const readRegularFileText = async (
path: string,
options: RegularFileReadOptions = {},
): Promise<string> => (await readRegularFile(path, options)).toString("utf8");
+59 -6
View File
@@ -29,6 +29,49 @@ export const runSetup = async (
options: SetupOptions,
host: SetupHost = systemSetupHost(),
confirm?: SetupConfirmation,
): Promise<SetupResult> => {
let result: SetupResult | undefined;
let primaryFailure: unknown;
let threw = false;
try {
result = await runSetupWorkflow(options, host, confirm);
} catch (cause: unknown) {
threw = true;
primaryFailure = cause;
}
let cleanupFailure: string | undefined;
try {
cleanupFailure = await host.close?.();
} catch (cause: unknown) {
cleanupFailure = `Setup resource cleanup failed: ${failureMessage(cause)}`;
}
if (threw) {
if (cleanupFailure !== undefined)
throw new AggregateError(
[primaryFailure, new Error(cleanupFailure)],
"Setup and resource cleanup failed.",
{ cause: primaryFailure },
);
throw primaryFailure;
}
if (result === undefined) throw new Error("Setup returned no result.");
if (cleanupFailure === undefined) return result;
return {
...result,
status: "needs_human",
code: result.code ?? "cleanup_failed",
remediation: result.remediation
? `${result.remediation} ${cleanupFailure}`
: cleanupFailure,
};
};
const runSetupWorkflow = async (
options: SetupOptions,
host: SetupHost,
confirm?: SetupConfirmation,
): Promise<SetupResult> => {
const appliedActions: string[] = [];
let plannedActions: readonly SetupAction[] = [];
@@ -59,6 +102,7 @@ export const runSetup = async (
options.readinessScope?.clients === undefined &&
options.allDetectedClients !== true &&
options.installHopper !== true &&
options.skillClientIds === undefined &&
options.installSkill === undefined;
const interactiveSelection =
confirm !== undefined && !options.structured && clientSelectionAllowed;
@@ -67,10 +111,12 @@ export const runSetup = async (
: (options.clientIds ??
options.readinessScope?.clients ??
discovery.defaultClientIds);
let skillNeedsInstall = await host.skillNeedsInstall(selectedClientIds);
let selectedSkillClientIds = options.skillClientIds ?? selectedClientIds;
let skillNeedsInstall = await host.skillNeedsInstall(selectedSkillClientIds);
let skillSelected =
options.installSkill === true ||
(options.installSkill !== false && selectedClientIds.length > 0);
(options.installSkill !== false &&
(selectedClientIds.length > 0 || selectedSkillClientIds.length > 0));
let installSkill = skillSelected && skillNeedsInstall;
let planDiscovery = discovery;
if (interactiveSelection) {
@@ -138,6 +184,7 @@ export const runSetup = async (
selectedClientIds.length === 0) ||
(options.installSkill !== false && selectedClientIds.length > 0);
installSkill = skillSelected && skillNeedsInstall;
selectedSkillClientIds = selectedClientIds;
}
const planned = await planSetupActions({
discovery: planDiscovery,
@@ -145,7 +192,7 @@ export const runSetup = async (
providerEnvironment,
command: host.registrationCommand,
clientIds: selectedClientIds,
skillClientIds: selectedClientIds,
skillClientIds: selectedSkillClientIds,
installSkill,
});
plannedActions = planned.plannedActions;
@@ -256,7 +303,7 @@ export const runSetup = async (
host,
options,
appliedActions,
selectedClientIds,
selectedSkillClientIds,
))
)
return fail(
@@ -325,8 +372,11 @@ const installHopperAction = async (input: {
emitProgress(input.options, {
actionId: "install_hopper",
label,
state: "completed",
detail: installed.launcherPath,
state: installed.cleanupFailure === undefined ? "completed" : "warning",
detail:
installed.cleanupFailure === undefined
? installed.launcherPath
: `${installed.launcherPath}; ${installed.cleanupFailure}`,
});
const providerEnvironment = {
...input.providerEnvironment,
@@ -492,3 +542,6 @@ const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
},
}),
});
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
+6 -5
View File
@@ -14,7 +14,7 @@ import {
type ClientRegistrationDialect,
} from "./ClientConfigurationDocument.js";
import { constants as fsConstants } from "node:fs";
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import writeFileAtomic from "write-file-atomic";
@@ -22,6 +22,7 @@ import { PRODUCT_IDENTITY } from "../identity.js";
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js";
import type {
ClientConfigurationInspection,
ClientConfigurationResult,
@@ -64,7 +65,7 @@ const configureClientDocument = async (
return { status: "failed", reason: "path" };
let original: string | undefined;
try {
original = await readFile(transactionPath, "utf8");
original = await readRegularFileText(transactionPath);
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
@@ -149,7 +150,7 @@ const configureClientDocument = async (
}
try {
const readback = parseClientConfiguration(
await readFile(transactionPath, "utf8"),
await readRegularFileText(transactionPath),
format,
);
if (!registrationCurrent(readback, desired)) {
@@ -178,7 +179,7 @@ export const clientConfigurationAligned = async (
try {
const files = await readClientConfigurationFiles(client);
if (!files.ok) return false;
const original = await readFile(client.configPath, "utf8");
const original = await readRegularFileText(client.configPath);
const parsed = parseClientConfiguration(original, client.format);
const policy = await readClientPolicyBlock(
client,
@@ -246,7 +247,7 @@ export const inspectClientConfiguration = async (
};
let original: string;
try {
original = await readFile(transactionPath, "utf8");
original = await readRegularFileText(transactionPath);
} catch (cause: unknown) {
if (isMissing(cause)) return { status: "create" };
return {
+32 -9
View File
@@ -94,6 +94,10 @@ export const systemSetupHost = (
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
const platform = doctorHost.platform;
const { homeDirectory } = doctorHost;
const macHopperHost =
platform === "darwin"
? systemMacHopperInstallHost(homeDirectory, environment)
: undefined;
return {
platform,
homeDirectory,
@@ -106,6 +110,7 @@ export const systemSetupHost = (
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution: readLinuxDistribution,
close: () => macHopperHost?.close() ?? Promise.resolve(undefined),
initialSetupState: async (
scope?: DoctorScope,
): Promise<SetupInitialState> => {
@@ -126,15 +131,33 @@ export const systemSetupHost = (
};
},
installHopper: async (replaceExisting) => {
const result =
platform === "linux"
? await installLinuxHopper(systemLinuxHopperInstallHost(environment))
: await installMacHopper(
{ replaceExisting },
systemMacHopperInstallHost(homeDirectory, environment),
);
if (result.status === "installed") return result;
return setupInstallFailure(result.reason);
if (platform === "linux") {
const result = await installLinuxHopper(
systemLinuxHopperInstallHost(environment),
);
return result.status === "installed"
? result
: setupInstallFailure(result.reason);
}
if (macHopperHost === undefined)
return setupInstallFailure("unsupported_host");
const result = await installMacHopper({ replaceExisting }, macHopperHost);
if (result.status === "installed")
return {
status: "installed",
launcherPath: result.launcherPath,
...(result.cleanupFailure === undefined
? {}
: { cleanupFailure: result.cleanupFailure }),
};
const failure = setupInstallFailure(result.reason);
if (result.cleanupFailure === undefined) return failure;
return failure.status === "failed"
? {
...failure,
remediation: `${failure.remediation} ${result.cleanupFailure}`,
}
: failure;
},
detectedClients: () => detectClients(homeDirectory, platform, environment),
supportedClients: () =>
+7 -2
View File
@@ -10,10 +10,15 @@ export type SetupFailureCode =
| "destination_exists"
| "mount_failed"
| "bundle_invalid"
| "copy_failed";
| "copy_failed"
| "cleanup_failed";
export type SetupHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
| {
readonly status: "installed";
readonly launcherPath: string;
readonly cleanupFailure?: string;
}
| {
readonly status: "failed";
readonly code: SetupFailureCode;
+59 -7
View File
@@ -1,11 +1,14 @@
import { mkdir, readFile, rm } from "node:fs/promises";
import { copyFile, lstat, mkdir, readFile, rm } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { dirname, join } from "node:path";
import writeFileAtomic from "write-file-atomic";
import { PRODUCT_IDENTITY } from "../identity.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
import { readRegularFileText } from "./RegularFileRead.js";
import { clientSkillDirectories } from "./SupportedClients.js";
const SKILL_FILES = [
export const MANAGED_SKILL_FILES = [
"SKILL.md",
"references/native-and-artifacts.md",
"references/javascript-applications.md",
@@ -14,6 +17,13 @@ const SKILL_FILES = [
"references/evidence-workflows.md",
] as const;
export const isManagedSkillManifest = (content: string): boolean =>
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
export const readSkillFile = (path: string): Promise<string> =>
readRegularFileText(path, { symlinks: "reject" });
interface CanonicalSkillFile {
readonly content: string;
readonly destination: string;
@@ -47,9 +57,38 @@ export const skillDestinations = (
}),
);
/** Resolve existing REA-owned skill roots using setup's canonical destinations. */
export const existingSkillDestinations = async (
home: string,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<readonly SkillDestination[]> => {
const existing: SkillDestination[] = [];
for (const destination of skillDestinations(
home,
undefined,
environment,
platform,
)) {
try {
const root = await lstat(destination.path);
if (root.isSymbolicLink() || !root.isDirectory()) continue;
const manifest = join(destination.path, "SKILL.md");
const content = await readSkillFile(manifest);
if (isManagedSkillManifest(content)) existing.push(destination);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
continue;
if (cause instanceof NonRegularFileReadError) continue;
throw cause;
}
}
return existing;
};
const readOptionalText = async (path: string): Promise<string | undefined> => {
try {
return await readFile(path, "utf8");
return await readSkillFile(path);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return undefined;
@@ -66,7 +105,7 @@ const canonicalSkillFiles = async (
Promise.all(
skillDestinations(home, clientIds, environment, platform).flatMap(
({ path: root }) =>
SKILL_FILES.map(async (relativePath) => {
MANAGED_SKILL_FILES.map(async (relativePath) => {
const destination = join(root, relativePath);
return {
destination,
@@ -104,6 +143,19 @@ export const canonicalSkillNeedsInstall = async (
const writeText = (path: string, content: string): Promise<void> =>
writeFileAtomic(path, content, { encoding: "utf8", mode: 0o600 });
const preserveSkillBackup = async (
source: string,
destination: string,
): Promise<void> => {
try {
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
} catch (cause: unknown) {
if (cause instanceof Error && "code" in cause && cause.code === "EEXIST")
return;
throw cause;
}
};
const restoreSkillFiles = async (
changed: readonly CanonicalSkillFile[],
): Promise<void> => {
@@ -134,12 +186,12 @@ export const installCanonicalSkill = async (
for (const { destination, original } of changed) {
await mkdir(dirname(destination), { recursive: true });
if (original !== undefined)
await writeText(`${destination}.rea.backup`, original);
await preserveSkillBackup(destination, `${destination}.rea.backup`);
}
for (const { destination, content } of changed)
await writeText(destination, content);
for (const { destination, content } of changed)
if ((await readFile(destination, "utf8")) !== content)
if ((await readSkillFile(destination)) !== content)
throw new Error(`skill readback mismatch: ${destination}`);
return "installed";
} catch (cause: unknown) {
@@ -171,7 +223,7 @@ export const readInstalledSkillIdentity = async (
let content: string | undefined;
for (const destination of destinations) {
try {
content = await readFile(join(destination.path, "SKILL.md"), "utf8");
content = await readSkillFile(join(destination.path, "SKILL.md"));
break;
} catch {
// Another selected copy may provide metadata; canonical validation below
+4
View File
@@ -50,6 +50,8 @@ export interface SetupHost {
linuxDistribution(): Promise<LinuxDistribution | undefined>;
initialSetupState(scope?: DoctorScope): Promise<SetupInitialState>;
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
/** Retry and report any cleanup resources still owned by this setup host. */
close?(): Promise<string | undefined>;
detectedClients(): Promise<readonly SetupClient[]>;
supportedClients(): Promise<readonly SetupClient[]>;
configureClient(
@@ -155,6 +157,8 @@ export interface SetupOptions {
readonly structured: boolean;
readonly proposeHopper?: boolean;
readonly clientIds?: readonly string[];
/** Skill roots to update independently of MCP registrations (maintenance). */
readonly skillClientIds?: readonly string[];
readonly installSkill?: boolean;
readonly readinessScope?: DoctorScope;
readonly dryRun?: boolean;
+66 -29
View File
@@ -1,6 +1,7 @@
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { lstatSync } from "node:fs";
import { err, ok, type Result } from "../domain/result.js";
import {
HermesClientPathError,
resolveHermesClientHome,
@@ -39,7 +40,15 @@ export interface SetupClient {
| "unsupported";
}
type ClientPath = readonly string[] | ((paths: ClientPathContext) => string);
interface ClientPathFailure {
readonly path: string;
readonly message: string;
}
type ClientPathResult = Result<string, ClientPathFailure>;
type ClientPath =
| readonly string[]
| ((paths: ClientPathContext) => string | ClientPathResult);
interface ClientPathContext {
readonly home: string;
@@ -146,34 +155,32 @@ export const manualRegistrationRemediation = (
: undefined;
const OMP_PROFILE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
/**
* OMP's active named profile. OMP_PROFILE wins over the legacy PI_PROFILE even
* when empty; empty and "default" select the default profile. OMP refuses to
* start with an invalid name, so one leaves the default location in place.
*/
const ompProfile = ({ env }: ClientPathContext): string | undefined => {
const name = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim();
return name === undefined ||
name === "" ||
name === "default" ||
name.endsWith(".") ||
!OMP_PROFILE_NAME.test(name)
? undefined
: name;
};
const OMP_RESERVED_PROFILE =
/^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/iu;
/**
* The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP
* joins PI_CONFIG_DIR to the home directory, and a named profile ignores
* PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory.
*/
const ompAgentDirectory = (context: ClientPathContext): string => {
const ompAgentDirectory = (context: ClientPathContext): ClientPathResult => {
const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp");
const profile = ompProfile(context);
if (profile !== undefined) return join(root, "profiles", profile, "agent");
// OMP_PROFILE wins even when empty. Match OMP's validation on every host.
const profile = (context.env.OMP_PROFILE ?? context.env.PI_PROFILE)?.trim();
if (profile !== undefined && profile !== "" && profile !== "default") {
if (
!OMP_PROFILE_NAME.test(profile) ||
profile.endsWith(".") ||
OMP_RESERVED_PROFILE.test(profile)
)
return err({
path: root,
message: `Invalid OMP profile ${JSON.stringify(profile)}: names must match ^[a-z0-9][a-z0-9._-]{0,63}$, cannot end with ".", and cannot be a Windows reserved device name (CON, PRN, AUX, NUL, COM0-9, LPT0-9, including extensions). Repair OMP_PROFILE or PI_PROFILE before configuring OMP.`,
});
return ok(join(root, "profiles", profile, "agent"));
}
const override = context.env.PI_CODING_AGENT_DIR;
return override ? resolve(override) : join(root, "agent");
return ok(override ? resolve(override) : join(root, "agent"));
};
/**
@@ -456,8 +463,10 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
{
name: "omp",
displayName: "OMP",
configPath: (context: ClientPathContext) =>
join(ompAgentDirectory(context), "mcp.json"),
configPath: (context: ClientPathContext) => {
const directory = ompAgentDirectory(context);
return directory.ok ? ok(join(directory.value, "mcp.json")) : directory;
},
markerPath: ompAgentDirectory,
format: "omp",
},
@@ -508,8 +517,14 @@ export const clientEvidencePaths = (
),
];
const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
typeof path === "function" ? path(context) : join(context.home, ...path);
const resolvePath = (
path: ClientPath,
context: ClientPathContext,
): ClientPathResult => {
const resolved =
typeof path === "function" ? path(context) : join(context.home, ...path);
return typeof resolved === "string" ? ok(resolved) : resolved;
};
/** Describe every client location that setup, doctor, or uninstall may inspect. */
export const supportedClients = (
@@ -521,7 +536,27 @@ export const supportedClients = (
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
return definitions.map((definition) => {
try {
const configPath = resolvePath(definition.configPath, context);
const config = resolvePath(definition.configPath, context);
const marker = config.ok
? resolvePath(definition.markerPath, context)
: config;
if (!config.ok)
return {
name: definition.name,
displayName: definition.displayName,
format: definition.format,
configPath: config.error.path,
configPathError: config.error.message,
};
if (!marker.ok)
return {
name: definition.name,
displayName: definition.displayName,
format: definition.format,
configPath: marker.error.path,
configPathError: marker.error.message,
};
const configPath = config.value;
return {
name: definition.name,
displayName: definition.displayName,
@@ -536,7 +571,7 @@ export const supportedClients = (
...new Set([...definition.configPaths(context), configPath]),
],
}),
markerPath: resolvePath(definition.markerPath, context),
markerPath: marker.value,
format: definition.format,
};
} catch (cause: unknown) {
@@ -584,14 +619,16 @@ export const clientSkillDirectories = (
const definition = definitions.find(({ name }) => name === client);
let directory: string;
try {
directory =
const resolved =
definition?.skillPath === undefined
? join(home, ".agents", "skills")
? ok(join(home, ".agents", "skills"))
: resolvePath(definition.skillPath, {
home,
platform,
env: environment,
});
if (!resolved.ok) continue;
directory = resolved.value;
} catch (cause: unknown) {
// Registration diagnostics own an unresolved profile; never fall back to another skill root.
if (cause instanceof HermesClientPathError) continue;
+155 -6
View File
@@ -6,7 +6,7 @@ import {
withClientServers,
type ClientConfigurationDocument,
} from "./ClientConfigurationDocument.js";
import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises";
import { copyFile, lstat, realpath, rm } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { join } from "node:path";
@@ -15,9 +15,15 @@ import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { skillDestinations } from "./SetupSkill.js";
import {
isManagedSkillManifest,
MANAGED_SKILL_FILES,
readSkillFile,
skillDestinations,
} from "./SetupSkill.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import { readRegularFileText } from "./RegularFileRead.js";
import {
manualRegistrationRemediation,
supportedClients,
@@ -27,29 +33,32 @@ import {
interface ManagedPathStats {
readonly uid?: number;
isFile?(): boolean;
isDirectory?(): boolean;
isSymbolicLink(): boolean;
}
/** Injectable filesystem operations used to prove uninstall failure recovery. */
export interface UninstallFileSystem {
readText(path: string): Promise<string>;
readRegularText?(path: string): Promise<string>;
/** Preserve the first backup; reject an existing destination with EEXIST. */
copy(source: string, destination: string): Promise<void>;
writeText(path: string, contents: string): Promise<void>;
stat(path: string): Promise<ManagedPathStats>;
realpath?(path: string): Promise<string>;
remove(path: string): Promise<void>;
remove(path: string, recursive?: boolean): Promise<void>;
}
const systemFileSystem: UninstallFileSystem = {
readText: (path) => readFile(path, "utf8"),
readText: (path) => readRegularFileText(path),
readRegularText: readSkillFile,
copy: (source, destination) =>
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
writeText: (path, contents) =>
writeFileAtomic(path, contents, { encoding: "utf8" }),
stat: (path) => lstat(path),
realpath: (path) => realpath(path),
remove: (path) => rm(path, { recursive: true }),
remove: (path, recursive = true) => rm(path, { recursive }),
};
/** One explicitly classified uninstall action. */
@@ -422,7 +431,7 @@ const removeManagedSkills = async (
const results = await Promise.all(
skillDestinations(home, undefined, environment, platform).map(
({ client, path }) =>
removeManagedPath(
removeManagedSkill(
path,
client === "claude_code" ? "Claude Code skill" : "skill",
fileSystem,
@@ -457,6 +466,146 @@ const removeManagedSkills = async (
);
};
const removeManagedSkill = async (
path: string,
name: string,
fileSystem: UninstallFileSystem,
): Promise<UninstallItem> => {
let root: ManagedPathStats;
try {
root = await fileSystem.stat(path);
} catch (cause: unknown) {
return isMissing(cause)
? item(name, "skipped", "Skill directory does not exist.")
: item(name, "failed", "Skill directory could not be inspected.");
}
if (root.isSymbolicLink())
return item(name, "retained", "Skill directory is a symbolic link.");
if (root.isDirectory?.() !== true)
return item(name, "retained", "Skill path is not a directory.");
const manifestPath = join(path, "SKILL.md");
try {
const manifest = await fileSystem.stat(manifestPath);
if (manifest.isSymbolicLink())
return item(name, "retained", "Skill manifest is a symbolic link.");
if (manifest.isFile?.() !== true)
return item(name, "retained", "Skill manifest is not a regular file.");
} catch (cause: unknown) {
return isMissing(cause)
? item(name, "retained", "Skill ownership could not be established.")
: item(name, "failed", "Skill manifest could not be inspected.");
}
let content: string;
try {
content = await (fileSystem.readRegularText?.(manifestPath) ??
fileSystem.readText(manifestPath));
} catch (cause: unknown) {
return isMissing(cause)
? item(name, "retained", "Skill ownership could not be established.")
: item(name, "failed", "Skill manifest could not be read.");
}
if (!isManagedSkillManifest(content))
return item(name, "retained", "Skill manifest is not REA-owned.");
const manifestName = "SKILL.md";
const otherFiles = MANAGED_SKILL_FILES.filter(
(relativePath) => relativePath !== manifestName,
);
const results: UninstallItem[] = [];
for (const relativePath of otherFiles)
results.push(
await removeManagedSkillFile(path, relativePath, name, fileSystem),
);
if (
results.some(({ status }) => status === "failed" || status === "retained")
)
results.push(
item(
name,
"retained",
"REA ownership manifest was retained because a managed file could not be safely removed.",
),
);
else
results.push(
await removeManagedSkillFile(path, manifestName, name, fileSystem),
);
const failed = results.find(({ status }) => status === "failed");
if (failed !== undefined)
return item(name, "failed", results.map(({ detail }) => detail).join(" "));
const removed = results.some(({ status }) => status === "removed");
return item(
name,
removed ? "removed" : "skipped",
"REA-managed skill files were removed; other files were preserved.",
);
};
const removeManagedSkillFile = async (
root: string,
relativePath: string,
name: string,
fileSystem: UninstallFileSystem,
): Promise<UninstallItem> => {
const segments = relativePath.split(/[\\/]/u);
let parent = root;
for (const segment of segments.slice(0, -1)) {
parent = join(parent, segment);
try {
const stats = await fileSystem.stat(parent);
if (stats.isSymbolicLink() || stats.isDirectory?.() !== true)
return item(
name,
"retained",
`Managed path parent was retained: ${parent}.`,
);
} catch (cause: unknown) {
return isMissing(cause)
? item(
name,
"skipped",
`${join(parent, segments.at(-1) ?? "")} does not exist.`,
)
: item(
name,
"failed",
`Managed path parent could not be inspected: ${parent}.`,
);
}
}
const target = join(root, relativePath);
let stats: ManagedPathStats;
try {
stats = await fileSystem.stat(target);
} catch (cause: unknown) {
return isMissing(cause)
? item(name, "skipped", `${target} does not exist.`)
: item(name, "failed", `Managed file could not be inspected: ${target}.`);
}
if (stats.isSymbolicLink())
return item(
name,
"retained",
`Managed file is a symbolic link: ${target}.`,
);
if (stats.isFile?.() !== true)
return item(
name,
"retained",
`Managed path is not a regular file: ${target}.`,
);
try {
await fileSystem.remove(target, false);
return item(name, "removed", `Removed ${target}.`);
} catch (cause: unknown) {
return isMissing(cause)
? item(name, "skipped", `${target} does not exist.`)
: item(name, "failed", `Managed file could not be removed: ${target}.`);
}
};
const item = (
name: string,
status: UninstallItem["status"],
+27 -23
View File
@@ -1,5 +1,3 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
@@ -12,13 +10,19 @@ import {
parseClientConfiguration,
} from "./ClientConfigurationDocument.js";
import { supportedClients } from "./SupportedClients.js";
import { existingSkillDestinations } from "./SetupSkill.js";
import type { SetupAction } from "./SetupTypes.js";
import type { Result } from "../domain/result.js";
import { readRegularFileText } from "./RegularFileRead.js";
/** Existing REA integrations selected for maintenance, without discovering new targets. */
export interface MaintenanceScope {
readonly clients: readonly string[];
readonly skill: boolean;
readonly skillDestinations?: readonly {
readonly client: string;
readonly path: string;
}[];
}
/** Unapplied maintenance evidence produced by the updated setup executable. */
@@ -49,7 +53,7 @@ const setupPlanSchema = z.object({
),
});
/** Discover owned registrations and an existing REA skill without writes. */
/** Discover owned registrations and REA skills without writes. */
export const existingMaintenanceScope = async (
home: string,
entryPoint: string,
@@ -69,7 +73,7 @@ export const existingMaintenanceScope = async (
const client = supported.find(({ name }) => name === registration.client);
if (client === undefined) continue;
const parsed = parseClientConfiguration(
await readFile(client.configPath, "utf8"),
await readRegularFileText(client.configPath),
client.format,
);
const enabled = z
@@ -87,20 +91,12 @@ export const existingMaintenanceScope = async (
)
clients.push(client.name);
}
let skill = false;
try {
const content = await readFile(
join(home, ".agents", "skills", PRODUCT_IDENTITY.skillName, "SKILL.md"),
"utf8",
);
skill =
content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) ||
content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`);
} catch (cause: unknown) {
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
throw cause;
}
return { clients, skill };
const skillDestinations = await existingSkillDestinations(home, environment);
return {
clients,
skill: skillDestinations.length > 0,
skillDestinations,
};
};
/** Ask the verified new executable to plan only existing REA integrations. */
@@ -112,6 +108,7 @@ export const planIntegrationMaintenance = async (
): Promise<IntegrationMaintenance> => {
try {
const scope = await existingMaintenanceScope(home, entryPoint, environment);
const skillDestinations = scope.skillDestinations ?? [];
if (scope.clients.length === 0 && !scope.skill)
return { status: "current", plannedActions: [] };
const command = [
@@ -119,6 +116,7 @@ export const planIntegrationMaintenance = async (
entryPoint,
"setup",
...scope.clients.flatMap((client) => ["--client", client]),
...skillDestinations.flatMap(({ client }) => ["--skill-client", client]),
`--skill=${String(scope.skill)}`,
];
const execution = await execute([...command, "--dry-run", "--json"]);
@@ -132,11 +130,17 @@ export const planIntegrationMaintenance = async (
};
if (plan.data.plannedActions.length === 0)
return { status: "current", plannedActions: [] };
const allowedIds = new Set([
...scope.clients.map((client) => `configure_client:${client}`),
...(scope.skill ? ["install_skill"] : []),
]);
if (plan.data.plannedActions.some(({ id }) => !allowedIds.has(id)))
const allowedIds = new Set(
scope.clients.map((client) => `configure_client:${client}`),
);
const skillTargets = new Set(skillDestinations.map(({ path }) => path));
if (
plan.data.plannedActions.some((action) =>
action.kind === "install_skill"
? !skillTargets.has(action.target)
: !allowedIds.has(action.id),
)
)
return {
status: "unavailable",
remediation:
+6 -2
View File
@@ -27,7 +27,10 @@ import { WebScriptExportError } from "../domain/webScriptExportError.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import { WEB_SCRIPT_EXPORT_PROVIDER as PROVIDER } from "./InvestigationProviders.js";
import { readRegularFile } from "./RegularFileRead.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
import {
NonRegularFileReadError,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
const OPERATION = "export_web_scripts";
@@ -153,7 +156,7 @@ const readCapture = async (
signal: AbortSignal | undefined,
): Promise<Result<Buffer, AnalysisError>> => {
try {
return ok(await readRegularFile(path, signal));
return ok(await readRegularFile(path, { signal }));
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? String(cause.code) : "";
@@ -163,6 +166,7 @@ const readCapture = async (
);
if (
cause instanceof NonRegularFileReadError ||
cause instanceof RegularFileChangedError ||
code === "ENOENT" ||
code === "ENOTDIR" ||
code === "EISDIR" ||
@@ -28,6 +28,7 @@ export interface WebSourceLocationArtifactPort {
/** Replaceable codec receives map data and one explicit generated point. */
export interface WebSourceMapPort {
close?(): Promise<void>;
trace(
input: {
readonly text: string;
@@ -59,6 +59,10 @@ export class WebSourceLocationService {
readonly decoder: WebSourceMapPort,
) {}
close(): Promise<void> {
return this.decoder.close?.() ?? Promise.resolve();
}
/** Shared CLI/MCP workflow preserves raw declarations and every equal-position match. */
async trace(
rawInput: unknown,
@@ -0,0 +1,23 @@
import type {
AnalysisExecution,
ExecutionOptions,
ProviderAvailability,
} from "../AnalysisProvider.js";
import type { AdbRequest } from "../../domain/adb/adbDeviceAnalysis.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Result } from "../../domain/result.js";
/**
* ADB device inspection boundary over a caller-selected adb binary.
* Implementation details belong to the provider adapter.
*/
export interface AdbDeviceAnalysisPort {
/** Probe the selected adb binary without contacting devices. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
/** Terminally stop admission, drain work, and retry retained package rollback. */
close(): Promise<void>;
execute(
request: AdbRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
}
@@ -0,0 +1,96 @@
import { expect, it } from "vitest";
import { AdbDeviceAnalysisService } from "./AdbDeviceAnalysisService.js";
import type { AdbDeviceAnalysisPort } from "./AdbDeviceAnalysisPort.js";
import { createAnalysisExecution } from "../AnalysisProvider.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { parseEvidence } from "../../domain/evidence.js";
import { ok } from "../../domain/result.js";
const identity = { id: "adb", name: "Android Debug Bridge", version: null };
const execution = createAnalysisExecution(
{
client: {
binary_path: "/usr/bin/adb",
path_source: "path",
version: "34.0.5-debian",
installed_path: "/usr/bin/adb",
},
devices: [],
may_have_started_adb_server: false,
},
identity,
);
const refusedPort: AdbDeviceAnalysisPort = {
async inspectAvailability() {
throw new Error("must not probe");
},
async close() {},
async execute() {
throw new Error("must not execute");
},
};
it.each([
[{ serial: "" }],
[{ serial: "has space" }],
[{ serial: "emulator-5554", scope: "unknown" }],
[{ serial: "emulator-5554", extra: true }],
])("rejects invalid device selection before any effect: %j", async (input) => {
const service = new AdbDeviceAnalysisService(refusedPort);
expect(await service.execute("inspect_adb_device", input)).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it("rejects an unknown operation before any effect", async () => {
const service = new AdbDeviceAnalysisService(refusedPort);
expect(
await service.execute("inspect_adb_device" as never, {}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it("composes observed Evidence with the caller parameters inline", async () => {
const port: AdbDeviceAnalysisPort = {
async inspectAvailability() {
return { status: "available", code: null, reason: null, diagnostics: {} };
},
async close() {},
async execute(request) {
expect(request).toEqual({
operation: "list_adb_devices",
input: {},
});
return ok(execution);
},
};
const result = await new AdbDeviceAnalysisService(port).execute(
"list_adb_devices",
{},
);
if (!result.ok) throw new Error(result.error.message);
const evidence = parseEvidence(result.value);
expect(evidence.provider).toEqual(identity);
expect(evidence.operation).toBe("list_adb_devices");
expect(evidence.parameters).toEqual({});
expect(evidence.confidence).toBe("observed");
});
it("preserves cancellation before execution", async () => {
const controller = new AbortController();
controller.abort();
const service = new AdbDeviceAnalysisService(refusedPort);
const result = await service.execute(
"list_adb_devices",
{},
{
signal: controller.signal,
},
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
@@ -0,0 +1,31 @@
import type { AdbDeviceAnalysisPort } from "./AdbDeviceAnalysisPort.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
adbRequestSchema,
type AdbOperation,
} from "../../domain/adb/adbDeviceAnalysis.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import type { Result } from "../../domain/result.js";
import { executeProviderOperation } from "../ProviderOperationExecution.js";
/** Shared CLI/MCP admission, execution, and Evidence composition for ADB. */
export class AdbDeviceAnalysisService {
constructor(readonly provider: AdbDeviceAnalysisPort) {}
/** Run one caller-selected ADB request and retain device provenance inline. */
async execute(
operation: AdbOperation,
input: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
return executeProviderOperation(
operation,
input,
options,
adbRequestSchema,
this.provider,
"observed",
);
}
}
@@ -1,4 +1,4 @@
import { expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
@@ -9,7 +9,13 @@ import {
analysisViewJavaScriptEvidence,
analysisViewLayoutEvidence,
} from "../../../tests/fixtures/analysisView.js";
import { inspectAnalysisView } from "./AnalysisViewService.js";
import {
inspectAnalysisView,
summarizeRetainedAnalysis,
} from "./AnalysisViewService.js";
import type { Evidence } from "../../domain/evidence.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { err, ok } from "../../domain/result.js";
it.each(["/fixtures/unknown.exe", ""])(
"preserves recorded empty native identity fields: path=%j",
@@ -266,3 +272,47 @@ it("rejects contradictory subject and analysis artifact digests", () => {
error: { _tag: "EvidenceIntegrityError" },
});
});
describe("summarizeRetainedAnalysis", () => {
const parent = analysisViewJavaScriptEvidence(
analysisViewJavaScriptAnalysisWithSource(),
);
it("retains the complete Evidence before projecting its summary", () => {
const retained = new Map<string, Evidence>();
const summary = summarizeRetainedAnalysis(parent, {
recordEvidence: (evidence) => {
retained.set(evidence.evidence_id, evidence);
return ok("added");
},
evidenceById: (evidenceId) => retained.get(evidenceId),
});
if (!summary.ok) throw summary.error;
expect(retained.get(parent.evidence_id)).toBe(parent);
expect(summary.value.evidence_links).toEqual([parent.evidence_id]);
expect(summary.value.normalized_result).toMatchObject({
kind: "summary",
parent_evidence_id: parent.evidence_id,
parent_operation: "analyze_javascript_application",
});
});
it("refuses a summary whose complete Evidence cannot be retained", () => {
const unavailable = summarizeRetainedAnalysis(parent, {
recordEvidence: undefined,
evidenceById: undefined,
});
expect(unavailable.ok).toBe(false);
if (unavailable.ok) return;
expect(unavailable.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(unavailable.error.userMessage).toContain("detail complete");
const conflict = new EvidenceIntegrityError("ledger conflict");
expect(
summarizeRetainedAnalysis(parent, {
recordEvidence: () => err(conflict),
evidenceById: () => parent,
}),
).toEqual(err(conflict));
});
});

Some files were not shown because too many files have changed in this diff Show More