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:
Quentin Limo
2026-10-10 23:01:24 +08:00
committed by GitHub
co-authored by REA Contributor morluto
parent efd81f9776
commit fef6f73064
48 changed files with 6862 additions and 1 deletions
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+1
View File
@@ -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) |
+164
View File
@@ -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.
+31
View File
@@ -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`
+1
View File
@@ -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",
+9
View File
@@ -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:
+2
View File
@@ -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",
+400
View File
@@ -0,0 +1,400 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AdbDeviceAnalysisService } from "../dist/application/adb/AdbDeviceAnalysisService.js";
import { createAdbDeviceAnalysisProvider } from "../dist/composition/adb.js";
const options = parseArguments(process.argv.slice(2));
if (options.help) {
process.stdout.write(
"Usage: npm run verify:adb [-- --serial SERIAL --pull PACKAGE]\n" +
"Requires a caller-selected adb binary (REA_ADB_PATH or PATH) and a\n" +
"connected device or emulator. Verifies all five operations against\n" +
"the live device; --pull acquires a real package and re-digests the\n" +
"files on disk against the returned SHA-256 values.\n",
);
process.exit(0);
}
const provider = createAdbDeviceAnalysisProvider(process.env);
const service = new AdbDeviceAnalysisService(provider);
let failures = 0;
const check = (name, condition, detail = "") => {
const mark = condition ? "ok" : "FAIL";
process.stdout.write(
`[${mark}] ${name}${detail === "" ? "" : ` — ${detail}`}\n`,
);
if (!condition) failures += 1;
};
const run = async (operation, input) => {
const result = await service.execute(operation, input);
if (!result.ok) {
process.stdout.write(`[FAIL] ${operation} — ${result.error.message}\n`);
failures += 1;
return null;
}
return result.value.normalized_result;
};
const client = await run("inspect_adb_client", {});
if (client !== null) {
check(
"adb client reports a version",
typeof client.client.version === "string" && client.client.version !== "",
`binary=${client.client.binary_path} version=${client.client.version}`,
);
}
const listing = await run("list_adb_devices", {});
let serial = options.serial ?? null;
if (listing !== null) {
check(
"device listing parsed",
Array.isArray(listing.devices),
`${listing.devices.length} device(s)`,
);
const usable = listing.devices.filter((device) => device.state === "device");
if (serial === null && usable.length > 0) serial = usable[0].serial;
if (serial === null) {
process.stdout.write(
"[FAIL] no usable device is attached; start an emulator or connect a phone\n",
);
failures += 1;
} else {
const selected = listing.devices.find((device) => device.serial === serial);
check(
"selected device present with a kind inference",
selected !== undefined &&
["emulator", "physical", "unknown"].includes(selected.kind),
`serial=${serial} kind=${selected?.kind} basis=${selected?.kind_basis}`,
);
}
}
if (serial !== null) {
const device = await run("inspect_adb_device", { serial });
if (device !== null) {
check(
"device build identity projected",
device.properties.some(
(property) => property.name === "ro.build.version.sdk",
),
`sdk=${
device.properties.find((p) => p.name === "ro.build.version.sdk")?.value
} emulator_observed=${String(device.emulator_observed)}`,
);
}
// Observation tools: processes, window, display, settings, logcat.
const processes = await run("list_adb_processes", { serial });
if (processes !== null) {
check(
"process listing is non-empty with a header",
processes.processes.length > 0 && typeof processes.columns === "string",
`${processes.processes.length} process(es)`,
);
}
const window = await run("inspect_adb_window", { serial });
if (window !== null)
check(
"window focus observation completed",
typeof window.focused_window === "string" ||
window.focused_window === null,
`focused_window=${String(window.focused_window).slice(0, 60)} (null is legitimate on headless devices)`,
);
const display = await run("inspect_adb_display", { serial });
if (display !== null)
check(
"display reports a physical size",
display.physical_size !== null &&
display.physical_size.width > 0 &&
display.physical_size.height > 0,
`${display.physical_size?.width}x${display.physical_size?.height} @${String(display.physical_density)}`,
);
const setting = await run("read_adb_setting", {
serial,
namespace: "global",
key: "adb_enabled",
});
if (setting !== null)
check(
"settings read returns the device's own value",
setting.value === "1",
`adb_enabled=${String(setting.value)}`,
);
const logcat = await run("read_adb_logcat", {
serial,
count: 20,
buffer: "main",
});
if (logcat !== null)
check(
"logcat dump returns bounded lines",
logcat.lines.length <= 20,
`${logcat.lines.length} line(s), coverage=${logcat.coverage}`,
);
const features = await run("list_adb_features", { serial });
if (features !== null)
check(
"feature inventory is non-empty",
features.coverage === "complete" && features.features.length > 0,
`${features.features.length} feature(s)`,
);
const services = await run("list_adb_services", { serial });
if (services !== null)
check(
"service inventory is non-empty",
services.coverage === "complete" && services.services.length > 0,
`${services.services.length} service(s)`,
);
const directory = await run("list_adb_directory", {
serial,
device_path: "/data/local/tmp",
});
if (directory !== null)
check(
"directory listing completed",
directory.coverage === "complete",
`${directory.entries.length} entr(ies)`,
);
const packages = await run("list_adb_packages", {
serial,
scope: "third_party",
});
let pullCandidate = options.pull ?? null;
if (packages !== null) {
// A third-party scope may legitimately be empty (clean emulator images);
// completeness is about unparsed lines, not row count.
check(
"third-party package listing reports complete coverage",
packages.coverage === "complete",
`${packages.packages.length} package(s)`,
);
const allPackages = await run("list_adb_packages", {
serial,
scope: "all",
});
if (allPackages !== null) {
check(
"full package inventory is non-empty",
allPackages.coverage === "complete" && allPackages.packages.length > 0,
`${allPackages.packages.length} package(s)`,
);
}
if (pullCandidate === null && packages.packages.length > 0)
pullCandidate = packages.packages[0].package_name;
}
if (pullCandidate !== null) {
const directory = await mkdtemp(join(tmpdir(), "rea-adb-verify-"));
try {
const pull = await run("pull_adb_package", {
serial,
package: pullCandidate,
output_directory: directory,
});
if (pull !== null) {
// Installed /data/app packages name their APKs base.apk and
// split_*.apk; system packages keep arbitrary names, so the lane
// checks a non-empty artifact set and verifies each file on disk.
check(
"pull returned a non-empty artifact set",
pull.artifacts.length > 0,
`${pull.artifacts.length} artifact(s), coverage=${pull.coverage}, roles=${pull.artifacts.map((a) => a.role).join(",")}`,
);
for (const artifact of pull.artifacts) {
const bytes = await stat(artifact.local_path).then(
(info) => info.size,
);
const digest = createHash("sha256")
.update(await readFile(artifact.local_path))
.digest("hex");
check(
`artifact digest matches on disk: ${artifact.file_name}`,
bytes === artifact.bytes && digest === artifact.sha256,
`${bytes} bytes`,
);
}
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}
// File transfer: push one lane-owned file, verify both digests, pull it
// back, then clean up the device copy through the same fixed argv surface.
const transferRoot = await mkdtemp(join(tmpdir(), "rea-adb-transfer-"));
try {
const payload = join(transferRoot, "rea-verify.txt");
const payloadBytes = "rea adb transfer verification\n";
await import("node:fs/promises").then((fs) =>
fs.writeFile(payload, payloadBytes),
);
const deviceTarget = `/data/local/tmp/rea-verify-${Date.now()}.txt`;
const pushed = await run("push_adb_file", {
serial,
local_path: payload,
device_path: deviceTarget,
overwrite: false,
});
if (pushed !== null) {
check(
"push reports matching device-side digest",
pushed.device_digest_source === "device_sha256sum" &&
pushed.device_sha256 === pushed.sha256,
`${pushed.bytes} bytes`,
);
const pulled = await run("pull_adb_file", {
serial,
device_path: deviceTarget,
output_directory: transferRoot,
});
if (pulled !== null)
check(
"pulled file digest matches the pushed digest",
pulled.sha256 === pushed.sha256,
`${pulled.bytes} bytes`,
);
}
const screenRoot = await mkdtemp(join(tmpdir(), "rea-adb-screen-"));
try {
const screen = await run("capture_adb_screen", {
serial,
output_directory: screenRoot,
});
if (screen !== null)
check(
"screen capture is a sized PNG",
screen.width !== null && screen.height !== null && screen.bytes > 0,
`${screen.width}x${screen.height}, ${screen.bytes} bytes`,
);
} finally {
await rm(screenRoot, { recursive: true, force: true });
}
if (options.pull !== null) {
const details = await run("inspect_adb_package", {
serial,
package: options.pull,
});
if (details !== null)
check(
"dumpsys package projection completed",
details.coverage === "complete",
`versionCode=${String(details.version_code)}`,
);
}
if (options.installApk !== null && options.installApk !== undefined) {
const probePackage = "com.rea.adb.probe";
const installedProbe = await run("install_adb_package", {
serial,
apk_path: options.installApk,
replace: false,
});
if (installedProbe !== null) {
check(
"install reports the APK digest",
typeof installedProbe.sha256 === "string" &&
installedProbe.sha256.length === 64,
`${installedProbe.bytes} bytes`,
);
const resolved = await run("resolve_adb_packages", {
serial,
query: "rea.adb.probe",
});
if (resolved !== null)
check(
"resolve finds the installed probe uniquely",
resolved.matches.length === 1 &&
resolved.matches[0]?.package_name === probePackage,
`${resolved.matches.length} match(es)`,
);
const started = await run("start_adb_app", {
serial,
package: probePackage,
});
if (started !== null)
check(
"app start resolves and launches the launcher activity",
typeof started.component === "string" &&
started.component.startsWith(probePackage),
String(started.component),
);
const observedProcesses = await run("list_adb_processes", { serial });
if (observedProcesses !== null)
check(
"started app appears in the process listing",
observedProcesses.processes.some(
(process) => process.name === probePackage,
),
);
const stopped = await run("stop_adb_app", {
serial,
package: probePackage,
});
if (stopped !== null)
check(
"force-stop completes the dynamic-analysis run",
stopped.package_name === probePackage,
);
const removed = await run("uninstall_adb_package", {
serial,
package: probePackage,
});
if (removed !== null)
check(
"uninstall completes the lifecycle",
removed.package_name === probePackage,
);
const afterRemoval = await run("resolve_adb_packages", {
serial,
query: "rea.adb.probe",
});
if (afterRemoval !== null)
check(
"resolve reports zero matches after removal",
afterRemoval.matches.length === 0,
);
}
}
} finally {
await rm(transferRoot, { recursive: true, force: true });
}
}
try {
await provider.close();
} catch {
// The provider owns nothing long-lived; closing is best-effort.
}
if (failures > 0) {
process.stdout.write(`\n${failures} check(s) failed\n`);
process.exit(1);
}
process.stdout.write("\nall adb device checks passed\n");
function parseArguments(argv) {
const parsed = { help: false, serial: null, pull: null, installApk: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help" || argument === "-h") parsed.help = true;
else if (argument === "--serial") {
parsed.serial = argv[index + 1] ?? null;
index += 1;
} else if (argument === "--pull") {
parsed.pull = argv[index + 1] ?? null;
index += 1;
} else if (argument === "--install-apk") {
parsed.installApk = argv[index + 1] ?? null;
index += 1;
} else {
process.stderr.write(`unknown argument: ${argument}\n`);
process.exit(2);
}
}
return parsed;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
ADB_BINARY_ENV,
parseAdbVersionOutput,
resolveAdbBinary,
} from "./AdbConfiguration.js";
describe("resolveAdbBinary", () => {
it("prefers an absolute REA_ADB_PATH", () => {
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: "/opt/tools/adb" })).toEqual({
binary: "/opt/tools/adb",
pathSource: "environment",
});
});
it("falls back to PATH resolution without the environment key", () => {
expect(resolveAdbBinary({})).toEqual({
binary: "adb",
pathSource: "path",
});
expect(resolveAdbBinary({ [ADB_BINARY_ENV]: " " })).toEqual({
binary: "adb",
pathSource: "path",
});
});
it("rejects a relative environment path explicitly", () => {
expect(() => resolveAdbBinary({ [ADB_BINARY_ENV]: "tools/adb" })).toThrow(
/must be an absolute path/u,
);
});
});
describe("parseAdbVersionOutput", () => {
it("reads the build version and install path from real output", () => {
expect(
parseAdbVersionOutput(
[
"Android Debug Bridge version 1.0.41",
"Version 34.0.5-debian",
"Installed as /usr/lib/android-sdk/platform-tools/adb",
"",
].join("\n"),
),
).toEqual({
version: "34.0.5-debian",
installedPath: "/usr/lib/android-sdk/platform-tools/adb",
});
});
it("reports null identity fields when the named lines are absent", () => {
expect(parseAdbVersionOutput("unexpected output")).toEqual({
version: null,
installedPath: null,
});
});
});
+144
View File
@@ -0,0 +1,144 @@
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { JsonValue } from "../domain/jsonValue.js";
/** Environment key selecting the caller's adb binary. */
export const ADB_BINARY_ENV = "REA_ADB_PATH";
const VERSION_TIMEOUT_MS = 10_000;
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
/** How the adb binary was selected for this provider instance. */
export interface AdbBinarySelection {
readonly binary: string;
readonly pathSource: "environment" | "path";
}
/**
* Resolve the caller-supplied adb binary. `REA_ADB_PATH` must be absolute so
* the selection stays explicit; without it, adb is resolved from `PATH` by
* the platform exec at run time, like any bring-your-own tool.
*/
export const resolveAdbBinary = (
environment: Readonly<Record<string, string | undefined>>,
): AdbBinarySelection => {
const configured = environment[ADB_BINARY_ENV];
if (configured === undefined || configured.trim() === "")
return { binary: "adb", pathSource: "path" };
if (!isAbsolute(configured))
throw new AdbConfigurationFailure(
"not_absolute",
`${ADB_BINARY_ENV} must be an absolute path to the adb binary`,
{ configured_path: configured },
);
return { binary: configured, pathSource: "environment" };
};
export type AdbConfigurationFailureCode =
| "not_absolute"
| "binary_missing"
| "version_unresolved";
export class AdbConfigurationFailure extends Error {
constructor(
readonly code: AdbConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
super(message, options);
}
}
export interface AdbClientIdentity {
readonly version: string | null;
readonly installedPath: string | null;
}
/**
* Parse `adb version` output. The first line carries the protocol version,
* `Version ` the build version (no colon), and `Installed as` the binary path.
*/
export const parseAdbVersionOutput = (stdout: string): AdbClientIdentity => {
const build = /^Version\s+(\S.*)$/mu.exec(stdout);
const installed = /^Installed as\s+(.+)$/mu.exec(stdout);
return {
version: build === null ? null : build[1]!.trim(),
installedPath: installed === null ? null : installed[1]!.trim(),
};
};
/**
* Probe the selected adb binary without contacting devices. `adb version`
* never starts the adb server, so this stays a pure client check.
*/
export const inspectAdbClient = async (
selection: AdbBinarySelection,
signal?: AbortSignal,
): Promise<AdbClientIdentity> => {
try {
const { stdout } = await execFileOutput(selection.binary, ["version"], {
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
});
return parseAdbVersionOutput(stdout);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
throw new AnalysisCancelledError("inspect_adb_client", { cause });
if (failure?.code === "ENOENT")
throw new AdbConfigurationFailure(
"binary_missing",
`The selected adb binary was not found: ${selection.binary}`,
{
selected_binary: selection.binary,
path_source: selection.pathSource,
remediation: `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary`,
},
{ cause },
);
throw new AdbConfigurationFailure(
"version_unresolved",
"The selected adb binary did not report a usable version",
{
selected_binary: selection.binary,
...(failure === undefined
? {}
: {
exit_code: failure.code,
stderr_excerpt: failure.stderr.slice(0, 512),
}),
},
{ cause },
);
}
};
/** Availability in the shape shared by every provider. */
const rejectionCodeOf = (
code: AdbConfigurationFailureCode,
): ProviderRejectionCode =>
code === "binary_missing"
? "executable_missing"
: code === "not_absolute"
? "not_configured"
: "version_unresolved";
export const adbConfigurationAvailability = (
cause: AdbConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from "vitest";
import {
parseAdbDevicesOutput,
parseGetpropOutput,
parsePackageListOutput,
parsePmPathOutput,
} from "./AdbDeviceOutput.js";
describe("parseAdbDevicesOutput", () => {
it("parses emulator, USB, and network devices with their metadata", () => {
const output = [
"List of devices attached",
"emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1",
"07ef9c2a device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2",
"192.168.1.42:5555 device product:mock model:mock transport_id:3",
"",
].join("\n");
const { devices, unparsedLines, daemonStarted } =
parseAdbDevicesOutput(output);
expect(unparsedLines).toEqual([]);
expect(daemonStarted).toBe(false);
expect(devices).toEqual([
{
serial: "emulator-5554",
state: "device",
transport: "tcp",
product: "sdk_gphone64_x86_64",
model: "sdk_gphone64_x86_64",
device: "emu64xa",
transport_id: 1,
kind: "emulator",
kind_basis: "emulator_serial_prefix",
},
{
serial: "07ef9c2a",
state: "device",
transport: "usb",
product: "raven",
model: "Pixel_6_Pro",
device: "raven",
transport_id: 2,
kind: "physical",
kind_basis: "usb_transport",
},
{
serial: "192.168.1.42:5555",
state: "device",
transport: "tcp",
product: "mock",
model: "mock",
device: null,
transport_id: 3,
kind: "unknown",
kind_basis: "none",
},
]);
});
it("recognizes daemon startup notices instead of failing the listing", () => {
const output = [
"* daemon not running; starting now at tcp:5037",
"* daemon started successfully",
"List of devices attached",
"emulator-5554 device transport_id:1",
"",
].join("\n");
const { devices, daemonStarted } = parseAdbDevicesOutput(output);
expect(daemonStarted).toBe(true);
expect(devices).toHaveLength(1);
});
it("keeps non-device states such as unauthorized and offline", () => {
const output = [
"List of devices attached",
"emulator-5556 offline transport_id:2",
"HT84L0240512 unauthorized usb:1-2 transport_id:3",
"",
].join("\n");
const { devices } = parseAdbDevicesOutput(output);
expect(devices.map((device) => [device.serial, device.state])).toEqual([
["emulator-5556", "offline"],
["HT84L0240512", "unauthorized"],
]);
});
it("returns unrecognized lines instead of dropping them", () => {
const output = [
"List of devices attached",
"this is not a device line",
"",
].join("\n");
const { unparsedLines } = parseAdbDevicesOutput(output);
expect(unparsedLines).toEqual(["this is not a device line"]);
});
it("reports a non-numeric transport_id as unknown", () => {
const output = [
"List of devices attached",
"emulator-5554 device transport_id:not-a-number",
"",
].join("\n");
const { devices } = parseAdbDevicesOutput(output);
expect(devices[0]?.transport_id).toBeNull();
});
});
describe("parseGetpropOutput", () => {
it("projects the whitelisted build identity from a real dump", () => {
const dump = [
"[ro.build.version.release]: [14]",
"[ro.build.version.sdk]: [34]",
"[ro.build.version.security_patch]: [2024-06-05]",
"[ro.build.id]: [AP21.240617.014]",
"[ro.build.fingerprint]: [google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys]",
"[ro.product.brand]: [google]",
"[ro.product.device]: [emu64xa]",
"[ro.product.manufacturer]: [Google]",
"[ro.product.model]: [sdk_gphone64_x86_64]",
"[ro.product.cpu.abilist]: [x86_64,x86,arm64-v8a,armeabi-v7a,armeabi]",
"[ro.kernel.qemu]: [1]",
"[ro.debuggable]: [1]",
"[sys.unlisted.property]: [dropped]",
"",
].join("\n");
const { properties, emulatorObserved } = parseGetpropOutput(dump);
expect(emulatorObserved).toBe(true);
expect(properties).not.toContainEqual({
name: "sys.unlisted.property",
value: "dropped",
});
expect(properties).toEqual([
{ name: "ro.build.version.release", value: "14" },
{ name: "ro.build.version.sdk", value: "34" },
{ name: "ro.build.version.security_patch", value: "2024-06-05" },
{ name: "ro.build.id", value: "AP21.240617.014" },
{
name: "ro.build.fingerprint",
value:
"google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys",
},
{ name: "ro.debuggable", value: "1" },
{ name: "ro.kernel.qemu", value: "1" },
{ name: "ro.product.brand", value: "google" },
{ name: "ro.product.device", value: "emu64xa" },
{ name: "ro.product.manufacturer", value: "Google" },
{ name: "ro.product.model", value: "sdk_gphone64_x86_64" },
{
name: "ro.product.cpu.abilist",
value: "x86_64,x86,arm64-v8a,armeabi-v7a,armeabi",
},
]);
});
it("reports an explicit non-emulator build without inventing a value", () => {
const dump = [
"[ro.build.version.release]: [13]",
"[ro.kernel.qemu]: [0]",
"",
].join("\n");
const { emulatorObserved } = parseGetpropOutput(dump);
expect(emulatorObserved).toBe(false);
});
it("reports the emulator indicator unknown when the build omits it", () => {
const { emulatorObserved } = parseGetpropOutput(
"[ro.build.version.sdk]: [33]\n",
);
expect(emulatorObserved).toBeNull();
});
});
describe("parsePackageListOutput", () => {
it("parses package lines with their base APK device paths", () => {
const output = [
"package:/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk=com.example",
"package:/system/app/WebView/WebView.apk=com.android.webview",
"",
].join("\n");
const { packages, unparsedLines } = parsePackageListOutput(output);
expect(unparsedLines).toEqual([]);
expect(packages).toEqual([
{
package_name: "com.example",
base_apk_device_path:
"/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk",
},
{
package_name: "com.android.webview",
base_apk_device_path: "/system/app/WebView/WebView.apk",
},
]);
});
it("keeps unrecognized lines for partial coverage reporting", () => {
const { packages, unparsedLines } = parsePackageListOutput(
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
);
expect(packages).toHaveLength(1);
expect(unparsedLines).toEqual(["garbage line"]);
});
});
describe("parsePmPathOutput", () => {
it("classifies base and split APKs from their file names", () => {
const output = [
"package:/data/app/~~a==/com.example.b==/base.apk",
"package:/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk",
"package:/data/app/~~a==/com.example.b==/split_config.en.apk",
"",
].join("\n");
const { paths, unparsedLines } = parsePmPathOutput(output);
expect(unparsedLines).toEqual([]);
expect(paths).toEqual([
{
device_path: "/data/app/~~a==/com.example.b==/base.apk",
file_name: "base.apk",
role: "base",
},
{
device_path:
"/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk",
file_name: "split_config.arm64_v8a.apk",
role: "split",
},
{
device_path: "/data/app/~~a==/com.example.b==/split_config.en.apk",
file_name: "split_config.en.apk",
role: "split",
},
]);
});
it("reports an unknown role for names that follow neither convention", () => {
const { paths } = parsePmPathOutput("package:/system/app/X/Y.apk\n");
expect(paths[0]).toEqual({
device_path: "/system/app/X/Y.apk",
file_name: "Y.apk",
role: "unknown",
});
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* Parsers for the exact adb and Package Manager output formats REA consumes.
*
* Each parser preserves what the tool actually printed: unparseable lines are
* retained rather than silently dropped, and derived classifications carry
* their evidence basis.
*/
/** One device exactly as `adb devices -l` reports it. */
export interface AdbDeviceEntry {
readonly serial: string;
readonly state: string;
readonly transport: "usb" | "tcp" | "unknown";
readonly product: string | null;
readonly model: string | null;
readonly device: string | null;
readonly transport_id: number | null;
readonly kind: "emulator" | "physical" | "unknown";
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
}
/**
* Device states adb prints after the serial, longest first so two-word
* states match before their single-word prefixes. A line whose remainder
* starts with none of them is not a device line.
*/
const DEVICE_STATES = [
"no permissions",
"unauthorized",
"authorizing",
"offline",
"recovery",
"sideload",
"connecting",
"bootloader",
"device",
"unknown",
"host",
] as const;
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
const metadataValue = (remainder: string, key: string): string | null => {
const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder);
return match === null ? null : match[1]!;
};
/**
* Parse `adb devices -l` device lines. The banner, blank lines, and the
* daemon startup notices adb prints on first use are recognized separately;
* every other unrecognized line is returned for honest reporting.
*/
export const parseAdbDevicesOutput = (
stdout: string,
): {
devices: readonly AdbDeviceEntry[];
unparsedLines: readonly string[];
daemonStarted: boolean;
} => {
const devices: AdbDeviceEntry[] = [];
const unparsedLines: string[] = [];
let daemonStarted = false;
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line === "" || line.startsWith("List of devices attached")) continue;
if (line.startsWith("* daemon")) {
if (line.includes("started successfully")) daemonStarted = true;
continue;
}
const match = DEVICE_LINE.exec(line);
const state = DEVICE_STATES.find((candidate) =>
match?.[2]?.startsWith(candidate),
);
if (match === null || state === undefined) {
if (line.trim() !== "") unparsedLines.push(line);
continue;
}
const serial = match[1]!;
const remainder = match[2]!.slice(state.length);
const usb = metadataValue(remainder, "usb");
const transportIdText = metadataValue(remainder, "transport_id");
const parsedTransportId =
transportIdText === null || !/^\d+$/u.test(transportIdText)
? null
: Number(transportIdText);
const isEmulator = /^emulator-\d+$/u.test(serial);
const kind = isEmulator
? ("emulator" as const)
: usb !== null
? ("physical" as const)
: ("unknown" as const);
devices.push({
serial,
state,
transport:
usb !== null
? "usb"
: isEmulator || serial.includes(":")
? "tcp"
: "unknown",
product: metadataValue(remainder, "product"),
model: metadataValue(remainder, "model"),
device: metadataValue(remainder, "device"),
transport_id: parsedTransportId,
kind,
kind_basis:
kind === "emulator"
? "emulator_serial_prefix"
: kind === "physical"
? "usb_transport"
: "none",
});
}
return { devices, unparsedLines, daemonStarted };
};
/**
* Properties projected from a `getprop` dump. The whitelist keeps device
* inspection to build identity facts; a full dump is out of scope.
*/
const DEVICE_PROPERTY_WHITELIST = [
"ro.build.version.release",
"ro.build.version.sdk",
"ro.build.version.security_patch",
"ro.build.id",
"ro.build.fingerprint",
"ro.build.characteristics",
"ro.debuggable",
"ro.kernel.qemu",
"ro.product.brand",
"ro.product.device",
"ro.product.manufacturer",
"ro.product.model",
"ro.product.cpu.abilist",
] as const;
export interface AdbDeviceProperty {
readonly name: string;
readonly value: string;
}
/** Parse a full `getprop` dump into `[name]: [value]` pairs. */
export const parseGetpropOutput = (
stdout: string,
): {
properties: readonly AdbDeviceProperty[];
emulatorObserved: boolean | null;
} => {
const values = new Map<string, string>();
for (const rawLine of stdout.split(/\r?\n/u)) {
const match = /^\[([^\]]+)\]:\s*\[(.*)\]$/u.exec(rawLine.trim());
if (match === null) continue;
values.set(match[1]!, match[2]!);
}
const properties = DEVICE_PROPERTY_WHITELIST.flatMap((name) => {
const value = values.get(name);
return value === undefined || value === ""
? []
: [{ name, value } satisfies AdbDeviceProperty];
});
const qemu = values.get("ro.kernel.qemu");
return {
properties,
emulatorObserved: qemu === undefined ? null : qemu === "1",
};
};
/** One installed package exactly as `pm list packages -f` reports it. */
export interface AdbPackageEntry {
readonly package_name: string;
readonly base_apk_device_path: string;
}
const PACKAGE_LINE = /^package:(.+)=([^\s=]+)$/u;
/** Parse `pm list packages -f` lines, retaining unrecognized lines. */
export const parsePackageListOutput = (
stdout: string,
): {
packages: readonly AdbPackageEntry[];
unparsedLines: readonly string[];
} => {
const packages: AdbPackageEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line === "") continue;
const match = PACKAGE_LINE.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
packages.push({
package_name: match[2]!,
base_apk_device_path: match[1]!,
});
}
return { packages, unparsedLines };
};
/** One APK of an installed package exactly as `pm path` reports it. */
export interface AdbPackagePathEntry {
readonly device_path: string;
readonly file_name: string;
readonly role: "base" | "split" | "unknown";
}
const fileNameOf = (devicePath: string): string => {
const parts = devicePath.split(/\//u);
return parts[parts.length - 1] ?? devicePath;
};
/**
* Parse `pm path PACKAGE` lines. The base/split role is derived from the file
* name (`base.apk`, `split_*.apk`), which is the only signal the output
* carries; any other name reports an unknown role.
*/
export const parsePmPathOutput = (
stdout: string,
): {
paths: readonly AdbPackagePathEntry[];
unparsedLines: readonly string[];
} => {
const paths: AdbPackagePathEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line === "") continue;
const match = /^package:(\S+)$/u.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
const devicePath = match[1]!;
const fileName = fileNameOf(devicePath);
paths.push({
device_path: devicePath,
file_name: fileName,
role:
fileName === "base.apk"
? "base"
: fileName.startsWith("split_") && fileName.endsWith(".apk")
? "split"
: "unknown",
});
}
return { paths, unparsedLines };
};
+960
View File
@@ -0,0 +1,960 @@
import { createHash } from "node:crypto";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { AdbProvider } from "./AdbProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js";
const it = test.skipIf(process.platform === "win32");
const VERSION_BLOCK =
"printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' \"$0\"";
const writeAdbStub = async (
body: (root: string) => string,
): Promise<{ readonly binary: string; readonly root: string }> => {
const root = await mkdtemp(join("/tmp", "rea-adb-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const binary = join(root, "adb");
await writeFile(binary, `#!/bin/sh\n${body(root)}\n`, { mode: 0o755 });
await chmod(binary, 0o755);
return { binary, root };
};
const providerOver = (binary: string): AdbProvider =>
new AdbProvider({ environment: { REA_ADB_PATH: binary } });
const evidenceResult = async (
provider: AdbProvider,
request: AdbRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw new Error(result.error.message);
return result.value.result as Record<string, unknown>;
};
const refusedBy = async (
provider: AdbProvider,
request: AdbRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
it("inspects the adb client without contacting the server", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_client",
input: {},
});
expect(result.client).toMatchObject({
path_source: "environment",
version: "34.0.5-stub",
installed_path: binary,
});
expect(result.contacted_adb_server).toBe(false);
});
it("lists devices with inferred kinds and observed daemon startup", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "devices" ]; then
printf '* daemon not running; starting now at tcp:5037\\n* daemon started successfully\\n'
printf 'List of devices attached\\n'
printf 'emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\\n'
exit 0
fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_devices",
input: {},
});
expect(result.may_have_started_adb_server).toBe(true);
expect(result.devices).toEqual([
{
serial: "emulator-5554",
state: "device",
transport: "tcp",
product: "sdk_gphone64_x86_64",
model: "sdk_gphone64_x86_64",
device: "emu64xa",
transport_id: 1,
kind: "emulator",
kind_basis: "emulator_serial_prefix",
},
]);
});
it("inspects a device through the getprop whitelist", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ]; then
printf '[ro.build.version.sdk]: [34]\\n[ro.kernel.qemu]: [1]\\n[sys.private]: [x]\\n'
exit 0
fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_device",
input: { serial: "emulator-5554" },
});
expect(result.emulator_observed).toBe(true);
expect(result.properties).toEqual([
{ name: "ro.build.version.sdk", value: "34" },
{ name: "ro.kernel.qemu", value: "1" },
]);
});
it("passes the caller scope as an exact fixed argv vector", async () => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")}
if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then printf 'package:/data/app/x/base.apk=com.example\\n'; exit 0; fi`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_packages",
input: { serial: "emulator-5554", scope: "third_party" },
});
expect(result.coverage).toBe("complete");
expect(result.packages).toEqual([
{
package_name: "com.example",
base_apk_device_path: "/data/app/x/base.apk",
},
]);
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
expect(logged).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"pm",
"list",
"packages",
"-f",
"-3",
]);
});
const pullStub = (
paths: string,
pullBody: string,
): string => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then
printf '%s\\n' ${paths}
exit 0
fi
if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then
${pullBody}
exit 0
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
/** Stub dispatching one canned stdout per first shell word (and optional second). */
const shellStub = (
dispatch: Readonly<Record<string, string>>,
): ((root: string) => string) => {
const cases = Object.entries(dispatch)
.map(([key, output]) => {
const [first, second] = key.split(":");
const guard =
second === undefined
? `[ "$4" = "${first}" ]`
: `[ "$4" = "${first}" ] && [ "$5" = "${second}" ]`;
return ` if ${guard}; then printf '%b' '${output.replace(/'/gu, "'\\''")}'; exit 0; fi`;
})
.join("\n");
return () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then
${cases}
printf 'unexpected shell command: %s %s %s\\n' "$4" "$5" "$6" >&2
exit 1
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
};
it("pulls a complete split set with digests into a new package directory", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub(
"'package:/data/app/~~a==/com.example.b==/base.apk' 'package:/data/app/~~a==/com.example.b==/split_config.en.apk'",
` printf 'apk-bytes-for-testing' > "$5"
printf '1 file pulled, 0 skipped.\\n'`,
),
);
const output = join(root, "pulls");
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.example",
output_directory: output,
},
});
expect(result.coverage).toBe("complete");
expect(result.failures).toEqual([]);
const artifacts = result.artifacts as {
file_name: string;
local_path: string;
sha256: string;
role: string;
}[];
expect(
artifacts.map((artifact) => [artifact.file_name, artifact.role]),
).toEqual([
["base.apk", "base"],
["split_config.en.apk", "split"],
]);
const digest = createHash("sha256")
.update("apk-bytes-for-testing")
.digest("hex");
for (const artifact of artifacts) expect(artifact.sha256).toBe(digest);
const pulled = await readFile(
join(output, "com.example", "base.apk"),
"utf8",
);
expect(pulled).toBe("apk-bytes-for-testing");
});
it("keeps successful artifacts when one split pull fails", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub(
"'package:/data/app/y/base.apk' 'package:/data/app/y/split_dpi.apk'",
`case "$4" in
*/split_dpi.apk) printf 'error: closed\\n' >&2; exit 1;;
*) printf 'apk-bytes-for-testing' > "$5"; printf '1 file pulled.\\n';;
esac`,
),
);
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.partial",
output_directory: join(root, "pulls"),
},
});
expect(result.coverage).toBe("partial");
expect(result.artifacts).toHaveLength(1);
expect(result.failures).toEqual([
{
device_path: "/data/app/y/split_dpi.apk",
stage: "pull",
message: expect.stringContaining("closed") as unknown,
},
]);
});
it("refuses to reuse an existing package output directory", async () => {
const { binary, root } = await writeAdbStub(() =>
pullStub("'package:/data/app/z/base.apk'", ` printf 'x' > "$5"`),
);
const output = join(root, "existing");
await mkdir(join(output, "com.example"), { recursive: true });
const provider = providerOver(binary);
const result = await provider.execute({
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.example",
output_directory: output,
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
});
expect(result.error.message).toContain("already exists");
});
it("reports an uninstalled package from empty pm path output", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then exit 0; fi`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "pull_adb_package",
input: {
serial: "emulator-5554",
package: "com.absent",
output_directory: "/tmp/rea-adb-absent",
},
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("no APK paths");
});
it.each([
["not found", "error: device 'emulator-9' not found", "not found"],
["offline", "error: device offline", "offline"],
["unauthorized", "error: device unauthorized", "unauthorized"],
] as const)(
"reports the %s device state as a target refusal",
async (_, stderr, excerpt) => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ]; then printf '%s\\n' "${stderr.replace(/'/gu, "'\\''")}" >&2; exit 1; fi`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "inspect_adb_device",
input: { serial: "emulator-5554" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain(excerpt);
},
);
it("reports a missing selected binary as a capability gap with recovery", async () => {
const provider = new AdbProvider({
environment: { REA_ADB_PATH: "/nonexistent/adb" },
});
const availability = await provider.inspectAvailability();
expect(availability).toMatchObject({ status: "unavailable" });
if (availability.status !== "unavailable") return;
expect(availability.diagnostics.remediation).toContain("REA_ADB_PATH");
const result = await provider.execute({
operation: "list_adb_devices",
input: {},
});
expect(result.ok).toBe(false);
if (result.ok) return;
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({ code: "capability_unavailable" });
expect(projected.details).toMatchObject({
provider_id: "adb",
reason: expect.stringContaining("not found") as unknown,
});
});
it("cancels before spawning anything", async () => {
const { binary, root } = await writeAdbStub(
(root) => `: > ${join(root, "spawned")}
if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`,
);
const controller = new AbortController();
controller.abort();
const cancelled = await providerOver(binary).execute(
{ operation: "list_adb_devices", input: {} },
{ signal: controller.signal },
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok) return;
expect(projectAnalysisError(cancelled.error)).toMatchObject({
code: "cancelled",
});
expect(
await readFile(join(root, "spawned")).then(
() => true,
() => false,
),
).toBe(false);
});
it("reads a bounded logcat dump with the caller count and buffer", async () => {
const { binary } = await writeAdbStub(
shellStub({
logcat:
"08-10 13:00:00.000 I/Tag( 123): first\\n08-10 13:00:01.000 I/Tag( 123): second\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "read_adb_logcat",
input: { serial: "emulator-5554", count: 500, buffer: "main" },
});
expect(result.lines).toEqual([
"08-10 13:00:00.000 I/Tag( 123): first",
"08-10 13:00:01.000 I/Tag( 123): second",
]);
});
it("projects package details from dumpsys package output", async () => {
const { binary } = await writeAdbStub(
shellStub({
"dumpsys:package": `Packages:
Package [com.example] (abcdef):
userId=10234
versionName=1.2.3
versionCode=45 minSdk=24 targetSdk=34
firstInstallTime=2026-01-02 03:04:05
lastUpdateTime=2026-02-03 04:05:06
installerPackageName=com.android.vending
pkgFlags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ]
requested permissions:
android.permission.INTERNET
android.permission.CAMERA
`,
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_package",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(result).toMatchObject({
version_name: "1.2.3",
version_code: 45,
first_install_time: "2026-01-02 03:04:05",
last_update_time: "2026-02-03 04:05:06",
installer_package_name: "com.android.vending",
user_id: 10234,
requested_permissions_count: 2,
coverage: "complete",
});
});
it("reports an uninstalled package from dumpsys refusal output", async () => {
const { binary } = await writeAdbStub(
shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }),
);
const projected = await refusedBy(providerOver(binary), {
operation: "inspect_adb_package",
input: { serial: "emulator-5554", package: "com.absent" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
});
it("pulls one device file with a digest", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then printf 'file-bytes' > "$5"; exit 0; fi
exit 1`,
);
const output = join(root, "pulls");
const result = await evidenceResult(providerOver(binary), {
operation: "pull_adb_file",
input: {
serial: "emulator-5554",
device_path: "/data/local/tmp/notes.txt",
output_directory: output,
},
});
const digest = createHash("sha256").update("file-bytes").digest("hex");
expect(result).toMatchObject({
device_path: "/data/local/tmp/notes.txt",
local_path: join(output, "notes.txt"),
bytes: 10,
sha256: digest,
});
});
it("pushes a file and verifies the device-side digest", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "8748b5d17d1ebcec7d46674fa6a8b92c0f95598a552f27c1c8a132eb58c04043" "$5"; exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "push-payload");
const digest = createHash("sha256").update("push-payload").digest("hex");
const pushed = await evidenceResult(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(pushed).toMatchObject({
bytes: 12,
sha256: digest,
device_sha256: digest,
overwritten: false,
device_digest_source: "device_sha256sum",
});
});
it("reports a digest mismatch after push as a changed artifact", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi
if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "$(printf 'a%.0s' 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64)" "$5"; exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "push-payload");
const projected = await refusedBy(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(projected).toMatchObject({ code: "artifact_changed" });
});
it("refuses a push onto an existing device path without overwrite", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 0; fi
exit 1`,
);
const localFile = join(root, "payload.bin");
await writeFile(localFile, "x");
const result = await providerOver(binary).execute({
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: localFile,
device_path: "/data/local/tmp/exists.bin",
overwrite: false,
},
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
});
expect(result.error.message).toContain("overwrite");
});
it("reports a local push source that does not exist", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "push_adb_file",
input: {
serial: "emulator-5554",
local_path: "/nonexistent/payload.bin",
device_path: "/data/local/tmp/payload.bin",
overwrite: false,
},
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("captures the screen as a digested PNG with its dimensions", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "exec-out" ]; then
printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'
exit 0
fi
exit 1`,
);
const output = join(root, "captures");
const result = await evidenceResult(providerOver(binary), {
operation: "capture_adb_screen",
input: { serial: "emulator-5554", output_directory: output },
});
expect(result).toMatchObject({ width: 4, height: 3 });
expect(
await readFile(join(output, "screen.png")).then(
(bytes) => bytes.subarray(1, 4).toString("latin1") === "PNG",
() => false,
),
).toBe(true);
});
it("lists processes, features, and services through fixed commands", async () => {
const { binary } = await writeAdbStub(
shellStub({
ps: "USER PID PPID VSZ RSS WCHAN ADDR S NAME\nu0_a1 1234 567 123456 7890 0 0 S com.example.app\n",
"pm:list":
"feature:android.hardware.camera\nfeature:android.hardware.nfc=1\n",
"service:list":
"Found 2 services:\n 1 package: [android.content.pm.IPackageManager]\n 2 activity: [android.app.IActivityManager]\n",
}),
);
const processes = await evidenceResult(providerOver(binary), {
operation: "list_adb_processes",
input: { serial: "emulator-5554" },
});
expect(processes.processes).toEqual([
{
user: "u0_a1",
pid: 1234,
ppid: 567,
rss: 7890,
state: "S",
name: "com.example.app",
},
]);
const features = await evidenceResult(providerOver(binary), {
operation: "list_adb_features",
input: { serial: "emulator-5554" },
});
expect(features.features).toEqual([
{ name: "android.hardware.camera", version: null },
{ name: "android.hardware.nfc", version: 1 },
]);
const services = await evidenceResult(providerOver(binary), {
operation: "list_adb_services",
input: { serial: "emulator-5554" },
});
expect(services.services).toEqual([
{ name: "package", interface: "android.content.pm.IPackageManager" },
{ name: "activity", interface: "android.app.IActivityManager" },
]);
});
it("lists a device directory with kinds and symlink targets", async () => {
const { binary } = await writeAdbStub(
shellStub({
ls: "total 4\n-rw-rw---- u0_a1 u0_a1 4096 2026-01-02 11:00 app.db\nlrwxrwxrwx root root 2026-01-01 10:00 lib -> /apex/com.android.art/lib\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "list_adb_directory",
input: {
serial: "emulator-5554",
device_path: "/data/data/com.example.app",
},
});
expect(result.entries).toEqual([
{
name: "app.db",
kind: "file",
permissions: "-rw-rw----",
owner: "u0_a1",
group: "u0_a1",
bytes: 4096,
date: "2026-01-02 11:00",
link_target: null,
},
{
name: "lib",
kind: "symlink",
permissions: "lrwxrwxrwx",
owner: "root",
group: "root",
bytes: null,
date: "2026-01-01 10:00",
link_target: "/apex/com.android.art/lib",
},
]);
});
it("inspects the display and window focus", async () => {
const { binary } = await writeAdbStub(
shellStub({
"wm:size": "Physical size: 1080x2340\n",
"wm:density": "Physical density: 420\n",
"dumpsys:window":
" mCurrentFocus=Window{abcdef u0 com.example.app/com.example.app.MainActivity}\n",
}),
);
const display = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_display",
input: { serial: "emulator-5554" },
});
expect(display.physical_size).toEqual({ width: 1080, height: 2340 });
expect(display.physical_density).toBe(420);
const window = await evidenceResult(providerOver(binary), {
operation: "inspect_adb_window",
input: { serial: "emulator-5554" },
});
expect(window.focused_window).toBe(
"Window{abcdef u0 com.example.app/com.example.app.MainActivity}",
);
});
it("reads one settings value and reports device-side nulls", async () => {
const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" }));
const enabled = await evidenceResult(providerOver(binary), {
operation: "read_adb_setting",
input: {
serial: "emulator-5554",
namespace: "secure",
key: "adb_enabled",
},
});
expect(enabled.value).toBe("1");
const { binary: nullBinary } = await writeAdbStub(
shellStub({ "settings:get": "null\n" }),
);
const absent = await evidenceResult(providerOver(nullBinary), {
operation: "read_adb_setting",
input: {
serial: "emulator-5554",
namespace: "global",
key: "absent_key",
},
});
expect(absent.value).toBeNull();
expect(absent.device_reported_null).toBe(true);
});
it("resolves packages by case-insensitive substring", async () => {
const { binary } = await writeAdbStub(
shellStub({
"pm:list":
"package:/data/app/x/base.apk=com.whatsapp\\npackage:/data/app/y/base.apk=com.whatsapp.w4b\\npackage:/data/app/z/base.apk=com.example\\n",
}),
);
const result = await evidenceResult(providerOver(binary), {
operation: "resolve_adb_packages",
input: { serial: "emulator-5554", query: "WhatsApp" },
});
expect(result.matches).toEqual([
{
package_name: "com.whatsapp",
base_apk_device_path: "/data/app/x/base.apk",
},
{
package_name: "com.whatsapp.w4b",
base_apk_device_path: "/data/app/y/base.apk",
},
]);
expect(result.exact_match).toBe(false);
expect(result.coverage).toBe("complete");
});
it("installs a local APK and preserves the device's failure verdict", async () => {
const { binary, root } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Success\\n'; exit 0; fi
exit 1`,
);
const apk = join(root, "app.apk");
await writeFile(apk, "apk");
const digest = createHash("sha256").update("apk").digest("hex");
const installed = await evidenceResult(providerOver(binary), {
operation: "install_adb_package",
input: { serial: "emulator-5554", apk_path: apk, replace: false },
});
expect(installed).toMatchObject({
bytes: 3,
sha256: digest,
replaced: false,
});
const { binary: failing } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Failure [INSTALL_FAILED_ALREADY_EXISTS]\\n'; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(failing), {
operation: "install_adb_package",
input: {
serial: "emulator-5554",
apk_path: apk,
replace: false,
},
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("INSTALL_FAILED_ALREADY_EXISTS");
});
it("uninstalls a package and reports the device's refusal for absent ones", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Success\\n'; exit 0; fi
exit 1`,
);
const removed = await evidenceResult(providerOver(binary), {
operation: "uninstall_adb_package",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(removed.package_name).toBe("com.example");
const { binary: refusing } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi
exit 1`,
);
const projected = await refusedBy(providerOver(refusing), {
operation: "uninstall_adb_package",
input: { serial: "emulator-5554", package: "com.absent" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("not installed for 0");
});
it("starts an app through resolved launcher component and force-stops it", async () => {
const { binary } = await writeAdbStub(
shellStub({
"cmd:package":
"priority=0 preferredOrder=0 match=0x00108000 specificIndex=-1 isDefault=true\ncom.example/.Main",
"am:start":
"Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example/.Main }\n",
"am:force-stop": "",
}),
);
const provider = providerOver(binary);
const started = await evidenceResult(provider, {
operation: "start_adb_app",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(started).toMatchObject({
package_name: "com.example",
component: "com.example/.Main",
started_activity: expect.stringContaining(
"cmp=com.example/.Main",
) as unknown,
});
const stopped = await evidenceResult(provider, {
operation: "stop_adb_app",
input: { serial: "emulator-5554", package: "com.example" },
});
expect(stopped.package_name).toBe("com.example");
});
it("reports an app without a launcher activity as unsupported", async () => {
const { binary } = await writeAdbStub(
shellStub({ "cmd:package": "No activity found\n" }),
);
const projected = await refusedBy(providerOver(binary), {
operation: "start_adb_app",
input: { serial: "emulator-5554", package: "com.library.only" },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("No launcher activity");
});
it("starts an intent with typed extras as a fixed argv vector", async () => {
const { binary, root } = await writeAdbStub(
(root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")}
${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat=https://example.com/ }\n" })(root)}`,
);
const started = await evidenceResult(providerOver(binary), {
operation: "start_adb_activity",
input: {
serial: "emulator-5554",
action: "android.intent.action.VIEW",
data_uri: "https://example.com/",
extras: [
{ key: "level", type: "int", value: "3" },
{ key: "label", type: "string", value: "probe" },
],
},
});
expect(started.action).toBe("android.intent.action.VIEW");
const logged = (await readFile(join(root, "argv.log"), "utf8"))
.trim()
.split("\n");
expect(logged).toEqual([
"version",
"-s",
"emulator-5554",
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
"https://example.com/",
"--ei",
"level",
"3",
"--es",
"label",
"probe",
]);
});
it("rejects mistyped intent extras before touching the device", async () => {
const { binary } = await writeAdbStub(
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
: > should-not-exist`,
);
const projected = await refusedBy(providerOver(binary), {
operation: "start_adb_activity",
input: {
serial: "emulator-5554",
action: "com.example.ACTION",
extras: [{ key: "flag", type: "boolean", value: "yes" }],
},
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("collects a device bugreport archive with its digest", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then
printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}"
printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n'
exit 0
fi
exit 1`,
);
const result = await evidenceResult(providerOver(binary), {
operation: "collect_adb_bugreport",
input: { serial: "emulator-5554", output_directory: root },
});
const digest = createHash("sha256").update("bugreport-bytes").digest("hex");
expect(result).toMatchObject({
local_path: join(root, "bugreport test.zip"),
bytes: 15,
sha256: digest,
adb_reported_path: join(root, "bugreport test.zip"),
});
});
it("quotes caller text before adb joins the remote shell command", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
shift 3
# Model adb's remote command joining, then execute the resulting shell string.
printf '#!/bin/sh\nprintf "%%s" "$@" > "${join(root, "observed")}"\n' > "${join(root, "ls")}"
chmod +x "${join(root, "ls")}"
PATH="${root}:$PATH" sh -c "$*"
`,
);
const value = `/sdcard/a 'quoted'; touch ${join(root, "injected")}`;
await providerOver(binary).execute({
operation: "list_adb_directory",
input: { serial: "device", device_path: value },
});
expect(await readFile(join(root, "observed"), "utf8")).toContain(value);
await expect(readFile(join(root, "injected"))).rejects.toThrow();
});
it("preserves a screen output created while capture is running", async () => {
const { binary, root } = await writeAdbStub(
(root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
printf 'existing evidence' > "${join(root, "captures", "screen.png")}"
printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003'
`,
);
const result = await providerOver(binary).execute({
operation: "capture_adb_screen",
input: { serial: "device", output_directory: join(root, "captures") },
});
expect(result.ok).toBe(false);
expect(await readFile(join(root, "captures", "screen.png"), "utf8")).toBe(
"existing evidence",
);
});
it("reports cancellation arriving during the version probe", async () => {
const { binary } = await writeAdbStub(() => "exec sleep 30");
const controller = new AbortController();
const pending = providerOver(binary).execute(
{ operation: "inspect_adb_client", input: {} },
{ signal: controller.signal },
);
const timer = setTimeout(() => controller.abort(), 50);
try {
const result = await pending;
expect(result.ok).toBe(false);
if (!result.ok)
expect(projectAnalysisError(result.error).code).toBe("cancelled");
} finally {
clearTimeout(timer);
}
});
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
/**
* Parsers for fixed observation commands REA runs through `adb shell`.
*
* Formats follow toybox output on Android 8+ (verified against an Android 14
* emulator). Unparseable lines are retained rather than dropped so callers
* can see the format drift instead of silently losing rows.
*/
export interface AdbProcessEntry {
readonly user: string;
readonly pid: number;
readonly ppid: number | null;
readonly rss: number | null;
readonly state: string | null;
readonly name: string;
}
const PROCESS_LINE =
/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/u;
/** Parse `ps -A` output; the header line is preserved verbatim as `columns`. */
export const parseProcessListOutput = (
stdout: string,
): {
columns: string | null;
processes: readonly AdbProcessEntry[];
unparsedLines: readonly string[];
} => {
const processes: AdbProcessEntry[] = [];
const unparsedLines: string[] = [];
let columns: string | null = null;
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line === "") continue;
if (/^USER\s+PID\s+PPID\b/u.test(line)) {
columns = line.trim();
continue;
}
const match = PROCESS_LINE.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
processes.push({
user: match[1]!,
pid: Number(match[2]!),
ppid: Number.isSafeInteger(Number(match[3]!)) ? Number(match[3]!) : null,
rss: Number.isSafeInteger(Number(match[5]!)) ? Number(match[5]!) : null,
state: match[8]!,
name: match[9]!.trim(),
});
}
return { columns, processes, unparsedLines };
};
export interface AdbDirectoryEntry {
readonly name: string;
readonly kind: "directory" | "file" | "symlink" | "other";
readonly permissions: string | null;
readonly owner: string | null;
readonly group: string | null;
readonly bytes: number | null;
readonly date: string | null;
readonly link_target: string | null;
}
const LS_LINE =
/^([bcdlps-][rwxstST-]{9})\s+(?:(\d+)\s+)?(\S+)\s+(\S+)\s+(?:(\d+)\s+)?([\d-]{10})\s+([\d:]{4,8})\s+(.+)$/u;
/** Parse `ls -la` output, including `total` headers and symlink targets. */
export const parseLsOutput = (
stdout: string,
): {
entries: readonly AdbDirectoryEntry[];
unparsedLines: readonly string[];
} => {
const entries: AdbDirectoryEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line === "" || line.startsWith("total ")) continue;
const match = LS_LINE.exec(line);
if (match === null) {
if (line.trim() !== "") unparsedLines.push(line);
continue;
}
const permissions = match[1]!;
let name = match[8]!.trim();
let linkTarget: string | null = null;
const linkArrow = name.indexOf(" -> ");
if (linkArrow !== -1 && permissions.startsWith("l")) {
linkTarget = name.slice(linkArrow + 4);
name = name.slice(0, linkArrow);
}
entries.push({
name,
kind: permissions.startsWith("d")
? "directory"
: permissions.startsWith("l")
? "symlink"
: permissions.startsWith("-")
? "file"
: "other",
permissions,
owner: match[3] ?? null,
group: match[4] ?? null,
bytes: match[5] === undefined ? null : Number(match[5]),
date: `${match[6] ?? ""} ${match[7] ?? ""}`.trim() || null,
link_target: linkTarget,
});
}
return { entries, unparsedLines };
};
export interface AdbFeatureEntry {
readonly name: string;
readonly version: number | null;
}
const FEATURE_LINE = /^feature:([^=\s]+)(?:=(0x[0-9a-fA-F]+|\d+))?$/u;
const parseFeatureVersion = (text: string): number =>
text.startsWith("0x") ? Number.parseInt(text.slice(2), 16) : Number(text);
/** Parse `pm list features` output, including hex GL versions. */
export const parseFeatureListOutput = (
stdout: string,
): {
features: readonly AdbFeatureEntry[];
unparsedLines: readonly string[];
} => {
const features: AdbFeatureEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line === "") continue;
const match = FEATURE_LINE.exec(line);
if (match === null) {
unparsedLines.push(line);
continue;
}
features.push({
name: match[1]!,
version: match[2] === undefined ? null : parseFeatureVersion(match[2]!),
});
}
return { features, unparsedLines };
};
export interface AdbServiceEntry {
readonly name: string;
readonly interface: string | null;
}
const SERVICE_LINE = /^\s*(?:\d+\s+)?([A-Za-z0-9._$/-]+):\s*\[([^\]]*)\]\s*$/u;
/** Parse `service list` output, tolerating numbered and plain formats. */
export const parseServiceListOutput = (
stdout: string,
): {
services: readonly AdbServiceEntry[];
unparsedLines: readonly string[];
} => {
const services: AdbServiceEntry[] = [];
const unparsedLines: string[] = [];
for (const rawLine of stdout.split(/\r?\n/u)) {
const line = rawLine.trimEnd();
if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim()))
continue;
const match = SERVICE_LINE.exec(line);
if (match === null) {
if (line.trim() !== "") unparsedLines.push(line.trim());
continue;
}
services.push({
name: match[1]!,
interface: match[2] === "" ? null : match[2]!,
});
}
return { services, unparsedLines };
};
const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u;
const SIZE_OVERRIDE = /^Override size:\s+(\d+)x(\d+)$/u;
const DENSITY_PHYSICAL = /^Physical density:\s+(\d+)$/u;
const DENSITY_OVERRIDE = /^Override density:\s+(\d+)$/u;
/** Parse `wm size` output into physical and optional override sizes. */
export const parseWmSizeOutput = (
stdout: string,
): {
physical: { readonly width: number; readonly height: number } | null;
override: { readonly width: number; readonly height: number } | null;
} => {
let physical = null;
let override = null;
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
const physicalMatch = SIZE_PAIR.exec(trimmed);
if (physicalMatch !== null)
physical = {
width: Number(physicalMatch[1]!),
height: Number(physicalMatch[2]!),
};
const overrideMatch = SIZE_OVERRIDE.exec(trimmed);
if (overrideMatch !== null)
override = {
width: Number(overrideMatch[1]!),
height: Number(overrideMatch[2]!),
};
}
return { physical, override };
};
/** Parse `wm density` output. Older builds print a bare number. */
export const parseWmDensityOutput = (
stdout: string,
): {
physical: number | null;
override: number | null;
} => {
let physical = null;
let override = null;
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
const physicalMatch = DENSITY_PHYSICAL.exec(trimmed);
if (physicalMatch !== null) physical = Number(physicalMatch[1]!);
const overrideMatch = DENSITY_OVERRIDE.exec(trimmed);
if (overrideMatch !== null) override = Number(overrideMatch[1]!);
}
return { physical, override };
};
/** Parse the focus declarations from `dumpsys window windows`. */
export const parseWindowFocusOutput = (
stdout: string,
): {
focusedWindow: string | null;
focusedApp: string | null;
} => {
let focusedWindow = null;
let focusedApp = null;
for (const line of stdout.split(/\r?\n/u)) {
const trimmed = line.trim();
if (focusedWindow === null && trimmed.startsWith("mCurrentFocus="))
focusedWindow = trimmed.slice("mCurrentFocus=".length).trim();
if (focusedApp === null && trimmed.startsWith("mFocusedApp="))
focusedApp = trimmed.slice("mFocusedApp=".length).trim();
}
return { focusedWindow, focusedApp };
};
export interface AdbPackageDetails {
readonly versionName: string | null;
readonly versionCode: number | null;
readonly firstInstallTime: string | null;
readonly lastUpdateTime: string | null;
readonly installerPackageName: string | null;
readonly userId: number | null;
readonly pkgFlags: string | null;
readonly requestedPermissions: number | null;
}
const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u;
/**
* Project `dumpsys package PACKAGE` output. Only the listed fields are
* extracted; the input is version-dependent and everything else is ignored
* by design rather than guessed.
*/
export const parseDumpsysPackageOutput = (
stdout: string,
): AdbPackageDetails => {
const details: {
versionName: string | null;
versionCode: number | null;
firstInstallTime: string | null;
lastUpdateTime: string | null;
installerPackageName: string | null;
userId: number | null;
pkgFlags: string | null;
} = {
versionName: null,
versionCode: null,
firstInstallTime: null,
lastUpdateTime: null,
installerPackageName: null,
userId: null,
pkgFlags: null,
};
let inPermissions = false;
let permissions = 0;
for (const line of stdout.split(/\r?\n/u)) {
if (line.trim().startsWith("requested permissions:")) {
inPermissions = true;
continue;
}
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) {
permissions += 1;
continue;
}
inPermissions = false;
const match = DETAIL_LINE.exec(line);
if (match === null) continue;
const key = match[1]!;
const value = match[2]!.trim();
if (key === "versionName" && details.versionName === null)
details.versionName = value === "" ? null : value;
else if (key === "versionCode" && details.versionCode === null) {
const parsed = /^(\d+)/u.exec(value);
if (parsed !== null) details.versionCode = Number(parsed[1]!);
} else if (key === "firstInstallTime" && details.firstInstallTime === null)
details.firstInstallTime = value === "" ? null : value;
else if (key === "lastUpdateTime" && details.lastUpdateTime === null)
details.lastUpdateTime = value === "" ? null : value;
else if (
key === "installerPackageName" &&
details.installerPackageName === null
)
details.installerPackageName = value === "" ? null : value;
else if (key === "userId" && details.userId === null) {
const parsed = /^(\d+)/u.exec(value);
if (parsed !== null) details.userId = Number(parsed[1]!);
} else if (key === "pkgFlags" && details.pkgFlags === null)
details.pkgFlags = value === "" ? null : value;
}
return { ...details, requestedPermissions: permissions };
};
/**
* Parse `settings get` output. Android prints the literal string `null` for
* absent keys; that is reported as a device-side null observation.
*/
export const parseSettingsGetOutput = (
stdout: string,
): {
value: string | null;
deviceReportedNull: boolean;
} => {
const trimmed = stdout.replace(/\r?\n$/u, "");
if (trimmed === "null") return { value: null, deviceReportedNull: true };
return { value: trimmed, deviceReportedNull: false };
};
/** Parse the host-side completion line from `adb bugreport`. */
export const parseBugreportCopiedPath = (stdout: string): string | null => {
const match = /^Bug report copied to (.+)\r?$/mu.exec(stdout.trim());
return match === null ? null : match[1]!;
};
/**
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
* with a zero exit code, so the text decides, not the exit status.
*/
export const parsePackageChangeOutput = (
stdout: string,
): {
readonly status: "success" | "failure";
readonly reason: string | null;
} => {
const text = stdout.trim();
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
const failure = /Failure \[([^\]]+)\]/u.exec(text);
return {
status: "failure",
reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
};
};
/**
* Parse `cmd package resolve-activity --brief` output: the component is the
* last non-empty line.
*/
export const parseResolveActivityOutput = (stdout: string): string | null => {
const lines = stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line !== "" && !/^No activity found$/iu.test(line));
return lines.length === 0 ? null : lines[lines.length - 1]!;
};
/**
* Parse `am start` output. Success prints `Starting: Intent { ... }`;
* refusals print lines starting with `Error:` or `Warning:` and activity
* classes that do not exist name the component.
*/
export const parseAmStartOutput = (
stdout: string,
): {
readonly status: "started" | "refused";
readonly startedActivity: string | null;
readonly reason: string | null;
} => {
const text = stdout.trim();
const starting = /^Starting:\s+Intent\s*\{(.*)\}\s*$/mu.exec(text);
if (starting !== null)
return {
status: "started",
startedActivity: starting[1]!.trim(),
reason: null,
};
const error = /^(?:Error|Warning|Exception)(?::|\b)(.*)$/mu.exec(text);
return {
status: "refused",
startedActivity: null,
reason:
error?.[1]?.trim() ??
(text === "" ? "am start returned no activity" : text.slice(0, 512)),
};
};
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
/** Read image dimensions from a PNG header, or null for other payloads. */
export const parsePngDimensions = (
bytes: Uint8Array,
): {
width: number | null;
height: number | null;
} => {
if (
bytes.length < 24 ||
!Buffer.from(bytes.subarray(0, 8)).equals(PNG_SIGNATURE)
)
return { width: null, height: null };
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return {
width: view.getUint32(16),
height: view.getUint32(20),
};
};
@@ -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",
}),
);
}
}
+2
View File
@@ -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);
+393
View File
@@ -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,
}),
});
};
+24
View File
@@ -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",
+7
View File
@@ -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 });
+382
View File
@@ -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[];
+1
View File
@@ -25,6 +25,7 @@ export const TOOL_KINDS = [
"artifact-provider",
"managed-provider",
"android-provider",
"adb-provider",
"firmware-provider",
"browser-provider",
"electron-provider",
+2
View File
@@ -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,
+140
View File
@@ -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,
+706
View File
@@ -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;
+13
View File
@@ -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) {
+66
View File
@@ -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),
);
}
};
+77
View File
@@ -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);
});
+397
View File
@@ -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
View File
@@ -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,