Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c72e9305 | ||
|
|
f9ce32b474 | ||
|
|
9a4fdbeb52 | ||
|
|
2fc47976a0 | ||
|
|
46c9cba03a | ||
|
|
b4db35f092 | ||
|
|
7a56fe6260 | ||
|
|
b25922e4e8 | ||
|
|
b6ad1f2c3d | ||
|
|
2b6f14f528 | ||
|
|
f51503ef1c | ||
|
|
2bf812772d | ||
|
|
a936303723 | ||
|
|
93ca276921 | ||
|
|
911323c5dd | ||
|
|
f63f12d8ff | ||
|
|
983b070d4d | ||
|
|
9e22da845b | ||
|
|
f970b886aa | ||
|
|
a0e5e9db2b | ||
|
|
1f692c83e2 | ||
|
|
782e0147cd | ||
|
|
3006514c4c |
@@ -37,8 +37,9 @@ unknown rather than false.
|
||||
For a completed `analyze_function` dossier, use `inspect_analysis_view` with
|
||||
`view: {kind: "native", facet: "value_flow_operations", offset: 0, limit: 32}`
|
||||
and the exact retained Evidence ID to read selected facts without reanalysis.
|
||||
Native facets also include procedure, pseudocode, assembly, callers/callees,
|
||||
references, and value-flow summaries. Pseudocode offsets and limits count UTF-16
|
||||
Native facets also include procedure, pseudocode, assembly, basic blocks,
|
||||
comments, callers/callees, references, unresolved calls, referenced strings and
|
||||
names, the native API record, and value-flow summaries. Pseudocode offsets and limits count UTF-16
|
||||
code units; use the returned `next_offset` for continuation. Provider limitations
|
||||
and unavailable facts remain explicit; the complete parent Evidence is retained.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
**English** · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
**English** · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Reverse Engineer Anything
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: هندسة عكسية لأي شيء
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Alles reverse-engineeren
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Ingeniería inversa de cualquier cosa
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center" dir="rtl">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی**
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی** · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: مهندسی معکوس هر چیزی
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA : rétro-ingénierie de tout
|
||||
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · **Bahasa Indonesia**
|
||||
|
||||
# REA: Rekayasa Balik Apa Pun
|
||||
|
||||
### Satu MCP untuk rekayasa balik pada berbagai biner, aplikasi, dan perilaku saat runtime.
|
||||
|
||||
**Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#current-status)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/GkcryMnJDM)
|
||||
|
||||
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
**[Situs Web](https://rea.tools/) · [Panduan](https://rea.tools/guides/) · [Contoh Penggunaan](https://rea.tools/showcase/)**
|
||||
|
||||
[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [FAQ](#faq) · [Dokumentasi](#dokumentasi)
|
||||
|
||||
<code>npx rea-agents setup</code>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="docs/assets/rea-hopper-analysis.png" alt="REA menjalankan jembatan analisisnya di dalam Hopper saat memeriksa biner native" width="1200" />
|
||||
|
||||
<br /><br />
|
||||
|
||||
<table aria-label="Komunitas REA">
|
||||
<tr>
|
||||
<td align="center" width="360">
|
||||
<a href="https://discord.gg/GkcryMnJDM">
|
||||
<img src="docs/assets/discord.svg" height="42" alt="Discord" /><br />
|
||||
<strong>Bergabunglah dengan Komunitas Rekayasa Balik</strong>
|
||||
</a><br />
|
||||
<sub>Discord · Tanya Jawab · Berbagi Hasil</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br />
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Melihat fitur dalam suatu aplikasi yang ingin Anda hadirkan di produk sendiri? Minta agen Anda menyelidikinya menggunakan REA. REA dapat memeriksa aplikasi tanpa kode sumbernya, menjelaskan cara kerja fitur tersebut, menunjukkan bukti, dan membangun versi untuk proyek Anda.
|
||||
|
||||
REA menghubungkan agen Anda dengan berbagai alat untuk memeriksa biner native, aplikasi JavaScript dan Electron, assembly .NET, serta situs web. Anda juga dapat menggunakan alat yang sama dari terminal. Analisis dijalankan secara lokal, dan hasilnya mencakup bukti serta keterbatasan yang mendasari setiap kesimpulan.
|
||||
|
||||
Proses penyiapan mendaftarkan REA ke agen Anda dan memasang instruksi alur kerja yang sesuai. Analisis native dapat menggunakan instalasi Hopper, Ghidra, atau IDA yang sudah ada. Proses penyiapan juga dapat memasang Hopper secara opsional setelah mendapatkan persetujuan. Analisis JavaScript statis tidak memerlukan mesin analisis native.
|
||||
|
||||
> **[Kunjungi situs web REA](https://rea.tools/)** untuk mendapatkan petunjuk penyiapan, panduan bergambar, dan studi kasus nyata.
|
||||
|
||||
## Mulai Cepat
|
||||
|
||||
### Siapkan agen Anda
|
||||
|
||||
Dengan Node.js dan npm yang telah terpasang, jalankan:
|
||||
|
||||
```bash
|
||||
npx rea-agents setup
|
||||
```
|
||||
|
||||
Pilih agen yang ingin digunakan, tinjau perubahan yang diusulkan, lalu setujui perubahan tersebut. Proses penyiapan menambahkan server MCP REA dan instruksi alur kerja yang sesuai, serta membuat cadangan konfigurasi yang sudah ada. Setelah itu, mulai ulang agen Anda.
|
||||
|
||||
Penyiapan mendukung Claude Code, Codex, Cursor, Gemini CLI, Grok Build, dan [agen lainnya](docs/installation.md#supported-agents). Lihat [instalasi dan penyiapan](docs/installation.md) untuk konfigurasi penyedia dan pendaftaran MCP secara manual.
|
||||
|
||||
### Minta agen Anda
|
||||
|
||||
```text
|
||||
Pahami cara kerja pencarian di aplikasi Notes, tunjukkan buktinya, dan buat fitur serupa untuk proyek saya.
|
||||
```
|
||||
|
||||
Ganti Notes dengan aplikasi target dan fitur yang ingin Anda pahami.
|
||||
|
||||
### Gunakan terminal
|
||||
|
||||
Periksa direktori aplikasi JavaScript/Electron yang telah diekstrak atau berkas ASAR:
|
||||
|
||||
```bash
|
||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
||||
```
|
||||
|
||||
Hasilnya mencakup modul, impor, batas antarkomponen Electron, dan bukti yang mendasarinya. Ganti jalur tersebut dengan lokasi target Anda, misalnya `"D:/apps/example"` di Windows.
|
||||
|
||||
Untuk memasang perintah `rea` agar dapat digunakan secara rutin:
|
||||
|
||||
```bash
|
||||
npm install --global rea-agents
|
||||
rea --help
|
||||
```
|
||||
|
||||
Untuk analisis native, konfigurasikan penyedia terlebih dahulu. Lihat [Panduan CLI dan Bukti](docs/cli.md) untuk perintah native, pemilihan penyedia, snapshot, dan pembuatan skrip.
|
||||
|
||||
### Perbarui REA
|
||||
|
||||
REA berkembang dengan cepat, dan rilis baru sering menyertakan perbaikan bug. Pastikan instalasi Anda selalu diperbarui.
|
||||
|
||||
Untuk CLI yang dipasang melalui npm:
|
||||
|
||||
```bash
|
||||
rea update
|
||||
```
|
||||
|
||||
Untuk memperbarui pendaftaran agen dan skill Anda, jalankan perintah penyiapan yang ditampilkan oleh proses pembaruan.
|
||||
|
||||
Jika Anda menggunakan `npx`, perbarui penyiapan agen dengan:
|
||||
|
||||
```bash
|
||||
npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
Tinjau perubahan penyiapan dan mulai ulang agen Anda. Untuk menjalankan perintah CLI sekali pakai, gunakan `npx rea-agents@latest` diikuti dengan perintah yang diinginkan.
|
||||
|
||||
## Cara Kerja REA
|
||||
|
||||
Agen Anda memanggil REA melalui MCP untuk memeriksa target dan menelusuri kode yang relevan. REA mengembalikan temuan beserta bukti pendukungnya. Agen kemudian menggunakan temuan tersebut untuk mengajukan pertanyaan lanjutan, menjelaskan perilaku, atau menulis dan menguji implementasi.
|
||||
|
||||
Perintah CLI menggunakan alur kerja yang sama.
|
||||
|
||||

|
||||
|
||||
[Buka ilustrasi dalam ukuran penuh](website/public/assets/figures/rea-investigation-flow.svg).
|
||||
|
||||
<a id="current-status"></a>
|
||||
|
||||
## Hal yang Dapat Anda Analisis
|
||||
|
||||
REA memerlukan Node.js 22.x (>=22.19), 24.x (>=24.11), atau 26+, beserta npm.
|
||||
|
||||
Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
|
||||
|
||||
| Target | Hasil yang diberikan REA | Persyaratan dan panduan |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](https://rea.tools/guides/native/) |
|
||||
| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](docs/binary-diagnostics.md) |
|
||||
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](docs/evm-bytecode.md) |
|
||||
| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](docs/recorded-crashes.md) |
|
||||
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/guides/javascript/) |
|
||||
| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/guides/browser/) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
Pemeriksaan JavaScript dan .NET statis membaca berkas yang disediakan tanpa menjalankan aplikasinya. Perekaman runtime menjalankan atau berinteraksi dengan target yang dipilih menggunakan izin pengguna Anda. Setiap panduan runtime menjelaskan dampaknya.
|
||||
|
||||
<a id="choosing-a-deep-analysis-provider"></a>
|
||||
|
||||
Format native dan dukungan sistem host berbeda-beda menurut penyedia. Lihat [penyiapan Hopper dan Ghidra](docs/installation.md#hopper), [panduan IDA](docs/ida-provider.md), dan [dukungan eksperimental Ghidra di Windows](docs/windows-ghidra-p0.md).
|
||||
|
||||
Ghidra juga mendukung [analisis DOS 16-bit](docs/ghidra-dos.md).
|
||||
|
||||
Untuk biner berukuran besar, tingkatkan batas waktu startup menggunakan `REA_GHIDRA_STARTUP_TIMEOUT_MS`.
|
||||
|
||||
Untuk memilih penyedia, lihat [panduan CLI](docs/cli.md#choose-a-provider).
|
||||
|
||||
Periksa [ketersediaan rilis](docs/installation.md#released-package-and-main) untuk mengetahui fitur yang ditambahkan setelah rilis npm terbaru.
|
||||
|
||||
## Contoh Penggunaan
|
||||
|
||||
[](https://rea.tools/showcase/)
|
||||
|
||||
### DX-Ball: merekonstruksi perhitungan sound-pan
|
||||
|
||||
Ikuti pemanggilan suara hingga ke fungsi pembantu yang mengubah posisi menjadi nilai pan, periksa instruksinya, lalu ubah pseudocode yang belum lengkap menjadi C. Hasil rekonstruksi tersebut lolos pada 3.205 kasus x86 asli dan mereproduksi seluruh 63 byte fungsi hasil kompilasi.
|
||||
|
||||
[Baca studi kasus](https://rea.tools/showcase/dx-ball/) · [Repositori rekonstruksi](https://github.com/N0zoM1z0/dx-ball)
|
||||
|
||||
### Notion: menelusuri jembatan clipboard Electron
|
||||
|
||||
Temukan API clipboard pada renderer, telusuri jalurnya melalui preload dan IPC hingga ke proses utama, lalu periksa format clipboard kaya.
|
||||
|
||||
[Baca studi kasus](https://rea.tools/showcase/notion/)
|
||||
|
||||
### TH04: memulihkan perhitungan bullet-ring DOS
|
||||
|
||||
Periksa instruksi 16-bit dari gim PC-98 asli, pulihkan perhitungan sudut tetap dan sudut yang diarahkan, lalu bandingkan C++ hasil rekonstruksi dengan keluaran historis kompiler.
|
||||
|
||||
[Baca studi kasus](https://rea.tools/showcase/th04/) · [Repositori rekonstruksi](https://github.com/N0zoM1z0/th04)
|
||||
|
||||
Jika Anda telah menggunakan REA untuk menganalisis sesuatu yang menarik, kami ingin melihat hasilnya. Bagikan kasus Anda melalui [issue](https://github.com/morluto/rea/issues) atau [pull request](https://github.com/morluto/rea/pulls), sertakan target, pertanyaan Anda, bagaimana REA membantu, dan apa yang Anda temukan.
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><strong>Agen apa saja yang dapat menggunakan REA?</strong></summary>
|
||||
|
||||
Agen apa pun yang mendukung server MCP lokal dapat menggunakan REA. Proses penyiapan mengonfigurasi [agen yang didukung](docs/installation.md#supported-agents). Klien lain dapat menggunakan [pendaftaran MCP manual](docs/installation.md#mcp-registry).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Apakah saya memerlukan Hopper, Ghidra, atau IDA?</strong></summary>
|
||||
|
||||
Analisis native mendalam menggunakan salah satu dari ketiganya. Pemeriksaan JavaScript dan .NET statis dapat dilakukan tanpa mesin analisis native.
|
||||
|
||||
Proses penyiapan dapat memasang Hopper setelah memperoleh persetujuan. Ghidra dan IDA menggunakan instalasi yang sudah tersedia. Lihat [penyiapan penyedia](docs/installation.md#hopper).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Apakah saya harus menjalankan Hopper terlebih dahulu?</strong></summary>
|
||||
|
||||
REA akan menjalankan Hopper saat suatu operasi membutuhkannya. Di macOS, dialog saat pertama kali dijalankan mungkin meminta Anda memilih mode demo atau mengaktifkan lisensi.
|
||||
|
||||
Lihat [startup dan pemecahan masalah Hopper](docs/installation.md#launcher-paths-and-troubleshooting).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Apa fungsi memasang skill dari skills.sh?</strong></summary>
|
||||
|
||||
Skill tersebut menyediakan instruksi investigasi untuk agen Anda. Gunakan `npx rea-agents setup` untuk mendaftarkan server MCP REA dan memasang instruksi yang sesuai, lalu mulai ulang agen Anda.
|
||||
|
||||
Lihat [instalasi khusus skill](docs/installation.md#skill-only-installation).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Kode seperti apa yang dihasilkan REA?</strong></summary>
|
||||
|
||||
Analisis native menghasilkan pseudocode dan assembly. Analisis JavaScript/Electron memulihkan modul dan hubungan antarmodulnya. Agen Anda menggunakan temuan tersebut untuk menulis dan menguji implementasi. [Contoh penggunaan](#contoh-penggunaan) menyediakan contoh yang telah dikerjakan.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Apakah REA mengunggah aplikasi saya?</strong></summary>
|
||||
|
||||
REA menganalisis target secara lokal. Agen Anda menerima hasil alat, sedangkan penyedia model yang digunakannya memiliki kebijakan datanya sendiri.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Apa yang harus saya lakukan jika menemukan bug?</strong></summary>
|
||||
|
||||
Perbarui terlebih dahulu. Rilis terbaru mungkin sudah memperbaiki masalah tersebut.
|
||||
|
||||
Untuk CLI yang dipasang melalui npm:
|
||||
|
||||
```bash
|
||||
rea update
|
||||
```
|
||||
|
||||
Untuk penyiapan agen melalui `npx`:
|
||||
|
||||
```bash
|
||||
npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
Jika Anda menggunakan agen, selesaikan [pembaruan penyiapan](#perbarui-rea) dan mulai ulang agen tersebut.
|
||||
|
||||
Coba kembali tugas yang sama. Jika masalah masih terjadi, [buat issue](https://github.com/morluto/rea/issues) dengan menyertakan versi REA, jenis target, langkah-langkah untuk mereproduksi masalah, dan keluaran kesalahan.
|
||||
|
||||
</details>
|
||||
|
||||
## Dokumentasi
|
||||
|
||||
Mulailah dengan [panduan praktik di situs web](https://rea.tools/guides/).
|
||||
|
||||
Untuk mengetahui opsi, prasyarat, dan kontrak hasil secara tepat:
|
||||
|
||||
- [Instalasi dan penyiapan](docs/installation.md): pendaftaran agen, konfigurasi penyedia, pembaruan, dan penghapusan instalasi.
|
||||
- [Kesiapan dan pemecahan masalah](docs/installation.md#check-readiness-for-your-task): mendiagnosis satu agen atau mesin analisis.
|
||||
- [CLI dan Bukti](docs/cli.md): perintah, pemilihan penyedia, snapshot, impor/ekspor, dan status keluar.
|
||||
- [Kontrak MCP](docs/mcp-contracts.md) dan [prompt agen](docs/mcp-prompts.md): hasil alat, sesi, dan investigasi terpandu.
|
||||
- [Katalog alat](docs/mcp-contracts.md#generated-catalog): inventaris alat, penyedia, dan perintah CLI yang dibuat secara otomatis.
|
||||
- [Peta jalan](docs/roadmap.md): pekerjaan yang direncanakan dan pelacak kemampuan.
|
||||
|
||||
Laporkan kerentanan melalui [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Riwayat Bintang
|
||||
|
||||
🎉 **50.000 bintang GitHub, terima kasih!**
|
||||
|
||||
Terima kasih kepada semua orang yang menggunakan REA, melaporkan bug, meminta fitur, menguji build, dan berkontribusi dalam perbaikan.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
<img alt="Riwayat bintang GitHub REA" src="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Penafian
|
||||
|
||||
REA menyediakan alat untuk penelitian, analisis, dan rekonstruksi rekayasa balik yang sah. Anda bertanggung jawab untuk memperoleh otorisasi yang diperlukan dan mematuhi hukum yang berlaku. Proyek ini tidak mendukung penggunaan ilegal atau tanpa izin.
|
||||
|
||||
REA adalah proyek perangkat lunak sumber terbuka. Kami tidak menerbitkan atau mendukung mata uang kripto maupun token apa pun. Token yang menggunakan nama REA tidak berafiliasi dengan proyek ini.
|
||||
|
||||
## Kontribusi
|
||||
|
||||
Kami sangat menghargai bantuan Anda untuk REA! [Buat issue](https://github.com/morluto/rea/issues) untuk melaporkan bug atau menyarankan fitur, atau [kirim pull request](https://github.com/morluto/rea/pulls) untuk meningkatkan kode atau dokumentasi.
|
||||
|
||||
Lihat [CONTRIBUTING.md](CONTRIBUTING.md) untuk penyiapan pengembangan dan pemeriksaan, [pengujian](docs/testing.md) untuk tahapan verifikasi, serta [peta arsitektur](docs/architecture.mermaid) untuk struktur proyek.
|
||||
|
||||
## Lisensi
|
||||
|
||||
[MIT](LICENSE)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA:あらゆるものをリバースエンジニアリング
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: 무엇이든 리버스 엔지니어링
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Reverse Engineer Anything
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Faça engenharia reversa de qualquer coisa
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Реверс-инжиниринг чего угодно
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Reverse Engineer Anything
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Her şeyi tersine mühendislikle inceleyin
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Реверс-інжиніринг будь-чого
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA: Kỹ thuật dịch ngược cho mọi thứ
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA:逆向分析一切
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
||||
[English](README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||
|
||||
# REA:逆向分析一切
|
||||
|
||||
|
||||
+70
-7
@@ -139,6 +139,35 @@ listed after the table because its connector is not one of these files:
|
||||
| VS Code | `vscode` |
|
||||
| Grok Build | `grok_build` |
|
||||
| OMP | `omp` |
|
||||
| Pi | `pi` |
|
||||
| Hermes | `hermes` |
|
||||
|
||||
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),
|
||||
setup writes a `type: "stdio"` entry under `mcpServers.rea` in the user-level
|
||||
`~/.pi/agent/mcp.json` and installs the bundled skill under `~/.agents/skills`.
|
||||
Use `rea setup --client pi --dry-run --json` to inspect the plan, then
|
||||
`rea setup --client pi --yes --json` to approve it. Check the registration with
|
||||
`rea doctor --client pi --json`. Pi reads `mcp.json` as strict JSON, like its
|
||||
own loader: comments, trailing commas, a byte-order mark, or an empty existing
|
||||
file are invalid, and setup reports them instead of rewriting the file. Repair
|
||||
such a file to plain JSON first, then rerun setup.
|
||||
|
||||
Setup follows Pi's `PI_CODING_AGENT_DIR`: unset or empty selects the default;
|
||||
`~` and `~/` expand to the home directory; absolute paths and file URLs select
|
||||
that directory; relative paths stay relative to the command's working directory.
|
||||
On Windows, native paths, `~\`, and Git Bash/MSYS, Cygwin, and WSL drive paths
|
||||
are normalized as Pi does. Run REA and Pi from the same working directory when
|
||||
using a relative override. Pi has no profiles: `OMP_PROFILE`, `PI_PROFILE`,
|
||||
`PI_CONFIG_DIR`, and OMP's `enabledServers`/`disabledServers` lists do not affect
|
||||
Pi registration. Doctor accepts an omitted `type` for stdio but reports a
|
||||
non-stdio type as invalid; `enabled: false` stays disabled.
|
||||
|
||||
REA configures only the user scope. Pi also reads `.pi/mcp.json` in trusted
|
||||
projects; a same-name project entry can replace the user entry or override its
|
||||
enabled state and exposure. Check that file if the user registration is aligned
|
||||
but unavailable in a project. An extension that registers `/mcp` can replace
|
||||
Pi's built-in MCP support and use its own configuration instead. Run `/reload`
|
||||
in an existing Pi session after changing servers outside the session.
|
||||
|
||||
For Qwen Code, setup registers `rea` in the `mcpServers` table of the
|
||||
user-level `~/.qwen/settings.json`, or `$QWEN_HOME/settings.json` when configured.
|
||||
@@ -146,22 +175,55 @@ Setup and doctor follow Qwen Code's tilde and working-directory-relative home
|
||||
overrides. Qwen Code also discovers personal skills in `~/.agents/skills`,
|
||||
where setup installs the bundled workflow.
|
||||
|
||||
Gemini CLI uses `$GEMINI_CLI_HOME/.gemini/settings.json` when that home override
|
||||
is nonempty, including its shared personal skills at
|
||||
`$GEMINI_CLI_HOME/.agents/skills`. Setup and doctor check MCP allow/exclude
|
||||
policies from system defaults, user settings, trusted workspace settings, and
|
||||
system overrides. Workspace trust follows `GEMINI_CLI_TRUST_WORKSPACE`,
|
||||
`GEMINI_RESTRICTED_MODE`, the effective `security.folderTrust.enabled` setting,
|
||||
and the selected `trustedFolders.json` (including
|
||||
`GEMINI_CLI_TRUSTED_FOLDERS_PATH`). Settings in an untrusted workspace do not
|
||||
participate. A workspace or system `mcpServers.rea` replaces the entire user
|
||||
entry; setup stops if the winning definition conflicts with its planned entry.
|
||||
Review those files manually: setup never edits administrator/workspace settings
|
||||
or grants workspace trust. Trust supplied only by a running IDE is outside this
|
||||
standalone CLI check.
|
||||
|
||||
Codex treats an unset or empty `CODEX_HOME` as `~/.codex`. Devin honors
|
||||
`XDG_CONFIG_HOME` for its `devin/mcp_config.json` on non-Windows systems.
|
||||
|
||||
For OMP, setup writes a `type: "stdio"` entry to the user-level
|
||||
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute
|
||||
`PI_CODING_AGENT_DIR`, and the profile selected by `OMP_PROFILE` or
|
||||
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR` and
|
||||
`PI_CODING_AGENT_DIR` (relative to the setup working directory), and the profile selected by `OMP_PROFILE` or
|
||||
`PI_PROFILE` (`~/.omp/profiles/<name>/agent/mcp.json`). Setup also removes
|
||||
`rea` from that file's `disabledServers` list, which would otherwise hide the
|
||||
registration. Doctor treats an `enabled: false` entry as active when
|
||||
`enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists
|
||||
it. Run setup under each profile that should load REA.
|
||||
|
||||
For Hermes, setup writes a `mcp_servers.rea` entry to `config.yaml`, preserving
|
||||
comments and unrelated settings. It resolves that file from `HERMES_HOME`, else
|
||||
from the platform default Hermes itself uses — `%LOCALAPPDATA%\hermes` on
|
||||
Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Hermes
|
||||
connects an entry unless `enabled` is false, so setup states `enabled: true`.
|
||||
|
||||
Hermes personal skills install under the active Hermes home's `skills` directory.
|
||||
REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion,
|
||||
and home expansion before resolving that profile. Setup previews the resolved
|
||||
configuration and skill destinations; doctor and uninstall use those same paths.
|
||||
|
||||
For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2
|
||||
both load. If the configuration already uses OpenCode V2's native
|
||||
`mcp.servers` table, setup registers REA there instead and replaces any earlier
|
||||
`mcp.rea` entry from REA.
|
||||
`mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and
|
||||
`OPENCODE_CONFIG`, preferring the later-loaded JSONC file when both JSON and
|
||||
JSONC exist. The ordinary XDG global configuration still participates when a
|
||||
custom directory is selected. Doctor checks the merged user registration; uninstall removes
|
||||
REA-owned entries from every applicable user file so an older entry cannot
|
||||
become active again. Malformed applicable files block setup before changes.
|
||||
|
||||
Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from
|
||||
`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server
|
||||
`~/.grok/config.toml` when `GROK_HOME` is unset or empty. Setup edits that server
|
||||
table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that
|
||||
names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name
|
||||
in that list. The shared skill installed under `~/.agents/skills` is already
|
||||
@@ -201,9 +263,10 @@ save verified paths for an existing Ghidra installation.
|
||||
|
||||
The bundled skill is installed where each selected client discovers personal
|
||||
skills: Claude Code uses `~/.claude/skills` (or
|
||||
`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients,
|
||||
including Qwen Code, use the shared `~/.agents/skills` directory. A mixed selection
|
||||
plans the relevant paths. Selecting the skill without a client uses the shared
|
||||
`$CLAUDE_CONFIG_DIR/skills` when configured). Gemini CLI uses `.agents/skills`
|
||||
under its selected home; Antigravity 2.0 uses `~/.gemini/config/skills`.
|
||||
Other integrations, including Qwen Code, use `~/.agents/skills`.
|
||||
A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
|
||||
directory. Setup leaves existing skill copies in other locations untouched.
|
||||
|
||||
`doctor --skill --json` verifies the selected copies against the bundled
|
||||
|
||||
@@ -186,7 +186,10 @@ keys, and parser recovery remain partial or unknown.
|
||||
Objects and arrays passed to calls, constructors, or tagged templates, used
|
||||
as method receivers, or stored through property targets are not assumed
|
||||
unchanged afterward. Property stores conservatively retain reference escape
|
||||
uncertainty rather than pretending to execute later writes. Aliases
|
||||
uncertainty rather than pretending to execute later writes. References
|
||||
handed back by local functions, methods, constructors, async functions, and
|
||||
generators also retain escape uncertainty, including when the call is read-only;
|
||||
this does not establish that a mutation occurred. Aliases
|
||||
and shared children in spread and rest copies retain that uncertainty; copied
|
||||
primitive slots and unrelated containing properties remain known. Object rest
|
||||
excludes consumed keys, array rest excludes the consumed prefix for known
|
||||
|
||||
+52
-2
@@ -36,6 +36,49 @@ after SDK conversion and check advertised validation and actual calls.
|
||||
Individual model APIs can impose additional nesting limits; complete producer
|
||||
captures can exceed ten structural levels.
|
||||
|
||||
### Compact input schema profile
|
||||
|
||||
Some function-calling providers reject a request while any single tool's
|
||||
`parameters` schema exceeds a literal serialized size cap; Moonshot clients
|
||||
such as kimi-cli fail every request while the default catalog is attached
|
||||
([#1484](https://github.com/morluto/rea/issues/1484)). Setting the server's
|
||||
`REA_MCP_INPUT_SCHEMA_PROFILE` environment variable to `compact` renders every
|
||||
advertised input schema within a 13,312-byte budget, measured to sit below
|
||||
Moonshot's observed cap (community measurements: a 13.7 KB schema is accepted
|
||||
while a ~15 KB one is rejected). The setting is read at startup; restart the
|
||||
server to apply changes, as with `REA_MCP_MAX_RESPONSE_BYTES`. The default
|
||||
value `full` keeps the complete advertisement unchanged.
|
||||
|
||||
The compact profile renders the same tool set, output schemas, annotations,
|
||||
and canonical server-side validation. It changes only the advertised input
|
||||
JSON Schema:
|
||||
|
||||
- Annotation prose leaves the advertised form: nested property descriptions,
|
||||
literal examples, defaults, and titles are dropped. The root keeps its
|
||||
description because it states the accepted input groups.
|
||||
- Schema-local references are inlined within the budget. Kimi Code expands
|
||||
references before sending provider requests, so sharing definitions cannot
|
||||
establish the provider-facing size. Expansion stops at the budget before
|
||||
allocating an oversized presentation. Unconstrained JSON fields advertise
|
||||
all JSON value types explicitly so Kimi cannot infer string for Evidence
|
||||
objects or arrays.
|
||||
- The few schemas whose validation structure alone exceeds the budget
|
||||
(`compare_web_captures`, `build_reconstruction_obligation_ledger`,
|
||||
`evaluate_reconstruction_coverage`, `project_managed_application_graph`,
|
||||
`compare_application_versions`, `capture_browser_scenario`) are advertised
|
||||
in a reduced form: property names, reference-resolved types (including union
|
||||
alternatives), first-level guidance, and the root's required-field constraints,
|
||||
with nested validation detail elided. The
|
||||
canonical schema still rejects malformed calls server-side, so callers
|
||||
following the reduced advertisement receive the canonical typed error
|
||||
instead of silent acceptance.
|
||||
|
||||
Boundary tests pin every advertised compact schema to the budget, keep them
|
||||
valid JSON Schema that still accepts every canonical example, and hold the
|
||||
set of six reduced presentations as contracts evolve. Client verification
|
||||
also checks provider-facing sizes and canonical examples after conversion by
|
||||
the actual Kimi Code CLI; local captures do not establish Moonshot acceptance.
|
||||
|
||||
Self-contained output schemas advertise a content-bound `$id`, including their
|
||||
declared dialect. SDK validators can reuse compiled schemas across complete
|
||||
catalog refreshes and equivalent tool outputs; changing the schema changes its
|
||||
@@ -101,6 +144,12 @@ if retention is unavailable or fails, the diagnostic reports that reason.
|
||||
REA accepts ordinary `tools/call` progress tokens. Updates are monotonic,
|
||||
rate-bounded to at most one intermediate update per 100 ms, and always allow a
|
||||
terminal update. Unknown totals are omitted; REA does not fabricate percentages.
|
||||
`capture_process_scenario` uses that boundary only when the client supplies a
|
||||
progress token. Notifications set `progress` to the count of collected terminal
|
||||
frames, process samples, and interaction events, omit `total`, and put the
|
||||
lifecycle phase, elapsed time, and final cleanup status in `message`. A client
|
||||
that does not send a progress token receives no progress notifications. The
|
||||
tool result is unchanged either way.
|
||||
Provider calls receive the request cancellation signal. Artifact traversal,
|
||||
hashing, version comparisons, Hopper requests, and process capture
|
||||
check the same signal. Cancellation is distinct from timeout. A cleanup failure
|
||||
@@ -262,8 +311,9 @@ Ordinary responses keep their existing complete result contract.
|
||||
|
||||
`inspect_analysis_view` projects a caller-selected view of already completed
|
||||
`inspect_binary_layout`, `analyze_javascript_application`, or `analyze_function`
|
||||
Evidence. Native views select procedure, pseudocode, assembly, callers/callees,
|
||||
references, and high-pcode facets without starting a provider. Native offset
|
||||
Evidence. Native views select procedure, pseudocode, assembly, basic blocks,
|
||||
comments, callers/callees, references, unresolved calls, referenced strings and
|
||||
names, the native API record, and high-pcode facets without starting a provider. Native offset
|
||||
and limit default to 0 and 64; pseudocode uses UTF-16 code units and never
|
||||
splits surrogate pairs, while other pages count rows. Unavailable facts and
|
||||
provider limitations remain explicit. Source is
|
||||
|
||||
@@ -51,6 +51,34 @@ format is TOON. The file contains the complete capture Evidence record.
|
||||
Choose an output file distinct from the scenario input: shell redirection opens
|
||||
and truncates the output before REA reads the scenario.
|
||||
|
||||
While the capture runs, REA writes live status to stderr as one JSON object per
|
||||
line. Stdout stays the final Evidence document:
|
||||
|
||||
```json
|
||||
{
|
||||
"rea_progress": {
|
||||
"phase": "running",
|
||||
"completed": 3,
|
||||
"total": null,
|
||||
"message": "elapsed_ms=1200 frames=1 samples=2 interactions=0",
|
||||
"sequence": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`phase` is the observed lifecycle stage: `prepare`, `running`, `settling`, or
|
||||
`cleanup`. `completed` counts collected terminal frames, process samples, and
|
||||
interaction events. `total` stays null; REA does not invent a unit total or a
|
||||
percentage. `message` reports elapsed time and those counts. The final line uses
|
||||
`phase` `cleanup`, sets `terminal` to true, and adds `disposition` (`exited`,
|
||||
`timeout`, `idle_timeout`, `cancelled`, or `failed`) plus the owned-process,
|
||||
renderer, and temporary-root cleanup states. Updates use the shared progress
|
||||
boundary and are limited to one intermediate line per 100 ms; the terminal line
|
||||
is always emitted. If a receiver is slow, intermediate observations are merged
|
||||
into the latest status, and stale live observations are discarded before final
|
||||
cleanup status. Progress never copies child output. Child output remains in the
|
||||
capture Evidence on stdout.
|
||||
|
||||
For example:
|
||||
|
||||
```json
|
||||
@@ -119,6 +147,14 @@ selected normalization. Original admitted PTY chunks are available through
|
||||
comparison contract, so a capture whose lines were padded to full width does not
|
||||
compare as the same contract.
|
||||
|
||||
Custom `normalization.patterns` replace literal text with the caller's literal
|
||||
replacement, including dollar sequences such as `$&` and `$$`. An empty pattern
|
||||
also matches the first and last string positions. Captures with custom patterns
|
||||
commit `pattern_normalization_version: "literal-replacements-v1"`. Legacy captures
|
||||
remain readable. Cross-version comparison is rejected; capture both sides with
|
||||
the same replacement semantics. Captures without custom patterns keep their
|
||||
existing comparison contract.
|
||||
|
||||
Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and
|
||||
`listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with
|
||||
ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`;
|
||||
|
||||
@@ -88,6 +88,21 @@ entrypoint and include its verifier/fixture files in their cache inputs.
|
||||
|
||||
## End-to-end, integration and golden evidence
|
||||
|
||||
The optional `verify:qwen-client`, `verify:pi-client`, and `verify:hermes-client`
|
||||
lanes require an installed native client. Select its executable with
|
||||
`REA_VERIFY_QWEN_COMMAND`, `REA_VERIFY_PI_COMMAND`, or `REA_VERIFY_HERMES_COMMAND`.
|
||||
They configure disposable profiles through the public REA CLI, preserve caller
|
||||
settings and backups, and exercise real stdio MCP calls and skill loading. The
|
||||
`call` mode checks complete catalog discovery and actual JavaScript Evidence;
|
||||
pass `-- chat` to check plain chat with REA enabled. Qwen also reads the full
|
||||
result that its client offloads, and Pi exercises default codemode execution.
|
||||
Use `REA_VERIFY_RUNTIME_ROOT` to select a production-only installed REA package.
|
||||
Receipts retain client versions, result digests, host coverage and owned-process
|
||||
cleanup. These POSIX lanes use a deterministic loopback model, so they do not
|
||||
prove live model-provider or native Windows compatibility. Qwen and Pi use
|
||||
caller-configured skill directories to isolate the fixture account from the OS
|
||||
home; this does not prove their default home-directory discovery.
|
||||
|
||||
Full E2E tests invoke the production command dispatcher and real providers,
|
||||
without fake launchers, runners or responses. `verify:keyed-archive` writes an
|
||||
actual Foundation binary archive, runs the CLI and a separate stdio MCP
|
||||
|
||||
Generated
+2
-2
@@ -41,6 +41,7 @@
|
||||
"stream-json": "3.7.0",
|
||||
"write-file-atomic": "7.0.0",
|
||||
"ws": "8.21.0",
|
||||
"yaml": "2.9.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -64,8 +65,7 @@
|
||||
"turbo": "2.11.7",
|
||||
"typescript": "7.0.2",
|
||||
"vitepress": "1.6.4",
|
||||
"vitest": "5.0.3",
|
||||
"yaml": "2.9.0"
|
||||
"vitest": "5.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
||||
|
||||
+6
-3
@@ -179,6 +179,9 @@
|
||||
"verify:readiness": "npm run build:cached && npm run verify:fixtures && node scripts/verify-reconstruction-readiness.mjs",
|
||||
"verify:package": "npm run build:cached && node scripts/verify-package.mjs",
|
||||
"verify:package:windows": "npm run build:cached && node scripts/verify-windows-package.mjs",
|
||||
"verify:qwen-client": "npm run build:cached && node scripts/verify-qwen-client.mjs",
|
||||
"verify:pi-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs pi",
|
||||
"verify:hermes-client": "npm run build:cached && node scripts/verify-native-agent-client.mjs hermes",
|
||||
"probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs",
|
||||
"verify:published": "node scripts/verify-published-package.mjs",
|
||||
"config:print": "node scripts/print-mcp-config.mjs",
|
||||
@@ -269,7 +272,8 @@
|
||||
"write-file-atomic": "7.0.0",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3",
|
||||
"evmole": "0.9.3"
|
||||
"evmole": "0.9.3",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vitepress": {
|
||||
@@ -293,7 +297,6 @@
|
||||
"turbo": "2.11.7",
|
||||
"typescript": "7.0.2",
|
||||
"vitepress": "1.6.4",
|
||||
"vitest": "5.0.3",
|
||||
"yaml": "2.9.0"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
TOOL_CONTRACTS,
|
||||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
import { exec } from "./lib/verify-package-core.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
|
||||
|
||||
const client = process.argv[2];
|
||||
const mode = process.argv[3] ?? "call";
|
||||
assert(["pi", "hermes"].includes(client), "Select pi or hermes.");
|
||||
assert(["chat", "call"].includes(mode), "Select chat or call.");
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"This real-client lane requires POSIX; native Windows is unverified.",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const command =
|
||||
process.env[`REA_VERIFY_${client.toUpperCase()}_COMMAND`] ?? client;
|
||||
const lab = await mkdtemp(join(tmpdir(), `rea-${client}-client-`));
|
||||
const account = join(lab, "account");
|
||||
const profile = join(lab, "selected profile");
|
||||
const workspace = join(lab, "workspace");
|
||||
const target = join(workspace, "fixture α测试");
|
||||
const artifactPath = join(workspace, "evidence.json");
|
||||
const skillPath = join(
|
||||
client === "pi" ? join(account, ".agents") : profile,
|
||||
"skills",
|
||||
"reverse-engineer-anything",
|
||||
"SKILL.md",
|
||||
);
|
||||
const verifier = createVerifierRun();
|
||||
await Promise.all([
|
||||
mkdir(account),
|
||||
mkdir(profile),
|
||||
mkdir(target, { recursive: true }),
|
||||
]);
|
||||
await writeFile(
|
||||
join(target, "app.js"),
|
||||
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
|
||||
);
|
||||
const environment = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
USERPROFILE: account,
|
||||
[client === "pi" ? "PI_CODING_AGENT_DIR" : "HERMES_HOME"]: profile,
|
||||
PI_OFFLINE: "1",
|
||||
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||
};
|
||||
const configPath = join(profile, client === "pi" ? "mcp.json" : "config.yaml");
|
||||
const original =
|
||||
client === "pi"
|
||||
? JSON.stringify({
|
||||
mcpServers: { other: { command: "unrelated-server", enabled: false } },
|
||||
})
|
||||
: "# Keep caller settings.\nmcp_servers:\n other:\n command: unrelated-server\n enabled: false\n";
|
||||
await writeFile(configPath, original);
|
||||
if (client === "pi") {
|
||||
// The CLI fixture account and native os.homedir() differ; use Pi's caller-owned skill setting.
|
||||
await writeFile(
|
||||
join(profile, "settings.json"),
|
||||
JSON.stringify({ skills: [join(account, ".agents", "skills")] }),
|
||||
);
|
||||
}
|
||||
const clientVersion = (
|
||||
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
|
||||
).stdout.trim();
|
||||
for (const args of [
|
||||
["setup", "--client", client, "--dry-run"],
|
||||
["setup", "--client", client, "--yes"],
|
||||
["doctor", "--client", client, "--skill"],
|
||||
]) {
|
||||
const result = await exec(
|
||||
process.execPath,
|
||||
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
|
||||
{ env: environment, cwd: workspace, timeout: 60_000 },
|
||||
);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
if (args[0] === "doctor") assert.equal(parsed.healthy, true);
|
||||
else
|
||||
assert.equal(
|
||||
parsed.status,
|
||||
args.includes("--dry-run") ? "planned" : "ready",
|
||||
);
|
||||
await writeFile(
|
||||
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
|
||||
result.stdout,
|
||||
);
|
||||
}
|
||||
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||
const installedConfiguration = await readFile(configPath, "utf8");
|
||||
assert(installedConfiguration.includes("unrelated-server"));
|
||||
if (client === "hermes")
|
||||
assert(installedConfiguration.includes("# Keep caller settings."));
|
||||
const repeated = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
join(runtimeRoot, "scripts/rea.mjs"),
|
||||
"setup",
|
||||
"--client",
|
||||
client,
|
||||
"--yes",
|
||||
"--json",
|
||||
],
|
||||
{ env: environment, cwd: workspace, timeout: 60_000 },
|
||||
);
|
||||
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
|
||||
assert.equal(await readFile(configPath, "utf8"), installedConfiguration);
|
||||
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||
assert(
|
||||
(await readFile(skillPath, "utf8")).includes(
|
||||
"name: reverse-engineer-anything",
|
||||
),
|
||||
);
|
||||
let evidenceSeen = false;
|
||||
let skillLoaded = false;
|
||||
let skillRequested = false;
|
||||
let callRequested = false;
|
||||
let searchRequested = false;
|
||||
let describeRequested = false;
|
||||
let finalSeen = false;
|
||||
let catalog;
|
||||
let artifactDigest;
|
||||
const extractEvidence = (value) => {
|
||||
if (typeof value === "string") {
|
||||
if (
|
||||
value.startsWith("<untrusted_tool_result ") &&
|
||||
value.endsWith("</untrusted_tool_result>")
|
||||
) {
|
||||
// Hermes wraps external MCP content with its own instruction banner.
|
||||
// Decode only that observed envelope, retaining the original request artifact.
|
||||
const start = value.indexOf("\n\n");
|
||||
if (start >= 0)
|
||||
return extractEvidence(
|
||||
value
|
||||
.slice(start + 2, value.lastIndexOf("</untrusted_tool_result>"))
|
||||
.trim(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return extractEvidence(JSON.parse(value));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
if (
|
||||
typeof value.evidence_id === "string" &&
|
||||
value.normalized_result?.graph?.nodes
|
||||
)
|
||||
return value;
|
||||
for (const child of Object.values(value)) {
|
||||
const found = extractEvidence(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
};
|
||||
const validateEvidence = async (value) => {
|
||||
const evidence = toolContract(
|
||||
"analyze_javascript_application",
|
||||
).outputSchema.parse(value);
|
||||
assert.equal(evidence.subject.local_path, target);
|
||||
const shape = evidence.normalized_result.graph.nodes
|
||||
.flatMap((n) => n.observations)
|
||||
.map((o) => o.properties)
|
||||
.find(
|
||||
(p) =>
|
||||
p.semantic_role === "export-return-shapes" &&
|
||||
p.exported_name === "result",
|
||||
);
|
||||
assert(shape?.return_shape_coverage?.projection_complete);
|
||||
const fields = shape.static_return_shapes.flatMap((s) => s.fields);
|
||||
assert(
|
||||
fields.some(
|
||||
(f) =>
|
||||
f.path === "/clientCompatibility" &&
|
||||
f.state === "literal" &&
|
||||
f.value === "REA_CLIENT_FIXTURE",
|
||||
),
|
||||
);
|
||||
assert(
|
||||
fields.some(
|
||||
(f) => f.path === "/count" && f.state === "literal" && f.value === 7,
|
||||
),
|
||||
);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
await writeFile(join(lab, "evidence.json"), serialized);
|
||||
artifactDigest = createHash("sha256").update(serialized).digest("hex");
|
||||
evidenceSeen = true;
|
||||
};
|
||||
const fixture = await createOpenAiModelFixture({
|
||||
directory: lab,
|
||||
prefix: `${client}-${mode}`,
|
||||
onRequest: async ({ body, tools, requestIndex }) => {
|
||||
assert(
|
||||
requestIndex <= 6,
|
||||
"Client did not complete the bounded fixture workflow.",
|
||||
);
|
||||
const messages = body.messages ?? [];
|
||||
const toolMessages = messages.filter((m) => m.role === "tool");
|
||||
const serializedTools = JSON.stringify(toolMessages);
|
||||
const call = (name, args) => ({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: `call_rea_${requestIndex}`,
|
||||
type: "function",
|
||||
function: { name, arguments: JSON.stringify(args) },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (
|
||||
client === "pi" &&
|
||||
callRequested &&
|
||||
serializedTools.includes("REA_CLIENT_FIXTURE")
|
||||
) {
|
||||
const fact = toolMessages
|
||||
.flatMap((m) => {
|
||||
const text =
|
||||
typeof m.content === "string"
|
||||
? m.content
|
||||
: JSON.stringify(m.content);
|
||||
const marker = text.indexOf('{"REA_CLIENT_FIXTURE":true');
|
||||
if (marker < 0) return [];
|
||||
const end = text.indexOf("}\n", marker);
|
||||
try {
|
||||
return [
|
||||
JSON.parse(text.slice(marker, end < 0 ? undefined : end + 1)),
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.find((v) => Array.isArray(v.catalog));
|
||||
assert(
|
||||
fact,
|
||||
"Pi codemode must return its actual catalog and skill-read receipt.",
|
||||
);
|
||||
catalog = fact.catalog;
|
||||
skillLoaded = fact.skill_loaded === true;
|
||||
await validateEvidence(JSON.parse(await readFile(artifactPath, "utf8")));
|
||||
} else if (client === "hermes") {
|
||||
const search = tools.find((t) => t.name === "tool_search");
|
||||
const exposed = tools
|
||||
.filter((t) => t.name.startsWith("mcp__rea__"))
|
||||
.map((t) => t.name.slice(10));
|
||||
const deferred = [
|
||||
...(search?.description ?? "").matchAll(/^- mcp__rea__(\w+):/gmu),
|
||||
].map((m) => m[1]);
|
||||
const native = [...new Set([...exposed, ...deferred])];
|
||||
// Hermes advertises MCP prompt utilities beside server tools.
|
||||
if (native.length)
|
||||
catalog = native.filter(
|
||||
(name) => !["get_prompt", "list_prompts"].includes(name),
|
||||
);
|
||||
skillLoaded ||=
|
||||
serializedTools.includes(skillPath) &&
|
||||
serializedTools.includes("reverse-engineer-anything");
|
||||
for (const message of toolMessages) {
|
||||
const evidence = extractEvidence(message.content);
|
||||
if (evidence) await validateEvidence(evidence);
|
||||
}
|
||||
}
|
||||
if (mode === "chat" || evidenceSeen) {
|
||||
finalSeen = true;
|
||||
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||
}
|
||||
if (client === "pi") {
|
||||
const tool = tools.find((t) => t.name === "codemode");
|
||||
assert(
|
||||
tool,
|
||||
`Native Pi codemode unavailable: ${tools.map((t) => t.name).join(", ")}`,
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(messages).includes(skillPath),
|
||||
"Pi must advertise the installed REA skill.",
|
||||
);
|
||||
callRequested = true;
|
||||
return call(tool.name, {
|
||||
code: `await describeNamespace("rea"); const skill = await tools.read({path:${JSON.stringify(skillPath)}}); const result = await tools.mcp__rea__analyze_javascript_application({input_path:${JSON.stringify(target)},format:"directory"}); if(result.isError) throw new Error(JSON.stringify(result)); const evidence = result.structuredContent ?? JSON.parse(result.content.find(c=>c.type==="text").text); await tools.write({path:${JSON.stringify(artifactPath)},content:JSON.stringify(evidence)}); text({REA_CLIENT_FIXTURE:true,skill_loaded:typeof skill==="string"&&skill.includes("name: reverse-engineer-anything"),catalog:ALL_TOOLS.filter(t=>t.name.startsWith("mcp__rea__")).map(t=>t.name.slice(10))});`,
|
||||
});
|
||||
}
|
||||
if (!skillRequested) {
|
||||
const tool = tools.find((t) => t.name === "skill_view");
|
||||
assert(tool, "Hermes skill_view must be available.");
|
||||
skillRequested = true;
|
||||
return call(tool.name, { name: "reverse-engineer-anything" });
|
||||
}
|
||||
if (!searchRequested) {
|
||||
const tool = tools.find((t) => t.name === "tool_search");
|
||||
assert(tool, "Hermes native deferred tool_search must be available.");
|
||||
searchRequested = true;
|
||||
return call(tool.name, { queries: ["analyze_javascript_application"] });
|
||||
}
|
||||
assert(
|
||||
serializedTools.includes("mcp__rea__analyze_javascript_application"),
|
||||
"Hermes must discover the analysis tool.",
|
||||
);
|
||||
if (!describeRequested) {
|
||||
const tool = tools.find((t) => t.name === "tool_describe");
|
||||
assert(tool, "Hermes must load the actual parameter schema.");
|
||||
describeRequested = true;
|
||||
return call(tool.name, {
|
||||
names: ["mcp__rea__analyze_javascript_application"],
|
||||
});
|
||||
}
|
||||
const tool = tools.find((t) => t.name === "tool_call");
|
||||
assert(tool, "Hermes native deferred tool_call must be available.");
|
||||
callRequested = true;
|
||||
return call(tool.name, {
|
||||
calls: [
|
||||
{
|
||||
name: "mcp__rea__analyze_javascript_application",
|
||||
arguments: { input_path: target, format: "directory" },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
if (client === "pi") {
|
||||
await writeFile(
|
||||
join(profile, "models.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
"rea-fixture": {
|
||||
baseUrl: fixture.baseUrl,
|
||||
api: "openai-completions",
|
||||
apiKey: "local-fixture-key",
|
||||
models: [
|
||||
{
|
||||
id: "rea-client-fixture",
|
||||
reasoning: false,
|
||||
contextWindow: 200000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const configured = await readFile(configPath, "utf8");
|
||||
await writeFile(
|
||||
configPath,
|
||||
configured +
|
||||
`\nmodel:\n default: rea-client-fixture\n provider: custom\n base_url: ${fixture.baseUrl}\nagent:\n max_turns: 6\n`,
|
||||
);
|
||||
}
|
||||
const args =
|
||||
client === "pi"
|
||||
? [
|
||||
"--offline",
|
||||
"--provider",
|
||||
"rea-fixture",
|
||||
"--model",
|
||||
"rea-client-fixture",
|
||||
"--no-session",
|
||||
"--no-context-files",
|
||||
"--mode",
|
||||
"json",
|
||||
"-p",
|
||||
mode === "chat"
|
||||
? "Say hello briefly."
|
||||
: `Use REA to analyze ${target}.`,
|
||||
]
|
||||
: [
|
||||
"--oneshot",
|
||||
mode === "chat"
|
||||
? "Say hello briefly."
|
||||
: `Use REA to analyze ${target}.`,
|
||||
"--provider",
|
||||
"custom",
|
||||
"--model",
|
||||
"rea-client-fixture",
|
||||
"--reasoning",
|
||||
"none",
|
||||
];
|
||||
let failure;
|
||||
try {
|
||||
const execution = exec(command, args, {
|
||||
env: {
|
||||
...environment,
|
||||
OPENAI_API_KEY: "local-fixture-key",
|
||||
OPENAI_BASE_URL: fixture.baseUrl,
|
||||
},
|
||||
cwd: workspace,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
// Pi consumes piped input before its first prompt; an unused open pipe is not EOF.
|
||||
execution.child.stdin.end();
|
||||
const result = await execution;
|
||||
await writeFile(join(lab, "client.stdout"), result.stdout);
|
||||
await writeFile(join(lab, "client.stderr"), result.stderr);
|
||||
assert.equal(fixture.failure, undefined);
|
||||
assert(finalSeen);
|
||||
assert(result.stdout.includes("REA_CLIENT_COMPATIBILITY_OK"));
|
||||
if (mode === "call") {
|
||||
assert(evidenceSeen);
|
||||
assert(
|
||||
skillLoaded,
|
||||
"Native client must actually read the installed skill.",
|
||||
);
|
||||
assert.deepEqual(
|
||||
catalog?.sort(),
|
||||
TOOL_CONTRACTS.map((t) => t.name).sort(),
|
||||
"The complete REA catalog must survive native client discovery.",
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
if (typeof cause?.stdout === "string")
|
||||
await writeFile(join(lab, "client.stdout"), cause.stdout);
|
||||
if (typeof cause?.stderr === "string")
|
||||
await writeFile(join(lab, "client.stderr"), cause.stderr);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
const receipt = {
|
||||
client,
|
||||
clientVersion,
|
||||
mode,
|
||||
passed: failure === undefined,
|
||||
artifacts: lab,
|
||||
runtimeRoot,
|
||||
host: {
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
node: process.versions.node,
|
||||
},
|
||||
modelFixture:
|
||||
"loopback deterministic OpenAI-compatible endpoint; no live provider claim",
|
||||
skillDiscovery:
|
||||
client === "pi"
|
||||
? "caller-configured isolated shared directory; default OS home discovery unverified"
|
||||
: "native HERMES_HOME personal directory",
|
||||
catalogSize: catalog?.length,
|
||||
evidenceSeen,
|
||||
skillLoaded,
|
||||
artifactDigest,
|
||||
finalSeen,
|
||||
requests: fixture.requests,
|
||||
probes: fixture.probes,
|
||||
fixtureFailure: fixture.failure,
|
||||
verifier: await completeVerifierRun(verifier),
|
||||
};
|
||||
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
|
||||
console.log(JSON.stringify(receipt));
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
@@ -87,6 +87,10 @@ export async function verifyPackageEnvironment({
|
||||
PI_PROFILE: undefined,
|
||||
QWEN_HOME: undefined,
|
||||
OPENCODE_CONFIG: undefined,
|
||||
OPENCODE_CONFIG_DIR: undefined,
|
||||
GEMINI_CLI_HOME: undefined,
|
||||
GEMINI_CLI_SYSTEM_SETTINGS_PATH: undefined,
|
||||
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: undefined,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
REA_ANALYSIS_PROVIDER: "auto",
|
||||
|
||||
@@ -13,7 +13,16 @@ export async function verifyPackageInstall({
|
||||
}) {
|
||||
await exec(
|
||||
"npm",
|
||||
["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball],
|
||||
[
|
||||
"install",
|
||||
"--global",
|
||||
"--ignore-scripts",
|
||||
"--omit=dev",
|
||||
"--install-strategy=nested",
|
||||
"--prefix",
|
||||
prefix,
|
||||
tarball,
|
||||
],
|
||||
{ env: environment },
|
||||
);
|
||||
const cli = join(prefix, "bin", "rea");
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
|
||||
import {
|
||||
mkdir,
|
||||
writeFile,
|
||||
readFile,
|
||||
mkdtemp,
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { exec } from "./lib/verify-package-core.mjs";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import {
|
||||
TOOL_CONTRACTS,
|
||||
toolContract,
|
||||
} from "../dist/contracts/toolContracts.js";
|
||||
|
||||
assert(
|
||||
process.platform !== "win32",
|
||||
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
|
||||
);
|
||||
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
|
||||
const client = "qwen";
|
||||
const mode = process.argv[2] ?? "call";
|
||||
assert(
|
||||
["chat", "call"].includes(mode),
|
||||
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
|
||||
);
|
||||
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
|
||||
const verifier = createVerifierRun();
|
||||
const profile = join(lab, "selected profile");
|
||||
const account = join(lab, "account");
|
||||
const runtime = join(profile, "runtime");
|
||||
const workspace = join(lab, "workspace");
|
||||
const sharedSkills = join(account, ".agents", "skills");
|
||||
await Promise.all([
|
||||
mkdir(profile, { recursive: true }),
|
||||
mkdir(account),
|
||||
mkdir(workspace),
|
||||
]);
|
||||
const baseEnvironment = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
USERPROFILE: account,
|
||||
QWEN_HOME: profile,
|
||||
QWEN_RUNTIME_DIR: runtime,
|
||||
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||
};
|
||||
let clientVersion;
|
||||
try {
|
||||
clientVersion = (
|
||||
await exec(command, ["--version"], {
|
||||
env: baseEnvironment,
|
||||
timeout: 30_000,
|
||||
})
|
||||
).stdout.trim();
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
const original = JSON.stringify(
|
||||
{
|
||||
security: {
|
||||
auth: { selectedType: "openai" },
|
||||
folderTrust: { enabled: false },
|
||||
},
|
||||
telemetry: { enabled: false },
|
||||
model: { name: "rea-client-fixture" },
|
||||
// Qwen uses os.homedir(), while REA's isolated CLI account uses USERPROFILE.
|
||||
// This caller-owned setting lets the real client load the REA-installed bundle.
|
||||
skills: { directories: [sharedSkills] },
|
||||
mcpServers: { other: { command: "unrelated-server", disabled: true } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
await writeFile(join(profile, "settings.json"), original);
|
||||
for (const step of [
|
||||
["setup", "--client", "qwen_code", "--dry-run"],
|
||||
["setup", "--client", "qwen_code", "--yes"],
|
||||
["doctor", "--client", "qwen_code", "--skill"],
|
||||
]) {
|
||||
const response = await exec(
|
||||
process.execPath,
|
||||
[join(runtimeRoot, "scripts/rea.mjs"), ...step, "--json"],
|
||||
{ env: baseEnvironment, cwd: repo, timeout: 60_000 },
|
||||
);
|
||||
const result = JSON.parse(response.stdout);
|
||||
if (step.includes("--dry-run"))
|
||||
assert(
|
||||
result.plannedActions.every(
|
||||
(a) =>
|
||||
a.target.startsWith(profile + "/") ||
|
||||
a.target.startsWith(sharedSkills + "/"),
|
||||
),
|
||||
);
|
||||
await writeFile(
|
||||
join(lab, `${step[0]}${step.includes("--dry-run") ? "-plan" : ""}.json`),
|
||||
response.stdout,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await readFile(join(profile, "settings.json.rea.backup"), "utf8"),
|
||||
original,
|
||||
);
|
||||
const configured = JSON.parse(
|
||||
await readFile(join(profile, "settings.json"), "utf8"),
|
||||
);
|
||||
assert.deepEqual(configured.model, { name: "rea-client-fixture" });
|
||||
assert.deepEqual(configured.skills, { directories: [sharedSkills] });
|
||||
assert.deepEqual(configured.mcpServers.other, {
|
||||
command: "unrelated-server",
|
||||
disabled: true,
|
||||
});
|
||||
const target = join(lab, "fixture α测试");
|
||||
await mkdir(target, { recursive: true });
|
||||
await writeFile(
|
||||
`${target}/app.js`,
|
||||
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
|
||||
);
|
||||
let evidenceSeen = false;
|
||||
let skillSeen = false;
|
||||
let finalSeen = false;
|
||||
let skillRequested = false;
|
||||
let searchRequested = false;
|
||||
let artifactReadByAgent = false;
|
||||
let artifactPath;
|
||||
let selectedSkillLoaded = false;
|
||||
let artifactDigest;
|
||||
const extractEvidence = (value) => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return extractEvidence(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const result = extractEvidence(item);
|
||||
if (result) return result;
|
||||
}
|
||||
} else if (value && typeof value === "object") {
|
||||
if (
|
||||
typeof value.evidence_id === "string" &&
|
||||
value.normalized_result?.graph?.nodes?.length > 0
|
||||
)
|
||||
return value;
|
||||
for (const nested of Object.values(value)) {
|
||||
const result = extractEvidence(nested);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const fixture = await createOpenAiModelFixture({
|
||||
directory: lab,
|
||||
prefix: `${client}-${mode}`,
|
||||
onRequest: async ({ body, tools, requestIndex }) => {
|
||||
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
|
||||
join(sharedSkills, "reverse-engineer-anything"),
|
||||
);
|
||||
const toolText = JSON.stringify(
|
||||
(body.messages ?? []).filter((m) => m.role === "tool"),
|
||||
);
|
||||
if (
|
||||
toolText.includes("REA_CLIENT_FIXTURE") &&
|
||||
/(?:\\?"REA_CLIENT_FIXTURE\\?"\s*:\s*true)/u.test(toolText)
|
||||
) {
|
||||
artifactReadByAgent = true;
|
||||
assert(artifactPath);
|
||||
const artifact = await readFile(artifactPath);
|
||||
artifactDigest = createHash("sha256").update(artifact).digest("hex");
|
||||
const evidence = toolContract(
|
||||
"analyze_javascript_application",
|
||||
).outputSchema.parse(JSON.parse(artifact.toString("utf8")));
|
||||
assert(extractEvidence(evidence));
|
||||
assert.equal(evidence.subject.local_path, target);
|
||||
const shape = evidence.normalized_result.graph.nodes
|
||||
.flatMap((n) => n.observations)
|
||||
.map((o) => o.properties)
|
||||
.find(
|
||||
(p) =>
|
||||
p.semantic_role === "export-return-shapes" &&
|
||||
p.exported_name === "result",
|
||||
);
|
||||
assert(shape?.return_shape_coverage?.projection_complete);
|
||||
const fields = shape.static_return_shapes.flatMap((s) => s.fields);
|
||||
assert(
|
||||
fields.some(
|
||||
(f) =>
|
||||
f.path === "/clientCompatibility" &&
|
||||
f.state === "literal" &&
|
||||
f.value === "REA_CLIENT_FIXTURE",
|
||||
),
|
||||
);
|
||||
assert(
|
||||
fields.some(
|
||||
(f) => f.path === "/count" && f.state === "literal" && f.value === 7,
|
||||
),
|
||||
);
|
||||
evidenceSeen = true;
|
||||
await writeFile(
|
||||
`${lab}/${client}-${mode}-evidence.json`,
|
||||
JSON.stringify(evidence),
|
||||
);
|
||||
}
|
||||
for (const message of body.messages ?? []) {
|
||||
if (message.role !== "tool") continue;
|
||||
const evidence = extractEvidence(message.content);
|
||||
if (evidence) {
|
||||
assert(
|
||||
JSON.stringify(evidence.normalized_result).includes(
|
||||
"REA_CLIENT_FIXTURE",
|
||||
),
|
||||
);
|
||||
evidenceSeen = true;
|
||||
await writeFile(
|
||||
`${lab}/${client}-${mode}-evidence.json`,
|
||||
JSON.stringify(evidence),
|
||||
);
|
||||
}
|
||||
}
|
||||
const selected = tools.find((t) =>
|
||||
t.name.includes("analyze_javascript_application"),
|
||||
);
|
||||
const reviewedNames = [
|
||||
...JSON.stringify(
|
||||
(body.messages ?? []).filter((m) => m.role === "tool"),
|
||||
).matchAll(
|
||||
/(?:\\?"name\\?"\s*:\s*\\?")([^"\\]*analyze_javascript_application)(?:\\?")/g,
|
||||
),
|
||||
]
|
||||
.map((m) => m[1])
|
||||
.sort((a, b) => b.length - a.length);
|
||||
const search = tools.find((t) => t.name === "tool_search");
|
||||
const invoke = tools.find((t) => t.name === "tool_call");
|
||||
const skill = tools.find((t) => t.name === "skill");
|
||||
const shell = tools.find((t) => t.name === "run_shell_command");
|
||||
const persisted = toolText.match(
|
||||
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
|
||||
);
|
||||
const call = (name, args) => ({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: `call_rea_fixture_${requestIndex}`,
|
||||
type: "function",
|
||||
function: { name, arguments: JSON.stringify(args) },
|
||||
},
|
||||
],
|
||||
});
|
||||
let message;
|
||||
if (mode === "chat" || evidenceSeen) {
|
||||
message = { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||
finalSeen = true;
|
||||
} else if (skill && !skillRequested) {
|
||||
skillRequested = true;
|
||||
message = call(skill.name, { skill: "reverse-engineer-anything" });
|
||||
} else if (persisted && shell) {
|
||||
artifactPath = persisted[1];
|
||||
assert(
|
||||
(await realpath(artifactPath)).startsWith(
|
||||
(await realpath(runtime)) + "/",
|
||||
),
|
||||
);
|
||||
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
|
||||
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
message = call(shell.name, {
|
||||
command: `node -e ${quote(script)}`,
|
||||
description:
|
||||
"Read the complete saved REA Evidence and verify the fixture facts.",
|
||||
});
|
||||
} else if (selected)
|
||||
message = call(selected.name, {
|
||||
input_path: target,
|
||||
format: "directory",
|
||||
});
|
||||
else if (reviewedNames.length && invoke)
|
||||
message = call(invoke.name, {
|
||||
name: reviewedNames[0],
|
||||
arguments: { input_path: target, format: "directory" },
|
||||
});
|
||||
else if (search && !searchRequested) {
|
||||
searchRequested = true;
|
||||
message = call(search.name, { query: "analyze_javascript_application" });
|
||||
} else
|
||||
throw new Error(
|
||||
`REA analysis tool not exposed; tools: ${tools.map((t) => t.name).join(", ")}`,
|
||||
);
|
||||
return message;
|
||||
},
|
||||
});
|
||||
const endpoint = fixture.baseUrl;
|
||||
const prompt =
|
||||
mode === "chat"
|
||||
? "Say hello briefly."
|
||||
: `Use REA to analyze the JavaScript directory ${target}. Report the result.`;
|
||||
const env = {
|
||||
...baseEnvironment,
|
||||
OPENAI_API_KEY: "local-fixture-key",
|
||||
OPENAI_BASE_URL: endpoint,
|
||||
};
|
||||
const args = [
|
||||
"--auth-type",
|
||||
"openai",
|
||||
"--openai-base-url",
|
||||
endpoint,
|
||||
"--model",
|
||||
"rea-client-fixture",
|
||||
"--approval-mode",
|
||||
mode === "chat" ? "default" : "yolo",
|
||||
"--allowed-mcp-server-names",
|
||||
"rea",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--max-session-turns",
|
||||
"8",
|
||||
"-p",
|
||||
prompt,
|
||||
];
|
||||
let response;
|
||||
let failure;
|
||||
try {
|
||||
response = await exec(command, args, {
|
||||
env,
|
||||
cwd: workspace,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
|
||||
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
|
||||
const initialized = response.stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.find((v) => v.type === "system" && v.subtype === "init");
|
||||
assert(initialized);
|
||||
const observed = initialized.tools
|
||||
.filter((n) => n.startsWith("mcp__rea__"))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
observed,
|
||||
TOOL_CONTRACTS.map((c) => `mcp__rea__${c.name}`).sort(),
|
||||
"Qwen must discover the complete REA catalog",
|
||||
);
|
||||
assert.equal(fixture.failure, undefined);
|
||||
assert(finalSeen);
|
||||
if (mode === "call") {
|
||||
assert(evidenceSeen);
|
||||
assert(selectedSkillLoaded);
|
||||
assert(artifactReadByAgent);
|
||||
}
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
if (typeof cause?.stdout === "string")
|
||||
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
|
||||
if (typeof cause?.stderr === "string")
|
||||
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
const lineage = await completeVerifierRun(verifier);
|
||||
const receipt = {
|
||||
client: "Qwen Code",
|
||||
clientVersion,
|
||||
mode,
|
||||
passed: failure === undefined,
|
||||
artifacts: lab,
|
||||
runtimeRoot,
|
||||
host: {
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
node: process.versions.node,
|
||||
},
|
||||
modelFixture:
|
||||
"loopback deterministic OpenAI-compatible endpoint; no live model-provider claim",
|
||||
skillDiscovery:
|
||||
"caller-configured isolated shared directory; default OS home discovery unverified",
|
||||
catalogSize: TOOL_CONTRACTS.length,
|
||||
requests: fixture.requests,
|
||||
probes: fixture.probes,
|
||||
evidenceSeen,
|
||||
selectedSkillLoaded,
|
||||
artifactReadByAgent,
|
||||
artifactPath,
|
||||
artifactDigest,
|
||||
finalSeen,
|
||||
fixtureFailure: fixture.failure,
|
||||
verifier: lineage,
|
||||
};
|
||||
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
|
||||
console.log(JSON.stringify(receipt));
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
@@ -29,7 +29,7 @@ const LINK_MODES = [
|
||||
{
|
||||
mode: "dyld-info",
|
||||
flags: ["-Wl,-no_fixup_chains"],
|
||||
fixups: "LC_DYLD_INFO bind opcodes",
|
||||
fixups: "LC_DYLD_INFO rebase opcodes were not decoded",
|
||||
swift: true,
|
||||
},
|
||||
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
|
||||
@@ -135,7 +135,12 @@ const checkObjc = (metadata, mode) => {
|
||||
item.selector === "reaFixtureLength",
|
||||
) ||
|
||||
objcCoverage?.status !== "complete" ||
|
||||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
|
||||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
|
||||
(mode.mode === "dyld-info") !==
|
||||
(fixupCoverage(metadata)?.reason?.includes(
|
||||
"LC_DYLD_INFO bind opcodes",
|
||||
) ===
|
||||
true)
|
||||
)
|
||||
throw new Error(
|
||||
`Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
|
||||
@@ -165,7 +170,12 @@ const checkSwift = (metadata, mode) => {
|
||||
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
|
||||
item.implementation_address !== null,
|
||||
) ||
|
||||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
|
||||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
|
||||
(mode.mode === "dyld-info") !==
|
||||
(fixupCoverage(metadata)?.reason?.includes(
|
||||
"LC_DYLD_INFO bind opcodes",
|
||||
) ===
|
||||
true)
|
||||
)
|
||||
throw new Error(
|
||||
`Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** A loopback model fixture exercises real clients without claiming live-provider compatibility. */
|
||||
export const createOpenAiModelFixture = async ({
|
||||
directory,
|
||||
prefix,
|
||||
onRequest,
|
||||
}) => {
|
||||
const requests = [];
|
||||
const probes = [];
|
||||
let incoming = 0;
|
||||
let failure;
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method === "GET" && request.url?.endsWith("/models")) {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
id: "rea-client-fixture",
|
||||
object: "model",
|
||||
owned_by: "local-fixture",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
let bytes = 0;
|
||||
for await (const chunk of request) {
|
||||
bytes += chunk.length;
|
||||
assert(
|
||||
bytes <= 10 * 1024 * 1024,
|
||||
"Local model fixture exceeded its 10 MiB request resource budget",
|
||||
);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
const body = rawBody.length === 0 ? null : JSON.parse(rawBody);
|
||||
await writeFile(
|
||||
join(directory, `${prefix}-incoming-${++incoming}.json`),
|
||||
JSON.stringify({ method: request.method, path: request.url, body }),
|
||||
);
|
||||
if (request.url !== "/v1/chat/completions") {
|
||||
// Hermes probes custom endpoints for Ollama/llama.cpp metadata.
|
||||
// Unsupported HTTP endpoints return 404, as on an OpenAI-only server.
|
||||
probes.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
body,
|
||||
status: 404,
|
||||
});
|
||||
response.statusCode = 404;
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"This OpenAI fixture exposes models and chat completions only.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert.equal(request.method, "POST");
|
||||
assert(body !== null && typeof body === "object" && !Array.isArray(body));
|
||||
assert(Array.isArray(body.messages));
|
||||
const tools = (body.tools ?? [])
|
||||
.filter((t) => t.type === "function")
|
||||
.map((t) => t.function ?? t);
|
||||
requests.push({
|
||||
path: request.url,
|
||||
tools: tools.map((t) => t.name),
|
||||
stream: body.stream,
|
||||
messageRoles: body.messages.map((m) => m.role),
|
||||
});
|
||||
await writeFile(
|
||||
join(directory, `${prefix}-request-${requests.length}.json`),
|
||||
JSON.stringify(body),
|
||||
);
|
||||
const message = await onRequest({
|
||||
body,
|
||||
tools,
|
||||
requestIndex: requests.length,
|
||||
});
|
||||
const base = {
|
||||
id: `chatcmpl-rea-${requests.length}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: body.model,
|
||||
};
|
||||
const finish = message.tool_calls ? "tool_calls" : "stop";
|
||||
const usage = {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
};
|
||||
if (body.stream) {
|
||||
response.setHeader("Content-Type", "text/event-stream");
|
||||
const emit = (delta, finishReason = null) =>
|
||||
response.write(
|
||||
`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`,
|
||||
);
|
||||
if (message.tool_calls) {
|
||||
const fragments = message.tool_calls.map((call, index) => {
|
||||
const split = Math.floor(call.function.arguments.length / 2);
|
||||
return {
|
||||
first: {
|
||||
index,
|
||||
...call,
|
||||
function: {
|
||||
name: call.function.name,
|
||||
arguments: call.function.arguments.slice(0, split),
|
||||
},
|
||||
},
|
||||
second: {
|
||||
index,
|
||||
function: { arguments: call.function.arguments.slice(split) },
|
||||
},
|
||||
};
|
||||
});
|
||||
emit({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: fragments.map((f) => f.first),
|
||||
});
|
||||
emit({ tool_calls: fragments.map((f) => f.second) });
|
||||
} else emit(message);
|
||||
emit({}, finish);
|
||||
response.write(
|
||||
`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [], usage })}\n\n`,
|
||||
);
|
||||
response.end("data: [DONE]\n\n");
|
||||
} else {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
choices: [{ index: 0, message, finish_reason: finish }],
|
||||
usage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
failure = String(cause);
|
||||
response.statusCode = 400;
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
error: { message: failure, type: "invalid_request_error" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
assert(address !== null && typeof address !== "string");
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
requests,
|
||||
probes,
|
||||
get failure() {
|
||||
return failure;
|
||||
},
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import {
|
||||
parse as parseYaml,
|
||||
parseDocument as parseYamlDocument,
|
||||
stringify as stringifyYaml,
|
||||
} from "yaml";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
applyEdits,
|
||||
@@ -70,6 +75,7 @@ export const clientConfigurationServersKey = (
|
||||
switch (format) {
|
||||
case "toml":
|
||||
case "grok":
|
||||
case "hermes":
|
||||
return "mcp_servers";
|
||||
case "opencode":
|
||||
return "mcp";
|
||||
@@ -93,6 +99,12 @@ const parseDocument = (
|
||||
): Record<string, unknown> => {
|
||||
if (format === "toml" || format === "grok")
|
||||
return objectSchema.parse(parseToml(text));
|
||||
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
|
||||
// comments, trailing commas, a BOM, or an empty existing file must be
|
||||
// rejected instead of registering into a document Pi cannot load.
|
||||
if (format === "pi") return objectSchema.parse(JSON.parse(text));
|
||||
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
|
||||
if (format === "hermes") return objectSchema.parse(parseYaml(text));
|
||||
// An empty file, which some clients create before any server is added,
|
||||
// holds no settings to preserve.
|
||||
if (isEmptyClientConfigurationText(text)) return {};
|
||||
@@ -1265,6 +1277,8 @@ export const serializeClientConfiguration = (
|
||||
return serializeCodexConfiguration(document, originalText);
|
||||
if (format === "grok")
|
||||
return serializeGrokConfiguration(document, originalText);
|
||||
if (format === "hermes")
|
||||
return serializeHermesConfiguration(document, originalText);
|
||||
// An empty original holds no comments or settings to preserve. Writing a
|
||||
// fresh document also keeps a lone BOM from shifting past the closing brace.
|
||||
if (
|
||||
@@ -1303,6 +1317,34 @@ export const legacyClientServerPath = (
|
||||
? [parsed.serversPath[0] ?? "mcp", name]
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Keep an existing Hermes `config.yaml` intact aside from the REA server entry.
|
||||
* Hermes config files carry user comments and unrelated settings, so the rea
|
||||
* entry is upserted into the parsed YAML document (which retains formatting and
|
||||
* comments) rather than re-serializing the whole object.
|
||||
*/
|
||||
const serializeHermesConfiguration = (
|
||||
document: Record<string, unknown>,
|
||||
originalText: string | undefined,
|
||||
): string => {
|
||||
if (
|
||||
originalText === undefined ||
|
||||
isEmptyClientConfigurationText(originalText)
|
||||
)
|
||||
return stringifyYaml(document);
|
||||
const serversKey = clientConfigurationServersKey("hermes");
|
||||
const servers = document[serversKey];
|
||||
const entry =
|
||||
typeof servers === "object" && servers !== null
|
||||
? (servers as Record<string, unknown>)[PRODUCT_IDENTITY.mcpServerKey]
|
||||
: undefined;
|
||||
const yamlDocument = parseYamlDocument(originalText);
|
||||
if (entry === undefined)
|
||||
yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]);
|
||||
else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry);
|
||||
return yamlDocument.toString();
|
||||
};
|
||||
|
||||
/** Build the stdio entry shape expected by one client's configuration dialect. */
|
||||
export const clientRegistrationEntry = (
|
||||
format: ClientRegistrationDialect,
|
||||
@@ -1331,6 +1373,7 @@ export const clientRegistrationEntry = (
|
||||
};
|
||||
case "vscode":
|
||||
case "omp":
|
||||
case "pi":
|
||||
return {
|
||||
type: "stdio",
|
||||
command: executable,
|
||||
@@ -1353,6 +1396,15 @@ export const clientRegistrationEntry = (
|
||||
args,
|
||||
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
||||
};
|
||||
case "hermes":
|
||||
// Hermes reads stdio servers from `mcp_servers.<name>` and connects them
|
||||
// unless `enabled` is false, so the entry states it explicitly.
|
||||
return {
|
||||
command: executable,
|
||||
args,
|
||||
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
||||
enabled: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
command: executable,
|
||||
@@ -1361,3 +1413,45 @@ export const clientRegistrationEntry = (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
|
||||
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
|
||||
z
|
||||
.object({
|
||||
allowed: z.array(z.string()).optional(),
|
||||
excluded: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.parse(parsed.document.mcp ?? {});
|
||||
|
||||
/** Gemini consolidates excludes by union and allowlists by normalized intersection. */
|
||||
export const geminiServerPolicyBlock = (
|
||||
policies: readonly ReturnType<typeof parseGeminiMcpPolicy>[],
|
||||
serverKey: string,
|
||||
): string | undefined => {
|
||||
if (policies.some(({ excluded }) => excluded?.includes(serverKey)))
|
||||
return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`;
|
||||
const allowlists = policies.flatMap(({ allowed }) =>
|
||||
allowed === undefined ? [] : [allowed],
|
||||
);
|
||||
const allowed = allowlists.reduce<string[] | undefined>(
|
||||
(previous, current) => {
|
||||
if (previous === undefined) return current;
|
||||
const normalized = new Set(
|
||||
current.map((name) => name.toLowerCase().trim()),
|
||||
);
|
||||
return previous.filter((name) =>
|
||||
normalized.has(name.toLowerCase().trim()),
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
// Gemini's manager applies the consolidated allowlist only when nonempty.
|
||||
if (
|
||||
allowed !== undefined &&
|
||||
allowed.length > 0 &&
|
||||
!allowed.includes(serverKey)
|
||||
)
|
||||
return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
effectiveClientServer,
|
||||
parseClientConfiguration,
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
/** A named source failure that setup can explain without losing the bad path. */
|
||||
export interface ClientConfigurationFileError {
|
||||
readonly kind: "unreadable" | "malformed";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
/** Preserve named read failures and distinguish a missing source from an empty file. */
|
||||
export const readConfigurationText = async (
|
||||
path: string,
|
||||
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||
try {
|
||||
return ok(await readFile(path, "utf8"));
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
return ok(undefined);
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path,
|
||||
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Read and parse one configuration at its source boundary. */
|
||||
export const readConfigurationFile = async (
|
||||
path: string,
|
||||
format: SetupClient["format"],
|
||||
): Promise<
|
||||
Result<ClientConfigurationDocument | undefined, ClientConfigurationFileError>
|
||||
> => {
|
||||
const source = await readConfigurationText(path);
|
||||
if (!source.ok) return source;
|
||||
const content = source.value;
|
||||
if (content === undefined) return ok(undefined);
|
||||
try {
|
||||
return ok(parseClientConfiguration(content, format));
|
||||
} catch (cause: unknown) {
|
||||
return err({
|
||||
kind: "malformed",
|
||||
path,
|
||||
detail: `Configuration ${path} is malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Read every applicable user file; malformed lower-priority files also stop setup. */
|
||||
export const readClientConfigurationFiles = async (
|
||||
client: SetupClient,
|
||||
): Promise<
|
||||
Result<readonly ClientConfigurationDocument[], ClientConfigurationFileError>
|
||||
> => {
|
||||
const documents: ClientConfigurationDocument[] = [];
|
||||
for (const path of client.configPaths ?? [client.configPath]) {
|
||||
const document = await readConfigurationFile(path, client.format);
|
||||
if (!document.ok) return document;
|
||||
if (document.value !== undefined) documents.push(document.value);
|
||||
}
|
||||
return ok(documents);
|
||||
};
|
||||
|
||||
/** OpenCode merges server objects in file order, replacing arrays and scalar values. */
|
||||
const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => {
|
||||
if (!isObject(previous) || !isObject(next)) return next;
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(previous), ...Object.keys(next)])].map(
|
||||
(key) => [
|
||||
key,
|
||||
Object.hasOwn(next, key)
|
||||
? mergeConfigurationValue(previous[key], next[key])
|
||||
: previous[key],
|
||||
],
|
||||
),
|
||||
);
|
||||
};
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Project a client's effective user registration without modifying its source files. */
|
||||
export const effectiveClientConfiguration = (
|
||||
documents: readonly ClientConfigurationDocument[],
|
||||
): ClientConfigurationDocument | undefined => {
|
||||
const last = documents.at(-1);
|
||||
if (last === undefined) return undefined;
|
||||
if (documents.length === 1) return last;
|
||||
const servers: Record<string, unknown> = {};
|
||||
for (const document of documents) {
|
||||
const names = new Set([
|
||||
...Object.keys(document.legacyServers),
|
||||
...Object.keys(document.servers),
|
||||
]);
|
||||
for (const name of names)
|
||||
servers[name] = mergeConfigurationValue(
|
||||
servers[name],
|
||||
effectiveClientServer(document, name),
|
||||
);
|
||||
}
|
||||
return { ...last, servers, legacyServers: {} };
|
||||
};
|
||||
@@ -1,10 +1,14 @@
|
||||
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
||||
import {
|
||||
effectiveClientConfiguration,
|
||||
readClientConfigurationFiles,
|
||||
} from "./ClientConfigurationFiles.js";
|
||||
import {
|
||||
clientServerForcedEnabled,
|
||||
clientServerListedDisabled,
|
||||
effectiveClientServer,
|
||||
parseClientConfiguration,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { access } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
@@ -16,6 +20,7 @@ import {
|
||||
npxRegistrationCommand,
|
||||
} from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
clientEvidencePaths,
|
||||
manualRegistrationRemediation,
|
||||
supportedClients,
|
||||
} from "./SupportedClients.js";
|
||||
@@ -78,29 +83,19 @@ export const readClientRegistrationStatuses = async (
|
||||
for (const client of supportedClients(
|
||||
home,
|
||||
options.platform,
|
||||
options.environment === undefined
|
||||
? undefined
|
||||
: {
|
||||
APPDATA: options.environment.APPDATA,
|
||||
CLAUDE_CONFIG_DIR: options.environment.CLAUDE_CONFIG_DIR,
|
||||
CODEX_HOME: options.environment.CODEX_HOME,
|
||||
COPILOT_HOME: options.environment.COPILOT_HOME,
|
||||
GROK_HOME: options.environment.GROK_HOME,
|
||||
OMP_PROFILE: options.environment.OMP_PROFILE,
|
||||
OPENCODE_CONFIG: options.environment.OPENCODE_CONFIG,
|
||||
PI_CODING_AGENT_DIR: options.environment.PI_CODING_AGENT_DIR,
|
||||
PI_CONFIG_DIR: options.environment.PI_CONFIG_DIR,
|
||||
PI_PROFILE: options.environment.PI_PROFILE,
|
||||
QWEN_HOME: options.environment.QWEN_HOME,
|
||||
SAND_DATA_ROOT: options.environment.SAND_DATA_ROOT,
|
||||
XDG_CONFIG_HOME: options.environment.XDG_CONFIG_HOME,
|
||||
},
|
||||
options.environment,
|
||||
)) {
|
||||
if (
|
||||
!(await exists(client.markerPath)) &&
|
||||
!(await exists(client.configPath))
|
||||
)
|
||||
if (client.configPathError !== undefined) {
|
||||
statuses.push({
|
||||
client: client.name,
|
||||
config_path: client.configPath,
|
||||
command: [],
|
||||
state: "invalid",
|
||||
remediation: client.configPathError,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!(await clientEvidencePresent(client))) continue;
|
||||
const manualRemediation = manualRegistrationRemediation(client.name);
|
||||
if (client.format === "unsupported") {
|
||||
if (manualRemediation !== undefined)
|
||||
@@ -114,8 +109,36 @@ export const readClientRegistrationStatuses = async (
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const content = await readFile(client.configPath, "utf8");
|
||||
const parsed = parseClientConfiguration(content, client.format);
|
||||
const files = await readClientConfigurationFiles(client);
|
||||
if (!files.ok) {
|
||||
statuses.push({
|
||||
client: client.name,
|
||||
config_path: client.configPath,
|
||||
command: [],
|
||||
state: "invalid",
|
||||
remediation: files.error.detail,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const parsed = effectiveClientConfiguration(files.value);
|
||||
if (parsed === undefined) {
|
||||
statuses.push(
|
||||
unavailableStatus(client.name, client.configPath, "missing"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const policy = await readClientPolicyBlock(client, parsed);
|
||||
if (!policy.ok) {
|
||||
statuses.push({
|
||||
client: client.name,
|
||||
config_path: client.configPath,
|
||||
command: [],
|
||||
state: "invalid",
|
||||
remediation: policy.error.detail,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const policyBlock = policy.value;
|
||||
const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
|
||||
if (raw === undefined) {
|
||||
statuses.push(
|
||||
@@ -140,9 +163,14 @@ export const readClientRegistrationStatuses = async (
|
||||
options.platform ?? process.platform,
|
||||
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
|
||||
) &&
|
||||
!clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey)
|
||||
!clientServerListedDisabled(
|
||||
parsed,
|
||||
PRODUCT_IDENTITY.mcpServerKey,
|
||||
) &&
|
||||
policyBlock === undefined
|
||||
? "aligned"
|
||||
: "stale",
|
||||
policyBlock,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -169,7 +197,7 @@ const registrationAligned = (
|
||||
): boolean => {
|
||||
const command = [registration.command, ...registration.args];
|
||||
if (
|
||||
registration.disabled === true ||
|
||||
(client.format !== "grok" && registration.disabled === true) ||
|
||||
(registration.enabled === false && !forcedEnabled)
|
||||
)
|
||||
return false;
|
||||
@@ -182,9 +210,9 @@ const registrationAligned = (
|
||||
)
|
||||
return false;
|
||||
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
||||
// OMP infers stdio for a command entry without an explicit type.
|
||||
// OMP and Pi infer stdio for a command entry without an explicit type.
|
||||
if (
|
||||
client.format === "omp" &&
|
||||
(client.format === "omp" || client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
@@ -246,7 +274,7 @@ const parseRegistration = (
|
||||
if (client.format === "commandcode" && registration.transport !== "stdio")
|
||||
throw new TypeError("Expected an stdio registration");
|
||||
if (
|
||||
client.format === "omp" &&
|
||||
(client.format === "omp" || client.format === "pi") &&
|
||||
registration.type !== undefined &&
|
||||
registration.type !== "stdio"
|
||||
)
|
||||
@@ -262,10 +290,17 @@ const configuredStatus = (
|
||||
configPath: string,
|
||||
command: RegistrationCommand,
|
||||
state: "aligned" | "stale",
|
||||
policyRemediation?: string,
|
||||
): ClientRegistrationStatus =>
|
||||
state === "aligned"
|
||||
? { client, config_path: configPath, command, state, remediation: null }
|
||||
: { client, config_path: configPath, command, state, remediation };
|
||||
: {
|
||||
client,
|
||||
config_path: configPath,
|
||||
command,
|
||||
state,
|
||||
remediation: policyRemediation ?? remediation,
|
||||
};
|
||||
|
||||
const unavailableStatus = (
|
||||
client: string,
|
||||
@@ -279,6 +314,12 @@ const unavailableStatus = (
|
||||
remediation,
|
||||
});
|
||||
|
||||
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
|
||||
for (const path of clientEvidencePaths(client))
|
||||
if (await exists(path)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const exists = async (path: string | undefined): Promise<boolean> => {
|
||||
if (path === undefined) return false;
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type LinuxDistribution,
|
||||
} from "./LinuxHopper.js";
|
||||
import {
|
||||
claudeCodeSkillsDirectory,
|
||||
readInstalledSkillIdentity,
|
||||
type InstalledSkillIdentity,
|
||||
} from "./SetupSkill.js";
|
||||
@@ -441,7 +440,8 @@ export const systemDoctorHost = (
|
||||
readInstalledSkillIdentity(
|
||||
homeDirectory,
|
||||
clientIds,
|
||||
claudeCodeSkillsDirectory(homeDirectory, environment),
|
||||
environment,
|
||||
platform,
|
||||
),
|
||||
clientRegistrations: () =>
|
||||
readClientRegistrationStatuses(homeDirectory, undefined, {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { stripComments } from "jsonc-parser";
|
||||
import { z } from "zod";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
clientConfigurationValuesEqual,
|
||||
clientRegistrationEntry,
|
||||
geminiServerPolicyBlock,
|
||||
parseGeminiMcpPolicy,
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import {
|
||||
readConfigurationFile,
|
||||
readConfigurationText,
|
||||
type ClientConfigurationFileError,
|
||||
} from "./ClientConfigurationFiles.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
type SettingsSource = { path: string; document: ClientConfigurationDocument };
|
||||
const folderTrustSchema = z
|
||||
.object({
|
||||
security: z
|
||||
.object({
|
||||
folderTrust: z
|
||||
.object({ enabled: z.boolean().optional() })
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
const trustRulesSchema = z.record(
|
||||
z.string(),
|
||||
z.enum(["TRUST_FOLDER", "TRUST_PARENT", "DO_NOT_TRUST"]),
|
||||
);
|
||||
const sourceFailure = (
|
||||
path: string,
|
||||
cause: unknown,
|
||||
): ClientConfigurationFileError => ({
|
||||
kind: "malformed",
|
||||
path,
|
||||
detail: `Gemini settings in ${path} are malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
});
|
||||
|
||||
// Gemini resolves symlinks when possible and compares macOS/Windows paths without case.
|
||||
const normalizedPath = async (
|
||||
path: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<string> => {
|
||||
const physical = await realpath(path).catch(() => path);
|
||||
const absolute = resolve(physical).replaceAll("\\", "/");
|
||||
return platform === "win32" || platform === "darwin"
|
||||
? absolute.toLowerCase()
|
||||
: absolute;
|
||||
};
|
||||
|
||||
const workspaceTrusted = async (
|
||||
settings: NonNullable<SetupClient["geminiSettings"]>,
|
||||
sources: readonly SettingsSource[],
|
||||
): Promise<Result<boolean, ClientConfigurationFileError>> => {
|
||||
let enabled = true;
|
||||
for (const { path, document } of sources) {
|
||||
const parsed = folderTrustSchema.safeParse(document.document);
|
||||
if (!parsed.success) return err(sourceFailure(path, parsed.error));
|
||||
enabled = parsed.data.security?.folderTrust?.enabled ?? enabled;
|
||||
}
|
||||
if (settings.trustOverride !== undefined) return ok(settings.trustOverride);
|
||||
if (!enabled) return ok(true);
|
||||
const text = await readConfigurationText(settings.trustedFoldersPath);
|
||||
if (!text.ok) return text;
|
||||
if (text.value === undefined) return ok(false);
|
||||
let rules: z.output<typeof trustRulesSchema>;
|
||||
try {
|
||||
// Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels.
|
||||
rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value)));
|
||||
} catch (cause: unknown) {
|
||||
return err(sourceFailure(settings.trustedFoldersPath, cause));
|
||||
}
|
||||
const workspace = await normalizedPath(
|
||||
settings.workspaceDirectory,
|
||||
settings.platform,
|
||||
);
|
||||
let longest = -1;
|
||||
let trusted = false;
|
||||
for (const [path, level] of Object.entries(rules)) {
|
||||
const effective = await normalizedPath(
|
||||
level === "TRUST_PARENT" ? dirname(path) : path,
|
||||
settings.platform,
|
||||
);
|
||||
if (
|
||||
(workspace === effective ||
|
||||
workspace.startsWith(
|
||||
effective.endsWith("/") ? effective : `${effective}/`,
|
||||
)) &&
|
||||
path.length > longest
|
||||
) {
|
||||
longest = path.length;
|
||||
trusted = level !== "DO_NOT_TRUST";
|
||||
}
|
||||
}
|
||||
return ok(trusted);
|
||||
};
|
||||
|
||||
/** Check effective Gemini scopes while preserving their authority and source identity. */
|
||||
export const readClientPolicyBlock = async (
|
||||
client: SetupClient,
|
||||
userDocument?: ClientConfigurationDocument,
|
||||
desiredRegistration?: unknown,
|
||||
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||
if (client.name !== "gemini_cli" || client.geminiSettings === undefined)
|
||||
return ok(undefined);
|
||||
const settings = client.geminiSettings;
|
||||
const sources: SettingsSource[] = [];
|
||||
const higher: SettingsSource[] = [];
|
||||
for (const path of [
|
||||
settings.defaultsPath,
|
||||
client.configPath,
|
||||
settings.systemPath,
|
||||
]) {
|
||||
const parsed =
|
||||
path === client.configPath && userDocument !== undefined
|
||||
? ok(userDocument)
|
||||
: await readConfigurationFile(path, "json");
|
||||
if (!parsed.ok) return parsed;
|
||||
if (parsed.value !== undefined)
|
||||
sources.push({ path, document: parsed.value });
|
||||
}
|
||||
const workspace = await normalizedPath(
|
||||
settings.workspaceDirectory,
|
||||
settings.platform,
|
||||
);
|
||||
const home = await normalizedPath(
|
||||
dirname(dirname(client.configPath)),
|
||||
settings.platform,
|
||||
);
|
||||
if (workspace !== home) {
|
||||
const path = resolve(
|
||||
settings.workspaceDirectory,
|
||||
".gemini",
|
||||
"settings.json",
|
||||
);
|
||||
const document = await readConfigurationFile(path, "json");
|
||||
if (!document.ok) return document;
|
||||
if (document.value !== undefined) {
|
||||
const trusted = await workspaceTrusted(settings, sources);
|
||||
if (!trusted.ok) return trusted;
|
||||
if (trusted.value) {
|
||||
const source = { path, document: document.value };
|
||||
const systemIndex = sources.findIndex(
|
||||
(item) => item.path === settings.systemPath,
|
||||
);
|
||||
sources.splice(
|
||||
systemIndex < 0 ? sources.length : systemIndex,
|
||||
0,
|
||||
source,
|
||||
);
|
||||
higher.push(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
const system = sources.find(({ path }) => path === settings.systemPath);
|
||||
if (system !== undefined) higher.push(system);
|
||||
const policies: ReturnType<typeof parseGeminiMcpPolicy>[] = [];
|
||||
for (const { path, document } of sources) {
|
||||
try {
|
||||
policies.push(parseGeminiMcpPolicy(document));
|
||||
} catch (cause: unknown) {
|
||||
return err(sourceFailure(path, cause));
|
||||
}
|
||||
}
|
||||
const policy = geminiServerPolicyBlock(
|
||||
policies,
|
||||
PRODUCT_IDENTITY.mcpServerKey,
|
||||
);
|
||||
if (policy !== undefined)
|
||||
return ok(
|
||||
`${policy} Settings sources: ${sources.map(({ path }) => path).join(", ")}.`,
|
||||
);
|
||||
// mcpServers uses shallow merge: the entire same-name definition is replaced.
|
||||
const override = higher.findLast(({ document }) =>
|
||||
Object.hasOwn(document.servers, PRODUCT_IDENTITY.mcpServerKey),
|
||||
);
|
||||
const desired =
|
||||
desiredRegistration ??
|
||||
userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ??
|
||||
clientRegistrationEntry("json", npxRegistrationCommand(), {});
|
||||
if (
|
||||
override !== undefined &&
|
||||
!clientConfigurationValuesEqual(
|
||||
override.document.servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
)
|
||||
)
|
||||
return ok(
|
||||
`Gemini mcpServers.${PRODUCT_IDENTITY.mcpServerKey} in ${override.path} overrides the user registration. Review that settings file before rerunning setup; REA does not modify system or workspace settings.`,
|
||||
);
|
||||
return ok(undefined);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createProgressReporter } from "./ProgressReporter.js";
|
||||
|
||||
it("limits intermediate sends to 100 ms and always delivers terminal status", async () => {
|
||||
let now = 0;
|
||||
const sent: number[] = [];
|
||||
const reporter = createProgressReporter(
|
||||
async () => {
|
||||
sent.push(now);
|
||||
},
|
||||
{ now: () => now },
|
||||
);
|
||||
for (const [completed, elapsed] of [0, 25, 50, 99, 100, 199, 200].entries()) {
|
||||
now = elapsed;
|
||||
await reporter.report({
|
||||
phase: "running",
|
||||
completed,
|
||||
total: null,
|
||||
message: "Observed capture status",
|
||||
});
|
||||
}
|
||||
expect(sent).toEqual([0, 100, 200]);
|
||||
now = 201;
|
||||
await reporter.report({
|
||||
phase: "cleanup",
|
||||
completed: 7,
|
||||
total: null,
|
||||
message: "Owned cleanup complete",
|
||||
terminal: true,
|
||||
});
|
||||
expect(sent).toEqual([0, 100, 200, 201]);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { posix } from "node:path";
|
||||
|
||||
import {
|
||||
logicalPathEscapesRoot,
|
||||
normalizeJoinedLogicalPath,
|
||||
} from "../domain/artifactIdentity.js";
|
||||
import {
|
||||
classifyReferenceSourcePath,
|
||||
detectReferenceSourceLanguage,
|
||||
@@ -83,10 +87,10 @@ const resolveInternalSpecifier = (
|
||||
): { to: string; resolution: "internal" | "unresolved" } => {
|
||||
if (posix.isAbsolute(specifier))
|
||||
return { to: specifier, resolution: "unresolved" };
|
||||
const normalized = posix.normalize(
|
||||
const normalized = normalizeJoinedLogicalPath(
|
||||
posix.join(posix.dirname(fromPath), specifier),
|
||||
);
|
||||
if (normalized.startsWith("../") || normalized === "..")
|
||||
if (logicalPathEscapesRoot(normalized))
|
||||
return { to: normalized, resolution: "unresolved" };
|
||||
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
||||
const candidates = suffixes.flatMap((suffix) => [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { skillDestinations } from "./SetupSkill.js";
|
||||
import {
|
||||
type ClientConfigurationInspection,
|
||||
type ClientConfigurationResult,
|
||||
@@ -18,7 +19,9 @@ import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
export class FakeSetupHost implements SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly homeDirectory = "/fixture/home";
|
||||
readonly claudeCodeSkillsDirectory = "/fixture/home/.claude/skills";
|
||||
skillDestinations(clientIds: readonly string[]) {
|
||||
return skillDestinations(this.homeDirectory, clientIds);
|
||||
}
|
||||
readonly registrationCommand: readonly string[];
|
||||
nodeVersion = "24.18.0";
|
||||
version: string | undefined = "14.5";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
||||
import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js";
|
||||
import {
|
||||
clientRegistrationEntry,
|
||||
clientConfigurationValuesEqual,
|
||||
@@ -33,6 +35,8 @@ export const configureClientConfiguration = (
|
||||
environment: SetupProviderEnvironment = {},
|
||||
command: readonly string[] = npxRegistrationCommand(),
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return Promise.resolve({ status: "failed", reason: "path" });
|
||||
if (client.format === undefined || client.format === "unsupported")
|
||||
return Promise.resolve({ status: "failed", reason: "readback" });
|
||||
return configureClientDocument(client, environment, command, client.format);
|
||||
@@ -44,6 +48,15 @@ const configureClientDocument = async (
|
||||
command: readonly string[],
|
||||
format: NonNullable<SetupClient["format"]>,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const files = await readClientConfigurationFiles(client);
|
||||
if (!files.ok) return { status: "failed", reason: "readback" };
|
||||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
undefined,
|
||||
clientConfigurationDesired(client, environment, command, client.format),
|
||||
);
|
||||
if (!policy.ok || policy.value !== undefined)
|
||||
return { status: "failed", reason: "readback" };
|
||||
const transactionPath = await resolveClientConfigTransactionPath(
|
||||
client.configPath,
|
||||
);
|
||||
@@ -61,6 +74,13 @@ const configureClientDocument = async (
|
||||
original ?? (format === "toml" || format === "grok" ? "" : "{}"),
|
||||
format,
|
||||
);
|
||||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
parsed,
|
||||
clientConfigurationDesired(client, environment, command, parsed.dialect),
|
||||
);
|
||||
if (!policy.ok || policy.value !== undefined)
|
||||
return { status: "failed", reason: "readback" };
|
||||
} catch (cause: unknown) {
|
||||
// Malformed existing configuration fails the readback gate.
|
||||
void cause;
|
||||
@@ -154,10 +174,14 @@ export const clientConfigurationAligned = async (
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<boolean> => {
|
||||
if (client.configPathError !== undefined) return false;
|
||||
try {
|
||||
const files = await readClientConfigurationFiles(client);
|
||||
if (!files.ok) return false;
|
||||
const original = await readFile(client.configPath, "utf8");
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
return registrationCurrent(
|
||||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
parsed,
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
@@ -166,6 +190,19 @@ export const clientConfigurationAligned = async (
|
||||
parsed.dialect,
|
||||
),
|
||||
);
|
||||
return (
|
||||
policy.ok &&
|
||||
policy.value === undefined &&
|
||||
registrationCurrent(
|
||||
parsed,
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed.dialect,
|
||||
),
|
||||
)
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// Unreadable configuration is treated as not aligned so setup repairs it.
|
||||
void cause;
|
||||
@@ -179,7 +216,25 @@ export const inspectClientConfiguration = async (
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationInspection> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return { status: "invalid", remediation: client.configPathError };
|
||||
if (client.format === "unsupported") return { status: "already_current" };
|
||||
const files = await readClientConfigurationFiles(client);
|
||||
if (!files.ok) return { status: "invalid", remediation: files.error.detail };
|
||||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
undefined,
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
client.format,
|
||||
),
|
||||
);
|
||||
if (!policy.ok)
|
||||
return { status: "invalid", remediation: policy.error.detail };
|
||||
if (policy.value !== undefined)
|
||||
return { status: "invalid", remediation: policy.value };
|
||||
const transactionPath = await resolveClientConfigTransactionPath(
|
||||
client.configPath,
|
||||
);
|
||||
@@ -202,6 +257,20 @@ export const inspectClientConfiguration = async (
|
||||
}
|
||||
try {
|
||||
const parsed = parseClientConfiguration(original, client.format);
|
||||
const policy = await readClientPolicyBlock(
|
||||
client,
|
||||
parsed,
|
||||
clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
parsed.dialect,
|
||||
),
|
||||
);
|
||||
if (!policy.ok)
|
||||
return { status: "invalid", remediation: policy.error.detail };
|
||||
if (policy.value !== undefined)
|
||||
return { status: "invalid", remediation: policy.value };
|
||||
const desired = clientConfigurationDesired(
|
||||
client,
|
||||
providerEnvironment,
|
||||
|
||||
@@ -14,9 +14,13 @@ import {
|
||||
systemLinuxHopperInstallHost,
|
||||
} from "./LinuxHopper.js";
|
||||
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
|
||||
import { supportedClients, type SetupClient } from "./SupportedClients.js";
|
||||
import {
|
||||
claudeCodeSkillsDirectory as resolveClaudeCodeSkillsDirectory,
|
||||
clientEvidencePaths,
|
||||
supportedClients,
|
||||
type SetupClient,
|
||||
} from "./SupportedClients.js";
|
||||
import {
|
||||
skillDestinations,
|
||||
canonicalSkillNeedsInstall,
|
||||
installCanonicalSkill,
|
||||
} from "./SetupSkill.js";
|
||||
@@ -90,14 +94,11 @@ export const systemSetupHost = (
|
||||
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
|
||||
const platform = doctorHost.platform;
|
||||
const { homeDirectory } = doctorHost;
|
||||
const claudeSkillsDirectory = resolveClaudeCodeSkillsDirectory(
|
||||
homeDirectory,
|
||||
environment,
|
||||
);
|
||||
return {
|
||||
platform,
|
||||
homeDirectory,
|
||||
claudeCodeSkillsDirectory: claudeSkillsDirectory,
|
||||
skillDestinations: (clientIds) =>
|
||||
skillDestinations(homeDirectory, clientIds, environment, platform),
|
||||
registrationCommand: setupRegistrationCommand(
|
||||
platform,
|
||||
environment.npm_command === "exec",
|
||||
@@ -153,10 +154,11 @@ export const systemSetupHost = (
|
||||
canonicalSkillNeedsInstall(
|
||||
homeDirectory,
|
||||
clientIds,
|
||||
claudeSkillsDirectory,
|
||||
environment,
|
||||
platform,
|
||||
),
|
||||
installSkill: (clientIds) =>
|
||||
installCanonicalSkill(homeDirectory, clientIds, claudeSkillsDirectory),
|
||||
installCanonicalSkill(homeDirectory, clientIds, environment, platform),
|
||||
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
|
||||
};
|
||||
};
|
||||
@@ -169,17 +171,20 @@ export const detectClients = async (
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const detected: SetupClient[] = [];
|
||||
for (const candidate of supportedClients(home, platform, environment)) {
|
||||
const [hasConfig, hasMarker] = await Promise.all([
|
||||
exists(candidate.configPath),
|
||||
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
|
||||
]);
|
||||
if (hasConfig || hasMarker) detected.push(candidate);
|
||||
if (candidate.configPathError !== undefined) continue;
|
||||
if (await clientEvidencePresent(candidate)) detected.push(candidate);
|
||||
}
|
||||
return detected;
|
||||
};
|
||||
|
||||
const major = (version: string): number =>
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
|
||||
for (const path of clientEvidencePaths(client))
|
||||
if (await exists(path)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const exists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
linuxHopperInstallDisclosure,
|
||||
type LinuxPackageFamily,
|
||||
} from "./LinuxHopper.js";
|
||||
import { skillDestinations } from "./SetupSkill.js";
|
||||
import type { SkillDestination } from "./SetupSkill.js";
|
||||
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||
import type {
|
||||
SetupAction,
|
||||
@@ -188,7 +188,7 @@ export const planSetupActions = async (input: {
|
||||
installHopper: input.discovery.installHopper,
|
||||
installSkill: input.installSkill,
|
||||
skillClientIds: input.skillClientIds,
|
||||
claudeCodeSkillsDirectory: input.host.claudeCodeSkillsDirectory,
|
||||
skillDestinations: input.host.skillDestinations(input.skillClientIds),
|
||||
clients: clientPlans,
|
||||
providerEnvironment: input.providerEnvironment,
|
||||
...(input.discovery.linuxPackageFamily === undefined
|
||||
@@ -210,7 +210,7 @@ const setupPlan = (input: {
|
||||
readonly installHopper: boolean;
|
||||
readonly installSkill: boolean;
|
||||
readonly skillClientIds: readonly string[];
|
||||
readonly claudeCodeSkillsDirectory: string;
|
||||
readonly skillDestinations: readonly SkillDestination[];
|
||||
readonly clients: readonly {
|
||||
readonly client: SetupClient;
|
||||
readonly operation: "create" | "update";
|
||||
@@ -268,15 +268,8 @@ const setupPlan = (input: {
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
})),
|
||||
...(input.installSkill
|
||||
? skillDestinations(
|
||||
input.homeDirectory,
|
||||
input.skillClientIds,
|
||||
input.claudeCodeSkillsDirectory,
|
||||
).map(({ client, path }): SetupAction => ({
|
||||
id:
|
||||
client === "claude_code"
|
||||
? "install_skill:claude_code"
|
||||
: "install_skill",
|
||||
? input.skillDestinations.map(({ client, path }): SetupAction => ({
|
||||
id: client === "shared" ? "install_skill" : `install_skill:${client}`,
|
||||
kind: "install_skill",
|
||||
label:
|
||||
client === "claude_code"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { clientSkillDirectories } from "./SupportedClients.js";
|
||||
|
||||
const SKILL_FILES = [
|
||||
"SKILL.md",
|
||||
@@ -21,7 +22,7 @@ interface CanonicalSkillFile {
|
||||
|
||||
/** Managed skill location selected for a client family. */
|
||||
export interface SkillDestination {
|
||||
readonly client: "shared" | "claude_code";
|
||||
readonly client: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
@@ -32,44 +33,19 @@ export interface InstalledSkillIdentity {
|
||||
readonly canonical: boolean;
|
||||
}
|
||||
|
||||
/** Resolve Claude Code personal skills from the selected environment. */
|
||||
export const claudeCodeSkillsDirectory = (
|
||||
home: string,
|
||||
environment: { readonly CLAUDE_CONFIG_DIR?: string } = {},
|
||||
): string =>
|
||||
join(environment.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills");
|
||||
|
||||
/** Resolve the skill roots required by a client selection, or all owned roots. */
|
||||
export const skillDestinations = (
|
||||
home: string,
|
||||
clientIds: readonly string[] | undefined,
|
||||
claudeSkillsDirectory = claudeCodeSkillsDirectory(home),
|
||||
): readonly SkillDestination[] => {
|
||||
const includeClaude =
|
||||
clientIds === undefined || clientIds.includes("claude_code");
|
||||
const includeShared =
|
||||
clientIds === undefined ||
|
||||
clientIds.length === 0 ||
|
||||
clientIds.some((clientId) => clientId !== "claude_code");
|
||||
return [
|
||||
...(includeShared
|
||||
? [
|
||||
{
|
||||
client: "shared" as const,
|
||||
path: join(home, ".agents/skills", PRODUCT_IDENTITY.skillName),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(includeClaude
|
||||
? [
|
||||
{
|
||||
client: "claude_code" as const,
|
||||
path: join(claudeSkillsDirectory, PRODUCT_IDENTITY.skillName),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
};
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): readonly SkillDestination[] =>
|
||||
clientSkillDirectories(home, clientIds, environment, platform).map(
|
||||
({ client, directory }) => ({
|
||||
client,
|
||||
path: join(directory, PRODUCT_IDENTITY.skillName),
|
||||
}),
|
||||
);
|
||||
|
||||
const readOptionalText = async (path: string): Promise<string | undefined> => {
|
||||
try {
|
||||
@@ -84,10 +60,11 @@ const readOptionalText = async (path: string): Promise<string | undefined> => {
|
||||
const canonicalSkillFiles = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
claudeSkillsDirectory?: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<readonly CanonicalSkillFile[]> =>
|
||||
Promise.all(
|
||||
skillDestinations(home, clientIds, claudeSkillsDirectory).flatMap(
|
||||
skillDestinations(home, clientIds, environment, platform).flatMap(
|
||||
({ path: root }) =>
|
||||
SKILL_FILES.map(async (relativePath) => {
|
||||
const destination = join(root, relativePath);
|
||||
@@ -110,11 +87,12 @@ const canonicalSkillFiles = async (
|
||||
export const canonicalSkillNeedsInstall = async (
|
||||
home: string,
|
||||
clientIds: readonly string[] = [],
|
||||
claudeSkillsDirectory?: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
return (
|
||||
await canonicalSkillFiles(home, clientIds, claudeSkillsDirectory)
|
||||
await canonicalSkillFiles(home, clientIds, environment, platform)
|
||||
).some(({ content, original }) => original !== content);
|
||||
} catch (cause: unknown) {
|
||||
// Unreadable skill state fails open to install so setup can repair it.
|
||||
@@ -139,14 +117,16 @@ const restoreSkillFiles = async (
|
||||
export const installCanonicalSkill = async (
|
||||
home: string,
|
||||
clientIds: readonly string[] = [],
|
||||
claudeSkillsDirectory?: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<"installed" | "unchanged" | "failed"> => {
|
||||
let changed: readonly CanonicalSkillFile[] = [];
|
||||
try {
|
||||
const canonical = await canonicalSkillFiles(
|
||||
home,
|
||||
clientIds,
|
||||
claudeSkillsDirectory,
|
||||
environment,
|
||||
platform,
|
||||
);
|
||||
changed = canonical.filter(({ content, original }) => original !== content);
|
||||
if (changed.length === 0) return "unchanged";
|
||||
@@ -179,12 +159,14 @@ export const installCanonicalSkill = async (
|
||||
export const readInstalledSkillIdentity = async (
|
||||
home: string,
|
||||
clientIds: readonly string[],
|
||||
claudeSkillsDirectory?: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<InstalledSkillIdentity | undefined> => {
|
||||
const destinations = skillDestinations(
|
||||
home,
|
||||
clientIds,
|
||||
claudeSkillsDirectory,
|
||||
environment,
|
||||
platform,
|
||||
);
|
||||
let content: string | undefined;
|
||||
for (const destination of destinations) {
|
||||
@@ -202,7 +184,8 @@ export const readInstalledSkillIdentity = async (
|
||||
canonical: !(await canonicalSkillNeedsInstall(
|
||||
home,
|
||||
clientIds,
|
||||
claudeSkillsDirectory,
|
||||
environment,
|
||||
platform,
|
||||
)),
|
||||
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
|
||||
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import type { SkillDestination } from "./SetupSkill.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type {
|
||||
SetupFailureCode,
|
||||
@@ -42,7 +43,7 @@ export type ClientConfigurationInspection =
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly homeDirectory: string;
|
||||
readonly claudeCodeSkillsDirectory: string;
|
||||
skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
|
||||
readonly registrationCommand: readonly string[];
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { lstatSync } from "node:fs";
|
||||
|
||||
/** One supported client configuration location. */
|
||||
@@ -6,6 +7,19 @@ export interface SetupClient {
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
readonly configPath: string;
|
||||
/** User configuration files in increasing precedence, including the write target. */
|
||||
readonly configPaths?: readonly string[];
|
||||
/** Read-only Gemini scopes and trust inputs; never setup write targets. */
|
||||
readonly geminiSettings?: {
|
||||
readonly systemPath: string;
|
||||
readonly defaultsPath: string;
|
||||
readonly workspaceDirectory: string;
|
||||
readonly trustedFoldersPath: string;
|
||||
readonly trustOverride?: boolean;
|
||||
readonly platform: NodeJS.Platform;
|
||||
};
|
||||
/** Unresolved path evidence only; consumers must not perform I/O on it. */
|
||||
readonly configPathError?: string;
|
||||
readonly markerPath?: string;
|
||||
readonly format?:
|
||||
| "json"
|
||||
@@ -16,6 +30,8 @@ export interface SetupClient {
|
||||
| "commandcode"
|
||||
| "grok"
|
||||
| "omp"
|
||||
| "pi"
|
||||
| "hermes"
|
||||
| "unsupported";
|
||||
}
|
||||
|
||||
@@ -24,21 +40,7 @@ type ClientPath = readonly string[] | ((paths: ClientPathContext) => string);
|
||||
interface ClientPathContext {
|
||||
readonly home: string;
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly env: {
|
||||
readonly APPDATA?: string | undefined;
|
||||
readonly CLAUDE_CONFIG_DIR?: string | undefined;
|
||||
readonly CODEX_HOME?: string | undefined;
|
||||
readonly COPILOT_HOME?: string | undefined;
|
||||
readonly GROK_HOME?: string | undefined;
|
||||
readonly OMP_PROFILE?: string | undefined;
|
||||
readonly OPENCODE_CONFIG?: string | undefined;
|
||||
readonly PI_CODING_AGENT_DIR?: string | undefined;
|
||||
readonly PI_CONFIG_DIR?: string | undefined;
|
||||
readonly PI_PROFILE?: string | undefined;
|
||||
readonly QWEN_HOME?: string | undefined;
|
||||
readonly SAND_DATA_ROOT?: string | undefined;
|
||||
readonly XDG_CONFIG_HOME?: string | undefined;
|
||||
};
|
||||
readonly env: Readonly<NodeJS.ProcessEnv>;
|
||||
}
|
||||
|
||||
interface ClientDefinition {
|
||||
@@ -47,6 +49,11 @@ interface ClientDefinition {
|
||||
readonly configPath: ClientPath;
|
||||
readonly markerPath: ClientPath;
|
||||
readonly format: NonNullable<SetupClient["format"]>;
|
||||
readonly skillPath?: ClientPath;
|
||||
readonly configPaths?: (context: ClientPathContext) => readonly string[];
|
||||
readonly geminiSettings?: (
|
||||
context: ClientPathContext,
|
||||
) => NonNullable<SetupClient["geminiSettings"]>;
|
||||
}
|
||||
|
||||
const vscodeUserDirectory = ({
|
||||
@@ -84,7 +91,7 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
|
||||
|
||||
const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.CODEX_HOME ?? join(home, ".codex");
|
||||
env.CODEX_HOME || join(home, ".codex");
|
||||
|
||||
/** Match Qwen Code's home override, including tilde and cwd-relative paths. */
|
||||
const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
|
||||
@@ -106,7 +113,51 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
|
||||
};
|
||||
|
||||
const grokDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.GROK_HOME ?? join(home, ".grok");
|
||||
env.GROK_HOME || join(home, ".grok");
|
||||
|
||||
/**
|
||||
* Hermes Agent home. `HERMES_HOME` wins; otherwise the platform default mirrors
|
||||
* Hermes's own resolver (`%LOCALAPPDATA%\\hermes` on Windows, `~/.hermes`
|
||||
* elsewhere), including the optional `HERMES_DATA_DIR_SUFFIX` that is appended
|
||||
* to the literal `hermes`/`.hermes` directory name.
|
||||
*/
|
||||
const hermesDirectory = ({
|
||||
home,
|
||||
platform,
|
||||
env,
|
||||
}: ClientPathContext): string => {
|
||||
const override = env.HERMES_HOME?.trim();
|
||||
if (override) {
|
||||
const expandedVariables = override.replace(
|
||||
/\$(\w+|\{[^}]*\})/gu,
|
||||
(match, variable: string) => {
|
||||
const key = variable.startsWith("{") ? variable.slice(1, -1) : variable;
|
||||
return env[key] ?? match;
|
||||
},
|
||||
);
|
||||
const expanded =
|
||||
platform === "win32"
|
||||
? expandedVariables.replace(
|
||||
/%([^%]+)%/gu,
|
||||
(match, variable: string) => env[variable] ?? match,
|
||||
)
|
||||
: expandedVariables;
|
||||
if (expanded === "~") return home;
|
||||
if (
|
||||
expanded.startsWith("~/") ||
|
||||
(platform === "win32" && expanded.startsWith("~\\"))
|
||||
)
|
||||
return join(home, expanded.slice(2));
|
||||
return resolve(expanded);
|
||||
}
|
||||
const suffix = env.HERMES_DATA_DIR_SUFFIX ?? "";
|
||||
return platform === "win32"
|
||||
? join(
|
||||
env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
|
||||
`hermes${suffix}`,
|
||||
)
|
||||
: join(home, `.hermes${suffix}`);
|
||||
};
|
||||
|
||||
/** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */
|
||||
const grokBotDirectory = ({ home, env }: ClientPathContext): string => {
|
||||
@@ -152,17 +203,49 @@ const ompProfile = ({ env }: ClientPathContext): string | undefined => {
|
||||
/**
|
||||
* The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP
|
||||
* joins PI_CONFIG_DIR to the home directory, and a named profile ignores
|
||||
* PI_CODING_AGENT_DIR. OMP resolves a relative agent directory against each
|
||||
* process's working directory, so only an absolute override names one file.
|
||||
* PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory.
|
||||
*/
|
||||
const ompAgentDirectory = (context: ClientPathContext): string => {
|
||||
const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp");
|
||||
const profile = ompProfile(context);
|
||||
if (profile !== undefined) return join(root, "profiles", profile, "agent");
|
||||
const override = context.env.PI_CODING_AGENT_DIR;
|
||||
return override !== undefined && override !== "" && isAbsolute(override)
|
||||
? override
|
||||
: join(root, "agent");
|
||||
return override ? resolve(override) : join(root, "agent");
|
||||
};
|
||||
|
||||
/**
|
||||
* Pi's public getAgentDir()/normalizePath semantics, not OMP's profiles.
|
||||
* Keep relative overrides relative: filesystem consumers resolve them in cwd.
|
||||
*/
|
||||
const piAgentDirectory = ({
|
||||
home,
|
||||
platform,
|
||||
env,
|
||||
}: ClientPathContext): string => {
|
||||
const paths = platform === "win32" ? win32 : { join };
|
||||
let directory = env.PI_CODING_AGENT_DIR;
|
||||
if (!directory) return paths.join(home, ".pi", "agent");
|
||||
if (
|
||||
platform === "win32" &&
|
||||
directory.startsWith("/") &&
|
||||
!directory.startsWith("//") &&
|
||||
!directory.includes("\\")
|
||||
) {
|
||||
const drive = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/iu.exec(
|
||||
directory,
|
||||
);
|
||||
if (drive !== null)
|
||||
directory = `${drive[1]?.toUpperCase()}:\\${drive[2]?.replaceAll("/", "\\") ?? ""}`;
|
||||
}
|
||||
if (directory === "~") return home;
|
||||
if (
|
||||
directory.startsWith("~/") ||
|
||||
(platform === "win32" && directory.startsWith("~\\"))
|
||||
)
|
||||
return paths.join(home, directory.slice(2));
|
||||
if (directory.startsWith("file://"))
|
||||
return fileURLToPath(directory, { windows: platform === "win32" });
|
||||
return directory;
|
||||
};
|
||||
|
||||
const copilotDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
@@ -171,21 +254,66 @@ const copilotDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
const commandCodeDirectory = ({ home }: ClientPathContext): string =>
|
||||
join(home, ".commandcode");
|
||||
|
||||
const opencodeDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode");
|
||||
/**
|
||||
* OpenCode appends `OPENCODE_CONFIG_DIR` only when the value is non-empty.
|
||||
* An empty variable is the same as unset and leaves the XDG directory in place.
|
||||
*/
|
||||
const openCodeOverrideDirectory = (
|
||||
env: ClientPathContext["env"],
|
||||
): string | undefined => {
|
||||
const directory = env.OPENCODE_CONFIG_DIR;
|
||||
return directory === undefined || directory === "" ? undefined : directory;
|
||||
};
|
||||
|
||||
const opencodeDirectory = (context: ClientPathContext): string =>
|
||||
openCodeOverrideDirectory(context.env) ??
|
||||
join(
|
||||
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
|
||||
"opencode",
|
||||
);
|
||||
|
||||
/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */
|
||||
const openCodeConfigurationPaths = (
|
||||
context: ClientPathContext,
|
||||
): readonly string[] => {
|
||||
const directory = join(
|
||||
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
|
||||
"opencode",
|
||||
);
|
||||
const overrideDirectory = openCodeOverrideDirectory(context.env);
|
||||
const paths = [
|
||||
join(directory, "config.json"),
|
||||
join(directory, "opencode.json"),
|
||||
join(directory, "opencode.jsonc"),
|
||||
...(context.env.OPENCODE_CONFIG ? [context.env.OPENCODE_CONFIG] : []),
|
||||
...(overrideDirectory !== undefined
|
||||
? [
|
||||
join(overrideDirectory, "opencode.json"),
|
||||
join(overrideDirectory, "opencode.jsonc"),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const resolved = paths.map((path) => resolve(path));
|
||||
return resolved.filter((path, index) => resolved.lastIndexOf(path) === index);
|
||||
};
|
||||
|
||||
const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
|
||||
if (context.env.OPENCODE_CONFIG !== undefined)
|
||||
return context.env.OPENCODE_CONFIG;
|
||||
const directory = opencodeDirectory(context);
|
||||
const candidates = [
|
||||
"opencode.json",
|
||||
"opencode.jsonc",
|
||||
".opencode.json",
|
||||
".opencode.jsonc",
|
||||
].map((filename) => join(directory, filename));
|
||||
// The selected directory is loaded after an explicit file by OpenCode V1.
|
||||
const overrideDirectory = openCodeOverrideDirectory(context.env);
|
||||
const candidates =
|
||||
overrideDirectory !== undefined
|
||||
? [
|
||||
join(overrideDirectory, "opencode.json"),
|
||||
join(overrideDirectory, "opencode.jsonc"),
|
||||
].map((path) => resolve(path))
|
||||
: context.env.OPENCODE_CONFIG
|
||||
? [resolve(context.env.OPENCODE_CONFIG)]
|
||||
: [
|
||||
join(opencodeDirectory(context), "opencode.json"),
|
||||
join(opencodeDirectory(context), "opencode.jsonc"),
|
||||
].map((path) => resolve(path));
|
||||
return (
|
||||
candidates.find((path) => {
|
||||
[...candidates].reverse().find((path) => {
|
||||
try {
|
||||
lstatSync(path);
|
||||
return true;
|
||||
@@ -197,21 +325,60 @@ const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
|
||||
);
|
||||
}
|
||||
}) ??
|
||||
candidates[0] ??
|
||||
join(directory, "opencode.json")
|
||||
resolve(
|
||||
overrideDirectory !== undefined
|
||||
? join(overrideDirectory, "opencode.json")
|
||||
: context.env.OPENCODE_CONFIG ||
|
||||
join(opencodeDirectory(context), "opencode.json"),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const geminiSettings = ({
|
||||
home,
|
||||
platform,
|
||||
env,
|
||||
}: ClientPathContext): NonNullable<SetupClient["geminiSettings"]> => {
|
||||
const system =
|
||||
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH ||
|
||||
(platform === "darwin"
|
||||
? "/Library/Application Support/GeminiCli/settings.json"
|
||||
: platform === "win32"
|
||||
? "C:\\ProgramData\\gemini-cli\\settings.json"
|
||||
: "/etc/gemini-cli/settings.json");
|
||||
const trustOverride =
|
||||
env.GEMINI_RESTRICTED_MODE === "true" ||
|
||||
env.GEMINI_CLI_TRUST_WORKSPACE === "false"
|
||||
? false
|
||||
: env.GEMINI_CLI_TRUST_WORKSPACE === "true"
|
||||
? true
|
||||
: undefined;
|
||||
return {
|
||||
systemPath: system,
|
||||
defaultsPath:
|
||||
env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH ||
|
||||
join(dirname(system), "system-defaults.json"),
|
||||
workspaceDirectory: process.cwd(),
|
||||
trustedFoldersPath:
|
||||
env.GEMINI_CLI_TRUSTED_FOLDERS_PATH ||
|
||||
join(env.GEMINI_CLI_HOME || home, ".gemini", "trustedFolders.json"),
|
||||
...(trustOverride === undefined ? {} : { trustOverride }),
|
||||
platform,
|
||||
};
|
||||
};
|
||||
|
||||
const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
|
||||
platform === "win32"
|
||||
? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin")
|
||||
: join(home, ".config", "devin");
|
||||
: join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
|
||||
|
||||
/** Stable product metadata used to derive setup discovery and documentation. */
|
||||
export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
{
|
||||
name: "claude_code",
|
||||
displayName: "Claude Code",
|
||||
skillPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"),
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(claudeCodeConfigDirectory(context), ".claude.json"),
|
||||
markerPath: claudeCodeMarkerDirectory,
|
||||
@@ -243,8 +410,13 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
{
|
||||
name: "gemini_cli",
|
||||
displayName: "Gemini CLI",
|
||||
configPath: [".gemini", "settings.json"],
|
||||
markerPath: [".gemini"],
|
||||
geminiSettings,
|
||||
configPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"),
|
||||
markerPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.GEMINI_CLI_HOME || home, ".gemini"),
|
||||
skillPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.GEMINI_CLI_HOME || home, ".agents", "skills"),
|
||||
format: "json",
|
||||
},
|
||||
{
|
||||
@@ -266,12 +438,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
name: "opencode",
|
||||
displayName: "OpenCode",
|
||||
configPath: existingOpenCodeConfigPath,
|
||||
configPaths: openCodeConfigurationPaths,
|
||||
markerPath: opencodeDirectory,
|
||||
format: "opencode",
|
||||
},
|
||||
{
|
||||
name: "antigravity",
|
||||
displayName: "Antigravity",
|
||||
skillPath: [".gemini", "config", "skills"],
|
||||
configPath: [".gemini", "config", "mcp_config.json"],
|
||||
markerPath: [".gemini", "config"],
|
||||
format: "json",
|
||||
@@ -324,6 +498,27 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
markerPath: ompAgentDirectory,
|
||||
format: "omp",
|
||||
},
|
||||
{
|
||||
name: "pi",
|
||||
displayName: "Pi",
|
||||
configPath: (context: ClientPathContext) =>
|
||||
(context.platform === "win32" ? win32 : { join }).join(
|
||||
piAgentDirectory(context),
|
||||
"mcp.json",
|
||||
),
|
||||
markerPath: piAgentDirectory,
|
||||
format: "pi",
|
||||
},
|
||||
{
|
||||
name: "hermes",
|
||||
displayName: "Hermes",
|
||||
skillPath: (context: ClientPathContext) =>
|
||||
join(hermesDirectory(context), "skills"),
|
||||
configPath: (context: ClientPathContext) =>
|
||||
join(hermesDirectory(context), "config.yaml"),
|
||||
markerPath: hermesDirectory,
|
||||
format: "hermes",
|
||||
},
|
||||
{
|
||||
name: "grok_bot",
|
||||
displayName: "Grok Bot",
|
||||
@@ -333,6 +528,23 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||
},
|
||||
] as const satisfies readonly ClientDefinition[];
|
||||
|
||||
/**
|
||||
* Paths whose presence means doctor and setup should inspect the client.
|
||||
* Layered sources count: OpenCode still loads its XDG files when a custom
|
||||
* directory override does not exist.
|
||||
*/
|
||||
export const clientEvidencePaths = (
|
||||
client: Pick<SetupClient, "configPath" | "configPaths" | "markerPath">,
|
||||
): readonly string[] => [
|
||||
...new Set(
|
||||
[
|
||||
client.configPath,
|
||||
...(client.configPaths ?? []),
|
||||
client.markerPath,
|
||||
].filter((path): path is string => path !== undefined && path.length > 0),
|
||||
),
|
||||
];
|
||||
|
||||
const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
|
||||
typeof path === "function" ? path(context) : join(context.home, ...path);
|
||||
|
||||
@@ -340,28 +552,81 @@ const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
|
||||
export const supportedClients = (
|
||||
home: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: ClientPathContext["env"] = {
|
||||
APPDATA: process.env.APPDATA,
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
CODEX_HOME: process.env.CODEX_HOME,
|
||||
COPILOT_HOME: process.env.COPILOT_HOME,
|
||||
GROK_HOME: process.env.GROK_HOME,
|
||||
OMP_PROFILE: process.env.OMP_PROFILE,
|
||||
OPENCODE_CONFIG: process.env.OPENCODE_CONFIG,
|
||||
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
|
||||
PI_CONFIG_DIR: process.env.PI_CONFIG_DIR,
|
||||
PI_PROFILE: process.env.PI_PROFILE,
|
||||
QWEN_HOME: process.env.QWEN_HOME,
|
||||
SAND_DATA_ROOT: process.env.SAND_DATA_ROOT,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
},
|
||||
env: ClientPathContext["env"] = process.env,
|
||||
): readonly SetupClient[] => {
|
||||
const context = { home, platform, env };
|
||||
return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => ({
|
||||
name: definition.name,
|
||||
displayName: definition.displayName,
|
||||
configPath: resolvePath(definition.configPath, context),
|
||||
markerPath: resolvePath(definition.markerPath, context),
|
||||
format: definition.format,
|
||||
}));
|
||||
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
|
||||
return definitions.map((definition) => {
|
||||
try {
|
||||
const configPath = resolvePath(definition.configPath, context);
|
||||
return {
|
||||
name: definition.name,
|
||||
displayName: definition.displayName,
|
||||
configPath,
|
||||
...(definition.geminiSettings === undefined
|
||||
? {}
|
||||
: { geminiSettings: definition.geminiSettings(context) }),
|
||||
...(definition.configPaths === undefined
|
||||
? {}
|
||||
: {
|
||||
configPaths: [
|
||||
...new Set([...definition.configPaths(context), configPath]),
|
||||
],
|
||||
}),
|
||||
markerPath: resolvePath(definition.markerPath, context),
|
||||
format: definition.format,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
if (definition.name !== "pi") throw cause;
|
||||
return {
|
||||
name: definition.name,
|
||||
displayName: definition.displayName,
|
||||
format: definition.format,
|
||||
configPath: env.PI_CODING_AGENT_DIR ?? "",
|
||||
configPathError: `Invalid PI_CODING_AGENT_DIR: ${cause instanceof Error ? cause.message : String(cause)}. Repair the override before configuring Pi.`,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Resolve personal skill roots using the same client environment as setup. */
|
||||
export const clientSkillDirectories = (
|
||||
home: string,
|
||||
clientIds: readonly string[] | undefined,
|
||||
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): readonly { readonly client: string; readonly directory: string }[] => {
|
||||
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
|
||||
const selected =
|
||||
clientIds === undefined
|
||||
? [
|
||||
"shared",
|
||||
...definitions
|
||||
.filter(({ skillPath }) => skillPath !== undefined)
|
||||
.map(({ name }) => name),
|
||||
]
|
||||
: clientIds.length === 0
|
||||
? ["shared"]
|
||||
: clientIds;
|
||||
const destinations = new Map<string, { client: string; directory: string }>();
|
||||
for (const client of selected) {
|
||||
const definition = definitions.find(({ name }) => name === client);
|
||||
const directory =
|
||||
definition?.skillPath === undefined
|
||||
? join(home, ".agents", "skills")
|
||||
: resolvePath(definition.skillPath, {
|
||||
home,
|
||||
platform,
|
||||
env: environment,
|
||||
});
|
||||
if (!destinations.has(directory))
|
||||
destinations.set(directory, {
|
||||
client:
|
||||
directory === join(home, ".agents", "skills") ? "shared" : client,
|
||||
directory,
|
||||
});
|
||||
}
|
||||
return [...destinations.values()].sort((left, right) =>
|
||||
left.directory.localeCompare(right.directory),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import writeFileAtomic from "write-file-atomic";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { claudeCodeSkillsDirectory, skillDestinations } from "./SetupSkill.js";
|
||||
import { skillDestinations } from "./SetupSkill.js";
|
||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import {
|
||||
@@ -124,11 +124,12 @@ export const runUninstall = async (
|
||||
status: "retained",
|
||||
detail: "Hopper is not owned by REA uninstall.",
|
||||
});
|
||||
const reported = collapseUninstallItems(items);
|
||||
return {
|
||||
status: items.some(({ status }) => status === "failed")
|
||||
status: reported.some(({ status }) => status === "failed")
|
||||
? "failed"
|
||||
: "complete",
|
||||
items,
|
||||
items: reported,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -171,7 +172,16 @@ export const systemUninstallHost = (
|
||||
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
|
||||
return {
|
||||
clients: () =>
|
||||
Promise.resolve(supportedClients(home, platform, environment)),
|
||||
Promise.resolve(
|
||||
supportedClients(home, platform, environment).flatMap((client) =>
|
||||
client.configPaths === undefined
|
||||
? [client]
|
||||
: client.configPaths.map((configPath) => ({
|
||||
...client,
|
||||
configPath,
|
||||
})),
|
||||
),
|
||||
),
|
||||
inspectClient: async (client) => {
|
||||
const read = await readClientConfiguration(client, fileSystem);
|
||||
return read.kind === "item" && read.item.status === "failed"
|
||||
@@ -179,7 +189,8 @@ export const systemUninstallHost = (
|
||||
: undefined;
|
||||
},
|
||||
removeClient: (client) => removeClient(client, fileSystem),
|
||||
removeSkill: () => removeManagedSkills(home, fileSystem, environment),
|
||||
removeSkill: () =>
|
||||
removeManagedSkills(home, fileSystem, environment, platform),
|
||||
purgeData: async () => [
|
||||
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
|
||||
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
|
||||
@@ -200,6 +211,8 @@ const readClientConfiguration = async (
|
||||
client: SetupClient,
|
||||
fileSystem: UninstallFileSystem,
|
||||
): Promise<ClientConfigurationRead> => {
|
||||
if (client.configPathError !== undefined)
|
||||
return itemRead(item(client.name, "failed", client.configPathError));
|
||||
if (client.format === "unsupported")
|
||||
return itemRead(
|
||||
item(
|
||||
@@ -404,18 +417,16 @@ const removeManagedSkills = async (
|
||||
home: string,
|
||||
fileSystem: UninstallFileSystem,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<UninstallItem> => {
|
||||
const results = await Promise.all(
|
||||
skillDestinations(
|
||||
home,
|
||||
undefined,
|
||||
claudeCodeSkillsDirectory(home, environment),
|
||||
).map(({ client, path }) =>
|
||||
removeManagedPath(
|
||||
path,
|
||||
client === "claude_code" ? "Claude Code skill" : "skill",
|
||||
fileSystem,
|
||||
),
|
||||
skillDestinations(home, undefined, environment, platform).map(
|
||||
({ client, path }) =>
|
||||
removeManagedPath(
|
||||
path,
|
||||
client === "claude_code" ? "Claude Code skill" : "skill",
|
||||
fileSystem,
|
||||
),
|
||||
),
|
||||
);
|
||||
const failed = results.find(({ status }) => status === "failed");
|
||||
@@ -451,6 +462,68 @@ const item = (
|
||||
status: UninstallItem["status"],
|
||||
detail: string,
|
||||
): UninstallItem => ({ name, status, detail });
|
||||
|
||||
/**
|
||||
* Layered clients are removed file by file, but callers look up one status by
|
||||
* client name. A missing earlier file must not hide a later removal.
|
||||
*/
|
||||
const collapseUninstallItems = (
|
||||
items: readonly UninstallItem[],
|
||||
): UninstallItem[] => {
|
||||
const collapsed: UninstallItem[] = [];
|
||||
for (const next of items) {
|
||||
const index = collapsed.findIndex(({ name }) => name === next.name);
|
||||
const current = collapsed[index];
|
||||
if (current === undefined) {
|
||||
collapsed.push(next);
|
||||
continue;
|
||||
}
|
||||
collapsed[index] = mergeUninstallItems(current, next);
|
||||
}
|
||||
return collapsed;
|
||||
};
|
||||
|
||||
const mergeUninstallItems = (
|
||||
current: UninstallItem,
|
||||
next: UninstallItem,
|
||||
): UninstallItem => {
|
||||
const status = preferredUninstallStatus(current.status, next.status);
|
||||
const details = [current, next]
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.status === status ||
|
||||
(status === "removed" && entry.status === "retained"),
|
||||
)
|
||||
.map(({ detail }) => detail);
|
||||
return item(current.name, status, [...new Set(details)].join(" "));
|
||||
};
|
||||
|
||||
const preferredUninstallStatus = (
|
||||
current: UninstallItem["status"],
|
||||
next: UninstallItem["status"],
|
||||
): UninstallItem["status"] =>
|
||||
uninstallStatusPriority(next) > uninstallStatusPriority(current)
|
||||
? next
|
||||
: current;
|
||||
|
||||
const uninstallStatusPriority = (status: UninstallItem["status"]): number => {
|
||||
switch (status) {
|
||||
case "failed":
|
||||
return 3;
|
||||
case "removed":
|
||||
return 2;
|
||||
case "retained":
|
||||
return 1;
|
||||
case "skipped":
|
||||
return 0;
|
||||
default: {
|
||||
const exhaustive: never = status;
|
||||
throw new TypeError(
|
||||
`Unhandled uninstall item status: ${String(exhaustive)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const registrationSchema = z
|
||||
.object({ command: z.string(), args: z.array(z.string()) })
|
||||
.passthrough();
|
||||
|
||||
@@ -7,6 +7,13 @@ import { safeParseJson, type SafeJsonParseResult } from "../domain/safeJson.js";
|
||||
export const JSON_INPUT_RESOURCE_REMEDIATION =
|
||||
"Provide a smaller valid JSON value. For Evidence workflows, re-analyze a smaller selection of the original target and use its complete Evidence; splitting JSON text or trimming Evidence fields does not produce valid workflow input.";
|
||||
|
||||
/**
|
||||
* Strict JSON input keeps rejecting a leading byte-order mark, but names it:
|
||||
* the parser's own message quotes the invisible U+FEFF as the bad token.
|
||||
*/
|
||||
export const JSON_BYTE_ORDER_MARK_MESSAGE =
|
||||
"JSON input begins with a UTF-8 byte-order mark (EF BB BF), which strict JSON input does not accept. Save the file as UTF-8 without a byte-order mark.";
|
||||
|
||||
/** Decode JSON bytes without confusing a runtime string limit with malformed input. */
|
||||
export const parseUtf8Json = (
|
||||
bytes: Uint8Array,
|
||||
@@ -77,5 +84,7 @@ export const parseUtf8Json = (
|
||||
cause,
|
||||
};
|
||||
}
|
||||
if (text.startsWith("\uFEFF"))
|
||||
return { ok: false, error: JSON_BYTE_ORDER_MARK_MESSAGE, cause: undefined };
|
||||
return safeParseJson(text);
|
||||
};
|
||||
|
||||
@@ -67,6 +67,36 @@ describe("artifact-local graph identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("logical path identity", () => {
|
||||
it("resolves an NFD relative import to the NFC inventory path and rejects a root escape", () => {
|
||||
const nfc = "caf\u00e9".normalize("NFC");
|
||||
const nfd = nfc.normalize("NFD");
|
||||
const files = fileMap([
|
||||
file("dir/index.js", "root"),
|
||||
file(`dir/${nfc}.js`, "root"),
|
||||
]);
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: `./${nfd}.js`,
|
||||
sourcePath: "dir/index.js",
|
||||
context: "module-specifier",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: `dir/${nfc}.js`,
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath: "../escape.js",
|
||||
sourcePath: "index.js",
|
||||
context: "module-specifier",
|
||||
files,
|
||||
}),
|
||||
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("contextual JavaScript artifact path resolution", () => {
|
||||
it.each([
|
||||
["main.js", "app/main.js"],
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { isBuiltin } from "node:module";
|
||||
import { posix } from "node:path";
|
||||
|
||||
import {
|
||||
normalizeJoinedLogicalPath,
|
||||
logicalPathEscapesRoot,
|
||||
} from "../../domain/artifactIdentity.js";
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import {
|
||||
admitsCanonicalPathSyntax,
|
||||
@@ -326,12 +330,8 @@ const confineCandidate = (
|
||||
input: ResolveArtifactPathInput,
|
||||
candidate: string,
|
||||
): string | ArtifactPathResolution => {
|
||||
const normalized = posix.normalize(candidate);
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.startsWith("/")
|
||||
)
|
||||
const normalized = normalizeJoinedLogicalPath(candidate);
|
||||
if (logicalPathEscapesRoot(normalized))
|
||||
return unresolvedOutcome(input, "rejected", [
|
||||
"The resolved candidate escapes the canonical artifact root.",
|
||||
]);
|
||||
|
||||
@@ -73,15 +73,27 @@ export const traceNativeUiAction = async (
|
||||
),
|
||||
);
|
||||
graph = joinInterfaceBuilderDispatch(decoded.data.graph, metadata.result);
|
||||
const archiveDecode = decoded.data.graph.coverage.find(
|
||||
(facet) => facet.facet === "archive_decode",
|
||||
);
|
||||
const documentsFound = decoded.data.documents.length > 0;
|
||||
coverage.push({
|
||||
facet: "interface_builder_archives",
|
||||
status: decoded.data.documents.length > 0 ? "complete" : "unsupported",
|
||||
status:
|
||||
archiveDecode?.status === "partial" ||
|
||||
archiveDecode?.status === "complete" ||
|
||||
archiveDecode?.status === "unsupported"
|
||||
? archiveDecode.status
|
||||
: documentsFound
|
||||
? "complete"
|
||||
: "unsupported",
|
||||
reason:
|
||||
decoded.data.documents.length > 0
|
||||
archiveDecode?.reason ??
|
||||
(documentsFound
|
||||
? null
|
||||
: "no_compiled_interface_builder_archives_found",
|
||||
examined: decoded.data.documents.length,
|
||||
omitted: 0,
|
||||
: "no_compiled_interface_builder_archives_found"),
|
||||
examined: archiveDecode?.examined ?? decoded.data.documents.length,
|
||||
omitted: archiveDecode?.omitted ?? 0,
|
||||
});
|
||||
const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges);
|
||||
graph = bounded.graph;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
captureProcessScenarioFile,
|
||||
projectProcessCliError,
|
||||
} from "./ProcessCli.js";
|
||||
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
@@ -87,4 +88,14 @@ describe("process CLI input diagnostics", () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("names a leading byte-order mark instead of quoting it as a token", async () => {
|
||||
const path = join(await fixture(), "scenario.json");
|
||||
await writeFile(path, '\uFEFF{"executable":"/bin/echo"}');
|
||||
expect(await captureProcessScenarioFile(path)).toEqual({
|
||||
error: "Process command failed",
|
||||
category: "invalid_input",
|
||||
message: `Process input file is not valid UTF-8 JSON: ${path} (${JSON_BYTE_ORDER_MARK_MESSAGE}). Repair the file, then try again.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,11 @@ import { processTraceSpecificationSchema } from "../../domain/process/processTra
|
||||
import { processScenarioSchema } from "../../domain/process/processScenario.js";
|
||||
import { compareProcessCaptures } from "../../domain/process/processComparison.js";
|
||||
import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js";
|
||||
import { createProgressReporter } from "../ProgressReporter.js";
|
||||
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
|
||||
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
|
||||
import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
|
||||
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
|
||||
|
||||
/** Safe process-command failure returned to the CLI adapter. */
|
||||
export interface ProcessCliErrorOutput {
|
||||
@@ -55,11 +57,20 @@ export const captureProcessScenarioFile = async (
|
||||
input,
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
const progress = createProgressReporter(
|
||||
async (update) => {
|
||||
process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`);
|
||||
},
|
||||
{ minimumIntervalMs: 100 },
|
||||
);
|
||||
const captured = await captureProcessScenario(
|
||||
parsed.data,
|
||||
signal,
|
||||
process.platform,
|
||||
environment,
|
||||
undefined,
|
||||
undefined,
|
||||
progress,
|
||||
);
|
||||
if (!captured.ok) return cliAnalysisError(captured.error);
|
||||
return createProcessCaptureEvidence(parsed.data, captured.value);
|
||||
@@ -181,6 +192,8 @@ const readJson = async (path: string): Promise<unknown> => {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(bytes);
|
||||
if (text.startsWith("\uFEFF"))
|
||||
throw new Error(JSON_BYTE_ORDER_MARK_MESSAGE);
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return parsed;
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
annotateEnglishUnicodeCaseCollisions,
|
||||
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
|
||||
} from "./ArtifactPaths.js";
|
||||
|
||||
const NOTE =
|
||||
"a case-insensitive destination cannot store both. This comparison is not a filesystem observation.";
|
||||
|
||||
const annotate = (paths: readonly string[]) => {
|
||||
const occurrences = paths.map((logical_path) => ({
|
||||
logical_path,
|
||||
limitations: [] as string[],
|
||||
}));
|
||||
const limitation = annotateEnglishUnicodeCaseCollisions(occurrences);
|
||||
return {
|
||||
limitation,
|
||||
byPath: new Map(
|
||||
occurrences.map(({ logical_path, limitations }) => [
|
||||
logical_path,
|
||||
limitations,
|
||||
]),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
describe("English Unicode case collisions", () => {
|
||||
it("names the other spelling of a colliding file", () => {
|
||||
const { limitation, byPath } = annotate([".", "Main.js", "main.js", "x"]);
|
||||
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
|
||||
expect(byPath.get("Main.js")).toEqual([
|
||||
`Logical path Main.js collides under English (en-US) Unicode case folding with main.js; ${NOTE}`,
|
||||
]);
|
||||
expect(byPath.get("main.js")).toEqual([
|
||||
`Logical path main.js collides under English (en-US) Unicode case folding with Main.js; ${NOTE}`,
|
||||
]);
|
||||
expect(byPath.get("x")).toEqual([]);
|
||||
});
|
||||
|
||||
it("records a directory collision once at the diverging segment", () => {
|
||||
const { byPath } = annotate([
|
||||
"res/Foo",
|
||||
"res/Foo/a.txt",
|
||||
"res/foo",
|
||||
"res/foo/b.txt",
|
||||
]);
|
||||
expect(byPath.get("res/Foo")).toEqual([
|
||||
`Logical path res/Foo collides under English (en-US) Unicode case folding with res/foo; ${NOTE}`,
|
||||
]);
|
||||
expect(byPath.get("res/foo/b.txt")).toEqual([
|
||||
`Logical path res/foo/b.txt is under res/foo, which collides under English (en-US) Unicode case folding with res/Foo; ${NOTE}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts further spellings, each of which is its own occurrence", () => {
|
||||
const { byPath } = annotate(["A.txt", "a.txt", "a.TXT"]);
|
||||
expect(byPath.get("a.txt")).toEqual([
|
||||
`Logical path a.txt collides under English (en-US) Unicode case folding with A.txt and 1 other spelling; ${NOTE}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("stays linear for large case-variant subtrees", () => {
|
||||
const count = 5_000;
|
||||
const paths = Array.from({ length: count }, (_, index) => [
|
||||
`Foo/a${String(index)}.txt`,
|
||||
`foo/b${String(index)}.txt`,
|
||||
]).flat();
|
||||
const { byPath } = annotate(paths);
|
||||
for (const path of paths) {
|
||||
const limitations = byPath.get(path) ?? [];
|
||||
expect(limitations).toHaveLength(1);
|
||||
expect(limitations[0]?.length).toBeLessThan(300);
|
||||
}
|
||||
});
|
||||
|
||||
it("walks names with more components than the call stack holds", () => {
|
||||
const deep = Array.from({ length: 30_000 }, () => "a").join("/");
|
||||
const { byPath } = annotate([deep, `${deep}/X`, `${deep}/x`]);
|
||||
expect(byPath.get(`${deep}/X`)).toHaveLength(1);
|
||||
expect(byPath.get(deep)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { posix } from "node:path";
|
||||
|
||||
import { logicalPathNfc } from "../domain/artifactIdentity.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
|
||||
@@ -31,7 +32,7 @@ export const normalizeArtifactPath = (input: string): string => {
|
||||
"path",
|
||||
`Artifact path is absolute or unsafe: ${JSON.stringify(input)}`,
|
||||
);
|
||||
const normalized = input.normalize("NFC").replace(/\/+$/u, "");
|
||||
const normalized = logicalPathNfc(input).replace(/\/+$/u, "");
|
||||
const parts = normalized.split("/");
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
@@ -115,16 +116,39 @@ export const annotateEnglishUnicodeCaseCollisions = (
|
||||
occurrences.map(({ logical_path }) => logical_path),
|
||||
);
|
||||
if (collisions.size === 0) return undefined;
|
||||
for (const occurrence of occurrences) {
|
||||
const others = collisions.get(occurrence.logical_path);
|
||||
if (others === undefined || others.length === 0) continue;
|
||||
occurrence.limitations.push(
|
||||
`Logical path ${occurrence.logical_path} collides under English (en-US) Unicode case folding with ${others.join(", ")}; a case-insensitive destination cannot store both. ${ENGLISH_UNICODE_CASE_NOTE}`,
|
||||
);
|
||||
}
|
||||
for (const occurrence of occurrences)
|
||||
for (const collision of collisions.get(occurrence.logical_path) ?? [])
|
||||
occurrence.limitations.push(
|
||||
caseCollisionLimitation(occurrence.logical_path, collision),
|
||||
);
|
||||
return ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION;
|
||||
};
|
||||
|
||||
/** One logical path segment whose spelling folds to another sibling's. */
|
||||
interface CaseCollision {
|
||||
/** Logical path through the colliding segment. */
|
||||
readonly prefix: string;
|
||||
/** First other sibling spelling, as a logical path, in code-point order. */
|
||||
readonly other: string;
|
||||
/** Count of further sibling spellings, each its own occurrence. */
|
||||
readonly additional: number;
|
||||
}
|
||||
|
||||
const caseCollisionLimitation = (
|
||||
path: string,
|
||||
{ prefix, other, additional }: CaseCollision,
|
||||
): string => {
|
||||
const others =
|
||||
additional === 0
|
||||
? other
|
||||
: `${other} and ${String(additional)} other spelling${additional === 1 ? "" : "s"}`;
|
||||
const subject =
|
||||
prefix === path
|
||||
? `Logical path ${path} collides`
|
||||
: `Logical path ${path} is under ${prefix}, which collides`;
|
||||
return `${subject} under English (en-US) Unicode case folding with ${others}; a case-insensitive destination cannot store both. ${ENGLISH_UNICODE_CASE_NOTE}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Name the directory-listing spelling that differs from the requested segment
|
||||
* only by English (en-US) Unicode case. Exact matches are the same logical name.
|
||||
@@ -147,7 +171,7 @@ export const destinationCaseCollisionMessage = (
|
||||
|
||||
const englishUnicodeCaseCollisions = (
|
||||
paths: readonly string[],
|
||||
): ReadonlyMap<string, readonly string[]> => {
|
||||
): ReadonlyMap<string, readonly CaseCollision[]> => {
|
||||
const root = emptyCaseNode();
|
||||
for (const path of paths) {
|
||||
if (path.length === 0 || path === ".") continue;
|
||||
@@ -166,60 +190,62 @@ const englishUnicodeCaseCollisions = (
|
||||
}
|
||||
node.terminals.push(path);
|
||||
}
|
||||
const collisions = new Map<string, Set<string>>();
|
||||
recordCaseCollisions(root, collisions);
|
||||
return new Map(
|
||||
[...collisions.entries()].map(([path, others]) => [
|
||||
path,
|
||||
[...others].sort(compareUnicodeCodePoints),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const recordCaseCollisions = (
|
||||
node: CaseNode,
|
||||
collisions: Map<string, Set<string>>,
|
||||
): void => {
|
||||
for (const spellings of node.spellingsByFold.values())
|
||||
if (spellings.length > 1) noteFoldedSpellings(node, spellings, collisions);
|
||||
for (const child of node.bySpelling.values())
|
||||
recordCaseCollisions(child, collisions);
|
||||
};
|
||||
|
||||
const noteFoldedSpellings = (
|
||||
node: CaseNode,
|
||||
spellings: readonly string[],
|
||||
collisions: Map<string, Set<string>>,
|
||||
): void => {
|
||||
const groups = spellings.map((spelling) => {
|
||||
const child = node.bySpelling.get(spelling);
|
||||
return child === undefined ? [] : terminalsUnder(child);
|
||||
});
|
||||
for (const [index, group] of groups.entries())
|
||||
noteCaseGroup(
|
||||
group,
|
||||
groups.flatMap((candidate, candidateIndex) =>
|
||||
candidateIndex === index ? [] : candidate,
|
||||
),
|
||||
collisions,
|
||||
);
|
||||
};
|
||||
|
||||
const noteCaseGroup = (
|
||||
group: readonly string[],
|
||||
others: readonly string[],
|
||||
collisions: Map<string, Set<string>>,
|
||||
): void => {
|
||||
for (const path of group) {
|
||||
const set = collisions.get(path) ?? new Set<string>();
|
||||
for (const other of others) if (other !== path) set.add(other);
|
||||
collisions.set(path, set);
|
||||
// A collision is recorded once, at the segment where spellings diverge, and
|
||||
// shared down the subtree. Pairing every descendant of one spelling with
|
||||
// every descendant of another grew quadratically with the subtree sizes.
|
||||
// The walk is iterative because a ZIP name can hold more components than
|
||||
// the call stack allows.
|
||||
const collisions = new Map<string, readonly CaseCollision[]>();
|
||||
const pending: {
|
||||
readonly node: CaseNode;
|
||||
readonly prefix: string | undefined;
|
||||
readonly chain: CollisionChain | undefined;
|
||||
}[] = [{ node: root, prefix: undefined, chain: undefined }];
|
||||
for (let item = pending.pop(); item !== undefined; item = pending.pop()) {
|
||||
const { node, prefix, chain } = item;
|
||||
if (chain !== undefined)
|
||||
for (const path of node.terminals)
|
||||
collisions.set(path, collisionsAlong(chain));
|
||||
for (const spellings of node.spellingsByFold.values())
|
||||
spellings.sort(compareUnicodeCodePoints);
|
||||
for (const [spelling, child] of node.bySpelling) {
|
||||
const childPrefix =
|
||||
prefix === undefined ? spelling : `${prefix}/${spelling}`;
|
||||
const spellings =
|
||||
node.spellingsByFold.get(englishCaseFold(spelling)) ?? [];
|
||||
const other = spellings[0] === spelling ? spellings[1] : spellings[0];
|
||||
pending.push({
|
||||
node: child,
|
||||
prefix: childPrefix,
|
||||
chain:
|
||||
other === undefined
|
||||
? chain
|
||||
: {
|
||||
collision: {
|
||||
prefix: childPrefix,
|
||||
other: prefix === undefined ? other : `${prefix}/${other}`,
|
||||
additional: spellings.length - 2,
|
||||
},
|
||||
parent: chain,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return collisions;
|
||||
};
|
||||
|
||||
const terminalsUnder = (node: CaseNode): readonly string[] => {
|
||||
const paths = [...node.terminals];
|
||||
for (const child of node.bySpelling.values())
|
||||
paths.push(...terminalsUnder(child));
|
||||
return paths;
|
||||
/** Collisions on one path, linked from the deepest segment to the root. */
|
||||
interface CollisionChain {
|
||||
readonly collision: CaseCollision;
|
||||
readonly parent: CollisionChain | undefined;
|
||||
}
|
||||
|
||||
const collisionsAlong = (chain: CollisionChain): CaseCollision[] => {
|
||||
const collisions: CaseCollision[] = [];
|
||||
let link: CollisionChain | undefined = chain;
|
||||
while (link !== undefined) {
|
||||
collisions.push(link.collision);
|
||||
link = link.parent;
|
||||
}
|
||||
return collisions.reverse();
|
||||
};
|
||||
|
||||
@@ -194,10 +194,8 @@ class ArtifactClient implements AnalysisClient {
|
||||
this.target.sourcePath ?? this.target.path,
|
||||
result.manifest,
|
||||
),
|
||||
locations: result.artifacts.map(({ relative_path: path }) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path,
|
||||
})),
|
||||
// Each extracted file's path is already in artifacts.
|
||||
locations: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -211,10 +209,8 @@ class ArtifactClient implements AnalysisClient {
|
||||
this.target.sourcePath ?? this.target.path,
|
||||
result.manifest,
|
||||
),
|
||||
locations: result.occurrences.map(({ logical_path: path }) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path,
|
||||
})),
|
||||
// Each occurrence's logical path is already in the result.
|
||||
locations: [],
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -323,17 +319,14 @@ class ArtifactClient implements AnalysisClient {
|
||||
this.target.sourcePath ?? this.target.path,
|
||||
inventory.manifest,
|
||||
);
|
||||
const locations = inventory.occurrences.map(({ logical_path: path }) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path,
|
||||
}));
|
||||
const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, {
|
||||
operation: "inventory_artifact",
|
||||
parameters: inventoryParameters,
|
||||
result: inventory,
|
||||
rawResult: null,
|
||||
limitations: inventory.limitations,
|
||||
locations,
|
||||
// The nested inventory already names every occurrence's logical path.
|
||||
locations: [],
|
||||
});
|
||||
const result = createArtifactInspection(inventoryEvidence);
|
||||
await options?.progress?.report({
|
||||
@@ -347,7 +340,7 @@ class ArtifactClient implements AnalysisClient {
|
||||
rawResult: null,
|
||||
limitations: result.limitations,
|
||||
subject,
|
||||
locations,
|
||||
locations: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,10 +147,45 @@ const estimateXmlPlistExpansion = (
|
||||
return decodedSourceBytes * 2 + elementCount * 128;
|
||||
};
|
||||
|
||||
/** Count every expanded binary-plist reference before calling plist.parseBinary. */
|
||||
const estimateBinaryPlistExpansion = (
|
||||
/** Resource a binary plist preflight found over its budget, or a malformed reference cycle. */
|
||||
export type BinaryPlistExpansionFailure = "cycle" | "depth" | "expansion";
|
||||
|
||||
const interfaceBuilderBudgetFailure = (
|
||||
_kind: BinaryPlistExpansionFailure,
|
||||
message: string,
|
||||
): Error =>
|
||||
new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
message,
|
||||
);
|
||||
|
||||
/** How a caller names and reports binary plist expansion failures. */
|
||||
export interface BinaryPlistExpansionOptions {
|
||||
/** Budget named in failure messages. */
|
||||
readonly budget?: string;
|
||||
/** Error for budget, depth, and reference-cycle failures. */
|
||||
readonly fail?: (kind: BinaryPlistExpansionFailure, message: string) => Error;
|
||||
/**
|
||||
* Structural bounds: "strict" keeps objects and the offset table before the
|
||||
* trailer; "decoder" admits exactly what plist.parseBinary can read, so
|
||||
* expansion is bounded without rejecting archives that decoder accepts.
|
||||
*/
|
||||
readonly bounds?: "strict" | "decoder";
|
||||
}
|
||||
|
||||
/**
|
||||
* Count every expanded binary-plist reference before calling plist.parseBinary,
|
||||
* which recurses on each reference and copies shared containers. Malformed
|
||||
* structure throws TypeError; budget, depth, and cycle failures use `fail`.
|
||||
*/
|
||||
export const estimateBinaryPlistExpansion = (
|
||||
bytes: Buffer,
|
||||
maxBytes: number,
|
||||
{
|
||||
budget = "the aggregate Interface Builder decode budget",
|
||||
fail = interfaceBuilderBudgetFailure,
|
||||
bounds = "strict",
|
||||
}: BinaryPlistExpansionOptions = {},
|
||||
): number => {
|
||||
if (bytes.length < 40)
|
||||
throw new TypeError("binary plist trailer is truncated");
|
||||
@@ -172,20 +207,19 @@ const estimateBinaryPlistExpansion = (
|
||||
const objectCount = readInteger(trailer + 8, 8);
|
||||
const topObject = readInteger(trailer + 16, 8);
|
||||
const offsetTable = readInteger(trailer + 24, 8);
|
||||
const tableEnd = bounds === "strict" ? trailer : bytes.length;
|
||||
const objectsEnd = bounds === "strict" ? offsetTable : bytes.length;
|
||||
if (
|
||||
objectCount < 1 ||
|
||||
topObject < 0 ||
|
||||
topObject >= objectCount ||
|
||||
offsetTable < 8 ||
|
||||
offsetTable + objectCount * offsetSize > trailer
|
||||
offsetTable + objectCount * offsetSize > tableEnd
|
||||
)
|
||||
throw new TypeError("binary plist trailer or object table is invalid");
|
||||
let estimate = bytes.length * 2 + objectCount * 16;
|
||||
if (estimate > maxBytes)
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
"binary plist object table exceeds the aggregate Interface Builder decode budget",
|
||||
);
|
||||
throw fail("expansion", `binary plist object table exceeds ${budget}`);
|
||||
|
||||
const active = new Set<number>();
|
||||
const pending: Array<{ object: number; depth: number; exit: boolean }> = [
|
||||
@@ -201,26 +235,20 @@ const estimateBinaryPlistExpansion = (
|
||||
}
|
||||
pendingObjectCount -= 1;
|
||||
if (entry.depth > 128)
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
"binary plist nesting exceeds the aggregate Interface Builder decode budget",
|
||||
);
|
||||
throw fail("depth", `binary plist nesting exceeds ${budget}`);
|
||||
if (active.has(entry.object))
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
"binary plist reference cycle exceeds the aggregate Interface Builder decode budget",
|
||||
);
|
||||
throw fail("cycle", `binary plist reference cycle exceeds ${budget}`);
|
||||
estimate += 96;
|
||||
if (estimate > maxBytes)
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
"binary plist reference expansion exceeds the aggregate Interface Builder decode budget",
|
||||
throw fail(
|
||||
"expansion",
|
||||
`binary plist reference expansion exceeds ${budget}`,
|
||||
);
|
||||
const offset = readInteger(
|
||||
offsetTable + entry.object * offsetSize,
|
||||
offsetSize,
|
||||
);
|
||||
if (offset < 8 || offset >= offsetTable)
|
||||
if (offset < 8 || offset >= objectsEnd)
|
||||
throw new TypeError("binary plist object offset is invalid");
|
||||
const marker = bytes[offset] ?? 0;
|
||||
const type = marker >> 4;
|
||||
@@ -234,31 +262,31 @@ const estimateBinaryPlistExpansion = (
|
||||
const integerSize = 1 << (extMarker & 0x0f);
|
||||
size = readInteger(cursor, integerSize);
|
||||
cursor += integerSize;
|
||||
if (size < 0 || cursor > offsetTable)
|
||||
if (size < 0 || cursor > objectsEnd)
|
||||
throw new TypeError("binary plist extended object size is invalid");
|
||||
}
|
||||
if (type === 4 || type === 5 || type === 6) {
|
||||
const byteLength = type === 6 ? size * 2 : size;
|
||||
if (cursor + byteLength > offsetTable)
|
||||
if (cursor + byteLength > objectsEnd)
|
||||
throw new TypeError("binary plist scalar object is truncated");
|
||||
estimate += type === 4 ? size + 4 * Math.ceil(size / 3) : byteLength * 2;
|
||||
if (estimate > maxBytes)
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
"binary plist scalar expansion exceeds the aggregate Interface Builder decode budget",
|
||||
throw fail(
|
||||
"expansion",
|
||||
`binary plist scalar expansion exceeds ${budget}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (type !== 10 && type !== 13) continue;
|
||||
const childCount = type === 13 ? size * 2 : size;
|
||||
const refsEnd = cursor + childCount * referenceSize;
|
||||
if (!Number.isSafeInteger(refsEnd) || refsEnd > offsetTable)
|
||||
if (!Number.isSafeInteger(refsEnd) || refsEnd > objectsEnd)
|
||||
throw new TypeError("binary plist reference table is truncated");
|
||||
const availableNodes = Math.floor((maxBytes - estimate) / 96);
|
||||
if (childCount > availableNodes - pendingObjectCount)
|
||||
throw new InterfaceBuilderDecodeBudgetExceeded(
|
||||
"aggregate_decode_budget_exhausted",
|
||||
"binary plist reference expansion exceeds the aggregate Interface Builder decode budget",
|
||||
throw fail(
|
||||
"expansion",
|
||||
`binary plist reference expansion exceeds ${budget}`,
|
||||
);
|
||||
active.add(entry.object);
|
||||
pending.push({ object: entry.object, depth: entry.depth, exit: true });
|
||||
|
||||
@@ -103,3 +103,88 @@ describe("inert keyed archive decoding", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Encode a bplist00 whose objects reference each other by index. */
|
||||
const binaryPlist = (objects: readonly Buffer[]): Buffer => {
|
||||
const header = Buffer.from("bplist00");
|
||||
const offsets: number[] = [];
|
||||
let position = header.length;
|
||||
for (const object of objects) {
|
||||
offsets.push(position);
|
||||
position += object.length;
|
||||
}
|
||||
const table = Buffer.alloc(objects.length * 4);
|
||||
offsets.forEach((offset, index) => table.writeUInt32BE(offset, index * 4));
|
||||
const trailer = Buffer.alloc(32);
|
||||
trailer[6] = 4;
|
||||
trailer[7] = 2;
|
||||
trailer.writeBigUInt64BE(BigInt(objects.length), 8);
|
||||
trailer.writeBigUInt64BE(0n, 16);
|
||||
trailer.writeBigUInt64BE(BigInt(position), 24);
|
||||
return Buffer.concat([header, ...objects, table, trailer]);
|
||||
};
|
||||
const plistArray = (references: readonly number[]): Buffer => {
|
||||
const bytes = Buffer.alloc(1 + references.length * 2);
|
||||
bytes[0] = 0xa0 | references.length;
|
||||
references.forEach((reference, index) =>
|
||||
bytes.writeUInt16BE(reference, 1 + index * 2),
|
||||
);
|
||||
return bytes;
|
||||
};
|
||||
const plistString = (value: string): Buffer =>
|
||||
Buffer.concat([Buffer.from([0x50 | value.length]), Buffer.from(value)]);
|
||||
// {"$archiver": "NSKeyedArchiver", "$top": {"root": UID 1}, "$objects": object 8}
|
||||
const keyedRoot = (payload: readonly Buffer[]): Buffer =>
|
||||
binaryPlist([
|
||||
Buffer.from([0xd3, 0, 1, 0, 3, 0, 4, 0, 2, 0, 8, 0, 5]),
|
||||
plistString("$archiver"),
|
||||
Buffer.concat([
|
||||
Buffer.from([0x5f, 0x10, 15]),
|
||||
Buffer.from("NSKeyedArchiver"),
|
||||
]),
|
||||
plistString("$objects"),
|
||||
plistString("$top"),
|
||||
Buffer.from([0xd1, 0, 6, 0, 7]),
|
||||
plistString("root"),
|
||||
Buffer.from([0x80, 1]),
|
||||
...payload,
|
||||
]);
|
||||
|
||||
describe("binary plist reference preflight", () => {
|
||||
const selection = { offset: 0, limit: 10 };
|
||||
|
||||
it("reports a reference cycle as malformed, not as an oversized archive", () => {
|
||||
expect(() =>
|
||||
decodeKeyedArchiveBytes(keyedRoot([plistArray([8])]), selection),
|
||||
).toThrow(
|
||||
new TypeError(
|
||||
"binary plist object references form a cycle, which a property list cannot represent",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects exponential shared-container expansion before decoding", () => {
|
||||
// Forty levels of [next, next] expand to 2^40 leaves in a few hundred bytes.
|
||||
const levels = Array.from({ length: 40 }, (_, index) =>
|
||||
plistArray([9 + index, 9 + index]),
|
||||
);
|
||||
expect(() =>
|
||||
decodeKeyedArchiveBytes(
|
||||
keyedRoot([...levels, plistString("x")]),
|
||||
selection,
|
||||
),
|
||||
).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("decodes shared leaf references", () => {
|
||||
const decoded = decodeKeyedArchiveBytes(
|
||||
keyedRoot([plistArray([9, 9, 9]), plistString("$null")]),
|
||||
selection,
|
||||
);
|
||||
expect(decoded.objects.map(({ id, value }) => [id, value])).toEqual([
|
||||
[0, "$null"],
|
||||
[1, "$null"],
|
||||
[2, "$null"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,11 @@ import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import type { ArtifactEntry } from "../ArtifactReader.js";
|
||||
|
||||
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
|
||||
import { estimateBinaryPlistExpansion } from "./InterfaceBuilderDecodeBudget.js";
|
||||
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
/** Decoded representation budget, the same 8:1 ratio Interface Builder uses. */
|
||||
const MAX_DECODE_BYTES = 8 * MAX_BYTES;
|
||||
|
||||
/** Parse inert plist bytes, preserving data and dates with explicit typed values. */
|
||||
export const decodeKeyedArchiveBytes = (
|
||||
@@ -45,6 +48,20 @@ export const decodeKeyedArchiveBytes = (
|
||||
);
|
||||
const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00";
|
||||
const xmlText = binary ? undefined : decodeXmlPlistText(bytes);
|
||||
// plist.parseBinary recurses on every object reference and copies shared
|
||||
// containers, so a few hundred bytes can exhaust memory or the stack.
|
||||
// RangeError reports a resource limit; a reference cycle is malformed.
|
||||
if (binary)
|
||||
estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, {
|
||||
budget: "the keyed archive decode budget",
|
||||
fail: (kind, message) =>
|
||||
kind === "cycle"
|
||||
? new TypeError(
|
||||
"binary plist object references form a cycle, which a property list cannot represent",
|
||||
)
|
||||
: new RangeError(message),
|
||||
bounds: "decoder",
|
||||
});
|
||||
const parsed = binary
|
||||
? { value: parseBinary(bytes), omittedPrototypeKeys: 0 }
|
||||
: parseXmlPropertyList(xmlText ?? "");
|
||||
|
||||
@@ -249,16 +249,14 @@ export const createArtifactEdges = (
|
||||
rootArtifactId;
|
||||
const semantic = {
|
||||
parent_artifact_id: parentArtifactId,
|
||||
child_artifact_id: occurrence.artifact_id,
|
||||
relation:
|
||||
occurrence.entry_kind === "slice"
|
||||
? ("slice-of" as const)
|
||||
: relationFor(occurrence.logical_path),
|
||||
occurrence_id: occurrence.occurrence_id,
|
||||
logical_path: occurrence.logical_path,
|
||||
};
|
||||
edges.push({
|
||||
edge_id: artifactEdgeId(semantic),
|
||||
edge_id: artifactEdgeId(semantic, occurrence),
|
||||
...semantic,
|
||||
producer: occurrence.entry_kind === "slice" ? (producer ?? null) : null,
|
||||
ordinal: edges.length,
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
|
||||
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
||||
import {
|
||||
classifyArtifactBytes,
|
||||
classifyArtifactContent,
|
||||
} from "./ArtifactGraphConstruction.js";
|
||||
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js";
|
||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||
import {
|
||||
@@ -17,17 +20,6 @@ import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import type { HashResult } from "../ArtifactHash.js";
|
||||
import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js";
|
||||
|
||||
const classifyContainerExtension = (
|
||||
path: string,
|
||||
): ArtifactOccurrence["artifact_format"] | undefined => {
|
||||
const lower = path.toLowerCase();
|
||||
const zipPackage = zipPackageFormatForPath(lower);
|
||||
if (zipPackage !== undefined) return zipPackage;
|
||||
for (const format of ["asar", "dmg", "pkg"] as const)
|
||||
if (lower.endsWith(`.${format}`)) return format;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Classify and hash one file root through the same stable open descriptor. */
|
||||
export const classifyAndHashRoot = async (
|
||||
path: string,
|
||||
@@ -47,9 +39,7 @@ export const classifyAndHashRoot = async (
|
||||
"integrity",
|
||||
`Root artifact changed before inventory: ${path}`,
|
||||
);
|
||||
const format =
|
||||
classifyContainerExtension(path) ??
|
||||
(await classifyOpenedRoot(path, handle));
|
||||
const format = await classifyRootFormat(path, handle);
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
@@ -79,14 +69,50 @@ const openRootFile = async (
|
||||
}
|
||||
};
|
||||
|
||||
const classifyOpenedRoot = async (
|
||||
const classifyRootFormat = async (
|
||||
path: string,
|
||||
handle: FileHandle,
|
||||
): Promise<ArtifactOccurrence["artifact_format"]> => {
|
||||
const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES);
|
||||
const observed = await handle.read(magic, 0, magic.length, 0);
|
||||
const prefix = magic.subarray(0, observed.bytesRead);
|
||||
if (hasZipSignature(prefix)) return "zip";
|
||||
return classifyArtifactContent(path, prefix, (await handle.stat()).size)
|
||||
.format;
|
||||
const lower = path.toLowerCase();
|
||||
if (hasZipSignature(prefix)) return zipPackageFormatForPath(lower) ?? "zip";
|
||||
if (lower.endsWith(".asar") && hasAsarHeader(prefix)) return "asar";
|
||||
if (
|
||||
lower.endsWith(".pkg") &&
|
||||
prefix.subarray(0, 4).toString("ascii") === "xar!"
|
||||
)
|
||||
return "pkg";
|
||||
if (lower.endsWith(".dmg") && (await hasKolyTrailer(handle))) return "dmg";
|
||||
const size = (await handle.stat()).size;
|
||||
const format = classifyArtifactContent(path, prefix, size).format;
|
||||
// Path suffixes stay role hints for nested members. A root whose container
|
||||
// magic did not match is classified from the bytes that were read.
|
||||
if (format === "asar" || format === "pkg" || format === "dmg")
|
||||
return classifyArtifactBytes(prefix, size);
|
||||
return format;
|
||||
};
|
||||
|
||||
/**
|
||||
* Electron ASAR starts with a size pickle whose payload is one uint32, then
|
||||
* a header pickle whose payload begins with a JSON object.
|
||||
*/
|
||||
const hasAsarHeader = (prefix: Buffer): boolean => {
|
||||
if (prefix.length < 16 || prefix.readUInt32LE(0) !== 4) return false;
|
||||
const headerSize = prefix.readUInt32LE(4);
|
||||
const payloadSize = prefix.readUInt32LE(8);
|
||||
if (headerSize < 16 || payloadSize !== headerSize - 4) return false;
|
||||
const stringLength = prefix.readUInt32LE(12);
|
||||
return (
|
||||
stringLength >= 2 && stringLength <= payloadSize - 4 && prefix[16] === 0x7b
|
||||
);
|
||||
};
|
||||
|
||||
const hasKolyTrailer = async (handle: FileHandle): Promise<boolean> => {
|
||||
const size = (await handle.stat()).size;
|
||||
if (size < 512) return false;
|
||||
const trailer = Buffer.alloc(4);
|
||||
const read = await handle.read(trailer, 0, trailer.length, size - 512);
|
||||
return read.bytesRead === 4 && trailer.toString("ascii") === "koly";
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
|
||||
import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js";
|
||||
import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||
import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "../../domain/zipPackageFormat.js";
|
||||
|
||||
export const createReader = async (
|
||||
path: string,
|
||||
@@ -39,7 +40,15 @@ export const inventoryLimitations = (
|
||||
format: ArtifactOccurrence["artifact_format"],
|
||||
reader: ArtifactReader | undefined,
|
||||
): string[] => {
|
||||
if (reader !== undefined) return [];
|
||||
if (reader !== undefined)
|
||||
return reader.format === "zip" ||
|
||||
reader.format === "ipa" ||
|
||||
reader.format === "apk" ||
|
||||
reader.format === "msix" ||
|
||||
reader.format === "appx"
|
||||
? [ZIP_NON_ENTRY_TAIL_LIMITATION]
|
||||
: [];
|
||||
// dmg and pkg reach here only after classifyRootFormat matched their bytes.
|
||||
if (format === "dmg" || format === "pkg")
|
||||
return [
|
||||
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
|
||||
|
||||
@@ -56,4 +56,39 @@ describe("CDP execution-context retention", () => {
|
||||
});
|
||||
expect(electronFrames.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a script on the frame stored when it was parsed", () => {
|
||||
const request = inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: ["https://app.example.test"],
|
||||
target_id: "page-1",
|
||||
});
|
||||
const browser = new CdpCaptureEvents(
|
||||
request,
|
||||
new Set(["https://app.example.test"]),
|
||||
);
|
||||
browser.beginAuthorizedFrame("frame-old");
|
||||
browser.beginAuthorizedFrame("frame-new");
|
||||
browser.ingest({
|
||||
method: "Runtime.executionContextCreated",
|
||||
params: { context: { id: 7, auxData: { frameId: "frame-old" } } },
|
||||
});
|
||||
browser.ingest({
|
||||
method: "Debugger.scriptParsed",
|
||||
params: {
|
||||
scriptId: "script-1",
|
||||
url: "https://app.example.test/app.js",
|
||||
executionContextId: 7,
|
||||
},
|
||||
});
|
||||
browser.ingest({
|
||||
method: "Runtime.executionContextCreated",
|
||||
params: { context: { id: 7, auxData: { frameId: "frame-new" } } },
|
||||
});
|
||||
const script = browser.scripts.get("script-1");
|
||||
if (script === undefined) throw new TypeError("Expected captured script");
|
||||
expect(
|
||||
browser.frameForScript(script, new Set(["frame-old", "frame-new"])),
|
||||
).toBe("frame-old");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,6 +198,23 @@ describe("empty form destinations with a document base URL", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the first value when an attribute name repeats", () => {
|
||||
const value = snapshot();
|
||||
const later = `${origin}/later`;
|
||||
value.strings.push(later);
|
||||
const href = value.strings.indexOf("href");
|
||||
const guide = value.strings.indexOf("guide");
|
||||
const nodes = value.documents[0]?.nodes;
|
||||
if (nodes === undefined) throw new TypeError("Expected a DOM snapshot");
|
||||
nodes.attributes[2] = [href, guide, href, value.strings.length - 1];
|
||||
const result = capture(value);
|
||||
expect(result.nodes[2]?.attribute_names).toEqual(["href", "href"]);
|
||||
expect(result.urls.map(({ url }) => url)).toContain(
|
||||
`${origin}/screens/guide`,
|
||||
);
|
||||
expect(result.urls.map(({ url }) => url)).not.toContain(later);
|
||||
});
|
||||
|
||||
it("does not apply form semantics to an action attribute on another element", () => {
|
||||
const baseUrl = `${origin}/assets/`;
|
||||
const value = snapshot(baseUrl);
|
||||
|
||||
@@ -387,7 +387,8 @@ const domMetadata = (
|
||||
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
||||
const name = indexedString(strings, attributes[index]).toLowerCase();
|
||||
const value = indexedString(strings, attributes[index + 1]);
|
||||
pairs.set(name, value);
|
||||
// HTML keeps the first declaration. A later repeat must not move dom_urls.
|
||||
if (!pairs.has(name)) pairs.set(name, value);
|
||||
}
|
||||
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
||||
for (const attribute of domUrlAttributes) {
|
||||
|
||||
@@ -47,6 +47,7 @@ export const handleExecutionContextCreated = (
|
||||
)
|
||||
return;
|
||||
const key = String(identifier);
|
||||
// New contexts may reuse an id. Scripts already parsed keep the frame stored on them.
|
||||
state.executionContextFrames.set(key, frameId);
|
||||
};
|
||||
|
||||
@@ -80,6 +81,7 @@ export const handleScriptParsed = (
|
||||
state.completeness.exclude("scripts", exclusionReasonForUrl(rawUrl));
|
||||
return;
|
||||
}
|
||||
const executionContext = executionContextKey(params.executionContextId);
|
||||
const baseScript = {
|
||||
scriptId,
|
||||
rawUrl,
|
||||
@@ -87,7 +89,11 @@ export const handleScriptParsed = (
|
||||
origin: sanitized.origin,
|
||||
...scriptMetadataValues(params),
|
||||
language: cdpStringValue(params.scriptLanguage) ?? null,
|
||||
executionContextKey: executionContextKey(params.executionContextId),
|
||||
executionContextKey: executionContext,
|
||||
frameId:
|
||||
executionContext === null
|
||||
? null
|
||||
: (state.executionContextFrames.get(executionContext) ?? null),
|
||||
};
|
||||
const baseBytes = scriptMetadataStringBytes(baseScript);
|
||||
const sourceMapDeclared =
|
||||
@@ -131,6 +137,7 @@ const scriptMetadataStringBytes = (script: {
|
||||
readonly hash: string | null;
|
||||
readonly language: string | null;
|
||||
readonly executionContextKey: string | null;
|
||||
readonly frameId: string | null;
|
||||
}): number =>
|
||||
stringByteLength(script.scriptId) +
|
||||
stringByteLength(script.rawUrl) +
|
||||
@@ -138,7 +145,8 @@ const scriptMetadataStringBytes = (script: {
|
||||
stringByteLength(script.origin) +
|
||||
stringByteLength(script.hash) +
|
||||
stringByteLength(script.language) +
|
||||
stringByteLength(script.executionContextKey);
|
||||
stringByteLength(script.executionContextKey) +
|
||||
stringByteLength(script.frameId);
|
||||
|
||||
const stringByteLength = (value: string | null): number =>
|
||||
value === null ? 0 : Buffer.byteLength(value, "utf8");
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface CapturedScript {
|
||||
readonly sourceMapUrl: string | null;
|
||||
readonly sourceMapRawUrl: string | null;
|
||||
readonly executionContextKey: string | null;
|
||||
/** Frame observed when this script was parsed. Later context reuse does not move it. */
|
||||
readonly frameId: string | null;
|
||||
}
|
||||
|
||||
export type NetworkState = WebPageInspection["network"]["requests"][number];
|
||||
|
||||
@@ -159,10 +159,8 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
|
||||
script: CapturedScript,
|
||||
allowedFrameIds: ReadonlySet<string>,
|
||||
): string | null {
|
||||
if (script.executionContextKey === null) return null;
|
||||
const frameId = this.executionContextFrames.get(script.executionContextKey);
|
||||
return frameId !== undefined && allowedFrameIds.has(frameId)
|
||||
? frameId
|
||||
return script.frameId !== null && allowedFrameIds.has(script.frameId)
|
||||
? script.frameId
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,22 @@ import type { Browser } from "playwright-core";
|
||||
|
||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||
import {
|
||||
attachedScenarioLeaseKey,
|
||||
openPlaywrightScenarioBrowser,
|
||||
PlaywrightScenarioBrowserCleanupOwner,
|
||||
} from "./PlaywrightScenarioBrowser.js";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("locks one attached browser for spellings that discover the same endpoint", () => {
|
||||
expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).toBe(
|
||||
attachedScenarioLeaseKey("http://127.0.0.1:9222/", "page-1"),
|
||||
);
|
||||
expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).not.toBe(
|
||||
attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-2"),
|
||||
);
|
||||
});
|
||||
|
||||
const deferred = <Value>() => {
|
||||
let resolve: (value: Value | PromiseLike<Value>) => void = () => undefined;
|
||||
const promise = new Promise<Value>((resolvePromise) => {
|
||||
|
||||
@@ -182,6 +182,13 @@ const createAttachedScenarioRelease = (
|
||||
};
|
||||
};
|
||||
|
||||
/** One browser admission. The key is the discovery URL this endpoint already parses. */
|
||||
export const attachedScenarioLeaseKey = (
|
||||
endpoint: string,
|
||||
targetId: string,
|
||||
): string =>
|
||||
JSON.stringify([new URL("/json/version", endpoint).href, targetId]);
|
||||
|
||||
const acquireAttachedScenario = (
|
||||
scenario: Extract<BrowserScenario["browser"], { readonly mode: "connect" }>,
|
||||
signal: AbortSignal | undefined,
|
||||
@@ -189,7 +196,10 @@ const acquireAttachedScenario = (
|
||||
if (signal?.aborted === true)
|
||||
return Promise.reject(new BrowserObservationError(OPERATION, "cancelled"));
|
||||
|
||||
const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]);
|
||||
const key = attachedScenarioLeaseKey(
|
||||
scenario.cdp_endpoint,
|
||||
scenario.target_id,
|
||||
);
|
||||
const admission =
|
||||
attachedScenarioAdmissions.get(key) ??
|
||||
({
|
||||
|
||||
@@ -2,20 +2,26 @@ import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js";
|
||||
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
|
||||
import {
|
||||
webScriptExportManifestSchema,
|
||||
webScriptExportResultSchema,
|
||||
type ExportWebScriptsInput,
|
||||
type ExportedWebScript,
|
||||
type WebScriptExportResult,
|
||||
} from "../../domain/webScriptExport.js";
|
||||
import { planWebScriptExport } from "../../domain/webScriptExportPlan.js";
|
||||
import {
|
||||
planWebScriptExport,
|
||||
type PlannedWebScript,
|
||||
} from "../../domain/webScriptExportPlan.js";
|
||||
import { WebScriptExportError } from "../../domain/webScriptExportError.js";
|
||||
import type { SelectedScriptCapture } from "./ScriptCaptureAdapters.js";
|
||||
|
||||
const LIMITATIONS = [
|
||||
"Only script bytes retained in this capture are exported. Sources are not refetched or executed, and a response or Debugger source does not prove execution.",
|
||||
"Ordinary unambiguous URL paths preserve relative module layouts by origin. Query variants, collisions, inline sources, and unrepresentable paths are isolated; their browser module relationships remain unknown.",
|
||||
"Ordinary unambiguous URL paths preserve relative module layouts by origin, including case-distinct spellings. Query variants, exact path collisions, file/directory prefixes, inline sources, and unrepresentable paths are isolated. A case-distinct pair is isolated only when the destination filesystem cannot store both spellings; the record names that pair. Browser module relationships of isolated paths remain unknown.",
|
||||
"Local JavaScript analysis does not implement browser import maps, root-relative URL resolution, remote URL loading, or browser runtime behavior. It may strip import queries and fragments; isolated variants are not canonical targets.",
|
||||
"Source-map declarations are retained as metadata. No source maps or missing dependencies are fetched or reconstructed.",
|
||||
"Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.",
|
||||
@@ -32,7 +38,11 @@ export const publishWebScripts = async (
|
||||
const tree = await SafeOutputTree.create(input.output_directory);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const records = planned.map(({ record }) => record);
|
||||
const records: ExportedWebScript[] = [];
|
||||
for (const [index, item] of planned.entries()) {
|
||||
signal?.throwIfAborted();
|
||||
records.push(await publishPlannedScript(tree, item, index, signal));
|
||||
}
|
||||
const manifest = webScriptExportManifestSchema.parse({
|
||||
capture_path: input.capture_path,
|
||||
capture_sha256: captureSha256,
|
||||
@@ -66,19 +76,6 @@ export const publishWebScripts = async (
|
||||
bytes: bytes.length,
|
||||
},
|
||||
});
|
||||
for (const { script, record } of planned) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
script.content.state === "captured" &&
|
||||
record.content.state === "exported"
|
||||
)
|
||||
await tree.write(
|
||||
`files/${record.content.relative_path}`,
|
||||
Readable.from([script.content.bytes]),
|
||||
script.content.sha256,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
await tree.write("manifest.json", Readable.from([bytes]), digest, signal);
|
||||
signal?.throwIfAborted();
|
||||
await tree.commit();
|
||||
@@ -98,5 +95,56 @@ export const publishWebScripts = async (
|
||||
}
|
||||
};
|
||||
|
||||
const publishPlannedScript = async (
|
||||
tree: SafeOutputTree,
|
||||
item: PlannedWebScript,
|
||||
index: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ExportedWebScript> => {
|
||||
const { script, record } = item;
|
||||
const content = script.content;
|
||||
if (content.state !== "captured" || record.content.state !== "exported")
|
||||
return record;
|
||||
const write = (relativePath: string): Promise<unknown> =>
|
||||
tree.write(
|
||||
`files/${relativePath}`,
|
||||
Readable.from([content.bytes]),
|
||||
content.sha256,
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
await write(record.content.relative_path);
|
||||
return record;
|
||||
} catch (cause: unknown) {
|
||||
const collision = destinationCollisionReason(cause);
|
||||
if (collision === undefined || record.content.layout !== "url-path")
|
||||
throw cause;
|
||||
const relativePath = isolatedScriptPath(script, index);
|
||||
await write(relativePath);
|
||||
return {
|
||||
...record,
|
||||
content: {
|
||||
...record.content,
|
||||
relative_path: relativePath,
|
||||
layout: "isolated",
|
||||
layout_reason: collision,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isolatedScriptPath = (
|
||||
script: PlannedWebScript["script"],
|
||||
index: number,
|
||||
): string =>
|
||||
`isolated/source-${index + 1}-${createHash("sha256").update(JSON.stringify(script.source)).digest("hex")}.js`;
|
||||
|
||||
const destinationCollisionReason = (cause: unknown): string | undefined =>
|
||||
cause instanceof ArtifactReaderFailure &&
|
||||
cause.reason === "path" &&
|
||||
cause.message.startsWith(DESTINATION_CASE_COLLISION_PREFIX)
|
||||
? cause.message
|
||||
: undefined;
|
||||
|
||||
const message = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : "Unknown publication failure";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { expect, it } from "vitest";
|
||||
import { historicalHar } from "../../../tests/fixtures/historicalHar.js";
|
||||
import { decodeHarCapture } from "./HarCapture.js";
|
||||
import { decodeHarCapture, decodeHarText } from "./HarCapture.js";
|
||||
|
||||
it.each([
|
||||
{ values: [] },
|
||||
@@ -558,3 +558,23 @@ it.each([
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores one leading byte-order mark, as HAR 1.2 requires of readers", () => {
|
||||
const text = JSON.stringify(historicalHar());
|
||||
const body = Buffer.from(text, "utf8");
|
||||
const marked = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body]);
|
||||
expect(decodeHarText(marked)).toBe(text);
|
||||
expect(decodeHarCapture(decodeHarText(marked), [])).toEqual(
|
||||
decodeHarCapture(decodeHarText(body), []),
|
||||
);
|
||||
// A second mark is content, not an encoding signature.
|
||||
expect(() =>
|
||||
decodeHarCapture(
|
||||
decodeHarText(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), marked])),
|
||||
[],
|
||||
),
|
||||
).toThrow("HAR is malformed JSON.");
|
||||
expect(() => decodeHarText(Buffer.from([0x7b, 0xff, 0x7d]))).toThrow(
|
||||
"HAR is not valid UTF-8 JSON.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { isSafeNumber, LosslessNumber } from "lossless-json";
|
||||
import { parseHarJson } from "./HarJson.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
@@ -25,6 +26,19 @@ type Projection = Pick<
|
||||
"reported" | "numeric_literals" | "binary_fields" | "redactions"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Decode retained HAR bytes as UTF-8. HAR 1.2 lets writers emit a byte-order
|
||||
* mark and requires readers to ignore it, so one leading BOM is skipped.
|
||||
*/
|
||||
export const decodeHarText = (bytes: Uint8Array): string => {
|
||||
try {
|
||||
// ignoreBOM: false (the default) is what skips the mark; true keeps it.
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw new CaptureFormatError("format", "HAR is not valid UTF-8 JSON.");
|
||||
}
|
||||
};
|
||||
|
||||
/** Decode a retained HAR with exact numeric lexemes and unchanged upstream schema validation. */
|
||||
export const decodeHarCapture = (
|
||||
text: string,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { z } from "zod";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
|
||||
import { CaptureFormatError } from "./CaptureFormatError.js";
|
||||
import { decodeHarCapture } from "./HarCapture.js";
|
||||
import { decodeHarCapture, decodeHarText } from "./HarCapture.js";
|
||||
|
||||
const requestSchema = z.strictObject({
|
||||
snapshot_path: z.string(),
|
||||
@@ -23,17 +22,12 @@ const main = async (): Promise<void> => {
|
||||
request.snapshot_path,
|
||||
WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
|
||||
);
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
snapshot.bytes,
|
||||
);
|
||||
} catch {
|
||||
throw new CaptureFormatError("format", "HAR is not valid UTF-8 JSON.");
|
||||
}
|
||||
reply = {
|
||||
ok: true,
|
||||
value: decodeHarCapture(text, request.sensitive_values),
|
||||
value: decodeHarCapture(
|
||||
decodeHarText(snapshot.bytes),
|
||||
request.sensitive_values,
|
||||
),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
reply =
|
||||
|
||||
+25
-5
@@ -8,6 +8,7 @@ import streamValues from "stream-json/streamers/stream-values.js";
|
||||
|
||||
import { withRegularFile } from "./application/RegularFileRead.js";
|
||||
import {
|
||||
JSON_BYTE_ORDER_MARK_MESSAGE,
|
||||
JSON_INPUT_RESOURCE_REMEDIATION,
|
||||
parseUtf8Json,
|
||||
} from "./application/Utf8JsonInput.js";
|
||||
@@ -211,12 +212,22 @@ async function* decodedChunks(
|
||||
prefix?: Uint8Array,
|
||||
): AsyncGenerator<string> {
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
let leading = true;
|
||||
const checked = (text: string): string => {
|
||||
if (leading && text.length > 0) {
|
||||
leading = false;
|
||||
if (text.startsWith("\uFEFF")) throw new JsonByteOrderMarkError();
|
||||
}
|
||||
return text;
|
||||
};
|
||||
if (prefix !== undefined)
|
||||
for (let offset = 0; offset < prefix.length; offset += READ_CHUNK_BYTES) {
|
||||
signal?.throwIfAborted();
|
||||
yield decoder.decode(prefix.subarray(offset, offset + READ_CHUNK_BYTES), {
|
||||
stream: true,
|
||||
});
|
||||
yield checked(
|
||||
decoder.decode(prefix.subarray(offset, offset + READ_CHUNK_BYTES), {
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
||||
while (true) {
|
||||
@@ -224,9 +235,18 @@ async function* decodedChunks(
|
||||
const read = await handle.read(bytes, 0, bytes.length, null);
|
||||
signal?.throwIfAborted();
|
||||
if (read.bytesRead === 0) break;
|
||||
yield decoder.decode(bytes.subarray(0, read.bytesRead), { stream: true });
|
||||
yield checked(
|
||||
decoder.decode(bytes.subarray(0, read.bytesRead), { stream: true }),
|
||||
);
|
||||
}
|
||||
yield checked(decoder.decode());
|
||||
}
|
||||
|
||||
/** A streamed JSON file begins with a byte-order mark; reported as invalid JSON. */
|
||||
class JsonByteOrderMarkError extends SyntaxError {
|
||||
constructor() {
|
||||
super(JSON_BYTE_ORDER_MARK_MESSAGE);
|
||||
}
|
||||
yield decoder.decode();
|
||||
}
|
||||
|
||||
const readPrefix = async (
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
|
||||
import { ghidraStartupTimeoutSchema } from "./ghidraStartupTimeout.js";
|
||||
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
|
||||
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
|
||||
|
||||
const environmentSchema = z.object({
|
||||
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
|
||||
REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(),
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: mcpInputSchemaProfileSchema.optional(),
|
||||
REA_IDA_MCP_CONFIG: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
// Moonshot's function-calling API rejects requests while any single tool
|
||||
// parameters schema exceeds its literal serialized size cap. Community
|
||||
// measurements bound the cap between ~13.7 KB (accepted) and ~15 KB
|
||||
// (rejected); 13 KiB leaves margin under the lower bound. See
|
||||
// https://github.com/morluto/rea/issues/1484 for the measured evidence.
|
||||
export const COMPACT_INPUT_SCHEMA_BUDGET_BYTES = 13 * 1024;
|
||||
|
||||
export const mcpInputSchemaProfileSchema = z.enum(["full", "compact"]);
|
||||
|
||||
const profileConstraint =
|
||||
'REA_MCP_INPUT_SCHEMA_PROFILE must be "full" (default) or "compact". The compact profile advertises input schemas without nested annotation prose for providers that cap each tool schema\'s serialized size.';
|
||||
|
||||
export type McpInputSchemaProfile = z.infer<typeof mcpInputSchemaProfileSchema>;
|
||||
|
||||
/** Parse the advertised input schema presentation selection. */
|
||||
export const parseMcpInputSchemaProfile = (
|
||||
value: string | undefined,
|
||||
): Result<McpInputSchemaProfile | undefined, ConfigurationError> => {
|
||||
if (value === undefined) return ok(undefined);
|
||||
const parsed = mcpInputSchemaProfileSchema.safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new ConfigurationError(profileConstraint, {
|
||||
settings: [
|
||||
{
|
||||
setting: "REA_MCP_INPUT_SCHEMA_PROFILE",
|
||||
constraint: profileConstraint,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -50,6 +50,9 @@ export const parseConfig = (
|
||||
...(env.REA_MCP_MAX_RESPONSE_BYTES === undefined
|
||||
? {}
|
||||
: { mcpMaxResponseBytes: env.REA_MCP_MAX_RESPONSE_BYTES }),
|
||||
...(env.REA_MCP_INPUT_SCHEMA_PROFILE === undefined
|
||||
? {}
|
||||
: { mcpInputSchemaProfile: env.REA_MCP_INPUT_SCHEMA_PROFILE }),
|
||||
analysisProvider: env.REA_ANALYSIS_PROVIDER,
|
||||
...(env.REA_IDA_MCP_CONFIG === undefined
|
||||
? {}
|
||||
|
||||
@@ -2,8 +2,11 @@ import type { LogLevel } from "../logger.js";
|
||||
import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js";
|
||||
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
|
||||
|
||||
import type { McpInputSchemaProfile } from "./mcpInputSchemaProfile.js";
|
||||
|
||||
export interface AppConfig {
|
||||
readonly mcpMaxResponseBytes?: number;
|
||||
readonly mcpInputSchemaProfile?: McpInputSchemaProfile;
|
||||
readonly analysisProvider: AnalysisProviderSelector;
|
||||
readonly idaMcpConfigPath?: string;
|
||||
readonly ghidraInstallDir: string | undefined;
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_analysis_view"),
|
||||
kind: "application",
|
||||
description:
|
||||
"Inspect selected views of completed binary-layout, JavaScript application or native analyze_function Evidence without repeating analysis. Use an exact same-session retained evidence_id or portable inline Evidence. Select a summary, layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Native views select procedure, pseudocode, assembly, callers/callees, references and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.",
|
||||
"Inspect selected views of completed binary-layout, JavaScript application or native analyze_function Evidence without repeating analysis. Use an exact same-session retained evidence_id or portable inline Evidence. Select a summary, layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Native views select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.",
|
||||
inputSchema: inspectAnalysisViewInputSchema,
|
||||
outputSchema: evidenceResultOf(analysisViewResultSchema),
|
||||
examples: [
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { compactAdvertisedInputSchema } from "./compactInputSchema.js";
|
||||
|
||||
const serializedBytes = (value: unknown): number =>
|
||||
Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
|
||||
const repeatedObservation = {
|
||||
type: "object",
|
||||
properties: {
|
||||
captured_url: { type: "string", minLength: 1 },
|
||||
captured_at: { type: "string", minLength: 1 },
|
||||
status_code: { type: "integer", minimum: 100, maximum: 599 },
|
||||
headers: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
minProperties: 1,
|
||||
},
|
||||
body_digest: { type: "string", minLength: 64, maxLength: 64 },
|
||||
},
|
||||
required: ["captured_url", "captured_at", "status_code"],
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const annotatedSchema = {
|
||||
type: "object",
|
||||
description: "Root group guidance stays.",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Field guidance leaves.",
|
||||
examples: ["seed"],
|
||||
default: "seed",
|
||||
title: "Query",
|
||||
minLength: 1,
|
||||
},
|
||||
limits: {
|
||||
type: "object",
|
||||
description: "Nested guidance leaves.",
|
||||
properties: {
|
||||
max_results: {
|
||||
type: "integer",
|
||||
description: "Leaves.",
|
||||
minimum: 1,
|
||||
maximum: 500,
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
required: ["max_results"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
dependentRequired: { limits: ["query"] },
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const oversizedPairSchema = (): Record<string, unknown> => {
|
||||
const deepStructure = {
|
||||
type: "object",
|
||||
description: "Nested guidance that leaves.",
|
||||
properties: Object.fromEntries(
|
||||
Array.from({ length: 40 }, (_, index) => [
|
||||
`field_${index}`,
|
||||
{ type: "string", minLength: 1, description: `Field ${index}` },
|
||||
]),
|
||||
),
|
||||
required: Array.from({ length: 40 }, (_, index) => `field_${index}`),
|
||||
additionalProperties: false,
|
||||
};
|
||||
return {
|
||||
type: "object",
|
||||
description: "Provide a complete capture pair.",
|
||||
properties: {
|
||||
before: structuredClone(deepStructure),
|
||||
after: structuredClone(deepStructure),
|
||||
},
|
||||
required: ["before", "after"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
};
|
||||
|
||||
describe("compact advertised input schema", () => {
|
||||
it("drops nested annotation prose while keeping validation keywords", () => {
|
||||
const { schema, presentation } = compactAdvertisedInputSchema(
|
||||
structuredClone(annotatedSchema),
|
||||
13 * 1024,
|
||||
);
|
||||
expect(presentation).toBe("inline");
|
||||
expect(schema.description).toBe("Root group guidance stays.");
|
||||
const query = (schema.properties as Record<string, unknown>)
|
||||
.query as Record<string, unknown>;
|
||||
expect(query).toEqual({ type: "string", minLength: 1 });
|
||||
const limits = (schema.properties as Record<string, unknown>)
|
||||
.limits as Record<string, unknown>;
|
||||
expect(limits.description).toBeUndefined();
|
||||
expect(limits.required).toEqual(["max_results"]);
|
||||
expect(schema.dependentRequired).toEqual({ limits: ["query"] });
|
||||
expect(schema.required).toEqual(["query"]);
|
||||
});
|
||||
|
||||
it("keeps annotation-shaped keys inside literal const and enum data", () => {
|
||||
const literalMode = {
|
||||
description: "saved data",
|
||||
default: { keep: "me" },
|
||||
};
|
||||
const { schema } = compactAdvertisedInputSchema(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { enum: [literalMode] },
|
||||
pinned: { const: { description: "literal" } },
|
||||
},
|
||||
},
|
||||
13 * 1024,
|
||||
);
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect((properties.mode as Record<string, unknown>).enum).toEqual([
|
||||
literalMode,
|
||||
]);
|
||||
expect((properties.pinned as Record<string, unknown>).const).toEqual({
|
||||
description: "literal",
|
||||
});
|
||||
});
|
||||
|
||||
it("inlines local references without changing validation or literal data", () => {
|
||||
const original = {
|
||||
type: "object",
|
||||
$defs: { "observation/~": structuredClone(repeatedObservation) },
|
||||
properties: {
|
||||
before: { $ref: "#/$defs/observation~1~0" },
|
||||
after: { $ref: "#/$defs/observation~1~0" },
|
||||
literal: { const: { $ref: "saved data", $defs: { keep: true } } },
|
||||
},
|
||||
required: ["before", "after"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const { schema, presentation } = compactAdvertisedInputSchema(
|
||||
original,
|
||||
13312,
|
||||
);
|
||||
expect(presentation).toBe("inline");
|
||||
expect(schema.$defs).toBeUndefined();
|
||||
const ajv = new Ajv2020({ strict: false });
|
||||
const canonical = ajv.compile(original);
|
||||
const compact = ajv.compile(schema);
|
||||
const observation = {
|
||||
captured_url: "https://example.com",
|
||||
captured_at: "now",
|
||||
status_code: 200,
|
||||
};
|
||||
for (const input of [
|
||||
{ before: observation, after: observation },
|
||||
{ before: observation, after: { ...observation, status_code: 99 } },
|
||||
{
|
||||
before: observation,
|
||||
after: observation,
|
||||
literal: original.properties.literal.const,
|
||||
},
|
||||
])
|
||||
expect(compact(input)).toBe(canonical(input));
|
||||
});
|
||||
|
||||
it("keeps unconstrained JSON values explicit for client type inference", () => {
|
||||
const { schema } = compactAdvertisedInputSchema(
|
||||
{
|
||||
type: "object",
|
||||
properties: { normalized_result: {}, literal: { const: {} } },
|
||||
},
|
||||
13312,
|
||||
);
|
||||
const properties = schema.properties as Record<string, unknown>;
|
||||
expect(properties.normalized_result).toEqual({
|
||||
type: ["null", "boolean", "object", "array", "number", "string"],
|
||||
});
|
||||
expect(properties.literal).toEqual({ const: {} });
|
||||
const validate = new Ajv2020({ strict: false }).compile(schema);
|
||||
for (const normalized_result of [null, true, {}, [], 42, "result"])
|
||||
expect(validate({ normalized_result })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves independent validation constraints on reference siblings", () => {
|
||||
const original = {
|
||||
type: "object",
|
||||
$defs: { text: { type: "string", minLength: 3 } },
|
||||
properties: {
|
||||
query: { $ref: "#/$defs/text", minLength: 1, maxLength: 5 },
|
||||
},
|
||||
};
|
||||
const { schema } = compactAdvertisedInputSchema(original, 13312);
|
||||
const validate = new Ajv2020({ strict: false }).compile(schema);
|
||||
expect(validate({ query: "a" })).toBe(false);
|
||||
expect(validate({ query: "abc" })).toBe(true);
|
||||
expect(validate({ query: "abcdef" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compact input schema budget and reduction", () => {
|
||||
it("budgets repeated-reference expansion before materializing it", () => {
|
||||
const definitions: Record<string, unknown> = {
|
||||
leaf: structuredClone(repeatedObservation),
|
||||
};
|
||||
for (let index = 0; index < 30; index++) {
|
||||
const $ref = `#/$defs/${index === 0 ? "leaf" : `level${index - 1}`}`;
|
||||
definitions[`level${index}`] = {
|
||||
type: "object",
|
||||
properties: { left: { $ref }, right: { $ref } },
|
||||
};
|
||||
}
|
||||
const { schema, presentation } = compactAdvertisedInputSchema(
|
||||
{
|
||||
type: "object",
|
||||
$defs: definitions,
|
||||
properties: { tree: { $ref: "#/$defs/level29" } },
|
||||
},
|
||||
1024,
|
||||
);
|
||||
expect(presentation).toBe("reduced");
|
||||
expect(serializedBytes(schema)).toBeLessThanOrEqual(1024);
|
||||
expect(schema.properties).toEqual({ tree: { type: "object" } });
|
||||
});
|
||||
|
||||
it("retains referenced object, array and nullable types in reduced inputs", () => {
|
||||
const original = {
|
||||
type: "object",
|
||||
$defs: {
|
||||
pair: oversizedPairSchema(),
|
||||
observations: {
|
||||
type: "array",
|
||||
items: structuredClone(repeatedObservation),
|
||||
},
|
||||
nullable: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
properties: {
|
||||
capture: { $ref: "#/$defs/pair", description: "Complete capture." },
|
||||
observations: { $ref: "#/$defs/observations" },
|
||||
label: { $ref: "#/$defs/nullable" },
|
||||
},
|
||||
};
|
||||
const { schema, presentation } = compactAdvertisedInputSchema(
|
||||
original,
|
||||
1024,
|
||||
);
|
||||
expect(presentation).toBe("reduced");
|
||||
expect(schema.properties).toEqual({
|
||||
capture: { type: "object", description: "Complete capture." },
|
||||
observations: { type: "array" },
|
||||
label: { type: ["string", "null"] },
|
||||
});
|
||||
const validate = new Ajv2020({ strict: false }).compile(schema);
|
||||
expect(validate({ capture: {}, observations: [], label: null })).toBe(true);
|
||||
expect(validate({ capture: {}, observations: [], label: "capture" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(validate({ capture: "{}", observations: [], label: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("reduces structurally oversized schemas to the shallow property presentation", () => {
|
||||
const compacted = compactAdvertisedInputSchema(oversizedPairSchema(), 1024);
|
||||
expect(compacted.presentation).toBe("reduced");
|
||||
expect(serializedBytes(compacted.schema)).toBeLessThanOrEqual(1024);
|
||||
expect(compacted.schema.type).toBe("object");
|
||||
expect(compacted.schema.description).toContain(
|
||||
"Provide a complete capture pair.",
|
||||
);
|
||||
expect(compacted.schema.description).toContain(
|
||||
"compact input schema profile",
|
||||
);
|
||||
const properties = compacted.schema.properties as Record<string, unknown>;
|
||||
expect(properties.before).toEqual({
|
||||
type: "object",
|
||||
description: "Nested guidance that leaves.",
|
||||
});
|
||||
expect(properties.after).toEqual(properties.before);
|
||||
expect(compacted.schema.required).toEqual(["before", "after"]);
|
||||
});
|
||||
|
||||
it("produces deterministic output for equal input", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
before: structuredClone(repeatedObservation),
|
||||
after: structuredClone(repeatedObservation),
|
||||
},
|
||||
};
|
||||
expect(compactAdvertisedInputSchema(schema, 13 * 1024)).toEqual(
|
||||
compactAdvertisedInputSchema(structuredClone(schema), 13 * 1024),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps inline presentations under the measured provider budget", () => {
|
||||
const { schema, presentation } = compactAdvertisedInputSchema(
|
||||
{
|
||||
type: "object",
|
||||
description: "Group guidance.",
|
||||
properties: {
|
||||
before: structuredClone(repeatedObservation),
|
||||
after: structuredClone(repeatedObservation),
|
||||
normalization: {
|
||||
type: "object",
|
||||
description: "Rules.",
|
||||
properties: { rules: { type: "array", items: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
13312,
|
||||
);
|
||||
expect(presentation).toBe("inline");
|
||||
expect(serializedBytes(schema)).toBeLessThanOrEqual(13312);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import {
|
||||
schemaArrays,
|
||||
schemaChildren,
|
||||
schemaMaps,
|
||||
} from "./inputSchemaPresentation.js";
|
||||
|
||||
// Kimi expands schema-local references before sending tools to Moonshot.
|
||||
// Compact advertisements must fit after expansion, independently of client
|
||||
// reference handling. Canonical server-side validation remains unchanged.
|
||||
|
||||
/** Annotation keys that carry guidance but no validation semantics. */
|
||||
const annotationKeys = new Set(["description", "examples", "default", "title"]);
|
||||
|
||||
/** Keys whose values are literal data, never nested schemas to rewrite. */
|
||||
const dataKeys = new Set(["const", "enum", "examples", "default"]);
|
||||
const definitionKeys = new Set(["$defs", "definitions"]);
|
||||
// An empty schema accepts every JSON value. Kimi otherwise infers string.
|
||||
const jsonValueTypes = [
|
||||
"null",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string",
|
||||
];
|
||||
|
||||
export type AdvertisedInputSchemaPresentation = "inline" | "reduced";
|
||||
|
||||
export interface CompactedInputSchema {
|
||||
readonly schema: Record<string, unknown>;
|
||||
readonly presentation: AdvertisedInputSchemaPresentation;
|
||||
}
|
||||
|
||||
const isSchemaObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const serializedBytes = (value: unknown): number =>
|
||||
Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||
|
||||
/** Rewrite every schema-valued position; literal data stays opaque. */
|
||||
const rebuildSchemaPositions = (
|
||||
node: unknown,
|
||||
rebuild: (child: unknown) => unknown,
|
||||
): unknown => {
|
||||
if (Array.isArray(node)) return node.map((child) => rebuild(child));
|
||||
if (!isSchemaObject(node)) return node;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (dataKeys.has(key)) {
|
||||
out[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (schemaMaps.has(key) && isSchemaObject(value)) {
|
||||
out[key] = Object.fromEntries(
|
||||
Object.entries(value).map(([name, child]) => [name, rebuild(child)]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (schemaArrays.has(key) && Array.isArray(value)) {
|
||||
out[key] = value.map((child) => rebuild(child));
|
||||
continue;
|
||||
}
|
||||
if (schemaChildren.has(key)) {
|
||||
out[key] = Array.isArray(value)
|
||||
? value.map((child) => rebuild(child))
|
||||
: rebuild(value);
|
||||
continue;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop annotation prose from every subschema while preserving all validation
|
||||
* keywords. The root keeps its own description because the root-union
|
||||
* projection encodes accepted input groups there; the root's echoed literal
|
||||
* examples leave with the rest of the annotation prose.
|
||||
*/
|
||||
const stripAnnotations = (
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const rebuilt = rebuildSchemaPositions(
|
||||
schema,
|
||||
stripChildAnnotations,
|
||||
) as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rebuilt)) {
|
||||
if (annotationKeys.has(key) && key !== "description") continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const stripChildAnnotations = (child: unknown): unknown => {
|
||||
const rebuilt = rebuildSchemaPositions(child, stripChildAnnotations);
|
||||
if (!isSchemaObject(rebuilt)) return rebuilt;
|
||||
const kept: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(rebuilt))
|
||||
if (!annotationKeys.has(key)) kept[key] = value;
|
||||
return kept;
|
||||
};
|
||||
|
||||
const resolveLocalReference = (
|
||||
root: Record<string, unknown>,
|
||||
reference: string,
|
||||
): unknown => {
|
||||
if (reference === "#") return root;
|
||||
if (!reference.startsWith("#/")) return undefined;
|
||||
let value: unknown = root;
|
||||
for (const token of reference.slice(2).split("/")) {
|
||||
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
|
||||
if (!isSchemaObject(value) || !Object.hasOwn(value, key)) return undefined;
|
||||
value = value[key];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Stop serialization at the budget before allocating an expanded schema. */
|
||||
const inlineWithinBudget = (
|
||||
root: Record<string, unknown>,
|
||||
budgetBytes: number,
|
||||
): Record<string, unknown> | undefined => {
|
||||
let resolved = true;
|
||||
function* serialize(
|
||||
value: unknown,
|
||||
position: "schema" | "map" | "data",
|
||||
active: ReadonlySet<string>,
|
||||
): Generator<string> {
|
||||
if (Array.isArray(value)) {
|
||||
yield "[";
|
||||
for (const [index, child] of value.entries()) {
|
||||
if (index > 0) yield ",";
|
||||
yield* serialize(child, position, active);
|
||||
}
|
||||
yield "]";
|
||||
} else if (isSchemaObject(value)) {
|
||||
if (position === "schema" && Object.keys(value).length === 0) {
|
||||
yield JSON.stringify({ type: jsonValueTypes });
|
||||
return;
|
||||
}
|
||||
if (position === "schema" && typeof value.$ref === "string") {
|
||||
const target = resolveLocalReference(root, value.$ref);
|
||||
if (target === undefined || active.has(value.$ref)) {
|
||||
resolved = false;
|
||||
return;
|
||||
}
|
||||
const { $ref, ...siblings } = value;
|
||||
// Reference siblings are independent constraints in draft 2020-12.
|
||||
// Merging their keys into the target would overwrite validation rules.
|
||||
yield* serialize(
|
||||
Object.keys(siblings).length === 0
|
||||
? target
|
||||
: { allOf: [target, siblings] },
|
||||
"schema",
|
||||
new Set([...active, $ref]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
yield "{";
|
||||
let first = true;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (position === "schema" && definitionKeys.has(key)) continue;
|
||||
if (!first) yield ",";
|
||||
first = false;
|
||||
yield JSON.stringify(key) + ":";
|
||||
const childPosition =
|
||||
position === "map"
|
||||
? "schema"
|
||||
: position === "schema" && schemaMaps.has(key)
|
||||
? "map"
|
||||
: position === "schema" &&
|
||||
(schemaArrays.has(key) || schemaChildren.has(key))
|
||||
? "schema"
|
||||
: "data";
|
||||
yield* serialize(child, childPosition, active);
|
||||
}
|
||||
yield "}";
|
||||
} else {
|
||||
yield JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let bytes = 0;
|
||||
for (const chunk of serialize(root, "schema", new Set())) {
|
||||
bytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (bytes > budgetBytes || !resolved) return undefined;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return resolved
|
||||
? (JSON.parse(chunks.join("")) as Record<string, unknown>)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Preserve every possible type, including referenced and nullable unions. */
|
||||
const detectedTypes = (
|
||||
schema: unknown,
|
||||
root: Record<string, unknown>,
|
||||
active: ReadonlySet<string> = new Set(),
|
||||
): readonly string[] | undefined => {
|
||||
if (!isSchemaObject(schema)) return undefined;
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (active.has(schema.$ref)) return undefined;
|
||||
return detectedTypes(
|
||||
resolveLocalReference(root, schema.$ref),
|
||||
root,
|
||||
new Set([...active, schema.$ref]),
|
||||
);
|
||||
}
|
||||
if (typeof schema.type === "string") return [schema.type];
|
||||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.every((type) => typeof type === "string")
|
||||
)
|
||||
return schema.type;
|
||||
const alternatives = Array.isArray(schema.anyOf)
|
||||
? schema.anyOf
|
||||
: Array.isArray(schema.oneOf)
|
||||
? schema.oneOf
|
||||
: [];
|
||||
if (alternatives.length === 0) return undefined;
|
||||
const types = alternatives.map((branch) =>
|
||||
detectedTypes(branch, root, active),
|
||||
);
|
||||
if (types.some((type) => type === undefined)) return undefined;
|
||||
return [...new Set(types.flatMap((type) => type ?? []))];
|
||||
};
|
||||
|
||||
const REDUCED_ANNOTATION =
|
||||
"Advertised in reduced form by the compact input schema profile; the server still validates complete canonical inputs.";
|
||||
|
||||
/**
|
||||
* Present only the root property shape with per-property guidance. Used for
|
||||
* schemas whose validation structure alone exceeds the compact budget; the
|
||||
* canonical schema still rejects malformed calls server-side.
|
||||
*/
|
||||
const reduceToShallowProperties = (
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const properties = isSchemaObject(schema.properties) ? schema.properties : {};
|
||||
const description = [
|
||||
typeof schema.description === "string" ? schema.description : undefined,
|
||||
REDUCED_ANNOTATION,
|
||||
]
|
||||
.filter((text) => text !== undefined)
|
||||
.join(" ");
|
||||
const $schema =
|
||||
typeof schema.$schema === "string" ? { $schema: schema.$schema } : {};
|
||||
const shallow = Object.fromEntries(
|
||||
Object.entries(properties).map(([name, value]) => {
|
||||
const types = detectedTypes(value, schema);
|
||||
const type = types?.length === 1 ? types[0] : types;
|
||||
const description =
|
||||
isSchemaObject(value) && typeof value.description === "string"
|
||||
? { description: value.description }
|
||||
: {};
|
||||
return [
|
||||
name,
|
||||
{ ...(type === undefined ? {} : { type }), ...description },
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...$schema,
|
||||
type: "object",
|
||||
description,
|
||||
properties: shallow,
|
||||
...(Array.isArray(schema.required)
|
||||
? { required: [...schema.required] }
|
||||
: {}),
|
||||
...(typeof schema.minProperties === "number"
|
||||
? { minProperties: schema.minProperties }
|
||||
: {}),
|
||||
...(isSchemaObject(schema.dependentRequired)
|
||||
? { dependentRequired: { ...schema.dependentRequired } }
|
||||
: {}),
|
||||
...(schema.additionalProperties === false
|
||||
? { additionalProperties: false }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an advertised input schema within a per-schema byte budget for
|
||||
* providers that validate the literal serialized schema size. Validation
|
||||
* keywords and the canonical contract are preserved; only
|
||||
* annotation prose and, for structurally oversized schemas, nested
|
||||
* validation detail leave the advertised form.
|
||||
*/
|
||||
export const compactAdvertisedInputSchema = (
|
||||
schema: Record<string, unknown>,
|
||||
budgetBytes: number,
|
||||
): CompactedInputSchema => {
|
||||
const inline = inlineWithinBudget(stripAnnotations(schema), budgetBytes);
|
||||
if (inline !== undefined) return { schema: inline, presentation: "inline" };
|
||||
const reduced = reduceToShallowProperties(schema);
|
||||
if (serializedBytes(reduced) <= budgetBytes)
|
||||
return { schema: reduced, presentation: "reduced" };
|
||||
// A future contract with an extreme root-property count still advertises
|
||||
// an honest, budget-fitting shape instead of an oversized one.
|
||||
return {
|
||||
schema: reduceToShallowProperties({
|
||||
type: "object",
|
||||
description:
|
||||
(typeof schema.description === "string"
|
||||
? `${schema.description} `
|
||||
: "") + REDUCED_ANNOTATION,
|
||||
}),
|
||||
presentation: "reduced",
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
const schemaMaps = new Set([
|
||||
export const schemaMaps = new Set([
|
||||
"$defs",
|
||||
"definitions",
|
||||
"properties",
|
||||
@@ -8,8 +8,8 @@ const schemaMaps = new Set([
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
]);
|
||||
const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
||||
const schemaChildren = new Set([
|
||||
export const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
||||
export const schemaChildren = new Set([
|
||||
"items",
|
||||
"additionalItems",
|
||||
"contains",
|
||||
|
||||
@@ -95,6 +95,46 @@ export const toolOutputSchemaWithMetadata = <Contract extends ToolContract>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Apply a wire-presentation transform to one schema's advertised input
|
||||
* projection. The wrapper preserves the schema's own parser and standard
|
||||
* metadata, so both Zod registrations and plain standard schemas keep their
|
||||
* canonical validation while only the advertised JSON changes.
|
||||
*/
|
||||
export const transformAdvertisedInputJsonSchema = <Schema>(
|
||||
schema: Schema,
|
||||
transform: (projected: Record<string, unknown>) => Record<string, unknown>,
|
||||
): Schema => {
|
||||
const standard = Reflect.get(schema as object, "~standard");
|
||||
if (!isSchemaRecord(standard)) return schema;
|
||||
const jsonSchema = Reflect.get(standard, "jsonSchema");
|
||||
if (!isSchemaRecord(jsonSchema)) return schema;
|
||||
const project = Reflect.get(jsonSchema, "input");
|
||||
if (typeof project !== "function") return schema;
|
||||
const byTarget = new Map<string, Record<string, unknown>>();
|
||||
const transformedInput = (options: unknown): unknown => {
|
||||
const target = Reflect.get(options as object, "target");
|
||||
if (typeof target === "string") {
|
||||
const cached = byTarget.get(target);
|
||||
if (cached !== undefined) return cached;
|
||||
}
|
||||
const projected = (project as (options: unknown) => unknown)(options);
|
||||
const transformed = isSchemaRecord(projected)
|
||||
? transform(projected)
|
||||
: projected;
|
||||
if (typeof target === "string" && isSchemaRecord(transformed))
|
||||
byTarget.set(target, transformed);
|
||||
return transformed;
|
||||
};
|
||||
return {
|
||||
...(schema as object),
|
||||
"~standard": {
|
||||
...standard,
|
||||
jsonSchema: { ...jsonSchema, input: transformedInput },
|
||||
},
|
||||
} as Schema;
|
||||
};
|
||||
|
||||
const hasRelativeSchemaReference = (
|
||||
schema: Record<string, unknown>,
|
||||
): boolean => {
|
||||
@@ -164,6 +204,9 @@ const memoizeByTarget = (
|
||||
};
|
||||
};
|
||||
|
||||
const isSchemaRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const fallbackPropertyDescription = (property: string): string | undefined => {
|
||||
const explicit = Object.hasOwn(PROPERTY_DESCRIPTIONS, property)
|
||||
? PROPERTY_DESCRIPTIONS[property]
|
||||
|
||||
@@ -98,6 +98,7 @@ import type { ArtifactInventoryPartialObservation } from "./artifactPartialObser
|
||||
import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture.js";
|
||||
import type { Evidence } from "./evidence.js";
|
||||
import type { AndroidPartialObservation } from "./android/androidPartialObservation.js";
|
||||
import type { FirmwarePartialObservation } from "./firmware/firmwareAnalysis.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
@@ -109,4 +110,5 @@ export type AnalysisPartialObservation =
|
||||
| JavaScriptRuntimeObservation
|
||||
| ArtifactInventoryPartialObservation
|
||||
| Evidence
|
||||
| AndroidPartialObservation;
|
||||
| AndroidPartialObservation
|
||||
| FirmwarePartialObservation;
|
||||
|
||||
@@ -161,6 +161,64 @@ it("preserves native API limitations and unavailable residual unknowns", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("pages every retained dossier collection and the native API record", () => {
|
||||
const original = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||
const page = (
|
||||
facet:
|
||||
| "basic_blocks"
|
||||
| "comments"
|
||||
| "unresolved_calls"
|
||||
| "referenced_strings"
|
||||
| "referenced_names",
|
||||
result: unknown = original,
|
||||
) => {
|
||||
const view = projectAnalysisView(nativeParent(result), {
|
||||
kind: "native",
|
||||
facet,
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (!view.ok) throw view.error;
|
||||
return view.value;
|
||||
};
|
||||
expect(page("basic_blocks")).toMatchObject({
|
||||
item: original.basic_blocks,
|
||||
coverage: { total: original.basic_blocks.length, exhausted: true },
|
||||
});
|
||||
expect(page("referenced_strings")).toMatchObject({
|
||||
item: original.referenced_strings,
|
||||
});
|
||||
expect(
|
||||
page("comments", {
|
||||
...original,
|
||||
comments: [{ address: "0x401000", kind: "comment", text: "entry" }],
|
||||
}),
|
||||
).toMatchObject({ item: [{ text: "entry" }] });
|
||||
expect(page("referenced_names")).toMatchObject({ coverage: { total: 0 } });
|
||||
const { unresolved_calls: _omitted, ...withoutUnresolved } = original;
|
||||
expect(
|
||||
page("unresolved_calls", {
|
||||
...withoutUnresolved,
|
||||
native_value_flow: {
|
||||
available: false,
|
||||
reason: "No high p-code",
|
||||
limitations: [],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ item: { available: false, reason: "not recorded" } });
|
||||
const api = projectAnalysisView(nativeParent(), {
|
||||
kind: "native",
|
||||
facet: "native_api",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (!api.ok) throw api.error;
|
||||
expect(api.value).toMatchObject({
|
||||
item: original.native_api,
|
||||
coverage: { status: "complete-within-view" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the malformed native dossier field", () => {
|
||||
const result = projectAnalysisView(
|
||||
nativeParent({
|
||||
|
||||
@@ -82,10 +82,16 @@ const nativeViewSchema = z.strictObject({
|
||||
"procedure",
|
||||
"pseudocode",
|
||||
"assembly",
|
||||
"basic_blocks",
|
||||
"comments",
|
||||
"callers",
|
||||
"callees",
|
||||
"incoming_references",
|
||||
"outgoing_references",
|
||||
"unresolved_calls",
|
||||
"referenced_strings",
|
||||
"referenced_names",
|
||||
"native_api",
|
||||
"value_flow_summary",
|
||||
"value_flow_operations",
|
||||
"value_flow_def_use",
|
||||
|
||||
@@ -104,17 +104,22 @@ const nativeRows = (
|
||||
dossier: FunctionDossier,
|
||||
facet: Exclude<
|
||||
Extract<AnalysisViewRequest, { readonly kind: "native" }>["facet"],
|
||||
"procedure" | "pseudocode" | "value_flow_summary"
|
||||
"procedure" | "pseudocode" | "native_api" | "value_flow_summary"
|
||||
>,
|
||||
) => {
|
||||
): readonly unknown[] | undefined => {
|
||||
const sourceFlow = dossier.native_value_flow;
|
||||
const flow = sourceFlow?.available ? sourceFlow : undefined;
|
||||
return {
|
||||
assembly: dossier.assembly,
|
||||
basic_blocks: dossier.basic_blocks,
|
||||
comments: dossier.comments,
|
||||
callers: dossier.callers,
|
||||
callees: dossier.callees,
|
||||
incoming_references: dossier.incoming_references,
|
||||
outgoing_references: dossier.outgoing_references,
|
||||
unresolved_calls: dossier.unresolved_calls,
|
||||
referenced_strings: dossier.referenced_strings,
|
||||
referenced_names: dossier.referenced_names,
|
||||
value_flow_operations: flow?.operations,
|
||||
value_flow_def_use: flow?.def_use,
|
||||
value_flow_effects: flow?.effects,
|
||||
@@ -152,10 +157,20 @@ export const projectNativeFunctionView = (
|
||||
...common,
|
||||
});
|
||||
const flow = dossier.native_value_flow;
|
||||
if (view.facet === "procedure" || view.facet === "value_flow_summary") {
|
||||
if (
|
||||
view.facet === "procedure" ||
|
||||
view.facet === "native_api" ||
|
||||
view.facet === "value_flow_summary"
|
||||
) {
|
||||
if (view.offset !== 0)
|
||||
return err(viewError("Singleton native facets require offset 0."));
|
||||
if (view.facet === "procedure") return ok(singleton(dossier.procedure));
|
||||
if (view.facet === "native_api")
|
||||
return ok(
|
||||
singleton(
|
||||
dossier.native_api ?? { available: false, reason: "not recorded" },
|
||||
),
|
||||
);
|
||||
const status =
|
||||
flow === null
|
||||
? { available: false, reason: "not recorded" }
|
||||
@@ -188,7 +203,12 @@ export const projectNativeFunctionView = (
|
||||
return ok(
|
||||
singleton({
|
||||
available: false,
|
||||
reason: flow?.available === false ? flow.reason : "not recorded",
|
||||
// Only value-flow facets inherit the value-flow failure reason; an
|
||||
// older dossier simply has no unresolved_calls record.
|
||||
reason:
|
||||
view.facet.startsWith("value_flow_") && flow?.available === false
|
||||
? flow.reason
|
||||
: "not recorded",
|
||||
}),
|
||||
);
|
||||
const items = rows.slice(view.offset, view.offset + view.limit);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
bridgeCandidateCoverageSchema,
|
||||
projectCartesianCandidates,
|
||||
} from "../bridgeCandidateProjection.js";
|
||||
import { nativeRuntimeConvention } from "../nativeConvention.js";
|
||||
|
||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
const pathSchema = z.string().min(1);
|
||||
@@ -176,7 +177,6 @@ const classify = (all: readonly Component[]) => ({
|
||||
});
|
||||
|
||||
const runtimeFamilies = (all: readonly Component[]) => {
|
||||
const paths = all.map(({ path }) => path.toLowerCase());
|
||||
const families = new Set<
|
||||
AndroidApplicationProjectionResult["runtime_families"][number]
|
||||
>();
|
||||
@@ -187,15 +187,10 @@ const runtimeFamilies = (all: readonly Component[]) => {
|
||||
if (all.some(({ format }) => format === "elf")) families.add("native");
|
||||
if (all.some(({ format }) => format === "javascript-bundle"))
|
||||
families.add("javascript");
|
||||
if (
|
||||
paths.some(
|
||||
(path) => path.includes("reactnative") || path.includes("hermes"),
|
||||
)
|
||||
)
|
||||
families.add("react-native");
|
||||
if (paths.some((path) => path.includes("libflutter.so")))
|
||||
families.add("flutter");
|
||||
if (paths.some((path) => path.includes("libunity.so"))) families.add("unity");
|
||||
for (const { path } of all) {
|
||||
const convention = nativeRuntimeConvention(path);
|
||||
if (convention !== null) families.add(convention);
|
||||
}
|
||||
return [...families].sort(compare);
|
||||
};
|
||||
|
||||
@@ -216,12 +211,12 @@ const bridgeCandidates = (
|
||||
const bridgeBasis = (
|
||||
path: string,
|
||||
): AndroidApplicationProjectionResult["bridge_candidates"][number]["basis"] => {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.includes("react") || lower.includes("hermes"))
|
||||
return "react-native-convention";
|
||||
if (lower.includes("flutter")) return "flutter-convention";
|
||||
if (lower.includes("unity")) return "unity-convention";
|
||||
if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(lower)) return "jni-library-convention";
|
||||
const convention = nativeRuntimeConvention(path);
|
||||
if (convention === "react-native") return "react-native-convention";
|
||||
if (convention === "flutter") return "flutter-convention";
|
||||
if (convention === "unity") return "unity-convention";
|
||||
if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(path.toLowerCase()))
|
||||
return "jni-library-convention";
|
||||
return "managed-and-native-content";
|
||||
};
|
||||
|
||||
|
||||
@@ -572,3 +572,80 @@ describe("Apple application bridge candidates", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Apple path conventions", () => {
|
||||
it("recognizes anatomy names by English case and reports the archive spelling", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("directory", "Fixture.app", [
|
||||
{ path: "contents/info.plist" },
|
||||
macho("contents/macos/Fixture"),
|
||||
{ path: "contents/_codesignature/coderesources" },
|
||||
]),
|
||||
);
|
||||
expect(result.bundles[0]).toMatchObject({
|
||||
layout: "macos-deep",
|
||||
info_plist_path: "contents/info.plist",
|
||||
executable_candidates: ["contents/macos/Fixture"],
|
||||
signing_paths: ["contents/_codesignature/coderesources"],
|
||||
});
|
||||
expect(result.components.bundle_metadata.map(({ path }) => path)).toEqual([
|
||||
"contents/info.plist",
|
||||
]);
|
||||
expect(result.components.signing.map(({ path }) => path)).toEqual([
|
||||
"contents/_codesignature/coderesources",
|
||||
]);
|
||||
});
|
||||
|
||||
it("limits runtime families and bridge bases to path segments", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: "Payload/Fixture.app/Info.plist" },
|
||||
macho("Payload/Fixture.app/Fixture"),
|
||||
macho("Payload/Fixture.app/Frameworks/libfoo.dylib"),
|
||||
macho("SwiftSupport/libswiftCore.dylib"),
|
||||
macho("Payload/Fixture.app/Frameworks/MyApp.framework/MyApp"),
|
||||
macho("Payload/Fixture.app/community/libcommunity.so"),
|
||||
{
|
||||
path: "Payload/Fixture.app/main.js",
|
||||
format: "javascript-bundle",
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(result.runtime_families).toEqual(
|
||||
expect.arrayContaining(["javascript", "native", "swift-objective-c"]),
|
||||
);
|
||||
expect(result.runtime_families).not.toContain("flutter");
|
||||
expect(result.runtime_families).not.toContain("unity");
|
||||
expect(result.runtime_families).not.toContain("react-native");
|
||||
expect(
|
||||
result.bridge_candidates.every(
|
||||
({ basis }) => basis === "javascript-and-native-content",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.limitations).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Runtime families are inferred from inventory formats and paths.",
|
||||
"A bridge basis is inferred from the native path and is repeated for every managed component.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes framework segments and does not treat a bare dylib as Swift", () => {
|
||||
const result = project(
|
||||
inventoryEvidence("ipa", "Fixture.ipa", [
|
||||
{ path: "Payload/Fixture.app/Info.plist" },
|
||||
macho("Payload/Fixture.app/Fixture"),
|
||||
macho("Payload/Fixture.app/Frameworks/Flutter.framework/Flutter"),
|
||||
macho(
|
||||
"Payload/Fixture.app/Frameworks/UnityFramework.framework/UnityFramework",
|
||||
),
|
||||
macho("Payload/Fixture.app/Frameworks/hermes.framework/hermes"),
|
||||
macho("Payload/Fixture.app/Frameworks/libfoo.dylib"),
|
||||
]),
|
||||
);
|
||||
expect(result.runtime_families).toEqual(
|
||||
expect.arrayContaining(["flutter", "unity", "react-native", "native"]),
|
||||
);
|
||||
expect(result.runtime_families).not.toContain("swift-objective-c");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
anatomyNameIs,
|
||||
anatomyTailMatches,
|
||||
appleBundleSchema,
|
||||
appleComponentSchema,
|
||||
applicationRoots,
|
||||
@@ -14,6 +16,10 @@ import {
|
||||
platformsOf,
|
||||
type AppleInventoryEntry,
|
||||
} from "./appleBundleAnatomy.js";
|
||||
import {
|
||||
isSwiftRuntimeLibrary,
|
||||
nativeRuntimeConvention,
|
||||
} from "../nativeConvention.js";
|
||||
import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js";
|
||||
import { evidenceSchema } from "../evidence.js";
|
||||
import { digestSchema } from "../digests.js";
|
||||
@@ -239,19 +245,20 @@ const classifyComponents = (
|
||||
) => {
|
||||
const withinApp = (path: string): boolean =>
|
||||
roots.some((root) => isWithin(path, root));
|
||||
const anatomy = (pattern: RegExp): Component[] =>
|
||||
const matching = (predicate: (path: string) => boolean): Component[] =>
|
||||
entries.flatMap(({ path, kind, component }) =>
|
||||
kind === "file" &&
|
||||
component !== undefined &&
|
||||
!isSidecar(path) &&
|
||||
withinApp(path) &&
|
||||
pattern.test(path)
|
||||
predicate(path)
|
||||
? [component]
|
||||
: [],
|
||||
);
|
||||
const lastSegment = (path: string): string => path.split("/").at(-1) ?? "";
|
||||
return {
|
||||
bundle_metadata: all.filter(({ path }) =>
|
||||
/(?:^|\/)Info\.plist$/u.test(path),
|
||||
anatomyNameIs(lastSegment(path), "Info.plist"),
|
||||
),
|
||||
executables: all.filter(
|
||||
({ path, format }) => withinApp(path) && isMachOFormat(format),
|
||||
@@ -259,23 +266,41 @@ const classifyComponents = (
|
||||
frameworks: all.filter(({ path }) => /\.framework\//u.test(path)),
|
||||
native_libraries: all.filter(({ path }) => /\.(?:dylib|so)$/iu.test(path)),
|
||||
javascript: all.filter(({ format }) => format === "javascript-bundle"),
|
||||
signing: all.filter(({ path }) =>
|
||||
/(?:^|\/)(?:embedded\.(?:mobileprovision|provisionprofile)|_CodeSignature\/CodeResources)$/u.test(
|
||||
path,
|
||||
),
|
||||
signing: all.filter(
|
||||
({ path }) =>
|
||||
anatomyTailMatches(path, ["_CodeSignature", "CodeResources"]) ||
|
||||
anatomyNameIs(lastSegment(path), "embedded.mobileprovision") ||
|
||||
anatomyNameIs(lastSegment(path), "embedded.provisionprofile"),
|
||||
),
|
||||
privileged_helpers: anatomy(
|
||||
/(?:^|\/)Contents\/Library\/LaunchServices\/[^/]+$/u,
|
||||
privileged_helpers: matching((path) =>
|
||||
anatomyTailMatches(path, ["Contents", "Library", "LaunchServices", "*"]),
|
||||
),
|
||||
launchd_plists: anatomy(
|
||||
/(?:^|\/)Contents\/Library\/Launch(?:Agents|Daemons)\/[^/]+\.plist$/u,
|
||||
launchd_plists: matching(
|
||||
(path) =>
|
||||
path.endsWith(".plist") &&
|
||||
(anatomyTailMatches(path, [
|
||||
"Contents",
|
||||
"Library",
|
||||
"LaunchAgents",
|
||||
"*",
|
||||
]) ||
|
||||
anatomyTailMatches(path, [
|
||||
"Contents",
|
||||
"Library",
|
||||
"LaunchDaemons",
|
||||
"*",
|
||||
])),
|
||||
).map((component) => ({
|
||||
...component,
|
||||
domain: component.path.includes("/LaunchDaemons/")
|
||||
domain: component.path
|
||||
.split("/")
|
||||
.some((segment) => anatomyNameIs(segment, "LaunchDaemons"))
|
||||
? ("daemon" as const)
|
||||
: ("agent" as const),
|
||||
})),
|
||||
helpers: anatomy(/(?:^|\/)Contents\/Helpers\/[^/]+$/u),
|
||||
helpers: matching((path) =>
|
||||
anatomyTailMatches(path, ["Contents", "Helpers", "*"]),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -285,7 +310,6 @@ const deduplicateComponents = (values: readonly Component[]): Component[] =>
|
||||
);
|
||||
|
||||
const identifyRuntimeFamilies = (all: readonly Component[]) => {
|
||||
const paths = all.map(({ path }) => path.toLowerCase());
|
||||
const families = new Set<
|
||||
z.infer<
|
||||
typeof appleApplicationProjectionResultSchema
|
||||
@@ -293,28 +317,14 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => {
|
||||
>();
|
||||
if (all.some(({ format }) => ["mach-o", "mach-o-universal"].includes(format)))
|
||||
families.add("native");
|
||||
if (paths.some((path) => /\.(?:dylib|framework\/[^/]+)$/u.test(path)))
|
||||
if (all.some(({ path }) => isSwiftRuntimeLibrary(path)))
|
||||
families.add("swift-objective-c");
|
||||
if (all.some(({ format }) => format === "javascript-bundle"))
|
||||
families.add("javascript");
|
||||
if (
|
||||
paths.some(
|
||||
(path) =>
|
||||
path.includes("reactnative") ||
|
||||
path.includes("react.framework") ||
|
||||
path.includes("hermes"),
|
||||
)
|
||||
)
|
||||
families.add("react-native");
|
||||
if (
|
||||
paths.some(
|
||||
(path) =>
|
||||
path.includes("flutter.framework") || path.includes("app.framework"),
|
||||
)
|
||||
)
|
||||
families.add("flutter");
|
||||
if (paths.some((path) => path.includes("unityframework.framework")))
|
||||
families.add("unity");
|
||||
for (const { path } of all) {
|
||||
const convention = nativeRuntimeConvention(path);
|
||||
if (convention !== null) families.add(convention);
|
||||
}
|
||||
return [...families].sort(compare);
|
||||
};
|
||||
|
||||
@@ -361,11 +371,10 @@ const bridgeBasis = (
|
||||
| "react-native-convention"
|
||||
| "flutter-convention"
|
||||
| "unity-convention" => {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.includes("react")) return "react-native-convention";
|
||||
if (lower.includes("flutter") || lower.includes("app.framework"))
|
||||
return "flutter-convention";
|
||||
if (lower.includes("unity")) return "unity-convention";
|
||||
const convention = nativeRuntimeConvention(path);
|
||||
if (convention === "react-native") return "react-native-convention";
|
||||
if (convention === "flutter") return "flutter-convention";
|
||||
if (convention === "unity") return "unity-convention";
|
||||
return "javascript-and-native-content";
|
||||
};
|
||||
|
||||
@@ -422,7 +431,9 @@ const projectionLimitations = (facts: {
|
||||
: []),
|
||||
"Bundle roles follow path conventions. Read each info_plist_path with inspect_plist for CFBundleExecutable, identifiers, and declared services.",
|
||||
"Bundle identifiers and signing claims require dedicated plist and CMS parsing; this projection reports only exact paths and hashes.",
|
||||
"Runtime families are inferred from inventory formats and paths.",
|
||||
"Bridge candidates are path-based hypotheses, not observed runtime calls.",
|
||||
"A bridge basis is inferred from the native path and is repeated for every managed component.",
|
||||
];
|
||||
|
||||
const compare = (left: string, right: string): number =>
|
||||
|
||||
@@ -67,6 +67,50 @@ export const isSidecar = (path: string): boolean => {
|
||||
export const isWithin = (path: string, root: string): boolean =>
|
||||
root === "." || path === root || path.startsWith(`${root}/`);
|
||||
|
||||
/**
|
||||
* English (en-US) case fold used only for Apple's fixed anatomy vocabulary.
|
||||
* Reported paths keep the archive spelling. Inventory identity stays case-sensitive.
|
||||
*/
|
||||
export const anatomyNameIs = (actual: string, expected: string): boolean =>
|
||||
actual.toLocaleLowerCase("en-US") === expected.toLocaleLowerCase("en-US");
|
||||
|
||||
/** Directory entry that folds to one vocabulary word. The exact spelling wins. */
|
||||
export const anatomyChild = (
|
||||
names: ReadonlySet<string> | undefined,
|
||||
expected: string,
|
||||
): string | null => {
|
||||
if (names === undefined) return null;
|
||||
let exact: string | undefined;
|
||||
let folded: string | undefined;
|
||||
for (const name of names) {
|
||||
if (name === expected) exact = name;
|
||||
else if (
|
||||
anatomyNameIs(name, expected) &&
|
||||
(folded === undefined || compare(name, folded) < 0)
|
||||
)
|
||||
folded = name;
|
||||
}
|
||||
return exact ?? folded ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether `path`'s tail matches anatomy vocabulary. `*` matches one segment
|
||||
* that is not itself a vocabulary comparison.
|
||||
*/
|
||||
export const anatomyTailMatches = (
|
||||
path: string,
|
||||
expected: readonly string[],
|
||||
): boolean => {
|
||||
const actual = path.split("/");
|
||||
if (actual.length < expected.length) return false;
|
||||
const tail = actual.slice(-expected.length);
|
||||
return expected.every((name, index) => {
|
||||
const segment = tail[index];
|
||||
if (segment === undefined || segment.length === 0) return false;
|
||||
return name === "*" || anatomyNameIs(segment, name);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Outermost iOS (`Payload/X.app`) and macOS (`X.app/Contents/…`) applications.
|
||||
* An inventoried `.app` directory is its own root, `.`.
|
||||
@@ -82,15 +126,20 @@ export const applicationRoots = (
|
||||
const found = new Set<string>();
|
||||
for (const { path } of entries) {
|
||||
if (isSidecar(path)) continue;
|
||||
const ios = /^(Payload\/[^/]+\.app)(?:\/|$)/u.exec(path);
|
||||
if (ios?.[1] !== undefined) {
|
||||
found.add(ios[1]);
|
||||
const segments = path.split("/");
|
||||
const app = segments[1];
|
||||
if (
|
||||
anatomyNameIs(segments[0] ?? "", "Payload") &&
|
||||
app !== undefined &&
|
||||
/\.app$/iu.test(app)
|
||||
) {
|
||||
found.add(`${segments[0]}/${app}`);
|
||||
continue;
|
||||
}
|
||||
const segments = path.split("/");
|
||||
const index = segments.findIndex(
|
||||
(segment, position) =>
|
||||
/\.app$/iu.test(segment) && segments[position + 1] === "Contents",
|
||||
/\.app$/iu.test(segment) &&
|
||||
anatomyNameIs(segments[position + 1] ?? "", "Contents"),
|
||||
);
|
||||
if (index >= 0) found.add(segments.slice(0, index + 1).join("/"));
|
||||
}
|
||||
@@ -186,8 +235,8 @@ const enclosingBundle = (
|
||||
|
||||
const bundleLayout = (bundle: string, tree: Tree): Bundle["layout"] => {
|
||||
const names = tree.children.get(directoryOf(bundle));
|
||||
if (names?.has("Contents") === true) return "macos-deep";
|
||||
if (names?.has("Versions") === true) return "versioned-framework";
|
||||
if (anatomyChild(names, "Contents") !== null) return "macos-deep";
|
||||
if (anatomyChild(names, "Versions") !== null) return "versioned-framework";
|
||||
return "shallow";
|
||||
};
|
||||
|
||||
@@ -201,13 +250,18 @@ const bundleRole = (bundle: string): Bundle["role"] => {
|
||||
if (name.endsWith(".systemextension")) return "system-extension";
|
||||
if (name.endsWith(".dext")) return "driver-extension";
|
||||
if (name.endsWith(".bundle")) {
|
||||
if (container === "PlugIns") return "plug-in";
|
||||
return container === "Resources" ? "resource-bundle" : "bundle";
|
||||
if (anatomyNameIs(container ?? "", "PlugIns")) return "plug-in";
|
||||
return anatomyNameIs(container ?? "", "Resources")
|
||||
? "resource-bundle"
|
||||
: "bundle";
|
||||
}
|
||||
if (!name.endsWith(".app")) return "plug-in";
|
||||
if (container === "LoginItems" && segments.at(-3) === "Library")
|
||||
if (
|
||||
anatomyNameIs(container ?? "", "LoginItems") &&
|
||||
anatomyNameIs(segments.at(-3) ?? "", "Library")
|
||||
)
|
||||
return "login-item";
|
||||
if (container === "Helpers") return "helper-application";
|
||||
if (anatomyNameIs(container ?? "", "Helpers")) return "helper-application";
|
||||
return "nested-application";
|
||||
};
|
||||
|
||||
@@ -225,18 +279,32 @@ const contentDirectories = (
|
||||
tree: Tree,
|
||||
): string[] => {
|
||||
const directory = directoryOf(bundle);
|
||||
if (layout === "macos-deep") return [joinPath(directory, "Contents")];
|
||||
if (layout === "macos-deep") {
|
||||
const contents = namedChild(tree, directory, "Contents");
|
||||
return contents === null ? [] : [contents];
|
||||
}
|
||||
if (layout === "shallow") return [directory];
|
||||
const versions = joinPath(directory, "Versions");
|
||||
const versions = namedChild(tree, directory, "Versions");
|
||||
if (versions === null) return [];
|
||||
// Only real version directories count: Current is a symlink, and files
|
||||
// such as .DS_Store are not versions.
|
||||
return [...(tree.children.get(versions) ?? [])]
|
||||
.filter((version) => version !== "Current")
|
||||
.filter((version) => !anatomyNameIs(version, "Current"))
|
||||
.map((version) => joinPath(versions, version))
|
||||
.filter((version) => tree.directories.has(version))
|
||||
.sort(compare);
|
||||
};
|
||||
|
||||
/** Child path using the archive's spelling of one anatomy name. */
|
||||
const namedChild = (
|
||||
tree: Tree,
|
||||
directory: string,
|
||||
expected: string,
|
||||
): string | null => {
|
||||
const name = anatomyChild(tree.children.get(directory), expected);
|
||||
return name === null ? null : joinPath(directory, name);
|
||||
};
|
||||
|
||||
const infoPlistPath = (
|
||||
bundle: string,
|
||||
layout: Bundle["layout"],
|
||||
@@ -246,17 +314,15 @@ const infoPlistPath = (
|
||||
// With several real versions, Versions/Current (a symlink outside the
|
||||
// inventory) decides which plist applies, so the plist stays unknown.
|
||||
if (directories.length !== 1) return null;
|
||||
const candidates = directories
|
||||
.map((directory) =>
|
||||
joinPath(
|
||||
directory,
|
||||
layout === "versioned-framework"
|
||||
? "Resources/Info.plist"
|
||||
: "Info.plist",
|
||||
),
|
||||
)
|
||||
.filter((path) => tree.files.has(path));
|
||||
return candidates.length === 1 ? (candidates[0] ?? null) : null;
|
||||
const directory = directories[0];
|
||||
if (directory === undefined) return null;
|
||||
const parent =
|
||||
layout === "versioned-framework"
|
||||
? namedChild(tree, directory, "Resources")
|
||||
: directory;
|
||||
if (parent === null) return null;
|
||||
const info = namedChild(tree, parent, "Info.plist");
|
||||
return info !== null && tree.files.has(info) ? info : null;
|
||||
};
|
||||
|
||||
const executableCandidates = (
|
||||
@@ -265,12 +331,15 @@ const executableCandidates = (
|
||||
tree: Tree,
|
||||
): string[] =>
|
||||
contentDirectories(bundle, layout, tree)
|
||||
.flatMap((directory) =>
|
||||
directFiles(
|
||||
layout === "macos-deep" ? joinPath(directory, "MacOS") : directory,
|
||||
tree,
|
||||
),
|
||||
)
|
||||
.flatMap((directory) => {
|
||||
const executableDirectory =
|
||||
layout === "macos-deep"
|
||||
? namedChild(tree, directory, "MacOS")
|
||||
: directory;
|
||||
return executableDirectory === null
|
||||
? []
|
||||
: directFiles(executableDirectory, tree);
|
||||
})
|
||||
.filter((path) =>
|
||||
MACH_O_FORMATS.includes(tree.files.get(path)?.format ?? ""),
|
||||
);
|
||||
@@ -281,12 +350,17 @@ const signingPaths = (
|
||||
tree: Tree,
|
||||
): string[] =>
|
||||
contentDirectories(bundle, layout, tree)
|
||||
.flatMap((directory) => [
|
||||
joinPath(directory, "_CodeSignature/CodeResources"),
|
||||
joinPath(directory, "embedded.provisionprofile"),
|
||||
joinPath(directory, "embedded.mobileprovision"),
|
||||
])
|
||||
.filter((path) => tree.files.has(path))
|
||||
.flatMap((directory) => {
|
||||
const signature = namedChild(tree, directory, "_CodeSignature");
|
||||
return [
|
||||
signature === null
|
||||
? null
|
||||
: namedChild(tree, signature, "CodeResources"),
|
||||
namedChild(tree, directory, "embedded.provisionprofile"),
|
||||
namedChild(tree, directory, "embedded.mobileprovision"),
|
||||
];
|
||||
})
|
||||
.filter((path): path is string => path !== null && tree.files.has(path))
|
||||
.sort(compare);
|
||||
|
||||
/** Platforms of application roots whose layout was observed, not assumed. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./artifactInventoryEvidence.js";
|
||||
import { prefixedDigestSchema } from "./../domain/digests.js";
|
||||
import { canonicalJson } from "./comparisonSemantics.js";
|
||||
import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js";
|
||||
|
||||
const evidenceIdSchema = prefixedDigestSchema("ev");
|
||||
const comparisonStatusSchema = z.enum([
|
||||
@@ -122,6 +123,11 @@ export const compareArtifacts = (
|
||||
],
|
||||
leftCovered && rightCovered,
|
||||
);
|
||||
const rootChange = rootHashChange(left.inventory, right.inventory, changes, [
|
||||
...left.evidence.map(({ evidence_id: id }) => id),
|
||||
...right.evidence.map(({ evidence_id: id }) => id),
|
||||
]);
|
||||
if (rootChange !== null) changes.push(rootChange);
|
||||
const summary = summarize(
|
||||
left.inventory,
|
||||
right.inventory,
|
||||
@@ -350,19 +356,20 @@ const relationsByPath = (
|
||||
inventory.occurrences.map((item) => [item.occurrence_id, item]),
|
||||
);
|
||||
for (const edge of inventory.edges) {
|
||||
const path = edge.logical_path ?? ".";
|
||||
const values = output.get(path);
|
||||
const occurrence = occurrences.get(edge.occurrence_id);
|
||||
// An incomplete inventory can omit the occurrence that names the child.
|
||||
if (occurrence?.artifact_id === null || occurrence === undefined) continue;
|
||||
const path = occurrence.logical_path;
|
||||
const values = output.get(path);
|
||||
const projection: RelationProjection = {
|
||||
parent_logical_path:
|
||||
occurrence?.parent_occurrence_id === null ||
|
||||
occurrence?.parent_occurrence_id === undefined
|
||||
occurrence.parent_occurrence_id === null
|
||||
? null
|
||||
: (occurrences.get(occurrence.parent_occurrence_id)?.logical_path ??
|
||||
null),
|
||||
child_artifact_id: edge.child_artifact_id,
|
||||
child_artifact_id: occurrence.artifact_id,
|
||||
relation: edge.relation,
|
||||
logical_path: edge.logical_path,
|
||||
logical_path: occurrence.logical_path,
|
||||
producer: edge.producer,
|
||||
};
|
||||
if (values === undefined) output.set(path, [projection]);
|
||||
@@ -430,5 +437,38 @@ const summarize = (
|
||||
const hasCoverageLimitation = (limitations: readonly string[]): boolean =>
|
||||
limitations.some(
|
||||
(limitation) =>
|
||||
limitation !== ZIP_NON_ENTRY_TAIL_LIMITATION &&
|
||||
!/integrity contradiction\(s\) were recorded/u.test(limitation),
|
||||
);
|
||||
|
||||
const rootHashChange = (
|
||||
left: InventorySet,
|
||||
right: InventorySet,
|
||||
changes: readonly ArtifactChange[],
|
||||
evidenceLinks: readonly string[],
|
||||
): ArtifactChange | null => {
|
||||
if (left.manifest.root_sha256 === right.manifest.root_sha256) return null;
|
||||
if (
|
||||
changes.some(
|
||||
(change) =>
|
||||
change.logical_path === "." && change.dimensions.includes("content"),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const leftRoot = left.occurrences.find(
|
||||
(occurrence) => occurrence.logical_path === ".",
|
||||
);
|
||||
const rightRoot = right.occurrences.find(
|
||||
(occurrence) => occurrence.logical_path === ".",
|
||||
);
|
||||
return {
|
||||
classification: "changed",
|
||||
logical_path: ".",
|
||||
dimensions: ["content"],
|
||||
left_occurrence_id: leftRoot?.occurrence_id ?? null,
|
||||
right_occurrence_id: rightRoot?.occurrence_id ?? null,
|
||||
left_artifact_id: leftRoot?.artifact_id ?? null,
|
||||
right_artifact_id: rightRoot?.artifact_id ?? null,
|
||||
evidence_links: [...evidenceLinks],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -151,12 +151,14 @@ export const integrityContradictionSchema = z.object({
|
||||
limitations: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
/** Parent-child derivation relation between two content-addressed artifacts. */
|
||||
/**
|
||||
* Parent-child derivation relation between two content-addressed artifacts.
|
||||
* The child artifact and its logical path are those of the edge's occurrence.
|
||||
*/
|
||||
const artifactEdgeSchema = z.object({
|
||||
edge_id: edgeIdSchema,
|
||||
ordinal: z.number().int().min(0),
|
||||
parent_artifact_id: artifactIdSchema,
|
||||
child_artifact_id: artifactIdSchema,
|
||||
relation: z.enum([
|
||||
"contains",
|
||||
"extracts",
|
||||
@@ -167,7 +169,6 @@ const artifactEdgeSchema = z.object({
|
||||
"derived-from",
|
||||
]),
|
||||
occurrence_id: occurrenceIdSchema,
|
||||
logical_path: boundedRelativePathSchema.nullable(),
|
||||
producer: artifactCommandSchema.nullable(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { posix } from "node:path";
|
||||
|
||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
||||
import type {
|
||||
ArtifactEdge,
|
||||
@@ -6,6 +8,29 @@ import type {
|
||||
IntegrityContradiction,
|
||||
} from "./artifactGraph.js";
|
||||
|
||||
/** NFC spelling of a logical archive path. Inventory and path joins share it. */
|
||||
export const logicalPathNfc = (value: string): string => value.normalize("NFC");
|
||||
|
||||
/**
|
||||
* Lexical join against an inventoried logical path. The result is NFC.
|
||||
* `"."` is the archive root. A path that escapes the root, or that contains
|
||||
* NUL, is left in a form the caller can report; `logicalPathEscapesRoot`
|
||||
* is then true.
|
||||
*/
|
||||
export const normalizeJoinedLogicalPath = (value: string): string => {
|
||||
if (value.includes("\0")) return value;
|
||||
return logicalPathNfc(
|
||||
posix.normalize(value.replaceAll("\\", "/")).replace(/^\.\//u, ""),
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether a joined logical path leaves the archive root. */
|
||||
export const logicalPathEscapesRoot = (path: string): boolean =>
|
||||
path.includes("\0") ||
|
||||
path === ".." ||
|
||||
path.startsWith("../") ||
|
||||
path.startsWith("/");
|
||||
|
||||
/** Content-address an artifact independently of where it was observed. */
|
||||
export const artifactIdForContent = (sha256: string): string =>
|
||||
`art_${digestCanonicalValue({ sha256 }, "Artifact")}`;
|
||||
@@ -71,14 +96,21 @@ export const artifactContradictionId = (input: {
|
||||
"Artifact",
|
||||
)}`;
|
||||
|
||||
/** Identify an edge by its semantic relationship and exact occurrence. */
|
||||
/**
|
||||
* Identify an edge by its semantic relationship and exact occurrence. The
|
||||
* child artifact and logical path come from that occurrence.
|
||||
*/
|
||||
export const artifactEdgeId = (
|
||||
semantic: Pick<
|
||||
ArtifactEdge,
|
||||
| "parent_artifact_id"
|
||||
| "child_artifact_id"
|
||||
| "relation"
|
||||
| "occurrence_id"
|
||||
| "logical_path"
|
||||
>,
|
||||
): string => `edge_${digestCanonicalValue(semantic, "Artifact")}`;
|
||||
edge: Pick<ArtifactEdge, "parent_artifact_id" | "relation" | "occurrence_id">,
|
||||
occurrence: Pick<ArtifactOccurrence, "artifact_id" | "logical_path">,
|
||||
): string =>
|
||||
`edge_${digestCanonicalValue(
|
||||
{
|
||||
parent_artifact_id: edge.parent_artifact_id,
|
||||
child_artifact_id: occurrence.artifact_id,
|
||||
relation: edge.relation,
|
||||
occurrence_id: edge.occurrence_id,
|
||||
logical_path: occurrence.logical_path,
|
||||
},
|
||||
"Artifact",
|
||||
)}`;
|
||||
|
||||
@@ -211,8 +211,14 @@ const validateCompleteInventory = (inventory: InventorySet): void => {
|
||||
);
|
||||
for (const occurrence of inventory.occurrences)
|
||||
validateOccurrence(occurrence, inventory, nodeIds, occurrenceIds);
|
||||
const occurrencesById = new Map(
|
||||
inventory.occurrences.map((occurrence) => [
|
||||
occurrence.occurrence_id,
|
||||
occurrence,
|
||||
]),
|
||||
);
|
||||
for (const edge of inventory.edges)
|
||||
validateEdge(edge, nodeIds, occurrenceIds);
|
||||
validateEdge(edge, nodeIds, occurrencesById);
|
||||
for (const contradiction of inventory.integrityContradictions) {
|
||||
if (
|
||||
!nodeIds.has(contradiction.parent_artifact_id) ||
|
||||
@@ -273,22 +279,17 @@ const validateOccurrence = (
|
||||
const validateEdge = (
|
||||
edge: ArtifactInventoryResult["edges"][number],
|
||||
nodeIds: ReadonlySet<string>,
|
||||
occurrenceIds: ReadonlySet<string>,
|
||||
occurrences: ReadonlyMap<string, ArtifactOccurrence>,
|
||||
): void => {
|
||||
const occurrence = occurrences.get(edge.occurrence_id);
|
||||
if (
|
||||
!nodeIds.has(edge.parent_artifact_id) ||
|
||||
!nodeIds.has(edge.child_artifact_id) ||
|
||||
!occurrenceIds.has(edge.occurrence_id)
|
||||
occurrence?.artifact_id === null ||
|
||||
occurrence === undefined ||
|
||||
!nodeIds.has(occurrence.artifact_id)
|
||||
)
|
||||
throw new TypeError("Artifact edge references a missing graph member");
|
||||
const semantic = {
|
||||
parent_artifact_id: edge.parent_artifact_id,
|
||||
child_artifact_id: edge.child_artifact_id,
|
||||
relation: edge.relation,
|
||||
occurrence_id: edge.occurrence_id,
|
||||
logical_path: edge.logical_path,
|
||||
};
|
||||
if (edge.edge_id !== artifactEdgeId(semantic))
|
||||
if (edge.edge_id !== artifactEdgeId(edge, occurrence))
|
||||
throw new TypeError("Artifact edge ID does not match its identity");
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Policy divergence (deliberate, not yet unified):
|
||||
* - Local artifact paths resolve against an inventoried container root and
|
||||
* report rejected | external | not-found. Confinement is lexical
|
||||
* (posix.normalize must stay inside the root).
|
||||
* (normalizeJoinedLogicalPath must stay inside the root).
|
||||
* - Live DOM destinations resolve against the document base URL and report
|
||||
* approved | outside_policy | unsupported against allowedOrigins
|
||||
* (see CdpCaptureDocuments.domDestination).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../jsonValue.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
@@ -95,6 +95,12 @@ const chunk = z.strictObject({
|
||||
root_file_range: range.nullable(),
|
||||
});
|
||||
|
||||
/** Completed firmware result retained when private-runtime cleanup cannot be confirmed. */
|
||||
export interface FirmwarePartialObservation {
|
||||
readonly kind: "firmware";
|
||||
readonly result: JsonValue;
|
||||
}
|
||||
|
||||
/** Portable results retain reported boundaries, unknowns and extraction lineage. */
|
||||
export const firmwareResultSchemas = {
|
||||
inspect_firmware_regions: z.strictObject({
|
||||
|
||||
@@ -2,6 +2,10 @@ import { posix, relative, win32 } from "node:path";
|
||||
|
||||
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
|
||||
import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
|
||||
import {
|
||||
logicalPathEscapesRoot,
|
||||
normalizeJoinedLogicalPath,
|
||||
} from "../artifactIdentity.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
import type { ParsedStaticLayer } from "./javascriptRuntimeReconciliationParsing.js";
|
||||
@@ -174,7 +178,7 @@ const nodePaths = (node: ApplicationNode): string[] => {
|
||||
paths.push(observation.evidence.location.value.path);
|
||||
}
|
||||
return paths
|
||||
.map(normalizeArtifactPath)
|
||||
.map(relativeLogicalPath)
|
||||
.filter(
|
||||
(path): path is string => path !== null && path !== "artifact-root",
|
||||
);
|
||||
@@ -221,7 +225,7 @@ const pathBelowRoot = (root: string, value: string): string | null => {
|
||||
portableRemainder.startsWith("../")
|
||||
)
|
||||
return null;
|
||||
return normalizeArtifactPath(portableRemainder);
|
||||
return relativeLogicalPath(portableRemainder);
|
||||
};
|
||||
|
||||
const absoluteFilePathStyle = (path: string): "posix" | "windows" | null => {
|
||||
@@ -250,7 +254,7 @@ const pathBelowUrlPrefix = (prefix: string, value: string): string | null => {
|
||||
)
|
||||
return null;
|
||||
try {
|
||||
return normalizeArtifactPath(
|
||||
return relativeLogicalPath(
|
||||
decodeURIComponent(candidate.pathname.slice(base.pathname.length)),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
@@ -292,17 +296,13 @@ const pathWithinPrefix = (path: string, prefix: string): boolean =>
|
||||
prefix === "" || path === prefix || path.startsWith(`${prefix}/`);
|
||||
|
||||
const safeArtifactPath = (prefix: string, suffix: string): string | null =>
|
||||
normalizeArtifactPath(prefix === "" ? suffix : posix.join(prefix, suffix));
|
||||
relativeLogicalPath(prefix === "" ? suffix : posix.join(prefix, suffix));
|
||||
|
||||
const normalizeArtifactPath = (value: string): string | null => {
|
||||
const normalized = posix
|
||||
.normalize(value.replaceAll("\\", "/"))
|
||||
.replace(/^\.\//u, "");
|
||||
const relativeLogicalPath = (value: string): string | null => {
|
||||
const normalized = normalizeJoinedLogicalPath(value);
|
||||
return normalized !== "" &&
|
||||
normalized !== "." &&
|
||||
normalized !== ".." &&
|
||||
!normalized.startsWith("/") &&
|
||||
!normalized.startsWith("../")
|
||||
!logicalPathEscapesRoot(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -611,3 +611,39 @@ describe("property mutation collection on minified bundles", () => {
|
||||
});
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("results of methods on an escaped receiver", () => {
|
||||
it("makes every method's distinct result uncertain, not only the called one", () => {
|
||||
expect(
|
||||
resultValue(
|
||||
'const first = { t: "A" }; const second = { t: "B" }; const kept = { t: "C" }; const box = { a() { return first; }, b() { return second; }, c() { return first; } }; box.a(); return [first.t, second.t, kept.t];',
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "array",
|
||||
items: [
|
||||
{ value: { status: "unknown" } },
|
||||
{ value: { status: "unknown" } },
|
||||
{ value: { status: "literal", value: "C" } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a shared result uncertain across a wide escaped receiver (#1495)", () => {
|
||||
const count = 2000;
|
||||
const methods = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `m${index}() { return shared; },`,
|
||||
).join(" ");
|
||||
const calls = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `box.m${index}().p${index} = ${index};`,
|
||||
).join(" ");
|
||||
const value = resultValue(
|
||||
`const shared = { token: "TOKEN" }; const box = { ${methods} }; ${calls} return [shared.token];`,
|
||||
);
|
||||
expect(value).toMatchObject({
|
||||
status: "array",
|
||||
items: [{ value: { status: "unknown" } }],
|
||||
});
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user