Compare commits

..
163 changed files with 1246 additions and 7970 deletions
@@ -37,9 +37,8 @@ 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, 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
Native facets also include procedure, pseudocode, assembly, callers/callees,
references, 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 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Reverse Engineer Anything
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: هندسة عكسية لأي شيء
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Alles reverse-engineeren
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Ingeniería inversa de cualquier cosa
+1 -1
View File
@@ -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) · **فارسی** · [Bahasa Indonesia](README_id.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) · **فارسی**
# REA: مهندسی معکوس هر چیزی
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA : rétro-ingénierie de tout
-307
View File
@@ -1,307 +0,0 @@
<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.**
[![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![MCP tool catalog](https://img.shields.io/badge/MCP-tool_catalog-5c4ee5?style=flat-square)](docs/mcp-contracts.md#generated-catalog)
[![Node.js requirements](https://img.shields.io/badge/Node.js-requirements-339933?style=flat-square&logo=nodedotjs&logoColor=white)](#current-status)
[![skills.sh](https://skills.sh/b/morluto/rea?style=flat-square)](https://skills.sh/morluto/rea/reverse-engineer-anything)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[![Discord](https://img.shields.io/discord/1556595354999332884?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/GkcryMnJDM)
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&amp;utm_medium=badge&amp;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.
![Alur investigasi REA: agen Anda menanyakan sesuatu tentang target lokal, REA memeriksa dan menelusurinya menggunakan alat analisis, lalu agen menggunakan kode, referensi, dan hal-hal yang belum diketahui dari hasil tersebut untuk menjelaskan, mengimplementasikan, dan menguji.](website/public/assets/figures/rea-investigation-flow.svg)
[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
[![Ilustrasi contoh penggunaan DX-Ball sound-pan, Notion clipboard-bridge, dan TH04 bullet-ring](docs/assets/rea-showcases.png)](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&amp;type=date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date&amp;theme=dark&amp;legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&amp;type=date" />
<img alt="Riwayat bintang GitHub REA" src="https://api.star-history.com/chart?repos=morluto/rea&amp;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
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA:あらゆるものをリバースエンジニアリング
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: 무엇이든 리버스 엔지니어링
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Reverse Engineer Anything
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Faça engenharia reversa de qualquer coisa
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Реверс-инжиниринг чего угодно
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Reverse Engineer Anything
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Her şeyi tersine mühendislikle inceleyin
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Реверс-інжиніринг будь-чого
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA: Kỹ thuật dịch ngược cho mọi thứ
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA:逆向分析一切
+1 -1
View File
@@ -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) · [Bahasa Indonesia](README_id.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)
# REA:逆向分析一切
+6 -7
View File
@@ -841,14 +841,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
private JsonObject instructionReference(Reference reference) {
JsonObject item = new JsonObject();
RefType type = reference.getReferenceType();
JsonObject kind = referenceKind(reference);
item.addProperty("source_address", canonicalAddress(reference.getFromAddress()));
item.addProperty("target_address", canonicalAddress(reference.getToAddress()));
item.addProperty("type", type.getName());
item.addProperty("call", type.isCall());
item.addProperty("jump", type.isJump());
item.addProperty("indirect", type.isIndirect());
item.addProperty("computed", type.isComputed());
item.addProperty("operand_index", reference.getOperandIndex());
for (String field : List.of("type", "call", "jump", "indirect", "computed",
"operand_index", "data", "read", "write", "primary", "provenance"))
item.add(field, kind.get(field));
item.addProperty("source", normalizedSource(reference.getSource()));
return item;
}
+7 -70
View File
@@ -139,35 +139,6 @@ 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.
@@ -175,55 +146,22 @@ 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` and
`PI_CODING_AGENT_DIR` (relative to the setup working directory), and the profile selected by `OMP_PROFILE` or
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute
`PI_CODING_AGENT_DIR`, 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. 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.
`mcp.rea` entry from REA.
Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from
`~/.grok/config.toml` when `GROK_HOME` is unset or empty. Setup edits that server
`~/.grok/config.toml` when `GROK_HOME` is unset. 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
@@ -263,10 +201,9 @@ 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). 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
`$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
directory. Setup leaves existing skill copies in other locations untouched.
`doctor --skill --json` verifies the selected copies against the bundled
+1 -4
View File
@@ -186,10 +186,7 @@ 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. 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
uncertainty rather than pretending to execute later writes. 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
+2 -46
View File
@@ -36,49 +36,6 @@ 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
@@ -305,9 +262,8 @@ 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, 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
Evidence. Native views select procedure, pseudocode, assembly, callers/callees,
references, 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
+8
View File
@@ -195,6 +195,14 @@ outside-memory and undecodable addresses have separate outcomes. Effective
memory base/index/displacement roles and per-instruction context mode remain
unavailable; `mode` is the program language variant.
Compact instruction references and call-target references preserve the source
address, operand index, DATA/READ/WRITE classification, primary status, provider
`provenance`, and reference `source` (for Ghidra: `analysis`, `default`, `imported`
or `user_defined`). Primary status reports the provider's chosen reference; it
does not establish literal addressing. Analyzer-inferred indexed targets remain
static references, not observed runtime reads or writes. Older evidence may omit
the additive fields, and null or omitted facts mean unavailable rather than false.
Ghidra distinguishes a return using decoded instruction p-code's `RETURN`
operation. `flow.classification_evidence` preserves the Listing flow type and
the observed return evidence; mnemonic spelling alone is not the classifier.
-8
View File
@@ -119,14 +119,6 @@ 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.`;
-15
View File
@@ -88,21 +88,6 @@ 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
+2 -2
View File
@@ -41,7 +41,6 @@
"stream-json": "3.7.0",
"write-file-atomic": "7.0.0",
"ws": "8.21.0",
"yaml": "2.9.0",
"zod": "4.4.3"
},
"bin": {
@@ -65,7 +64,8 @@
"turbo": "2.11.7",
"typescript": "7.0.2",
"vitepress": "1.6.4",
"vitest": "5.0.3"
"vitest": "5.0.3",
"yaml": "2.9.0"
},
"engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
+3 -6
View File
@@ -179,9 +179,6 @@
"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",
@@ -272,8 +269,7 @@
"write-file-atomic": "7.0.0",
"ws": "8.21.0",
"zod": "4.4.3",
"evmole": "0.9.3",
"yaml": "2.9.0"
"evmole": "0.9.3"
},
"overrides": {
"vitepress": {
@@ -297,6 +293,7 @@
"turbo": "2.11.7",
"typescript": "7.0.2",
"vitepress": "1.6.4",
"vitest": "5.0.3"
"vitest": "5.0.3",
"yaml": "2.9.0"
}
}
+43 -5
View File
@@ -13,7 +13,7 @@ for (const architecture of ["x86_64", "x86"]) {
"fixtures",
`rea-ghidra-windows${x86 ? "-x86" : ""}.${dll ? "dll" : "exe"}`,
);
const bytes = Buffer.alloc(1024);
const bytes = Buffer.alloc(dll ? 1024 : 1536);
const peOffset = 0x80;
const optionalHeader = peOffset + 24;
const optionalHeaderSize = x86 ? 0xe0 : 0xf0;
@@ -24,7 +24,7 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.writeUInt32LE(peOffset, 0x3c);
bytes.write("PE\0\0", peOffset, "binary");
bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4);
bytes.writeUInt16LE(1, peOffset + 6);
bytes.writeUInt16LE(dll ? 1 : 2, peOffset + 6);
bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20);
bytes.writeUInt16LE(
(x86 ? 0x102 : 0x22) | (dll ? 0x2000 : 0),
@@ -33,6 +33,7 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader);
bytes.writeUInt32LE(0x200, optionalHeader + 4);
if (!dll) bytes.writeUInt32LE(0x200, optionalHeader + 8);
bytes.writeUInt32LE(dll ? 0 : 0x1000, optionalHeader + 16);
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28);
@@ -41,7 +42,7 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.writeUInt32LE(0x200, optionalHeader + 36);
bytes.writeUInt16LE(6, optionalHeader + 40);
bytes.writeUInt16LE(6, optionalHeader + 48);
bytes.writeUInt32LE(0x2000, optionalHeader + 56);
bytes.writeUInt32LE(dll ? 0x2000 : 0x3000, optionalHeader + 56);
bytes.writeUInt32LE(0x200, optionalHeader + 60);
bytes.writeUInt16LE(3, optionalHeader + 68);
bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70);
@@ -60,7 +61,7 @@ for (const architecture of ["x86_64", "x86"]) {
}
bytes.write(".text", sectionTable, "ascii");
bytes.writeUInt32LE(dll ? 0x180 : 0x16, sectionTable + 8);
bytes.writeUInt32LE(dll ? 0x180 : 0x50, sectionTable + 8);
bytes.writeUInt32LE(0x1000, sectionTable + 12);
bytes.writeUInt32LE(0x200, sectionTable + 16);
bytes.writeUInt32LE(0x200, sectionTable + 20);
@@ -70,11 +71,48 @@ for (const architecture of ["x86_64", "x86"]) {
bytes.set(
dll
? [0xe8, 0x0b, 0, 0, 0, 0x83, 0xc0, 1, 0xc3]
: [0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3],
: [0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3],
0x200,
);
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
if (!dll) {
const dataSection = sectionTable + 40;
bytes.write(".data", dataSection, "ascii");
bytes.writeUInt32LE(0x20, dataSection + 8);
bytes.writeUInt32LE(0x2000, dataSection + 12);
bytes.writeUInt32LE(0x200, dataSection + 16);
bytes.writeUInt32LE(0x400, dataSection + 20);
bytes.writeUInt32LE(0xc000_0040, dataSection + 36);
bytes.writeUInt32LE(0x1122_3344, 0x400);
const referenceInstructions = x86
? [
"ba00204000", // MOV EDX, address: DATA only.
"a100204000", // MOV EAX, [address]: READ.
"a304204000", // MOV [address], EAX: WRITE.
"ba10204000", // Indexed base, unknown ECX.
"85db", // TEST EBX, EBX: two candidate bases.
"7405", // JZ over the alternate base.
"ba18204000", // MOV EDX, alternate base.
"c6040a01", // MOV byte [EDX + ECX], 1.
"ffd7", // CALL EDI: unknown target.
"c3",
]
: [
"488d15d90f0000", // LEA RDX, [RIP + displacement]: DATA.
"8b05d30f0000", // MOV EAX, [RIP + displacement]: READ.
"8905d10f0000", // MOV [RIP + displacement], EAX: WRITE.
"488d15d60f0000", // Indexed base, unknown RCX.
"85db", // TEST EBX, EBX: two candidate bases.
"7407", // JZ over the alternate base.
"488d15d30f0000", // LEA RDX, alternate base.
"c6040a01", // MOV byte [RDX + RCX], 1.
"41ffd0", // CALL R8: unknown target.
"c3",
];
bytes.set(Buffer.from(referenceInstructions.join(""), "hex"), 0x220);
}
if (dll) {
// Export both functions; an entry-point-free library is discovered by exports.
bytes.writeUInt32LE(0x1040, directories);
@@ -0,0 +1,170 @@
import assert from "node:assert/strict";
/** Verify actual reference facts on the source-owned Windows PE fixture. */
export async function verifyGhidraCompactReferences(call, x86, cli) {
const base = x86 ? 0x400000n : 0x1_4000_0000n;
const address = (offset) => `0x${(base + BigInt(offset)).toString(16)}`;
const cases = [
["data", 0x1020, 0x2000],
["read", x86 ? 0x1025 : 0x1027, 0x2000],
["write", x86 ? 0x102a : 0x102d, 0x2004],
];
const full = await call("procedure_references", {
procedure: address(0x1020),
});
const inspected = {};
for (const [kind, offset, target] of cases) {
const instruction = await call("inspect_native_instruction", {
address: address(offset),
});
inspected[kind] = instruction;
const reference = instruction.references.find(
(item) => item.target_address === address(target),
);
assert.ok(reference, `Missing ${kind} fixture reference`);
assertReference(reference, instruction.address, full.references);
assert.equal(reference.data, true);
assert.equal(reference.read, kind === "read");
assert.equal(reference.write, kind === "write");
}
const indexed = await call("inspect_native_instruction", {
address: address(x86 ? 0x103d : 0x1045),
});
for (const reference of indexed.references)
assertReference(reference, indexed.address, full.references);
assert.ok(
indexed.references.some(
(reference) =>
reference.primary === false && reference.source === "analysis",
),
"Indexed fixture lacks a non-primary analyzer reference",
);
const { direct, targets } = await verifyDirectCall(
call,
address(0x1000),
address(0x1010),
);
const indirectAddress = address(x86 ? 0x1041 : 0x1049);
const indirect = await call("inspect_native_instruction", {
address: indirectAddress,
});
const unresolved = await call("resolve_native_call_targets", {
address: indirectAddress,
});
assert.equal(indirect.flow.kind, "call");
assert.deepEqual(indirect.references, []);
assert.equal(unresolved.status, "unresolved");
assert.deepEqual(unresolved.targets, []);
const returned = await verifyEmptyReferences(
call,
address(x86 ? 0x1043 : 0x104c),
);
if (cli !== undefined) {
for (const instruction of [inspected.write, indexed, direct, returned])
assert.deepEqual(
await cli("inspect-native-instruction", instruction.address),
instruction,
);
assert.deepEqual(
await cli("resolve-native-call-targets", direct.address),
targets,
);
assert.deepEqual(
await cli("resolve-native-call-targets", indirectAddress),
unresolved,
);
}
return {
data_read_write: true,
source_primary_provenance: true,
full_reference_parity: true,
direct_call_reference_parity: true,
indexed_reference_count: indexed.references.length,
indexed_non_primary_count: indexed.references.filter(
(reference) => reference.primary === false,
).length,
indexed_non_primary_analyzer_reference: true,
indexed_reference_kinds: indexed.references.map(
({ type, read, write, primary, source }) => ({
type,
read,
write,
primary,
source,
}),
),
targetless_indirect: true,
empty_and_unavailable: true,
cli_mcp_parity: cli !== undefined,
};
}
async function verifyDirectCall(call, entry, callee) {
const direct = await call("inspect_native_instruction", { address: entry });
const targets = await call("resolve_native_call_targets", { address: entry });
assert.equal(targets.status, "direct");
assert.equal(targets.targets[0].address, callee);
assert.deepEqual(
targets.targets[0].references,
direct.references.filter((item) => item.call),
);
const full = await call("procedure_references", { procedure: entry });
for (const reference of direct.references)
assertReference(reference, entry, full.references);
return { direct, targets };
}
async function verifyEmptyReferences(call, address) {
const returned = await call("inspect_native_instruction", { address });
assert.equal(returned.flow.kind, "return");
assert.deepEqual(returned.references, []);
assert.equal(
(await call("resolve_native_call_targets", { address })).status,
"not-call",
);
const outside = await call("inspect_native_instruction", { address: "0x0" });
assert.equal(outside.status, "outside-memory");
assert.deepEqual(outside.references, []);
assert.equal(
(await call("resolve_native_call_targets", { address: "0x0" })).status,
"unavailable",
);
return returned;
}
function assertReference(reference, source, full) {
assert.equal(reference.source_address, source);
assert.equal(reference.provenance, "ghidra-reference-manager");
assert.equal(typeof reference.primary, "boolean");
assert.equal(typeof reference.source, "string");
assert.ok(reference.source.length > 0);
const edge = full.find(
(item) =>
item.source_address === source &&
item.target_address === reference.target_address &&
item.kind.operand_index === reference.operand_index,
);
assert.ok(edge, "Compact reference lacks its matching full reference");
for (const field of [
"type",
"call",
"jump",
"indirect",
"computed",
"operand_index",
"data",
"read",
"write",
"primary",
"provenance",
])
assert.equal(
reference[field],
edge.kind[field],
`Reference ${field} differs from its full representation`,
);
}
-456
View File
@@ -1,456 +0,0 @@
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;
-4
View File
@@ -87,10 +87,6 @@ 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",
+1 -10
View File
@@ -13,16 +13,7 @@ export async function verifyPackageInstall({
}) {
await exec(
"npm",
[
"install",
"--global",
"--ignore-scripts",
"--omit=dev",
"--install-strategy=nested",
"--prefix",
prefix,
tarball,
],
["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball],
{ env: environment },
);
const cli = join(prefix, "bin", "rea");
@@ -2,6 +2,7 @@
import assert from "node:assert/strict";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
@@ -358,6 +359,36 @@ try {
await call("inspect_native_data_type", { type: "/undefined8" });
await call("inspect_native_instruction", { address: procedure.address });
await call("resolve_native_call_targets", { address: procedure.address });
// Explicit caller-selected targets need not contain our fixture instructions.
const controlledTargets = [
"rea-ghidra-windows.exe",
"rea-ghidra-windows-x86.exe",
].map((name) => resolve("build", "fixtures", name));
if (controlledTargets.includes(target))
report.compactReferences = await verifyGhidraCompactReferences(
call,
target === controlledTargets[1],
async (command, address) => {
const result = await exec(
process.execPath,
[entry, command, target, address, "--provider", "ghidra", "--json"],
{
env: environment,
cwd: callerDirectory,
timeout: 360_000,
maxBuffer: 8 * 1024 * 1024,
},
);
const evidence = JSON.parse(result.stdout);
assert.ok(evidence.error === undefined, `CLI ${command} failed`);
const contract = TOOL_CONTRACTS.find(
({ name }) => name === command.replaceAll("-", "_"),
);
assert.ok(contract, `Missing CLI ${command} contract`);
contract.outputSchema.parse(evidence);
return evidence.normalized_result;
},
);
const expected = (
await import(
pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js"))
-403
View File
@@ -1,403 +0,0 @@
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;
+10
View File
@@ -24,6 +24,7 @@ import { windowsP0Capabilities } from "../dist/ghidra/GhidraProviderCapabilities
import { GHIDRA_SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs";
const verifierRun = createVerifierRun();
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
@@ -247,6 +248,14 @@ try {
`Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`,
);
// The reference controls occupy the EXE fixture's additional function.
// DLL fixtures retain their independent exported-caller/return controls.
const compactReferences = dllFixture
? null
: await verifyGhidraCompactReferences(
functionOperation,
architecture === "x86",
);
report = {
ok: true,
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
@@ -261,6 +270,7 @@ try {
transport: "authenticated-loopback-tcp",
operations: [...observed].sort((left, right) => left.localeCompare(right)),
cleanup: "complete",
compact_references: compactReferences,
native_controls: {
job_object: true,
protected_dacl: true,
+3 -13
View File
@@ -29,7 +29,7 @@ const LINK_MODES = [
{
mode: "dyld-info",
flags: ["-Wl,-no_fixup_chains"],
fixups: "LC_DYLD_INFO rebase opcodes were not decoded",
fixups: "LC_DYLD_INFO bind opcodes",
swift: true,
},
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
@@ -135,12 +135,7 @@ const checkObjc = (metadata, mode) => {
item.selector === "reaFixtureLength",
) ||
objcCoverage?.status !== "complete" ||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
(mode.mode === "dyld-info") !==
(fixupCoverage(metadata)?.reason?.includes(
"LC_DYLD_INFO bind opcodes",
) ===
true)
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
)
throw new Error(
`Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
@@ -170,12 +165,7 @@ const checkSwift = (metadata, mode) => {
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
item.implementation_address !== null,
) ||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
(mode.mode === "dyld-info") !==
(fixupCoverage(metadata)?.reason?.includes(
"LC_DYLD_INFO bind opcodes",
) ===
true)
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
)
throw new Error(
`Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
-171
View File
@@ -1,171 +0,0 @@
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,9 +1,4 @@
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,
@@ -75,7 +70,6 @@ export const clientConfigurationServersKey = (
switch (format) {
case "toml":
case "grok":
case "hermes":
return "mcp_servers";
case "opencode":
return "mcp";
@@ -99,12 +93,6 @@ 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 {};
@@ -1277,8 +1265,6 @@ 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 (
@@ -1317,34 +1303,6 @@ 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,
@@ -1373,7 +1331,6 @@ export const clientRegistrationEntry = (
};
case "vscode":
case "omp":
case "pi":
return {
type: "stdio",
command: executable,
@@ -1396,15 +1353,6 @@ 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,
@@ -1413,45 +1361,3 @@ 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;
};
-108
View File
@@ -1,108 +0,0 @@
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: {} };
};
+31 -72
View File
@@ -1,14 +1,10 @@
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import {
effectiveClientConfiguration,
readClientConfigurationFiles,
} from "./ClientConfigurationFiles.js";
import {
clientServerForcedEnabled,
clientServerListedDisabled,
effectiveClientServer,
parseClientConfiguration,
} from "./ClientConfigurationDocument.js";
import { access } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { z } from "zod";
@@ -20,7 +16,6 @@ import {
npxRegistrationCommand,
} from "./ClientRegistrationIdentity.js";
import {
clientEvidencePaths,
manualRegistrationRemediation,
supportedClients,
} from "./SupportedClients.js";
@@ -83,19 +78,29 @@ export const readClientRegistrationStatuses = async (
for (const client of supportedClients(
home,
options.platform,
options.environment,
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,
},
)) {
if (client.configPathError !== undefined) {
statuses.push({
client: client.name,
config_path: client.configPath,
command: [],
state: "invalid",
remediation: client.configPathError,
});
if (
!(await exists(client.markerPath)) &&
!(await exists(client.configPath))
)
continue;
}
if (!(await clientEvidencePresent(client))) continue;
const manualRemediation = manualRegistrationRemediation(client.name);
if (client.format === "unsupported") {
if (manualRemediation !== undefined)
@@ -109,36 +114,8 @@ export const readClientRegistrationStatuses = async (
continue;
}
try {
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 content = await readFile(client.configPath, "utf8");
const parsed = parseClientConfiguration(content, client.format);
const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
if (raw === undefined) {
statuses.push(
@@ -163,14 +140,9 @@ export const readClientRegistrationStatuses = async (
options.platform ?? process.platform,
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
) &&
!clientServerListedDisabled(
parsed,
PRODUCT_IDENTITY.mcpServerKey,
) &&
policyBlock === undefined
!clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey)
? "aligned"
: "stale",
policyBlock,
),
);
} catch (cause: unknown) {
@@ -197,7 +169,7 @@ const registrationAligned = (
): boolean => {
const command = [registration.command, ...registration.args];
if (
(client.format !== "grok" && registration.disabled === true) ||
registration.disabled === true ||
(registration.enabled === false && !forcedEnabled)
)
return false;
@@ -210,9 +182,9 @@ const registrationAligned = (
)
return false;
if (client.format === "vscode" && registration.type !== "stdio") return false;
// OMP and Pi infer stdio for a command entry without an explicit type.
// OMP infers stdio for a command entry without an explicit type.
if (
(client.format === "omp" || client.format === "pi") &&
client.format === "omp" &&
registration.type !== undefined &&
registration.type !== "stdio"
)
@@ -274,7 +246,7 @@ const parseRegistration = (
if (client.format === "commandcode" && registration.transport !== "stdio")
throw new TypeError("Expected an stdio registration");
if (
(client.format === "omp" || client.format === "pi") &&
client.format === "omp" &&
registration.type !== undefined &&
registration.type !== "stdio"
)
@@ -290,17 +262,10 @@ 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: policyRemediation ?? remediation,
};
: { client, config_path: configPath, command, state, remediation };
const unavailableStatus = (
client: string,
@@ -314,12 +279,6 @@ 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 {
+2 -2
View File
@@ -16,6 +16,7 @@ import {
type LinuxDistribution,
} from "./LinuxHopper.js";
import {
claudeCodeSkillsDirectory,
readInstalledSkillIdentity,
type InstalledSkillIdentity,
} from "./SetupSkill.js";
@@ -440,8 +441,7 @@ export const systemDoctorHost = (
readInstalledSkillIdentity(
homeDirectory,
clientIds,
environment,
platform,
claudeCodeSkillsDirectory(homeDirectory, environment),
),
clientRegistrations: () =>
readClientRegistrationStatuses(homeDirectory, undefined, {
-202
View File
@@ -1,202 +0,0 @@
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);
};
@@ -1,10 +1,6 @@
import { createHash } from "node:crypto";
import { posix } from "node:path";
import {
logicalPathEscapesRoot,
normalizeJoinedLogicalPath,
} from "../domain/artifactIdentity.js";
import {
classifyReferenceSourcePath,
detectReferenceSourceLanguage,
@@ -87,10 +83,10 @@ const resolveInternalSpecifier = (
): { to: string; resolution: "internal" | "unresolved" } => {
if (posix.isAbsolute(specifier))
return { to: specifier, resolution: "unresolved" };
const normalized = normalizeJoinedLogicalPath(
const normalized = posix.normalize(
posix.join(posix.dirname(fromPath), specifier),
);
if (logicalPathEscapesRoot(normalized))
if (normalized.startsWith("../") || normalized === "..")
return { to: normalized, resolution: "unresolved" };
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
const candidates = suffixes.flatMap((suffix) => [
+1 -4
View File
@@ -1,4 +1,3 @@
import { skillDestinations } from "./SetupSkill.js";
import {
type ClientConfigurationInspection,
type ClientConfigurationResult,
@@ -19,9 +18,7 @@ import { setupRegistrationCommand } from "./SetupHost.js";
export class FakeSetupHost implements SetupHost {
readonly platform: NodeJS.Platform;
readonly homeDirectory = "/fixture/home";
skillDestinations(clientIds: readonly string[]) {
return skillDestinations(this.homeDirectory, clientIds);
}
readonly claudeCodeSkillsDirectory = "/fixture/home/.claude/skills";
readonly registrationCommand: readonly string[];
nodeVersion = "24.18.0";
version: string | undefined = "14.5";
+1 -70
View File
@@ -1,5 +1,3 @@
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js";
import {
clientRegistrationEntry,
clientConfigurationValuesEqual,
@@ -35,8 +33,6 @@ 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);
@@ -48,15 +44,6 @@ 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,
);
@@ -74,13 +61,6 @@ 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;
@@ -174,14 +154,10 @@ 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);
const policy = await readClientPolicyBlock(
client,
return registrationCurrent(
parsed,
clientConfigurationDesired(
client,
@@ -190,19 +166,6 @@ 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;
@@ -216,25 +179,7 @@ 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,
);
@@ -257,20 +202,6 @@ 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 -19
View File
@@ -14,13 +14,9 @@ import {
systemLinuxHopperInstallHost,
} from "./LinuxHopper.js";
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
import {
clientEvidencePaths,
supportedClients,
type SetupClient,
} from "./SupportedClients.js";
import {
skillDestinations,
claudeCodeSkillsDirectory as resolveClaudeCodeSkillsDirectory,
canonicalSkillNeedsInstall,
installCanonicalSkill,
} from "./SetupSkill.js";
@@ -94,11 +90,14 @@ export const systemSetupHost = (
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
const platform = doctorHost.platform;
const { homeDirectory } = doctorHost;
const claudeSkillsDirectory = resolveClaudeCodeSkillsDirectory(
homeDirectory,
environment,
);
return {
platform,
homeDirectory,
skillDestinations: (clientIds) =>
skillDestinations(homeDirectory, clientIds, environment, platform),
claudeCodeSkillsDirectory: claudeSkillsDirectory,
registrationCommand: setupRegistrationCommand(
platform,
environment.npm_command === "exec",
@@ -154,11 +153,10 @@ export const systemSetupHost = (
canonicalSkillNeedsInstall(
homeDirectory,
clientIds,
environment,
platform,
claudeSkillsDirectory,
),
installSkill: (clientIds) =>
installCanonicalSkill(homeDirectory, clientIds, environment, platform),
installCanonicalSkill(homeDirectory, clientIds, claudeSkillsDirectory),
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
};
};
@@ -171,20 +169,17 @@ export const detectClients = async (
): Promise<readonly SetupClient[]> => {
const detected: SetupClient[] = [];
for (const candidate of supportedClients(home, platform, environment)) {
if (candidate.configPathError !== undefined) continue;
if (await clientEvidencePresent(candidate)) detected.push(candidate);
const [hasConfig, hasMarker] = await Promise.all([
exists(candidate.configPath),
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
]);
if (hasConfig || hasMarker) 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);
+12 -5
View File
@@ -5,7 +5,7 @@ import {
linuxHopperInstallDisclosure,
type LinuxPackageFamily,
} from "./LinuxHopper.js";
import type { SkillDestination } from "./SetupSkill.js";
import { skillDestinations } 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,
skillDestinations: input.host.skillDestinations(input.skillClientIds),
claudeCodeSkillsDirectory: input.host.claudeCodeSkillsDirectory,
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 skillDestinations: readonly SkillDestination[];
readonly claudeCodeSkillsDirectory: string;
readonly clients: readonly {
readonly client: SetupClient;
readonly operation: "create" | "update";
@@ -268,8 +268,15 @@ const setupPlan = (input: {
...(backupPath === undefined ? {} : { backupPath }),
})),
...(input.installSkill
? input.skillDestinations.map(({ client, path }): SetupAction => ({
id: client === "shared" ? "install_skill" : `install_skill:${client}`,
? skillDestinations(
input.homeDirectory,
input.skillClientIds,
input.claudeCodeSkillsDirectory,
).map(({ client, path }): SetupAction => ({
id:
client === "claude_code"
? "install_skill:claude_code"
: "install_skill",
kind: "install_skill",
label:
client === "claude_code"
+44 -27
View File
@@ -3,7 +3,6 @@ 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",
@@ -22,7 +21,7 @@ interface CanonicalSkillFile {
/** Managed skill location selected for a client family. */
export interface SkillDestination {
readonly client: string;
readonly client: "shared" | "claude_code";
readonly path: string;
}
@@ -33,19 +32,44 @@ 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,
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),
}),
);
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),
},
]
: []),
];
};
const readOptionalText = async (path: string): Promise<string | undefined> => {
try {
@@ -60,11 +84,10 @@ const readOptionalText = async (path: string): Promise<string | undefined> => {
const canonicalSkillFiles = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
claudeSkillsDirectory?: string,
): Promise<readonly CanonicalSkillFile[]> =>
Promise.all(
skillDestinations(home, clientIds, environment, platform).flatMap(
skillDestinations(home, clientIds, claudeSkillsDirectory).flatMap(
({ path: root }) =>
SKILL_FILES.map(async (relativePath) => {
const destination = join(root, relativePath);
@@ -87,12 +110,11 @@ const canonicalSkillFiles = async (
export const canonicalSkillNeedsInstall = async (
home: string,
clientIds: readonly string[] = [],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
claudeSkillsDirectory?: string,
): Promise<boolean> => {
try {
return (
await canonicalSkillFiles(home, clientIds, environment, platform)
await canonicalSkillFiles(home, clientIds, claudeSkillsDirectory)
).some(({ content, original }) => original !== content);
} catch (cause: unknown) {
// Unreadable skill state fails open to install so setup can repair it.
@@ -117,16 +139,14 @@ const restoreSkillFiles = async (
export const installCanonicalSkill = async (
home: string,
clientIds: readonly string[] = [],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
claudeSkillsDirectory?: string,
): Promise<"installed" | "unchanged" | "failed"> => {
let changed: readonly CanonicalSkillFile[] = [];
try {
const canonical = await canonicalSkillFiles(
home,
clientIds,
environment,
platform,
claudeSkillsDirectory,
);
changed = canonical.filter(({ content, original }) => original !== content);
if (changed.length === 0) return "unchanged";
@@ -159,14 +179,12 @@ export const installCanonicalSkill = async (
export const readInstalledSkillIdentity = async (
home: string,
clientIds: readonly string[],
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
claudeSkillsDirectory?: string,
): Promise<InstalledSkillIdentity | undefined> => {
const destinations = skillDestinations(
home,
clientIds,
environment,
platform,
claudeSkillsDirectory,
);
let content: string | undefined;
for (const destination of destinations) {
@@ -184,8 +202,7 @@ export const readInstalledSkillIdentity = async (
canonical: !(await canonicalSkillNeedsInstall(
home,
clientIds,
environment,
platform,
claudeSkillsDirectory,
)),
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
+1 -2
View File
@@ -1,6 +1,5 @@
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,
@@ -43,7 +42,7 @@ export type ClientConfigurationInspection =
export interface SetupHost {
readonly platform: NodeJS.Platform;
readonly homeDirectory: string;
skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
readonly claudeCodeSkillsDirectory: string;
readonly registrationCommand: readonly string[];
readonly nodeVersion: string;
macosVersion(): Promise<string | undefined>;
+62 -327
View File
@@ -1,5 +1,4 @@
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
import { fileURLToPath } from "node:url";
import { isAbsolute, join, resolve } from "node:path";
import { lstatSync } from "node:fs";
/** One supported client configuration location. */
@@ -7,19 +6,6 @@ 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"
@@ -30,8 +16,6 @@ export interface SetupClient {
| "commandcode"
| "grok"
| "omp"
| "pi"
| "hermes"
| "unsupported";
}
@@ -40,7 +24,21 @@ type ClientPath = readonly string[] | ((paths: ClientPathContext) => string);
interface ClientPathContext {
readonly home: string;
readonly platform: NodeJS.Platform;
readonly env: Readonly<NodeJS.ProcessEnv>;
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;
};
}
interface ClientDefinition {
@@ -49,11 +47,6 @@ 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 = ({
@@ -91,7 +84,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 => {
@@ -113,51 +106,7 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
};
const grokDirectory = ({ home, env }: ClientPathContext): string =>
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}`);
};
env.GROK_HOME ?? join(home, ".grok");
/** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */
const grokBotDirectory = ({ home, env }: ClientPathContext): string => {
@@ -203,49 +152,17 @@ 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. Relative overrides use the setup process working directory.
* PI_CODING_AGENT_DIR. OMP resolves a relative agent directory against each
* process's working directory, so only an absolute override names one file.
*/
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 ? 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;
return override !== undefined && override !== "" && isAbsolute(override)
? override
: join(root, "agent");
};
const copilotDirectory = ({ home, env }: ClientPathContext): string =>
@@ -254,66 +171,21 @@ const copilotDirectory = ({ home, env }: ClientPathContext): string =>
const commandCodeDirectory = ({ home }: ClientPathContext): string =>
join(home, ".commandcode");
/**
* 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 opencodeDirectory = ({ home, env }: ClientPathContext): string =>
join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode");
const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
// 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));
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));
return (
[...candidates].reverse().find((path) => {
candidates.find((path) => {
try {
lstatSync(path);
return true;
@@ -325,60 +197,21 @@ const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
);
}
}) ??
resolve(
overrideDirectory !== undefined
? join(overrideDirectory, "opencode.json")
: context.env.OPENCODE_CONFIG ||
join(opencodeDirectory(context), "opencode.json"),
)
candidates[0] ??
join(directory, "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(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
: 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,
@@ -410,13 +243,8 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
{
name: "gemini_cli",
displayName: "Gemini CLI",
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"),
configPath: [".gemini", "settings.json"],
markerPath: [".gemini"],
format: "json",
},
{
@@ -438,14 +266,12 @@ 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",
@@ -498,27 +324,6 @@ 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",
@@ -528,23 +333,6 @@ 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);
@@ -552,81 +340,28 @@ const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
export const supportedClients = (
home: string,
platform: NodeJS.Platform = process.platform,
env: ClientPathContext["env"] = process.env,
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,
},
): readonly SetupClient[] => {
const context = { home, platform, env };
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),
);
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,
}));
};
+15 -88
View File
@@ -15,7 +15,7 @@ import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { skillDestinations } from "./SetupSkill.js";
import { claudeCodeSkillsDirectory, skillDestinations } from "./SetupSkill.js";
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
import {
@@ -124,12 +124,11 @@ export const runUninstall = async (
status: "retained",
detail: "Hopper is not owned by REA uninstall.",
});
const reported = collapseUninstallItems(items);
return {
status: reported.some(({ status }) => status === "failed")
status: items.some(({ status }) => status === "failed")
? "failed"
: "complete",
items: reported,
items,
};
};
@@ -172,16 +171,7 @@ export const systemUninstallHost = (
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
return {
clients: () =>
Promise.resolve(
supportedClients(home, platform, environment).flatMap((client) =>
client.configPaths === undefined
? [client]
: client.configPaths.map((configPath) => ({
...client,
configPath,
})),
),
),
Promise.resolve(supportedClients(home, platform, environment)),
inspectClient: async (client) => {
const read = await readClientConfiguration(client, fileSystem);
return read.kind === "item" && read.item.status === "failed"
@@ -189,8 +179,7 @@ export const systemUninstallHost = (
: undefined;
},
removeClient: (client) => removeClient(client, fileSystem),
removeSkill: () =>
removeManagedSkills(home, fileSystem, environment, platform),
removeSkill: () => removeManagedSkills(home, fileSystem, environment),
purgeData: async () => [
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
@@ -211,8 +200,6 @@ 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(
@@ -417,16 +404,18 @@ const removeManagedSkills = async (
home: string,
fileSystem: UninstallFileSystem,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
): Promise<UninstallItem> => {
const results = await Promise.all(
skillDestinations(home, undefined, environment, platform).map(
({ client, path }) =>
removeManagedPath(
path,
client === "claude_code" ? "Claude Code skill" : "skill",
fileSystem,
),
skillDestinations(
home,
undefined,
claudeCodeSkillsDirectory(home, environment),
).map(({ client, path }) =>
removeManagedPath(
path,
client === "claude_code" ? "Claude Code skill" : "skill",
fileSystem,
),
),
);
const failed = results.find(({ status }) => status === "failed");
@@ -462,68 +451,6 @@ 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();
-9
View File
@@ -7,13 +7,6 @@ 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,
@@ -84,7 +77,5 @@ export const parseUtf8Json = (
cause,
};
}
if (text.startsWith("\uFEFF"))
return { ok: false, error: JSON_BYTE_ORDER_MARK_MESSAGE, cause: undefined };
return safeParseJson(text);
};
@@ -67,36 +67,6 @@ 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,10 +1,6 @@
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,
@@ -330,8 +326,12 @@ const confineCandidate = (
input: ResolveArtifactPathInput,
candidate: string,
): string | ArtifactPathResolution => {
const normalized = normalizeJoinedLogicalPath(candidate);
if (logicalPathEscapesRoot(normalized))
const normalized = posix.normalize(candidate);
if (
normalized === ".." ||
normalized.startsWith("../") ||
normalized.startsWith("/")
)
return unresolvedOutcome(input, "rejected", [
"The resolved candidate escapes the canonical artifact root.",
]);
+5 -17
View File
@@ -73,27 +73,15 @@ 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:
archiveDecode?.status === "partial" ||
archiveDecode?.status === "complete" ||
archiveDecode?.status === "unsupported"
? archiveDecode.status
: documentsFound
? "complete"
: "unsupported",
status: decoded.data.documents.length > 0 ? "complete" : "unsupported",
reason:
archiveDecode?.reason ??
(documentsFound
decoded.data.documents.length > 0
? null
: "no_compiled_interface_builder_archives_found"),
examined: archiveDecode?.examined ?? decoded.data.documents.length,
omitted: archiveDecode?.omitted ?? 0,
: "no_compiled_interface_builder_archives_found",
examined: decoded.data.documents.length,
omitted: 0,
});
const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges);
graph = bounded.graph;
@@ -6,7 +6,6 @@ import {
captureProcessScenarioFile,
projectProcessCliError,
} from "./ProcessCli.js";
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
const roots: string[] = [];
afterEach(async () => {
@@ -88,14 +87,4 @@ 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.`,
});
});
});
-3
View File
@@ -18,7 +18,6 @@ import { parseProcessCapture } from "../../domain/process/processCaptureParsing.
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 {
@@ -182,8 +181,6 @@ 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) {
-83
View File
@@ -1,83 +0,0 @@
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([]);
});
});
+61 -87
View File
@@ -1,6 +1,5 @@
import { posix } from "node:path";
import { logicalPathNfc } from "../domain/artifactIdentity.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
@@ -32,7 +31,7 @@ export const normalizeArtifactPath = (input: string): string => {
"path",
`Artifact path is absolute or unsafe: ${JSON.stringify(input)}`,
);
const normalized = logicalPathNfc(input).replace(/\/+$/u, "");
const normalized = input.normalize("NFC").replace(/\/+$/u, "");
const parts = normalized.split("/");
if (
normalized.length === 0 ||
@@ -116,39 +115,16 @@ export const annotateEnglishUnicodeCaseCollisions = (
occurrences.map(({ logical_path }) => logical_path),
);
if (collisions.size === 0) return undefined;
for (const occurrence of occurrences)
for (const collision of collisions.get(occurrence.logical_path) ?? [])
occurrence.limitations.push(
caseCollisionLimitation(occurrence.logical_path, collision),
);
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}`,
);
}
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.
@@ -171,7 +147,7 @@ export const destinationCaseCollisionMessage = (
const englishUnicodeCaseCollisions = (
paths: readonly string[],
): ReadonlyMap<string, readonly CaseCollision[]> => {
): ReadonlyMap<string, readonly string[]> => {
const root = emptyCaseNode();
for (const path of paths) {
if (path.length === 0 || path === ".") continue;
@@ -190,62 +166,60 @@ const englishUnicodeCaseCollisions = (
}
node.terminals.push(path);
}
// 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 collisions = new Map<string, Set<string>>();
recordCaseCollisions(root, collisions);
return new Map(
[...collisions.entries()].map(([path, others]) => [
path,
[...others].sort(compareUnicodeCodePoints),
]),
);
};
/** Collisions on one path, linked from the deepest segment to the root. */
interface CollisionChain {
readonly collision: CaseCollision;
readonly parent: CollisionChain | undefined;
}
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 collisionsAlong = (chain: CollisionChain): CaseCollision[] => {
const collisions: CaseCollision[] = [];
let link: CollisionChain | undefined = chain;
while (link !== undefined) {
collisions.push(link.collision);
link = link.parent;
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);
}
return collisions.reverse();
};
const terminalsUnder = (node: CaseNode): readonly string[] => {
const paths = [...node.terminals];
for (const child of node.bySpelling.values())
paths.push(...terminalsUnder(child));
return paths;
};
+14 -7
View File
@@ -194,8 +194,10 @@ class ArtifactClient implements AnalysisClient {
this.target.sourcePath ?? this.target.path,
result.manifest,
),
// Each extracted file's path is already in artifacts.
locations: [],
locations: result.artifacts.map(({ relative_path: path }) => ({
kind: "artifact-path" as const,
path,
})),
}),
);
}
@@ -209,8 +211,10 @@ class ArtifactClient implements AnalysisClient {
this.target.sourcePath ?? this.target.path,
result.manifest,
),
// Each occurrence's logical path is already in the result.
locations: [],
locations: result.occurrences.map(({ logical_path: path }) => ({
kind: "artifact-path" as const,
path,
})),
}),
);
} catch (cause: unknown) {
@@ -319,14 +323,17 @@ 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,
// The nested inventory already names every occurrence's logical path.
locations: [],
locations,
});
const result = createArtifactInspection(inventoryEvidence);
await options?.progress?.report({
@@ -340,7 +347,7 @@ class ArtifactClient implements AnalysisClient {
rawResult: null,
limitations: result.limitations,
subject,
locations: [],
locations,
}),
);
}
@@ -147,45 +147,10 @@ const estimateXmlPlistExpansion = (
return decodedSourceBytes * 2 + elementCount * 128;
};
/** 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 = (
/** Count every expanded binary-plist reference before calling plist.parseBinary. */
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");
@@ -207,19 +172,20 @@ export 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 > tableEnd
offsetTable + objectCount * offsetSize > trailer
)
throw new TypeError("binary plist trailer or object table is invalid");
let estimate = bytes.length * 2 + objectCount * 16;
if (estimate > maxBytes)
throw fail("expansion", `binary plist object table exceeds ${budget}`);
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist object table exceeds the aggregate Interface Builder decode budget",
);
const active = new Set<number>();
const pending: Array<{ object: number; depth: number; exit: boolean }> = [
@@ -235,20 +201,26 @@ export const estimateBinaryPlistExpansion = (
}
pendingObjectCount -= 1;
if (entry.depth > 128)
throw fail("depth", `binary plist nesting exceeds ${budget}`);
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist nesting exceeds the aggregate Interface Builder decode budget",
);
if (active.has(entry.object))
throw fail("cycle", `binary plist reference cycle exceeds ${budget}`);
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist reference cycle exceeds the aggregate Interface Builder decode budget",
);
estimate += 96;
if (estimate > maxBytes)
throw fail(
"expansion",
`binary plist reference expansion exceeds ${budget}`,
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist reference expansion exceeds the aggregate Interface Builder decode budget",
);
const offset = readInteger(
offsetTable + entry.object * offsetSize,
offsetSize,
);
if (offset < 8 || offset >= objectsEnd)
if (offset < 8 || offset >= offsetTable)
throw new TypeError("binary plist object offset is invalid");
const marker = bytes[offset] ?? 0;
const type = marker >> 4;
@@ -262,31 +234,31 @@ export const estimateBinaryPlistExpansion = (
const integerSize = 1 << (extMarker & 0x0f);
size = readInteger(cursor, integerSize);
cursor += integerSize;
if (size < 0 || cursor > objectsEnd)
if (size < 0 || cursor > offsetTable)
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 > objectsEnd)
if (cursor + byteLength > offsetTable)
throw new TypeError("binary plist scalar object is truncated");
estimate += type === 4 ? size + 4 * Math.ceil(size / 3) : byteLength * 2;
if (estimate > maxBytes)
throw fail(
"expansion",
`binary plist scalar expansion exceeds ${budget}`,
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist scalar expansion exceeds the aggregate Interface Builder decode 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 > objectsEnd)
if (!Number.isSafeInteger(refsEnd) || refsEnd > offsetTable)
throw new TypeError("binary plist reference table is truncated");
const availableNodes = Math.floor((maxBytes - estimate) / 96);
if (childCount > availableNodes - pendingObjectCount)
throw fail(
"expansion",
`binary plist reference expansion exceeds ${budget}`,
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist reference expansion exceeds the aggregate Interface Builder decode budget",
);
active.add(entry.object);
pending.push({ object: entry.object, depth: entry.depth, exit: true });
@@ -103,88 +103,3 @@ 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"],
]);
});
});
-17
View File
@@ -28,11 +28,8 @@ 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 = (
@@ -48,20 +45,6 @@ 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,14 +249,16 @@ 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, occurrence),
edge_id: artifactEdgeId(semantic),
...semantic,
producer: occurrence.entry_kind === "slice" ? (producer ?? null) : null,
ordinal: edges.length,
+19 -45
View File
@@ -6,10 +6,7 @@ import {
import type { FileHandle } from "node:fs/promises";
import type { Stats } from "node:fs";
import {
classifyArtifactBytes,
classifyArtifactContent,
} from "./ArtifactGraphConstruction.js";
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js";
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
import {
@@ -20,6 +17,17 @@ 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,
@@ -39,7 +47,9 @@ export const classifyAndHashRoot = async (
"integrity",
`Root artifact changed before inventory: ${path}`,
);
const format = await classifyRootFormat(path, handle);
const format =
classifyContainerExtension(path) ??
(await classifyOpenedRoot(path, handle));
const digest = await hashStableRootArtifactHandle(
path,
handle,
@@ -69,50 +79,14 @@ const openRootFile = async (
}
};
const classifyRootFormat = async (
const classifyOpenedRoot = 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);
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";
if (hasZipSignature(prefix)) return "zip";
return classifyArtifactContent(path, prefix, (await handle.stat()).size)
.format;
};
+1 -10
View File
@@ -5,7 +5,6 @@ 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,
@@ -40,15 +39,7 @@ export const inventoryLimitations = (
format: ArtifactOccurrence["artifact_format"],
reader: ArtifactReader | undefined,
): string[] => {
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 (reader !== undefined) return [];
if (format === "dmg" || format === "pkg")
return [
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
@@ -56,39 +56,4 @@ 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,23 +198,6 @@ 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);
+1 -2
View File
@@ -387,8 +387,7 @@ 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]);
// HTML keeps the first declaration. A later repeat must not move dom_urls.
if (!pairs.has(name)) pairs.set(name, value);
pairs.set(name, value);
}
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
for (const attribute of domUrlAttributes) {
+2 -10
View File
@@ -47,7 +47,6 @@ 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);
};
@@ -81,7 +80,6 @@ export const handleScriptParsed = (
state.completeness.exclude("scripts", exclusionReasonForUrl(rawUrl));
return;
}
const executionContext = executionContextKey(params.executionContextId);
const baseScript = {
scriptId,
rawUrl,
@@ -89,11 +87,7 @@ export const handleScriptParsed = (
origin: sanitized.origin,
...scriptMetadataValues(params),
language: cdpStringValue(params.scriptLanguage) ?? null,
executionContextKey: executionContext,
frameId:
executionContext === null
? null
: (state.executionContextFrames.get(executionContext) ?? null),
executionContextKey: executionContextKey(params.executionContextId),
};
const baseBytes = scriptMetadataStringBytes(baseScript);
const sourceMapDeclared =
@@ -137,7 +131,6 @@ 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) +
@@ -145,8 +138,7 @@ const scriptMetadataStringBytes = (script: {
stringByteLength(script.origin) +
stringByteLength(script.hash) +
stringByteLength(script.language) +
stringByteLength(script.executionContextKey) +
stringByteLength(script.frameId);
stringByteLength(script.executionContextKey);
const stringByteLength = (value: string | null): number =>
value === null ? 0 : Buffer.byteLength(value, "utf8");
-2
View File
@@ -12,8 +12,6 @@ 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];
+4 -2
View File
@@ -159,8 +159,10 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
script: CapturedScript,
allowedFrameIds: ReadonlySet<string>,
): string | null {
return script.frameId !== null && allowedFrameIds.has(script.frameId)
? script.frameId
if (script.executionContextKey === null) return null;
const frameId = this.executionContextFrames.get(script.executionContextKey);
return frameId !== undefined && allowedFrameIds.has(frameId)
? frameId
: null;
}
@@ -3,22 +3,12 @@ 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) => {
+1 -11
View File
@@ -182,13 +182,6 @@ 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,
@@ -196,10 +189,7 @@ const acquireAttachedScenario = (
if (signal?.aborted === true)
return Promise.reject(new BrowserObservationError(OPERATION, "cancelled"));
const key = attachedScenarioLeaseKey(
scenario.cdp_endpoint,
scenario.target_id,
);
const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]);
const admission =
attachedScenarioAdmissions.get(key) ??
({
+16 -64
View File
@@ -2,26 +2,20 @@ 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,
type PlannedWebScript,
} from "../../domain/webScriptExportPlan.js";
import { planWebScriptExport } 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, 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.",
"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.",
"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.",
@@ -38,11 +32,7 @@ export const publishWebScripts = async (
const tree = await SafeOutputTree.create(input.output_directory);
try {
signal?.throwIfAborted();
const records: ExportedWebScript[] = [];
for (const [index, item] of planned.entries()) {
signal?.throwIfAborted();
records.push(await publishPlannedScript(tree, item, index, signal));
}
const records = planned.map(({ record }) => record);
const manifest = webScriptExportManifestSchema.parse({
capture_path: input.capture_path,
capture_sha256: captureSha256,
@@ -76,6 +66,19 @@ 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();
@@ -95,56 +98,5 @@ 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 -21
View File
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import { historicalHar } from "../../../tests/fixtures/historicalHar.js";
import { decodeHarCapture, decodeHarText } from "./HarCapture.js";
import { decodeHarCapture } from "./HarCapture.js";
it.each([
{ values: [] },
@@ -558,23 +558,3 @@ 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.",
);
});
-14
View File
@@ -1,5 +1,4 @@
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";
@@ -26,19 +25,6 @@ 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,
+11 -5
View File
@@ -1,9 +1,10 @@
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, decodeHarText } from "./HarCapture.js";
import { decodeHarCapture } from "./HarCapture.js";
const requestSchema = z.strictObject({
snapshot_path: z.string(),
@@ -22,12 +23,17 @@ 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(
decodeHarText(snapshot.bytes),
request.sensitive_values,
),
value: decodeHarCapture(text, request.sensitive_values),
};
} catch (cause: unknown) {
reply =
+5 -25
View File
@@ -8,7 +8,6 @@ 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";
@@ -212,22 +211,12 @@ 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 checked(
decoder.decode(prefix.subarray(offset, offset + READ_CHUNK_BYTES), {
stream: true,
}),
);
yield decoder.decode(prefix.subarray(offset, offset + READ_CHUNK_BYTES), {
stream: true,
});
}
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
while (true) {
@@ -235,18 +224,9 @@ async function* decodedChunks(
const read = await handle.read(bytes, 0, bytes.length, null);
signal?.throwIfAborted();
if (read.bytesRead === 0) break;
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(bytes.subarray(0, read.bytesRead), { stream: true });
}
yield decoder.decode();
}
const readPrefix = async (
-2
View File
@@ -5,13 +5,11 @@ 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)
-38
View File
@@ -1,38 +0,0 @@
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,
},
],
}),
);
};
-3
View File
@@ -50,9 +50,6 @@ 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
? {}
-3
View File
@@ -2,11 +2,8 @@ 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;
+1 -1
View File
@@ -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, 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.",
"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.",
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
-314
View File
@@ -1,314 +0,0 @@
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);
});
});
-311
View File
@@ -1,311 +0,0 @@
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",
};
};
+3 -3
View File
@@ -1,6 +1,6 @@
import { isDeepStrictEqual } from "node:util";
export const schemaMaps = new Set([
const schemaMaps = new Set([
"$defs",
"definitions",
"properties",
@@ -8,8 +8,8 @@ export const schemaMaps = new Set([
"dependentSchemas",
"dependencies",
]);
export const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
export const schemaChildren = new Set([
const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
const schemaChildren = new Set([
"items",
"additionalItems",
"contains",
-43
View File
@@ -95,46 +95,6 @@ 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 => {
@@ -204,9 +164,6 @@ 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]
+1 -3
View File
@@ -98,7 +98,6 @@ 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 =
@@ -110,5 +109,4 @@ export type AnalysisPartialObservation =
| JavaScriptRuntimeObservation
| ArtifactInventoryPartialObservation
| Evidence
| AndroidPartialObservation
| FirmwarePartialObservation;
| AndroidPartialObservation;
@@ -161,64 +161,6 @@ 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({
-6
View File
@@ -82,16 +82,10 @@ 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",
+4 -24
View File
@@ -104,22 +104,17 @@ const nativeRows = (
dossier: FunctionDossier,
facet: Exclude<
Extract<AnalysisViewRequest, { readonly kind: "native" }>["facet"],
"procedure" | "pseudocode" | "native_api" | "value_flow_summary"
"procedure" | "pseudocode" | "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,
@@ -157,20 +152,10 @@ export const projectNativeFunctionView = (
...common,
});
const flow = dossier.native_value_flow;
if (
view.facet === "procedure" ||
view.facet === "native_api" ||
view.facet === "value_flow_summary"
) {
if (view.facet === "procedure" || 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" }
@@ -203,12 +188,7 @@ export const projectNativeFunctionView = (
return ok(
singleton({
available: false,
// 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",
reason: flow?.available === false ? flow.reason : "not recorded",
}),
);
const items = rows.slice(view.offset, view.offset + view.limit);
+16 -11
View File
@@ -11,7 +11,6 @@ import {
bridgeCandidateCoverageSchema,
projectCartesianCandidates,
} from "../bridgeCandidateProjection.js";
import { nativeRuntimeConvention } from "../nativeConvention.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const pathSchema = z.string().min(1);
@@ -177,6 +176,7 @@ 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,10 +187,15 @@ const runtimeFamilies = (all: readonly Component[]) => {
if (all.some(({ format }) => format === "elf")) families.add("native");
if (all.some(({ format }) => format === "javascript-bundle"))
families.add("javascript");
for (const { path } of all) {
const convention = nativeRuntimeConvention(path);
if (convention !== null) families.add(convention);
}
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");
return [...families].sort(compare);
};
@@ -211,12 +216,12 @@ const bridgeCandidates = (
const bridgeBasis = (
path: string,
): AndroidApplicationProjectionResult["bridge_candidates"][number]["basis"] => {
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";
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";
return "managed-and-native-content";
};
-77
View File
@@ -572,80 +572,3 @@ 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");
});
});
+38 -49
View File
@@ -4,8 +4,6 @@ import canonicalize from "canonicalize";
import { z } from "zod";
import {
anatomyNameIs,
anatomyTailMatches,
appleBundleSchema,
appleComponentSchema,
applicationRoots,
@@ -16,10 +14,6 @@ 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";
@@ -245,20 +239,19 @@ const classifyComponents = (
) => {
const withinApp = (path: string): boolean =>
roots.some((root) => isWithin(path, root));
const matching = (predicate: (path: string) => boolean): Component[] =>
const anatomy = (pattern: RegExp): Component[] =>
entries.flatMap(({ path, kind, component }) =>
kind === "file" &&
component !== undefined &&
!isSidecar(path) &&
withinApp(path) &&
predicate(path)
pattern.test(path)
? [component]
: [],
);
const lastSegment = (path: string): string => path.split("/").at(-1) ?? "";
return {
bundle_metadata: all.filter(({ path }) =>
anatomyNameIs(lastSegment(path), "Info.plist"),
/(?:^|\/)Info\.plist$/u.test(path),
),
executables: all.filter(
({ path, format }) => withinApp(path) && isMachOFormat(format),
@@ -266,41 +259,23 @@ 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 }) =>
anatomyTailMatches(path, ["_CodeSignature", "CodeResources"]) ||
anatomyNameIs(lastSegment(path), "embedded.mobileprovision") ||
anatomyNameIs(lastSegment(path), "embedded.provisionprofile"),
signing: all.filter(({ path }) =>
/(?:^|\/)(?:embedded\.(?:mobileprovision|provisionprofile)|_CodeSignature\/CodeResources)$/u.test(
path,
),
),
privileged_helpers: matching((path) =>
anatomyTailMatches(path, ["Contents", "Library", "LaunchServices", "*"]),
privileged_helpers: anatomy(
/(?:^|\/)Contents\/Library\/LaunchServices\/[^/]+$/u,
),
launchd_plists: matching(
(path) =>
path.endsWith(".plist") &&
(anatomyTailMatches(path, [
"Contents",
"Library",
"LaunchAgents",
"*",
]) ||
anatomyTailMatches(path, [
"Contents",
"Library",
"LaunchDaemons",
"*",
])),
launchd_plists: anatomy(
/(?:^|\/)Contents\/Library\/Launch(?:Agents|Daemons)\/[^/]+\.plist$/u,
).map((component) => ({
...component,
domain: component.path
.split("/")
.some((segment) => anatomyNameIs(segment, "LaunchDaemons"))
domain: component.path.includes("/LaunchDaemons/")
? ("daemon" as const)
: ("agent" as const),
})),
helpers: matching((path) =>
anatomyTailMatches(path, ["Contents", "Helpers", "*"]),
),
helpers: anatomy(/(?:^|\/)Contents\/Helpers\/[^/]+$/u),
};
};
@@ -310,6 +285,7 @@ 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
@@ -317,14 +293,28 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => {
>();
if (all.some(({ format }) => ["mach-o", "mach-o-universal"].includes(format)))
families.add("native");
if (all.some(({ path }) => isSwiftRuntimeLibrary(path)))
if (paths.some((path) => /\.(?:dylib|framework\/[^/]+)$/u.test(path)))
families.add("swift-objective-c");
if (all.some(({ format }) => format === "javascript-bundle"))
families.add("javascript");
for (const { path } of all) {
const convention = nativeRuntimeConvention(path);
if (convention !== null) families.add(convention);
}
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");
return [...families].sort(compare);
};
@@ -371,10 +361,11 @@ const bridgeBasis = (
| "react-native-convention"
| "flutter-convention"
| "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";
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";
return "javascript-and-native-content";
};
@@ -431,9 +422,7 @@ 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 =>
+37 -111
View File
@@ -67,50 +67,6 @@ 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, `.`.
@@ -126,20 +82,15 @@ export const applicationRoots = (
const found = new Set<string>();
for (const { path } of entries) {
if (isSidecar(path)) continue;
const segments = path.split("/");
const app = segments[1];
if (
anatomyNameIs(segments[0] ?? "", "Payload") &&
app !== undefined &&
/\.app$/iu.test(app)
) {
found.add(`${segments[0]}/${app}`);
const ios = /^(Payload\/[^/]+\.app)(?:\/|$)/u.exec(path);
if (ios?.[1] !== undefined) {
found.add(ios[1]);
continue;
}
const segments = path.split("/");
const index = segments.findIndex(
(segment, position) =>
/\.app$/iu.test(segment) &&
anatomyNameIs(segments[position + 1] ?? "", "Contents"),
/\.app$/iu.test(segment) && segments[position + 1] === "Contents",
);
if (index >= 0) found.add(segments.slice(0, index + 1).join("/"));
}
@@ -235,8 +186,8 @@ const enclosingBundle = (
const bundleLayout = (bundle: string, tree: Tree): Bundle["layout"] => {
const names = tree.children.get(directoryOf(bundle));
if (anatomyChild(names, "Contents") !== null) return "macos-deep";
if (anatomyChild(names, "Versions") !== null) return "versioned-framework";
if (names?.has("Contents") === true) return "macos-deep";
if (names?.has("Versions") === true) return "versioned-framework";
return "shallow";
};
@@ -250,18 +201,13 @@ 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 (anatomyNameIs(container ?? "", "PlugIns")) return "plug-in";
return anatomyNameIs(container ?? "", "Resources")
? "resource-bundle"
: "bundle";
if (container === "PlugIns") return "plug-in";
return container === "Resources" ? "resource-bundle" : "bundle";
}
if (!name.endsWith(".app")) return "plug-in";
if (
anatomyNameIs(container ?? "", "LoginItems") &&
anatomyNameIs(segments.at(-3) ?? "", "Library")
)
if (container === "LoginItems" && segments.at(-3) === "Library")
return "login-item";
if (anatomyNameIs(container ?? "", "Helpers")) return "helper-application";
if (container === "Helpers") return "helper-application";
return "nested-application";
};
@@ -279,32 +225,18 @@ const contentDirectories = (
tree: Tree,
): string[] => {
const directory = directoryOf(bundle);
if (layout === "macos-deep") {
const contents = namedChild(tree, directory, "Contents");
return contents === null ? [] : [contents];
}
if (layout === "macos-deep") return [joinPath(directory, "Contents")];
if (layout === "shallow") return [directory];
const versions = namedChild(tree, directory, "Versions");
if (versions === null) return [];
const versions = joinPath(directory, "Versions");
// 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) => !anatomyNameIs(version, "Current"))
.filter((version) => 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"],
@@ -314,15 +246,17 @@ 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 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 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 executableCandidates = (
@@ -331,15 +265,12 @@ const executableCandidates = (
tree: Tree,
): string[] =>
contentDirectories(bundle, layout, tree)
.flatMap((directory) => {
const executableDirectory =
layout === "macos-deep"
? namedChild(tree, directory, "MacOS")
: directory;
return executableDirectory === null
? []
: directFiles(executableDirectory, tree);
})
.flatMap((directory) =>
directFiles(
layout === "macos-deep" ? joinPath(directory, "MacOS") : directory,
tree,
),
)
.filter((path) =>
MACH_O_FORMATS.includes(tree.files.get(path)?.format ?? ""),
);
@@ -350,17 +281,12 @@ const signingPaths = (
tree: Tree,
): string[] =>
contentDirectories(bundle, layout, tree)
.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))
.flatMap((directory) => [
joinPath(directory, "_CodeSignature/CodeResources"),
joinPath(directory, "embedded.provisionprofile"),
joinPath(directory, "embedded.mobileprovision"),
])
.filter((path) => tree.files.has(path))
.sort(compare);
/** Platforms of application roots whose layout was observed, not assumed. */
+6 -46
View File
@@ -13,7 +13,6 @@ 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([
@@ -123,11 +122,6 @@ 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,
@@ -356,20 +350,19 @@ const relationsByPath = (
inventory.occurrences.map((item) => [item.occurrence_id, item]),
);
for (const edge of inventory.edges) {
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 path = edge.logical_path ?? ".";
const values = output.get(path);
const occurrence = occurrences.get(edge.occurrence_id);
const projection: RelationProjection = {
parent_logical_path:
occurrence.parent_occurrence_id === null
occurrence?.parent_occurrence_id === null ||
occurrence?.parent_occurrence_id === undefined
? null
: (occurrences.get(occurrence.parent_occurrence_id)?.logical_path ??
null),
child_artifact_id: occurrence.artifact_id,
child_artifact_id: edge.child_artifact_id,
relation: edge.relation,
logical_path: occurrence.logical_path,
logical_path: edge.logical_path,
producer: edge.producer,
};
if (values === undefined) output.set(path, [projection]);
@@ -437,38 +430,5 @@ 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],
};
};
+3 -4
View File
@@ -151,14 +151,12 @@ export const integrityContradictionSchema = z.object({
limitations: z.array(z.string()).min(1),
});
/**
* Parent-child derivation relation between two content-addressed artifacts.
* The child artifact and its logical path are those of the edge's occurrence.
*/
/** Parent-child derivation relation between two content-addressed artifacts. */
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",
@@ -169,6 +167,7 @@ const artifactEdgeSchema = z.object({
"derived-from",
]),
occurrence_id: occurrenceIdSchema,
logical_path: boundedRelativePathSchema.nullable(),
producer: artifactCommandSchema.nullable(),
});
+10 -42
View File
@@ -1,5 +1,3 @@
import { posix } from "node:path";
import { digestCanonicalValue } from "./canonicalDigest.js";
import type {
ArtifactEdge,
@@ -8,29 +6,6 @@ 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")}`;
@@ -96,21 +71,14 @@ export const artifactContradictionId = (input: {
"Artifact",
)}`;
/**
* Identify an edge by its semantic relationship and exact occurrence. The
* child artifact and logical path come from that occurrence.
*/
/** Identify an edge by its semantic relationship and exact occurrence. */
export const artifactEdgeId = (
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",
)}`;
semantic: Pick<
ArtifactEdge,
| "parent_artifact_id"
| "child_artifact_id"
| "relation"
| "occurrence_id"
| "logical_path"
>,
): string => `edge_${digestCanonicalValue(semantic, "Artifact")}`;

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