Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e2ed9aa2 | ||
|
|
1ae4bec45b | ||
|
|
058bc0af35 | ||
|
|
dce81ca400 | ||
|
|
bb764d26bd | ||
|
|
acd8d86898 | ||
|
|
e6d0b3993d | ||
|
|
92a901e0db | ||
|
|
cef6d6d3c4 | ||
|
|
bd972bf80b | ||
|
|
ac9709a80a | ||
|
|
d1e6fe2eca | ||
|
|
a147ecd4b4 | ||
|
|
22fdcb8ab7 | ||
|
|
b22d817073 | ||
|
|
3d876c5c1a | ||
|
|
cf346368c4 | ||
|
|
e52c0a72c5 | ||
|
|
999e3e2098 | ||
|
|
c1217e711b | ||
|
|
0d89978333 | ||
|
|
11e3fae697 | ||
|
|
603d04ee3a | ||
|
|
472f313232 | ||
|
|
1f7e279aa2 | ||
|
|
db8ccaa251 | ||
|
|
5daff4d67e | ||
|
|
a9fd1aecae | ||
|
|
abb8e81d3a | ||
|
|
6d8f95e38d | ||
|
|
c1b5ccf0a4 | ||
|
|
5f30ea6ff2 | ||
|
|
d438675677 | ||
|
|
a84dea5500 | ||
|
|
2a66ec9094 | ||
|
|
d1235fadc9 | ||
|
|
a01148553e | ||
|
|
a6f76f5da9 | ||
|
|
2ca3ad2386 | ||
|
|
5d0a883605 | ||
|
|
0f0d6e4d43 | ||
|
|
6d4cefc4f7 | ||
|
|
2a0b58e440 | ||
|
|
6543405252 | ||
|
|
cca7bf0022 | ||
|
|
bb742110a4 | ||
|
|
a97bd5ed29 | ||
|
|
bddddccdee | ||
|
|
96fd98cce0 | ||
|
|
749e58772a | ||
|
|
ded59689ce | ||
|
|
0363e5a8e1 | ||
|
|
905e4866c4 | ||
|
|
f6009d0077 | ||
|
|
4d8d76b253 | ||
|
|
162f9bf7ff | ||
|
|
fef6f73064 | ||
|
|
efd81f9776 | ||
|
|
5156ba44c5 | ||
|
|
c19e15a2b3 | ||
|
|
649661dc8b | ||
|
|
e4cc0f09de | ||
|
|
a53689c55d |
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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 فایلهای ارائهشده را بدون اجرای برنامه میخواند. ضبط زمان اجرا، هدف انتخابشده را با مجوزهای کاربری شما اجرا میکند یا با آن تعامل دارد؛ آثار این عملیات در راهنمای مربوط به هر نوع ضبط توضیح داده شدهاند.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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 の検査は、指定されたファイルを読み取り、アプリケーションを実行しません。実行時キャプチャは、あなたのユーザー権限で対象を実行したり、対象とやり取りしたりします。それぞれの実行時ガイドに、どのような操作が行われるかを記載しています。
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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") } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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));
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 & b <c></string></resources>';
|
||||
expect(parseDecodedStrings(stringsXml).entries[0]?.value).toBe(
|
||||
"a & b <c>",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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: "你好" }]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user