mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
feat(adb): complete ADB device support for Android reverse engineering (#1552)
* feat(adb): acquire installed APK sets from connected Android devices Add a read-only ADB device-acquisition family over the caller's adb binary: inspect_adb_client, list_adb_devices, inspect_adb_device, list_adb_packages, and pull_adb_package, exposed through the CLI and MCP with shared admission, Evidence provenance (binary, version, serial, transport), and fixed argv vectors that never reach a shell. pull_adb_package acquires the complete split set reported by pm path into a package-named directory that must not exist, digesting every file with SHA-256 and keeping per-file failure reasons and partial coverage. Device kind is reported as an inference with its basis; getprop whitelisting keeps build identity bounded. REA installs nothing and never manages devices or emulators; the first device query may start the caller's local adb server, recorded when observed. Verified against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: full 224-package inventory, a real two-APK split set pulled with byte-exact digests, and a 73.9 MB system-package pull. npm run verify:adb records the lane; parser goldens come from the same device. READMEs carry the new row in every supported language. * feat(adb): complete the adb surface with observation, transfer, and lifecycle tools Extend the ADB family from five to twenty-four operations, all through fixed adb argument vectors with no shell exposure: Observation: read_adb_logcat (bounded dump, count/buffer/pid), inspect_adb_package (dumpsys package projection), list_adb_processes (ps -A), list_adb_directory (ls -la with kinds and symlink targets), list_adb_features (hex GL versions), list_adb_services (AIDL slash-suffixed names), inspect_adb_display (wm size and density), inspect_adb_window (focus declarations), read_adb_setting (device-side nulls reported as such), capture_adb_screen (exec-out screencap with PNG dimensions), collect_adb_bugreport (digested device archive). Transfer and search: pull_adb_file and push_adb_file (device-side sha256sum verification, explicit overwrite authority), resolve_adb_packages (substring package search with exact-match flag — the find step of an easy get). Lifecycle for dynamic analysis, each declaring mutatesTarget: install_adb_package and uninstall_adb_package preserving the device's Success/Failure verdict, start_adb_app (resolve-activity then am start -W), start_adb_activity (typed extras as --es/--ez/--ei/--el/--ef), and stop_adb_app (am force-stop). Verified end-to-end against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: 287 services, 92 features, split-set pulls with byte-exact digests, verified push/pull roundtrip, sized screen capture, and the full install → resolve → start → observe-in-ps → force-stop → uninstall lifecycle. Fixed a v-mode regex incompatibility in the settings key pattern and added argument descriptions required by the CLI catalog audit. * fix(adb): quote remote shell operands and protect captured output * test(adb): cover bugreport destinations containing spaces --------- Co-authored-by: REA Contributor <[email protected]> Co-authored-by: morluto <[email protected]>
This commit is contained in:
co-authored by
REA Contributor
morluto
parent
efd81f9776
commit
fef6f73064
@@ -158,6 +158,7 @@ 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) |
|
||||
| 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,7 @@ 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) |
|
||||
| البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | 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,7 @@ 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) |
|
||||
| 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,7 @@ 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -134,6 +134,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
|
||||
| دادههای ضبطشدهٔ شبکه | درخواستها، پاسخها، 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -143,6 +143,7 @@ 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) |
|
||||
| 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,7 @@ 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -142,6 +142,7 @@ 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) |
|
||||
| ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | 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,7 @@ 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) |
|
||||
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | 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,7 @@ 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) |
|
||||
| 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,7 @@ 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) |
|
||||
| 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,7 @@ 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) |
|
||||
| Прошивки | Области, результаты извлечения и передача в нативный анализ | 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,7 @@ 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) |
|
||||
| เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | 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,7 @@ 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) |
|
||||
| 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,7 @@ 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) |
|
||||
| Прошивки | Області, результати видобування й передавання до нативного аналізу | 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,7 @@ 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) |
|
||||
| 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,7 @@ 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) |
|
||||
| 韌體 | 區域、提取結果和轉交原生分析的內容 | 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,7 @@ 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) |
|
||||
| 固件 | 区域、提取结果和转交原生分析的内容 | 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,164 @@
|
||||
# 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.
|
||||
- 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.
|
||||
@@ -545,6 +545,37 @@ Synthetic producer regressions run independently:
|
||||
npm run test:focused -- tests/boundary/process/jadxIntegration.test.ts tests/boundary/process/androidAnalysisMcp.test.ts
|
||||
```
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"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:adb": "npm run build:cached && node scripts/verify-adb.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.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",
|
||||
|
||||
@@ -47,6 +47,11 @@ export const toolFamilyCatalog = (sources) => {
|
||||
surface: "android-provider",
|
||||
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
id: "adb",
|
||||
surface: "adb-provider",
|
||||
contracts: sources.adbContracts.ADB_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
surface: "browser-provider",
|
||||
@@ -188,6 +193,10 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
|
||||
contracts: sources.androidContracts.ANDROID_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,8 @@ const SOURCE_PATHS = {
|
||||
firmwareProvider: "dist/firmware/FirmwareRelease.js",
|
||||
androidContracts: "dist/contracts/android/androidToolContracts.js",
|
||||
androidProvider: "dist/android/JadxRelease.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,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),
|
||||
};
|
||||
};
|
||||
@@ -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>;
|
||||
/** The provider holds no long-lived resources; safe to call always. */
|
||||
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,46 @@
|
||||
import type { AdbDeviceAnalysisPort } from "./AdbDeviceAnalysisPort.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import {
|
||||
adbRequestSchema,
|
||||
type AdbOperation,
|
||||
} from "../../domain/adb/adbDeviceAnalysis.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.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>> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const request = adbRequestSchema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
return err(new AnalysisInputError(operation, { cause: request.error }));
|
||||
const executed = await this.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: "observed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { registerArtifactCommands } from "./cli/artifactCommands.js";
|
||||
import { registerManagedCommands } from "./cli/managedCommands.js";
|
||||
import { registerFirmwareCommands } from "./cli/firmwareCommands.js";
|
||||
import { registerAndroidCommands } from "./cli/androidCommands.js";
|
||||
import { registerAdbCommands } from "./cli/adbCommands.js";
|
||||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cli/processCommands.js";
|
||||
import { registerBrowserCommands } from "./cli/browserCommands.js";
|
||||
@@ -73,6 +74,7 @@ export const createCli = (
|
||||
registerArtifactCommands(cli, logger, analysis.runProviderAnalysis);
|
||||
registerManagedCommands(cli, logger, analysis.runProviderAnalysis);
|
||||
registerAndroidCommands(cli, logger, environment);
|
||||
registerAdbCommands(cli, logger, environment);
|
||||
registerFirmwareCommands(cli, logger, environment);
|
||||
registerBinaryDiagnosticsCommands(cli, logger, environment);
|
||||
registerAnalysisViewCommands(cli, logger);
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { z } from "incur";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
|
||||
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
|
||||
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AdbOperation } from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** Expose the same explicit ADB requests through one-shot CLI commands. */
|
||||
export const registerAdbCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
provider: AdbDeviceAnalysisPort = createAdbDeviceAnalysisProvider(
|
||||
environment,
|
||||
),
|
||||
): void => {
|
||||
const service = new AdbDeviceAnalysisService(provider);
|
||||
const execute = (name: string, operation: AdbOperation, input: unknown) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, name, async () => {
|
||||
const result = await service.execute(operation, input, { signal });
|
||||
try {
|
||||
await provider.close();
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisError)
|
||||
return projectAnalysisError(cause);
|
||||
throw cause;
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
);
|
||||
|
||||
const serial = z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Device serial exactly as `adb devices` reports it");
|
||||
|
||||
cli.command(CLI_COMMANDS.inspectAdbClient, {
|
||||
description:
|
||||
"Inspect the caller-selected adb client without contacting devices",
|
||||
args: z.object({}),
|
||||
run: () => execute(CLI_COMMANDS.inspectAdbClient, "inspect_adb_client", {}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listAdbDevices, {
|
||||
description:
|
||||
"List attached devices with serial, state, transport, and inferred kind",
|
||||
args: z.object({}),
|
||||
run: () => execute(CLI_COMMANDS.listAdbDevices, "list_adb_devices", {}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAdbDevice, {
|
||||
description:
|
||||
"Inspect one device's build identity through a fixed getprop whitelist",
|
||||
args: z.object({ serial }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.inspectAdbDevice, "inspect_adb_device", args),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listAdbPackages, {
|
||||
description:
|
||||
"List installed packages on one device with base APK device paths",
|
||||
args: z.object({ serial }),
|
||||
options: z.object({
|
||||
scope: z
|
||||
.enum(["all", "third_party", "system"])
|
||||
.default("all")
|
||||
.describe("Restrict to third-party or system packages"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.listAdbPackages, "list_adb_packages", {
|
||||
serial: args.serial,
|
||||
scope: options.scope,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.pullAdbPackage, {
|
||||
description:
|
||||
"Pull one installed package's complete APK set with SHA-256 digests",
|
||||
args: z.object({
|
||||
serial,
|
||||
package: z.string().min(1).describe("Installed package name"),
|
||||
}),
|
||||
options: z.object({
|
||||
"output-directory": z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Local directory for the pulled APK set; a package-named subdirectory is created inside",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.pullAdbPackage, "pull_adb_package", {
|
||||
serial: args.serial,
|
||||
package: args.package,
|
||||
output_directory: options["output-directory"],
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.readAdbLogcat, {
|
||||
description: "Read a bounded dump of the device log for one buffer",
|
||||
args: z.object({ serial }),
|
||||
options: z.object({
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.default(500)
|
||||
.describe("Number of most recent lines to read"),
|
||||
buffer: z
|
||||
.enum(["main", "system", "radio", "events", "crash"])
|
||||
.default("main")
|
||||
.describe("Log buffer to read"),
|
||||
pid: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Restrict to one process id"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.readAdbLogcat, "read_adb_logcat", {
|
||||
serial: args.serial,
|
||||
count: options.count,
|
||||
buffer: options.buffer,
|
||||
...(options.pid === undefined ? {} : { pid: options.pid }),
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAdbPackage, {
|
||||
description:
|
||||
"Inspect one installed package's version, installer, and flags through dumpsys",
|
||||
args: z.object({ serial, package: z.string().min(1).describe("Package") }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.inspectAdbPackage, "inspect_adb_package", {
|
||||
serial: args.serial,
|
||||
package: args.package,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.pullAdbFile, {
|
||||
description: "Pull one device file to a local copy with a SHA-256 digest",
|
||||
args: z.object({
|
||||
serial,
|
||||
"device-path": z.string().min(1).describe("Absolute device path to pull"),
|
||||
}),
|
||||
options: z.object({
|
||||
"output-directory": z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Local directory for the pulled file"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.pullAdbFile, "pull_adb_file", {
|
||||
serial: args.serial,
|
||||
device_path: args["device-path"],
|
||||
output_directory: options["output-directory"],
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.pushAdbFile, {
|
||||
description:
|
||||
"Push one local file to the device; refuses existing targets without --overwrite",
|
||||
args: z.object({
|
||||
serial,
|
||||
"local-path": z.string().min(1).describe("Existing local file"),
|
||||
"device-path": z.string().min(1).describe("Absolute device destination"),
|
||||
}),
|
||||
options: z.object({
|
||||
overwrite: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Allow replacing an existing device file"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.pushAdbFile, "push_adb_file", {
|
||||
serial: args.serial,
|
||||
local_path: args["local-path"],
|
||||
device_path: args["device-path"],
|
||||
overwrite: options.overwrite,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.captureAdbScreen, {
|
||||
description: "Capture one screen frame as a digested PNG",
|
||||
args: z.object({ serial }),
|
||||
options: z.object({
|
||||
"output-directory": z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Local directory for screen.png"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.captureAdbScreen, "capture_adb_screen", {
|
||||
serial: args.serial,
|
||||
output_directory: options["output-directory"],
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listAdbProcesses, {
|
||||
description: "List running processes exactly as ps -A reports them",
|
||||
args: z.object({ serial }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.listAdbProcesses, "list_adb_processes", args),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listAdbDirectory, {
|
||||
description: "List one device directory with kinds, permissions, and sizes",
|
||||
args: z.object({
|
||||
serial,
|
||||
"device-path": z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute device directory path to list"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.listAdbDirectory, "list_adb_directory", {
|
||||
serial: args.serial,
|
||||
device_path: args["device-path"],
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listAdbFeatures, {
|
||||
description: "List the device's declared hardware and software features",
|
||||
args: z.object({ serial }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.listAdbFeatures, "list_adb_features", args),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.listAdbServices, {
|
||||
description: "List the device's binder services and interfaces",
|
||||
args: z.object({ serial }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.listAdbServices, "list_adb_services", args),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAdbDisplay, {
|
||||
description: "Report the display's declared size and density",
|
||||
args: z.object({ serial }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.inspectAdbDisplay, "inspect_adb_display", args),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAdbWindow, {
|
||||
description: "Report the window manager's current focus declarations",
|
||||
args: z.object({ serial }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.inspectAdbWindow, "inspect_adb_window", args),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.readAdbSetting, {
|
||||
description: "Read one settings value from system, secure, or global",
|
||||
args: z.object({
|
||||
serial,
|
||||
namespace: z
|
||||
.enum(["system", "secure", "global"])
|
||||
.describe("Settings namespace to read"),
|
||||
key: z.string().min(1).describe("Setting key to read"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.readAdbSetting, "read_adb_setting", {
|
||||
serial: args.serial,
|
||||
namespace: args.namespace,
|
||||
key: args.key,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.collectAdbBugreport, {
|
||||
description: "Collect and digest one device-generated bugreport archive",
|
||||
args: z.object({ serial }),
|
||||
options: z.object({
|
||||
"output-directory": z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Local directory for the bugreport zip"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.collectAdbBugreport, "collect_adb_bugreport", {
|
||||
serial: args.serial,
|
||||
output_directory: options["output-directory"],
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.resolveAdbPackages, {
|
||||
description: "Find installed packages by case-insensitive name substring",
|
||||
args: z.object({
|
||||
serial,
|
||||
query: z.string().min(2).describe("Package-name substring to find"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.resolveAdbPackages, "resolve_adb_packages", {
|
||||
serial: args.serial,
|
||||
query: args.query,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.installAdbPackage, {
|
||||
description:
|
||||
"Install one local APK; --replace re-installs keeping the app's data",
|
||||
args: z.object({
|
||||
serial,
|
||||
"apk-path": z.string().min(1).describe("Local APK file to install"),
|
||||
}),
|
||||
options: z.object({
|
||||
replace: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Replace an existing install and keep its data"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.installAdbPackage, "install_adb_package", {
|
||||
serial: args.serial,
|
||||
apk_path: args["apk-path"],
|
||||
replace: options.replace,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.uninstallAdbPackage, {
|
||||
description: "Uninstall one package and its data from the device",
|
||||
args: z.object({
|
||||
serial,
|
||||
package: z.string().min(1).describe("Installed package to uninstall"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.uninstallAdbPackage, "uninstall_adb_package", {
|
||||
serial: args.serial,
|
||||
package: args.package,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.startAdbApp, {
|
||||
description:
|
||||
"Start one installed app by resolving and launching its launcher activity",
|
||||
args: z.object({
|
||||
serial,
|
||||
package: z.string().min(1).describe("Installed package to start"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.startAdbApp, "start_adb_app", {
|
||||
serial: args.serial,
|
||||
package: args.package,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.startAdbActivity, {
|
||||
description:
|
||||
"Start an intent with am start (action, URI, component, extras)",
|
||||
args: z.object({
|
||||
serial,
|
||||
action: z.string().min(1).describe("Intent action to start"),
|
||||
}),
|
||||
options: z.object({
|
||||
"data-uri": z.string().min(1).optional().describe("Intent data URI (-d)"),
|
||||
component: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Explicit component in package/activity form (-n)"),
|
||||
extra: z
|
||||
.array(z.string().min(1))
|
||||
.default([])
|
||||
.describe(
|
||||
"Typed extra as type:key:value (string, boolean, int, long, float), repeatable",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) => {
|
||||
const extras = options.extra.map((entry) => {
|
||||
const first = entry.indexOf(":");
|
||||
const second = entry.indexOf(":", first + 1);
|
||||
if (first === -1 || second === -1)
|
||||
throw new Error(`--extra expects type:key:value, received: ${entry}`);
|
||||
return {
|
||||
type: entry.slice(0, first) as
|
||||
| "string"
|
||||
| "boolean"
|
||||
| "int"
|
||||
| "long"
|
||||
| "float",
|
||||
key: entry.slice(first + 1, second),
|
||||
value: entry.slice(second + 1),
|
||||
};
|
||||
});
|
||||
return execute(CLI_COMMANDS.startAdbActivity, "start_adb_activity", {
|
||||
serial: args.serial,
|
||||
action: args.action,
|
||||
...(options["data-uri"] === undefined
|
||||
? {}
|
||||
: { data_uri: options["data-uri"] }),
|
||||
...(options.component === undefined
|
||||
? {}
|
||||
: { component: options.component }),
|
||||
extras,
|
||||
});
|
||||
},
|
||||
});
|
||||
cli.command(CLI_COMMANDS.stopAdbApp, {
|
||||
description: "Force-stop every process of one package",
|
||||
args: z.object({
|
||||
serial,
|
||||
package: z.string().min(1).describe("Package to force-stop"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.stopAdbApp, "stop_adb_app", {
|
||||
serial: args.serial,
|
||||
package: args.package,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -45,6 +45,30 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
inspectFirmwareRegions: "inspect-firmware-regions",
|
||||
extractFirmware: "extract-firmware",
|
||||
inspectAndroidPackage: "inspect-android-package",
|
||||
inspectAdbClient: "inspect-adb-client",
|
||||
listAdbDevices: "list-adb-devices",
|
||||
inspectAdbDevice: "inspect-adb-device",
|
||||
listAdbPackages: "list-adb-packages",
|
||||
pullAdbPackage: "pull-adb-package",
|
||||
readAdbLogcat: "read-adb-logcat",
|
||||
inspectAdbPackage: "inspect-adb-package",
|
||||
pullAdbFile: "pull-adb-file",
|
||||
pushAdbFile: "push-adb-file",
|
||||
captureAdbScreen: "capture-adb-screen",
|
||||
listAdbProcesses: "list-adb-processes",
|
||||
listAdbDirectory: "list-adb-directory",
|
||||
listAdbFeatures: "list-adb-features",
|
||||
listAdbServices: "list-adb-services",
|
||||
inspectAdbDisplay: "inspect-adb-display",
|
||||
inspectAdbWindow: "inspect-adb-window",
|
||||
readAdbSetting: "read-adb-setting",
|
||||
collectAdbBugreport: "collect-adb-bugreport",
|
||||
resolveAdbPackages: "resolve-adb-packages",
|
||||
installAdbPackage: "install-adb-package",
|
||||
uninstallAdbPackage: "uninstall-adb-package",
|
||||
startAdbApp: "start-adb-app",
|
||||
startAdbActivity: "start-adb-activity",
|
||||
stopAdbApp: "stop-adb-app",
|
||||
searchAndroidClasses: "search-android-classes",
|
||||
inspectAndroidClass: "inspect-android-class",
|
||||
inspectAndroidMethod: "inspect-android-method",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
|
||||
import { AdbProvider } from "../adb/AdbProvider.js";
|
||||
|
||||
/** Construct one ADB provider bound to the caller-selected binary. */
|
||||
export const createAdbDeviceAnalysisProvider = (
|
||||
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): AdbDeviceAnalysisPort => new AdbProvider({ environment });
|
||||
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
adbInputSchemas,
|
||||
adbResultSchemas,
|
||||
} from "../../domain/adb/adbDeviceAnalysis.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
|
||||
/** ADB device inspection and acquisition over a caller-supplied binary. */
|
||||
export const ADB_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_adb_client",
|
||||
...toolContractMetadata("inspect_adb_client"),
|
||||
description:
|
||||
"Inspect the caller-selected adb client: resolved binary, version, and install path. Never contacts devices and never starts the adb server. REA never installs platform tools; select a binary with REA_ADB_PATH (absolute) or adb on PATH.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.inspect_adb_client,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_client),
|
||||
examples: [{ title: "Check the adb client", input: {} }],
|
||||
},
|
||||
{
|
||||
name: "list_adb_devices",
|
||||
...toolContractMetadata("list_adb_devices"),
|
||||
description:
|
||||
"List attached devices exactly as `adb devices -l` reports them: serial, state, transport, model metadata, and an inferred emulator/physical kind with its evidence basis. Emulators and USB or network devices appear alike; REA neither starts nor manages them. May start the local adb server on first use.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.list_adb_devices,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.list_adb_devices),
|
||||
examples: [{ title: "List attached devices", input: {} }],
|
||||
},
|
||||
{
|
||||
name: "inspect_adb_device",
|
||||
...toolContractMetadata("inspect_adb_device"),
|
||||
description:
|
||||
"Inspect one device's build identity through a fixed `getprop` whitelist: Android and SDK versions, security patch, fingerprint, product identity, CPU ABIs, and debuggable flag, plus the observed emulator indicator ro.kernel.qemu. Read-only with respect to the device.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.inspect_adb_device,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_device),
|
||||
examples: [
|
||||
{ title: "Inspect one device", input: { serial: "emulator-5554" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "list_adb_packages",
|
||||
...toolContractMetadata("list_adb_packages"),
|
||||
description:
|
||||
"List installed packages on one device with each base APK's device path, optionally scoped to third-party or system packages. Paths are device filesystem paths, not host-local. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.list_adb_packages,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.list_adb_packages),
|
||||
examples: [
|
||||
{
|
||||
title: "List third-party packages",
|
||||
input: { serial: "emulator-5554", scope: "third_party" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "pull_adb_package",
|
||||
...toolContractMetadata("pull_adb_package"),
|
||||
description:
|
||||
"Pull one installed package's complete APK set (base plus every split, as `pm path` reports) from the device into a caller-selected output directory, creating a package-named subdirectory that must not already exist. Every pulled file is digested with SHA-256; partial pulls keep successful artifacts and per-file failure reasons. The device keeps the originals.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.pull_adb_package,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.pull_adb_package),
|
||||
examples: [
|
||||
{
|
||||
title: "Pull a package APK set",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
package: "com.example.app",
|
||||
output_directory: "/tmp/rea-pulls",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "read_adb_logcat",
|
||||
...toolContractMetadata("read_adb_logcat"),
|
||||
description:
|
||||
"Read a bounded dump of the device log (`logcat -d -t`) for one buffer, optionally restricted to a process id. No streaming, no clearing; the newest lines are kept when the count cap trims. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.read_adb_logcat,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.read_adb_logcat),
|
||||
examples: [
|
||||
{
|
||||
title: "Read the last 200 main-buffer lines",
|
||||
input: { serial: "emulator-5554", count: 200, buffer: "main" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_adb_package",
|
||||
...toolContractMetadata("inspect_adb_package"),
|
||||
description:
|
||||
"Inspect one installed package through `dumpsys package`: version name/code, install and update times, installer, user id, flags, and the requested-permission count. A fixed projection of version-dependent output; unknown sections are ignored rather than guessed. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.inspect_adb_package,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_package),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect an installed package",
|
||||
input: { serial: "emulator-5554", package: "com.example.app" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "pull_adb_file",
|
||||
...toolContractMetadata("pull_adb_file"),
|
||||
description:
|
||||
"Pull one absolute device path (a database, shared-preferences XML, or any readable file) into a caller-selected output directory, digesting it with SHA-256. The local copy must not already exist. Read-only with respect to the device.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.pull_adb_file,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.pull_adb_file),
|
||||
examples: [
|
||||
{
|
||||
title: "Pull an app database",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
device_path: "/data/data/com.example.app/databases/app.db",
|
||||
output_directory: "/tmp/rea-pulls",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "push_adb_file",
|
||||
...toolContractMetadata("push_adb_file"),
|
||||
description:
|
||||
"Push one local file to an absolute device path. The only device-mutating ADB tool: existing device files are refused unless overwrite is explicitly requested, and the transfer is verified by comparing the local digest with the device's own sha256sum when the device provides one.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.push_adb_file,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.push_adb_file),
|
||||
examples: [
|
||||
{
|
||||
title: "Push a script to tmp",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
local_path: "/tmp/frida-server",
|
||||
device_path: "/data/local/tmp/frida-server",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "capture_adb_screen",
|
||||
...toolContractMetadata("capture_adb_screen"),
|
||||
description:
|
||||
"Capture one frame of the device screen as a PNG (`screencap -p` through exec-out, no device-side file) into a caller-selected output directory, digesting it and reporting its dimensions. No recording or interaction.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.capture_adb_screen,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.capture_adb_screen),
|
||||
examples: [
|
||||
{
|
||||
title: "Capture the current screen",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
output_directory: "/tmp/rea-captures",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "list_adb_processes",
|
||||
...toolContractMetadata("list_adb_processes"),
|
||||
description:
|
||||
"List running processes exactly as `ps -A` reports them: user, pid, ppid, rss, state, and name. Useful for pairing with instrumentation lanes. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.list_adb_processes,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.list_adb_processes),
|
||||
examples: [{ title: "List processes", input: { serial: "emulator-5554" } }],
|
||||
},
|
||||
{
|
||||
name: "list_adb_directory",
|
||||
...toolContractMetadata("list_adb_directory"),
|
||||
description:
|
||||
"List one absolute device directory (`ls -la`): names, kinds, permissions, owner, group, sizes, dates, and symlink targets. Recognizes the standard errors for absent paths and denied reads. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.list_adb_directory,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.list_adb_directory),
|
||||
examples: [
|
||||
{
|
||||
title: "List an app's data directory",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
device_path: "/data/data/com.example.app",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "list_adb_features",
|
||||
...toolContractMetadata("list_adb_features"),
|
||||
description:
|
||||
"List the device's declared hardware and software features (`pm list features`) for device fingerprinting. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.list_adb_features,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.list_adb_features),
|
||||
examples: [{ title: "List features", input: { serial: "emulator-5554" } }],
|
||||
},
|
||||
{
|
||||
name: "list_adb_services",
|
||||
...toolContractMetadata("list_adb_services"),
|
||||
description:
|
||||
"List the device's binder services with their interfaces (`service list`). Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.list_adb_services,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.list_adb_services),
|
||||
examples: [{ title: "List services", input: { serial: "emulator-5554" } }],
|
||||
},
|
||||
{
|
||||
name: "inspect_adb_display",
|
||||
...toolContractMetadata("inspect_adb_display"),
|
||||
description:
|
||||
"Report the display's physical and any overridden size (`wm size`) and density (`wm density`) as the device declares them. Read-only; setting values is not exposed.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.inspect_adb_display,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_display),
|
||||
examples: [
|
||||
{ title: "Inspect the display", input: { serial: "emulator-5554" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_adb_window",
|
||||
...toolContractMetadata("inspect_adb_window"),
|
||||
description:
|
||||
"Report the window manager's current focus declarations (`dumpsys window windows`: mCurrentFocus and mFocusedApp) — what is on screen at read time without any interaction. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.inspect_adb_window,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_window),
|
||||
examples: [
|
||||
{ title: "Inspect window focus", input: { serial: "emulator-5554" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "read_adb_setting",
|
||||
...toolContractMetadata("read_adb_setting"),
|
||||
description:
|
||||
"Read one settings value (`settings get`) from the system, secure, or global namespace. Absent keys report the device's own null observation. Read-only; writing settings is not exposed.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.read_adb_setting,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.read_adb_setting),
|
||||
examples: [
|
||||
{
|
||||
title: "Read the ADB-enabled setting",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
namespace: "secure",
|
||||
key: "adb_enabled",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "collect_adb_bugreport",
|
||||
...toolContractMetadata("collect_adb_bugreport"),
|
||||
description:
|
||||
"Collect one device-generated bugreport archive (`adb bugreport`) into a caller-selected output directory, digesting the zip. The archive contains the device's own dumpstate, dumpsys, and logcat report; collection can take minutes. Read-only with respect to configuration.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.collect_adb_bugreport,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.collect_adb_bugreport),
|
||||
examples: [
|
||||
{
|
||||
title: "Collect a bugreport",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
output_directory: "/tmp/rea-bugreports",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "resolve_adb_packages",
|
||||
...toolContractMetadata("resolve_adb_packages"),
|
||||
description:
|
||||
"Find installed packages by case-insensitive substring of the package name (e.g. `whatsapp` → `com.whatsapp`), returning each match's base APK device path. The first step of an easy get: resolve, then pull_adb_package the exact name. Read-only.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.resolve_adb_packages,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.resolve_adb_packages),
|
||||
examples: [
|
||||
{
|
||||
title: "Find a package by partial name",
|
||||
input: { serial: "emulator-5554", query: "whatsapp" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "install_adb_package",
|
||||
...toolContractMetadata("install_adb_package"),
|
||||
description:
|
||||
"Install one local APK on the device (`adb install [-r]`), recording the APK's host digest and byte size. A device-mutating tool: `replace` re-installs over an existing package while keeping its data; the device's own Success/Failure verdict is preserved with its reason.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.install_adb_package,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.install_adb_package),
|
||||
examples: [
|
||||
{
|
||||
title: "Install an APK",
|
||||
input: { serial: "emulator-5554", apk_path: "/tmp/app.apk" },
|
||||
},
|
||||
{
|
||||
title: "Replace an existing install keeping data",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
apk_path: "/tmp/app.apk",
|
||||
replace: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "uninstall_adb_package",
|
||||
...toolContractMetadata("uninstall_adb_package"),
|
||||
description:
|
||||
"Uninstall one package from the device (`adb uninstall`), removing its data for user 0. A device-mutating tool; absent packages report the device's own refusal reason.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.uninstall_adb_package,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.uninstall_adb_package),
|
||||
examples: [
|
||||
{
|
||||
title: "Uninstall a package",
|
||||
input: { serial: "emulator-5554", package: "com.example.app" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "start_adb_app",
|
||||
...toolContractMetadata("start_adb_app"),
|
||||
description:
|
||||
"Start one installed app by launching its declared launcher activity: the component is resolved through `cmd package resolve-activity --brief`, then started with `am start -n`. A device-mutating tool for dynamic-analysis workflows; pair with `read_adb_logcat` (pid filter) and `list_adb_processes` to observe the started app, and `stop_adb_app` to end the run.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.start_adb_app,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.start_adb_app),
|
||||
examples: [
|
||||
{
|
||||
title: "Start an app",
|
||||
input: { serial: "emulator-5554", package: "com.example.app" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "start_adb_activity",
|
||||
...toolContractMetadata("start_adb_activity"),
|
||||
description:
|
||||
"Start an intent with `am start`: one action, optional data URI, optional explicit component, and up to 16 typed extras (string, boolean, int, long, float). A device-mutating tool; the activity manager's Starting/Error verdict is preserved verbatim.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.start_adb_activity,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.start_adb_activity),
|
||||
examples: [
|
||||
{
|
||||
title: "Open a deep link",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
action: "android.intent.action.VIEW",
|
||||
data_uri: "https://example.com/path",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Start with typed extras",
|
||||
input: {
|
||||
serial: "emulator-5554",
|
||||
action: "com.example.APP_ACTION",
|
||||
extras: [{ key: "level", type: "int", value: "3" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "stop_adb_app",
|
||||
...toolContractMetadata("stop_adb_app"),
|
||||
description:
|
||||
"Force-stop every process of one package (`am force-stop`), ending a dynamic-analysis run. A device-mutating tool; it succeeds whether or not the package had running processes.",
|
||||
kind: "adb-provider",
|
||||
inputSchema: adbInputSchemas.stop_adb_app,
|
||||
outputSchema: evidenceResultOf(adbResultSchemas.stop_adb_app),
|
||||
examples: [
|
||||
{
|
||||
title: "Stop an app",
|
||||
input: { serial: "emulator-5554", package: "com.example.app" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -25,6 +25,7 @@ export const TOOL_KINDS = [
|
||||
"artifact-provider",
|
||||
"managed-provider",
|
||||
"android-provider",
|
||||
"adb-provider",
|
||||
"firmware-provider",
|
||||
"browser-provider",
|
||||
"electron-provider",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js";
|
||||
import { FIRMWARE_TOOL_CONTRACTS } from "./firmware/firmwareToolContracts.js";
|
||||
import { ANDROID_TOOL_CONTRACTS } from "./android/androidToolContracts.js";
|
||||
import { ADB_TOOL_CONTRACTS } from "./adb/adbToolContracts.js";
|
||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managed/managedWorkflowToolContracts.js";
|
||||
import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.js";
|
||||
import { ELECTRON_TOOL_CONTRACTS } from "./javascript/electronToolContracts.js";
|
||||
@@ -30,6 +31,7 @@ export const TOOL_CONTRACTS = [
|
||||
...EVM_TOOL_CONTRACTS,
|
||||
...MANAGED_TOOL_CONTRACTS,
|
||||
...ANDROID_TOOL_CONTRACTS,
|
||||
...ADB_TOOL_CONTRACTS,
|
||||
...FIRMWARE_TOOL_CONTRACTS,
|
||||
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||
...BROWSER_PROVIDER_TOOL_CONTRACTS,
|
||||
|
||||
@@ -97,6 +97,146 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
inspect_adb_client: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
}),
|
||||
list_adb_devices: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
inspect_adb_device: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
list_adb_packages: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
pull_adb_package: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
read_adb_logcat: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
inspect_adb_package: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
pull_adb_file: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
push_adb_file: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
capture_adb_screen: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
list_adb_processes: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
list_adb_directory: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
list_adb_features: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
list_adb_services: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
inspect_adb_display: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
inspect_adb_window: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
read_adb_setting: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
collect_adb_bugreport: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
resolve_adb_packages: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
}),
|
||||
install_adb_package: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
uninstall_adb_package: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
start_adb_app: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
start_adb_activity: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
stop_adb_app: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
search_android_classes: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
import { z } from "zod";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
/**
|
||||
* Device serials as adb prints them: emulator-5554, USB identifiers, and
|
||||
* host:port transports. The pattern keeps arguments free of whitespace and
|
||||
* quotes; argv is positional in the provider, so this is admission hygiene
|
||||
* rather than injection defense.
|
||||
*/
|
||||
const deviceSerialSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(
|
||||
/^[^\x00\s"']+$/u,
|
||||
"Device serial cannot contain whitespace or quotes",
|
||||
);
|
||||
|
||||
/**
|
||||
* Android package names as Package Manager reports them. System entries can
|
||||
* be a single segment (`android`), so at least one identifier is required.
|
||||
*/
|
||||
const packageNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(
|
||||
/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/u,
|
||||
"Package name must be dot-separated Java identifiers",
|
||||
);
|
||||
|
||||
/**
|
||||
* Caller intent for ADB-backed device inspection and acquisition.
|
||||
*
|
||||
* REA never installs platform tools, starts an emulator, or manages devices;
|
||||
* every operation addresses devices the caller already connected, through the
|
||||
* adb binary the caller selected.
|
||||
*/
|
||||
export const adbInputSchemas = {
|
||||
inspect_adb_client: z.strictObject({}),
|
||||
list_adb_devices: z.strictObject({}),
|
||||
inspect_adb_device: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
}),
|
||||
list_adb_packages: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
scope: z
|
||||
.enum(["all", "third_party", "system"])
|
||||
.default("all")
|
||||
.describe(
|
||||
"Package scope: all packages, third-party only (-3), or system only (-s)",
|
||||
),
|
||||
}),
|
||||
pull_adb_package: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
package: packageNameSchema.describe(
|
||||
"Installed package name whose APK set is pulled",
|
||||
),
|
||||
output_directory: localPathStringSchema.describe(
|
||||
"Local directory for the pulled APK set; REA creates a package-named subdirectory inside it",
|
||||
),
|
||||
}),
|
||||
read_adb_logcat: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000)
|
||||
.default(500)
|
||||
.describe("Number of most recent lines to read (logcat -d -t)"),
|
||||
buffer: z
|
||||
.enum(["main", "system", "radio", "events", "crash"])
|
||||
.default("main")
|
||||
.describe("Log buffer to read (logcat -b)"),
|
||||
pid: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Restrict to one process id (logcat --pid)"),
|
||||
}),
|
||||
inspect_adb_package: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
package: packageNameSchema.describe("Installed package to inspect"),
|
||||
}),
|
||||
pull_adb_file: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
device_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\/[^\x00]*$/u, "Device path must be absolute")
|
||||
.describe("Absolute device filesystem path to pull"),
|
||||
output_directory: localPathStringSchema.describe(
|
||||
"Local directory for the pulled file; must not already contain the file",
|
||||
),
|
||||
}),
|
||||
push_adb_file: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
local_path: localPathStringSchema.describe(
|
||||
"Existing local regular file to push",
|
||||
),
|
||||
device_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\/[^\x00]*$/u, "Device path must be absolute")
|
||||
.describe("Absolute destination path on the device"),
|
||||
overwrite: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Allow replacing an existing device file; refused by default"),
|
||||
}),
|
||||
capture_adb_screen: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
output_directory: localPathStringSchema.describe(
|
||||
"Local directory for the captured PNG; must not already contain it",
|
||||
),
|
||||
}),
|
||||
list_adb_processes: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
}),
|
||||
list_adb_directory: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
device_path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^\/[^\x00]*$/u, "Device path must be absolute")
|
||||
.describe("Absolute device directory path to list (ls -la)"),
|
||||
}),
|
||||
list_adb_features: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
}),
|
||||
list_adb_services: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
}),
|
||||
inspect_adb_display: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
}),
|
||||
inspect_adb_window: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
}),
|
||||
read_adb_setting: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
namespace: z
|
||||
.enum(["system", "secure", "global"])
|
||||
.describe("Settings namespace to read"),
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(
|
||||
/^[A-Za-z0-9._-]+$/u,
|
||||
"Setting keys are restricted to identifiers",
|
||||
)
|
||||
.describe("Setting key to read"),
|
||||
}),
|
||||
collect_adb_bugreport: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
output_directory: localPathStringSchema.describe(
|
||||
"Local directory for the bugreport archive; REA writes exactly one new zip into it",
|
||||
),
|
||||
}),
|
||||
resolve_adb_packages: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
query: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(256)
|
||||
.describe(
|
||||
"Case-insensitive substring matched against installed package names (e.g. whatsapp)",
|
||||
),
|
||||
}),
|
||||
install_adb_package: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
apk_path: localPathStringSchema.describe(
|
||||
"Existing local APK file to install",
|
||||
),
|
||||
replace: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Replace an existing installation and keep its data (adb install -r)",
|
||||
),
|
||||
}),
|
||||
uninstall_adb_package: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
package: packageNameSchema.describe("Installed package to uninstall"),
|
||||
}),
|
||||
start_adb_app: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
package: packageNameSchema.describe(
|
||||
"Installed package whose launcher activity is started",
|
||||
),
|
||||
}),
|
||||
start_adb_activity: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
action: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(
|
||||
/^[A-Za-z][A-Za-z0-9._-]*$/u,
|
||||
"Intent action must be a dotted identifier such as android.intent.action.VIEW",
|
||||
)
|
||||
.describe("Intent action to start"),
|
||||
data_uri: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.regex(/^[^\x00\s]+$/u, "Data URI cannot contain whitespace or NUL")
|
||||
.optional()
|
||||
.describe("Intent data URI (-d)"),
|
||||
component: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.regex(
|
||||
/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]*)?$/u,
|
||||
"Component must be package/activity form",
|
||||
)
|
||||
.optional()
|
||||
.describe("Explicit component to start (-n)"),
|
||||
extras: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.regex(/^[A-Za-z0-9._-]+$/u, "Extra keys are identifiers")
|
||||
.describe("Extra key"),
|
||||
type: z
|
||||
.enum(["string", "boolean", "int", "long", "float"])
|
||||
.describe("Extra value type"),
|
||||
value: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.regex(/^[^\x00]*$/u, "Extra values cannot contain NUL")
|
||||
.describe("Extra value as text, parsed per type"),
|
||||
}),
|
||||
)
|
||||
.max(16)
|
||||
.default([])
|
||||
.describe("Typed intent extras (--es/--ez/--ei/--el/--ef)"),
|
||||
}),
|
||||
stop_adb_app: z.strictObject({
|
||||
serial: deviceSerialSchema.describe(
|
||||
"Device serial exactly as `adb devices` reports it",
|
||||
),
|
||||
package: packageNameSchema.describe(
|
||||
"Package whose processes are force-stopped",
|
||||
),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/** Supported ADB-backed operations. */
|
||||
export type AdbOperation = keyof typeof adbInputSchemas;
|
||||
/** Validated ADB requests carry their selected operation. */
|
||||
export type AdbRequest = {
|
||||
[Name in AdbOperation]: {
|
||||
operation: Name;
|
||||
input: z.infer<(typeof adbInputSchemas)[Name]>;
|
||||
};
|
||||
}[AdbOperation];
|
||||
|
||||
/** Validate operation and input together so their types remain correlated. */
|
||||
export const adbRequestSchema = z.discriminatedUnion("operation", [
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_adb_client"),
|
||||
input: adbInputSchemas.inspect_adb_client,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("list_adb_devices"),
|
||||
input: adbInputSchemas.list_adb_devices,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_adb_device"),
|
||||
input: adbInputSchemas.inspect_adb_device,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("list_adb_packages"),
|
||||
input: adbInputSchemas.list_adb_packages,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("pull_adb_package"),
|
||||
input: adbInputSchemas.pull_adb_package,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("read_adb_logcat"),
|
||||
input: adbInputSchemas.read_adb_logcat,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_adb_package"),
|
||||
input: adbInputSchemas.inspect_adb_package,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("pull_adb_file"),
|
||||
input: adbInputSchemas.pull_adb_file,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("push_adb_file"),
|
||||
input: adbInputSchemas.push_adb_file,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("capture_adb_screen"),
|
||||
input: adbInputSchemas.capture_adb_screen,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("list_adb_processes"),
|
||||
input: adbInputSchemas.list_adb_processes,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("list_adb_directory"),
|
||||
input: adbInputSchemas.list_adb_directory,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("list_adb_features"),
|
||||
input: adbInputSchemas.list_adb_features,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("list_adb_services"),
|
||||
input: adbInputSchemas.list_adb_services,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_adb_display"),
|
||||
input: adbInputSchemas.inspect_adb_display,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_adb_window"),
|
||||
input: adbInputSchemas.inspect_adb_window,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("read_adb_setting"),
|
||||
input: adbInputSchemas.read_adb_setting,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("collect_adb_bugreport"),
|
||||
input: adbInputSchemas.collect_adb_bugreport,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("resolve_adb_packages"),
|
||||
input: adbInputSchemas.resolve_adb_packages,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("install_adb_package"),
|
||||
input: adbInputSchemas.install_adb_package,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("uninstall_adb_package"),
|
||||
input: adbInputSchemas.uninstall_adb_package,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("start_adb_app"),
|
||||
input: adbInputSchemas.start_adb_app,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("start_adb_activity"),
|
||||
input: adbInputSchemas.start_adb_activity,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("stop_adb_app"),
|
||||
input: adbInputSchemas.stop_adb_app,
|
||||
}),
|
||||
]);
|
||||
|
||||
const client = z.strictObject({
|
||||
binary_path: z.string().min(1),
|
||||
path_source: z.enum(["environment", "path"]),
|
||||
version: z.string().min(1).nullable(),
|
||||
installed_path: z.string().min(1).nullable(),
|
||||
});
|
||||
|
||||
const device = z.strictObject({
|
||||
serial: z.string().min(1),
|
||||
state: z.string().min(1),
|
||||
transport: z.enum(["usb", "tcp", "unknown"]),
|
||||
product: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
device: z.string().nullable(),
|
||||
transport_id: z.number().int().positive().nullable(),
|
||||
/**
|
||||
* Inferred device kind with its evidence basis. The serial prefix and the
|
||||
* `device:` metadata line are observations; the kind itself is derived.
|
||||
*/
|
||||
kind: z.enum(["emulator", "physical", "unknown"]),
|
||||
kind_basis: z.enum(["emulator_serial_prefix", "usb_transport", "none"]),
|
||||
});
|
||||
|
||||
const pulledArtifact = z.strictObject({
|
||||
device_path: z.string().min(1),
|
||||
file_name: z.string().min(1),
|
||||
local_path: z.string().min(1),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
role: z.enum(["base", "split", "unknown"]),
|
||||
role_basis: z.literal("file_name"),
|
||||
});
|
||||
|
||||
const pullFailure = z.strictObject({
|
||||
device_path: z.string().min(1).nullable(),
|
||||
stage: z.enum(["resolve_paths", "pull", "digest"]),
|
||||
message: z.string().min(1),
|
||||
});
|
||||
|
||||
/** Portable ADB observations with explicit identity and coverage limitations. */
|
||||
export const adbResultSchemas = {
|
||||
inspect_adb_client: z.strictObject({
|
||||
client,
|
||||
/**
|
||||
* `adb version` never contacts the adb server; this tool starts nothing.
|
||||
* Recorded explicitly so the session keeps the distinction observable.
|
||||
*/
|
||||
contacted_adb_server: z.literal(false),
|
||||
}),
|
||||
list_adb_devices: z.strictObject({
|
||||
client,
|
||||
devices: z.array(device),
|
||||
/**
|
||||
* Starting the local adb server is an adb client behavior on first use;
|
||||
* REA observes whichever server state the caller's environment has.
|
||||
*/
|
||||
may_have_started_adb_server: z.boolean(),
|
||||
}),
|
||||
inspect_adb_device: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
properties: z.array(
|
||||
z.strictObject({ name: z.string().min(1), value: z.string().min(1) }),
|
||||
),
|
||||
property_count: z.number().int().nonnegative(),
|
||||
emulator_observed: z.boolean().nullable(),
|
||||
}),
|
||||
list_adb_packages: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
scope: z.enum(["all", "third_party", "system"]),
|
||||
packages: z.array(
|
||||
z.strictObject({
|
||||
package_name: packageNameSchema,
|
||||
/** Device filesystem path; not a host-local path. */
|
||||
base_apk_device_path: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
unparsed_lines: z.array(z.string().min(1)),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
pull_adb_package: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
package_name: packageNameSchema,
|
||||
output_directory: z.string().min(1),
|
||||
artifacts: z.array(pulledArtifact),
|
||||
failures: z.array(pullFailure),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
read_adb_logcat: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
count: z.number().int().positive(),
|
||||
buffer: z.enum(["main", "system", "radio", "events", "crash"]),
|
||||
pid: z.number().int().positive().nullable(),
|
||||
lines: z.array(z.string()),
|
||||
total_bytes: z.number().int().nonnegative(),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
inspect_adb_package: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
package_name: packageNameSchema,
|
||||
version_name: z.string().nullable(),
|
||||
version_code: z.number().int().nullable(),
|
||||
first_install_time: z.string().nullable(),
|
||||
last_update_time: z.string().nullable(),
|
||||
installer_package_name: z.string().nullable(),
|
||||
user_id: z.number().int().nullable(),
|
||||
pkg_flags: z.string().nullable(),
|
||||
requested_permissions_count: z.number().int().nonnegative().nullable(),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
pull_adb_file: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
device_path: z.string().min(1),
|
||||
local_path: z.string().min(1),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
}),
|
||||
push_adb_file: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
local_path: z.string().min(1),
|
||||
device_path: z.string().min(1),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
device_sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
overwritten: z.boolean(),
|
||||
device_digest_source: z.enum(["device_sha256sum", "unavailable"]),
|
||||
}),
|
||||
capture_adb_screen: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
local_path: z.string().min(1),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
width: z.number().int().positive().nullable(),
|
||||
height: z.number().int().positive().nullable(),
|
||||
}),
|
||||
list_adb_processes: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
columns: z.string().nullable(),
|
||||
processes: z.array(
|
||||
z.strictObject({
|
||||
user: z.string(),
|
||||
pid: z.number().int().nonnegative(),
|
||||
ppid: z.number().int().nullable(),
|
||||
rss: z.number().int().nullable(),
|
||||
state: z.string().nullable(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
unparsed_lines: z.array(z.string().min(1)),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
list_adb_directory: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
device_path: z.string().min(1),
|
||||
entries: z.array(
|
||||
z.strictObject({
|
||||
name: z.string().min(1),
|
||||
kind: z.enum(["directory", "file", "symlink", "other"]),
|
||||
permissions: z.string().nullable(),
|
||||
owner: z.string().nullable(),
|
||||
group: z.string().nullable(),
|
||||
bytes: z.number().int().nullable(),
|
||||
date: z.string().nullable(),
|
||||
link_target: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
unparsed_lines: z.array(z.string().min(1)),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
list_adb_features: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
features: z.array(
|
||||
z.strictObject({
|
||||
name: z.string().min(1),
|
||||
version: z.number().int().nullable(),
|
||||
}),
|
||||
),
|
||||
unparsed_lines: z.array(z.string().min(1)),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
list_adb_services: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
services: z.array(
|
||||
z.strictObject({
|
||||
name: z.string().min(1),
|
||||
interface: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
unparsed_lines: z.array(z.string().min(1)),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
inspect_adb_display: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
physical_size: z.strictObject({
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
}),
|
||||
override_size: z
|
||||
.strictObject({
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
})
|
||||
.nullable(),
|
||||
physical_density: z.number().int().positive().nullable(),
|
||||
override_density: z.number().int().positive().nullable(),
|
||||
}),
|
||||
inspect_adb_window: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
focused_window: z.string().nullable(),
|
||||
focused_app: z.string().nullable(),
|
||||
observed_lines: z.array(z.string().min(1)),
|
||||
}),
|
||||
read_adb_setting: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
namespace: z.enum(["system", "secure", "global"]),
|
||||
key: z.string().min(1),
|
||||
value: z.string().nullable(),
|
||||
device_reported_null: z.boolean(),
|
||||
}),
|
||||
collect_adb_bugreport: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
local_path: z.string().min(1),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
adb_reported_path: z.string().nullable(),
|
||||
}),
|
||||
resolve_adb_packages: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
query: z.string().min(1),
|
||||
matches: z.array(
|
||||
z.strictObject({
|
||||
package_name: packageNameSchema,
|
||||
base_apk_device_path: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
exact_match: z.boolean(),
|
||||
coverage: z.enum(["complete", "partial"]),
|
||||
}),
|
||||
install_adb_package: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
apk_path: z.string().min(1),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
replaced: z.boolean(),
|
||||
}),
|
||||
uninstall_adb_package: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
package_name: packageNameSchema,
|
||||
}),
|
||||
start_adb_app: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
package_name: packageNameSchema,
|
||||
component: z.string().min(1),
|
||||
started_activity: z.string().nullable(),
|
||||
}),
|
||||
start_adb_activity: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
data_uri: z.string().nullable(),
|
||||
component: z.string().nullable(),
|
||||
extras: z.array(
|
||||
z.strictObject({
|
||||
key: z.string().min(1),
|
||||
type: z.enum(["string", "boolean", "int", "long", "float"]),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
started_activity: z.string().nullable(),
|
||||
}),
|
||||
stop_adb_app: z.strictObject({
|
||||
client,
|
||||
serial: z.string().min(1),
|
||||
package_name: packageNameSchema,
|
||||
}),
|
||||
} as const;
|
||||
@@ -62,9 +62,13 @@ import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysi
|
||||
import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js";
|
||||
import { createFirmwareAnalysisProvider } from "../composition/firmware.js";
|
||||
import { registerAndroidTools } from "./registerAndroidTools.js";
|
||||
import { registerAdbTools } from "./registerAdbTools.js";
|
||||
import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js";
|
||||
import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js";
|
||||
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
|
||||
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
|
||||
import { createAndroidAnalysisProvider } from "../composition/android.js";
|
||||
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
|
||||
import { registerManagedWorkflowTools } from "./registerManagedWorkflowTools.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
@@ -99,6 +103,7 @@ export interface CreateServerOptions {
|
||||
readonly webRuntime?: WebRuntimeService;
|
||||
readonly webNetworkCapture?: WebNetworkCaptureService;
|
||||
readonly androidAnalysis?: AndroidAnalysisPort;
|
||||
readonly adbDeviceAnalysis?: AdbDeviceAnalysisPort;
|
||||
readonly browserObservation?: BrowserObservationPort;
|
||||
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
||||
readonly electronObservation?: ElectronObservationPort;
|
||||
@@ -197,6 +202,8 @@ export const createServer = (
|
||||
);
|
||||
const android =
|
||||
options.androidAnalysis ?? createAndroidAnalysisProvider(environment);
|
||||
const adbDevice =
|
||||
options.adbDeviceAnalysis ?? createAdbDeviceAnalysisProvider(environment);
|
||||
const availability = installSessionToolAvailability(
|
||||
session,
|
||||
selectedOptions,
|
||||
@@ -239,6 +246,12 @@ export const createServer = (
|
||||
if (result.status === "rejected") throw result.reason;
|
||||
};
|
||||
registerConfiguredAnalysisTools(toolContext, android);
|
||||
registerAdbTools(
|
||||
server,
|
||||
new AdbDeviceAnalysisService(adbDevice),
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
);
|
||||
registerObservationTools(toolContext);
|
||||
registerGuidedPrompts(server, analysis, session);
|
||||
if (session !== undefined) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { ServerContext } from "@modelcontextprotocol/server";
|
||||
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import type { AdbOperation } from "../domain/adb/adbDeviceAnalysis.js";
|
||||
import type { Logger } from "pino";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
|
||||
/** Bind ADB handlers to their exact named contracts through shared workflows. */
|
||||
export const registerAdbTools = (
|
||||
server: EvidenceMcpServer,
|
||||
service: AdbDeviceAnalysisService,
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
): void => {
|
||||
const handler = (contract: ToolContract<AdbOperation>) => {
|
||||
const name = contract.name;
|
||||
return async (input: unknown, context: ServerContext) => {
|
||||
const result = await logToolExecution(logger, name, () =>
|
||||
service.execute(name, input, { signal: context.mcpReq.signal }),
|
||||
);
|
||||
if (!result.ok) return server.delivery.toCallToolResult(result, contract);
|
||||
const recorded = recordEvidence?.(result.value);
|
||||
return server.delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
contract,
|
||||
recorded,
|
||||
);
|
||||
};
|
||||
};
|
||||
for (const contract of [
|
||||
toolContract("inspect_adb_client"),
|
||||
toolContract("list_adb_devices"),
|
||||
toolContract("inspect_adb_device"),
|
||||
toolContract("list_adb_packages"),
|
||||
toolContract("pull_adb_package"),
|
||||
toolContract("read_adb_logcat"),
|
||||
toolContract("inspect_adb_package"),
|
||||
toolContract("pull_adb_file"),
|
||||
toolContract("push_adb_file"),
|
||||
toolContract("capture_adb_screen"),
|
||||
toolContract("list_adb_processes"),
|
||||
toolContract("list_adb_directory"),
|
||||
toolContract("list_adb_features"),
|
||||
toolContract("list_adb_services"),
|
||||
toolContract("inspect_adb_display"),
|
||||
toolContract("inspect_adb_window"),
|
||||
toolContract("read_adb_setting"),
|
||||
toolContract("collect_adb_bugreport"),
|
||||
toolContract("resolve_adb_packages"),
|
||||
toolContract("install_adb_package"),
|
||||
toolContract("uninstall_adb_package"),
|
||||
toolContract("start_adb_app"),
|
||||
toolContract("start_adb_activity"),
|
||||
toolContract("stop_adb_app"),
|
||||
] as const) {
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
|
||||
const serve = async (
|
||||
argv: readonly string[],
|
||||
environment: Readonly<Record<string, string | undefined>> = {},
|
||||
): Promise<{ readonly stdout: string; readonly exitCode: number }> => {
|
||||
const cli = createCli(environment);
|
||||
let stdout = "";
|
||||
let exitCode = 0;
|
||||
await cli.serve([...argv], {
|
||||
env: {},
|
||||
exit: (code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
});
|
||||
return { stdout, exitCode };
|
||||
};
|
||||
|
||||
it.each([
|
||||
["inspect-adb-client"],
|
||||
["list-adb-devices"],
|
||||
["inspect-adb-device"],
|
||||
["list-adb-packages"],
|
||||
["pull-adb-package"],
|
||||
["read-adb-logcat"],
|
||||
["inspect-adb-package"],
|
||||
["pull-adb-file"],
|
||||
["push-adb-file"],
|
||||
["capture-adb-screen"],
|
||||
["list-adb-processes"],
|
||||
["list-adb-directory"],
|
||||
["list-adb-features"],
|
||||
["list-adb-services"],
|
||||
["inspect-adb-display"],
|
||||
["inspect-adb-window"],
|
||||
["read-adb-setting"],
|
||||
["collect-adb-bugreport"],
|
||||
["resolve-adb-packages"],
|
||||
["install-adb-package"],
|
||||
["uninstall-adb-package"],
|
||||
["start-adb-app"],
|
||||
["start-adb-activity"],
|
||||
["stop-adb-app"],
|
||||
])("%s advertises a schema through the public CLI", async (command) => {
|
||||
const result = await serve([command, "--schema", "--json"]);
|
||||
expect(result.exitCode, command).toBe(0);
|
||||
const schema = JSON.parse(result.stdout) as { args?: unknown };
|
||||
expect(schema).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports a missing adb binary with recovery guidance", async () => {
|
||||
const result = await serve(["inspect-adb-client", "--json"], {
|
||||
REA_ADB_PATH: "/nonexistent/adb",
|
||||
});
|
||||
expect(result.stdout).toContain("capability_unavailable");
|
||||
expect(result.stdout).toContain("REA_ADB_PATH");
|
||||
});
|
||||
|
||||
it("validates device serials before contacting any device", async () => {
|
||||
const result = await serve(["inspect-adb-device", "bad serial", "--json"]);
|
||||
expect(result.stdout).toContain("invalid_input");
|
||||
});
|
||||
|
||||
it("requires the output directory option on pull", async () => {
|
||||
const result = await serve([
|
||||
"pull-adb-package",
|
||||
"emulator-5554",
|
||||
"com.example",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import type { AdbDeviceAnalysisPort } from "../../../src/application/adb/AdbDeviceAnalysisPort.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
const identity = { id: "adb", name: "Android Debug Bridge", version: null };
|
||||
|
||||
const clientIdentity = {
|
||||
binary_path: "/usr/bin/adb",
|
||||
path_source: "path",
|
||||
version: "34.0.5-debian",
|
||||
installed_path: "/usr/bin/adb",
|
||||
};
|
||||
|
||||
const results: Readonly<Record<string, unknown>> = {
|
||||
inspect_adb_client: {
|
||||
client: clientIdentity,
|
||||
contacted_adb_server: false,
|
||||
},
|
||||
list_adb_devices: {
|
||||
client: clientIdentity,
|
||||
devices: [
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
may_have_started_adb_server: false,
|
||||
},
|
||||
inspect_adb_device: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
properties: [{ name: "ro.build.version.sdk", value: "34" }],
|
||||
property_count: 1,
|
||||
emulator_observed: true,
|
||||
},
|
||||
list_adb_packages: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
scope: "third_party",
|
||||
packages: [
|
||||
{
|
||||
package_name: "com.example",
|
||||
base_apk_device_path: "/data/app/x/base.apk",
|
||||
},
|
||||
],
|
||||
unparsed_lines: [],
|
||||
coverage: "complete",
|
||||
},
|
||||
pull_adb_package: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
package_name: "com.example",
|
||||
output_directory: "/tmp/rea-pulls",
|
||||
artifacts: [
|
||||
{
|
||||
device_path: "/data/app/x/base.apk",
|
||||
file_name: "base.apk",
|
||||
local_path: "/tmp/rea-pulls/com.example/base.apk",
|
||||
bytes: 2048,
|
||||
sha256: "a".repeat(64),
|
||||
role: "base",
|
||||
role_basis: "file_name",
|
||||
},
|
||||
],
|
||||
failures: [],
|
||||
coverage: "complete",
|
||||
},
|
||||
read_adb_logcat: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
count: 500,
|
||||
buffer: "main",
|
||||
pid: null,
|
||||
lines: ["08-10 13:00:00.000 I/Tag( 123): message"],
|
||||
total_bytes: 34,
|
||||
coverage: "complete",
|
||||
},
|
||||
inspect_adb_package: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
package_name: "com.example",
|
||||
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: null,
|
||||
user_id: 10234,
|
||||
pkg_flags: "[ HAS_CODE ]",
|
||||
requested_permissions_count: 2,
|
||||
coverage: "complete",
|
||||
},
|
||||
pull_adb_file: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
device_path: "/data/local/tmp/notes.txt",
|
||||
local_path: "/tmp/rea-pulls/notes.txt",
|
||||
bytes: 10,
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
push_adb_file: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
local_path: "/tmp/payload.bin",
|
||||
device_path: "/data/local/tmp/payload.bin",
|
||||
bytes: 12,
|
||||
sha256: "a".repeat(64),
|
||||
device_sha256: "a".repeat(64),
|
||||
overwritten: false,
|
||||
device_digest_source: "device_sha256sum",
|
||||
},
|
||||
capture_adb_screen: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
local_path: "/tmp/rea-captures/screen.png",
|
||||
bytes: 8192,
|
||||
sha256: "a".repeat(64),
|
||||
width: 1080,
|
||||
height: 2340,
|
||||
},
|
||||
list_adb_processes: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
columns: "USER PID PPID VSZ RSS WCHAN ADDR S NAME",
|
||||
processes: [
|
||||
{
|
||||
user: "u0_a1",
|
||||
pid: 1234,
|
||||
ppid: 567,
|
||||
rss: 7890,
|
||||
state: "S",
|
||||
name: "com.example.app",
|
||||
},
|
||||
],
|
||||
unparsed_lines: [],
|
||||
coverage: "complete",
|
||||
},
|
||||
list_adb_directory: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
device_path: "/data/local/tmp",
|
||||
entries: [
|
||||
{
|
||||
name: "notes.txt",
|
||||
kind: "file",
|
||||
permissions: "-rw-rw-rw-",
|
||||
owner: "shell",
|
||||
group: "shell",
|
||||
bytes: 10,
|
||||
date: "2026-01-01 10:00",
|
||||
link_target: null,
|
||||
},
|
||||
],
|
||||
unparsed_lines: [],
|
||||
coverage: "complete",
|
||||
},
|
||||
list_adb_features: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
features: [{ name: "android.hardware.camera", version: null }],
|
||||
unparsed_lines: [],
|
||||
coverage: "complete",
|
||||
},
|
||||
list_adb_services: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
services: [
|
||||
{ name: "package", interface: "android.content.pm.IPackageManager" },
|
||||
],
|
||||
unparsed_lines: [],
|
||||
coverage: "complete",
|
||||
},
|
||||
inspect_adb_display: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
physical_size: { width: 1080, height: 2340 },
|
||||
override_size: null,
|
||||
physical_density: 420,
|
||||
override_density: null,
|
||||
},
|
||||
inspect_adb_window: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
focused_window: "Window{abcdef u0 com.example.app/Main}",
|
||||
focused_app: null,
|
||||
observed_lines: ["Window{abcdef u0 com.example.app/Main}"],
|
||||
},
|
||||
read_adb_setting: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
namespace: "secure",
|
||||
key: "adb_enabled",
|
||||
value: "1",
|
||||
device_reported_null: false,
|
||||
},
|
||||
collect_adb_bugreport: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
local_path: "/tmp/rea-bugreports/bugreport.zip",
|
||||
bytes: 1024,
|
||||
sha256: "a".repeat(64),
|
||||
adb_reported_path: "/tmp/rea-bugreports/bugreport.zip",
|
||||
},
|
||||
resolve_adb_packages: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
query: "whatsapp",
|
||||
matches: [
|
||||
{
|
||||
package_name: "com.whatsapp",
|
||||
base_apk_device_path: "/data/app/x/base.apk",
|
||||
},
|
||||
],
|
||||
exact_match: true,
|
||||
coverage: "complete",
|
||||
},
|
||||
install_adb_package: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
apk_path: "/tmp/app.apk",
|
||||
bytes: 2048,
|
||||
sha256: "a".repeat(64),
|
||||
replaced: false,
|
||||
},
|
||||
uninstall_adb_package: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
package_name: "com.whatsapp",
|
||||
},
|
||||
start_adb_app: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
package_name: "com.example.app",
|
||||
component: "com.example.app/.Main",
|
||||
started_activity:
|
||||
"act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example.app/.Main",
|
||||
},
|
||||
start_adb_activity: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
action: "android.intent.action.VIEW",
|
||||
data_uri: "https://example.com/",
|
||||
component: null,
|
||||
extras: [],
|
||||
started_activity: "act=android.intent.action.VIEW dat=https://example.com/",
|
||||
},
|
||||
stop_adb_app: {
|
||||
client: clientIdentity,
|
||||
serial: "emulator-5554",
|
||||
package_name: "com.example.app",
|
||||
},
|
||||
};
|
||||
|
||||
it("advertises exact valid ADB schemas and records inline evidence through MCP", async () => {
|
||||
const provider: AdbDeviceAnalysisPort = {
|
||||
async inspectAvailability() {
|
||||
return { status: "available", code: null, reason: null, diagnostics: {} };
|
||||
},
|
||||
async close() {},
|
||||
async execute(request) {
|
||||
return ok(createAnalysisExecution(results[request.operation], identity));
|
||||
},
|
||||
};
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("deep provider must not start");
|
||||
});
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ adbDeviceAnalysis: provider },
|
||||
);
|
||||
onTestFinished(() => server.close());
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
const client = new Client({ name: "adb-boundary", version: "0.0.0" });
|
||||
onTestFinished(() => client.close());
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
const names = tools.map((tool) => tool.name);
|
||||
const operationNames = [
|
||||
"inspect_adb_client",
|
||||
"list_adb_devices",
|
||||
"inspect_adb_device",
|
||||
"list_adb_packages",
|
||||
"pull_adb_package",
|
||||
"read_adb_logcat",
|
||||
"inspect_adb_package",
|
||||
"pull_adb_file",
|
||||
"push_adb_file",
|
||||
"capture_adb_screen",
|
||||
"list_adb_processes",
|
||||
"list_adb_directory",
|
||||
"list_adb_features",
|
||||
"list_adb_services",
|
||||
"inspect_adb_display",
|
||||
"inspect_adb_window",
|
||||
"read_adb_setting",
|
||||
"collect_adb_bugreport",
|
||||
"resolve_adb_packages",
|
||||
"install_adb_package",
|
||||
"uninstall_adb_package",
|
||||
"start_adb_app",
|
||||
"start_adb_activity",
|
||||
"stop_adb_app",
|
||||
] as const;
|
||||
for (const name of operationNames) expect(names, name).toContain(name);
|
||||
|
||||
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
||||
for (const name of operationNames) {
|
||||
const contract = toolContract(name);
|
||||
const listed = tools.find((tool) => tool.name === name);
|
||||
expect(listed, name).toBeTruthy();
|
||||
if (listed?.inputSchema === undefined) continue;
|
||||
// The server may present a compacted form of the contract schema; what
|
||||
// must hold is that the advertised schema compiles and accepts the
|
||||
// contract's own examples, and rejects unknown input.
|
||||
const validate = ajv.compile(
|
||||
JSON.parse(JSON.stringify(listed.inputSchema)),
|
||||
);
|
||||
for (const example of contract.examples)
|
||||
expect(validate(example.input), `${name} example`).toBe(true);
|
||||
expect(validate({ unknown_argument: true }), `${name} rejects`).toBe(false);
|
||||
}
|
||||
|
||||
const pulled = await client.callTool({
|
||||
name: "pull_adb_package",
|
||||
arguments: {
|
||||
serial: "emulator-5554",
|
||||
package: "com.example",
|
||||
output_directory: "/tmp/rea-pulls",
|
||||
},
|
||||
});
|
||||
expect(pulled.isError).not.toBe(true);
|
||||
const text = (pulled.content as { readonly text?: string }[])
|
||||
.map((part) => part.text ?? "")
|
||||
.join("");
|
||||
const evidence = parseEvidence(JSON.parse(text));
|
||||
expect(evidence.provider.id).toBe("adb");
|
||||
expect(evidence.operation).toBe("pull_adb_package");
|
||||
expect(evidence.normalized_result).toMatchObject({
|
||||
artifacts: [{ file_name: "base.apk", role: "base", bytes: 2048 }],
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid tool input before reaching the provider", async () => {
|
||||
const provider: AdbDeviceAnalysisPort = {
|
||||
async inspectAvailability() {
|
||||
return { status: "available", code: null, reason: null, diagnostics: {} };
|
||||
},
|
||||
async close() {},
|
||||
async execute() {
|
||||
throw new Error("must not execute");
|
||||
},
|
||||
};
|
||||
const server = createServer(
|
||||
{
|
||||
kind: "session",
|
||||
session: createTestBinarySession(() => {
|
||||
throw new Error("must not start");
|
||||
}),
|
||||
},
|
||||
{ adbDeviceAnalysis: provider },
|
||||
);
|
||||
onTestFinished(() => server.close());
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
const client = new Client({ name: "adb-boundary-invalid", version: "0.0.0" });
|
||||
onTestFinished(() => client.close());
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const rejected = await client.callTool({
|
||||
name: "inspect_adb_device",
|
||||
arguments: { serial: "bad serial" },
|
||||
});
|
||||
expect(rejected.isError).toBe(true);
|
||||
const text = (rejected.content as { readonly text?: string }[])
|
||||
.map((part) => part.text ?? "")
|
||||
.join("");
|
||||
expect(text).toContain("Invalid arguments for tool inspect_adb_device");
|
||||
});
|
||||
+1
-1
@@ -37,7 +37,7 @@ const TEST_PROJECTS = [
|
||||
name: "adapters",
|
||||
include: [
|
||||
"src/*.test.ts",
|
||||
"src/{artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts",
|
||||
"src/{adb,artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts",
|
||||
],
|
||||
pool: "forks" as const,
|
||||
maxWorkers: MAX_TEST_WORKERS,
|
||||
|
||||
Reference in New Issue
Block a user