Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c72e9305 | ||
|
|
f9ce32b474 | ||
|
|
9a4fdbeb52 | ||
|
|
2fc47976a0 | ||
|
|
46c9cba03a | ||
|
|
b4db35f092 | ||
|
|
7a56fe6260 | ||
|
|
b25922e4e8 | ||
|
|
b6ad1f2c3d | ||
|
|
2b6f14f528 | ||
|
|
f51503ef1c | ||
|
|
2bf812772d | ||
|
|
a936303723 | ||
|
|
93ca276921 | ||
|
|
911323c5dd | ||
|
|
f63f12d8ff | ||
|
|
983b070d4d | ||
|
|
9e22da845b | ||
|
|
f970b886aa | ||
|
|
a0e5e9db2b | ||
|
|
1f692c83e2 | ||
|
|
782e0147cd | ||
|
|
9defed9725 | ||
|
|
5f6bb7a5ba | ||
|
|
2596f8da14 | ||
|
|
b12d0c6361 | ||
|
|
cbcba2902d | ||
|
|
07d416505c | ||
|
|
3ea0233342 | ||
|
|
cf6957e796 | ||
|
|
ed200a2302 | ||
|
|
7df2050606 | ||
|
|
130ffdf6bc | ||
|
|
b09d02d93c | ||
|
|
1f0775684b | ||
|
|
7296f2b5bf | ||
|
|
3c7b176a3d | ||
|
|
aa23ec883f | ||
|
|
934ffd28d1 | ||
|
|
79f6756239 | ||
|
|
78e1308f49 | ||
|
|
4e3d335439 | ||
|
|
ea92328775 | ||
|
|
28d3cb545c | ||
|
|
b118f04492 | ||
|
|
ef96657d6b | ||
|
|
696fb834b8 | ||
|
|
2781cad201 | ||
|
|
c89c8f5318 | ||
|
|
1ec490f361 | ||
|
|
66863b609f | ||
|
|
817efaca9a | ||
|
|
0d2082d36b | ||
|
|
f1231d4a2c | ||
|
|
a957f397ad | ||
|
|
e09cfe9b59 | ||
|
|
ea1dbda1c7 | ||
|
|
7633dd8d5d | ||
|
|
941dc425ca | ||
|
|
33b9b31a6f | ||
|
|
eeb83f6581 | ||
|
|
bb6fa1c6f5 | ||
|
|
ce16f7bc95 | ||
|
|
0a14ca41b4 | ||
|
|
dc5af6d68f | ||
|
|
0b9815b6e7 | ||
|
|
6375ccaf04 | ||
|
|
c29931ce81 | ||
|
|
f4a0c1ce95 | ||
|
|
a9ee2360fd | ||
|
|
4d95f2b2e7 | ||
|
|
28595276fa | ||
|
|
98022a8352 | ||
|
|
81cdafc132 | ||
|
|
4d8ef3f350 | ||
|
|
ca3131b36c | ||
|
|
de8fc67195 | ||
|
|
3006514c4c | ||
|
|
8550913cda | ||
|
|
1f0f728c63 | ||
|
|
7623e21ff0 | ||
|
|
499e2f54ef | ||
|
|
6875d4a64c | ||
|
|
2b6e9ec03f | ||
|
|
498cec042a | ||
|
|
5fc4d792ca | ||
|
|
e4a8e0b728 | ||
|
|
e17290cd2a | ||
|
|
40f632a155 | ||
|
|
7aa4d768eb | ||
|
|
e10c934d5a | ||
|
|
08ec2bf82f | ||
|
|
be6863e9a1 |
+13
-6
@@ -2,7 +2,7 @@
|
|||||||
name: reverse-engineer-anything
|
name: reverse-engineer-anything
|
||||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||||
metadata:
|
metadata:
|
||||||
version: "34"
|
version: "35"
|
||||||
---
|
---
|
||||||
|
|
||||||
# REA
|
# REA
|
||||||
@@ -62,13 +62,20 @@ with a version-pinned `rea-agents` package or continue through the CLI.
|
|||||||
A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is:
|
A concrete CLI fallback for an operator-supplied JavaScript tree or ASAR is:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json > app-evidence.json
|
||||||
|
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
|
||||||
|
npx -y rea-agents@latest inspect-analysis-view app-view.json
|
||||||
```
|
```
|
||||||
|
|
||||||
No MCP registration or native engine is required. Read the returned Evidence,
|
No MCP registration or native engine is required. The CLI returns the complete
|
||||||
graph, limitations, and unknowns with the same care as an MCP result; the CLI
|
Evidence record directly, and a real application can produce hundreds of
|
||||||
returns the Evidence record directly. This fallback does not establish that
|
megabytes, so save it to a file rather than reading it into context. Start from
|
||||||
MCP is configured. Native CLI tasks still require their selected engine.
|
the summary view, then request a module page
|
||||||
|
(`{"kind": "page", "collection": "modules", "offset": 0, "limit": 32}`) or one
|
||||||
|
module the same way, or pass the saved Evidence inline to
|
||||||
|
`trace-application-feature`. Read graph, limitations, and unknowns with the
|
||||||
|
same care as an MCP result. This fallback does not establish that MCP is
|
||||||
|
configured. Native CLI tasks still require their selected engine.
|
||||||
|
|
||||||
## Route the target first
|
## Route the target first
|
||||||
|
|
||||||
+9
@@ -34,6 +34,15 @@ cross-references. Addresses and recovered pseudocode are analysis observations,
|
|||||||
not original source. Provider unavailability and unsupported metadata remain
|
not original source. Provider unavailability and unsupported metadata remain
|
||||||
unknown rather than false.
|
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
|
||||||
|
code units; use the returned `next_offset` for continuation. Provider limitations
|
||||||
|
and unavailable facts remain explicit; the complete parent Evidence is retained.
|
||||||
|
|
||||||
To see which functions or Objective-C methods a Mach-O actually calls in one
|
To see which functions or Objective-C methods a Mach-O actually calls in one
|
||||||
run, use `observe_native_calls` with explicit breakpoints and a bounded
|
run, use `observe_native_calls` with explicit breakpoints and a bounded
|
||||||
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
|
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
|
||||||
@@ -28,7 +28,7 @@ intentionally not included. -->
|
|||||||
- Provider, bridge, target-format, or platform compatibility:
|
- Provider, bridge, target-format, or platform compatibility:
|
||||||
- Evidence, artifact, provenance, or reconstruction contract:
|
- Evidence, artifact, provenance, or reconstruction contract:
|
||||||
- Process execution, authorization, cleanup, or containment impact:
|
- Process execution, authorization, cleanup, or containment impact:
|
||||||
- Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `skill-src/` and never commit build outputs):
|
- Generated metadata (`docs/public/product-catalog.json`), package, or installation impact (CI generates and validates ignored catalogs and `skills/`; edit authored instructions in `.agents/skills/reverse-engineer-anything/` and never commit build outputs):
|
||||||
|
|
||||||
## Evidence and regression coverage
|
## Evidence and regression coverage
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REA_ANALYSIS_PROVIDER: ghidra
|
REA_ANALYSIS_PROVIDER: ghidra
|
||||||
run: npm run verify:ghidra:windows -- --x86 | Tee-Object -FilePath windows-ghidra-x86-proof.log
|
run: npm run verify:ghidra:windows -- --x86 | Tee-Object -FilePath windows-ghidra-x86-proof.log
|
||||||
|
- name: Verify native x64 DLL Windows Ghidra operations
|
||||||
|
env:
|
||||||
|
REA_ANALYSIS_PROVIDER: ghidra
|
||||||
|
run: npm run verify:ghidra:windows -- --dll | Tee-Object -FilePath windows-ghidra-dll-proof.log
|
||||||
|
- name: Verify native x86 DLL Windows Ghidra operations
|
||||||
|
env:
|
||||||
|
REA_ANALYSIS_PROVIDER: ghidra
|
||||||
|
run: npm run verify:ghidra:windows -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-proof.log
|
||||||
- name: Verify ordinary-user CLI and MCP operations
|
- name: Verify ordinary-user CLI and MCP operations
|
||||||
env:
|
env:
|
||||||
REA_ANALYSIS_PROVIDER: ghidra
|
REA_ANALYSIS_PROVIDER: ghidra
|
||||||
@@ -72,6 +80,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REA_ANALYSIS_PROVIDER: ghidra
|
REA_ANALYSIS_PROVIDER: ghidra
|
||||||
run: npm run verify:ghidra:windows:package -- --x86 | Tee-Object -FilePath windows-ghidra-x86-package-proof.log
|
run: npm run verify:ghidra:windows:package -- --x86 | Tee-Object -FilePath windows-ghidra-x86-package-proof.log
|
||||||
|
- name: Verify ordinary-user x64 DLL CLI and MCP operations
|
||||||
|
env:
|
||||||
|
REA_ANALYSIS_PROVIDER: ghidra
|
||||||
|
run: npm run verify:ghidra:windows:package -- --dll | Tee-Object -FilePath windows-ghidra-dll-package-proof.log
|
||||||
|
- name: Verify ordinary-user x86 DLL CLI and MCP operations
|
||||||
|
env:
|
||||||
|
REA_ANALYSIS_PROVIDER: ghidra
|
||||||
|
run: npm run verify:ghidra:windows:package -- --x86 --dll | Tee-Object -FilePath windows-ghidra-x86-dll-package-proof.log
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
@@ -81,4 +97,8 @@ jobs:
|
|||||||
windows-ghidra-package-proof.log
|
windows-ghidra-package-proof.log
|
||||||
windows-ghidra-x86-proof.log
|
windows-ghidra-x86-proof.log
|
||||||
windows-ghidra-x86-package-proof.log
|
windows-ghidra-x86-package-proof.log
|
||||||
|
windows-ghidra-dll-proof.log
|
||||||
|
windows-ghidra-x86-dll-proof.log
|
||||||
|
windows-ghidra-dll-package-proof.log
|
||||||
|
windows-ghidra-x86-dll-package-proof.log
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Use [.nvmrc](.nvmrc) and `package.json#packageManager` for the pinned toolchain.
|
|||||||
|
|
||||||
Use relevant checks for the change. The complete local gate, `npm run check:pr`, is optional for broad changes or CI diagnosis. See [CONTRIBUTING.md](CONTRIBUTING.md) for development, generated-file ownership, and release conventions.
|
Use relevant checks for the change. The complete local gate, `npm run check:pr`, is optional for broad changes or CI diagnosis. See [CONTRIBUTING.md](CONTRIBUTING.md) for development, generated-file ownership, and release conventions.
|
||||||
|
|
||||||
`docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored outputs. Edit source contracts and `skill-src/`, then regenerate as needed. `.cache/mcp-tool-catalog.json` is test metadata; runtime builds and source checking do not consume it. Never commit binaries, provider project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts.
|
`docs/public/product-catalog.json`, `docs/verification/managed-conformance-*.json`, and `skills/` are ignored outputs. Edit source contracts and `.agents/skills/reverse-engineer-anything/`, then regenerate as needed. `.cache/mcp-tool-catalog.json` is test metadata; runtime builds and source checking do not consume it. Never commit binaries, provider project documents, credentials, `dist/`, `node_modules/`, or local planning artifacts.
|
||||||
|
|
||||||
## Tool and Test Changes
|
## Tool and Test Changes
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -95,8 +95,12 @@ Pre-commit formats and lints staged files; pre-push runs `check:fast`.
|
|||||||
`evidence:generate` prepares only the managed commitment and its runtime/skill
|
`evidence:generate` prepares only the managed commitment and its runtime/skill
|
||||||
dependencies. The ignored outputs are `docs/public/product-catalog.json`,
|
dependencies. The ignored outputs are `docs/public/product-catalog.json`,
|
||||||
`docs/verification/managed-conformance-*.json`, and `skills/`. Edit authored
|
`docs/verification/managed-conformance-*.json`, and `skills/`. Edit authored
|
||||||
skill instructions in `skill-src/`. CI validates generated outputs and retains
|
skill instructions in `.agents/skills/reverse-engineer-anything/`, where Skills
|
||||||
them as artifacts; do not commit them. Reviewed source metadata, including
|
can discover the public bundle directly from a clean checkout. `verify:package`
|
||||||
|
installs that bundle with Skills 1.7.2 using default repository-root discovery
|
||||||
|
and checks every installed file, alongside the packaged setup checks.
|
||||||
|
CI validates generated outputs and retains them as artifacts; do not commit
|
||||||
|
them. Reviewed source metadata, including
|
||||||
`src/generatedPackageMetadata.ts` and `docs/error-contract.schema.json`, remains
|
`src/generatedPackageMetadata.ts` and `docs/error-contract.schema.json`, remains
|
||||||
tracked and checked for freshness.
|
tracked and checked for freshness.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**English** · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
**English** · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Reverse Engineer Anything
|
# REA: Reverse Engineer Anything
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ Report vulnerabilities through [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
## Star history
|
## Star history
|
||||||
|
|
||||||
🎉 **40,000 GitHub stars — thank you!**
|
🎉 **50,000 GitHub stars — thank you!**
|
||||||
|
|
||||||
Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes.
|
Thanks to everyone using REA, reporting bugs, requesting features, testing builds, and contributing fixes.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · **العربية** · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: هندسة عكسية لأي شيء
|
# REA: هندسة عكسية لأي شيء
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## سجل النجوم
|
## سجل النجوم
|
||||||
|
|
||||||
🎉 **40,000 نجمة على GitHub — شكرًا لكم!**
|
🎉 **50,000 نجمة على GitHub — شكرًا لكم!**
|
||||||
|
|
||||||
شكرًا لكل من يستخدم REA، ويبلّغ عن الأخطاء، ويقترح ميزات، ويختبر إصدارات البناء، ويساهم بالإصلاحات.
|
شكرًا لكل من يستخدم REA، ويبلّغ عن الأخطاء، ويقترح ميزات، ويختبر إصدارات البناء، ويساهم بالإصلاحات.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · **Deutsch** · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Alles reverse-engineeren
|
# REA: Alles reverse-engineeren
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Melde Sicherheitslücken gemäß [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
## Sternverlauf
|
## Sternverlauf
|
||||||
|
|
||||||
🎉 **40.000 GitHub-Sterne – vielen Dank!**
|
🎉 **50.000 GitHub-Sterne – vielen Dank!**
|
||||||
|
|
||||||
Danke an alle, die REA nutzen, Fehler melden, Funktionswünsche äußern, Builds testen und Fehlerbehebungen beitragen.
|
Danke an alle, die REA nutzen, Fehler melden, Funktionswünsche äußern, Builds testen und Fehlerbehebungen beitragen.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · **Español** · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Ingeniería inversa de cualquier cosa
|
# REA: Ingeniería inversa de cualquier cosa
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Informa de vulnerabilidades siguiendo [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
## Historial de estrellas
|
## Historial de estrellas
|
||||||
|
|
||||||
🎉 **40.000 estrellas en GitHub: ¡gracias!**
|
🎉 **50.000 estrellas en GitHub: ¡gracias!**
|
||||||
|
|
||||||
Gracias a quienes usan REA, informan de errores, solicitan funciones, prueban compilaciones y contribuyen con correcciones.
|
Gracias a quienes usan REA, informan de errores, solicitan funciones, prueban compilaciones y contribuyen con correcciones.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center" dir="rtl">
|
<div align="center" dir="rtl">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی**
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · **فارسی** · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: مهندسی معکوس هر چیزی
|
# REA: مهندسی معکوس هر چیزی
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## تاریخچهٔ ستارهها
|
## تاریخچهٔ ستارهها
|
||||||
|
|
||||||
🎉 **۴۰٬۰۰۰ ستارهٔ GitHub — سپاسگزاریم!**
|
🎉 **۵۰٬۰۰۰ ستارهٔ GitHub — سپاسگزاریم!**
|
||||||
|
|
||||||
از همهٔ کسانی که از REA استفاده میکنند، خطاها را گزارش میدهند، قابلیتهای جدید پیشنهاد میکنند، buildها را آزمایش میکنند و در بهبود پروژه مشارکت دارند سپاسگزاریم.
|
از همهٔ کسانی که از REA استفاده میکنند، خطاها را گزارش میدهند، قابلیتهای جدید پیشنهاد میکنند، buildها را آزمایش میکنند و در بهبود پروژه مشارکت دارند سپاسگزاریم.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · **Français** · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA : rétro-ingénierie de tout
|
# REA : rétro-ingénierie de tout
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ Signalez les vulnérabilités via [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
## Historique des étoiles
|
## Historique des étoiles
|
||||||
|
|
||||||
🎉 **40 000 étoiles GitHub — merci !**
|
🎉 **50 000 étoiles GitHub — merci !**
|
||||||
|
|
||||||
Merci à toutes les personnes qui utilisent REA, signalent des bugs, proposent des fonctionnalités, testent les builds et contribuent des correctifs.
|
Merci à toutes les personnes qui utilisent REA, signalent des bugs, proposent des fonctionnalités, testent les builds et contribuent des correctifs.
|
||||||
|
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · **Bahasa Indonesia**
|
||||||
|
|
||||||
|
# REA: Rekayasa Balik Apa Pun
|
||||||
|
|
||||||
|
### Satu MCP untuk rekayasa balik pada berbagai biner, aplikasi, dan perilaku saat runtime.
|
||||||
|
|
||||||
|
**Melihat fitur yang Anda sukai. Pahami cara kerjanya hingga ke tingkat biner.**
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/rea-agents)
|
||||||
|
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||||
|
[](docs/mcp-contracts.md#generated-catalog)
|
||||||
|
[](#current-status)
|
||||||
|
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://discord.gg/GkcryMnJDM)
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/82054?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-82054" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/82054" alt="morluto%2Frea | Trendshift" width="250" height="55"/></a>
|
||||||
|
|
||||||
|
**[Situs Web](https://rea.tools/) · [Panduan](https://rea.tools/guides/) · [Contoh Penggunaan](https://rea.tools/showcase/)**
|
||||||
|
|
||||||
|
[Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [FAQ](#faq) · [Dokumentasi](#dokumentasi)
|
||||||
|
|
||||||
|
<code>npx rea-agents setup</code>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<img src="docs/assets/rea-hopper-analysis.png" alt="REA menjalankan jembatan analisisnya di dalam Hopper saat memeriksa biner native" width="1200" />
|
||||||
|
|
||||||
|
<br /><br />
|
||||||
|
|
||||||
|
<table aria-label="Komunitas REA">
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="360">
|
||||||
|
<a href="https://discord.gg/GkcryMnJDM">
|
||||||
|
<img src="docs/assets/discord.svg" height="42" alt="Discord" /><br />
|
||||||
|
<strong>Bergabunglah dengan Komunitas Rekayasa Balik</strong>
|
||||||
|
</a><br />
|
||||||
|
<sub>Discord · Tanya Jawab · Berbagi Hasil</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Melihat fitur dalam suatu aplikasi yang ingin Anda hadirkan di produk sendiri? Minta agen Anda menyelidikinya menggunakan REA. REA dapat memeriksa aplikasi tanpa kode sumbernya, menjelaskan cara kerja fitur tersebut, menunjukkan bukti, dan membangun versi untuk proyek Anda.
|
||||||
|
|
||||||
|
REA menghubungkan agen Anda dengan berbagai alat untuk memeriksa biner native, aplikasi JavaScript dan Electron, assembly .NET, serta situs web. Anda juga dapat menggunakan alat yang sama dari terminal. Analisis dijalankan secara lokal, dan hasilnya mencakup bukti serta keterbatasan yang mendasari setiap kesimpulan.
|
||||||
|
|
||||||
|
Proses penyiapan mendaftarkan REA ke agen Anda dan memasang instruksi alur kerja yang sesuai. Analisis native dapat menggunakan instalasi Hopper, Ghidra, atau IDA yang sudah ada. Proses penyiapan juga dapat memasang Hopper secara opsional setelah mendapatkan persetujuan. Analisis JavaScript statis tidak memerlukan mesin analisis native.
|
||||||
|
|
||||||
|
> **[Kunjungi situs web REA](https://rea.tools/)** untuk mendapatkan petunjuk penyiapan, panduan bergambar, dan studi kasus nyata.
|
||||||
|
|
||||||
|
## Mulai Cepat
|
||||||
|
|
||||||
|
### Siapkan agen Anda
|
||||||
|
|
||||||
|
Dengan Node.js dan npm yang telah terpasang, jalankan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx rea-agents setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Pilih agen yang ingin digunakan, tinjau perubahan yang diusulkan, lalu setujui perubahan tersebut. Proses penyiapan menambahkan server MCP REA dan instruksi alur kerja yang sesuai, serta membuat cadangan konfigurasi yang sudah ada. Setelah itu, mulai ulang agen Anda.
|
||||||
|
|
||||||
|
Penyiapan mendukung Claude Code, Codex, Cursor, Gemini CLI, Grok Build, dan [agen lainnya](docs/installation.md#supported-agents). Lihat [instalasi dan penyiapan](docs/installation.md) untuk konfigurasi penyedia dan pendaftaran MCP secara manual.
|
||||||
|
|
||||||
|
### Minta agen Anda
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pahami cara kerja pencarian di aplikasi Notes, tunjukkan buktinya, dan buat fitur serupa untuk proyek saya.
|
||||||
|
```
|
||||||
|
|
||||||
|
Ganti Notes dengan aplikasi target dan fitur yang ingin Anda pahami.
|
||||||
|
|
||||||
|
### Gunakan terminal
|
||||||
|
|
||||||
|
Periksa direktori aplikasi JavaScript/Electron yang telah diekstrak atau berkas ASAR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Hasilnya mencakup modul, impor, batas antarkomponen Electron, dan bukti yang mendasarinya. Ganti jalur tersebut dengan lokasi target Anda, misalnya `"D:/apps/example"` di Windows.
|
||||||
|
|
||||||
|
Untuk memasang perintah `rea` agar dapat digunakan secara rutin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --global rea-agents
|
||||||
|
rea --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk analisis native, konfigurasikan penyedia terlebih dahulu. Lihat [Panduan CLI dan Bukti](docs/cli.md) untuk perintah native, pemilihan penyedia, snapshot, dan pembuatan skrip.
|
||||||
|
|
||||||
|
### Perbarui REA
|
||||||
|
|
||||||
|
REA berkembang dengan cepat, dan rilis baru sering menyertakan perbaikan bug. Pastikan instalasi Anda selalu diperbarui.
|
||||||
|
|
||||||
|
Untuk CLI yang dipasang melalui npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rea update
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk memperbarui pendaftaran agen dan skill Anda, jalankan perintah penyiapan yang ditampilkan oleh proses pembaruan.
|
||||||
|
|
||||||
|
Jika Anda menggunakan `npx`, perbarui penyiapan agen dengan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx rea-agents@latest setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Tinjau perubahan penyiapan dan mulai ulang agen Anda. Untuk menjalankan perintah CLI sekali pakai, gunakan `npx rea-agents@latest` diikuti dengan perintah yang diinginkan.
|
||||||
|
|
||||||
|
## Cara Kerja REA
|
||||||
|
|
||||||
|
Agen Anda memanggil REA melalui MCP untuk memeriksa target dan menelusuri kode yang relevan. REA mengembalikan temuan beserta bukti pendukungnya. Agen kemudian menggunakan temuan tersebut untuk mengajukan pertanyaan lanjutan, menjelaskan perilaku, atau menulis dan menguji implementasi.
|
||||||
|
|
||||||
|
Perintah CLI menggunakan alur kerja yang sama.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[Buka ilustrasi dalam ukuran penuh](website/public/assets/figures/rea-investigation-flow.svg).
|
||||||
|
|
||||||
|
<a id="current-status"></a>
|
||||||
|
|
||||||
|
## Hal yang Dapat Anda Analisis
|
||||||
|
|
||||||
|
REA memerlukan Node.js 22.x (>=22.19), 24.x (>=24.11), atau 26+, beserta npm.
|
||||||
|
|
||||||
|
Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
|
||||||
|
|
||||||
|
| Target | Hasil yang diberikan REA | Persyaratan dan panduan |
|
||||||
|
| -------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Biner native | Pseudocode, assembly, string, simbol, pemanggilan, dan referensi | Hopper, Ghidra, atau IDA; [analisis native](https://rea.tools/guides/native/) |
|
||||||
|
| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](docs/binary-diagnostics.md) |
|
||||||
|
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](docs/evm-bytecode.md) |
|
||||||
|
| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](docs/recorded-crashes.md) |
|
||||||
|
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/guides/javascript/) |
|
||||||
|
| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/guides/browser/) |
|
||||||
|
| Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](docs/web-network-captures.md) |
|
||||||
|
| Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](docs/managed-code-analysis.md) |
|
||||||
|
| APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](docs/android-analysis.md) |
|
||||||
|
| Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](docs/firmware-analysis.md) |
|
||||||
|
| Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](docs/javascript-artifact-reconstruction.md), [aplikasi Apple](docs/apple-application-analysis.md) |
|
||||||
|
| Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](docs/process-capture.md) |
|
||||||
|
|
||||||
|
Pemeriksaan JavaScript dan .NET statis membaca berkas yang disediakan tanpa menjalankan aplikasinya. Perekaman runtime menjalankan atau berinteraksi dengan target yang dipilih menggunakan izin pengguna Anda. Setiap panduan runtime menjelaskan dampaknya.
|
||||||
|
|
||||||
|
<a id="choosing-a-deep-analysis-provider"></a>
|
||||||
|
|
||||||
|
Format native dan dukungan sistem host berbeda-beda menurut penyedia. Lihat [penyiapan Hopper dan Ghidra](docs/installation.md#hopper), [panduan IDA](docs/ida-provider.md), dan [dukungan eksperimental Ghidra di Windows](docs/windows-ghidra-p0.md).
|
||||||
|
|
||||||
|
Ghidra juga mendukung [analisis DOS 16-bit](docs/ghidra-dos.md).
|
||||||
|
|
||||||
|
Untuk biner berukuran besar, tingkatkan batas waktu startup menggunakan `REA_GHIDRA_STARTUP_TIMEOUT_MS`.
|
||||||
|
|
||||||
|
Untuk memilih penyedia, lihat [panduan CLI](docs/cli.md#choose-a-provider).
|
||||||
|
|
||||||
|
Periksa [ketersediaan rilis](docs/installation.md#released-package-and-main) untuk mengetahui fitur yang ditambahkan setelah rilis npm terbaru.
|
||||||
|
|
||||||
|
## Contoh Penggunaan
|
||||||
|
|
||||||
|
[](https://rea.tools/showcase/)
|
||||||
|
|
||||||
|
### DX-Ball: merekonstruksi perhitungan sound-pan
|
||||||
|
|
||||||
|
Ikuti pemanggilan suara hingga ke fungsi pembantu yang mengubah posisi menjadi nilai pan, periksa instruksinya, lalu ubah pseudocode yang belum lengkap menjadi C. Hasil rekonstruksi tersebut lolos pada 3.205 kasus x86 asli dan mereproduksi seluruh 63 byte fungsi hasil kompilasi.
|
||||||
|
|
||||||
|
[Baca studi kasus](https://rea.tools/showcase/dx-ball/) · [Repositori rekonstruksi](https://github.com/N0zoM1z0/dx-ball)
|
||||||
|
|
||||||
|
### Notion: menelusuri jembatan clipboard Electron
|
||||||
|
|
||||||
|
Temukan API clipboard pada renderer, telusuri jalurnya melalui preload dan IPC hingga ke proses utama, lalu periksa format clipboard kaya.
|
||||||
|
|
||||||
|
[Baca studi kasus](https://rea.tools/showcase/notion/)
|
||||||
|
|
||||||
|
### TH04: memulihkan perhitungan bullet-ring DOS
|
||||||
|
|
||||||
|
Periksa instruksi 16-bit dari gim PC-98 asli, pulihkan perhitungan sudut tetap dan sudut yang diarahkan, lalu bandingkan C++ hasil rekonstruksi dengan keluaran historis kompiler.
|
||||||
|
|
||||||
|
[Baca studi kasus](https://rea.tools/showcase/th04/) · [Repositori rekonstruksi](https://github.com/N0zoM1z0/th04)
|
||||||
|
|
||||||
|
Jika Anda telah menggunakan REA untuk menganalisis sesuatu yang menarik, kami ingin melihat hasilnya. Bagikan kasus Anda melalui [issue](https://github.com/morluto/rea/issues) atau [pull request](https://github.com/morluto/rea/pulls), sertakan target, pertanyaan Anda, bagaimana REA membantu, dan apa yang Anda temukan.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Agen apa saja yang dapat menggunakan REA?</strong></summary>
|
||||||
|
|
||||||
|
Agen apa pun yang mendukung server MCP lokal dapat menggunakan REA. Proses penyiapan mengonfigurasi [agen yang didukung](docs/installation.md#supported-agents). Klien lain dapat menggunakan [pendaftaran MCP manual](docs/installation.md#mcp-registry).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Apakah saya memerlukan Hopper, Ghidra, atau IDA?</strong></summary>
|
||||||
|
|
||||||
|
Analisis native mendalam menggunakan salah satu dari ketiganya. Pemeriksaan JavaScript dan .NET statis dapat dilakukan tanpa mesin analisis native.
|
||||||
|
|
||||||
|
Proses penyiapan dapat memasang Hopper setelah memperoleh persetujuan. Ghidra dan IDA menggunakan instalasi yang sudah tersedia. Lihat [penyiapan penyedia](docs/installation.md#hopper).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Apakah saya harus menjalankan Hopper terlebih dahulu?</strong></summary>
|
||||||
|
|
||||||
|
REA akan menjalankan Hopper saat suatu operasi membutuhkannya. Di macOS, dialog saat pertama kali dijalankan mungkin meminta Anda memilih mode demo atau mengaktifkan lisensi.
|
||||||
|
|
||||||
|
Lihat [startup dan pemecahan masalah Hopper](docs/installation.md#launcher-paths-and-troubleshooting).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Apa fungsi memasang skill dari skills.sh?</strong></summary>
|
||||||
|
|
||||||
|
Skill tersebut menyediakan instruksi investigasi untuk agen Anda. Gunakan `npx rea-agents setup` untuk mendaftarkan server MCP REA dan memasang instruksi yang sesuai, lalu mulai ulang agen Anda.
|
||||||
|
|
||||||
|
Lihat [instalasi khusus skill](docs/installation.md#skill-only-installation).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Kode seperti apa yang dihasilkan REA?</strong></summary>
|
||||||
|
|
||||||
|
Analisis native menghasilkan pseudocode dan assembly. Analisis JavaScript/Electron memulihkan modul dan hubungan antarmodulnya. Agen Anda menggunakan temuan tersebut untuk menulis dan menguji implementasi. [Contoh penggunaan](#contoh-penggunaan) menyediakan contoh yang telah dikerjakan.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Apakah REA mengunggah aplikasi saya?</strong></summary>
|
||||||
|
|
||||||
|
REA menganalisis target secara lokal. Agen Anda menerima hasil alat, sedangkan penyedia model yang digunakannya memiliki kebijakan datanya sendiri.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Apa yang harus saya lakukan jika menemukan bug?</strong></summary>
|
||||||
|
|
||||||
|
Perbarui terlebih dahulu. Rilis terbaru mungkin sudah memperbaiki masalah tersebut.
|
||||||
|
|
||||||
|
Untuk CLI yang dipasang melalui npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rea update
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk penyiapan agen melalui `npx`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx rea-agents@latest setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika Anda menggunakan agen, selesaikan [pembaruan penyiapan](#perbarui-rea) dan mulai ulang agen tersebut.
|
||||||
|
|
||||||
|
Coba kembali tugas yang sama. Jika masalah masih terjadi, [buat issue](https://github.com/morluto/rea/issues) dengan menyertakan versi REA, jenis target, langkah-langkah untuk mereproduksi masalah, dan keluaran kesalahan.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Dokumentasi
|
||||||
|
|
||||||
|
Mulailah dengan [panduan praktik di situs web](https://rea.tools/guides/).
|
||||||
|
|
||||||
|
Untuk mengetahui opsi, prasyarat, dan kontrak hasil secara tepat:
|
||||||
|
|
||||||
|
- [Instalasi dan penyiapan](docs/installation.md): pendaftaran agen, konfigurasi penyedia, pembaruan, dan penghapusan instalasi.
|
||||||
|
- [Kesiapan dan pemecahan masalah](docs/installation.md#check-readiness-for-your-task): mendiagnosis satu agen atau mesin analisis.
|
||||||
|
- [CLI dan Bukti](docs/cli.md): perintah, pemilihan penyedia, snapshot, impor/ekspor, dan status keluar.
|
||||||
|
- [Kontrak MCP](docs/mcp-contracts.md) dan [prompt agen](docs/mcp-prompts.md): hasil alat, sesi, dan investigasi terpandu.
|
||||||
|
- [Katalog alat](docs/mcp-contracts.md#generated-catalog): inventaris alat, penyedia, dan perintah CLI yang dibuat secara otomatis.
|
||||||
|
- [Peta jalan](docs/roadmap.md): pekerjaan yang direncanakan dan pelacak kemampuan.
|
||||||
|
|
||||||
|
Laporkan kerentanan melalui [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Riwayat Bintang
|
||||||
|
|
||||||
|
🎉 **50.000 bintang GitHub, terima kasih!**
|
||||||
|
|
||||||
|
Terima kasih kepada semua orang yang menggunakan REA, melaporkan bug, meminta fitur, menguji build, dan berkontribusi dalam perbaikan.
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=morluto%2Frea&type=date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||||
|
<img alt="Riwayat bintang GitHub REA" src="https://api.star-history.com/chart?repos=morluto/rea&type=date" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Penafian
|
||||||
|
|
||||||
|
REA menyediakan alat untuk penelitian, analisis, dan rekonstruksi rekayasa balik yang sah. Anda bertanggung jawab untuk memperoleh otorisasi yang diperlukan dan mematuhi hukum yang berlaku. Proyek ini tidak mendukung penggunaan ilegal atau tanpa izin.
|
||||||
|
|
||||||
|
REA adalah proyek perangkat lunak sumber terbuka. Kami tidak menerbitkan atau mendukung mata uang kripto maupun token apa pun. Token yang menggunakan nama REA tidak berafiliasi dengan proyek ini.
|
||||||
|
|
||||||
|
## Kontribusi
|
||||||
|
|
||||||
|
Kami sangat menghargai bantuan Anda untuk REA! [Buat issue](https://github.com/morluto/rea/issues) untuk melaporkan bug atau menyarankan fitur, atau [kirim pull request](https://github.com/morluto/rea/pulls) untuk meningkatkan kode atau dokumentasi.
|
||||||
|
|
||||||
|
Lihat [CONTRIBUTING.md](CONTRIBUTING.md) untuk penyiapan pengembangan dan pemeriksaan, [pengujian](docs/testing.md) untuk tahapan verifikasi, serta [peta arsitektur](docs/architecture.mermaid) untuk struktur proyek.
|
||||||
|
|
||||||
|
## Lisensi
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · **日本語** · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA:あらゆるものをリバースエンジニアリング
|
# REA:あらゆるものをリバースエンジニアリング
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## スター履歴
|
## スター履歴
|
||||||
|
|
||||||
🎉 **GitHub スター 40,000 件、ありがとうございます!**
|
🎉 **GitHub スター 50,000 件、ありがとうございます!**
|
||||||
|
|
||||||
REA の利用、バグ報告、機能の要望、ビルドのテスト、修正への貢献に感謝します。
|
REA の利用、バグ報告、機能の要望、ビルドのテスト、修正への貢献に感謝します。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · **한국어** · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: 무엇이든 리버스 엔지니어링
|
# REA: 무엇이든 리버스 엔지니어링
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## 스타 기록
|
## 스타 기록
|
||||||
|
|
||||||
🎉 **GitHub 스타 40,000개, 감사합니다!**
|
🎉 **GitHub 스타 50,000개, 감사합니다!**
|
||||||
|
|
||||||
REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
|
REA를 사용하고, 버그를 보고하고, 기능을 요청하고, 빌드를 테스트하고, 수정에 기여해 주신 모든 분께 감사드립니다.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · **Polski** · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Reverse Engineer Anything
|
# REA: Reverse Engineer Anything
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Zgłaszaj podatności zgodnie z [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
## Historia gwiazdek
|
## Historia gwiazdek
|
||||||
|
|
||||||
🎉 **40 000 gwiazdek na GitHubie — dziękujemy!**
|
🎉 **50 000 gwiazdek na GitHubie — dziękujemy!**
|
||||||
|
|
||||||
Dziękujemy wszystkim, którzy używają REA, zgłaszają błędy, proponują nowe funkcje, testują kompilacje i przesyłają poprawki.
|
Dziękujemy wszystkim, którzy używają REA, zgłaszają błędy, proponują nowe funkcje, testują kompilacje i przesyłają poprawki.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · **Português (Brasil)** · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Faça engenharia reversa de qualquer coisa
|
# REA: Faça engenharia reversa de qualquer coisa
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Relate vulnerabilidades conforme [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
## Histórico de estrelas
|
## Histórico de estrelas
|
||||||
|
|
||||||
🎉 **40.000 estrelas no GitHub — muito obrigado!**
|
🎉 **50.000 estrelas no GitHub — muito obrigado!**
|
||||||
|
|
||||||
Obrigado a todos que usam o REA, relatam bugs, sugerem funcionalidades, testam builds e contribuem com correções.
|
Obrigado a todos que usam o REA, relatam bugs, sugerem funcionalidades, testam builds e contribuem com correções.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · **Русский** · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Реверс-инжиниринг чего угодно
|
# REA: Реверс-инжиниринг чего угодно
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## История звёзд
|
## История звёзд
|
||||||
|
|
||||||
🎉 **40 000 звёзд на GitHub — спасибо!**
|
🎉 **50 000 звёзд на GitHub — спасибо!**
|
||||||
|
|
||||||
Спасибо всем, кто использует REA, сообщает об ошибках, предлагает новые функции, тестирует сборки и помогает с исправлениями.
|
Спасибо всем, кто использует REA, сообщает об ошибках, предлагает новые функции, тестирует сборки и помогает с исправлениями.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · **ไทย** · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Reverse Engineer Anything
|
# REA: Reverse Engineer Anything
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## ประวัติดาว
|
## ประวัติดาว
|
||||||
|
|
||||||
🎉 **40,000 ดาวบน GitHub — ขอบคุณทุกคน!**
|
🎉 **50,000 ดาวบน GitHub — ขอบคุณทุกคน!**
|
||||||
|
|
||||||
ขอบคุณทุกคนที่ใช้ REA รายงานบั๊ก เสนอฟีเจอร์ ทดสอบบิลด์ และช่วยแก้ไข
|
ขอบคุณทุกคนที่ใช้ REA รายงานบั๊ก เสนอฟีเจอร์ ทดสอบบิลด์ และช่วยแก้ไข
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **Türkçe** · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Her şeyi tersine mühendislikle inceleyin
|
# REA: Her şeyi tersine mühendislikle inceleyin
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Güvenlik açıklarını [SECURITY.md](SECURITY.md) üzerinden bildirin.
|
|||||||
|
|
||||||
## Yıldız geçmişi
|
## Yıldız geçmişi
|
||||||
|
|
||||||
🎉 **GitHub'da 40.000 yıldız — teşekkürler!**
|
🎉 **GitHub'da 50.000 yıldız — teşekkürler!**
|
||||||
|
|
||||||
REA'yı kullanan, hata bildiren, özellik talep eden, derlemeleri test eden ve düzeltmelere katkıda bulunan herkese teşekkürler.
|
REA'yı kullanan, hata bildiren, özellik talep eden, derlemeleri test eden ve düzeltmelere katkıda bulunan herkese teşekkürler.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · **Українська** · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Реверс-інжиніринг будь-чого
|
# REA: Реверс-інжиніринг будь-чого
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## Історія зірок
|
## Історія зірок
|
||||||
|
|
||||||
🎉 **40 000 зірок на GitHub — дякуємо!**
|
🎉 **50 000 зірок на GitHub — дякуємо!**
|
||||||
|
|
||||||
Дякуємо всім, хто використовує REA, повідомляє про помилки, пропонує нові функції, тестує збірки й допомагає з виправленнями.
|
Дякуємо всім, хто використовує REA, повідомляє про помилки, пропонує нові функції, тестує збірки й допомагає з виправленнями.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · **Tiếng Việt** · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA: Kỹ thuật dịch ngược cho mọi thứ
|
# REA: Kỹ thuật dịch ngược cho mọi thứ
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Báo cáo lỗ hổng theo [SECURITY.md](SECURITY.md).
|
|||||||
|
|
||||||
## Lịch sử sao
|
## Lịch sử sao
|
||||||
|
|
||||||
🎉 **40.000 sao trên GitHub — xin cảm ơn!**
|
🎉 **50.000 sao trên GitHub — xin cảm ơn!**
|
||||||
|
|
||||||
Cảm ơn mọi người đã sử dụng REA, báo lỗi, đề xuất tính năng, kiểm thử bản dựng và đóng góp bản sửa lỗi.
|
Cảm ơn mọi người đã sử dụng REA, báo lỗi, đề xuất tính năng, kiểm thử bản dựng và đóng góp bản sửa lỗi.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · [简体中文](README_zh.md) · **繁體中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA:逆向分析一切
|
# REA:逆向分析一切
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## Star 歷史
|
## Star 歷史
|
||||||
|
|
||||||
🎉 **GitHub Star 達到 40,000 個,感謝大家!**
|
🎉 **GitHub Star 達到 50,000 個,感謝大家!**
|
||||||
|
|
||||||
感謝每一位使用 REA、報告錯誤、提出功能需求、測試建置和貢獻修復的朋友。
|
感謝每一位使用 REA、報告錯誤、提出功能需求、測試建置和貢獻修復的朋友。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[English](README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md)
|
[English](README.md) · **简体中文** · [繁體中文](README_zh-TW.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [Türkçe](README_tr.md) · [Русский](README_ru.md) · [Tiếng Việt](README_vi.md) · [ไทย](README_th.md) · [Deutsch](README_de.md) · [Español](README_es.md) · [Français](README_fr.md) · [Українська](README_uk.md) · [Polski](README_pl.md) · [Português (Brasil)](README_pt-BR.md) · [العربية](README_ar.md) · [فارسی](README_fa.md) · [Bahasa Indonesia](README_id.md)
|
||||||
|
|
||||||
# REA:逆向分析一切
|
# REA:逆向分析一切
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ npx rea-agents@latest setup
|
|||||||
|
|
||||||
## Star 历史
|
## Star 历史
|
||||||
|
|
||||||
🎉 **GitHub Star 达到 40,000 个,感谢大家!**
|
🎉 **GitHub Star 达到 50,000 个,感谢大家!**
|
||||||
|
|
||||||
感谢每一位使用 REA、报告错误、提出功能需求、测试构建和贡献修复的朋友。
|
感谢每一位使用 REA、报告错误、提出功能需求、测试构建和贡献修复的朋友。
|
||||||
|
|
||||||
|
|||||||
@@ -126,8 +126,13 @@ also stops target hashing before provider admission.
|
|||||||
|
|
||||||
These operations do not execute the APK or provide split APK/AAB handling,
|
These operations do not execute the APK or provide split APK/AAB handling,
|
||||||
signature validation, full resource-table semantics, native-library analysis or
|
signature validation, full resource-table semantics, native-library analysis or
|
||||||
Android runtime capture. Existing artifact inventory/extraction tools can supply
|
Android runtime capture. An `.aab` suffix is a ZIP archive, not an APK, so
|
||||||
archive evidence. Project that Evidence with the execution-free inventory tool:
|
Android tools and the application graph do not treat it as a standalone APK.
|
||||||
|
Existing artifact inventory/extraction tools can supply archive evidence.
|
||||||
|
Inventory keeps ZIP names that differ only in case and records that a
|
||||||
|
case-insensitive destination cannot store both spellings. Extraction fails
|
||||||
|
only when that destination directory cannot keep both names. Project that
|
||||||
|
Evidence with the execution-free inventory tool:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rea project-android-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
|
rea project-android-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
|
|||||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG.
|
- `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG.
|
||||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||||
|
- The CLI comparison commands accept each capture, screenshot artifact or normalization policy as inline JSON or a JSON file path. Use files for screenshot artifacts and scenario captures, whose embedded PNG bytes can exceed the host's command-line length limit.
|
||||||
- Every surface has equivalent CLI and MCP contracts and returns Evidence provenance.
|
- Every surface has equivalent CLI and MCP contracts and returns Evidence provenance.
|
||||||
- MCP tools return complete results inline and include the Evidence ID. Session evidence can be exported with `export_evidence_bundle`.
|
- MCP tools return complete results inline and include the Evidence ID. Session evidence can be exported with `export_evidence_bundle`.
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -20,6 +20,23 @@ files when neither `--provider` nor `--snapshot` is supplied. See
|
|||||||
[JavaScript artifact reconstruction](javascript-artifact-reconstruction.md)
|
[JavaScript artifact reconstruction](javascript-artifact-reconstruction.md)
|
||||||
for results, integrity checks and coverage.
|
for results, integrity checks and coverage.
|
||||||
|
|
||||||
|
The complete application Evidence can be hundreds of megabytes for a real
|
||||||
|
application. When an agent or a person reads the result, save it to a file and
|
||||||
|
project a summary, a module page or one module from the saved Evidence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rea analyze-javascript-application /absolute/path/to/app --json > app-evidence.json
|
||||||
|
jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evidence.json > app-view.json
|
||||||
|
rea inspect-analysis-view app-view.json
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, a 334 MB Obsidian application Evidence file yields a summary view
|
||||||
|
of about 10 KB. Replace the view with
|
||||||
|
`{"kind": "page", "collection": "modules", "offset": 0, "limit": 32}` for a
|
||||||
|
module page. See
|
||||||
|
[JavaScript application workflows](javascript-application-workflows.md) for
|
||||||
|
views, feature traces and comparisons that take the same saved Evidence.
|
||||||
|
|
||||||
## Native analysis
|
## Native analysis
|
||||||
|
|
||||||
Configure [Hopper or Ghidra](installation.md#hopper), or the
|
Configure [Hopper or Ghidra](installation.md#hopper), or the
|
||||||
@@ -179,7 +196,10 @@ user permissions; consult the chosen guide for host requirements and effects.
|
|||||||
## Output and exit status
|
## Output and exit status
|
||||||
|
|
||||||
The default terminal format is TOON. Use `--json` when saving results for a
|
The default terminal format is TOON. Use `--json` when saving results for a
|
||||||
JSON consumer. Output selection and formatting do not change operation status.
|
JSON consumer. `--json` is indented on a terminal and compact when piped or
|
||||||
|
redirected, which keeps large results smaller for agents and scripts; pipe
|
||||||
|
through `jq .` for indented files. Output selection and formatting do not change
|
||||||
|
operation status.
|
||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -62,11 +62,14 @@ Mach-O target coverage. It requires `clang`, LLD, and `lld-link`; set
|
|||||||
these tools before compilation. Keeping this matrix separate lets Linux
|
these tools before compilation. Keeping this matrix separate lets Linux
|
||||||
host/provider acceptance run with only the host compiler.
|
host/provider acceptance run with only the host compiler.
|
||||||
|
|
||||||
`npm run verify:ghidra:windows` and its `-- --x86` variant check the source-owned native x86 and x86-64 PE
|
`npm run verify:ghidra:windows` checks source-owned native x86-64 PE applications;
|
||||||
fixture, all 25 read-only operations, target/snapshot/import SHA-256 linkage,
|
`-- --x86` selects x86 and `-- --dll` selects DLLs, and the flags can be combined.
|
||||||
|
These lanes check all 25 read-only operations, target/snapshot/import SHA-256 linkage,
|
||||||
and project, endpoint, process, and runtime cleanup. The independent native
|
and project, endpoint, process, and runtime cleanup. The independent native
|
||||||
lane checks DACLs, handle admission, cancellation, and Job Object lifecycle.
|
lane checks DACLs, handle admission, cancellation, and Job Object lifecycle.
|
||||||
`npm run verify:ghidra:windows:package` packs and installs REA into an isolated
|
`npm run verify:ghidra:windows:package` packs and installs REA into an isolated
|
||||||
prefix, then checks ordinary-user CLI/MCP operations against canonical contracts.
|
prefix, then checks ordinary-user CLI/MCP operations against canonical contracts.
|
||||||
|
It accepts the same architecture and DLL flags and verifies the selected PE role
|
||||||
|
in the analysis profile.
|
||||||
All lanes require matching native controls; package startup alone establishes
|
All lanes require matching native controls; package startup alone establishes
|
||||||
only package compatibility.
|
only package compatibility.
|
||||||
|
|||||||
+80
-10
@@ -95,7 +95,7 @@ npx skills add morluto/rea --skill reverse-engineer-anything
|
|||||||
|
|
||||||
This installs agent instructions and bundled references, not REA MCP
|
This installs agent instructions and bundled references, not REA MCP
|
||||||
registration or analysis engines. Follow the skill's
|
registration or analysis engines. Follow the skill's
|
||||||
[conditional connection guide](https://github.com/morluto/rea/blob/main/skill-src/reverse-engineer-anything/SKILL.md#connect-only-when-needed).
|
[conditional connection guide](https://github.com/morluto/rea/blob/main/.agents/skills/reverse-engineer-anything/SKILL.md#connect-only-when-needed).
|
||||||
Working tools can be used immediately. If tools are missing, inspect the current
|
Working tools can be used immediately. If tools are missing, inspect the current
|
||||||
client's registration with `doctor --client codex --json` (substitute its client
|
client's registration with `doctor --client codex --json` (substitute its client
|
||||||
ID), then plan repairs with `setup --client codex --dry-run --json`. Show and
|
ID), then plan repairs with `setup --client codex --dry-run --json`. Show and
|
||||||
@@ -135,26 +135,95 @@ listed after the table because its connector is not one of these files:
|
|||||||
| Antigravity | `antigravity` |
|
| Antigravity | `antigravity` |
|
||||||
| GitHub Copilot CLI | `copilot_cli` |
|
| GitHub Copilot CLI | `copilot_cli` |
|
||||||
| Command Code | `commandcode` |
|
| Command Code | `commandcode` |
|
||||||
|
| Qwen Code | `qwen_code` |
|
||||||
| VS Code | `vscode` |
|
| VS Code | `vscode` |
|
||||||
| Grok Build | `grok_build` |
|
| Grok Build | `grok_build` |
|
||||||
| OMP | `omp` |
|
| 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.
|
||||||
|
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
|
For OMP, setup writes a `type: "stdio"` entry to the user-level
|
||||||
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR`, an absolute
|
`~/.omp/agent/mcp.json`. It follows `PI_CONFIG_DIR` and
|
||||||
`PI_CODING_AGENT_DIR`, and the profile selected by `OMP_PROFILE` or
|
`PI_CODING_AGENT_DIR` (relative to the setup working directory), and the profile selected by `OMP_PROFILE` or
|
||||||
`PI_PROFILE` (`~/.omp/profiles/<name>/agent/mcp.json`). Setup also removes
|
`PI_PROFILE` (`~/.omp/profiles/<name>/agent/mcp.json`). Setup also removes
|
||||||
`rea` from that file's `disabledServers` list, which would otherwise hide the
|
`rea` from that file's `disabledServers` list, which would otherwise hide the
|
||||||
registration. Doctor treats an `enabled: false` entry as active when
|
registration. Doctor treats an `enabled: false` entry as active when
|
||||||
`enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists
|
`enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists
|
||||||
it. Run setup under each profile that should load REA.
|
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
|
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
|
both load. If the configuration already uses OpenCode V2's native
|
||||||
`mcp.servers` table, setup registers REA there instead and replaces any earlier
|
`mcp.servers` table, setup registers REA there instead and replaces any earlier
|
||||||
`mcp.rea` entry from REA.
|
`mcp.rea` entry from REA. Setup honors `OPENCODE_CONFIG_DIR` and
|
||||||
|
`OPENCODE_CONFIG`, preferring the later-loaded JSONC file when both JSON and
|
||||||
|
JSONC exist. The ordinary XDG global configuration still participates when a
|
||||||
|
custom directory is selected. Doctor checks the merged user registration; uninstall removes
|
||||||
|
REA-owned entries from every applicable user file so an older entry cannot
|
||||||
|
become active again. Malformed applicable files block setup before changes.
|
||||||
|
|
||||||
Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from
|
Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from
|
||||||
`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server
|
`~/.grok/config.toml` when `GROK_HOME` is unset or empty. Setup edits that server
|
||||||
table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that
|
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
|
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
|
in that list. The shared skill installed under `~/.agents/skills` is already
|
||||||
@@ -194,10 +263,11 @@ save verified paths for an existing Ghidra installation.
|
|||||||
|
|
||||||
The bundled skill is installed where each selected client discovers personal
|
The bundled skill is installed where each selected client discovers personal
|
||||||
skills: Claude Code uses `~/.claude/skills` (or
|
skills: Claude Code uses `~/.claude/skills` (or
|
||||||
`$CLAUDE_CONFIG_DIR/skills` when configured), while other supported clients
|
`$CLAUDE_CONFIG_DIR/skills` when configured). Gemini CLI uses `.agents/skills`
|
||||||
use the shared `~/.agents/skills` directory. A mixed selection plans both
|
under its selected home; Antigravity 2.0 uses `~/.gemini/config/skills`.
|
||||||
paths. Selecting the skill without a client uses the shared directory. Setup
|
Other integrations, including Qwen Code, use `~/.agents/skills`.
|
||||||
leaves existing skill copies in other locations untouched.
|
A mixed selection plans each distinct destination. Selecting the skill without a client uses the shared
|
||||||
|
directory. Setup leaves existing skill copies in other locations untouched.
|
||||||
|
|
||||||
`doctor --skill --json` verifies the selected copies against the bundled
|
`doctor --skill --json` verifies the selected copies against the bundled
|
||||||
instructions and references. Consumers should use `identity.skill.state`,
|
instructions and references. Consumers should use `identity.skill.state`,
|
||||||
@@ -489,7 +559,7 @@ entry comments atomically and returns refreshed analysis. These session metadata
|
|||||||
edits leave executable bytes unchanged and are discarded on close. GUI controls
|
edits leave executable bytes unchanged and are discarded on close. GUI controls
|
||||||
require Hopper; Windows P0 remains read-only.
|
require Hopper; Windows P0 remains read-only.
|
||||||
|
|
||||||
Windows P0 admits native x86 and x86-64 PE applications on fixed local NTFS volumes.
|
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
|
||||||
The npm package bundles native Job Object ownership, protected private runtime
|
The npm package bundles native Job Object ownership, protected private runtime
|
||||||
DACLs, and handle-based path admission; no separate addon installation is needed.
|
DACLs, and handle-based path admission; no separate addon installation is needed.
|
||||||
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and
|
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and
|
||||||
|
|||||||
@@ -90,6 +90,44 @@ keep the result ambiguous. Runtime Evidence can later corroborate an exact
|
|||||||
mapped candidate, but structural reachability, semantic influence, runtime
|
mapped candidate, but structural reachability, semantic influence, runtime
|
||||||
observation, and causal proof remain separate claims.
|
observation, and causal proof remain separate claims.
|
||||||
|
|
||||||
|
Semantic literal nodes keep their complete value in `properties.value`; literal
|
||||||
|
queries match that field. String labels use `string literal` when that is
|
||||||
|
shorter than the complete JSON value. A literal's `identity.role_key` commits
|
||||||
|
its canonical JSON value as `value-sha256:<digest>` when the digest key is
|
||||||
|
shorter; smaller values keep their existing JSON key. This keeps large values
|
||||||
|
out of identity and display metadata without expanding short values or
|
||||||
|
truncating their Evidence.
|
||||||
|
|
||||||
|
New analyses therefore produce different IDs for hashed literals from the
|
||||||
|
earlier payload-bearing role keys. Use IDs returned by the selected parent
|
||||||
|
graph and read literal values from `properties.value`, rather than decoding
|
||||||
|
role keys or labels. Previously stored graphs and their original IDs remain
|
||||||
|
valid inputs.
|
||||||
|
|
||||||
|
Endpoint nodes keep the exact endpoint in each observation's `properties.value`;
|
||||||
|
semantic request and response nodes keep it in `properties.endpoint`. Endpoint
|
||||||
|
identity keys use `value-sha256:<digest>` over the canonical JSON string when
|
||||||
|
that is shorter, or when the original value starts with the reserved digest
|
||||||
|
prefix. Other short endpoint keys remain unchanged. Endpoint labels use the
|
||||||
|
shorter of the exact value and `<kind> endpoint`; semantic request/response
|
||||||
|
labels use the shorter of the endpoint and operation method. These labels are
|
||||||
|
display metadata, not endpoint lookup values.
|
||||||
|
|
||||||
|
New analyses therefore change hashed endpoint IDs and longer endpoint labels.
|
||||||
|
Use IDs from the selected graph and read the complete endpoint properties;
|
||||||
|
previously stored graphs still support their original IDs. Literal endpoint
|
||||||
|
search, semantic tracing, and version comparison use the preserved values.
|
||||||
|
|
||||||
|
Event and listener nodes likewise preserve the complete event name in
|
||||||
|
`properties.event_name`. Event role keys use the same reserved-prefix string
|
||||||
|
digest rule, scoped to the emitter; repeated registrations, removals, and
|
||||||
|
dispatches keep one event per exact emitter/name pair. Long event labels use
|
||||||
|
`event`, and listener labels use the shorter of `<method>:<name>` and
|
||||||
|
`<method>:listener`. Dynamic names remain `null` with explicit uncertainty. New analyses
|
||||||
|
change hashed event IDs and longer event/listener labels; query the preserved
|
||||||
|
name or select IDs from the parent graph. Previously stored graphs keep their
|
||||||
|
original IDs and remain valid trace inputs.
|
||||||
|
|
||||||
## Version comparison
|
## Version comparison
|
||||||
|
|
||||||
REA pairs entities only when a tier produces one unique candidate on each side.
|
REA pairs entities only when a tier produces one unique candidate on each side.
|
||||||
@@ -145,8 +183,13 @@ Direct return expressions, including expression-bodied arrows, are evaluated
|
|||||||
through an execution-free value lattice. Literal object fields and direct
|
through an execution-free value lattice. Literal object fields and direct
|
||||||
return sites are represented in the result. Calls, dynamic spreads, computed
|
return sites are represented in the result. Calls, dynamic spreads, computed
|
||||||
keys, and parser recovery remain partial or unknown.
|
keys, and parser recovery remain partial or unknown.
|
||||||
Objects and arrays passed to calls, constructors, or tagged templates, or used
|
Objects and arrays passed to calls, constructors, or tagged templates, used
|
||||||
as method receivers, are not assumed unchanged after the invocation. Aliases
|
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
|
||||||
and shared children in spread and rest copies retain that uncertainty; copied
|
and shared children in spread and rest copies retain that uncertainty; copied
|
||||||
primitive slots and unrelated containing properties remain known. Object rest
|
primitive slots and unrelated containing properties remain known. Object rest
|
||||||
excludes consumed keys, array rest excludes the consumed prefix for known
|
excludes consumed keys, array rest excludes the consumed prefix for known
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ x86-64 ELF and native Windows PE targets analyzed on Linux x64 with Ghidra
|
|||||||
See [NativeAOT recovery](ghidra-nativeaot.md) for build/configuration, recovered
|
See [NativeAOT recovery](ghidra-nativeaot.md) for build/configuration, recovered
|
||||||
metadata, derived-memory provenance, and the real verification lane.
|
metadata, derived-memory provenance, and the real verification lane.
|
||||||
|
|
||||||
|
REA's experimental Windows Ghidra boundary admits native x86 and x86-64 PE
|
||||||
|
applications and DLLs for static analysis. This does not establish Windows
|
||||||
|
NativeAOT metadata-recovery coverage.
|
||||||
|
|
||||||
For NativeAOT analysis, preserve the selected native artifact's path and digest
|
For NativeAOT analysis, preserve the selected native artifact's path and digest
|
||||||
and report recovered type data as provider observations. Linking those types
|
and report recovered type data as provider observations. Linking those types
|
||||||
to native function addresses requires verified
|
to native function addresses requires verified
|
||||||
|
|||||||
+87
-6
@@ -36,6 +36,56 @@ after SDK conversion and check advertised validation and actual calls.
|
|||||||
Individual model APIs can impose additional nesting limits; complete producer
|
Individual model APIs can impose additional nesting limits; complete producer
|
||||||
captures can exceed ten structural levels.
|
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
|
||||||
|
identity. Explicit schema IDs and relative external reference bases are
|
||||||
|
preserved. This keeps every tool and validation rule in discovery and does not
|
||||||
|
change the SDK's catalog invalidation or availability checks.
|
||||||
|
|
||||||
`compare_web_captures` accepts exactly one of two input shapes:
|
`compare_web_captures` accepts exactly one of two input shapes:
|
||||||
|
|
||||||
- Passive: `before` and `after` each contain `inspection`, the complete
|
- Passive: `before` and `after` each contain `inspection`, the complete
|
||||||
@@ -94,6 +144,12 @@ if retention is unavailable or fails, the diagnostic reports that reason.
|
|||||||
REA accepts ordinary `tools/call` progress tokens. Updates are monotonic,
|
REA accepts ordinary `tools/call` progress tokens. Updates are monotonic,
|
||||||
rate-bounded to at most one intermediate update per 100 ms, and always allow a
|
rate-bounded to at most one intermediate update per 100 ms, and always allow a
|
||||||
terminal update. Unknown totals are omitted; REA does not fabricate percentages.
|
terminal update. Unknown totals are omitted; REA does not fabricate percentages.
|
||||||
|
`capture_process_scenario` uses that boundary only when the client supplies a
|
||||||
|
progress token. Notifications set `progress` to the count of collected terminal
|
||||||
|
frames, process samples, and interaction events, omit `total`, and put the
|
||||||
|
lifecycle phase, elapsed time, and final cleanup status in `message`. A client
|
||||||
|
that does not send a progress token receives no progress notifications. The
|
||||||
|
tool result is unchanged either way.
|
||||||
Provider calls receive the request cancellation signal. Artifact traversal,
|
Provider calls receive the request cancellation signal. Artifact traversal,
|
||||||
hashing, version comparisons, Hopper requests, and process capture
|
hashing, version comparisons, Hopper requests, and process capture
|
||||||
check the same signal. Cancellation is distinct from timeout. A cleanup failure
|
check the same signal. Cancellation is distinct from timeout. A cleanup failure
|
||||||
@@ -186,10 +242,27 @@ the current client, so it is not a fresh-start recovery. On the same connection:
|
|||||||
|
|
||||||
This close/reopen flow was exercised on one real Linux stdio connection after a
|
This close/reopen flow was exercised on one real Linux stdio connection after a
|
||||||
controlled startup timeout, followed by a successful overview and function
|
controlled startup timeout, followed by a successful overview and function
|
||||||
analysis. It is not a Windows/macOS coverage claim. REA cleans only resources it
|
analysis. A separate Windows x64 stdio run with Ghidra 12.1.4, JDK 21 and Node
|
||||||
owns and never switches to another provider automatically. A provider timeout,
|
22.19.0 exercised the same connection and caller-selected native PE: the default
|
||||||
installation failure, or host permission denial needs its own reported recovery;
|
60-second client request timed out, close succeeded, and reopening before a
|
||||||
increasing a client deadline alone does not fix those failures.
|
240-second request completed the cold overview in about 163 seconds. The next
|
||||||
|
overview took about 19 ms. The cold and warm overviews reported two procedures
|
||||||
|
and two segments; the input digest was unchanged and no owned runtime root or
|
||||||
|
observed process-family member remained after final close. This was a small
|
||||||
|
1,024-byte fixture, so the longer request setting remains an example, not a
|
||||||
|
guarantee for larger targets. No macOS cold-start recovery was exercised.
|
||||||
|
|
||||||
|
With a supplied progress token, the Windows run received an operation-start
|
||||||
|
notification before timeout and start/completion notifications for the
|
||||||
|
successful retry. It received no intermediate import or auto-analysis progress.
|
||||||
|
These operation markers do not measure analysis work completed or extend the
|
||||||
|
client's deadline. Each new provider session still imports the target into a
|
||||||
|
fresh temporary project.
|
||||||
|
|
||||||
|
REA cleans only resources it owns and never switches to another provider
|
||||||
|
automatically. A provider timeout, installation failure, or host permission
|
||||||
|
denial needs its own reported recovery; increasing a client deadline alone does
|
||||||
|
not fix those failures.
|
||||||
|
|
||||||
## Tool results
|
## Tool results
|
||||||
|
|
||||||
@@ -198,7 +271,9 @@ Custom clients upgrading from 6.1 should follow the
|
|||||||
|
|
||||||
Evidence-producing tools return the complete canonical Evidence record in both
|
Evidence-producing tools return the complete canonical Evidence record in both
|
||||||
text and structured content. Read `structuredContent.normalized_result` for the
|
text and structured content. Read `structuredContent.normalized_result` for the
|
||||||
operation result and `structuredContent.evidence_id` for its identity. The same
|
operation result and `structuredContent.evidence_id` for its identity.
|
||||||
|
`raw_result` holds a provider representation distinct from that result, such
|
||||||
|
as an upstream tool's original report, and is `null` when there is none. The same
|
||||||
record is retained in the session bundle. `analysis_profile` is always present:
|
record is retained in the session bundle. `analysis_profile` is always present:
|
||||||
a concrete profile object or `null`; either value participates in semantic
|
a concrete profile object or `null`; either value participates in semantic
|
||||||
identity. Records omitting this field are rejected. Pass the returned Evidence
|
identity. Records omitting this field are rejected. Pass the returned Evidence
|
||||||
@@ -235,7 +310,13 @@ Ordinary responses keep their existing complete result contract.
|
|||||||
## Retained application Evidence inputs
|
## Retained application Evidence inputs
|
||||||
|
|
||||||
`inspect_analysis_view` projects a caller-selected view of already completed
|
`inspect_analysis_view` projects a caller-selected view of already completed
|
||||||
`inspect_binary_layout` or `analyze_javascript_application` Evidence. Source is
|
`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
|
||||||
|
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
|
||||||
an exact same-session retained reference or portable inline Evidence. Views are
|
an exact same-session retained reference or portable inline Evidence. Views are
|
||||||
a summary, a layout mitigations or linkage facet, one section/symbol/module, or
|
a summary, a layout mitigations or linkage facet, one section/symbol/module, or
|
||||||
a stable page with a caller-selected positive `limit`. Module pages include
|
a stable page with a caller-selected positive `limit`. Module pages include
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ the tool fails with a `debugger-attach-denied` reason.
|
|||||||
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
|
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
|
||||||
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
|
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
|
||||||
the matching native decompiler. Experimental Windows x64 P0 admits native
|
the matching native decompiler. Experimental Windows x64 P0 admits native
|
||||||
x86-64 PE applications on local NTFS using bundled Job Object ownership,
|
x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
|
||||||
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
|
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
|
||||||
[issue #527](https://github.com/morluto/rea/issues/527).
|
[issue #527](https://github.com/morluto/rea/issues/527).
|
||||||
On Linux and macOS, `annotate_native_function` atomically edits a function name
|
On Linux and macOS, `annotate_native_function` atomically edits a function name
|
||||||
|
|||||||
+89
-2
@@ -51,6 +51,34 @@ format is TOON. The file contains the complete capture Evidence record.
|
|||||||
Choose an output file distinct from the scenario input: shell redirection opens
|
Choose an output file distinct from the scenario input: shell redirection opens
|
||||||
and truncates the output before REA reads the scenario.
|
and truncates the output before REA reads the scenario.
|
||||||
|
|
||||||
|
While the capture runs, REA writes live status to stderr as one JSON object per
|
||||||
|
line. Stdout stays the final Evidence document:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rea_progress": {
|
||||||
|
"phase": "running",
|
||||||
|
"completed": 3,
|
||||||
|
"total": null,
|
||||||
|
"message": "elapsed_ms=1200 frames=1 samples=2 interactions=0",
|
||||||
|
"sequence": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`phase` is the observed lifecycle stage: `prepare`, `running`, `settling`, or
|
||||||
|
`cleanup`. `completed` counts collected terminal frames, process samples, and
|
||||||
|
interaction events. `total` stays null; REA does not invent a unit total or a
|
||||||
|
percentage. `message` reports elapsed time and those counts. The final line uses
|
||||||
|
`phase` `cleanup`, sets `terminal` to true, and adds `disposition` (`exited`,
|
||||||
|
`timeout`, `idle_timeout`, `cancelled`, or `failed`) plus the owned-process,
|
||||||
|
renderer, and temporary-root cleanup states. Updates use the shared progress
|
||||||
|
boundary and are limited to one intermediate line per 100 ms; the terminal line
|
||||||
|
is always emitted. If a receiver is slow, intermediate observations are merged
|
||||||
|
into the latest status, and stale live observations are discarded before final
|
||||||
|
cleanup status. Progress never copies child output. Child output remains in the
|
||||||
|
capture Evidence on stdout.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -110,6 +138,23 @@ while comparisons and trace assertions use the normalized text. Retaining the
|
|||||||
original adds at most `limits.output_bytes` of text, because it comes from the
|
original adds at most `limits.output_bytes` of text, because it comes from the
|
||||||
same admitted PTY chunks rather than a second unbounded stream.
|
same admitted PTY chunks rather than a second unbounded stream.
|
||||||
|
|
||||||
|
Rendered frame `lines` hold the visible rows after normalization, with trailing
|
||||||
|
U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records
|
||||||
|
terminal cell width; normalization and Unicode can make string lengths differ
|
||||||
|
from that width. `serialized_state` holds terminal serialization after the
|
||||||
|
selected normalization. Original admitted PTY chunks are available through
|
||||||
|
`raw_data ?? data`, as described above. Captures commit this line format in their
|
||||||
|
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
|
Port normalization recognizes explicit `port`, `tcp_port`, `udp_port`, and
|
||||||
`listen` fields, URL authorities, IP endpoints, and `localhost` endpoints, with
|
`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.`;
|
ports from 0 through 65535. A port may end a sentence, as in `port: 8080.`;
|
||||||
@@ -159,8 +204,11 @@ Trace assertions use coverage for the sources they select, so an assertion
|
|||||||
about complete raw terminal output need not fail because rendered snapshots
|
about complete raw terminal output need not fail because rendered snapshots
|
||||||
were omitted. Captures missing `truncation_details` are rejected on import;
|
were omitted. Captures missing `truncation_details` are rejected on import;
|
||||||
preserve them as historical files and recapture for current comparisons.
|
preserve them as historical files and recapture for current comparisons.
|
||||||
These diagnostics use the existing scenario
|
`limits.file_bytes` is already the cumulative whole-file hashing budget,
|
||||||
budgets; they do not introduce a separate file-hashing budget.
|
independent of `limits.output_bytes`, which bounds retained terminal content.
|
||||||
|
Each filesystem checkpoint starts with a fresh `file_bytes` budget across all
|
||||||
|
selected roots; it is not a per-file allowance. File contents are read in
|
||||||
|
64 KiB chunks for hashing and are not embedded in capture Evidence.
|
||||||
|
|
||||||
When the host withholds an unrelated process’s ownership token, REA leaves that
|
When the host withholds an unrelated process’s ownership token, REA leaves that
|
||||||
process untouched and records its PID and reason in `cleanup.unverified_processes`
|
process untouched and records its PID and reason in `cleanup.unverified_processes`
|
||||||
@@ -176,6 +224,45 @@ as well. A newly started unreadable process can prevent verified cleanup even
|
|||||||
after the selected command exits. REA preserves this uncertainty and leaves
|
after the selected command exits. REA preserves this uncertainty and leaves
|
||||||
that process untouched.
|
that process untouched.
|
||||||
|
|
||||||
|
## Hash a report independently of terminal output
|
||||||
|
|
||||||
|
Select the producer's report directory and give `file_bytes` enough room for
|
||||||
|
the complete files you need, including other files in those roots. For example,
|
||||||
|
save this scenario and run `rea capture-process ./scenario.json --json > capture.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"executable": "node",
|
||||||
|
"arguments": [
|
||||||
|
"-e",
|
||||||
|
"const fs=require('node:fs');fs.mkdirSync('reports',{recursive:true});fs.writeFileSync('reports/final.json',JSON.stringify({status:'complete',data:'X'.repeat(131072)}));console.log('x'.repeat(8192));"
|
||||||
|
],
|
||||||
|
"working_directory": ".",
|
||||||
|
"filesystem_observation_paths": ["./reports"],
|
||||||
|
"limits": { "output_bytes": 1024, "file_bytes": 262144 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The report exceeds the terminal budget but fits the independent 256 KiB hash
|
||||||
|
budget. Its complete SHA-256 appears in `normalized_result.files_after` at
|
||||||
|
`root_0:final.json`; check `truncation_details.filesystem_after` for omissions
|
||||||
|
and actual bytes hashed. The verbose terminal output is truncated without
|
||||||
|
preventing the report digest. The same scenario object is the MCP
|
||||||
|
`capture_process_scenario` input. REA never returns a prefix hash as a full-file
|
||||||
|
digest: an insufficient remaining budget produces a null digest and an
|
||||||
|
explicit omission reason.
|
||||||
|
|
||||||
|
A digest binds the complete bytes observed at that checkpoint, not the
|
||||||
|
producer's notion of a finished report. A stable periodic or running report
|
||||||
|
can also have a complete digest. Choose the final file and verify the
|
||||||
|
producer's documented completion marker, exit outcome and settlement
|
||||||
|
observations before calling it final. `after_settlement` names a filesystem
|
||||||
|
checkpoint, not a guarantee that an application finished successfully or
|
||||||
|
that an unobserved writer will never change the file. REA checks opened-file
|
||||||
|
identity/state around hashing and reports observed changes or unavailable
|
||||||
|
files; cancellation stops the hash. If an external sealer produces a manifest,
|
||||||
|
keep its completion claims and digest separate from REA's checkpoint facts.
|
||||||
|
|
||||||
## Compare two captures
|
## Compare two captures
|
||||||
|
|
||||||
Compare saved capture Evidence with:
|
Compare saved capture Evidence with:
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ entrypoint and include its verifier/fixture files in their cache inputs.
|
|||||||
|
|
||||||
## End-to-end, integration and golden evidence
|
## 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,
|
Full E2E tests invoke the production command dispatcher and real providers,
|
||||||
without fake launchers, runners or responses. `verify:keyed-archive` writes an
|
without fake launchers, runners or responses. `verify:keyed-archive` writes an
|
||||||
actual Foundation binary archive, runs the CLI and a separate stdio MCP
|
actual Foundation binary archive, runs the CLI and a separate stdio MCP
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ additional permission flag or degraded mode is required.
|
|||||||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
|
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
|
||||||
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
|
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
|
||||||
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
|
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
|
||||||
- an explicit native, non-managed, non-DLL x86 or x86-64 PE application; and
|
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
|
||||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||||
|
|
||||||
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
||||||
@@ -38,6 +38,13 @@ REA selects its packaged bridge scripts by explicit path. Same-named files or
|
|||||||
directories in the caller's working directory do not select a different
|
directories in the caller's working directory do not select a different
|
||||||
script or require users to clean that directory before analysis.
|
script or require users to clean that directory before analysis.
|
||||||
|
|
||||||
|
Native DLL analysis preserves the PE shared-library role in the analysis profile.
|
||||||
|
REA imports an immutable copy for static analysis; it does not load the library
|
||||||
|
into the analyst process or execute its entry routine. The source-owned DLL
|
||||||
|
fixtures have no entry routine or imports and expose two exported functions.
|
||||||
|
The same native snapshot, path-admission and cleanup requirements apply to EXEs
|
||||||
|
and DLLs.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -100,7 +107,7 @@ and artifact SHA-256 before loading the package-owned addon. Native failure
|
|||||||
reasons remain distinct from a valid installation and an unsupported target.
|
reasons remain distinct from a valid installation and an unsupported target.
|
||||||
|
|
||||||
PE header classification and SHA-256 come from the same open file. The Windows
|
PE header classification and SHA-256 come from the same open file. The Windows
|
||||||
provider admits native x86 and x86-64 PE applications and rejects unsupported roles,
|
provider admits native x86 and x86-64 PE applications and DLLs and rejects unsupported roles,
|
||||||
architectures, managed images, and malformed headers. The original selected
|
architectures, managed images, and malformed headers. The original selected
|
||||||
source coordinate is preserved for native admission.
|
source coordinate is preserved for native admission.
|
||||||
|
|
||||||
@@ -159,7 +166,11 @@ artifact and manifest.
|
|||||||
npm run verify:windows-native
|
npm run verify:windows-native
|
||||||
npm run verify:ghidra:windows
|
npm run verify:ghidra:windows
|
||||||
npm run verify:ghidra:windows -- --x86
|
npm run verify:ghidra:windows -- --x86
|
||||||
|
npm run verify:ghidra:windows -- --dll
|
||||||
|
npm run verify:ghidra:windows -- --x86 --dll
|
||||||
npm run verify:ghidra:windows:package -- --x86
|
npm run verify:ghidra:windows:package -- --x86
|
||||||
|
npm run verify:ghidra:windows:package -- --dll
|
||||||
|
npm run verify:ghidra:windows:package -- --x86 --dll
|
||||||
npm run verify:ghidra:windows:package -- C:\fixtures\installed-rea C:\fixtures\sample.exe
|
npm run verify:ghidra:windows:package -- C:\fixtures\installed-rea C:\fixtures\sample.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -41,6 +41,7 @@
|
|||||||
"stream-json": "3.7.0",
|
"stream-json": "3.7.0",
|
||||||
"write-file-atomic": "7.0.0",
|
"write-file-atomic": "7.0.0",
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
|
"yaml": "2.9.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -64,8 +65,7 @@
|
|||||||
"turbo": "2.11.7",
|
"turbo": "2.11.7",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitepress": "1.6.4",
|
"vitepress": "1.6.4",
|
||||||
"vitest": "5.0.3",
|
"vitest": "5.0.3"
|
||||||
"yaml": "2.9.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
||||||
|
|||||||
+6
-3
@@ -179,6 +179,9 @@
|
|||||||
"verify:readiness": "npm run build:cached && npm run verify:fixtures && node scripts/verify-reconstruction-readiness.mjs",
|
"verify: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": "npm run build:cached && node scripts/verify-package.mjs",
|
||||||
"verify:package:windows": "npm run build:cached && node scripts/verify-windows-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",
|
"probe:windows-capabilities": "npm run build:cached && node scripts/windows-capabilities.mjs",
|
||||||
"verify:published": "node scripts/verify-published-package.mjs",
|
"verify:published": "node scripts/verify-published-package.mjs",
|
||||||
"config:print": "node scripts/print-mcp-config.mjs",
|
"config:print": "node scripts/print-mcp-config.mjs",
|
||||||
@@ -269,7 +272,8 @@
|
|||||||
"write-file-atomic": "7.0.0",
|
"write-file-atomic": "7.0.0",
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
"zod": "4.4.3",
|
"zod": "4.4.3",
|
||||||
"evmole": "0.9.3"
|
"evmole": "0.9.3",
|
||||||
|
"yaml": "2.9.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vitepress": {
|
"vitepress": {
|
||||||
@@ -293,7 +297,6 @@
|
|||||||
"turbo": "2.11.7",
|
"turbo": "2.11.7",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitepress": "1.6.4",
|
"vitepress": "1.6.4",
|
||||||
"vitest": "5.0.3",
|
"vitest": "5.0.3"
|
||||||
"yaml": "2.9.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,68 +3,103 @@ import { dirname, resolve } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
// Source-owned static fixtures: no compiler, target loading or execution.
|
||||||
for (const architecture of ["x86_64", "x86"]) {
|
for (const architecture of ["x86_64", "x86"]) {
|
||||||
const x86 = architecture === "x86";
|
for (const dll of [false, true]) {
|
||||||
const output = resolve(
|
const x86 = architecture === "x86";
|
||||||
root,
|
const output = resolve(
|
||||||
"build",
|
root,
|
||||||
"fixtures",
|
"build",
|
||||||
x86 ? "rea-ghidra-windows-x86.exe" : "rea-ghidra-windows.exe",
|
"fixtures",
|
||||||
);
|
`rea-ghidra-windows${x86 ? "-x86" : ""}.${dll ? "dll" : "exe"}`,
|
||||||
const bytes = Buffer.alloc(1024);
|
);
|
||||||
const peOffset = 0x80;
|
const bytes = Buffer.alloc(1024);
|
||||||
const optionalHeader = peOffset + 24;
|
const peOffset = 0x80;
|
||||||
const optionalHeaderSize = x86 ? 0xe0 : 0xf0;
|
const optionalHeader = peOffset + 24;
|
||||||
const sectionTable = optionalHeader + optionalHeaderSize;
|
const optionalHeaderSize = x86 ? 0xe0 : 0xf0;
|
||||||
|
const sectionTable = optionalHeader + optionalHeaderSize;
|
||||||
|
const directories = optionalHeader + (x86 ? 96 : 112);
|
||||||
|
|
||||||
bytes.write("MZ", 0, "ascii");
|
bytes.write("MZ", 0, "ascii");
|
||||||
bytes.writeUInt32LE(peOffset, 0x3c);
|
bytes.writeUInt32LE(peOffset, 0x3c);
|
||||||
bytes.write("PE\0\0", peOffset, "binary");
|
bytes.write("PE\0\0", peOffset, "binary");
|
||||||
bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4);
|
bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4);
|
||||||
bytes.writeUInt16LE(1, peOffset + 6);
|
bytes.writeUInt16LE(1, peOffset + 6);
|
||||||
bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20);
|
bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20);
|
||||||
bytes.writeUInt16LE(x86 ? 0x102 : 0x22, peOffset + 22);
|
bytes.writeUInt16LE(
|
||||||
|
(x86 ? 0x102 : 0x22) | (dll ? 0x2000 : 0),
|
||||||
|
peOffset + 22,
|
||||||
|
);
|
||||||
|
|
||||||
bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader);
|
bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader);
|
||||||
bytes.writeUInt32LE(0x200, optionalHeader + 4);
|
bytes.writeUInt32LE(0x200, optionalHeader + 4);
|
||||||
bytes.writeUInt32LE(0x1000, optionalHeader + 16);
|
bytes.writeUInt32LE(dll ? 0 : 0x1000, optionalHeader + 16);
|
||||||
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
|
bytes.writeUInt32LE(0x1000, optionalHeader + 20);
|
||||||
if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28);
|
if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28);
|
||||||
else bytes.writeBigUInt64LE(0x1_4000_0000n, optionalHeader + 24);
|
else bytes.writeBigUInt64LE(0x1_4000_0000n, optionalHeader + 24);
|
||||||
bytes.writeUInt32LE(0x1000, optionalHeader + 32);
|
bytes.writeUInt32LE(0x1000, optionalHeader + 32);
|
||||||
bytes.writeUInt32LE(0x200, optionalHeader + 36);
|
bytes.writeUInt32LE(0x200, optionalHeader + 36);
|
||||||
bytes.writeUInt16LE(6, optionalHeader + 40);
|
bytes.writeUInt16LE(6, optionalHeader + 40);
|
||||||
bytes.writeUInt16LE(6, optionalHeader + 48);
|
bytes.writeUInt16LE(6, optionalHeader + 48);
|
||||||
bytes.writeUInt32LE(0x2000, optionalHeader + 56);
|
bytes.writeUInt32LE(0x2000, optionalHeader + 56);
|
||||||
bytes.writeUInt32LE(0x200, optionalHeader + 60);
|
bytes.writeUInt32LE(0x200, optionalHeader + 60);
|
||||||
bytes.writeUInt16LE(3, optionalHeader + 68);
|
bytes.writeUInt16LE(3, optionalHeader + 68);
|
||||||
bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70);
|
bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70);
|
||||||
if (x86) {
|
if (x86) {
|
||||||
bytes.writeUInt32LE(0x10_0000, optionalHeader + 72);
|
bytes.writeUInt32LE(0x10_0000, optionalHeader + 72);
|
||||||
bytes.writeUInt32LE(0x1000, optionalHeader + 76);
|
bytes.writeUInt32LE(0x1000, optionalHeader + 76);
|
||||||
bytes.writeUInt32LE(0x10_0000, optionalHeader + 80);
|
bytes.writeUInt32LE(0x10_0000, optionalHeader + 80);
|
||||||
bytes.writeUInt32LE(0x1000, optionalHeader + 84);
|
bytes.writeUInt32LE(0x1000, optionalHeader + 84);
|
||||||
bytes.writeUInt32LE(16, optionalHeader + 92);
|
bytes.writeUInt32LE(16, optionalHeader + 92);
|
||||||
} else {
|
} else {
|
||||||
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 72);
|
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 72);
|
||||||
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 80);
|
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 80);
|
||||||
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 88);
|
bytes.writeBigUInt64LE(0x10_0000n, optionalHeader + 88);
|
||||||
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 96);
|
bytes.writeBigUInt64LE(0x1000n, optionalHeader + 96);
|
||||||
bytes.writeUInt32LE(16, optionalHeader + 108);
|
bytes.writeUInt32LE(16, optionalHeader + 108);
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.write(".text", sectionTable, "ascii");
|
||||||
|
bytes.writeUInt32LE(dll ? 0x180 : 0x16, sectionTable + 8);
|
||||||
|
bytes.writeUInt32LE(0x1000, sectionTable + 12);
|
||||||
|
bytes.writeUInt32LE(0x200, sectionTable + 16);
|
||||||
|
bytes.writeUInt32LE(0x200, sectionTable + 20);
|
||||||
|
bytes.writeUInt32LE(0x6000_0020, sectionTable + 36);
|
||||||
|
|
||||||
|
// EXE entry returns zero. DLL caller adds one to the callee's 42.
|
||||||
|
bytes.set(
|
||||||
|
dll
|
||||||
|
? [0xe8, 0x0b, 0, 0, 0, 0x83, 0xc0, 1, 0xc3]
|
||||||
|
: [0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3],
|
||||||
|
0x200,
|
||||||
|
);
|
||||||
|
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
|
||||||
|
|
||||||
|
if (dll) {
|
||||||
|
// Export both functions; an entry-point-free library is discovered by exports.
|
||||||
|
bytes.writeUInt32LE(0x1040, directories);
|
||||||
|
bytes.writeUInt32LE(0x130, directories + 4);
|
||||||
|
const exports = 0x240;
|
||||||
|
bytes.writeUInt32LE(0x1100, exports + 12);
|
||||||
|
bytes.writeUInt32LE(1, exports + 16);
|
||||||
|
bytes.writeUInt32LE(2, exports + 20);
|
||||||
|
bytes.writeUInt32LE(2, exports + 24);
|
||||||
|
bytes.writeUInt32LE(0x1080, exports + 28);
|
||||||
|
bytes.writeUInt32LE(0x1088, exports + 32);
|
||||||
|
bytes.writeUInt32LE(0x1090, exports + 36);
|
||||||
|
bytes.writeUInt32LE(0x1000, 0x280);
|
||||||
|
bytes.writeUInt32LE(0x1010, 0x284);
|
||||||
|
bytes.writeUInt32LE(0x1120, 0x288);
|
||||||
|
bytes.writeUInt32LE(0x1140, 0x28c);
|
||||||
|
bytes.writeUInt16LE(0, 0x290);
|
||||||
|
bytes.writeUInt16LE(1, 0x292);
|
||||||
|
bytes.write("rea-fixture.dll\0", 0x300, "binary");
|
||||||
|
bytes.write("fixture_caller\0", 0x320, "binary");
|
||||||
|
bytes.write("fixture_return42\0", 0x340, "binary");
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(output), { recursive: true });
|
||||||
|
await writeFile(output, bytes);
|
||||||
|
if (!x86 && !dll) process.stdout.write(`${output}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes.write(".text", sectionTable, "ascii");
|
|
||||||
bytes.writeUInt32LE(0x16, sectionTable + 8);
|
|
||||||
bytes.writeUInt32LE(0x1000, sectionTable + 12);
|
|
||||||
bytes.writeUInt32LE(0x200, sectionTable + 16);
|
|
||||||
bytes.writeUInt32LE(0x200, sectionTable + 20);
|
|
||||||
bytes.writeUInt32LE(0x6000_0020, sectionTable + 36);
|
|
||||||
|
|
||||||
// entry calls the second function, returns zero; second returns 42.
|
|
||||||
bytes.set([0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3], 0x200);
|
|
||||||
bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210);
|
|
||||||
|
|
||||||
await mkdir(dirname(output), { recursive: true });
|
|
||||||
await writeFile(output, bytes);
|
|
||||||
if (!x86) process.stdout.write(`${output}\n`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const lock = JSON.parse(
|
|||||||
await readFile(join(root, "package-lock.json"), "utf8"),
|
await readFile(join(root, "package-lock.json"), "utf8"),
|
||||||
);
|
);
|
||||||
const skill = await readFile(
|
const skill = await readFile(
|
||||||
join(root, "skill-src/reverse-engineer-anything/SKILL.md"),
|
join(root, ".agents/skills/reverse-engineer-anything/SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1];
|
const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1];
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ for (const argument of arguments_)
|
|||||||
throw new Error(`Unknown skill metadata option: ${argument}`);
|
throw new Error(`Unknown skill metadata option: ${argument}`);
|
||||||
|
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const sourceRoot = join(root, "skill-src/reverse-engineer-anything");
|
const sourceRoot = join(root, ".agents/skills/reverse-engineer-anything");
|
||||||
const outputRoot = join(root, "skills/reverse-engineer-anything");
|
const outputRoot = join(root, "skills/reverse-engineer-anything");
|
||||||
const check = arguments_.has("--check");
|
const check = arguments_.has("--check");
|
||||||
const current = (
|
const current = (
|
||||||
@@ -25,7 +25,7 @@ const { PRODUCT_IDENTITY } = await import(
|
|||||||
);
|
);
|
||||||
if (/^\s{2}(?:tool_count|catalog_digest):/mu.test(current))
|
if (/^\s{2}(?:tool_count|catalog_digest):/mu.test(current))
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Catalog metadata belongs in the generated skill, not skill-src",
|
"Catalog metadata belongs in the generated skill, not the authored source",
|
||||||
);
|
);
|
||||||
const versionLine = /^ {2}version: "[^"\r\n]+"$/mu;
|
const versionLine = /^ {2}version: "[^"\r\n]+"$/mu;
|
||||||
if (!versionLine.test(current))
|
if (!versionLine.test(current))
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ async function inspect(
|
|||||||
evidence.provider.version,
|
evidence.provider.version,
|
||||||
"pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2",
|
"pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2",
|
||||||
);
|
);
|
||||||
assert.deepEqual(evidence.normalized_result, evidence.raw_result);
|
assert.equal(evidence.raw_result, null);
|
||||||
assert.equal(evidence.normalized_result.diagnostics.truncated, false);
|
assert.equal(evidence.normalized_result.diagnostics.truncated, false);
|
||||||
return evidence.normalized_result;
|
return evidence.normalized_result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
TOOL_CONTRACTS,
|
||||||
|
toolContract,
|
||||||
|
} from "../dist/contracts/toolContracts.js";
|
||||||
|
import { exec } from "./lib/verify-package-core.mjs";
|
||||||
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||||
|
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
|
||||||
|
|
||||||
|
const client = process.argv[2];
|
||||||
|
const mode = process.argv[3] ?? "call";
|
||||||
|
assert(["pi", "hermes"].includes(client), "Select pi or hermes.");
|
||||||
|
assert(["chat", "call"].includes(mode), "Select chat or call.");
|
||||||
|
assert(
|
||||||
|
process.platform !== "win32",
|
||||||
|
"This real-client lane requires POSIX; native Windows is unverified.",
|
||||||
|
);
|
||||||
|
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||||
|
const command =
|
||||||
|
process.env[`REA_VERIFY_${client.toUpperCase()}_COMMAND`] ?? client;
|
||||||
|
const lab = await mkdtemp(join(tmpdir(), `rea-${client}-client-`));
|
||||||
|
const account = join(lab, "account");
|
||||||
|
const profile = join(lab, "selected profile");
|
||||||
|
const workspace = join(lab, "workspace");
|
||||||
|
const target = join(workspace, "fixture α测试");
|
||||||
|
const artifactPath = join(workspace, "evidence.json");
|
||||||
|
const skillPath = join(
|
||||||
|
client === "pi" ? join(account, ".agents") : profile,
|
||||||
|
"skills",
|
||||||
|
"reverse-engineer-anything",
|
||||||
|
"SKILL.md",
|
||||||
|
);
|
||||||
|
const verifier = createVerifierRun();
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(account),
|
||||||
|
mkdir(profile),
|
||||||
|
mkdir(target, { recursive: true }),
|
||||||
|
]);
|
||||||
|
await writeFile(
|
||||||
|
join(target, "app.js"),
|
||||||
|
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
|
||||||
|
);
|
||||||
|
const environment = {
|
||||||
|
PATH: process.env.PATH ?? "",
|
||||||
|
USERPROFILE: account,
|
||||||
|
[client === "pi" ? "PI_CODING_AGENT_DIR" : "HERMES_HOME"]: profile,
|
||||||
|
PI_OFFLINE: "1",
|
||||||
|
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||||
|
};
|
||||||
|
const configPath = join(profile, client === "pi" ? "mcp.json" : "config.yaml");
|
||||||
|
const original =
|
||||||
|
client === "pi"
|
||||||
|
? JSON.stringify({
|
||||||
|
mcpServers: { other: { command: "unrelated-server", enabled: false } },
|
||||||
|
})
|
||||||
|
: "# Keep caller settings.\nmcp_servers:\n other:\n command: unrelated-server\n enabled: false\n";
|
||||||
|
await writeFile(configPath, original);
|
||||||
|
if (client === "pi") {
|
||||||
|
// The CLI fixture account and native os.homedir() differ; use Pi's caller-owned skill setting.
|
||||||
|
await writeFile(
|
||||||
|
join(profile, "settings.json"),
|
||||||
|
JSON.stringify({ skills: [join(account, ".agents", "skills")] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const clientVersion = (
|
||||||
|
await exec(command, ["--version"], { env: environment, timeout: 30_000 })
|
||||||
|
).stdout.trim();
|
||||||
|
for (const args of [
|
||||||
|
["setup", "--client", client, "--dry-run"],
|
||||||
|
["setup", "--client", client, "--yes"],
|
||||||
|
["doctor", "--client", client, "--skill"],
|
||||||
|
]) {
|
||||||
|
const result = await exec(
|
||||||
|
process.execPath,
|
||||||
|
[join(runtimeRoot, "scripts/rea.mjs"), ...args, "--json"],
|
||||||
|
{ env: environment, cwd: workspace, timeout: 60_000 },
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(result.stdout);
|
||||||
|
if (args[0] === "doctor") assert.equal(parsed.healthy, true);
|
||||||
|
else
|
||||||
|
assert.equal(
|
||||||
|
parsed.status,
|
||||||
|
args.includes("--dry-run") ? "planned" : "ready",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(lab, `${args[0]}${args.includes("--dry-run") ? "-plan" : ""}.json`),
|
||||||
|
result.stdout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||||
|
const installedConfiguration = await readFile(configPath, "utf8");
|
||||||
|
assert(installedConfiguration.includes("unrelated-server"));
|
||||||
|
if (client === "hermes")
|
||||||
|
assert(installedConfiguration.includes("# Keep caller settings."));
|
||||||
|
const repeated = await exec(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
join(runtimeRoot, "scripts/rea.mjs"),
|
||||||
|
"setup",
|
||||||
|
"--client",
|
||||||
|
client,
|
||||||
|
"--yes",
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
{ env: environment, cwd: workspace, timeout: 60_000 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(JSON.parse(repeated.stdout).appliedActions, []);
|
||||||
|
assert.equal(await readFile(configPath, "utf8"), installedConfiguration);
|
||||||
|
assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original);
|
||||||
|
assert(
|
||||||
|
(await readFile(skillPath, "utf8")).includes(
|
||||||
|
"name: reverse-engineer-anything",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let evidenceSeen = false;
|
||||||
|
let skillLoaded = false;
|
||||||
|
let skillRequested = false;
|
||||||
|
let callRequested = false;
|
||||||
|
let searchRequested = false;
|
||||||
|
let describeRequested = false;
|
||||||
|
let finalSeen = false;
|
||||||
|
let catalog;
|
||||||
|
let artifactDigest;
|
||||||
|
const extractEvidence = (value) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (
|
||||||
|
value.startsWith("<untrusted_tool_result ") &&
|
||||||
|
value.endsWith("</untrusted_tool_result>")
|
||||||
|
) {
|
||||||
|
// Hermes wraps external MCP content with its own instruction banner.
|
||||||
|
// Decode only that observed envelope, retaining the original request artifact.
|
||||||
|
const start = value.indexOf("\n\n");
|
||||||
|
if (start >= 0)
|
||||||
|
return extractEvidence(
|
||||||
|
value
|
||||||
|
.slice(start + 2, value.lastIndexOf("</untrusted_tool_result>"))
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return extractEvidence(JSON.parse(value));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
if (
|
||||||
|
typeof value.evidence_id === "string" &&
|
||||||
|
value.normalized_result?.graph?.nodes
|
||||||
|
)
|
||||||
|
return value;
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
const found = extractEvidence(child);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const validateEvidence = async (value) => {
|
||||||
|
const evidence = toolContract(
|
||||||
|
"analyze_javascript_application",
|
||||||
|
).outputSchema.parse(value);
|
||||||
|
assert.equal(evidence.subject.local_path, target);
|
||||||
|
const shape = evidence.normalized_result.graph.nodes
|
||||||
|
.flatMap((n) => n.observations)
|
||||||
|
.map((o) => o.properties)
|
||||||
|
.find(
|
||||||
|
(p) =>
|
||||||
|
p.semantic_role === "export-return-shapes" &&
|
||||||
|
p.exported_name === "result",
|
||||||
|
);
|
||||||
|
assert(shape?.return_shape_coverage?.projection_complete);
|
||||||
|
const fields = shape.static_return_shapes.flatMap((s) => s.fields);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(f) =>
|
||||||
|
f.path === "/clientCompatibility" &&
|
||||||
|
f.state === "literal" &&
|
||||||
|
f.value === "REA_CLIENT_FIXTURE",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(f) => f.path === "/count" && f.state === "literal" && f.value === 7,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const serialized = JSON.stringify(evidence);
|
||||||
|
await writeFile(join(lab, "evidence.json"), serialized);
|
||||||
|
artifactDigest = createHash("sha256").update(serialized).digest("hex");
|
||||||
|
evidenceSeen = true;
|
||||||
|
};
|
||||||
|
const fixture = await createOpenAiModelFixture({
|
||||||
|
directory: lab,
|
||||||
|
prefix: `${client}-${mode}`,
|
||||||
|
onRequest: async ({ body, tools, requestIndex }) => {
|
||||||
|
assert(
|
||||||
|
requestIndex <= 6,
|
||||||
|
"Client did not complete the bounded fixture workflow.",
|
||||||
|
);
|
||||||
|
const messages = body.messages ?? [];
|
||||||
|
const toolMessages = messages.filter((m) => m.role === "tool");
|
||||||
|
const serializedTools = JSON.stringify(toolMessages);
|
||||||
|
const call = (name, args) => ({
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: `call_rea_${requestIndex}`,
|
||||||
|
type: "function",
|
||||||
|
function: { name, arguments: JSON.stringify(args) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
client === "pi" &&
|
||||||
|
callRequested &&
|
||||||
|
serializedTools.includes("REA_CLIENT_FIXTURE")
|
||||||
|
) {
|
||||||
|
const fact = toolMessages
|
||||||
|
.flatMap((m) => {
|
||||||
|
const text =
|
||||||
|
typeof m.content === "string"
|
||||||
|
? m.content
|
||||||
|
: JSON.stringify(m.content);
|
||||||
|
const marker = text.indexOf('{"REA_CLIENT_FIXTURE":true');
|
||||||
|
if (marker < 0) return [];
|
||||||
|
const end = text.indexOf("}\n", marker);
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
JSON.parse(text.slice(marker, end < 0 ? undefined : end + 1)),
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.find((v) => Array.isArray(v.catalog));
|
||||||
|
assert(
|
||||||
|
fact,
|
||||||
|
"Pi codemode must return its actual catalog and skill-read receipt.",
|
||||||
|
);
|
||||||
|
catalog = fact.catalog;
|
||||||
|
skillLoaded = fact.skill_loaded === true;
|
||||||
|
await validateEvidence(JSON.parse(await readFile(artifactPath, "utf8")));
|
||||||
|
} else if (client === "hermes") {
|
||||||
|
const search = tools.find((t) => t.name === "tool_search");
|
||||||
|
const exposed = tools
|
||||||
|
.filter((t) => t.name.startsWith("mcp__rea__"))
|
||||||
|
.map((t) => t.name.slice(10));
|
||||||
|
const deferred = [
|
||||||
|
...(search?.description ?? "").matchAll(/^- mcp__rea__(\w+):/gmu),
|
||||||
|
].map((m) => m[1]);
|
||||||
|
const native = [...new Set([...exposed, ...deferred])];
|
||||||
|
// Hermes advertises MCP prompt utilities beside server tools.
|
||||||
|
if (native.length)
|
||||||
|
catalog = native.filter(
|
||||||
|
(name) => !["get_prompt", "list_prompts"].includes(name),
|
||||||
|
);
|
||||||
|
skillLoaded ||=
|
||||||
|
serializedTools.includes(skillPath) &&
|
||||||
|
serializedTools.includes("reverse-engineer-anything");
|
||||||
|
for (const message of toolMessages) {
|
||||||
|
const evidence = extractEvidence(message.content);
|
||||||
|
if (evidence) await validateEvidence(evidence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mode === "chat" || evidenceSeen) {
|
||||||
|
finalSeen = true;
|
||||||
|
return { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||||
|
}
|
||||||
|
if (client === "pi") {
|
||||||
|
const tool = tools.find((t) => t.name === "codemode");
|
||||||
|
assert(
|
||||||
|
tool,
|
||||||
|
`Native Pi codemode unavailable: ${tools.map((t) => t.name).join(", ")}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(messages).includes(skillPath),
|
||||||
|
"Pi must advertise the installed REA skill.",
|
||||||
|
);
|
||||||
|
callRequested = true;
|
||||||
|
return call(tool.name, {
|
||||||
|
code: `await describeNamespace("rea"); const skill = await tools.read({path:${JSON.stringify(skillPath)}}); const result = await tools.mcp__rea__analyze_javascript_application({input_path:${JSON.stringify(target)},format:"directory"}); if(result.isError) throw new Error(JSON.stringify(result)); const evidence = result.structuredContent ?? JSON.parse(result.content.find(c=>c.type==="text").text); await tools.write({path:${JSON.stringify(artifactPath)},content:JSON.stringify(evidence)}); text({REA_CLIENT_FIXTURE:true,skill_loaded:typeof skill==="string"&&skill.includes("name: reverse-engineer-anything"),catalog:ALL_TOOLS.filter(t=>t.name.startsWith("mcp__rea__")).map(t=>t.name.slice(10))});`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!skillRequested) {
|
||||||
|
const tool = tools.find((t) => t.name === "skill_view");
|
||||||
|
assert(tool, "Hermes skill_view must be available.");
|
||||||
|
skillRequested = true;
|
||||||
|
return call(tool.name, { name: "reverse-engineer-anything" });
|
||||||
|
}
|
||||||
|
if (!searchRequested) {
|
||||||
|
const tool = tools.find((t) => t.name === "tool_search");
|
||||||
|
assert(tool, "Hermes native deferred tool_search must be available.");
|
||||||
|
searchRequested = true;
|
||||||
|
return call(tool.name, { queries: ["analyze_javascript_application"] });
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
serializedTools.includes("mcp__rea__analyze_javascript_application"),
|
||||||
|
"Hermes must discover the analysis tool.",
|
||||||
|
);
|
||||||
|
if (!describeRequested) {
|
||||||
|
const tool = tools.find((t) => t.name === "tool_describe");
|
||||||
|
assert(tool, "Hermes must load the actual parameter schema.");
|
||||||
|
describeRequested = true;
|
||||||
|
return call(tool.name, {
|
||||||
|
names: ["mcp__rea__analyze_javascript_application"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tool = tools.find((t) => t.name === "tool_call");
|
||||||
|
assert(tool, "Hermes native deferred tool_call must be available.");
|
||||||
|
callRequested = true;
|
||||||
|
return call(tool.name, {
|
||||||
|
calls: [
|
||||||
|
{
|
||||||
|
name: "mcp__rea__analyze_javascript_application",
|
||||||
|
arguments: { input_path: target, format: "directory" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (client === "pi") {
|
||||||
|
await writeFile(
|
||||||
|
join(profile, "models.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
providers: {
|
||||||
|
"rea-fixture": {
|
||||||
|
baseUrl: fixture.baseUrl,
|
||||||
|
api: "openai-completions",
|
||||||
|
apiKey: "local-fixture-key",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "rea-client-fixture",
|
||||||
|
reasoning: false,
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 4096,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const configured = await readFile(configPath, "utf8");
|
||||||
|
await writeFile(
|
||||||
|
configPath,
|
||||||
|
configured +
|
||||||
|
`\nmodel:\n default: rea-client-fixture\n provider: custom\n base_url: ${fixture.baseUrl}\nagent:\n max_turns: 6\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const args =
|
||||||
|
client === "pi"
|
||||||
|
? [
|
||||||
|
"--offline",
|
||||||
|
"--provider",
|
||||||
|
"rea-fixture",
|
||||||
|
"--model",
|
||||||
|
"rea-client-fixture",
|
||||||
|
"--no-session",
|
||||||
|
"--no-context-files",
|
||||||
|
"--mode",
|
||||||
|
"json",
|
||||||
|
"-p",
|
||||||
|
mode === "chat"
|
||||||
|
? "Say hello briefly."
|
||||||
|
: `Use REA to analyze ${target}.`,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
"--oneshot",
|
||||||
|
mode === "chat"
|
||||||
|
? "Say hello briefly."
|
||||||
|
: `Use REA to analyze ${target}.`,
|
||||||
|
"--provider",
|
||||||
|
"custom",
|
||||||
|
"--model",
|
||||||
|
"rea-client-fixture",
|
||||||
|
"--reasoning",
|
||||||
|
"none",
|
||||||
|
];
|
||||||
|
let failure;
|
||||||
|
try {
|
||||||
|
const execution = exec(command, args, {
|
||||||
|
env: {
|
||||||
|
...environment,
|
||||||
|
OPENAI_API_KEY: "local-fixture-key",
|
||||||
|
OPENAI_BASE_URL: fixture.baseUrl,
|
||||||
|
},
|
||||||
|
cwd: workspace,
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
// Pi consumes piped input before its first prompt; an unused open pipe is not EOF.
|
||||||
|
execution.child.stdin.end();
|
||||||
|
const result = await execution;
|
||||||
|
await writeFile(join(lab, "client.stdout"), result.stdout);
|
||||||
|
await writeFile(join(lab, "client.stderr"), result.stderr);
|
||||||
|
assert.equal(fixture.failure, undefined);
|
||||||
|
assert(finalSeen);
|
||||||
|
assert(result.stdout.includes("REA_CLIENT_COMPATIBILITY_OK"));
|
||||||
|
if (mode === "call") {
|
||||||
|
assert(evidenceSeen);
|
||||||
|
assert(
|
||||||
|
skillLoaded,
|
||||||
|
"Native client must actually read the installed skill.",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
catalog?.sort(),
|
||||||
|
TOOL_CONTRACTS.map((t) => t.name).sort(),
|
||||||
|
"The complete REA catalog must survive native client discovery.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
failure = cause;
|
||||||
|
if (typeof cause?.stdout === "string")
|
||||||
|
await writeFile(join(lab, "client.stdout"), cause.stdout);
|
||||||
|
if (typeof cause?.stderr === "string")
|
||||||
|
await writeFile(join(lab, "client.stderr"), cause.stderr);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
const receipt = {
|
||||||
|
client,
|
||||||
|
clientVersion,
|
||||||
|
mode,
|
||||||
|
passed: failure === undefined,
|
||||||
|
artifacts: lab,
|
||||||
|
runtimeRoot,
|
||||||
|
host: {
|
||||||
|
platform: process.platform,
|
||||||
|
architecture: process.arch,
|
||||||
|
node: process.versions.node,
|
||||||
|
},
|
||||||
|
modelFixture:
|
||||||
|
"loopback deterministic OpenAI-compatible endpoint; no live provider claim",
|
||||||
|
skillDiscovery:
|
||||||
|
client === "pi"
|
||||||
|
? "caller-configured isolated shared directory; default OS home discovery unverified"
|
||||||
|
: "native HERMES_HOME personal directory",
|
||||||
|
catalogSize: catalog?.length,
|
||||||
|
evidenceSeen,
|
||||||
|
skillLoaded,
|
||||||
|
artifactDigest,
|
||||||
|
finalSeen,
|
||||||
|
requests: fixture.requests,
|
||||||
|
probes: fixture.probes,
|
||||||
|
fixtureFailure: fixture.failure,
|
||||||
|
verifier: await completeVerifierRun(verifier),
|
||||||
|
};
|
||||||
|
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
|
||||||
|
console.log(JSON.stringify(receipt));
|
||||||
|
}
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
@@ -85,7 +85,12 @@ export async function verifyPackageEnvironment({
|
|||||||
PI_CODING_AGENT_DIR: undefined,
|
PI_CODING_AGENT_DIR: undefined,
|
||||||
PI_CONFIG_DIR: undefined,
|
PI_CONFIG_DIR: undefined,
|
||||||
PI_PROFILE: undefined,
|
PI_PROFILE: undefined,
|
||||||
|
QWEN_HOME: undefined,
|
||||||
OPENCODE_CONFIG: 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"),
|
XDG_CONFIG_HOME: join(home, ".config"),
|
||||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||||
REA_ANALYSIS_PROVIDER: "auto",
|
REA_ANALYSIS_PROVIDER: "auto",
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ export async function verifyPackageInstall({
|
|||||||
}) {
|
}) {
|
||||||
await exec(
|
await exec(
|
||||||
"npm",
|
"npm",
|
||||||
["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball],
|
[
|
||||||
|
"install",
|
||||||
|
"--global",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--omit=dev",
|
||||||
|
"--install-strategy=nested",
|
||||||
|
"--prefix",
|
||||||
|
prefix,
|
||||||
|
tarball,
|
||||||
|
],
|
||||||
{ env: environment },
|
{ env: environment },
|
||||||
);
|
);
|
||||||
const cli = join(prefix, "bin", "rea");
|
const cli = join(prefix, "bin", "rea");
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function verifyPackagePack({ root, workspace }) {
|
|||||||
throw new Error("package did not expose both rea command entry points");
|
throw new Error("package did not expose both rea command entry points");
|
||||||
if (packedManifest.mcpName !== "io.github.morluto/rea")
|
if (packedManifest.mcpName !== "io.github.morluto/rea")
|
||||||
throw new Error("package did not retain its MCP Registry ownership marker");
|
throw new Error("package did not retain its MCP Registry ownership marker");
|
||||||
if (packedFiles.some((path) => path.startsWith("package/skill-src/")))
|
if (packedFiles.some((path) => path.startsWith("package/.agents/")))
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"package included authored skill sources instead of only the generated bundle",
|
"package included authored skill sources instead of only the generated bundle",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { verifyPackageMcp } from "./verify-package-mcp.mjs";
|
|||||||
import { verifyPackagePack } from "./verify-package-pack.mjs";
|
import { verifyPackagePack } from "./verify-package-pack.mjs";
|
||||||
import { verifyPackagePlatform } from "./verify-package-platform.mjs";
|
import { verifyPackagePlatform } from "./verify-package-platform.mjs";
|
||||||
import { verifyPackageSetup } from "./verify-package-setup.mjs";
|
import { verifyPackageSetup } from "./verify-package-setup.mjs";
|
||||||
|
import { verifyRepositorySkill } from "./verify-repository-skill.mjs";
|
||||||
import { verifyManaged } from "./verify-package-managed.mjs";
|
import { verifyManaged } from "./verify-package-managed.mjs";
|
||||||
import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs";
|
import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs";
|
||||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||||
@@ -34,9 +35,11 @@ let tarball;
|
|||||||
let mcpStartup;
|
let mcpStartup;
|
||||||
let mcpModuleLoading;
|
let mcpModuleLoading;
|
||||||
let update;
|
let update;
|
||||||
|
let repositorySkill;
|
||||||
let report;
|
let report;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
repositorySkill = await verifyRepositorySkill({ root, workspace });
|
||||||
({ tarball } = await verifyPackagePack({ root, workspace }));
|
({ tarball } = await verifyPackagePack({ root, workspace }));
|
||||||
const environmentData = await verifyPackageEnvironment({
|
const environmentData = await verifyPackageEnvironment({
|
||||||
root,
|
root,
|
||||||
@@ -156,6 +159,7 @@ try {
|
|||||||
configSymlinkLifecycle: supportedSetupHost,
|
configSymlinkLifecycle: supportedSetupHost,
|
||||||
skill: supportedSetupHost,
|
skill: supportedSetupHost,
|
||||||
skillReferences: supportedSetupHost,
|
skillReferences: supportedSetupHost,
|
||||||
|
repositorySkill,
|
||||||
mcpTools: TOOL_CONTRACTS.length,
|
mcpTools: TOOL_CONTRACTS.length,
|
||||||
mcpPrompts: prompts.names.length,
|
mcpPrompts: prompts.names.length,
|
||||||
promptCompletion: true,
|
promptCompletion: true,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
import { Client } from "@modelcontextprotocol/client";
|
import { Client } from "@modelcontextprotocol/client";
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||||
@@ -24,10 +24,50 @@ if (process.platform !== "win32" || process.arch !== "x64")
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
"Packaged Windows Ghidra acceptance requires a Windows x64 host.",
|
"Packaged Windows Ghidra acceptance requires a Windows x64 host.",
|
||||||
);
|
);
|
||||||
const workspace = await mkdtemp(join(tmpdir(), "rea-packaged-ghidra-"));
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
const x86Fixture = process.argv[2] === "--x86";
|
const arguments_ = process.argv.slice(2);
|
||||||
const packageRootArgument = x86Fixture ? undefined : process.argv[2];
|
const internalWorkspace = arguments_[0] === "--internal-workspace";
|
||||||
|
const fixtureArguments = internalWorkspace ? arguments_.slice(2) : arguments_;
|
||||||
|
const x86Fixture = fixtureArguments.includes("--x86");
|
||||||
|
const dllFixture = fixtureArguments.includes("--dll");
|
||||||
|
const [packageRootArgument, targetArgument, reportArgument] =
|
||||||
|
fixtureArguments.filter(
|
||||||
|
(argument) => argument !== "--x86" && argument !== "--dll",
|
||||||
|
);
|
||||||
|
// Windows locks the loaded package-owned addon until the verifier exits.
|
||||||
|
// Keep installation cleanup in a parent that never loads that addon.
|
||||||
|
if (!internalWorkspace) {
|
||||||
|
const ownedWorkspace = await mkdtemp(join(tmpdir(), "rea-packaged-ghidra-"));
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await exec(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
fileURLToPath(import.meta.url),
|
||||||
|
"--internal-workspace",
|
||||||
|
ownedWorkspace,
|
||||||
|
...arguments_,
|
||||||
|
],
|
||||||
|
{ timeout: 900_000, maxBuffer: 8 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
if (typeof cause.stdout === "string") process.stdout.write(cause.stdout);
|
||||||
|
if (typeof cause.stderr === "string") process.stderr.write(cause.stderr);
|
||||||
|
throw cause;
|
||||||
|
} finally {
|
||||||
|
await rm(ownedWorkspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
const report = JSON.parse(result.stdout);
|
||||||
|
report.workspaceCleanup = true;
|
||||||
|
report.ok = true;
|
||||||
|
if (reportArgument !== undefined)
|
||||||
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||||
|
process.stderr.write(result.stderr);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
assert.ok(arguments_[1] !== undefined, "Missing verifier workspace.");
|
||||||
|
const workspace = resolve(arguments_[1]);
|
||||||
let packageRoot = resolve(packageRootArgument ?? ".");
|
let packageRoot = resolve(packageRootArgument ?? ".");
|
||||||
// The default lane verifies the npm artifact in an isolated prefix. Explicit
|
// The default lane verifies the npm artifact in an isolated prefix. Explicit
|
||||||
// package roots support an already-installed artifact without a second install.
|
// package roots support an already-installed artifact without a second install.
|
||||||
@@ -75,17 +115,17 @@ if (packageRootArgument === undefined) {
|
|||||||
);
|
);
|
||||||
packageRoot = join(prefix, "node_modules", "rea-agents");
|
packageRoot = join(prefix, "node_modules", "rea-agents");
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
await rm(workspace, { recursive: true, force: true });
|
throw new Error("Packaged Windows verifier installation failed.", {
|
||||||
throw cause;
|
cause,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const target = resolve(
|
const target = resolve(
|
||||||
process.argv[3] ??
|
targetArgument ??
|
||||||
(x86Fixture
|
`build/fixtures/rea-ghidra-windows${x86Fixture ? "-x86" : ""}.${dllFixture ? "dll" : "exe"}`,
|
||||||
? "build/fixtures/rea-ghidra-windows-x86.exe"
|
|
||||||
: "build/fixtures/rea-ghidra-windows.exe"),
|
|
||||||
);
|
);
|
||||||
let TOOL_CONTRACTS, native, token, sha256;
|
let TOOL_CONTRACTS, native, token, sha256;
|
||||||
|
let parsedTarget;
|
||||||
try {
|
try {
|
||||||
({ TOOL_CONTRACTS } = await import(
|
({ TOOL_CONTRACTS } = await import(
|
||||||
pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js"))
|
pathToFileURL(join(packageRoot, "dist/contracts/toolContracts.js"))
|
||||||
@@ -107,9 +147,25 @@ try {
|
|||||||
);
|
);
|
||||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||||
sha256 = digest(await readFile(target));
|
sha256 = digest(await readFile(target));
|
||||||
|
const { parseBinaryTarget } = await import(
|
||||||
|
pathToFileURL(join(packageRoot, "dist/application/BinaryTargetResolver.js"))
|
||||||
|
);
|
||||||
|
parsedTarget = await parseBinaryTarget(target);
|
||||||
|
assert.equal(parsedTarget.ok, true);
|
||||||
|
assert.equal(parsedTarget.value.format, "pe");
|
||||||
|
assert.equal(parsedTarget.value.managed, false);
|
||||||
|
if (targetArgument === undefined) {
|
||||||
|
assert.equal(
|
||||||
|
parsedTarget.value.architecture,
|
||||||
|
x86Fixture ? "x86" : "x86_64",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parsedTarget.value.executableRole,
|
||||||
|
dllFixture ? "shared-library" : "application",
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
await rm(workspace, { recursive: true, force: true });
|
throw new Error("Packaged Windows verifier prerequisites failed.", { cause });
|
||||||
throw cause;
|
|
||||||
}
|
}
|
||||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||||
const runtimeParent = join(workspace, "runtime with spaces");
|
const runtimeParent = join(workspace, "runtime with spaces");
|
||||||
@@ -145,6 +201,8 @@ const report = {
|
|||||||
architecture: "x64",
|
architecture: "x64",
|
||||||
nonAdmin: true,
|
nonAdmin: true,
|
||||||
targetSha256: sha256,
|
targetSha256: sha256,
|
||||||
|
targetArchitecture: parsedTarget.value.architecture,
|
||||||
|
targetRole: parsedTarget.value.executableRole,
|
||||||
cli: {},
|
cli: {},
|
||||||
mcpOperations: [],
|
mcpOperations: [],
|
||||||
nativeControls: "available",
|
nativeControls: "available",
|
||||||
@@ -208,8 +266,8 @@ try {
|
|||||||
});
|
});
|
||||||
const call = async (name, arguments_) => {
|
const call = async (name, arguments_) => {
|
||||||
report.lastOperation = name;
|
report.lastOperation = name;
|
||||||
if (process.argv[4] !== undefined)
|
if (reportArgument !== undefined)
|
||||||
await writeFile(process.argv[4], `${JSON.stringify(report)}\n`);
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
||||||
const contract = TOOL_CONTRACTS.find((value) => value.name === name);
|
const contract = TOOL_CONTRACTS.find((value) => value.name === name);
|
||||||
assert.ok(contract !== undefined, `Missing canonical contract: ${name}`);
|
assert.ok(contract !== undefined, `Missing canonical contract: ${name}`);
|
||||||
const argumentsParsed = contract.inputSchema.parse(arguments_);
|
const argumentsParsed = contract.inputSchema.parse(arguments_);
|
||||||
@@ -230,6 +288,25 @@ try {
|
|||||||
await call("list_names", {});
|
await call("list_names", {});
|
||||||
const procedures = await call("list_procedures", {});
|
const procedures = await call("list_procedures", {});
|
||||||
assert.ok(procedures.length > 0);
|
assert.ok(procedures.length > 0);
|
||||||
|
if (targetArgument === undefined) {
|
||||||
|
const calleeAddress = x86Fixture ? "0x401010" : "0x140001010";
|
||||||
|
const calleeCode = await call("procedure_pseudo_code", {
|
||||||
|
procedure: calleeAddress,
|
||||||
|
});
|
||||||
|
assert.match(calleeCode, /\b(?:42|0x2a)\b/iu);
|
||||||
|
if (dllFixture) {
|
||||||
|
const callerAddress = x86Fixture ? "0x401000" : "0x140001000";
|
||||||
|
assert.ok(
|
||||||
|
(
|
||||||
|
await call("procedure_callees", { procedure: callerAddress })
|
||||||
|
).includes(calleeAddress),
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await call("procedure_pseudo_code", { procedure: callerAddress }),
|
||||||
|
/\+\s*1\b/u,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
assert.ok((await call("list_segments", {})).length > 0);
|
assert.ok((await call("list_segments", {})).length > 0);
|
||||||
await call("list_strings", {});
|
await call("list_strings", {});
|
||||||
const procedure =
|
const procedure =
|
||||||
@@ -296,6 +373,12 @@ try {
|
|||||||
"Packaged verification omitted an admitted operation.",
|
"Packaged verification omitted an admitted operation.",
|
||||||
);
|
);
|
||||||
const session = await call("binary_session", {});
|
const session = await call("binary_session", {});
|
||||||
|
assert.equal(
|
||||||
|
session.analysis_provider_binding?.analysis_profile?.parameters
|
||||||
|
?.executable_role,
|
||||||
|
parsedTarget.value.executableRole,
|
||||||
|
"MCP profile must preserve the selected PE role.",
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
session.analysis_provider_binding?.selection_source,
|
session.analysis_provider_binding?.selection_source,
|
||||||
"auto-single-candidate",
|
"auto-single-candidate",
|
||||||
@@ -326,9 +409,9 @@ try {
|
|||||||
assert.deepEqual(await readdir(join(callerDirectory, name)), []);
|
assert.deepEqual(await readdir(join(callerDirectory, name)), []);
|
||||||
report.callerScriptCollisionsPreserved = true;
|
report.callerScriptCollisionsPreserved = true;
|
||||||
report.runtimeCleanup = true;
|
report.runtimeCleanup = true;
|
||||||
report.ok = true;
|
// Only the parent can attest success after removing the installation.
|
||||||
if (process.argv[4] !== undefined)
|
if (reportArgument !== undefined)
|
||||||
await writeFile(process.argv[4], `${JSON.stringify(report)}\n`);
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
||||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
report.failure = {
|
report.failure = {
|
||||||
@@ -338,9 +421,7 @@ try {
|
|||||||
.replaceAll(target, "<target>")
|
.replaceAll(target, "<target>")
|
||||||
.replaceAll(workspace, "<workspace>"),
|
.replaceAll(workspace, "<workspace>"),
|
||||||
};
|
};
|
||||||
if (process.argv[4] !== undefined)
|
if (reportArgument !== undefined)
|
||||||
await writeFile(process.argv[4], `${JSON.stringify(report)}\n`);
|
await writeFile(reportArgument, `${JSON.stringify(report)}\n`);
|
||||||
throw cause;
|
throw cause;
|
||||||
} finally {
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs";
|
||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
writeFile,
|
||||||
|
readFile,
|
||||||
|
mkdtemp,
|
||||||
|
realpath,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { exec } from "./lib/verify-package-core.mjs";
|
||||||
|
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||||
|
import {
|
||||||
|
TOOL_CONTRACTS,
|
||||||
|
toolContract,
|
||||||
|
} from "../dist/contracts/toolContracts.js";
|
||||||
|
|
||||||
|
assert(
|
||||||
|
process.platform !== "win32",
|
||||||
|
"The Qwen real-client verifier currently requires a POSIX host; native Windows is unverified.",
|
||||||
|
);
|
||||||
|
const repo = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo;
|
||||||
|
const lab = await mkdtemp(join(tmpdir(), "rea-qwen-client-"));
|
||||||
|
const client = "qwen";
|
||||||
|
const mode = process.argv[2] ?? "call";
|
||||||
|
assert(
|
||||||
|
["chat", "call"].includes(mode),
|
||||||
|
"Usage: node scripts/verify-qwen-client.mjs [chat|call]",
|
||||||
|
);
|
||||||
|
const command = process.env.REA_VERIFY_QWEN_COMMAND ?? "qwen";
|
||||||
|
const verifier = createVerifierRun();
|
||||||
|
const profile = join(lab, "selected profile");
|
||||||
|
const account = join(lab, "account");
|
||||||
|
const runtime = join(profile, "runtime");
|
||||||
|
const workspace = join(lab, "workspace");
|
||||||
|
const sharedSkills = join(account, ".agents", "skills");
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(profile, { recursive: true }),
|
||||||
|
mkdir(account),
|
||||||
|
mkdir(workspace),
|
||||||
|
]);
|
||||||
|
const baseEnvironment = {
|
||||||
|
PATH: process.env.PATH ?? "",
|
||||||
|
USERPROFILE: account,
|
||||||
|
QWEN_HOME: profile,
|
||||||
|
QWEN_RUNTIME_DIR: runtime,
|
||||||
|
REA_PROCESS_RUN_ID: verifier.run_id,
|
||||||
|
};
|
||||||
|
let clientVersion;
|
||||||
|
try {
|
||||||
|
clientVersion = (
|
||||||
|
await exec(command, ["--version"], {
|
||||||
|
env: baseEnvironment,
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
|
} catch (cause) {
|
||||||
|
throw new Error(
|
||||||
|
`Qwen client preflight failed for ${command}; install Qwen Code or set REA_VERIFY_QWEN_COMMAND. ${String(cause)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const original = JSON.stringify(
|
||||||
|
{
|
||||||
|
security: {
|
||||||
|
auth: { selectedType: "openai" },
|
||||||
|
folderTrust: { enabled: false },
|
||||||
|
},
|
||||||
|
telemetry: { enabled: false },
|
||||||
|
model: { name: "rea-client-fixture" },
|
||||||
|
// Qwen uses os.homedir(), while REA's isolated CLI account uses USERPROFILE.
|
||||||
|
// This caller-owned setting lets the real client load the REA-installed bundle.
|
||||||
|
skills: { directories: [sharedSkills] },
|
||||||
|
mcpServers: { other: { command: "unrelated-server", disabled: true } },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
await writeFile(join(profile, "settings.json"), original);
|
||||||
|
for (const step of [
|
||||||
|
["setup", "--client", "qwen_code", "--dry-run"],
|
||||||
|
["setup", "--client", "qwen_code", "--yes"],
|
||||||
|
["doctor", "--client", "qwen_code", "--skill"],
|
||||||
|
]) {
|
||||||
|
const response = await exec(
|
||||||
|
process.execPath,
|
||||||
|
[join(runtimeRoot, "scripts/rea.mjs"), ...step, "--json"],
|
||||||
|
{ env: baseEnvironment, cwd: repo, timeout: 60_000 },
|
||||||
|
);
|
||||||
|
const result = JSON.parse(response.stdout);
|
||||||
|
if (step.includes("--dry-run"))
|
||||||
|
assert(
|
||||||
|
result.plannedActions.every(
|
||||||
|
(a) =>
|
||||||
|
a.target.startsWith(profile + "/") ||
|
||||||
|
a.target.startsWith(sharedSkills + "/"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(lab, `${step[0]}${step.includes("--dry-run") ? "-plan" : ""}.json`),
|
||||||
|
response.stdout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
await readFile(join(profile, "settings.json.rea.backup"), "utf8"),
|
||||||
|
original,
|
||||||
|
);
|
||||||
|
const configured = JSON.parse(
|
||||||
|
await readFile(join(profile, "settings.json"), "utf8"),
|
||||||
|
);
|
||||||
|
assert.deepEqual(configured.model, { name: "rea-client-fixture" });
|
||||||
|
assert.deepEqual(configured.skills, { directories: [sharedSkills] });
|
||||||
|
assert.deepEqual(configured.mcpServers.other, {
|
||||||
|
command: "unrelated-server",
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
const target = join(lab, "fixture α测试");
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
`${target}/app.js`,
|
||||||
|
'export function result() { return { clientCompatibility: "REA_CLIENT_FIXTURE", count: 7 }; }\n',
|
||||||
|
);
|
||||||
|
let evidenceSeen = false;
|
||||||
|
let skillSeen = false;
|
||||||
|
let finalSeen = false;
|
||||||
|
let skillRequested = false;
|
||||||
|
let searchRequested = false;
|
||||||
|
let artifactReadByAgent = false;
|
||||||
|
let artifactPath;
|
||||||
|
let selectedSkillLoaded = false;
|
||||||
|
let artifactDigest;
|
||||||
|
const extractEvidence = (value) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
return extractEvidence(JSON.parse(value));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const result = extractEvidence(item);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
if (
|
||||||
|
typeof value.evidence_id === "string" &&
|
||||||
|
value.normalized_result?.graph?.nodes?.length > 0
|
||||||
|
)
|
||||||
|
return value;
|
||||||
|
for (const nested of Object.values(value)) {
|
||||||
|
const result = extractEvidence(nested);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const fixture = await createOpenAiModelFixture({
|
||||||
|
directory: lab,
|
||||||
|
prefix: `${client}-${mode}`,
|
||||||
|
onRequest: async ({ body, tools, requestIndex }) => {
|
||||||
|
skillSeen ||= JSON.stringify(body.messages ?? []).includes(
|
||||||
|
"reverse-engineer-anything",
|
||||||
|
);
|
||||||
|
selectedSkillLoaded ||= JSON.stringify(body.messages ?? []).includes(
|
||||||
|
join(sharedSkills, "reverse-engineer-anything"),
|
||||||
|
);
|
||||||
|
const toolText = JSON.stringify(
|
||||||
|
(body.messages ?? []).filter((m) => m.role === "tool"),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
toolText.includes("REA_CLIENT_FIXTURE") &&
|
||||||
|
/(?:\\?"REA_CLIENT_FIXTURE\\?"\s*:\s*true)/u.test(toolText)
|
||||||
|
) {
|
||||||
|
artifactReadByAgent = true;
|
||||||
|
assert(artifactPath);
|
||||||
|
const artifact = await readFile(artifactPath);
|
||||||
|
artifactDigest = createHash("sha256").update(artifact).digest("hex");
|
||||||
|
const evidence = toolContract(
|
||||||
|
"analyze_javascript_application",
|
||||||
|
).outputSchema.parse(JSON.parse(artifact.toString("utf8")));
|
||||||
|
assert(extractEvidence(evidence));
|
||||||
|
assert.equal(evidence.subject.local_path, target);
|
||||||
|
const shape = evidence.normalized_result.graph.nodes
|
||||||
|
.flatMap((n) => n.observations)
|
||||||
|
.map((o) => o.properties)
|
||||||
|
.find(
|
||||||
|
(p) =>
|
||||||
|
p.semantic_role === "export-return-shapes" &&
|
||||||
|
p.exported_name === "result",
|
||||||
|
);
|
||||||
|
assert(shape?.return_shape_coverage?.projection_complete);
|
||||||
|
const fields = shape.static_return_shapes.flatMap((s) => s.fields);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(f) =>
|
||||||
|
f.path === "/clientCompatibility" &&
|
||||||
|
f.state === "literal" &&
|
||||||
|
f.value === "REA_CLIENT_FIXTURE",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fields.some(
|
||||||
|
(f) => f.path === "/count" && f.state === "literal" && f.value === 7,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
evidenceSeen = true;
|
||||||
|
await writeFile(
|
||||||
|
`${lab}/${client}-${mode}-evidence.json`,
|
||||||
|
JSON.stringify(evidence),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const message of body.messages ?? []) {
|
||||||
|
if (message.role !== "tool") continue;
|
||||||
|
const evidence = extractEvidence(message.content);
|
||||||
|
if (evidence) {
|
||||||
|
assert(
|
||||||
|
JSON.stringify(evidence.normalized_result).includes(
|
||||||
|
"REA_CLIENT_FIXTURE",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
evidenceSeen = true;
|
||||||
|
await writeFile(
|
||||||
|
`${lab}/${client}-${mode}-evidence.json`,
|
||||||
|
JSON.stringify(evidence),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selected = tools.find((t) =>
|
||||||
|
t.name.includes("analyze_javascript_application"),
|
||||||
|
);
|
||||||
|
const reviewedNames = [
|
||||||
|
...JSON.stringify(
|
||||||
|
(body.messages ?? []).filter((m) => m.role === "tool"),
|
||||||
|
).matchAll(
|
||||||
|
/(?:\\?"name\\?"\s*:\s*\\?")([^"\\]*analyze_javascript_application)(?:\\?")/g,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((m) => m[1])
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
const search = tools.find((t) => t.name === "tool_search");
|
||||||
|
const invoke = tools.find((t) => t.name === "tool_call");
|
||||||
|
const skill = tools.find((t) => t.name === "skill");
|
||||||
|
const shell = tools.find((t) => t.name === "run_shell_command");
|
||||||
|
const persisted = toolText.match(
|
||||||
|
/Full output saved to: ([^\\"\n]+)(?:\\n|\n)/u,
|
||||||
|
);
|
||||||
|
const call = (name, args) => ({
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: `call_rea_fixture_${requestIndex}`,
|
||||||
|
type: "function",
|
||||||
|
function: { name, arguments: JSON.stringify(args) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let message;
|
||||||
|
if (mode === "chat" || evidenceSeen) {
|
||||||
|
message = { role: "assistant", content: "REA_CLIENT_COMPATIBILITY_OK" };
|
||||||
|
finalSeen = true;
|
||||||
|
} else if (skill && !skillRequested) {
|
||||||
|
skillRequested = true;
|
||||||
|
message = call(skill.name, { skill: "reverse-engineer-anything" });
|
||||||
|
} else if (persisted && shell) {
|
||||||
|
artifactPath = persisted[1];
|
||||||
|
assert(
|
||||||
|
(await realpath(artifactPath)).startsWith(
|
||||||
|
(await realpath(runtime)) + "/",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const script = `const fs=require('node:fs');const e=JSON.parse(fs.readFileSync(${JSON.stringify(artifactPath)},'utf8'));console.log(JSON.stringify({REA_CLIENT_FIXTURE:JSON.stringify(e.normalized_result).includes('REA_CLIENT_FIXTURE'),evidence_id:e.evidence_id,graph_nodes:e.normalized_result.graph.nodes.length}));`;
|
||||||
|
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||||
|
message = call(shell.name, {
|
||||||
|
command: `node -e ${quote(script)}`,
|
||||||
|
description:
|
||||||
|
"Read the complete saved REA Evidence and verify the fixture facts.",
|
||||||
|
});
|
||||||
|
} else if (selected)
|
||||||
|
message = call(selected.name, {
|
||||||
|
input_path: target,
|
||||||
|
format: "directory",
|
||||||
|
});
|
||||||
|
else if (reviewedNames.length && invoke)
|
||||||
|
message = call(invoke.name, {
|
||||||
|
name: reviewedNames[0],
|
||||||
|
arguments: { input_path: target, format: "directory" },
|
||||||
|
});
|
||||||
|
else if (search && !searchRequested) {
|
||||||
|
searchRequested = true;
|
||||||
|
message = call(search.name, { query: "analyze_javascript_application" });
|
||||||
|
} else
|
||||||
|
throw new Error(
|
||||||
|
`REA analysis tool not exposed; tools: ${tools.map((t) => t.name).join(", ")}`,
|
||||||
|
);
|
||||||
|
return message;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const endpoint = fixture.baseUrl;
|
||||||
|
const prompt =
|
||||||
|
mode === "chat"
|
||||||
|
? "Say hello briefly."
|
||||||
|
: `Use REA to analyze the JavaScript directory ${target}. Report the result.`;
|
||||||
|
const env = {
|
||||||
|
...baseEnvironment,
|
||||||
|
OPENAI_API_KEY: "local-fixture-key",
|
||||||
|
OPENAI_BASE_URL: endpoint,
|
||||||
|
};
|
||||||
|
const args = [
|
||||||
|
"--auth-type",
|
||||||
|
"openai",
|
||||||
|
"--openai-base-url",
|
||||||
|
endpoint,
|
||||||
|
"--model",
|
||||||
|
"rea-client-fixture",
|
||||||
|
"--approval-mode",
|
||||||
|
mode === "chat" ? "default" : "yolo",
|
||||||
|
"--allowed-mcp-server-names",
|
||||||
|
"rea",
|
||||||
|
"--output-format",
|
||||||
|
"stream-json",
|
||||||
|
"--max-session-turns",
|
||||||
|
"8",
|
||||||
|
"-p",
|
||||||
|
prompt,
|
||||||
|
];
|
||||||
|
let response;
|
||||||
|
let failure;
|
||||||
|
try {
|
||||||
|
response = await exec(command, args, {
|
||||||
|
env,
|
||||||
|
cwd: workspace,
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
await writeFile(join(lab, `${mode}.stdout`), response.stdout);
|
||||||
|
await writeFile(join(lab, `${mode}.stderr`), response.stderr);
|
||||||
|
const initialized = response.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line))
|
||||||
|
.find((v) => v.type === "system" && v.subtype === "init");
|
||||||
|
assert(initialized);
|
||||||
|
const observed = initialized.tools
|
||||||
|
.filter((n) => n.startsWith("mcp__rea__"))
|
||||||
|
.sort();
|
||||||
|
assert.deepEqual(
|
||||||
|
observed,
|
||||||
|
TOOL_CONTRACTS.map((c) => `mcp__rea__${c.name}`).sort(),
|
||||||
|
"Qwen must discover the complete REA catalog",
|
||||||
|
);
|
||||||
|
assert.equal(fixture.failure, undefined);
|
||||||
|
assert(finalSeen);
|
||||||
|
if (mode === "call") {
|
||||||
|
assert(evidenceSeen);
|
||||||
|
assert(selectedSkillLoaded);
|
||||||
|
assert(artifactReadByAgent);
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
failure = cause;
|
||||||
|
if (typeof cause?.stdout === "string")
|
||||||
|
await writeFile(join(lab, `${mode}.stdout`), cause.stdout);
|
||||||
|
if (typeof cause?.stderr === "string")
|
||||||
|
await writeFile(join(lab, `${mode}.stderr`), cause.stderr);
|
||||||
|
} finally {
|
||||||
|
await fixture.close();
|
||||||
|
const lineage = await completeVerifierRun(verifier);
|
||||||
|
const receipt = {
|
||||||
|
client: "Qwen Code",
|
||||||
|
clientVersion,
|
||||||
|
mode,
|
||||||
|
passed: failure === undefined,
|
||||||
|
artifacts: lab,
|
||||||
|
runtimeRoot,
|
||||||
|
host: {
|
||||||
|
platform: process.platform,
|
||||||
|
architecture: process.arch,
|
||||||
|
node: process.versions.node,
|
||||||
|
},
|
||||||
|
modelFixture:
|
||||||
|
"loopback deterministic OpenAI-compatible endpoint; no live model-provider claim",
|
||||||
|
skillDiscovery:
|
||||||
|
"caller-configured isolated shared directory; default OS home discovery unverified",
|
||||||
|
catalogSize: TOOL_CONTRACTS.length,
|
||||||
|
requests: fixture.requests,
|
||||||
|
probes: fixture.probes,
|
||||||
|
evidenceSeen,
|
||||||
|
selectedSkillLoaded,
|
||||||
|
artifactReadByAgent,
|
||||||
|
artifactPath,
|
||||||
|
artifactDigest,
|
||||||
|
finalSeen,
|
||||||
|
fixtureFailure: fixture.failure,
|
||||||
|
verifier: lineage,
|
||||||
|
};
|
||||||
|
await writeFile(join(lab, "receipt.json"), JSON.stringify(receipt, null, 2));
|
||||||
|
console.log(JSON.stringify(receipt));
|
||||||
|
}
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
@@ -62,21 +62,21 @@ if (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||||
const architecture = process.argv[2] === "--x86" ? "x86" : "x86_64";
|
const architecture = process.argv.includes("--x86") ? "x86" : "x86_64";
|
||||||
|
const dllFixture = process.argv.includes("--dll");
|
||||||
|
const role = dllFixture ? "shared-library" : "application";
|
||||||
const targetPath = resolve(
|
const targetPath = resolve(
|
||||||
root,
|
root,
|
||||||
"build",
|
"build",
|
||||||
"fixtures",
|
"fixtures",
|
||||||
architecture === "x86"
|
`rea-ghidra-windows${architecture === "x86" ? "-x86" : ""}.${dllFixture ? "dll" : "exe"}`,
|
||||||
? "rea-ghidra-windows-x86.exe"
|
|
||||||
: "rea-ghidra-windows.exe",
|
|
||||||
);
|
);
|
||||||
const target = await parseBinaryTarget(targetPath);
|
const target = await parseBinaryTarget(targetPath);
|
||||||
if (
|
if (
|
||||||
!target.ok ||
|
!target.ok ||
|
||||||
target.value.format !== "pe" ||
|
target.value.format !== "pe" ||
|
||||||
target.value.architecture !== architecture ||
|
target.value.architecture !== architecture ||
|
||||||
target.value.executableRole !== "application" ||
|
target.value.executableRole !== role ||
|
||||||
target.value.managed !== false
|
target.value.managed !== false
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -89,6 +89,8 @@ const profile = await resolveGhidraAnalysisProfile(
|
|||||||
);
|
);
|
||||||
if (!profile.ok || profile.value.profile === null)
|
if (!profile.ok || profile.value.profile === null)
|
||||||
throw new Error("Windows Ghidra profile could not be committed");
|
throw new Error("Windows Ghidra profile could not be committed");
|
||||||
|
if (profile.value.profile.parameters.executable_role !== role)
|
||||||
|
throw new Error("Windows Ghidra profile lost the observed PE role");
|
||||||
|
|
||||||
const client = new GhidraClient({
|
const client = new GhidraClient({
|
||||||
platform: installation.platform,
|
platform: installation.platform,
|
||||||
@@ -162,6 +164,19 @@ try {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
"Windows fixture decompilation lost its known return value",
|
"Windows fixture decompilation lost its known return value",
|
||||||
);
|
);
|
||||||
|
if (dllFixture) {
|
||||||
|
const callerAddress = architecture === "x86" ? "0x401000" : "0x140001000";
|
||||||
|
const callees = await functionOperation("procedure_callees", {
|
||||||
|
procedure: callerAddress,
|
||||||
|
});
|
||||||
|
if (!callees.includes(callee.address))
|
||||||
|
throw new Error("DLL export caller lost its controlled call reference");
|
||||||
|
const callerCode = await functionOperation("procedure_pseudo_code", {
|
||||||
|
procedure: callerAddress,
|
||||||
|
});
|
||||||
|
if (typeof callerCode !== "string" || !/\+\s*1\b/u.test(callerCode))
|
||||||
|
throw new Error("DLL export caller lost its addition of one");
|
||||||
|
}
|
||||||
|
|
||||||
await inventory("inspect_native_load_image", {});
|
await inventory("inspect_native_load_image", {});
|
||||||
const memory = await inventory("read_bytes", {
|
const memory = await inventory("read_bytes", {
|
||||||
@@ -254,7 +269,7 @@ try {
|
|||||||
limitations: [
|
limitations: [
|
||||||
"approved-non-sensitive-fixtures-only",
|
"approved-non-sensitive-fixtures-only",
|
||||||
"windows-x64-local-ntfs-only",
|
"windows-x64-local-ntfs-only",
|
||||||
"native-x86-and-x86-64-pe-applications-only",
|
"native-x86-and-x86-64-pe-applications-and-dlls-only",
|
||||||
"no-gui-or-mutation-authority",
|
"no-gui-or-mutation-authority",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { cp, mkdir, readFile, readdir } from "node:fs/promises";
|
||||||
|
import { dirname, join, relative } from "node:path";
|
||||||
|
|
||||||
|
import { exec, pathExists } from "./lib/verify-package-core.mjs";
|
||||||
|
|
||||||
|
/** Exercise Skills' default root discovery without ignored build outputs. */
|
||||||
|
export async function verifyRepositorySkill({ root, workspace }) {
|
||||||
|
const snapshot = join(workspace, "repository");
|
||||||
|
const consumer = join(workspace, "skill-consumer");
|
||||||
|
const home = join(workspace, "skill-consumer-home");
|
||||||
|
const tracked = (
|
||||||
|
await exec(
|
||||||
|
"git",
|
||||||
|
["ls-files", "--cached", "--format=%(objectmode) %(path)", "-z"],
|
||||||
|
{ cwd: root },
|
||||||
|
)
|
||||||
|
).stdout
|
||||||
|
.split("\0")
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const entry of tracked) {
|
||||||
|
const mode = entry.slice(0, 6);
|
||||||
|
const path = entry.slice(7);
|
||||||
|
const destination = join(snapshot, path);
|
||||||
|
// A GitHub clone without submodule initialization has only these directories.
|
||||||
|
if (mode === "160000") {
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await cp(join(root, path), destination, { verbatimSymlinks: true });
|
||||||
|
}
|
||||||
|
if (await pathExists(join(snapshot, "skills")))
|
||||||
|
throw new Error("repository skill check must run without generated skills");
|
||||||
|
await mkdir(consumer, { recursive: true });
|
||||||
|
await mkdir(home, { recursive: true });
|
||||||
|
const skillsVersion = "1.7.2";
|
||||||
|
await exec(
|
||||||
|
"npm",
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
"--yes",
|
||||||
|
"--package",
|
||||||
|
`skills@${skillsVersion}`,
|
||||||
|
"--",
|
||||||
|
"skills",
|
||||||
|
"add",
|
||||||
|
snapshot,
|
||||||
|
"--skill",
|
||||||
|
"reverse-engineer-anything",
|
||||||
|
"--agent",
|
||||||
|
"codex",
|
||||||
|
"--yes",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: consumer,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
USERPROFILE: home,
|
||||||
|
XDG_CONFIG_HOME: join(home, ".config"),
|
||||||
|
CODEX_HOME: join(home, ".codex"),
|
||||||
|
DO_NOT_TRACK: "1",
|
||||||
|
SKILLS_NO_TELEMETRY: "1",
|
||||||
|
CI: "1",
|
||||||
|
},
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const authoredRoot = join(
|
||||||
|
snapshot,
|
||||||
|
".agents/skills/reverse-engineer-anything",
|
||||||
|
);
|
||||||
|
const installedSkills = join(consumer, ".agents/skills");
|
||||||
|
const installedRoot = join(installedSkills, "reverse-engineer-anything");
|
||||||
|
const files = await filePaths(authoredRoot);
|
||||||
|
if (
|
||||||
|
JSON.stringify(await readdir(installedSkills)) !==
|
||||||
|
JSON.stringify(["reverse-engineer-anything"]) ||
|
||||||
|
JSON.stringify(await filePaths(installedRoot)) !== JSON.stringify(files)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Skills did not install exactly the selected public bundle",
|
||||||
|
);
|
||||||
|
for (const path of files) {
|
||||||
|
const authored = await readFile(join(authoredRoot, path));
|
||||||
|
if (!authored.equals(await readFile(join(installedRoot, path))))
|
||||||
|
throw new Error(`Skills changed authored skill content: ${path}`);
|
||||||
|
}
|
||||||
|
return { skillsVersion, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filePaths(root) {
|
||||||
|
return (await readdir(root, { recursive: true, withFileTypes: true }))
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => relative(root, join(entry.parentPath, entry.name)))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ const LINK_MODES = [
|
|||||||
{
|
{
|
||||||
mode: "dyld-info",
|
mode: "dyld-info",
|
||||||
flags: ["-Wl,-no_fixup_chains"],
|
flags: ["-Wl,-no_fixup_chains"],
|
||||||
fixups: "LC_DYLD_INFO bind opcodes",
|
fixups: "LC_DYLD_INFO rebase opcodes were not decoded",
|
||||||
swift: true,
|
swift: true,
|
||||||
},
|
},
|
||||||
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
|
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
|
||||||
@@ -135,7 +135,12 @@ const checkObjc = (metadata, mode) => {
|
|||||||
item.selector === "reaFixtureLength",
|
item.selector === "reaFixtureLength",
|
||||||
) ||
|
) ||
|
||||||
objcCoverage?.status !== "complete" ||
|
objcCoverage?.status !== "complete" ||
|
||||||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
|
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
|
||||||
|
(mode.mode === "dyld-info") !==
|
||||||
|
(fixupCoverage(metadata)?.reason?.includes(
|
||||||
|
"LC_DYLD_INFO bind opcodes",
|
||||||
|
) ===
|
||||||
|
true)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
|
`Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
|
||||||
@@ -165,7 +170,12 @@ const checkSwift = (metadata, mode) => {
|
|||||||
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
|
item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" &&
|
||||||
item.implementation_address !== null,
|
item.implementation_address !== null,
|
||||||
) ||
|
) ||
|
||||||
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true
|
fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true ||
|
||||||
|
(mode.mode === "dyld-info") !==
|
||||||
|
(fixupCoverage(metadata)?.reason?.includes(
|
||||||
|
"LC_DYLD_INFO bind opcodes",
|
||||||
|
) ===
|
||||||
|
true)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
|
`Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { verifyDyldEnvironment } from "./dyld-environment.mjs";
|
import { verifyDyldEnvironment } from "./dyld-environment.mjs";
|
||||||
import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
|
import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
|
||||||
import { verifyMachOOccurrences } from "./macho-occurrences.mjs";
|
import { verifyMachOOccurrences } from "./macho-occurrences.mjs";
|
||||||
|
import { verifyNativeProvenance } from "./native-provenance.mjs";
|
||||||
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const EXPECTED_BUNDLES = [
|
|||||||
let report;
|
let report;
|
||||||
try {
|
try {
|
||||||
const { app, executable } = await buildMacosBundleFixture(root);
|
const { app, executable } = await buildMacosBundleFixture(root);
|
||||||
|
const nativeProvenance = await verifyNativeProvenance(app, executable);
|
||||||
const machoOccurrences = await verifyMachOOccurrences(app, executable);
|
const machoOccurrences = await verifyMachOOccurrences(app, executable);
|
||||||
const zip = join(root, "MacFixture.zip");
|
const zip = join(root, "MacFixture.zip");
|
||||||
await exec("/usr/bin/ditto", ["-c", "-k", "--keepParent", app, zip]);
|
await exec("/usr/bin/ditto", ["-c", "-k", "--keepParent", app, zip]);
|
||||||
@@ -166,6 +168,7 @@ try {
|
|||||||
containers: containers.map(({ format }) => format),
|
containers: containers.map(({ format }) => format),
|
||||||
bundles: EXPECTED_BUNDLES.length,
|
bundles: EXPECTED_BUNDLES.length,
|
||||||
dmg_detached: true,
|
dmg_detached: true,
|
||||||
|
native_provenance: nativeProvenance,
|
||||||
macho_occurrences: machoOccurrences,
|
macho_occurrences: machoOccurrences,
|
||||||
dylib_resolution: dylibs,
|
dylib_resolution: dylibs,
|
||||||
dyld_environment: dyldEnvironment,
|
dyld_environment: dyldEnvironment,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { toolContract } from "../../../dist/contracts/toolContracts.js";
|
||||||
|
import {
|
||||||
|
artifactCliEvidence,
|
||||||
|
withArtifactMcp,
|
||||||
|
} from "../../lib/artifact-e2e.mjs";
|
||||||
|
|
||||||
|
/** Inspect the compiled bundle with real native utilities through both adapters. */
|
||||||
|
export async function verifyNativeProvenance(app, executable) {
|
||||||
|
const original = await readFile(executable);
|
||||||
|
const sha256 = createHash("sha256").update(original).digest("hex");
|
||||||
|
const symbols = ["$s4main3FooV", "plain_symbol"];
|
||||||
|
const operations = [
|
||||||
|
["inspect_macho", []],
|
||||||
|
["inspect_signature", []],
|
||||||
|
["inspect_plist", []],
|
||||||
|
["list_architectures", []],
|
||||||
|
["demangle_swift", symbols],
|
||||||
|
];
|
||||||
|
const observations = [];
|
||||||
|
await withArtifactMcp(app, async (client) => {
|
||||||
|
const definitions = new Map(
|
||||||
|
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||||
|
);
|
||||||
|
for (const [operation, arguments_] of operations) {
|
||||||
|
const cli = toolContract(operation).outputSchema.parse(
|
||||||
|
await artifactCliEvidence(
|
||||||
|
operation.replaceAll("_", "-"),
|
||||||
|
app,
|
||||||
|
arguments_,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const response = await client.callTool(
|
||||||
|
{
|
||||||
|
name: operation,
|
||||||
|
arguments: operation === "demangle_swift" ? { symbols } : {},
|
||||||
|
},
|
||||||
|
{ toolDefinition: definitions.get(operation) },
|
||||||
|
);
|
||||||
|
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||||
|
const mcp = toolContract(operation).outputSchema.parse(
|
||||||
|
response.structuredContent,
|
||||||
|
);
|
||||||
|
assert.equal(cli.raw_result, null);
|
||||||
|
assert.equal(mcp.raw_result, null);
|
||||||
|
assert.ok(cli.normalized_result.provenance.length > 0);
|
||||||
|
assert.deepEqual(mcp.normalized_result, cli.normalized_result);
|
||||||
|
assert.deepEqual(
|
||||||
|
JSON.parse(response.content.find((item) => item.type === "text").text),
|
||||||
|
response.structuredContent,
|
||||||
|
);
|
||||||
|
await client.ping();
|
||||||
|
observations.push({
|
||||||
|
operation,
|
||||||
|
commands: cli.normalized_result.provenance.length,
|
||||||
|
cli_bytes: Buffer.byteLength(JSON.stringify(cli)),
|
||||||
|
mcp_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.deepEqual(await readFile(executable), original);
|
||||||
|
return {
|
||||||
|
real_native_utilities: true,
|
||||||
|
target_sha256: sha256,
|
||||||
|
bytes_unchanged: true,
|
||||||
|
observations,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/** A loopback model fixture exercises real clients without claiming live-provider compatibility. */
|
||||||
|
export const createOpenAiModelFixture = async ({
|
||||||
|
directory,
|
||||||
|
prefix,
|
||||||
|
onRequest,
|
||||||
|
}) => {
|
||||||
|
const requests = [];
|
||||||
|
const probes = [];
|
||||||
|
let incoming = 0;
|
||||||
|
let failure;
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (request.method === "GET" && request.url?.endsWith("/models")) {
|
||||||
|
response.setHeader("Content-Type", "application/json");
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "rea-client-fixture",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "local-fixture",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
let bytes = 0;
|
||||||
|
for await (const chunk of request) {
|
||||||
|
bytes += chunk.length;
|
||||||
|
assert(
|
||||||
|
bytes <= 10 * 1024 * 1024,
|
||||||
|
"Local model fixture exceeded its 10 MiB request resource budget",
|
||||||
|
);
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||||
|
const body = rawBody.length === 0 ? null : JSON.parse(rawBody);
|
||||||
|
await writeFile(
|
||||||
|
join(directory, `${prefix}-incoming-${++incoming}.json`),
|
||||||
|
JSON.stringify({ method: request.method, path: request.url, body }),
|
||||||
|
);
|
||||||
|
if (request.url !== "/v1/chat/completions") {
|
||||||
|
// Hermes probes custom endpoints for Ollama/llama.cpp metadata.
|
||||||
|
// Unsupported HTTP endpoints return 404, as on an OpenAI-only server.
|
||||||
|
probes.push({
|
||||||
|
method: request.method,
|
||||||
|
path: request.url,
|
||||||
|
body,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.setHeader("Content-Type", "application/json");
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
"This OpenAI fixture exposes models and chat completions only.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert.equal(request.method, "POST");
|
||||||
|
assert(body !== null && typeof body === "object" && !Array.isArray(body));
|
||||||
|
assert(Array.isArray(body.messages));
|
||||||
|
const tools = (body.tools ?? [])
|
||||||
|
.filter((t) => t.type === "function")
|
||||||
|
.map((t) => t.function ?? t);
|
||||||
|
requests.push({
|
||||||
|
path: request.url,
|
||||||
|
tools: tools.map((t) => t.name),
|
||||||
|
stream: body.stream,
|
||||||
|
messageRoles: body.messages.map((m) => m.role),
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
join(directory, `${prefix}-request-${requests.length}.json`),
|
||||||
|
JSON.stringify(body),
|
||||||
|
);
|
||||||
|
const message = await onRequest({
|
||||||
|
body,
|
||||||
|
tools,
|
||||||
|
requestIndex: requests.length,
|
||||||
|
});
|
||||||
|
const base = {
|
||||||
|
id: `chatcmpl-rea-${requests.length}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: body.model,
|
||||||
|
};
|
||||||
|
const finish = message.tool_calls ? "tool_calls" : "stop";
|
||||||
|
const usage = {
|
||||||
|
prompt_tokens: 100,
|
||||||
|
completion_tokens: 20,
|
||||||
|
total_tokens: 120,
|
||||||
|
};
|
||||||
|
if (body.stream) {
|
||||||
|
response.setHeader("Content-Type", "text/event-stream");
|
||||||
|
const emit = (delta, finishReason = null) =>
|
||||||
|
response.write(
|
||||||
|
`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`,
|
||||||
|
);
|
||||||
|
if (message.tool_calls) {
|
||||||
|
const fragments = message.tool_calls.map((call, index) => {
|
||||||
|
const split = Math.floor(call.function.arguments.length / 2);
|
||||||
|
return {
|
||||||
|
first: {
|
||||||
|
index,
|
||||||
|
...call,
|
||||||
|
function: {
|
||||||
|
name: call.function.name,
|
||||||
|
arguments: call.function.arguments.slice(0, split),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
second: {
|
||||||
|
index,
|
||||||
|
function: { arguments: call.function.arguments.slice(split) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
tool_calls: fragments.map((f) => f.first),
|
||||||
|
});
|
||||||
|
emit({ tool_calls: fragments.map((f) => f.second) });
|
||||||
|
} else emit(message);
|
||||||
|
emit({}, finish);
|
||||||
|
response.write(
|
||||||
|
`data: ${JSON.stringify({ ...base, object: "chat.completion.chunk", choices: [], usage })}\n\n`,
|
||||||
|
);
|
||||||
|
response.end("data: [DONE]\n\n");
|
||||||
|
} else {
|
||||||
|
response.setHeader("Content-Type", "application/json");
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
...base,
|
||||||
|
choices: [{ index: 0, message, finish_reason: finish }],
|
||||||
|
usage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
failure = String(cause);
|
||||||
|
response.statusCode = 400;
|
||||||
|
response.setHeader("Content-Type", "application/json");
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: { message: failure, type: "invalid_request_error" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
assert(address !== null && typeof address !== "string");
|
||||||
|
return {
|
||||||
|
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||||
|
requests,
|
||||||
|
probes,
|
||||||
|
get failure() {
|
||||||
|
return failure;
|
||||||
|
},
|
||||||
|
close: () => new Promise((resolve) => server.close(resolve)),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -54,15 +54,15 @@ try {
|
|||||||
data: { ...evidence, normalized_result: { payload: [marker] } },
|
data: { ...evidence, normalized_result: { payload: [marker] } },
|
||||||
meta: metadata,
|
meta: metadata,
|
||||||
};
|
};
|
||||||
const skeleton = JSON.stringify(template, null, format === "json" ? 2 : 0);
|
// The destination is a redirected file, so both formats are compact.
|
||||||
|
const skeleton = JSON.stringify(template);
|
||||||
const encodedMarker = JSON.stringify(marker);
|
const encodedMarker = JSON.stringify(marker);
|
||||||
const markerIndex = skeleton.indexOf(encodedMarker);
|
const markerIndex = skeleton.indexOf(encodedMarker);
|
||||||
assert.ok(markerIndex >= 0);
|
assert.ok(markerIndex >= 0);
|
||||||
assert.equal(markerIndex, skeleton.lastIndexOf(encodedMarker));
|
assert.equal(markerIndex, skeleton.lastIndexOf(encodedMarker));
|
||||||
const prefix = skeleton.slice(0, markerIndex);
|
const prefix = skeleton.slice(0, markerIndex);
|
||||||
const suffix = `${skeleton.slice(markerIndex + encodedMarker.length)}\n`;
|
const suffix = `${skeleton.slice(markerIndex + encodedMarker.length)}\n`;
|
||||||
const indentation = prefix.slice(prefix.lastIndexOf("\n") + 1);
|
const separator = ",";
|
||||||
const separator = format === "json" ? `,\n${indentation}` : ",";
|
|
||||||
const expectedHash = createHash("sha256");
|
const expectedHash = createHash("sha256");
|
||||||
expectedHash.update(prefix);
|
expectedHash.update(prefix);
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export async function connectRecordedCrash({ entrypoint, environment }) {
|
|||||||
evidence.confidence,
|
evidence.confidence,
|
||||||
debuggerContext ? "derived" : "observed",
|
debuggerContext ? "derived" : "observed",
|
||||||
);
|
);
|
||||||
assert.deepEqual(evidence.raw_result, report);
|
assert.equal(evidence.raw_result, null);
|
||||||
assert.equal(evidence.subject.local_path, path);
|
assert.equal(evidence.subject.local_path, path);
|
||||||
assert.equal(report.artifact.path, path);
|
assert.equal(report.artifact.path, path);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createAnalysisExecution } from "./AnalysisProvider.js";
|
||||||
|
|
||||||
|
const PROVIDER = { id: "fixture", name: "Fixture", version: "1" };
|
||||||
|
|
||||||
|
it("records no raw result unless the provider supplies a distinct one", () => {
|
||||||
|
const result = { entries: [{ value: "normalized" }] };
|
||||||
|
|
||||||
|
expect(createAnalysisExecution(result, PROVIDER)).toMatchObject({
|
||||||
|
result,
|
||||||
|
rawResult: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
createAnalysisExecution(result, PROVIDER, {
|
||||||
|
rawResult: { stdout: "raw" },
|
||||||
|
}).rawResult,
|
||||||
|
).toEqual({ stdout: "raw" });
|
||||||
|
expect(
|
||||||
|
createAnalysisExecution(result, PROVIDER, { rawResult: null }).rawResult,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
@@ -41,7 +41,13 @@ export interface AnalysisExecution {
|
|||||||
readonly subject: EvidenceSubjectTarget | null;
|
readonly subject: EvidenceSubjectTarget | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a validated atomic provider observation at an adapter boundary. */
|
/**
|
||||||
|
* Build a validated atomic provider observation at an adapter boundary.
|
||||||
|
*
|
||||||
|
* Pass `rawResult` only for a provider representation distinct from
|
||||||
|
* `result`. Omitting it records `null` rather than a second complete copy of
|
||||||
|
* the normalized result in every Evidence record and tool response.
|
||||||
|
*/
|
||||||
export const createAnalysisExecution = (
|
export const createAnalysisExecution = (
|
||||||
result: unknown,
|
result: unknown,
|
||||||
provider: ProviderIdentity,
|
provider: ProviderIdentity,
|
||||||
@@ -56,7 +62,7 @@ export const createAnalysisExecution = (
|
|||||||
result: jsonValueSchema.parse(result),
|
result: jsonValueSchema.parse(result),
|
||||||
rawResult:
|
rawResult:
|
||||||
options.rawResult === undefined
|
options.rawResult === undefined
|
||||||
? jsonValueSchema.parse(result)
|
? null
|
||||||
: jsonValueSchema.parse(options.rawResult),
|
: jsonValueSchema.parse(options.rawResult),
|
||||||
provider,
|
provider,
|
||||||
...(options.analysisProfile === undefined
|
...(options.analysisProfile === undefined
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
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 { isDeepStrictEqual } from "node:util";
|
||||||
import {
|
import {
|
||||||
applyEdits,
|
applyEdits,
|
||||||
@@ -70,6 +75,7 @@ export const clientConfigurationServersKey = (
|
|||||||
switch (format) {
|
switch (format) {
|
||||||
case "toml":
|
case "toml":
|
||||||
case "grok":
|
case "grok":
|
||||||
|
case "hermes":
|
||||||
return "mcp_servers";
|
return "mcp_servers";
|
||||||
case "opencode":
|
case "opencode":
|
||||||
return "mcp";
|
return "mcp";
|
||||||
@@ -93,6 +99,12 @@ const parseDocument = (
|
|||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
if (format === "toml" || format === "grok")
|
if (format === "toml" || format === "grok")
|
||||||
return objectSchema.parse(parseToml(text));
|
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,
|
// An empty file, which some clients create before any server is added,
|
||||||
// holds no settings to preserve.
|
// holds no settings to preserve.
|
||||||
if (isEmptyClientConfigurationText(text)) return {};
|
if (isEmptyClientConfigurationText(text)) return {};
|
||||||
@@ -1265,6 +1277,8 @@ export const serializeClientConfiguration = (
|
|||||||
return serializeCodexConfiguration(document, originalText);
|
return serializeCodexConfiguration(document, originalText);
|
||||||
if (format === "grok")
|
if (format === "grok")
|
||||||
return serializeGrokConfiguration(document, originalText);
|
return serializeGrokConfiguration(document, originalText);
|
||||||
|
if (format === "hermes")
|
||||||
|
return serializeHermesConfiguration(document, originalText);
|
||||||
// An empty original holds no comments or settings to preserve. Writing a
|
// 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.
|
// fresh document also keeps a lone BOM from shifting past the closing brace.
|
||||||
if (
|
if (
|
||||||
@@ -1303,6 +1317,34 @@ export const legacyClientServerPath = (
|
|||||||
? [parsed.serversPath[0] ?? "mcp", name]
|
? [parsed.serversPath[0] ?? "mcp", name]
|
||||||
: undefined;
|
: 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. */
|
/** Build the stdio entry shape expected by one client's configuration dialect. */
|
||||||
export const clientRegistrationEntry = (
|
export const clientRegistrationEntry = (
|
||||||
format: ClientRegistrationDialect,
|
format: ClientRegistrationDialect,
|
||||||
@@ -1331,6 +1373,7 @@ export const clientRegistrationEntry = (
|
|||||||
};
|
};
|
||||||
case "vscode":
|
case "vscode":
|
||||||
case "omp":
|
case "omp":
|
||||||
|
case "pi":
|
||||||
return {
|
return {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: executable,
|
command: executable,
|
||||||
@@ -1353,6 +1396,15 @@ export const clientRegistrationEntry = (
|
|||||||
args,
|
args,
|
||||||
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
...(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:
|
default:
|
||||||
return {
|
return {
|
||||||
command: executable,
|
command: executable,
|
||||||
@@ -1361,3 +1413,45 @@ export const clientRegistrationEntry = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
|
||||||
|
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
allowed: z.array(z.string()).optional(),
|
||||||
|
excluded: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.parse(parsed.document.mcp ?? {});
|
||||||
|
|
||||||
|
/** Gemini consolidates excludes by union and allowlists by normalized intersection. */
|
||||||
|
export const geminiServerPolicyBlock = (
|
||||||
|
policies: readonly ReturnType<typeof parseGeminiMcpPolicy>[],
|
||||||
|
serverKey: string,
|
||||||
|
): string | undefined => {
|
||||||
|
if (policies.some(({ excluded }) => excluded?.includes(serverKey)))
|
||||||
|
return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`;
|
||||||
|
const allowlists = policies.flatMap(({ allowed }) =>
|
||||||
|
allowed === undefined ? [] : [allowed],
|
||||||
|
);
|
||||||
|
const allowed = allowlists.reduce<string[] | undefined>(
|
||||||
|
(previous, current) => {
|
||||||
|
if (previous === undefined) return current;
|
||||||
|
const normalized = new Set(
|
||||||
|
current.map((name) => name.toLowerCase().trim()),
|
||||||
|
);
|
||||||
|
return previous.filter((name) =>
|
||||||
|
normalized.has(name.toLowerCase().trim()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
// Gemini's manager applies the consolidated allowlist only when nonempty.
|
||||||
|
if (
|
||||||
|
allowed !== undefined &&
|
||||||
|
allowed.length > 0 &&
|
||||||
|
!allowed.includes(serverKey)
|
||||||
|
)
|
||||||
|
return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import {
|
||||||
|
effectiveClientServer,
|
||||||
|
parseClientConfiguration,
|
||||||
|
type ClientConfigurationDocument,
|
||||||
|
} from "./ClientConfigurationDocument.js";
|
||||||
|
import type { SetupClient } from "./SupportedClients.js";
|
||||||
|
|
||||||
|
/** A named source failure that setup can explain without losing the bad path. */
|
||||||
|
export interface ClientConfigurationFileError {
|
||||||
|
readonly kind: "unreadable" | "malformed";
|
||||||
|
readonly path: string;
|
||||||
|
readonly detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve named read failures and distinguish a missing source from an empty file. */
|
||||||
|
export const readConfigurationText = async (
|
||||||
|
path: string,
|
||||||
|
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||||
|
try {
|
||||||
|
return ok(await readFile(path, "utf8"));
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
|
return ok(undefined);
|
||||||
|
return err({
|
||||||
|
kind: "unreadable",
|
||||||
|
path,
|
||||||
|
detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read and parse one configuration at its source boundary. */
|
||||||
|
export const readConfigurationFile = async (
|
||||||
|
path: string,
|
||||||
|
format: SetupClient["format"],
|
||||||
|
): Promise<
|
||||||
|
Result<ClientConfigurationDocument | undefined, ClientConfigurationFileError>
|
||||||
|
> => {
|
||||||
|
const source = await readConfigurationText(path);
|
||||||
|
if (!source.ok) return source;
|
||||||
|
const content = source.value;
|
||||||
|
if (content === undefined) return ok(undefined);
|
||||||
|
try {
|
||||||
|
return ok(parseClientConfiguration(content, format));
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return err({
|
||||||
|
kind: "malformed",
|
||||||
|
path,
|
||||||
|
detail: `Configuration ${path} is malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read every applicable user file; malformed lower-priority files also stop setup. */
|
||||||
|
export const readClientConfigurationFiles = async (
|
||||||
|
client: SetupClient,
|
||||||
|
): Promise<
|
||||||
|
Result<readonly ClientConfigurationDocument[], ClientConfigurationFileError>
|
||||||
|
> => {
|
||||||
|
const documents: ClientConfigurationDocument[] = [];
|
||||||
|
for (const path of client.configPaths ?? [client.configPath]) {
|
||||||
|
const document = await readConfigurationFile(path, client.format);
|
||||||
|
if (!document.ok) return document;
|
||||||
|
if (document.value !== undefined) documents.push(document.value);
|
||||||
|
}
|
||||||
|
return ok(documents);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** OpenCode merges server objects in file order, replacing arrays and scalar values. */
|
||||||
|
const mergeConfigurationValue = (previous: unknown, next: unknown): unknown => {
|
||||||
|
if (!isObject(previous) || !isObject(next)) return next;
|
||||||
|
return Object.fromEntries(
|
||||||
|
[...new Set([...Object.keys(previous), ...Object.keys(next)])].map(
|
||||||
|
(key) => [
|
||||||
|
key,
|
||||||
|
Object.hasOwn(next, key)
|
||||||
|
? mergeConfigurationValue(previous[key], next[key])
|
||||||
|
: previous[key],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
/** Project a client's effective user registration without modifying its source files. */
|
||||||
|
export const effectiveClientConfiguration = (
|
||||||
|
documents: readonly ClientConfigurationDocument[],
|
||||||
|
): ClientConfigurationDocument | undefined => {
|
||||||
|
const last = documents.at(-1);
|
||||||
|
if (last === undefined) return undefined;
|
||||||
|
if (documents.length === 1) return last;
|
||||||
|
const servers: Record<string, unknown> = {};
|
||||||
|
for (const document of documents) {
|
||||||
|
const names = new Set([
|
||||||
|
...Object.keys(document.legacyServers),
|
||||||
|
...Object.keys(document.servers),
|
||||||
|
]);
|
||||||
|
for (const name of names)
|
||||||
|
servers[name] = mergeConfigurationValue(
|
||||||
|
servers[name],
|
||||||
|
effectiveClientServer(document, name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...last, servers, legacyServers: {} };
|
||||||
|
};
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
||||||
|
import {
|
||||||
|
effectiveClientConfiguration,
|
||||||
|
readClientConfigurationFiles,
|
||||||
|
} from "./ClientConfigurationFiles.js";
|
||||||
import {
|
import {
|
||||||
clientServerForcedEnabled,
|
clientServerForcedEnabled,
|
||||||
clientServerListedDisabled,
|
clientServerListedDisabled,
|
||||||
effectiveClientServer,
|
effectiveClientServer,
|
||||||
parseClientConfiguration,
|
|
||||||
} from "./ClientConfigurationDocument.js";
|
} from "./ClientConfigurationDocument.js";
|
||||||
import { access, readFile } from "node:fs/promises";
|
import { access } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
npxRegistrationCommand,
|
npxRegistrationCommand,
|
||||||
} from "./ClientRegistrationIdentity.js";
|
} from "./ClientRegistrationIdentity.js";
|
||||||
import {
|
import {
|
||||||
|
clientEvidencePaths,
|
||||||
manualRegistrationRemediation,
|
manualRegistrationRemediation,
|
||||||
supportedClients,
|
supportedClients,
|
||||||
} from "./SupportedClients.js";
|
} from "./SupportedClients.js";
|
||||||
@@ -78,28 +83,19 @@ export const readClientRegistrationStatuses = async (
|
|||||||
for (const client of supportedClients(
|
for (const client of supportedClients(
|
||||||
home,
|
home,
|
||||||
options.platform,
|
options.platform,
|
||||||
options.environment === undefined
|
options.environment,
|
||||||
? 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,
|
|
||||||
SAND_DATA_ROOT: options.environment.SAND_DATA_ROOT,
|
|
||||||
XDG_CONFIG_HOME: options.environment.XDG_CONFIG_HOME,
|
|
||||||
},
|
|
||||||
)) {
|
)) {
|
||||||
if (
|
if (client.configPathError !== undefined) {
|
||||||
!(await exists(client.markerPath)) &&
|
statuses.push({
|
||||||
!(await exists(client.configPath))
|
client: client.name,
|
||||||
)
|
config_path: client.configPath,
|
||||||
|
command: [],
|
||||||
|
state: "invalid",
|
||||||
|
remediation: client.configPathError,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
if (!(await clientEvidencePresent(client))) continue;
|
||||||
const manualRemediation = manualRegistrationRemediation(client.name);
|
const manualRemediation = manualRegistrationRemediation(client.name);
|
||||||
if (client.format === "unsupported") {
|
if (client.format === "unsupported") {
|
||||||
if (manualRemediation !== undefined)
|
if (manualRemediation !== undefined)
|
||||||
@@ -113,8 +109,36 @@ export const readClientRegistrationStatuses = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const content = await readFile(client.configPath, "utf8");
|
const files = await readClientConfigurationFiles(client);
|
||||||
const parsed = parseClientConfiguration(content, client.format);
|
if (!files.ok) {
|
||||||
|
statuses.push({
|
||||||
|
client: client.name,
|
||||||
|
config_path: client.configPath,
|
||||||
|
command: [],
|
||||||
|
state: "invalid",
|
||||||
|
remediation: files.error.detail,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = effectiveClientConfiguration(files.value);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
statuses.push(
|
||||||
|
unavailableStatus(client.name, client.configPath, "missing"),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const policy = await readClientPolicyBlock(client, parsed);
|
||||||
|
if (!policy.ok) {
|
||||||
|
statuses.push({
|
||||||
|
client: client.name,
|
||||||
|
config_path: client.configPath,
|
||||||
|
command: [],
|
||||||
|
state: "invalid",
|
||||||
|
remediation: policy.error.detail,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const policyBlock = policy.value;
|
||||||
const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
|
const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey);
|
||||||
if (raw === undefined) {
|
if (raw === undefined) {
|
||||||
statuses.push(
|
statuses.push(
|
||||||
@@ -139,9 +163,14 @@ export const readClientRegistrationStatuses = async (
|
|||||||
options.platform ?? process.platform,
|
options.platform ?? process.platform,
|
||||||
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
|
clientServerForcedEnabled(parsed, PRODUCT_IDENTITY.mcpServerKey),
|
||||||
) &&
|
) &&
|
||||||
!clientServerListedDisabled(parsed, PRODUCT_IDENTITY.mcpServerKey)
|
!clientServerListedDisabled(
|
||||||
|
parsed,
|
||||||
|
PRODUCT_IDENTITY.mcpServerKey,
|
||||||
|
) &&
|
||||||
|
policyBlock === undefined
|
||||||
? "aligned"
|
? "aligned"
|
||||||
: "stale",
|
: "stale",
|
||||||
|
policyBlock,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
@@ -168,7 +197,7 @@ const registrationAligned = (
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
const command = [registration.command, ...registration.args];
|
const command = [registration.command, ...registration.args];
|
||||||
if (
|
if (
|
||||||
registration.disabled === true ||
|
(client.format !== "grok" && registration.disabled === true) ||
|
||||||
(registration.enabled === false && !forcedEnabled)
|
(registration.enabled === false && !forcedEnabled)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
@@ -181,9 +210,9 @@ const registrationAligned = (
|
|||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
if (client.format === "vscode" && registration.type !== "stdio") return false;
|
||||||
// OMP infers stdio for a command entry without an explicit type.
|
// OMP and Pi infer stdio for a command entry without an explicit type.
|
||||||
if (
|
if (
|
||||||
client.format === "omp" &&
|
(client.format === "omp" || client.format === "pi") &&
|
||||||
registration.type !== undefined &&
|
registration.type !== undefined &&
|
||||||
registration.type !== "stdio"
|
registration.type !== "stdio"
|
||||||
)
|
)
|
||||||
@@ -245,7 +274,7 @@ const parseRegistration = (
|
|||||||
if (client.format === "commandcode" && registration.transport !== "stdio")
|
if (client.format === "commandcode" && registration.transport !== "stdio")
|
||||||
throw new TypeError("Expected an stdio registration");
|
throw new TypeError("Expected an stdio registration");
|
||||||
if (
|
if (
|
||||||
client.format === "omp" &&
|
(client.format === "omp" || client.format === "pi") &&
|
||||||
registration.type !== undefined &&
|
registration.type !== undefined &&
|
||||||
registration.type !== "stdio"
|
registration.type !== "stdio"
|
||||||
)
|
)
|
||||||
@@ -261,10 +290,17 @@ const configuredStatus = (
|
|||||||
configPath: string,
|
configPath: string,
|
||||||
command: RegistrationCommand,
|
command: RegistrationCommand,
|
||||||
state: "aligned" | "stale",
|
state: "aligned" | "stale",
|
||||||
|
policyRemediation?: string,
|
||||||
): ClientRegistrationStatus =>
|
): ClientRegistrationStatus =>
|
||||||
state === "aligned"
|
state === "aligned"
|
||||||
? { client, config_path: configPath, command, state, remediation: null }
|
? { client, config_path: configPath, command, state, remediation: null }
|
||||||
: { client, config_path: configPath, command, state, remediation };
|
: {
|
||||||
|
client,
|
||||||
|
config_path: configPath,
|
||||||
|
command,
|
||||||
|
state,
|
||||||
|
remediation: policyRemediation ?? remediation,
|
||||||
|
};
|
||||||
|
|
||||||
const unavailableStatus = (
|
const unavailableStatus = (
|
||||||
client: string,
|
client: string,
|
||||||
@@ -278,6 +314,12 @@ const unavailableStatus = (
|
|||||||
remediation,
|
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> => {
|
const exists = async (path: string | undefined): Promise<boolean> => {
|
||||||
if (path === undefined) return false;
|
if (path === undefined) return false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
type LinuxDistribution,
|
type LinuxDistribution,
|
||||||
} from "./LinuxHopper.js";
|
} from "./LinuxHopper.js";
|
||||||
import {
|
import {
|
||||||
claudeCodeSkillsDirectory,
|
|
||||||
readInstalledSkillIdentity,
|
readInstalledSkillIdentity,
|
||||||
type InstalledSkillIdentity,
|
type InstalledSkillIdentity,
|
||||||
} from "./SetupSkill.js";
|
} from "./SetupSkill.js";
|
||||||
@@ -441,7 +440,8 @@ export const systemDoctorHost = (
|
|||||||
readInstalledSkillIdentity(
|
readInstalledSkillIdentity(
|
||||||
homeDirectory,
|
homeDirectory,
|
||||||
clientIds,
|
clientIds,
|
||||||
claudeCodeSkillsDirectory(homeDirectory, environment),
|
environment,
|
||||||
|
platform,
|
||||||
),
|
),
|
||||||
clientRegistrations: () =>
|
clientRegistrations: () =>
|
||||||
readClientRegistrationStatuses(homeDirectory, undefined, {
|
readClientRegistrationStatuses(homeDirectory, undefined, {
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { realpath } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { stripComments } from "jsonc-parser";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
|
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||||
|
import {
|
||||||
|
clientConfigurationValuesEqual,
|
||||||
|
clientRegistrationEntry,
|
||||||
|
geminiServerPolicyBlock,
|
||||||
|
parseGeminiMcpPolicy,
|
||||||
|
type ClientConfigurationDocument,
|
||||||
|
} from "./ClientConfigurationDocument.js";
|
||||||
|
import {
|
||||||
|
readConfigurationFile,
|
||||||
|
readConfigurationText,
|
||||||
|
type ClientConfigurationFileError,
|
||||||
|
} from "./ClientConfigurationFiles.js";
|
||||||
|
import type { SetupClient } from "./SupportedClients.js";
|
||||||
|
|
||||||
|
type SettingsSource = { path: string; document: ClientConfigurationDocument };
|
||||||
|
const folderTrustSchema = z
|
||||||
|
.object({
|
||||||
|
security: z
|
||||||
|
.object({
|
||||||
|
folderTrust: z
|
||||||
|
.object({ enabled: z.boolean().optional() })
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
const trustRulesSchema = z.record(
|
||||||
|
z.string(),
|
||||||
|
z.enum(["TRUST_FOLDER", "TRUST_PARENT", "DO_NOT_TRUST"]),
|
||||||
|
);
|
||||||
|
const sourceFailure = (
|
||||||
|
path: string,
|
||||||
|
cause: unknown,
|
||||||
|
): ClientConfigurationFileError => ({
|
||||||
|
kind: "malformed",
|
||||||
|
path,
|
||||||
|
detail: `Gemini settings in ${path} are malformed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gemini resolves symlinks when possible and compares macOS/Windows paths without case.
|
||||||
|
const normalizedPath = async (
|
||||||
|
path: string,
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
): Promise<string> => {
|
||||||
|
const physical = await realpath(path).catch(() => path);
|
||||||
|
const absolute = resolve(physical).replaceAll("\\", "/");
|
||||||
|
return platform === "win32" || platform === "darwin"
|
||||||
|
? absolute.toLowerCase()
|
||||||
|
: absolute;
|
||||||
|
};
|
||||||
|
|
||||||
|
const workspaceTrusted = async (
|
||||||
|
settings: NonNullable<SetupClient["geminiSettings"]>,
|
||||||
|
sources: readonly SettingsSource[],
|
||||||
|
): Promise<Result<boolean, ClientConfigurationFileError>> => {
|
||||||
|
let enabled = true;
|
||||||
|
for (const { path, document } of sources) {
|
||||||
|
const parsed = folderTrustSchema.safeParse(document.document);
|
||||||
|
if (!parsed.success) return err(sourceFailure(path, parsed.error));
|
||||||
|
enabled = parsed.data.security?.folderTrust?.enabled ?? enabled;
|
||||||
|
}
|
||||||
|
if (settings.trustOverride !== undefined) return ok(settings.trustOverride);
|
||||||
|
if (!enabled) return ok(true);
|
||||||
|
const text = await readConfigurationText(settings.trustedFoldersPath);
|
||||||
|
if (!text.ok) return text;
|
||||||
|
if (text.value === undefined) return ok(false);
|
||||||
|
let rules: z.output<typeof trustRulesSchema>;
|
||||||
|
try {
|
||||||
|
// Gemini strips comments, but rejects trailing commas, BOMs and invalid trust levels.
|
||||||
|
rules = trustRulesSchema.parse(JSON.parse(stripComments(text.value)));
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return err(sourceFailure(settings.trustedFoldersPath, cause));
|
||||||
|
}
|
||||||
|
const workspace = await normalizedPath(
|
||||||
|
settings.workspaceDirectory,
|
||||||
|
settings.platform,
|
||||||
|
);
|
||||||
|
let longest = -1;
|
||||||
|
let trusted = false;
|
||||||
|
for (const [path, level] of Object.entries(rules)) {
|
||||||
|
const effective = await normalizedPath(
|
||||||
|
level === "TRUST_PARENT" ? dirname(path) : path,
|
||||||
|
settings.platform,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(workspace === effective ||
|
||||||
|
workspace.startsWith(
|
||||||
|
effective.endsWith("/") ? effective : `${effective}/`,
|
||||||
|
)) &&
|
||||||
|
path.length > longest
|
||||||
|
) {
|
||||||
|
longest = path.length;
|
||||||
|
trusted = level !== "DO_NOT_TRUST";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ok(trusted);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Check effective Gemini scopes while preserving their authority and source identity. */
|
||||||
|
export const readClientPolicyBlock = async (
|
||||||
|
client: SetupClient,
|
||||||
|
userDocument?: ClientConfigurationDocument,
|
||||||
|
desiredRegistration?: unknown,
|
||||||
|
): Promise<Result<string | undefined, ClientConfigurationFileError>> => {
|
||||||
|
if (client.name !== "gemini_cli" || client.geminiSettings === undefined)
|
||||||
|
return ok(undefined);
|
||||||
|
const settings = client.geminiSettings;
|
||||||
|
const sources: SettingsSource[] = [];
|
||||||
|
const higher: SettingsSource[] = [];
|
||||||
|
for (const path of [
|
||||||
|
settings.defaultsPath,
|
||||||
|
client.configPath,
|
||||||
|
settings.systemPath,
|
||||||
|
]) {
|
||||||
|
const parsed =
|
||||||
|
path === client.configPath && userDocument !== undefined
|
||||||
|
? ok(userDocument)
|
||||||
|
: await readConfigurationFile(path, "json");
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
if (parsed.value !== undefined)
|
||||||
|
sources.push({ path, document: parsed.value });
|
||||||
|
}
|
||||||
|
const workspace = await normalizedPath(
|
||||||
|
settings.workspaceDirectory,
|
||||||
|
settings.platform,
|
||||||
|
);
|
||||||
|
const home = await normalizedPath(
|
||||||
|
dirname(dirname(client.configPath)),
|
||||||
|
settings.platform,
|
||||||
|
);
|
||||||
|
if (workspace !== home) {
|
||||||
|
const path = resolve(
|
||||||
|
settings.workspaceDirectory,
|
||||||
|
".gemini",
|
||||||
|
"settings.json",
|
||||||
|
);
|
||||||
|
const document = await readConfigurationFile(path, "json");
|
||||||
|
if (!document.ok) return document;
|
||||||
|
if (document.value !== undefined) {
|
||||||
|
const trusted = await workspaceTrusted(settings, sources);
|
||||||
|
if (!trusted.ok) return trusted;
|
||||||
|
if (trusted.value) {
|
||||||
|
const source = { path, document: document.value };
|
||||||
|
const systemIndex = sources.findIndex(
|
||||||
|
(item) => item.path === settings.systemPath,
|
||||||
|
);
|
||||||
|
sources.splice(
|
||||||
|
systemIndex < 0 ? sources.length : systemIndex,
|
||||||
|
0,
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
higher.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const system = sources.find(({ path }) => path === settings.systemPath);
|
||||||
|
if (system !== undefined) higher.push(system);
|
||||||
|
const policies: ReturnType<typeof parseGeminiMcpPolicy>[] = [];
|
||||||
|
for (const { path, document } of sources) {
|
||||||
|
try {
|
||||||
|
policies.push(parseGeminiMcpPolicy(document));
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return err(sourceFailure(path, cause));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const policy = geminiServerPolicyBlock(
|
||||||
|
policies,
|
||||||
|
PRODUCT_IDENTITY.mcpServerKey,
|
||||||
|
);
|
||||||
|
if (policy !== undefined)
|
||||||
|
return ok(
|
||||||
|
`${policy} Settings sources: ${sources.map(({ path }) => path).join(", ")}.`,
|
||||||
|
);
|
||||||
|
// mcpServers uses shallow merge: the entire same-name definition is replaced.
|
||||||
|
const override = higher.findLast(({ document }) =>
|
||||||
|
Object.hasOwn(document.servers, PRODUCT_IDENTITY.mcpServerKey),
|
||||||
|
);
|
||||||
|
const desired =
|
||||||
|
desiredRegistration ??
|
||||||
|
userDocument?.servers[PRODUCT_IDENTITY.mcpServerKey] ??
|
||||||
|
clientRegistrationEntry("json", npxRegistrationCommand(), {});
|
||||||
|
if (
|
||||||
|
override !== undefined &&
|
||||||
|
!clientConfigurationValuesEqual(
|
||||||
|
override.document.servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||||
|
desired,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ok(
|
||||||
|
`Gemini mcpServers.${PRODUCT_IDENTITY.mcpServerKey} in ${override.path} overrides the user registration. Review that settings file before rerunning setup; REA does not modify system or workspace settings.`,
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createProgressReporter } from "./ProgressReporter.js";
|
||||||
|
|
||||||
|
it("limits intermediate sends to 100 ms and always delivers terminal status", async () => {
|
||||||
|
let now = 0;
|
||||||
|
const sent: number[] = [];
|
||||||
|
const reporter = createProgressReporter(
|
||||||
|
async () => {
|
||||||
|
sent.push(now);
|
||||||
|
},
|
||||||
|
{ now: () => now },
|
||||||
|
);
|
||||||
|
for (const [completed, elapsed] of [0, 25, 50, 99, 100, 199, 200].entries()) {
|
||||||
|
now = elapsed;
|
||||||
|
await reporter.report({
|
||||||
|
phase: "running",
|
||||||
|
completed,
|
||||||
|
total: null,
|
||||||
|
message: "Observed capture status",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(sent).toEqual([0, 100, 200]);
|
||||||
|
now = 201;
|
||||||
|
await reporter.report({
|
||||||
|
phase: "cleanup",
|
||||||
|
completed: 7,
|
||||||
|
total: null,
|
||||||
|
message: "Owned cleanup complete",
|
||||||
|
terminal: true,
|
||||||
|
});
|
||||||
|
expect(sent).toEqual([0, 100, 200, 201]);
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { posix } from "node:path";
|
import { posix } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
logicalPathEscapesRoot,
|
||||||
|
normalizeJoinedLogicalPath,
|
||||||
|
} from "../domain/artifactIdentity.js";
|
||||||
import {
|
import {
|
||||||
classifyReferenceSourcePath,
|
classifyReferenceSourcePath,
|
||||||
detectReferenceSourceLanguage,
|
detectReferenceSourceLanguage,
|
||||||
@@ -83,10 +87,10 @@ const resolveInternalSpecifier = (
|
|||||||
): { to: string; resolution: "internal" | "unresolved" } => {
|
): { to: string; resolution: "internal" | "unresolved" } => {
|
||||||
if (posix.isAbsolute(specifier))
|
if (posix.isAbsolute(specifier))
|
||||||
return { to: specifier, resolution: "unresolved" };
|
return { to: specifier, resolution: "unresolved" };
|
||||||
const normalized = posix.normalize(
|
const normalized = normalizeJoinedLogicalPath(
|
||||||
posix.join(posix.dirname(fromPath), specifier),
|
posix.join(posix.dirname(fromPath), specifier),
|
||||||
);
|
);
|
||||||
if (normalized.startsWith("../") || normalized === "..")
|
if (logicalPathEscapesRoot(normalized))
|
||||||
return { to: normalized, resolution: "unresolved" };
|
return { to: normalized, resolution: "unresolved" };
|
||||||
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
||||||
const candidates = suffixes.flatMap((suffix) => [
|
const candidates = suffixes.flatMap((suffix) => [
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import fs from "node:fs";
|
|||||||
import { lstat, readFile } from "node:fs/promises";
|
import { lstat, readFile } from "node:fs/promises";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
import { resolveRef } from "isomorphic-git";
|
|
||||||
|
|
||||||
export type ReferenceSourceVcsInfo =
|
export type ReferenceSourceVcsInfo =
|
||||||
| {
|
| {
|
||||||
readonly kind: "git";
|
readonly kind: "git";
|
||||||
@@ -42,6 +40,8 @@ export const readReferenceSourceVcs = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveSourceHead = async (root: string): Promise<string> => {
|
const resolveSourceHead = async (root: string): Promise<string> => {
|
||||||
|
// isomorphic-git is loaded on first use so CLI and MCP startup skip it.
|
||||||
|
const { resolveRef } = await import("isomorphic-git");
|
||||||
const dotgit = join(root, ".git");
|
const dotgit = join(root, ".git");
|
||||||
if (!(await lstat(dotgit)).isFile())
|
if (!(await lstat(dotgit)).isFile())
|
||||||
return resolveRef({ fs, dir: root, ref: "HEAD" });
|
return resolveRef({ fs, dir: root, ref: "HEAD" });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { skillDestinations } from "./SetupSkill.js";
|
||||||
import {
|
import {
|
||||||
type ClientConfigurationInspection,
|
type ClientConfigurationInspection,
|
||||||
type ClientConfigurationResult,
|
type ClientConfigurationResult,
|
||||||
@@ -18,7 +19,9 @@ import { setupRegistrationCommand } from "./SetupHost.js";
|
|||||||
export class FakeSetupHost implements SetupHost {
|
export class FakeSetupHost implements SetupHost {
|
||||||
readonly platform: NodeJS.Platform;
|
readonly platform: NodeJS.Platform;
|
||||||
readonly homeDirectory = "/fixture/home";
|
readonly homeDirectory = "/fixture/home";
|
||||||
readonly claudeCodeSkillsDirectory = "/fixture/home/.claude/skills";
|
skillDestinations(clientIds: readonly string[]) {
|
||||||
|
return skillDestinations(this.homeDirectory, clientIds);
|
||||||
|
}
|
||||||
readonly registrationCommand: readonly string[];
|
readonly registrationCommand: readonly string[];
|
||||||
nodeVersion = "24.18.0";
|
nodeVersion = "24.18.0";
|
||||||
version: string | undefined = "14.5";
|
version: string | undefined = "14.5";
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readClientPolicyBlock } from "./GeminiClientSettings.js";
|
||||||
|
import { readClientConfigurationFiles } from "./ClientConfigurationFiles.js";
|
||||||
import {
|
import {
|
||||||
clientRegistrationEntry,
|
clientRegistrationEntry,
|
||||||
clientConfigurationValuesEqual,
|
clientConfigurationValuesEqual,
|
||||||
@@ -33,6 +35,8 @@ export const configureClientConfiguration = (
|
|||||||
environment: SetupProviderEnvironment = {},
|
environment: SetupProviderEnvironment = {},
|
||||||
command: readonly string[] = npxRegistrationCommand(),
|
command: readonly string[] = npxRegistrationCommand(),
|
||||||
): Promise<ClientConfigurationResult> => {
|
): Promise<ClientConfigurationResult> => {
|
||||||
|
if (client.configPathError !== undefined)
|
||||||
|
return Promise.resolve({ status: "failed", reason: "path" });
|
||||||
if (client.format === undefined || client.format === "unsupported")
|
if (client.format === undefined || client.format === "unsupported")
|
||||||
return Promise.resolve({ status: "failed", reason: "readback" });
|
return Promise.resolve({ status: "failed", reason: "readback" });
|
||||||
return configureClientDocument(client, environment, command, client.format);
|
return configureClientDocument(client, environment, command, client.format);
|
||||||
@@ -44,6 +48,15 @@ const configureClientDocument = async (
|
|||||||
command: readonly string[],
|
command: readonly string[],
|
||||||
format: NonNullable<SetupClient["format"]>,
|
format: NonNullable<SetupClient["format"]>,
|
||||||
): Promise<ClientConfigurationResult> => {
|
): 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(
|
const transactionPath = await resolveClientConfigTransactionPath(
|
||||||
client.configPath,
|
client.configPath,
|
||||||
);
|
);
|
||||||
@@ -61,6 +74,13 @@ const configureClientDocument = async (
|
|||||||
original ?? (format === "toml" || format === "grok" ? "" : "{}"),
|
original ?? (format === "toml" || format === "grok" ? "" : "{}"),
|
||||||
format,
|
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) {
|
} catch (cause: unknown) {
|
||||||
// Malformed existing configuration fails the readback gate.
|
// Malformed existing configuration fails the readback gate.
|
||||||
void cause;
|
void cause;
|
||||||
@@ -154,10 +174,14 @@ export const clientConfigurationAligned = async (
|
|||||||
providerEnvironment: SetupProviderEnvironment,
|
providerEnvironment: SetupProviderEnvironment,
|
||||||
command: readonly string[],
|
command: readonly string[],
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
|
if (client.configPathError !== undefined) return false;
|
||||||
try {
|
try {
|
||||||
|
const files = await readClientConfigurationFiles(client);
|
||||||
|
if (!files.ok) return false;
|
||||||
const original = await readFile(client.configPath, "utf8");
|
const original = await readFile(client.configPath, "utf8");
|
||||||
const parsed = parseClientConfiguration(original, client.format);
|
const parsed = parseClientConfiguration(original, client.format);
|
||||||
return registrationCurrent(
|
const policy = await readClientPolicyBlock(
|
||||||
|
client,
|
||||||
parsed,
|
parsed,
|
||||||
clientConfigurationDesired(
|
clientConfigurationDesired(
|
||||||
client,
|
client,
|
||||||
@@ -166,6 +190,19 @@ export const clientConfigurationAligned = async (
|
|||||||
parsed.dialect,
|
parsed.dialect,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
policy.ok &&
|
||||||
|
policy.value === undefined &&
|
||||||
|
registrationCurrent(
|
||||||
|
parsed,
|
||||||
|
clientConfigurationDesired(
|
||||||
|
client,
|
||||||
|
providerEnvironment,
|
||||||
|
command,
|
||||||
|
parsed.dialect,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// Unreadable configuration is treated as not aligned so setup repairs it.
|
// Unreadable configuration is treated as not aligned so setup repairs it.
|
||||||
void cause;
|
void cause;
|
||||||
@@ -179,7 +216,25 @@ export const inspectClientConfiguration = async (
|
|||||||
providerEnvironment: SetupProviderEnvironment,
|
providerEnvironment: SetupProviderEnvironment,
|
||||||
command: readonly string[],
|
command: readonly string[],
|
||||||
): Promise<ClientConfigurationInspection> => {
|
): Promise<ClientConfigurationInspection> => {
|
||||||
|
if (client.configPathError !== undefined)
|
||||||
|
return { status: "invalid", remediation: client.configPathError };
|
||||||
if (client.format === "unsupported") return { status: "already_current" };
|
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(
|
const transactionPath = await resolveClientConfigTransactionPath(
|
||||||
client.configPath,
|
client.configPath,
|
||||||
);
|
);
|
||||||
@@ -202,6 +257,20 @@ export const inspectClientConfiguration = async (
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = parseClientConfiguration(original, client.format);
|
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(
|
const desired = clientConfigurationDesired(
|
||||||
client,
|
client,
|
||||||
providerEnvironment,
|
providerEnvironment,
|
||||||
|
|||||||
@@ -14,9 +14,13 @@ import {
|
|||||||
systemLinuxHopperInstallHost,
|
systemLinuxHopperInstallHost,
|
||||||
} from "./LinuxHopper.js";
|
} from "./LinuxHopper.js";
|
||||||
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
|
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
|
||||||
import { supportedClients, type SetupClient } from "./SupportedClients.js";
|
|
||||||
import {
|
import {
|
||||||
claudeCodeSkillsDirectory as resolveClaudeCodeSkillsDirectory,
|
clientEvidencePaths,
|
||||||
|
supportedClients,
|
||||||
|
type SetupClient,
|
||||||
|
} from "./SupportedClients.js";
|
||||||
|
import {
|
||||||
|
skillDestinations,
|
||||||
canonicalSkillNeedsInstall,
|
canonicalSkillNeedsInstall,
|
||||||
installCanonicalSkill,
|
installCanonicalSkill,
|
||||||
} from "./SetupSkill.js";
|
} from "./SetupSkill.js";
|
||||||
@@ -90,14 +94,11 @@ export const systemSetupHost = (
|
|||||||
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
|
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
|
||||||
const platform = doctorHost.platform;
|
const platform = doctorHost.platform;
|
||||||
const { homeDirectory } = doctorHost;
|
const { homeDirectory } = doctorHost;
|
||||||
const claudeSkillsDirectory = resolveClaudeCodeSkillsDirectory(
|
|
||||||
homeDirectory,
|
|
||||||
environment,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
platform,
|
platform,
|
||||||
homeDirectory,
|
homeDirectory,
|
||||||
claudeCodeSkillsDirectory: claudeSkillsDirectory,
|
skillDestinations: (clientIds) =>
|
||||||
|
skillDestinations(homeDirectory, clientIds, environment, platform),
|
||||||
registrationCommand: setupRegistrationCommand(
|
registrationCommand: setupRegistrationCommand(
|
||||||
platform,
|
platform,
|
||||||
environment.npm_command === "exec",
|
environment.npm_command === "exec",
|
||||||
@@ -153,10 +154,11 @@ export const systemSetupHost = (
|
|||||||
canonicalSkillNeedsInstall(
|
canonicalSkillNeedsInstall(
|
||||||
homeDirectory,
|
homeDirectory,
|
||||||
clientIds,
|
clientIds,
|
||||||
claudeSkillsDirectory,
|
environment,
|
||||||
|
platform,
|
||||||
),
|
),
|
||||||
installSkill: (clientIds) =>
|
installSkill: (clientIds) =>
|
||||||
installCanonicalSkill(homeDirectory, clientIds, claudeSkillsDirectory),
|
installCanonicalSkill(homeDirectory, clientIds, environment, platform),
|
||||||
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
|
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -169,17 +171,20 @@ export const detectClients = async (
|
|||||||
): Promise<readonly SetupClient[]> => {
|
): Promise<readonly SetupClient[]> => {
|
||||||
const detected: SetupClient[] = [];
|
const detected: SetupClient[] = [];
|
||||||
for (const candidate of supportedClients(home, platform, environment)) {
|
for (const candidate of supportedClients(home, platform, environment)) {
|
||||||
const [hasConfig, hasMarker] = await Promise.all([
|
if (candidate.configPathError !== undefined) continue;
|
||||||
exists(candidate.configPath),
|
if (await clientEvidencePresent(candidate)) detected.push(candidate);
|
||||||
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
|
|
||||||
]);
|
|
||||||
if (hasConfig || hasMarker) detected.push(candidate);
|
|
||||||
}
|
}
|
||||||
return detected;
|
return detected;
|
||||||
};
|
};
|
||||||
|
|
||||||
const major = (version: string): number =>
|
const major = (version: string): number =>
|
||||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
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> => {
|
const exists = async (path: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await access(path);
|
await access(path);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
linuxHopperInstallDisclosure,
|
linuxHopperInstallDisclosure,
|
||||||
type LinuxPackageFamily,
|
type LinuxPackageFamily,
|
||||||
} from "./LinuxHopper.js";
|
} from "./LinuxHopper.js";
|
||||||
import { skillDestinations } from "./SetupSkill.js";
|
import type { SkillDestination } from "./SetupSkill.js";
|
||||||
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||||
import type {
|
import type {
|
||||||
SetupAction,
|
SetupAction,
|
||||||
@@ -188,7 +188,7 @@ export const planSetupActions = async (input: {
|
|||||||
installHopper: input.discovery.installHopper,
|
installHopper: input.discovery.installHopper,
|
||||||
installSkill: input.installSkill,
|
installSkill: input.installSkill,
|
||||||
skillClientIds: input.skillClientIds,
|
skillClientIds: input.skillClientIds,
|
||||||
claudeCodeSkillsDirectory: input.host.claudeCodeSkillsDirectory,
|
skillDestinations: input.host.skillDestinations(input.skillClientIds),
|
||||||
clients: clientPlans,
|
clients: clientPlans,
|
||||||
providerEnvironment: input.providerEnvironment,
|
providerEnvironment: input.providerEnvironment,
|
||||||
...(input.discovery.linuxPackageFamily === undefined
|
...(input.discovery.linuxPackageFamily === undefined
|
||||||
@@ -210,7 +210,7 @@ const setupPlan = (input: {
|
|||||||
readonly installHopper: boolean;
|
readonly installHopper: boolean;
|
||||||
readonly installSkill: boolean;
|
readonly installSkill: boolean;
|
||||||
readonly skillClientIds: readonly string[];
|
readonly skillClientIds: readonly string[];
|
||||||
readonly claudeCodeSkillsDirectory: string;
|
readonly skillDestinations: readonly SkillDestination[];
|
||||||
readonly clients: readonly {
|
readonly clients: readonly {
|
||||||
readonly client: SetupClient;
|
readonly client: SetupClient;
|
||||||
readonly operation: "create" | "update";
|
readonly operation: "create" | "update";
|
||||||
@@ -268,15 +268,8 @@ const setupPlan = (input: {
|
|||||||
...(backupPath === undefined ? {} : { backupPath }),
|
...(backupPath === undefined ? {} : { backupPath }),
|
||||||
})),
|
})),
|
||||||
...(input.installSkill
|
...(input.installSkill
|
||||||
? skillDestinations(
|
? input.skillDestinations.map(({ client, path }): SetupAction => ({
|
||||||
input.homeDirectory,
|
id: client === "shared" ? "install_skill" : `install_skill:${client}`,
|
||||||
input.skillClientIds,
|
|
||||||
input.claudeCodeSkillsDirectory,
|
|
||||||
).map(({ client, path }): SetupAction => ({
|
|
||||||
id:
|
|
||||||
client === "claude_code"
|
|
||||||
? "install_skill:claude_code"
|
|
||||||
: "install_skill",
|
|
||||||
kind: "install_skill",
|
kind: "install_skill",
|
||||||
label:
|
label:
|
||||||
client === "claude_code"
|
client === "claude_code"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|||||||
import writeFileAtomic from "write-file-atomic";
|
import writeFileAtomic from "write-file-atomic";
|
||||||
|
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
|
import { clientSkillDirectories } from "./SupportedClients.js";
|
||||||
|
|
||||||
const SKILL_FILES = [
|
const SKILL_FILES = [
|
||||||
"SKILL.md",
|
"SKILL.md",
|
||||||
@@ -21,7 +22,7 @@ interface CanonicalSkillFile {
|
|||||||
|
|
||||||
/** Managed skill location selected for a client family. */
|
/** Managed skill location selected for a client family. */
|
||||||
export interface SkillDestination {
|
export interface SkillDestination {
|
||||||
readonly client: "shared" | "claude_code";
|
readonly client: string;
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,44 +33,19 @@ export interface InstalledSkillIdentity {
|
|||||||
readonly canonical: boolean;
|
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. */
|
/** Resolve the skill roots required by a client selection, or all owned roots. */
|
||||||
export const skillDestinations = (
|
export const skillDestinations = (
|
||||||
home: string,
|
home: string,
|
||||||
clientIds: readonly string[] | undefined,
|
clientIds: readonly string[] | undefined,
|
||||||
claudeSkillsDirectory = claudeCodeSkillsDirectory(home),
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||||
): readonly SkillDestination[] => {
|
platform: NodeJS.Platform = process.platform,
|
||||||
const includeClaude =
|
): readonly SkillDestination[] =>
|
||||||
clientIds === undefined || clientIds.includes("claude_code");
|
clientSkillDirectories(home, clientIds, environment, platform).map(
|
||||||
const includeShared =
|
({ client, directory }) => ({
|
||||||
clientIds === undefined ||
|
client,
|
||||||
clientIds.length === 0 ||
|
path: join(directory, PRODUCT_IDENTITY.skillName),
|
||||||
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> => {
|
const readOptionalText = async (path: string): Promise<string | undefined> => {
|
||||||
try {
|
try {
|
||||||
@@ -84,10 +60,11 @@ const readOptionalText = async (path: string): Promise<string | undefined> => {
|
|||||||
const canonicalSkillFiles = async (
|
const canonicalSkillFiles = async (
|
||||||
home: string,
|
home: string,
|
||||||
clientIds: readonly string[],
|
clientIds: readonly string[],
|
||||||
claudeSkillsDirectory?: string,
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
): Promise<readonly CanonicalSkillFile[]> =>
|
): Promise<readonly CanonicalSkillFile[]> =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
skillDestinations(home, clientIds, claudeSkillsDirectory).flatMap(
|
skillDestinations(home, clientIds, environment, platform).flatMap(
|
||||||
({ path: root }) =>
|
({ path: root }) =>
|
||||||
SKILL_FILES.map(async (relativePath) => {
|
SKILL_FILES.map(async (relativePath) => {
|
||||||
const destination = join(root, relativePath);
|
const destination = join(root, relativePath);
|
||||||
@@ -110,11 +87,12 @@ const canonicalSkillFiles = async (
|
|||||||
export const canonicalSkillNeedsInstall = async (
|
export const canonicalSkillNeedsInstall = async (
|
||||||
home: string,
|
home: string,
|
||||||
clientIds: readonly string[] = [],
|
clientIds: readonly string[] = [],
|
||||||
claudeSkillsDirectory?: string,
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await canonicalSkillFiles(home, clientIds, claudeSkillsDirectory)
|
await canonicalSkillFiles(home, clientIds, environment, platform)
|
||||||
).some(({ content, original }) => original !== content);
|
).some(({ content, original }) => original !== content);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// Unreadable skill state fails open to install so setup can repair it.
|
// Unreadable skill state fails open to install so setup can repair it.
|
||||||
@@ -139,14 +117,16 @@ const restoreSkillFiles = async (
|
|||||||
export const installCanonicalSkill = async (
|
export const installCanonicalSkill = async (
|
||||||
home: string,
|
home: string,
|
||||||
clientIds: readonly string[] = [],
|
clientIds: readonly string[] = [],
|
||||||
claudeSkillsDirectory?: string,
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
): Promise<"installed" | "unchanged" | "failed"> => {
|
): Promise<"installed" | "unchanged" | "failed"> => {
|
||||||
let changed: readonly CanonicalSkillFile[] = [];
|
let changed: readonly CanonicalSkillFile[] = [];
|
||||||
try {
|
try {
|
||||||
const canonical = await canonicalSkillFiles(
|
const canonical = await canonicalSkillFiles(
|
||||||
home,
|
home,
|
||||||
clientIds,
|
clientIds,
|
||||||
claudeSkillsDirectory,
|
environment,
|
||||||
|
platform,
|
||||||
);
|
);
|
||||||
changed = canonical.filter(({ content, original }) => original !== content);
|
changed = canonical.filter(({ content, original }) => original !== content);
|
||||||
if (changed.length === 0) return "unchanged";
|
if (changed.length === 0) return "unchanged";
|
||||||
@@ -179,12 +159,14 @@ export const installCanonicalSkill = async (
|
|||||||
export const readInstalledSkillIdentity = async (
|
export const readInstalledSkillIdentity = async (
|
||||||
home: string,
|
home: string,
|
||||||
clientIds: readonly string[],
|
clientIds: readonly string[],
|
||||||
claudeSkillsDirectory?: string,
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
): Promise<InstalledSkillIdentity | undefined> => {
|
): Promise<InstalledSkillIdentity | undefined> => {
|
||||||
const destinations = skillDestinations(
|
const destinations = skillDestinations(
|
||||||
home,
|
home,
|
||||||
clientIds,
|
clientIds,
|
||||||
claudeSkillsDirectory,
|
environment,
|
||||||
|
platform,
|
||||||
);
|
);
|
||||||
let content: string | undefined;
|
let content: string | undefined;
|
||||||
for (const destination of destinations) {
|
for (const destination of destinations) {
|
||||||
@@ -202,7 +184,8 @@ export const readInstalledSkillIdentity = async (
|
|||||||
canonical: !(await canonicalSkillNeedsInstall(
|
canonical: !(await canonicalSkillNeedsInstall(
|
||||||
home,
|
home,
|
||||||
clientIds,
|
clientIds,
|
||||||
claudeSkillsDirectory,
|
environment,
|
||||||
|
platform,
|
||||||
)),
|
)),
|
||||||
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
|
version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null,
|
||||||
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
|
toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||||
|
import type { SkillDestination } from "./SetupSkill.js";
|
||||||
import type { SetupClient } from "./SupportedClients.js";
|
import type { SetupClient } from "./SupportedClients.js";
|
||||||
import type {
|
import type {
|
||||||
SetupFailureCode,
|
SetupFailureCode,
|
||||||
@@ -42,7 +43,7 @@ export type ClientConfigurationInspection =
|
|||||||
export interface SetupHost {
|
export interface SetupHost {
|
||||||
readonly platform: NodeJS.Platform;
|
readonly platform: NodeJS.Platform;
|
||||||
readonly homeDirectory: string;
|
readonly homeDirectory: string;
|
||||||
readonly claudeCodeSkillsDirectory: string;
|
skillDestinations(clientIds: readonly string[]): readonly SkillDestination[];
|
||||||
readonly registrationCommand: readonly string[];
|
readonly registrationCommand: readonly string[];
|
||||||
readonly nodeVersion: string;
|
readonly nodeVersion: string;
|
||||||
macosVersion(): Promise<string | undefined>;
|
macosVersion(): Promise<string | undefined>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isAbsolute, join } from "node:path";
|
import { dirname, isAbsolute, join, resolve, win32 } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { lstatSync } from "node:fs";
|
import { lstatSync } from "node:fs";
|
||||||
|
|
||||||
/** One supported client configuration location. */
|
/** One supported client configuration location. */
|
||||||
@@ -6,6 +7,19 @@ export interface SetupClient {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly displayName?: string;
|
readonly displayName?: string;
|
||||||
readonly configPath: 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 markerPath?: string;
|
||||||
readonly format?:
|
readonly format?:
|
||||||
| "json"
|
| "json"
|
||||||
@@ -16,6 +30,8 @@ export interface SetupClient {
|
|||||||
| "commandcode"
|
| "commandcode"
|
||||||
| "grok"
|
| "grok"
|
||||||
| "omp"
|
| "omp"
|
||||||
|
| "pi"
|
||||||
|
| "hermes"
|
||||||
| "unsupported";
|
| "unsupported";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,20 +40,7 @@ type ClientPath = readonly string[] | ((paths: ClientPathContext) => string);
|
|||||||
interface ClientPathContext {
|
interface ClientPathContext {
|
||||||
readonly home: string;
|
readonly home: string;
|
||||||
readonly platform: NodeJS.Platform;
|
readonly platform: NodeJS.Platform;
|
||||||
readonly env: {
|
readonly env: Readonly<NodeJS.ProcessEnv>;
|
||||||
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 SAND_DATA_ROOT?: string | undefined;
|
|
||||||
readonly XDG_CONFIG_HOME?: string | undefined;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientDefinition {
|
interface ClientDefinition {
|
||||||
@@ -46,6 +49,11 @@ interface ClientDefinition {
|
|||||||
readonly configPath: ClientPath;
|
readonly configPath: ClientPath;
|
||||||
readonly markerPath: ClientPath;
|
readonly markerPath: ClientPath;
|
||||||
readonly format: NonNullable<SetupClient["format"]>;
|
readonly format: NonNullable<SetupClient["format"]>;
|
||||||
|
readonly skillPath?: ClientPath;
|
||||||
|
readonly configPaths?: (context: ClientPathContext) => readonly string[];
|
||||||
|
readonly geminiSettings?: (
|
||||||
|
context: ClientPathContext,
|
||||||
|
) => NonNullable<SetupClient["geminiSettings"]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vscodeUserDirectory = ({
|
const vscodeUserDirectory = ({
|
||||||
@@ -83,10 +91,73 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
|
|||||||
env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
|
env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
|
||||||
|
|
||||||
const codexDirectory = ({ home, env }: ClientPathContext): string =>
|
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 => {
|
||||||
|
const override = env.QWEN_HOME;
|
||||||
|
if (!override) return join(home, ".qwen");
|
||||||
|
const expanded =
|
||||||
|
override === "~"
|
||||||
|
? home
|
||||||
|
: override.startsWith("~/") || override.startsWith("~\\")
|
||||||
|
? join(
|
||||||
|
home,
|
||||||
|
...override
|
||||||
|
.slice(2)
|
||||||
|
.split(/[/\\]+/)
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
: override;
|
||||||
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
||||||
|
};
|
||||||
|
|
||||||
const grokDirectory = ({ home, env }: ClientPathContext): string =>
|
const grokDirectory = ({ home, env }: ClientPathContext): string =>
|
||||||
env.GROK_HOME ?? join(home, ".grok");
|
env.GROK_HOME || join(home, ".grok");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hermes Agent home. `HERMES_HOME` wins; otherwise the platform default mirrors
|
||||||
|
* Hermes's own resolver (`%LOCALAPPDATA%\\hermes` on Windows, `~/.hermes`
|
||||||
|
* elsewhere), including the optional `HERMES_DATA_DIR_SUFFIX` that is appended
|
||||||
|
* to the literal `hermes`/`.hermes` directory name.
|
||||||
|
*/
|
||||||
|
const hermesDirectory = ({
|
||||||
|
home,
|
||||||
|
platform,
|
||||||
|
env,
|
||||||
|
}: ClientPathContext): string => {
|
||||||
|
const override = env.HERMES_HOME?.trim();
|
||||||
|
if (override) {
|
||||||
|
const expandedVariables = override.replace(
|
||||||
|
/\$(\w+|\{[^}]*\})/gu,
|
||||||
|
(match, variable: string) => {
|
||||||
|
const key = variable.startsWith("{") ? variable.slice(1, -1) : variable;
|
||||||
|
return env[key] ?? match;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const expanded =
|
||||||
|
platform === "win32"
|
||||||
|
? expandedVariables.replace(
|
||||||
|
/%([^%]+)%/gu,
|
||||||
|
(match, variable: string) => env[variable] ?? match,
|
||||||
|
)
|
||||||
|
: expandedVariables;
|
||||||
|
if (expanded === "~") return home;
|
||||||
|
if (
|
||||||
|
expanded.startsWith("~/") ||
|
||||||
|
(platform === "win32" && expanded.startsWith("~\\"))
|
||||||
|
)
|
||||||
|
return join(home, expanded.slice(2));
|
||||||
|
return resolve(expanded);
|
||||||
|
}
|
||||||
|
const suffix = env.HERMES_DATA_DIR_SUFFIX ?? "";
|
||||||
|
return platform === "win32"
|
||||||
|
? join(
|
||||||
|
env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
|
||||||
|
`hermes${suffix}`,
|
||||||
|
)
|
||||||
|
: join(home, `.hermes${suffix}`);
|
||||||
|
};
|
||||||
|
|
||||||
/** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */
|
/** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */
|
||||||
const grokBotDirectory = ({ home, env }: ClientPathContext): string => {
|
const grokBotDirectory = ({ home, env }: ClientPathContext): string => {
|
||||||
@@ -132,17 +203,49 @@ const ompProfile = ({ env }: ClientPathContext): string | undefined => {
|
|||||||
/**
|
/**
|
||||||
* The OMP agent directory whose `mcp.json` holds user-scope MCP servers. OMP
|
* 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
|
* joins PI_CONFIG_DIR to the home directory, and a named profile ignores
|
||||||
* PI_CODING_AGENT_DIR. OMP resolves a relative agent directory against each
|
* PI_CODING_AGENT_DIR. Relative overrides use the setup process working directory.
|
||||||
* process's working directory, so only an absolute override names one file.
|
|
||||||
*/
|
*/
|
||||||
const ompAgentDirectory = (context: ClientPathContext): string => {
|
const ompAgentDirectory = (context: ClientPathContext): string => {
|
||||||
const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp");
|
const root = join(context.home, context.env.PI_CONFIG_DIR || ".omp");
|
||||||
const profile = ompProfile(context);
|
const profile = ompProfile(context);
|
||||||
if (profile !== undefined) return join(root, "profiles", profile, "agent");
|
if (profile !== undefined) return join(root, "profiles", profile, "agent");
|
||||||
const override = context.env.PI_CODING_AGENT_DIR;
|
const override = context.env.PI_CODING_AGENT_DIR;
|
||||||
return override !== undefined && override !== "" && isAbsolute(override)
|
return override ? resolve(override) : join(root, "agent");
|
||||||
? override
|
};
|
||||||
: join(root, "agent");
|
|
||||||
|
/**
|
||||||
|
* Pi's public getAgentDir()/normalizePath semantics, not OMP's profiles.
|
||||||
|
* Keep relative overrides relative: filesystem consumers resolve them in cwd.
|
||||||
|
*/
|
||||||
|
const piAgentDirectory = ({
|
||||||
|
home,
|
||||||
|
platform,
|
||||||
|
env,
|
||||||
|
}: ClientPathContext): string => {
|
||||||
|
const paths = platform === "win32" ? win32 : { join };
|
||||||
|
let directory = env.PI_CODING_AGENT_DIR;
|
||||||
|
if (!directory) return paths.join(home, ".pi", "agent");
|
||||||
|
if (
|
||||||
|
platform === "win32" &&
|
||||||
|
directory.startsWith("/") &&
|
||||||
|
!directory.startsWith("//") &&
|
||||||
|
!directory.includes("\\")
|
||||||
|
) {
|
||||||
|
const drive = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/iu.exec(
|
||||||
|
directory,
|
||||||
|
);
|
||||||
|
if (drive !== null)
|
||||||
|
directory = `${drive[1]?.toUpperCase()}:\\${drive[2]?.replaceAll("/", "\\") ?? ""}`;
|
||||||
|
}
|
||||||
|
if (directory === "~") return home;
|
||||||
|
if (
|
||||||
|
directory.startsWith("~/") ||
|
||||||
|
(platform === "win32" && directory.startsWith("~\\"))
|
||||||
|
)
|
||||||
|
return paths.join(home, directory.slice(2));
|
||||||
|
if (directory.startsWith("file://"))
|
||||||
|
return fileURLToPath(directory, { windows: platform === "win32" });
|
||||||
|
return directory;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copilotDirectory = ({ home, env }: ClientPathContext): string =>
|
const copilotDirectory = ({ home, env }: ClientPathContext): string =>
|
||||||
@@ -151,21 +254,66 @@ const copilotDirectory = ({ home, env }: ClientPathContext): string =>
|
|||||||
const commandCodeDirectory = ({ home }: ClientPathContext): string =>
|
const commandCodeDirectory = ({ home }: ClientPathContext): string =>
|
||||||
join(home, ".commandcode");
|
join(home, ".commandcode");
|
||||||
|
|
||||||
const opencodeDirectory = ({ home, env }: ClientPathContext): string =>
|
/**
|
||||||
join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "opencode");
|
* OpenCode appends `OPENCODE_CONFIG_DIR` only when the value is non-empty.
|
||||||
|
* An empty variable is the same as unset and leaves the XDG directory in place.
|
||||||
|
*/
|
||||||
|
const openCodeOverrideDirectory = (
|
||||||
|
env: ClientPathContext["env"],
|
||||||
|
): string | undefined => {
|
||||||
|
const directory = env.OPENCODE_CONFIG_DIR;
|
||||||
|
return directory === undefined || directory === "" ? undefined : directory;
|
||||||
|
};
|
||||||
|
|
||||||
|
const opencodeDirectory = (context: ClientPathContext): string =>
|
||||||
|
openCodeOverrideDirectory(context.env) ??
|
||||||
|
join(
|
||||||
|
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
|
||||||
|
"opencode",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */
|
||||||
|
const openCodeConfigurationPaths = (
|
||||||
|
context: ClientPathContext,
|
||||||
|
): readonly string[] => {
|
||||||
|
const directory = join(
|
||||||
|
context.env.XDG_CONFIG_HOME ?? join(context.home, ".config"),
|
||||||
|
"opencode",
|
||||||
|
);
|
||||||
|
const overrideDirectory = openCodeOverrideDirectory(context.env);
|
||||||
|
const paths = [
|
||||||
|
join(directory, "config.json"),
|
||||||
|
join(directory, "opencode.json"),
|
||||||
|
join(directory, "opencode.jsonc"),
|
||||||
|
...(context.env.OPENCODE_CONFIG ? [context.env.OPENCODE_CONFIG] : []),
|
||||||
|
...(overrideDirectory !== undefined
|
||||||
|
? [
|
||||||
|
join(overrideDirectory, "opencode.json"),
|
||||||
|
join(overrideDirectory, "opencode.jsonc"),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const resolved = paths.map((path) => resolve(path));
|
||||||
|
return resolved.filter((path, index) => resolved.lastIndexOf(path) === index);
|
||||||
|
};
|
||||||
|
|
||||||
const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
|
const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
|
||||||
if (context.env.OPENCODE_CONFIG !== undefined)
|
// The selected directory is loaded after an explicit file by OpenCode V1.
|
||||||
return context.env.OPENCODE_CONFIG;
|
const overrideDirectory = openCodeOverrideDirectory(context.env);
|
||||||
const directory = opencodeDirectory(context);
|
const candidates =
|
||||||
const candidates = [
|
overrideDirectory !== undefined
|
||||||
"opencode.json",
|
? [
|
||||||
"opencode.jsonc",
|
join(overrideDirectory, "opencode.json"),
|
||||||
".opencode.json",
|
join(overrideDirectory, "opencode.jsonc"),
|
||||||
".opencode.jsonc",
|
].map((path) => resolve(path))
|
||||||
].map((filename) => join(directory, filename));
|
: context.env.OPENCODE_CONFIG
|
||||||
|
? [resolve(context.env.OPENCODE_CONFIG)]
|
||||||
|
: [
|
||||||
|
join(opencodeDirectory(context), "opencode.json"),
|
||||||
|
join(opencodeDirectory(context), "opencode.jsonc"),
|
||||||
|
].map((path) => resolve(path));
|
||||||
return (
|
return (
|
||||||
candidates.find((path) => {
|
[...candidates].reverse().find((path) => {
|
||||||
try {
|
try {
|
||||||
lstatSync(path);
|
lstatSync(path);
|
||||||
return true;
|
return true;
|
||||||
@@ -177,21 +325,60 @@ const existingOpenCodeConfigPath = (context: ClientPathContext): string => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}) ??
|
}) ??
|
||||||
candidates[0] ??
|
resolve(
|
||||||
join(directory, "opencode.json")
|
overrideDirectory !== undefined
|
||||||
|
? join(overrideDirectory, "opencode.json")
|
||||||
|
: context.env.OPENCODE_CONFIG ||
|
||||||
|
join(opencodeDirectory(context), "opencode.json"),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const geminiSettings = ({
|
||||||
|
home,
|
||||||
|
platform,
|
||||||
|
env,
|
||||||
|
}: ClientPathContext): NonNullable<SetupClient["geminiSettings"]> => {
|
||||||
|
const system =
|
||||||
|
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH ||
|
||||||
|
(platform === "darwin"
|
||||||
|
? "/Library/Application Support/GeminiCli/settings.json"
|
||||||
|
: platform === "win32"
|
||||||
|
? "C:\\ProgramData\\gemini-cli\\settings.json"
|
||||||
|
: "/etc/gemini-cli/settings.json");
|
||||||
|
const trustOverride =
|
||||||
|
env.GEMINI_RESTRICTED_MODE === "true" ||
|
||||||
|
env.GEMINI_CLI_TRUST_WORKSPACE === "false"
|
||||||
|
? false
|
||||||
|
: env.GEMINI_CLI_TRUST_WORKSPACE === "true"
|
||||||
|
? true
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
systemPath: system,
|
||||||
|
defaultsPath:
|
||||||
|
env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH ||
|
||||||
|
join(dirname(system), "system-defaults.json"),
|
||||||
|
workspaceDirectory: process.cwd(),
|
||||||
|
trustedFoldersPath:
|
||||||
|
env.GEMINI_CLI_TRUSTED_FOLDERS_PATH ||
|
||||||
|
join(env.GEMINI_CLI_HOME || home, ".gemini", "trustedFolders.json"),
|
||||||
|
...(trustOverride === undefined ? {} : { trustOverride }),
|
||||||
|
platform,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
|
const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
|
||||||
platform === "win32"
|
platform === "win32"
|
||||||
? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin")
|
? join(env.APPDATA ?? join(home, "AppData", "Roaming"), "devin")
|
||||||
: join(home, ".config", "devin");
|
: join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
|
||||||
|
|
||||||
/** Stable product metadata used to derive setup discovery and documentation. */
|
/** Stable product metadata used to derive setup discovery and documentation. */
|
||||||
export const SUPPORTED_CLIENT_DEFINITIONS = [
|
export const SUPPORTED_CLIENT_DEFINITIONS = [
|
||||||
{
|
{
|
||||||
name: "claude_code",
|
name: "claude_code",
|
||||||
displayName: "Claude Code",
|
displayName: "Claude Code",
|
||||||
|
skillPath: ({ home, env }: ClientPathContext) =>
|
||||||
|
join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"),
|
||||||
configPath: (context: ClientPathContext) =>
|
configPath: (context: ClientPathContext) =>
|
||||||
join(claudeCodeConfigDirectory(context), ".claude.json"),
|
join(claudeCodeConfigDirectory(context), ".claude.json"),
|
||||||
markerPath: claudeCodeMarkerDirectory,
|
markerPath: claudeCodeMarkerDirectory,
|
||||||
@@ -223,8 +410,13 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||||||
{
|
{
|
||||||
name: "gemini_cli",
|
name: "gemini_cli",
|
||||||
displayName: "Gemini CLI",
|
displayName: "Gemini CLI",
|
||||||
configPath: [".gemini", "settings.json"],
|
geminiSettings,
|
||||||
markerPath: [".gemini"],
|
configPath: ({ home, env }: ClientPathContext) =>
|
||||||
|
join(env.GEMINI_CLI_HOME || home, ".gemini", "settings.json"),
|
||||||
|
markerPath: ({ home, env }: ClientPathContext) =>
|
||||||
|
join(env.GEMINI_CLI_HOME || home, ".gemini"),
|
||||||
|
skillPath: ({ home, env }: ClientPathContext) =>
|
||||||
|
join(env.GEMINI_CLI_HOME || home, ".agents", "skills"),
|
||||||
format: "json",
|
format: "json",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -246,12 +438,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||||||
name: "opencode",
|
name: "opencode",
|
||||||
displayName: "OpenCode",
|
displayName: "OpenCode",
|
||||||
configPath: existingOpenCodeConfigPath,
|
configPath: existingOpenCodeConfigPath,
|
||||||
|
configPaths: openCodeConfigurationPaths,
|
||||||
markerPath: opencodeDirectory,
|
markerPath: opencodeDirectory,
|
||||||
format: "opencode",
|
format: "opencode",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "antigravity",
|
name: "antigravity",
|
||||||
displayName: "Antigravity",
|
displayName: "Antigravity",
|
||||||
|
skillPath: [".gemini", "config", "skills"],
|
||||||
configPath: [".gemini", "config", "mcp_config.json"],
|
configPath: [".gemini", "config", "mcp_config.json"],
|
||||||
markerPath: [".gemini", "config"],
|
markerPath: [".gemini", "config"],
|
||||||
format: "json",
|
format: "json",
|
||||||
@@ -272,6 +466,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||||||
markerPath: commandCodeDirectory,
|
markerPath: commandCodeDirectory,
|
||||||
format: "commandcode",
|
format: "commandcode",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "qwen_code",
|
||||||
|
displayName: "Qwen Code",
|
||||||
|
configPath: (context: ClientPathContext) =>
|
||||||
|
join(qwenCodeDirectory(context), "settings.json"),
|
||||||
|
markerPath: qwenCodeDirectory,
|
||||||
|
format: "json",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "vscode",
|
name: "vscode",
|
||||||
displayName: "VS Code",
|
displayName: "VS Code",
|
||||||
@@ -296,6 +498,27 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||||||
markerPath: ompAgentDirectory,
|
markerPath: ompAgentDirectory,
|
||||||
format: "omp",
|
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",
|
name: "grok_bot",
|
||||||
displayName: "Grok Bot",
|
displayName: "Grok Bot",
|
||||||
@@ -305,6 +528,23 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
] as const satisfies readonly ClientDefinition[];
|
] 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 =>
|
const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
|
||||||
typeof path === "function" ? path(context) : join(context.home, ...path);
|
typeof path === "function" ? path(context) : join(context.home, ...path);
|
||||||
|
|
||||||
@@ -312,27 +552,81 @@ const resolvePath = (path: ClientPath, context: ClientPathContext): string =>
|
|||||||
export const supportedClients = (
|
export const supportedClients = (
|
||||||
home: string,
|
home: string,
|
||||||
platform: NodeJS.Platform = process.platform,
|
platform: NodeJS.Platform = process.platform,
|
||||||
env: ClientPathContext["env"] = {
|
env: ClientPathContext["env"] = process.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,
|
|
||||||
SAND_DATA_ROOT: process.env.SAND_DATA_ROOT,
|
|
||||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
||||||
},
|
|
||||||
): readonly SetupClient[] => {
|
): readonly SetupClient[] => {
|
||||||
const context = { home, platform, env };
|
const context = { home, platform, env };
|
||||||
return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => ({
|
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
|
||||||
name: definition.name,
|
return definitions.map((definition) => {
|
||||||
displayName: definition.displayName,
|
try {
|
||||||
configPath: resolvePath(definition.configPath, context),
|
const configPath = resolvePath(definition.configPath, context);
|
||||||
markerPath: resolvePath(definition.markerPath, context),
|
return {
|
||||||
format: definition.format,
|
name: definition.name,
|
||||||
}));
|
displayName: definition.displayName,
|
||||||
|
configPath,
|
||||||
|
...(definition.geminiSettings === undefined
|
||||||
|
? {}
|
||||||
|
: { geminiSettings: definition.geminiSettings(context) }),
|
||||||
|
...(definition.configPaths === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
configPaths: [
|
||||||
|
...new Set([...definition.configPaths(context), configPath]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
markerPath: resolvePath(definition.markerPath, context),
|
||||||
|
format: definition.format,
|
||||||
|
};
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (definition.name !== "pi") throw cause;
|
||||||
|
return {
|
||||||
|
name: definition.name,
|
||||||
|
displayName: definition.displayName,
|
||||||
|
format: definition.format,
|
||||||
|
configPath: env.PI_CODING_AGENT_DIR ?? "",
|
||||||
|
configPathError: `Invalid PI_CODING_AGENT_DIR: ${cause instanceof Error ? cause.message : String(cause)}. Repair the override before configuring Pi.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve personal skill roots using the same client environment as setup. */
|
||||||
|
export const clientSkillDirectories = (
|
||||||
|
home: string,
|
||||||
|
clientIds: readonly string[] | undefined,
|
||||||
|
environment: Readonly<NodeJS.ProcessEnv> = {},
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): readonly { readonly client: string; readonly directory: string }[] => {
|
||||||
|
const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS;
|
||||||
|
const selected =
|
||||||
|
clientIds === undefined
|
||||||
|
? [
|
||||||
|
"shared",
|
||||||
|
...definitions
|
||||||
|
.filter(({ skillPath }) => skillPath !== undefined)
|
||||||
|
.map(({ name }) => name),
|
||||||
|
]
|
||||||
|
: clientIds.length === 0
|
||||||
|
? ["shared"]
|
||||||
|
: clientIds;
|
||||||
|
const destinations = new Map<string, { client: string; directory: string }>();
|
||||||
|
for (const client of selected) {
|
||||||
|
const definition = definitions.find(({ name }) => name === client);
|
||||||
|
const directory =
|
||||||
|
definition?.skillPath === undefined
|
||||||
|
? join(home, ".agents", "skills")
|
||||||
|
: resolvePath(definition.skillPath, {
|
||||||
|
home,
|
||||||
|
platform,
|
||||||
|
env: environment,
|
||||||
|
});
|
||||||
|
if (!destinations.has(directory))
|
||||||
|
destinations.set(directory, {
|
||||||
|
client:
|
||||||
|
directory === join(home, ".agents", "skills") ? "shared" : client,
|
||||||
|
directory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...destinations.values()].sort((left, right) =>
|
||||||
|
left.directory.localeCompare(right.directory),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import writeFileAtomic from "write-file-atomic";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
import { claudeCodeSkillsDirectory, skillDestinations } from "./SetupSkill.js";
|
import { skillDestinations } from "./SetupSkill.js";
|
||||||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||||
import {
|
import {
|
||||||
@@ -124,11 +124,12 @@ export const runUninstall = async (
|
|||||||
status: "retained",
|
status: "retained",
|
||||||
detail: "Hopper is not owned by REA uninstall.",
|
detail: "Hopper is not owned by REA uninstall.",
|
||||||
});
|
});
|
||||||
|
const reported = collapseUninstallItems(items);
|
||||||
return {
|
return {
|
||||||
status: items.some(({ status }) => status === "failed")
|
status: reported.some(({ status }) => status === "failed")
|
||||||
? "failed"
|
? "failed"
|
||||||
: "complete",
|
: "complete",
|
||||||
items,
|
items: reported,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,7 +172,16 @@ export const systemUninstallHost = (
|
|||||||
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
|
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
|
||||||
return {
|
return {
|
||||||
clients: () =>
|
clients: () =>
|
||||||
Promise.resolve(supportedClients(home, platform, environment)),
|
Promise.resolve(
|
||||||
|
supportedClients(home, platform, environment).flatMap((client) =>
|
||||||
|
client.configPaths === undefined
|
||||||
|
? [client]
|
||||||
|
: client.configPaths.map((configPath) => ({
|
||||||
|
...client,
|
||||||
|
configPath,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
inspectClient: async (client) => {
|
inspectClient: async (client) => {
|
||||||
const read = await readClientConfiguration(client, fileSystem);
|
const read = await readClientConfiguration(client, fileSystem);
|
||||||
return read.kind === "item" && read.item.status === "failed"
|
return read.kind === "item" && read.item.status === "failed"
|
||||||
@@ -179,7 +189,8 @@ export const systemUninstallHost = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
},
|
},
|
||||||
removeClient: (client) => removeClient(client, fileSystem),
|
removeClient: (client) => removeClient(client, fileSystem),
|
||||||
removeSkill: () => removeManagedSkills(home, fileSystem, environment),
|
removeSkill: () =>
|
||||||
|
removeManagedSkills(home, fileSystem, environment, platform),
|
||||||
purgeData: async () => [
|
purgeData: async () => [
|
||||||
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
|
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
|
||||||
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
|
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
|
||||||
@@ -200,6 +211,8 @@ const readClientConfiguration = async (
|
|||||||
client: SetupClient,
|
client: SetupClient,
|
||||||
fileSystem: UninstallFileSystem,
|
fileSystem: UninstallFileSystem,
|
||||||
): Promise<ClientConfigurationRead> => {
|
): Promise<ClientConfigurationRead> => {
|
||||||
|
if (client.configPathError !== undefined)
|
||||||
|
return itemRead(item(client.name, "failed", client.configPathError));
|
||||||
if (client.format === "unsupported")
|
if (client.format === "unsupported")
|
||||||
return itemRead(
|
return itemRead(
|
||||||
item(
|
item(
|
||||||
@@ -404,18 +417,16 @@ const removeManagedSkills = async (
|
|||||||
home: string,
|
home: string,
|
||||||
fileSystem: UninstallFileSystem,
|
fileSystem: UninstallFileSystem,
|
||||||
environment: Readonly<NodeJS.ProcessEnv>,
|
environment: Readonly<NodeJS.ProcessEnv>,
|
||||||
|
platform: NodeJS.Platform,
|
||||||
): Promise<UninstallItem> => {
|
): Promise<UninstallItem> => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
skillDestinations(
|
skillDestinations(home, undefined, environment, platform).map(
|
||||||
home,
|
({ client, path }) =>
|
||||||
undefined,
|
removeManagedPath(
|
||||||
claudeCodeSkillsDirectory(home, environment),
|
path,
|
||||||
).map(({ client, path }) =>
|
client === "claude_code" ? "Claude Code skill" : "skill",
|
||||||
removeManagedPath(
|
fileSystem,
|
||||||
path,
|
),
|
||||||
client === "claude_code" ? "Claude Code skill" : "skill",
|
|
||||||
fileSystem,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.find(({ status }) => status === "failed");
|
const failed = results.find(({ status }) => status === "failed");
|
||||||
@@ -451,6 +462,68 @@ const item = (
|
|||||||
status: UninstallItem["status"],
|
status: UninstallItem["status"],
|
||||||
detail: string,
|
detail: string,
|
||||||
): UninstallItem => ({ name, status, detail });
|
): 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
|
const registrationSchema = z
|
||||||
.object({ command: z.string(), args: z.array(z.string()) })
|
.object({ command: z.string(), args: z.array(z.string()) })
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { safeParseJson, type SafeJsonParseResult } from "../domain/safeJson.js";
|
|||||||
export const JSON_INPUT_RESOURCE_REMEDIATION =
|
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.";
|
"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. */
|
/** Decode JSON bytes without confusing a runtime string limit with malformed input. */
|
||||||
export const parseUtf8Json = (
|
export const parseUtf8Json = (
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
@@ -77,5 +84,7 @@ export const parseUtf8Json = (
|
|||||||
cause,
|
cause,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (text.startsWith("\uFEFF"))
|
||||||
|
return { ok: false, error: JSON_BYTE_ORDER_MARK_MESSAGE, cause: undefined };
|
||||||
return safeParseJson(text);
|
return safeParseJson(text);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { expect, it } from "vitest";
|
|||||||
|
|
||||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||||
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
|
import { createEvidence, parseEvidence } from "../../domain/evidence.js";
|
||||||
|
import { functionDossierSchema } from "../../domain/hopperValues.js";
|
||||||
|
import { ghidraFunctionDossier } from "../../domain/ghidraValues.fixture.js";
|
||||||
import {
|
import {
|
||||||
analysisViewJavaScriptAnalysisWithSource,
|
analysisViewJavaScriptAnalysisWithSource,
|
||||||
analysisViewJavaScriptEvidence,
|
analysisViewJavaScriptEvidence,
|
||||||
@@ -9,6 +11,96 @@ import {
|
|||||||
} from "../../../tests/fixtures/analysisView.js";
|
} from "../../../tests/fixtures/analysisView.js";
|
||||||
import { inspectAnalysisView } from "./AnalysisViewService.js";
|
import { inspectAnalysisView } from "./AnalysisViewService.js";
|
||||||
|
|
||||||
|
it.each(["/fixtures/unknown.exe", ""])(
|
||||||
|
"preserves recorded empty native identity fields: path=%j",
|
||||||
|
(path) => {
|
||||||
|
const original = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||||
|
const parent = createEvidence(
|
||||||
|
{ path, format: "pe", sha256: "c".repeat(64) },
|
||||||
|
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
|
||||||
|
{
|
||||||
|
operation: "analyze_function",
|
||||||
|
parameters: {},
|
||||||
|
result: {
|
||||||
|
...original,
|
||||||
|
procedure: {
|
||||||
|
...original.procedure,
|
||||||
|
address: "",
|
||||||
|
body: { available: false, reason: "Not recorded" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const result = inspectAnalysisView({
|
||||||
|
source: { kind: "inline", evidence: parent },
|
||||||
|
view: {
|
||||||
|
kind: "native",
|
||||||
|
facet: "procedure",
|
||||||
|
offset: 0,
|
||||||
|
limit: 64,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
expect(result.value.normalized_result).toMatchObject({
|
||||||
|
procedure_address: "",
|
||||||
|
artifact: { path },
|
||||||
|
item: { address: "" },
|
||||||
|
});
|
||||||
|
expect(result.value.locations).toEqual(
|
||||||
|
path.length > 0 ? [{ kind: "artifact-path", path }] : [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("projects a small native view from an authenticated >10 MiB retained dossier", () => {
|
||||||
|
const original = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||||
|
const parent = createEvidence(
|
||||||
|
{ path: "/fixtures/large.exe", format: "pe", sha256: "c".repeat(64) },
|
||||||
|
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
|
||||||
|
{
|
||||||
|
operation: "analyze_function",
|
||||||
|
parameters: { address: "0x401000" },
|
||||||
|
result: { ...original, pseudocode: "X".repeat(11 * 1024 * 1024) },
|
||||||
|
limitations: ["Synthetic fixture"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const input = {
|
||||||
|
source: {
|
||||||
|
kind: "retained-evidence" as const,
|
||||||
|
evidence_id: parent.evidence_id,
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
kind: "native" as const,
|
||||||
|
facet: "pseudocode" as const,
|
||||||
|
offset: 0,
|
||||||
|
limit: 128,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const view = inspectAnalysisView(input, (id) =>
|
||||||
|
id === parent.evidence_id ? parent : undefined,
|
||||||
|
);
|
||||||
|
if (!view.ok) throw view.error;
|
||||||
|
expect(view.value.evidence_links).toEqual([parent.evidence_id]);
|
||||||
|
expect(view.value.normalized_result).toMatchObject({
|
||||||
|
kind: "native",
|
||||||
|
parent_operation: "analyze_function",
|
||||||
|
parent_evidence_id: parent.evidence_id,
|
||||||
|
procedure_address: "0x401000",
|
||||||
|
artifact: { path: "/fixtures/large.exe", sha256: "c".repeat(64) },
|
||||||
|
coverage: { total: 11 * 1024 * 1024, examined: 128, next_offset: 128 },
|
||||||
|
});
|
||||||
|
expect(view.value.locations).toEqual([
|
||||||
|
{ kind: "artifact-path", path: "/fixtures/large.exe" },
|
||||||
|
{ kind: "address", address: "0x401000" },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(view.value).length).toBeLessThan(12000);
|
||||||
|
expect(JSON.stringify(parent).length).toBeGreaterThan(10 * 1024 * 1024);
|
||||||
|
expect(inspectAnalysisView(input, () => undefined)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "EvidenceIntegrityError" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("projects inline layout Evidence and retains a derived view record", () => {
|
it("projects inline layout Evidence and retains a derived view record", () => {
|
||||||
const parent = analysisViewLayoutEvidence();
|
const parent = analysisViewLayoutEvidence();
|
||||||
const result = inspectAnalysisView({
|
const result = inspectAnalysisView({
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export const inspectAnalysisViewValidated = (
|
|||||||
operation: parent.operation,
|
operation: parent.operation,
|
||||||
normalizedResult: parent.normalized_result,
|
normalizedResult: parent.normalized_result,
|
||||||
limitations: parent.limitations,
|
limitations: parent.limitations,
|
||||||
|
artifact:
|
||||||
|
parent.subject === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
path: parent.subject.local_path,
|
||||||
|
sha256: parent.subject.digest.sha256,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
input.view,
|
input.view,
|
||||||
);
|
);
|
||||||
@@ -126,7 +133,16 @@ const createAnalysisViewEvidence = (
|
|||||||
confidence: "derived",
|
confidence: "derived",
|
||||||
authority: parent.authority,
|
authority: parent.authority,
|
||||||
limitations: result.limitations,
|
limitations: result.limitations,
|
||||||
locations: [{ kind: "artifact-path", path: result.artifact.path }],
|
locations: [
|
||||||
|
...(result.artifact.path.length > 0
|
||||||
|
? [{ kind: "artifact-path" as const, path: result.artifact.path }]
|
||||||
|
: []),
|
||||||
|
...(result.kind === "native" &&
|
||||||
|
result.procedure_address !== null &&
|
||||||
|
result.procedure_address.length > 0
|
||||||
|
? [{ kind: "address" as const, address: result.procedure_address }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
evidenceLinks: [parent.evidence_id],
|
evidenceLinks: [parent.evidence_id],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
BINARY_LAYOUT_TEST_PROVIDER,
|
BINARY_LAYOUT_TEST_PROVIDER,
|
||||||
} from "../../../tests/fixtures/binaryDiagnostics/layout.js";
|
} from "../../../tests/fixtures/binaryDiagnostics/layout.js";
|
||||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||||
|
import { parseEvidence } from "../../domain/evidence.js";
|
||||||
import { err, ok } from "../../domain/result.js";
|
import { err, ok } from "../../domain/result.js";
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -28,6 +29,19 @@ it.each([
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("records the decoded layout once in its Evidence", async () => {
|
||||||
|
const layout = binaryLayoutFixture();
|
||||||
|
const service = new BinaryLayoutService({
|
||||||
|
identity: BINARY_LAYOUT_TEST_PROVIDER,
|
||||||
|
inspect: () => Promise.resolve(ok(layout)),
|
||||||
|
});
|
||||||
|
const result = await service.inspect({ path: layout.artifact.path });
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
const evidence = parseEvidence(result.value);
|
||||||
|
expect(evidence.normalized_result).toEqual(layout);
|
||||||
|
expect(evidence.raw_result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves a provider's actionable unsupported reason", async () => {
|
it("preserves a provider's actionable unsupported reason", async () => {
|
||||||
const failure = new AnalysisCapabilityUnavailableError(
|
const failure = new AnalysisCapabilityUnavailableError(
|
||||||
"fixture-layout",
|
"fixture-layout",
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ export class BinaryLayoutService {
|
|||||||
operation: OPERATION,
|
operation: OPERATION,
|
||||||
parameters: input.data,
|
parameters: input.data,
|
||||||
result: value,
|
result: value,
|
||||||
rawResult: value,
|
// The decoded report is the only representation; do not repeat it.
|
||||||
|
rawResult: null,
|
||||||
confidence: "observed",
|
confidence: "observed",
|
||||||
limitations: value.limitations,
|
limitations: value.limitations,
|
||||||
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
locations: [{ kind: "artifact-path", path: value.artifact.path }],
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ it("labels an envelope containing debugger-derived maps as derived", async () =>
|
|||||||
const evidence = parseEvidence(result.value);
|
const evidence = parseEvidence(result.value);
|
||||||
expect(evidence.confidence).toBe("derived");
|
expect(evidence.confidence).toBe("derived");
|
||||||
expect(evidence.normalized_result).toEqual(fixture);
|
expect(evidence.normalized_result).toEqual(fixture);
|
||||||
|
expect(evidence.raw_result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ export class RecordedCrashService {
|
|||||||
operation: OPERATION,
|
operation: OPERATION,
|
||||||
parameters: input.data,
|
parameters: input.data,
|
||||||
result: value,
|
result: value,
|
||||||
rawResult: value,
|
// The decoded report is the only representation; do not repeat it.
|
||||||
|
rawResult: null,
|
||||||
confidence:
|
confidence:
|
||||||
value.debugger.status === "available" ? "derived" : "observed",
|
value.debugger.status === "available" ? "derived" : "observed",
|
||||||
limitations: value.limitations,
|
limitations: value.limitations,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js";
|
||||||
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
|
||||||
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
@@ -162,16 +163,15 @@ const addEndpoint = (
|
|||||||
const source =
|
const source =
|
||||||
sourceNodeFor(context, input.file.path, input.value.module_key) ??
|
sourceNodeFor(context, input.file.path, input.value.module_key) ??
|
||||||
input.asset;
|
input.asset;
|
||||||
|
const key = compactStringIdentityKey(input.value.value);
|
||||||
|
const label = `${input.value.kind} endpoint`;
|
||||||
const node = context.accumulator.addNode({
|
const node = context.accumulator.addNode({
|
||||||
kind: "endpoint",
|
kind: "endpoint",
|
||||||
identity: artifactLocalIdentity(
|
identity: artifactLocalIdentity(input.file.sha256, input.value.kind, key),
|
||||||
input.file.sha256,
|
|
||||||
input.value.kind,
|
|
||||||
input.value.value,
|
|
||||||
),
|
|
||||||
observations: [
|
observations: [
|
||||||
{
|
{
|
||||||
label: input.value.value,
|
label:
|
||||||
|
input.value.value.length <= label.length ? input.value.value : label,
|
||||||
properties: {
|
properties: {
|
||||||
endpoint_kind: input.value.kind,
|
endpoint_kind: input.value.kind,
|
||||||
value: input.value.value,
|
value: input.value.value,
|
||||||
|
|||||||
@@ -67,6 +67,36 @@ describe("artifact-local graph identity", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("logical path identity", () => {
|
||||||
|
it("resolves an NFD relative import to the NFC inventory path and rejects a root escape", () => {
|
||||||
|
const nfc = "caf\u00e9".normalize("NFC");
|
||||||
|
const nfd = nfc.normalize("NFD");
|
||||||
|
const files = fileMap([
|
||||||
|
file("dir/index.js", "root"),
|
||||||
|
file(`dir/${nfc}.js`, "root"),
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
resolve({
|
||||||
|
declaredPath: `./${nfd}.js`,
|
||||||
|
sourcePath: "dir/index.js",
|
||||||
|
context: "module-specifier",
|
||||||
|
files,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
resolution_status: "resolved",
|
||||||
|
resolved_path: `dir/${nfc}.js`,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolve({
|
||||||
|
declaredPath: "../escape.js",
|
||||||
|
sourcePath: "index.js",
|
||||||
|
context: "module-specifier",
|
||||||
|
files,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ resolution_status: "rejected", resolved_path: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("contextual JavaScript artifact path resolution", () => {
|
describe("contextual JavaScript artifact path resolution", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["main.js", "app/main.js"],
|
["main.js", "app/main.js"],
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { isBuiltin } from "node:module";
|
import { isBuiltin } from "node:module";
|
||||||
import { posix } from "node:path";
|
import { posix } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeJoinedLogicalPath,
|
||||||
|
logicalPathEscapesRoot,
|
||||||
|
} from "../../domain/artifactIdentity.js";
|
||||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||||
import {
|
import {
|
||||||
admitsCanonicalPathSyntax,
|
admitsCanonicalPathSyntax,
|
||||||
@@ -326,12 +330,8 @@ const confineCandidate = (
|
|||||||
input: ResolveArtifactPathInput,
|
input: ResolveArtifactPathInput,
|
||||||
candidate: string,
|
candidate: string,
|
||||||
): string | ArtifactPathResolution => {
|
): string | ArtifactPathResolution => {
|
||||||
const normalized = posix.normalize(candidate);
|
const normalized = normalizeJoinedLogicalPath(candidate);
|
||||||
if (
|
if (logicalPathEscapesRoot(normalized))
|
||||||
normalized === ".." ||
|
|
||||||
normalized.startsWith("../") ||
|
|
||||||
normalized.startsWith("/")
|
|
||||||
)
|
|
||||||
return unresolvedOutcome(input, "rejected", [
|
return unresolvedOutcome(input, "rejected", [
|
||||||
"The resolved candidate escapes the canonical artifact root.",
|
"The resolved candidate escapes the canonical artifact root.",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js";
|
||||||
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js";
|
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js";
|
||||||
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -21,7 +22,7 @@ export const projectSemanticEvents = (
|
|||||||
const eventKey =
|
const eventKey =
|
||||||
operation.eventName === null
|
operation.eventName === null
|
||||||
? operation.eventId
|
? operation.eventId
|
||||||
: `${operation.emitterKey}\u0000${operation.eventName}`;
|
: `${operation.emitterKey}\u0000${compactStringIdentityKey(operation.eventName)}`;
|
||||||
const event =
|
const event =
|
||||||
eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey);
|
eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey);
|
||||||
if (event === null) continue;
|
if (event === null) continue;
|
||||||
@@ -59,7 +60,11 @@ const addEventNode = (
|
|||||||
kind: "event",
|
kind: "event",
|
||||||
roleKey: `event:${eventKey}`,
|
roleKey: `event:${eventKey}`,
|
||||||
location: operation.eventName === null ? operation.location : null,
|
location: operation.eventName === null ? operation.location : null,
|
||||||
label: operation.eventName,
|
label:
|
||||||
|
operation.eventName === null ||
|
||||||
|
operation.eventName.length <= "event".length
|
||||||
|
? operation.eventName
|
||||||
|
: "event",
|
||||||
functionNodeId: null,
|
functionNodeId: null,
|
||||||
properties: {
|
properties: {
|
||||||
emitter_key: operation.emitterKey,
|
emitter_key: operation.emitterKey,
|
||||||
@@ -78,7 +83,9 @@ const addListenerNode = (
|
|||||||
label:
|
label:
|
||||||
operation.eventName === null
|
operation.eventName === null
|
||||||
? operation.method
|
? operation.method
|
||||||
: `${operation.method}:${operation.eventName}`,
|
: operation.eventName.length <= "listener".length
|
||||||
|
? `${operation.method}:${operation.eventName}`
|
||||||
|
: `${operation.method}:listener`,
|
||||||
functionNodeId:
|
functionNodeId:
|
||||||
operation.ownerCallableId === null
|
operation.ownerCallableId === null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -175,7 +175,21 @@ export const createJavaScriptSemanticGraphProjection =
|
|||||||
): Value => {
|
): Value => {
|
||||||
bindSemanticGraphApplicationNodes(state, applicationGraph);
|
bindSemanticGraphApplicationNodes(state, applicationGraph);
|
||||||
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
|
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
|
||||||
|
const relations = [...state.relations.values()];
|
||||||
const unknowns = [...state.unknowns.values()];
|
const unknowns = [...state.unknowns.values()];
|
||||||
|
type Family = (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number];
|
||||||
|
const retainedByFamily = new Map<Family, number>();
|
||||||
|
const unknownIdsByFamily = new Map<Family, string[]>();
|
||||||
|
for (const relation of relations) {
|
||||||
|
const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation];
|
||||||
|
retainedByFamily.set(family, (retainedByFamily.get(family) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const unknown of unknowns) {
|
||||||
|
const identifiers = unknownIdsByFamily.get(unknown.family);
|
||||||
|
if (identifiers === undefined)
|
||||||
|
unknownIdsByFamily.set(unknown.family, [unknown.unknown_id]);
|
||||||
|
else identifiers.push(unknown.unknown_id);
|
||||||
|
}
|
||||||
const graph = factory({
|
const graph = factory({
|
||||||
schema: "JavaScriptSemanticRelationGraph",
|
schema: "JavaScriptSemanticRelationGraph",
|
||||||
root_artifact_sha256: rootArtifactSha256,
|
root_artifact_sha256: rootArtifactSha256,
|
||||||
@@ -183,7 +197,7 @@ export const createJavaScriptSemanticGraphProjection =
|
|||||||
root_node_ids: [...state.roots],
|
root_node_ids: [...state.roots],
|
||||||
evidence_contexts: state.evidenceContexts.contexts,
|
evidence_contexts: state.evidenceContexts.contexts,
|
||||||
nodes: [...state.nodes.values()],
|
nodes: [...state.nodes.values()],
|
||||||
relations: [...state.relations.values()],
|
relations,
|
||||||
fingerprints,
|
fingerprints,
|
||||||
unknowns,
|
unknowns,
|
||||||
coverage: {
|
coverage: {
|
||||||
@@ -204,15 +218,9 @@ export const createJavaScriptSemanticGraphProjection =
|
|||||||
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
|
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
|
||||||
family,
|
family,
|
||||||
status: semanticFamilyStatus(family),
|
status: semanticFamilyStatus(family),
|
||||||
retained_relations: [...state.relations.values()].filter(
|
retained_relations: retainedByFamily.get(family) ?? 0,
|
||||||
(relation) =>
|
|
||||||
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation] ===
|
|
||||||
family,
|
|
||||||
).length,
|
|
||||||
omitted_relations: truncatedFiles > 0 ? null : 0,
|
omitted_relations: truncatedFiles > 0 ? null : 0,
|
||||||
unknown_ids: unknowns
|
unknown_ids: unknownIdsByFamily.get(family) ?? [],
|
||||||
.filter((unknown) => unknown.family === family)
|
|
||||||
.map(({ unknown_id: identifier }) => identifier),
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
limitations: [
|
limitations: [
|
||||||
|
|||||||
@@ -137,7 +137,11 @@ const addRequestNode = (
|
|||||||
kind: operation.kind === "request" ? "request" : "response",
|
kind: operation.kind === "request" ? "request" : "response",
|
||||||
roleKey: operation.requestId,
|
roleKey: operation.requestId,
|
||||||
location: operation.location,
|
location: operation.location,
|
||||||
label: operation.endpoint ?? operation.method,
|
label:
|
||||||
|
operation.endpoint !== null &&
|
||||||
|
operation.endpoint.length <= operation.method.length
|
||||||
|
? operation.endpoint
|
||||||
|
: operation.method,
|
||||||
functionNodeId:
|
functionNodeId:
|
||||||
operation.ownerCallableId === null
|
operation.ownerCallableId === null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
semanticContainer,
|
semanticContainer,
|
||||||
semanticPropertyPointer,
|
semanticPropertyPointer,
|
||||||
} from "../../domain/javascript/javascriptSemanticSlots.js";
|
} from "../../domain/javascript/javascriptSemanticSlots.js";
|
||||||
|
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
|
||||||
import type { JavaScriptSemanticBinding } from "../../domain/javascript/javascriptSemanticIr.js";
|
import type { JavaScriptSemanticBinding } from "../../domain/javascript/javascriptSemanticIr.js";
|
||||||
import type {
|
import type {
|
||||||
JavaScriptSemanticProperty,
|
JavaScriptSemanticProperty,
|
||||||
@@ -153,16 +154,27 @@ const addLiteralNode = (
|
|||||||
binding: JavaScriptSemanticBinding,
|
binding: JavaScriptSemanticBinding,
|
||||||
value: string | number | boolean | null,
|
value: string | number | boolean | null,
|
||||||
role: string,
|
role: string,
|
||||||
) =>
|
) => {
|
||||||
retainSemanticGraphNode(context.state, context.file, {
|
const encodedValue = JSON.stringify(value);
|
||||||
|
const valueDigest = `value-sha256:${digestCanonicalValue(value, "Semantic literal")}`;
|
||||||
|
const valueKey =
|
||||||
|
encodedValue.length <= valueDigest.length ? encodedValue : valueDigest;
|
||||||
|
const stringLabel = "string literal";
|
||||||
|
return retainSemanticGraphNode(context.state, context.file, {
|
||||||
kind: "literal",
|
kind: "literal",
|
||||||
roleKey: `literal:${binding.bindingId}:${role}:${JSON.stringify(value)}`,
|
// Identity needs an exact value commitment, not another payload copy.
|
||||||
|
// Keep the complete literal in properties for queries and Evidence export.
|
||||||
|
roleKey: `literal:${binding.bindingId}:${role}:${valueKey}`,
|
||||||
location: binding.definitions[0]?.location ?? null,
|
location: binding.definitions[0]?.location ?? null,
|
||||||
label: JSON.stringify(value),
|
label:
|
||||||
|
typeof value === "string" && encodedValue.length > stringLabel.length
|
||||||
|
? stringLabel
|
||||||
|
: encodedValue,
|
||||||
functionNodeId:
|
functionNodeId:
|
||||||
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
|
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
|
||||||
properties: { value },
|
properties: { value },
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/** Create one canonical slot for a root binding and exact static property path. */
|
/** Create one canonical slot for a root binding and exact static property path. */
|
||||||
export const semanticPropertySlot = (
|
export const semanticPropertySlot = (
|
||||||
|
|||||||
@@ -73,15 +73,27 @@ export const traceNativeUiAction = async (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
graph = joinInterfaceBuilderDispatch(decoded.data.graph, metadata.result);
|
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({
|
coverage.push({
|
||||||
facet: "interface_builder_archives",
|
facet: "interface_builder_archives",
|
||||||
status: decoded.data.documents.length > 0 ? "complete" : "unsupported",
|
status:
|
||||||
|
archiveDecode?.status === "partial" ||
|
||||||
|
archiveDecode?.status === "complete" ||
|
||||||
|
archiveDecode?.status === "unsupported"
|
||||||
|
? archiveDecode.status
|
||||||
|
: documentsFound
|
||||||
|
? "complete"
|
||||||
|
: "unsupported",
|
||||||
reason:
|
reason:
|
||||||
decoded.data.documents.length > 0
|
archiveDecode?.reason ??
|
||||||
|
(documentsFound
|
||||||
? null
|
? null
|
||||||
: "no_compiled_interface_builder_archives_found",
|
: "no_compiled_interface_builder_archives_found"),
|
||||||
examined: decoded.data.documents.length,
|
examined: archiveDecode?.examined ?? decoded.data.documents.length,
|
||||||
omitted: 0,
|
omitted: archiveDecode?.omitted ?? 0,
|
||||||
});
|
});
|
||||||
const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges);
|
const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges);
|
||||||
graph = bounded.graph;
|
graph = bounded.graph;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
captureProcessScenarioFile,
|
captureProcessScenarioFile,
|
||||||
projectProcessCliError,
|
projectProcessCliError,
|
||||||
} from "./ProcessCli.js";
|
} from "./ProcessCli.js";
|
||||||
|
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -87,4 +88,14 @@ describe("process CLI input diagnostics", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names a leading byte-order mark instead of quoting it as a token", async () => {
|
||||||
|
const path = join(await fixture(), "scenario.json");
|
||||||
|
await writeFile(path, '\uFEFF{"executable":"/bin/echo"}');
|
||||||
|
expect(await captureProcessScenarioFile(path)).toEqual({
|
||||||
|
error: "Process command failed",
|
||||||
|
category: "invalid_input",
|
||||||
|
message: `Process input file is not valid UTF-8 JSON: ${path} (${JSON_BYTE_ORDER_MARK_MESSAGE}). Repair the file, then try again.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { processTraceSpecificationSchema } from "../../domain/process/processTra
|
|||||||
import { processScenarioSchema } from "../../domain/process/processScenario.js";
|
import { processScenarioSchema } from "../../domain/process/processScenario.js";
|
||||||
import { compareProcessCaptures } from "../../domain/process/processComparison.js";
|
import { compareProcessCaptures } from "../../domain/process/processComparison.js";
|
||||||
import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js";
|
import { parseProcessCapture } from "../../domain/process/processCaptureParsing.js";
|
||||||
|
import { createProgressReporter } from "../ProgressReporter.js";
|
||||||
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
|
import { captureProcessScenario } from "../../process/capture/ProcessHarness.js";
|
||||||
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
|
import { PROCESS_PROVIDER } from "../../domain/process/processEvidenceProvider.js";
|
||||||
import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
|
import { createProcessCaptureEvidence } from "./ProcessEvidence.js";
|
||||||
|
import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../Utf8JsonInput.js";
|
||||||
|
|
||||||
/** Safe process-command failure returned to the CLI adapter. */
|
/** Safe process-command failure returned to the CLI adapter. */
|
||||||
export interface ProcessCliErrorOutput {
|
export interface ProcessCliErrorOutput {
|
||||||
@@ -55,11 +57,20 @@ export const captureProcessScenarioFile = async (
|
|||||||
input,
|
input,
|
||||||
{ cause: parsed.error },
|
{ cause: parsed.error },
|
||||||
);
|
);
|
||||||
|
const progress = createProgressReporter(
|
||||||
|
async (update) => {
|
||||||
|
process.stderr.write(`${JSON.stringify({ rea_progress: update })}\n`);
|
||||||
|
},
|
||||||
|
{ minimumIntervalMs: 100 },
|
||||||
|
);
|
||||||
const captured = await captureProcessScenario(
|
const captured = await captureProcessScenario(
|
||||||
parsed.data,
|
parsed.data,
|
||||||
signal,
|
signal,
|
||||||
process.platform,
|
process.platform,
|
||||||
environment,
|
environment,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
progress,
|
||||||
);
|
);
|
||||||
if (!captured.ok) return cliAnalysisError(captured.error);
|
if (!captured.ok) return cliAnalysisError(captured.error);
|
||||||
return createProcessCaptureEvidence(parsed.data, captured.value);
|
return createProcessCaptureEvidence(parsed.data, captured.value);
|
||||||
@@ -181,6 +192,8 @@ const readJson = async (path: string): Promise<unknown> => {
|
|||||||
fatal: true,
|
fatal: true,
|
||||||
ignoreBOM: true,
|
ignoreBOM: true,
|
||||||
}).decode(bytes);
|
}).decode(bytes);
|
||||||
|
if (text.startsWith("\uFEFF"))
|
||||||
|
throw new Error(JSON_BYTE_ORDER_MARK_MESSAGE);
|
||||||
const parsed: unknown = JSON.parse(text);
|
const parsed: unknown = JSON.parse(text);
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
annotateEnglishUnicodeCaseCollisions,
|
||||||
|
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
|
||||||
|
} from "./ArtifactPaths.js";
|
||||||
|
|
||||||
|
const NOTE =
|
||||||
|
"a case-insensitive destination cannot store both. This comparison is not a filesystem observation.";
|
||||||
|
|
||||||
|
const annotate = (paths: readonly string[]) => {
|
||||||
|
const occurrences = paths.map((logical_path) => ({
|
||||||
|
logical_path,
|
||||||
|
limitations: [] as string[],
|
||||||
|
}));
|
||||||
|
const limitation = annotateEnglishUnicodeCaseCollisions(occurrences);
|
||||||
|
return {
|
||||||
|
limitation,
|
||||||
|
byPath: new Map(
|
||||||
|
occurrences.map(({ logical_path, limitations }) => [
|
||||||
|
logical_path,
|
||||||
|
limitations,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("English Unicode case collisions", () => {
|
||||||
|
it("names the other spelling of a colliding file", () => {
|
||||||
|
const { limitation, byPath } = annotate([".", "Main.js", "main.js", "x"]);
|
||||||
|
expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION);
|
||||||
|
expect(byPath.get("Main.js")).toEqual([
|
||||||
|
`Logical path Main.js collides under English (en-US) Unicode case folding with main.js; ${NOTE}`,
|
||||||
|
]);
|
||||||
|
expect(byPath.get("main.js")).toEqual([
|
||||||
|
`Logical path main.js collides under English (en-US) Unicode case folding with Main.js; ${NOTE}`,
|
||||||
|
]);
|
||||||
|
expect(byPath.get("x")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a directory collision once at the diverging segment", () => {
|
||||||
|
const { byPath } = annotate([
|
||||||
|
"res/Foo",
|
||||||
|
"res/Foo/a.txt",
|
||||||
|
"res/foo",
|
||||||
|
"res/foo/b.txt",
|
||||||
|
]);
|
||||||
|
expect(byPath.get("res/Foo")).toEqual([
|
||||||
|
`Logical path res/Foo collides under English (en-US) Unicode case folding with res/foo; ${NOTE}`,
|
||||||
|
]);
|
||||||
|
expect(byPath.get("res/foo/b.txt")).toEqual([
|
||||||
|
`Logical path res/foo/b.txt is under res/foo, which collides under English (en-US) Unicode case folding with res/Foo; ${NOTE}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts further spellings, each of which is its own occurrence", () => {
|
||||||
|
const { byPath } = annotate(["A.txt", "a.txt", "a.TXT"]);
|
||||||
|
expect(byPath.get("a.txt")).toEqual([
|
||||||
|
`Logical path a.txt collides under English (en-US) Unicode case folding with A.txt and 1 other spelling; ${NOTE}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays linear for large case-variant subtrees", () => {
|
||||||
|
const count = 5_000;
|
||||||
|
const paths = Array.from({ length: count }, (_, index) => [
|
||||||
|
`Foo/a${String(index)}.txt`,
|
||||||
|
`foo/b${String(index)}.txt`,
|
||||||
|
]).flat();
|
||||||
|
const { byPath } = annotate(paths);
|
||||||
|
for (const path of paths) {
|
||||||
|
const limitations = byPath.get(path) ?? [];
|
||||||
|
expect(limitations).toHaveLength(1);
|
||||||
|
expect(limitations[0]?.length).toBeLessThan(300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks names with more components than the call stack holds", () => {
|
||||||
|
const deep = Array.from({ length: 30_000 }, () => "a").join("/");
|
||||||
|
const { byPath } = annotate([deep, `${deep}/X`, `${deep}/x`]);
|
||||||
|
expect(byPath.get(`${deep}/X`)).toHaveLength(1);
|
||||||
|
expect(byPath.get(deep)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+184
-12
@@ -1,9 +1,25 @@
|
|||||||
import { posix } from "node:path";
|
import { posix } from "node:path";
|
||||||
|
|
||||||
|
import { logicalPathNfc } from "../domain/artifactIdentity.js";
|
||||||
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||||
|
|
||||||
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
|
const DRIVE_OR_UNC = /^(?:[A-Za-z]:|\\|\/\/)/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* English Unicode case folding used only to describe logical names a
|
||||||
|
* case-insensitive destination might collapse. It is not a filesystem case table.
|
||||||
|
*/
|
||||||
|
const ENGLISH_UNICODE_CASE_NOTE =
|
||||||
|
"This comparison is not a filesystem observation.";
|
||||||
|
|
||||||
|
/** Inventory limitation when logical names collide under English Unicode case folding. */
|
||||||
|
export const ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION = `Logical archive names collide under English (en-US) Unicode case folding; a case-insensitive destination cannot store both spellings. ${ENGLISH_UNICODE_CASE_NOTE}`;
|
||||||
|
|
||||||
|
/** User-facing prefix for a destination that cannot keep two logical spellings. */
|
||||||
|
export const DESTINATION_CASE_COLLISION_PREFIX =
|
||||||
|
"Destination filesystem cannot store both ";
|
||||||
|
|
||||||
/** Normalize an untrusted logical archive path without touching filesystem. */
|
/** Normalize an untrusted logical archive path without touching filesystem. */
|
||||||
export const normalizeArtifactPath = (input: string): string => {
|
export const normalizeArtifactPath = (input: string): string => {
|
||||||
if (
|
if (
|
||||||
@@ -16,7 +32,7 @@ export const normalizeArtifactPath = (input: string): string => {
|
|||||||
"path",
|
"path",
|
||||||
`Artifact path is absolute or unsafe: ${JSON.stringify(input)}`,
|
`Artifact path is absolute or unsafe: ${JSON.stringify(input)}`,
|
||||||
);
|
);
|
||||||
const normalized = input.normalize("NFC").replace(/\/+$/u, "");
|
const normalized = logicalPathNfc(input).replace(/\/+$/u, "");
|
||||||
const parts = normalized.split("/");
|
const parts = normalized.split("/");
|
||||||
if (
|
if (
|
||||||
normalized.length === 0 ||
|
normalized.length === 0 ||
|
||||||
@@ -30,7 +46,7 @@ export const normalizeArtifactPath = (input: string): string => {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Reject exact, Unicode-normalized, case-folded, and prefix collisions. */
|
/** Reject exact and same-spelling prefix collisions. Case-distinct names stay distinct. */
|
||||||
export class ArtifactPathRegistry {
|
export class ArtifactPathRegistry {
|
||||||
readonly #root: PathTrieNode = { kind: undefined, children: new Map() };
|
readonly #root: PathTrieNode = { kind: undefined, children: new Map() };
|
||||||
|
|
||||||
@@ -43,16 +59,10 @@ export class ArtifactPathRegistry {
|
|||||||
"path",
|
"path",
|
||||||
`Artifact prefix conflict: ${path}`,
|
`Artifact prefix conflict: ${path}`,
|
||||||
);
|
);
|
||||||
const foldedPart = part.toLocaleLowerCase("en-US");
|
let child = node.children.get(part);
|
||||||
let child = node.children.get(foldedPart);
|
|
||||||
if (child !== undefined && child.spelling !== part)
|
|
||||||
throw new ArtifactReaderFailure(
|
|
||||||
"path",
|
|
||||||
`Artifact path collision: ${path} differs only in case from ${[...parts.slice(0, index), child.spelling].join("/")}`,
|
|
||||||
);
|
|
||||||
if (child === undefined) {
|
if (child === undefined) {
|
||||||
child = { spelling: part, kind: undefined, children: new Map() };
|
child = { kind: undefined, children: new Map() };
|
||||||
node.children.set(foldedPart, child);
|
node.children.set(part, child);
|
||||||
}
|
}
|
||||||
node = child;
|
node = child;
|
||||||
if (index === parts.length - 1) {
|
if (index === parts.length - 1) {
|
||||||
@@ -73,7 +83,169 @@ export class ArtifactPathRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PathTrieNode {
|
interface PathTrieNode {
|
||||||
readonly spelling?: string;
|
|
||||||
kind: "file" | "directory" | "symlink" | "slice" | undefined;
|
kind: "file" | "directory" | "symlink" | "slice" | undefined;
|
||||||
readonly children: Map<string, PathTrieNode>;
|
readonly children: Map<string, PathTrieNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CaseNode {
|
||||||
|
readonly bySpelling: Map<string, CaseNode>;
|
||||||
|
readonly spellingsByFold: Map<string, string[]>;
|
||||||
|
readonly terminals: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCaseNode = (): CaseNode => ({
|
||||||
|
bySpelling: new Map(),
|
||||||
|
spellingsByFold: new Map(),
|
||||||
|
terminals: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const englishCaseFold = (part: string): string =>
|
||||||
|
part.toLocaleLowerCase("en-US");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record English (en-US) Unicode case collisions on each affected occurrence.
|
||||||
|
* Returns the inventory limitation when any logical names collide.
|
||||||
|
*/
|
||||||
|
export const annotateEnglishUnicodeCaseCollisions = (
|
||||||
|
occurrences: readonly {
|
||||||
|
readonly logical_path: string;
|
||||||
|
limitations: string[];
|
||||||
|
}[],
|
||||||
|
): string | undefined => {
|
||||||
|
const collisions = englishUnicodeCaseCollisions(
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export const destinationCaseCollisionMessage = (
|
||||||
|
requestedPath: string,
|
||||||
|
existingNames: readonly string[],
|
||||||
|
): string | undefined => {
|
||||||
|
const slash = requestedPath.lastIndexOf("/");
|
||||||
|
const segment = slash < 0 ? requestedPath : requestedPath.slice(slash + 1);
|
||||||
|
if (existingNames.includes(segment)) return undefined;
|
||||||
|
const folded = englishCaseFold(segment);
|
||||||
|
const existing = existingNames
|
||||||
|
.filter((name) => englishCaseFold(name) === folded)
|
||||||
|
.sort(compareUnicodeCodePoints)[0];
|
||||||
|
if (existing === undefined) return undefined;
|
||||||
|
const parent = slash < 0 ? "" : requestedPath.slice(0, slash + 1);
|
||||||
|
return `${DESTINATION_CASE_COLLISION_PREFIX}${requestedPath} and ${parent}${existing}; inventory retains both logical names.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const englishUnicodeCaseCollisions = (
|
||||||
|
paths: readonly string[],
|
||||||
|
): ReadonlyMap<string, readonly CaseCollision[]> => {
|
||||||
|
const root = emptyCaseNode();
|
||||||
|
for (const path of paths) {
|
||||||
|
if (path.length === 0 || path === ".") continue;
|
||||||
|
let node = root;
|
||||||
|
for (const part of path.split("/")) {
|
||||||
|
const folded = englishCaseFold(part);
|
||||||
|
const spellings = node.spellingsByFold.get(folded);
|
||||||
|
if (spellings === undefined) node.spellingsByFold.set(folded, [part]);
|
||||||
|
else if (!spellings.includes(part)) spellings.push(part);
|
||||||
|
let child = node.bySpelling.get(part);
|
||||||
|
if (child === undefined) {
|
||||||
|
child = emptyCaseNode();
|
||||||
|
node.bySpelling.set(part, child);
|
||||||
|
}
|
||||||
|
node = child;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collisions on one path, linked from the deepest segment to the root. */
|
||||||
|
interface CollisionChain {
|
||||||
|
readonly collision: CaseCollision;
|
||||||
|
readonly parent: CollisionChain | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collisionsAlong = (chain: CollisionChain): CaseCollision[] => {
|
||||||
|
const collisions: CaseCollision[] = [];
|
||||||
|
let link: CollisionChain | undefined = chain;
|
||||||
|
while (link !== undefined) {
|
||||||
|
collisions.push(link.collision);
|
||||||
|
link = link.parent;
|
||||||
|
}
|
||||||
|
return collisions.reverse();
|
||||||
|
};
|
||||||
|
|||||||
@@ -194,10 +194,8 @@ class ArtifactClient implements AnalysisClient {
|
|||||||
this.target.sourcePath ?? this.target.path,
|
this.target.sourcePath ?? this.target.path,
|
||||||
result.manifest,
|
result.manifest,
|
||||||
),
|
),
|
||||||
locations: result.artifacts.map(({ relative_path: path }) => ({
|
// Each extracted file's path is already in artifacts.
|
||||||
kind: "artifact-path" as const,
|
locations: [],
|
||||||
path,
|
|
||||||
})),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,10 +209,8 @@ class ArtifactClient implements AnalysisClient {
|
|||||||
this.target.sourcePath ?? this.target.path,
|
this.target.sourcePath ?? this.target.path,
|
||||||
result.manifest,
|
result.manifest,
|
||||||
),
|
),
|
||||||
locations: result.occurrences.map(({ logical_path: path }) => ({
|
// Each occurrence's logical path is already in the result.
|
||||||
kind: "artifact-path" as const,
|
locations: [],
|
||||||
path,
|
|
||||||
})),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
@@ -323,17 +319,14 @@ class ArtifactClient implements AnalysisClient {
|
|||||||
this.target.sourcePath ?? this.target.path,
|
this.target.sourcePath ?? this.target.path,
|
||||||
inventory.manifest,
|
inventory.manifest,
|
||||||
);
|
);
|
||||||
const locations = inventory.occurrences.map(({ logical_path: path }) => ({
|
|
||||||
kind: "artifact-path" as const,
|
|
||||||
path,
|
|
||||||
}));
|
|
||||||
const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, {
|
const inventoryEvidence = createEvidence(subject, ARTIFACT_GRAPH_PROVIDER, {
|
||||||
operation: "inventory_artifact",
|
operation: "inventory_artifact",
|
||||||
parameters: inventoryParameters,
|
parameters: inventoryParameters,
|
||||||
result: inventory,
|
result: inventory,
|
||||||
rawResult: null,
|
rawResult: null,
|
||||||
limitations: inventory.limitations,
|
limitations: inventory.limitations,
|
||||||
locations,
|
// The nested inventory already names every occurrence's logical path.
|
||||||
|
locations: [],
|
||||||
});
|
});
|
||||||
const result = createArtifactInspection(inventoryEvidence);
|
const result = createArtifactInspection(inventoryEvidence);
|
||||||
await options?.progress?.report({
|
await options?.progress?.report({
|
||||||
@@ -347,7 +340,7 @@ class ArtifactClient implements AnalysisClient {
|
|||||||
rawResult: null,
|
rawResult: null,
|
||||||
limitations: result.limitations,
|
limitations: result.limitations,
|
||||||
subject,
|
subject,
|
||||||
locations,
|
locations: [],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
lstat,
|
lstat,
|
||||||
mkdir,
|
mkdir,
|
||||||
open,
|
open,
|
||||||
|
readdir,
|
||||||
realpath,
|
realpath,
|
||||||
rm,
|
rm,
|
||||||
type FileHandle,
|
type FileHandle,
|
||||||
@@ -14,6 +15,7 @@ import { streamChunkToBuffer } from "./StreamBytes.js";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArtifactPathRegistry,
|
ArtifactPathRegistry,
|
||||||
|
destinationCaseCollisionMessage,
|
||||||
normalizeArtifactPath,
|
normalizeArtifactPath,
|
||||||
} from "./ArtifactPaths.js";
|
} from "./ArtifactPaths.js";
|
||||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||||
@@ -134,7 +136,8 @@ export class SafeOutputTree {
|
|||||||
constants.O_WRONLY |
|
constants.O_WRONLY |
|
||||||
constants.O_NOFOLLOW,
|
constants.O_NOFOLLOW,
|
||||||
0o600,
|
0o600,
|
||||||
).catch((cause: unknown) => {
|
).catch(async (cause: unknown) => {
|
||||||
|
await throwIfDestinationCaseCollision(dirname(destination), path, cause);
|
||||||
throw new ArtifactReaderFailure(
|
throw new ArtifactReaderFailure(
|
||||||
"path",
|
"path",
|
||||||
`Could not exclusively create extraction path: ${path}`,
|
`Could not exclusively create extraction path: ${path}`,
|
||||||
@@ -245,17 +248,28 @@ export class SafeOutputTree {
|
|||||||
if (fileName === undefined)
|
if (fileName === undefined)
|
||||||
throw new ArtifactReaderFailure("path", "Invalid extraction path");
|
throw new ArtifactReaderFailure("path", "Invalid extraction path");
|
||||||
let current = this.#outputRoot;
|
let current = this.#outputRoot;
|
||||||
|
let logicalParent = "";
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
|
const parentDirectory = current;
|
||||||
|
const logicalPath =
|
||||||
|
logicalParent.length === 0 ? part : `${logicalParent}/${part}`;
|
||||||
current = join(current, part);
|
current = join(current, part);
|
||||||
|
let created = true;
|
||||||
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
|
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
|
||||||
if (!isAlreadyExists(cause)) throw cause;
|
if (!isAlreadyExists(cause)) throw cause;
|
||||||
|
created = false;
|
||||||
});
|
});
|
||||||
|
if (!created)
|
||||||
|
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
|
||||||
const metadata = await lstat(current);
|
const metadata = await lstat(current);
|
||||||
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||||||
|
await throwIfDestinationCaseCollision(parentDirectory, logicalPath);
|
||||||
throw new ArtifactReaderFailure(
|
throw new ArtifactReaderFailure(
|
||||||
"path",
|
"path",
|
||||||
`Unsafe extraction parent: ${relativePath}`,
|
`Unsafe extraction parent: ${relativePath}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
logicalParent = logicalPath;
|
||||||
}
|
}
|
||||||
return join(current, fileName);
|
return join(current, fileName);
|
||||||
}
|
}
|
||||||
@@ -330,6 +344,23 @@ const isNotFound = (cause: unknown): boolean =>
|
|||||||
const isAlreadyExists = (cause: unknown): boolean =>
|
const isAlreadyExists = (cause: unknown): boolean =>
|
||||||
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||||
|
|
||||||
|
/** Fail when this directory already holds another spelling of the requested segment. */
|
||||||
|
const throwIfDestinationCaseCollision = async (
|
||||||
|
parentDirectory: string,
|
||||||
|
logicalPath: string,
|
||||||
|
cause?: unknown,
|
||||||
|
): Promise<void> => {
|
||||||
|
const names = await readdir(parentDirectory).catch(() => undefined);
|
||||||
|
if (names === undefined) return;
|
||||||
|
const message = destinationCaseCollisionMessage(logicalPath, names);
|
||||||
|
if (message === undefined) return;
|
||||||
|
throw new ArtifactReaderFailure(
|
||||||
|
"path",
|
||||||
|
message,
|
||||||
|
cause === undefined ? undefined : { cause },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const abortIfNeeded = (signal?: AbortSignal): void => {
|
const abortIfNeeded = (signal?: AbortSignal): void => {
|
||||||
if (signal?.aborted === true)
|
if (signal?.aborted === true)
|
||||||
throw new ArtifactReaderFailure(
|
throw new ArtifactReaderFailure(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user