mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
feat(javascript): recover readable modules with Wakaru (#848)
* feat(javascript): recover readable modules with Wakaru Integrate pinned upstream source behind a typed optional recovery port, preserve source identity and producer provenance, and publish verified artifacts for existing JavaScript analysis through CLI and MCP. Add focused real-tool CI with genuine bundler fixtures and bounded resource, cancellation, cleanup, schema and packaged-artifact checks. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(javascript): align recovery catalog and browser verification Share canonical Wakaru provider identity, describe CLI mode options, and format the new workflow test. Disconnect the real page transport after its observation readiness progress instead of a fixed delay. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * refactor(javascript): consume the investigation evidence writer Verify that the SDK recovery handler records complete Evidence through the newly shared investigation owner. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) * chore(test): account for isolated recovery fixture compilers * fix(javascript): validate recovery paths before acquiring the engine Return field-specific invalid_format diagnostics for relative paths and verify compiled CLI/MCP parity even when the optional engine is unavailable. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
@@ -7,6 +7,7 @@ on:
|
|||||||
- "scripts/verify-real-browser.mjs"
|
- "scripts/verify-real-browser.mjs"
|
||||||
- "scripts/verify-browser-network.mjs"
|
- "scripts/verify-browser-network.mjs"
|
||||||
- "scripts/verify-browser-scripts.mjs"
|
- "scripts/verify-browser-scripts.mjs"
|
||||||
|
- "scripts/verify-browser-dom-destinations.mjs"
|
||||||
- "scripts/lib/browser-*.mjs"
|
- "scripts/lib/browser-*.mjs"
|
||||||
- "scripts/fixtures/browser-network-site.mjs"
|
- "scripts/fixtures/browser-network-site.mjs"
|
||||||
- "scripts/fixtures/browser-script-site.mjs"
|
- "scripts/fixtures/browser-script-site.mjs"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Real JavaScript recovery
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/real-javascript-recovery.yml"
|
||||||
|
- "src/javascript/recovery/**"
|
||||||
|
- "src/domain/javascript*.ts"
|
||||||
|
- "src/application/JavaScriptRecovery*.ts"
|
||||||
|
- "src/composition/javascriptRecovery.ts"
|
||||||
|
- "src/contracts/javascriptRecoveryToolContracts.ts"
|
||||||
|
- "src/cli/javascriptRecoveryCommands.ts"
|
||||||
|
- "src/cli/javascriptApplicationAnalysis.ts"
|
||||||
|
- "src/server/registerJavaScriptRecoveryTool.ts"
|
||||||
|
- "src/server/createServer.ts"
|
||||||
|
- "src/server/sessionAvailabilityPolicy.ts"
|
||||||
|
- "src/application/CapabilityInventory.ts"
|
||||||
|
- "src/cli.ts"
|
||||||
|
- "src/cliCommandNames.ts"
|
||||||
|
- "src/contracts/toolContracts.ts"
|
||||||
|
- "src/contracts/toolEffects.ts"
|
||||||
|
- "scripts/rea.mjs"
|
||||||
|
- "src/process/**"
|
||||||
|
- "src/artifacts/SafeOutputTree.ts"
|
||||||
|
- "scripts/verify-javascript-recovery.mjs"
|
||||||
|
- "scripts/lib/javascript-recovery-*.mjs"
|
||||||
|
- "scripts/fixtures/javascript-recovery/**"
|
||||||
|
- "third_party/wakaru"
|
||||||
|
- "package.json"
|
||||||
|
- "package-lock.json"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: javascript-recovery-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
HUSKY: "0"
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=768"
|
||||||
|
RAYON_NUM_THREADS: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
real-javascript-recovery:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Fetch pinned Wakaru release into the job workspace
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/rea-wakaru"
|
||||||
|
curl --fail --location --retry 3 https://github.com/pionxzh/wakaru/releases/download/v1.13.0/wakaru-linux-x64.tar.gz -o "$RUNNER_TEMP/rea-wakaru/release.tar.gz"
|
||||||
|
tar -xzf "$RUNNER_TEMP/rea-wakaru/release.tar.gz" -C "$RUNNER_TEMP/rea-wakaru" wakaru
|
||||||
|
printf '%s %s\n' e8c7ca052974604197389f6ace7464e4dd24dce64e2730971521bd6606ec9c9e "$RUNNER_TEMP/rea-wakaru/wakaru" | sha256sum --check
|
||||||
|
- name: Install isolated fixture compilers
|
||||||
|
run: npm install --prefix "$RUNNER_TEMP/rea-fixture-compilers" --ignore-scripts --no-audit --no-fund [email protected] [email protected]
|
||||||
|
- run: npm run build:cached
|
||||||
|
- run: npm run verify:javascript:recovery
|
||||||
|
env:
|
||||||
|
REA_WAKARU_COMMAND: ${{ runner.temp }}/rea-wakaru/wakaru
|
||||||
|
REA_JAVASCRIPT_FIXTURE_TOOLS: ${{ runner.temp }}/rea-fixture-compilers
|
||||||
@@ -10,3 +10,6 @@
|
|||||||
[submodule "third_party/ghidra-nativeaot"]
|
[submodule "third_party/ghidra-nativeaot"]
|
||||||
path = third_party/ghidra-nativeaot
|
path = third_party/ghidra-nativeaot
|
||||||
url = https://github.com/Washi1337/ghidra-nativeaot.git
|
url = https://github.com/Washi1337/ghidra-nativeaot.git
|
||||||
|
[submodule "third_party/wakaru"]
|
||||||
|
path = third_party/wakaru
|
||||||
|
url = https://github.com/pionxzh/wakaru.git
|
||||||
|
|||||||
+6
-2
@@ -52,7 +52,8 @@
|
|||||||
"**/ida/**",
|
"**/ida/**",
|
||||||
"**/android/**",
|
"**/android/**",
|
||||||
"**/firmware/**",
|
"**/firmware/**",
|
||||||
"**/browser/*Provider.*"
|
"**/browser/*Provider.*",
|
||||||
|
"**/javascript/**"
|
||||||
],
|
],
|
||||||
"message": "Provider imports belong behind typed ports or in production composition."
|
"message": "Provider imports belong behind typed ports or in production composition."
|
||||||
}
|
}
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
"**/browser/**",
|
"**/browser/**",
|
||||||
"**/dotnet/**",
|
"**/dotnet/**",
|
||||||
"**/firmware/**",
|
"**/firmware/**",
|
||||||
|
"**/javascript/**",
|
||||||
"**/ghidra/**",
|
"**/ghidra/**",
|
||||||
"**/hopper/**",
|
"**/hopper/**",
|
||||||
"**/ida/**",
|
"**/ida/**",
|
||||||
@@ -113,6 +115,7 @@
|
|||||||
"**/android/**",
|
"**/android/**",
|
||||||
"**/firmware/**",
|
"**/firmware/**",
|
||||||
"**/browser/*Provider.*",
|
"**/browser/*Provider.*",
|
||||||
|
"**/javascript/**",
|
||||||
"**/composition/**"
|
"**/composition/**"
|
||||||
],
|
],
|
||||||
"message": "Application workflows use typed ports; production construction belongs in composition."
|
"message": "Application workflows use typed ports; production construction belongs in composition."
|
||||||
@@ -157,7 +160,8 @@
|
|||||||
"**/main.*",
|
"**/main.*",
|
||||||
"**/composition/**",
|
"**/composition/**",
|
||||||
"**/android/**",
|
"**/android/**",
|
||||||
"**/firmware/**"
|
"**/firmware/**",
|
||||||
|
"**/javascript/**"
|
||||||
],
|
],
|
||||||
"message": "Domain tests may import only their owning domain and inward dependencies."
|
"message": "Domain tests may import only their owning domain and inward dependencies."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
|||||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||||
| Application workflows | 8 | captured website script export; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
| Application workflows | 9 | captured website script export; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||||
|
|
||||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness records direct behavioral captures.
|
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness records direct behavioral captures.
|
||||||
@@ -417,6 +417,11 @@ Static Android APK inspection is verified on Linux with headless JADX; see [Andr
|
|||||||
and tracing tools. Source URLs, frame or transaction references, competing
|
and tracing tools. Source URLs, frame or transaction references, competing
|
||||||
versions, and missing-source states remain inline. See
|
versions, and missing-source states remain inline. See
|
||||||
[captured website scripts](docs/website-script-export.md).
|
[captured website scripts](docs/website-script-export.md).
|
||||||
|
Recover readable modules from selected local bundles with
|
||||||
|
`recover_javascript_sources` / `rea recover-javascript-sources`, then pass
|
||||||
|
the returned `analysis_input` to static analysis. This optional Linux x64
|
||||||
|
adapter requires caller-supplied Wakaru 1.13.0; see
|
||||||
|
[JavaScript source recovery](docs/javascript-recovery.md).
|
||||||
|
|
||||||
- **Electron and Node runtime observation:** Inspect selected Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
|
- **Electron and Node runtime observation:** Inspect selected Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
|
||||||
- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. See [managed-code analysis](docs/managed-code-analysis.md).
|
- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. See [managed-code analysis](docs/managed-code-analysis.md).
|
||||||
|
|||||||
+1
-1
@@ -223,7 +223,7 @@ rea setup
|
|||||||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||||
| سير عمل التطبيقات | 8 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
| سير عمل التطبيقات | 9 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||||
|
|
||||||
## خطة العمل
|
## خطة العمل
|
||||||
|
|||||||
+1
-1
@@ -212,7 +212,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
|||||||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||||
| アプリワークフロー | 8 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
| アプリワークフロー | 9 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||||
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
|
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
|
||||||
|
|
||||||
## ロードマップ
|
## ロードマップ
|
||||||
|
|||||||
+1
-1
@@ -213,7 +213,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
|||||||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||||
| 앱 워크플로 | 8 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
| 앱 워크플로 | 9 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||||
|
|
||||||
## 로드맵
|
## 로드맵
|
||||||
|
|||||||
+1
-1
@@ -212,7 +212,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
|||||||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||||
| 应用工作流 | 8 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
| 应用工作流 | 9 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||||
|
|
||||||
## 路线图
|
## 路线图
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# JavaScript source recovery
|
||||||
|
|
||||||
|
`recover_javascript_sources` / `recover-javascript-sources` derives readable
|
||||||
|
modules from one explicitly selected UTF-8 JavaScript file or bundle. Use it on
|
||||||
|
files returned by `export_web_scripts` or other local scripts. The operation
|
||||||
|
never executes the selected or recovered application code.
|
||||||
|
|
||||||
|
## Caller-supplied engine
|
||||||
|
|
||||||
|
The initial adapter is verified on Linux x64 with Wakaru **1.13.0**. Set
|
||||||
|
`REA_WAKARU_COMMAND` to the absolute path of an executable supplied by the caller.
|
||||||
|
The adapter uses util-linux `prlimit` (default `/usr/bin/prlimit`; override with
|
||||||
|
an absolute `REA_JAVASCRIPT_PRLIMIT_COMMAND`). REA does not install either tool.
|
||||||
|
Engine configuration is checked only when recovery is requested; other REA
|
||||||
|
capabilities remain available without it.
|
||||||
|
|
||||||
|
Unmodified upstream source is retained in `third_party/wakaru`, pinned to
|
||||||
|
`6070266d24b32951fa2fe1d2dad5b4313bd5c81b` (v1.13.0, Apache-2.0).
|
||||||
|
The result reports the executed binary's exact path, SHA-256 and observed version.
|
||||||
|
The audited source revision is distinct from `executed_source_revision: null`:
|
||||||
|
a version string cannot prove which source produced a caller-supplied binary.
|
||||||
|
|
||||||
|
## Recover and analyze
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rea recover-javascript-sources /tmp/web-scripts/app.js /tmp/recovered-app --json
|
||||||
|
rea analyze-javascript-application /tmp/recovered-app/modules --json
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP input:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "/tmp/web-scripts/app.js",
|
||||||
|
"output_directory": "/tmp/recovered-app"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The output directory must be absent, absolute, and have an existing parent.
|
||||||
|
The source is snapshotted in a private workspace, and the engine writes only to
|
||||||
|
its own staging directory. Reported module paths, input identities, UTF-8 byte
|
||||||
|
ranges, regular files and emitted map representations are validated before
|
||||||
|
publication. REA writes verified bytes to an exclusively owned output tree and
|
||||||
|
rolls it back on failure. It also checks that the original source and engine
|
||||||
|
bytes remain unchanged before committing. The existing output is never overwritten.
|
||||||
|
|
||||||
|
Published contents:
|
||||||
|
|
||||||
|
- `inputs/bundle.js`: exact original snapshot; its digest binds the Evidence.
|
||||||
|
- `modules/`: derived modules and any upstream-emitted source maps.
|
||||||
|
- `reports/stdout.json` and `reports/provenance.json`: exact producer reports.
|
||||||
|
- `manifest.json`: identities, modules, transform choices, warnings and limitations.
|
||||||
|
|
||||||
|
The inline result includes complete file paths/digests/sizes, extraction ranges,
|
||||||
|
warnings and `analysis_input`, directly usable as the arguments to
|
||||||
|
`analyze_javascript_application`. Raw Evidence retains selected command arguments,
|
||||||
|
reported input paths, exit status, stderr and reports; temporary paths are recorded
|
||||||
|
as historical execution coordinates after their workspace is removed.
|
||||||
|
|
||||||
|
## Transform choices and interpretation
|
||||||
|
|
||||||
|
`extraction_mode` defaults to `structural` (Wakaru `--unpack=strict`). `heuristic`
|
||||||
|
uses `--unpack=auto`; `inspection` uses `--unpack=inspect` and can return finer,
|
||||||
|
non-executable regions. `rewrite_level` accepts `minimal`, `standard` (default)
|
||||||
|
and `aggressive`. Maps and provenance are requested in every operation.
|
||||||
|
|
||||||
|
Structural extraction can fall back to a single file. In the real fixtures,
|
||||||
|
webpack 5 yields two modules; the esbuild scope-hoisted fixture remains one
|
||||||
|
unknown-format script. Returned module counts and detected formats report those
|
||||||
|
actual outcomes. A valid report with failed transformations returns `partial`
|
||||||
|
with original warnings and files. An unusable report, inconsistent provenance,
|
||||||
|
escaping path, symlink or missing module is an error, with owned cleanup.
|
||||||
|
|
||||||
|
Evidence confidence is `derived`. Extraction ranges are half-open **UTF-8 byte
|
||||||
|
offsets in the original snapshot**, rather than rewritten line positions or a
|
||||||
|
coverage assertion. Source maps are preserved without claiming independently
|
||||||
|
verified mapping accuracy. Original variable names and arbitrary runtime
|
||||||
|
equivalence remain unknown. No network fetching occurs.
|
||||||
|
|
||||||
|
## Resource policy and verification
|
||||||
|
|
||||||
|
Each operation uses one worker, a 1 GiB address-space ceiling and a 120-second
|
||||||
|
deadline; it accepts up to 64 MiB of input and 128 MiB/10000 entries of staged
|
||||||
|
output, with 8 MiB per diagnostic/report stream. Exceeding a limit is an explicit
|
||||||
|
failure with rollback. These limits apply per operation and do not claim aggregate
|
||||||
|
host containment across concurrent callers.
|
||||||
|
|
||||||
|
See [testing.md](testing.md#javascript-source-recovery) for the focused real
|
||||||
|
Wakaru CLI/MCP lane and its source-owned compiler fixtures. Other host platforms
|
||||||
|
and Bun/Vue recovery are not claimed by this adapter.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"skill_version": "26"
|
"skill_version": "26"
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"total": 126,
|
"total": 127,
|
||||||
"families": [
|
"families": [
|
||||||
{
|
{
|
||||||
"id": "direct",
|
"id": "direct",
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
{
|
{
|
||||||
"id": "application",
|
"id": "application",
|
||||||
"surface": "application-workflow",
|
"surface": "application-workflow",
|
||||||
"count": 8,
|
"count": 9,
|
||||||
"tools": [
|
"tools": [
|
||||||
"build_reconstruction_obligation_ledger",
|
"build_reconstruction_obligation_ledger",
|
||||||
"compare_application_versions",
|
"compare_application_versions",
|
||||||
@@ -187,6 +187,7 @@
|
|||||||
"compare_source_to_bundle",
|
"compare_source_to_bundle",
|
||||||
"evaluate_reconstruction_coverage",
|
"evaluate_reconstruction_coverage",
|
||||||
"export_web_scripts",
|
"export_web_scripts",
|
||||||
|
"recover_javascript_sources",
|
||||||
"trace_application_feature",
|
"trace_application_feature",
|
||||||
"trace_javascript_semantics"
|
"trace_javascript_semantics"
|
||||||
]
|
]
|
||||||
@@ -462,6 +463,12 @@
|
|||||||
"name": "Unblob",
|
"name": "Unblob",
|
||||||
"version": "26.6.4",
|
"version": "26.6.4",
|
||||||
"capabilities": ["extract_firmware"]
|
"capabilities": ["extract_firmware"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wakaru",
|
||||||
|
"name": "Wakaru",
|
||||||
|
"version": "1.13.0",
|
||||||
|
"capabilities": ["recover_javascript_sources"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"setup_clients": [
|
"setup_clients": [
|
||||||
@@ -539,7 +546,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"cli": {
|
"cli": {
|
||||||
"primary_count": 81,
|
"primary_count": 82,
|
||||||
"commands": [
|
"commands": [
|
||||||
"address-to-file-offset",
|
"address-to-file-offset",
|
||||||
"analyze",
|
"analyze",
|
||||||
@@ -608,6 +615,7 @@
|
|||||||
"providers",
|
"providers",
|
||||||
"read-bytes",
|
"read-bytes",
|
||||||
"reconcile-javascript-runtime",
|
"reconcile-javascript-runtime",
|
||||||
|
"recover-javascript-sources",
|
||||||
"resolve-native-call-targets",
|
"resolve-native-call-targets",
|
||||||
"search",
|
"search",
|
||||||
"search-android-classes",
|
"search-android-classes",
|
||||||
@@ -632,15 +640,15 @@
|
|||||||
},
|
},
|
||||||
"runtime_catalog": {
|
"runtime_catalog": {
|
||||||
"counts": {
|
"counts": {
|
||||||
"cli_commands": 81,
|
"cli_commands": 82,
|
||||||
"mcp_tools": 126,
|
"mcp_tools": 127,
|
||||||
"mcp_prompts": 6
|
"mcp_prompts": 6
|
||||||
},
|
},
|
||||||
"digests": {
|
"digests": {
|
||||||
"tools_sha256": "3d17936cd393a494987f7f52a133f23f9b582718d57719b2e06c678a7c5ca20a",
|
"tools_sha256": "1f1acf182aa87964404e65f3acb00c44f1c392373c5a8a6f40c250da8b94c274",
|
||||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||||
"combined_sha256": "de30f3145fb936a97fcb7bcc58ed9f20a974bae59b239dbf24664c145eff4275",
|
"combined_sha256": "0efbf17dddbdf52f84d4ca1f9a3e007a0d77c2623be62ce0a69844d617bf16da",
|
||||||
"providers_sha256": "7949ddcf2eb5ba41b022bb3dfa1951ab96dbf5640285fa7fd5d35b0e4a5bdab3"
|
"providers_sha256": "0bccfa9588376a90350d18abded999db7e1d0e4424a61a21d03d392a1c5418fe"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,3 +409,17 @@ unknown chunks, depth limits and extracted child digests. The optional
|
|||||||
`REA_FIRMWARE_VERIFY_GHIDRA=1` lane checks a selected host ELF through real Ghidra.
|
`REA_FIRMWARE_VERIFY_GHIDRA=1` lane checks a selected host ELF through real Ghidra.
|
||||||
Neither optional toolchain is a base-lane prerequisite. See
|
Neither optional toolchain is a base-lane prerequisite. See
|
||||||
[firmware analysis](firmware-analysis.md) for limits and unverified formats.
|
[firmware analysis](firmware-analysis.md) for limits and unverified formats.
|
||||||
|
|
||||||
|
### JavaScript source recovery
|
||||||
|
|
||||||
|
Build once with `npm run build:cached`, then run `npm run verify:javascript:recovery`.
|
||||||
|
This focused lane requires Linux x64, util-linux `prlimit`,
|
||||||
|
`REA_WAKARU_COMMAND` pointing to the official Wakaru 1.13.0 Linux x64 binary,
|
||||||
|
and `REA_JAVASCRIPT_FIXTURE_TOOLS` pointing to an isolated npm prefix containing
|
||||||
|
esbuild 0.25.10 and webpack 5.101.3. No global installation is required.
|
||||||
|
The lane compiles source-owned fixtures, exercises CLI and stdio MCP, verifies
|
||||||
|
published bytes and provenance, feeds recovered modules into existing analysis,
|
||||||
|
and compares a finite set of known fixture results. It does not establish
|
||||||
|
arbitrary recovered-application equivalence. CI installs these prerequisites only
|
||||||
|
in `.github/workflows/real-javascript-recovery.yml`; the existing `real-browser`
|
||||||
|
lane uses real Chrome for browser capture and website workflows.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifest_id": "ecm_0c8012eb19ae0f5a8e41f0676680b500668b9966cf95a320878e9554d4669e5a",
|
"manifest_id": "ecm_d24d65186c309bbfa713e50851ddae373e1dd874ff444f717e8e379946ed2407",
|
||||||
"verifier": {
|
"verifier": {
|
||||||
"id": "rea-managed-conformance",
|
"id": "rea-managed-conformance",
|
||||||
"version": "1"
|
"version": "1"
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"skill_digests": [
|
"skill_digests": [
|
||||||
{
|
{
|
||||||
"skill_id": "reverse-engineer-anything",
|
"skill_id": "reverse-engineer-anything",
|
||||||
"sha256": "50b3311f68cf2b73686c3b96ceb5f1cbe9054df7886ddf88b5c701f999c6c9cc"
|
"sha256": "6dfe4f9c24f9438982910f3170a7b0b1798761da6ad9a487d73e662f4c23e9ba"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"claims": [
|
"claims": [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "./node_modules/knip/schema.json",
|
"$schema": "./node_modules/knip/schema.json",
|
||||||
"entry": ["src/main.ts", "src/cli.ts", "src/contracts/errorSchemas.ts"],
|
"entry": ["src/main.ts", "src/cli.ts", "src/contracts/errorSchemas.ts"],
|
||||||
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
|
||||||
"ignoreDependencies": ["electron"],
|
"ignoreDependencies": ["electron", "esbuild", "webpack"],
|
||||||
"ignoreExportsUsedInFile": true,
|
"ignoreExportsUsedInFile": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"files": "error",
|
"files": "error",
|
||||||
|
|||||||
+2
-1
@@ -177,7 +177,8 @@
|
|||||||
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.mjs",
|
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.mjs",
|
||||||
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs",
|
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs",
|
||||||
"verify:browser:scripts": "npm run build:cached && node scripts/verify-browser-scripts.mjs",
|
"verify:browser:scripts": "npm run build:cached && node scripts/verify-browser-scripts.mjs",
|
||||||
"verify:test-discovery": "node scripts/verify-test-discovery.mjs"
|
"verify:test-discovery": "node scripts/verify-test-discovery.mjs",
|
||||||
|
"verify:javascript:recovery": "node scripts/verify-javascript-recovery.mjs"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { greet, total } from "./helpers.mjs";
|
||||||
|
|
||||||
|
globalThis.recoveryFixture = { greet, total };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function greet(name) {
|
||||||
|
return `Hello ${name ?? "world"}`;
|
||||||
|
}
|
||||||
|
export function total(items) {
|
||||||
|
return items.reduce((sum, value) => sum + value, 0);
|
||||||
|
}
|
||||||
@@ -67,6 +67,8 @@ export const toolFamilyCatalog = (sources) => {
|
|||||||
contracts: [
|
contracts: [
|
||||||
...sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
...sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||||
...sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
...sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||||
|
...sources.javascriptRecoveryContracts
|
||||||
|
.JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -191,6 +193,11 @@ export const providerCatalog = (sources) => {
|
|||||||
identity: sources.artifactProviders.WEB_SCRIPT_EXPORT_PROVIDER,
|
identity: sources.artifactProviders.WEB_SCRIPT_EXPORT_PROVIDER,
|
||||||
contracts: sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
contracts: sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
identity: sources.javascriptRecoveryProvider.WAKARU_PROVIDER_IDENTITY,
|
||||||
|
contracts:
|
||||||
|
sources.javascriptRecoveryContracts.JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
.map(({ identity, contracts }) => ({
|
.map(({ identity, contracts }) => ({
|
||||||
id: identity.id,
|
id: identity.id,
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ const SOURCE_PATHS = {
|
|||||||
"dist/contracts/javascriptRuntimeObservationToolContracts.js",
|
"dist/contracts/javascriptRuntimeObservationToolContracts.js",
|
||||||
applicationContracts: "dist/contracts/applicationToolContracts.js",
|
applicationContracts: "dist/contracts/applicationToolContracts.js",
|
||||||
webScriptContracts: "dist/contracts/webScriptToolContracts.js",
|
webScriptContracts: "dist/contracts/webScriptToolContracts.js",
|
||||||
|
javascriptRecoveryContracts:
|
||||||
|
"dist/contracts/javascriptRecoveryToolContracts.js",
|
||||||
|
javascriptRecoveryProvider: "dist/javascript/recovery/WakaruRelease.js",
|
||||||
supportedClients: "dist/application/SupportedClients.js",
|
supportedClients: "dist/application/SupportedClients.js",
|
||||||
hopperProvider: "dist/hopper/HopperProvider.js",
|
hopperProvider: "dist/hopper/HopperProvider.js",
|
||||||
ghidraProvider: "dist/ghidra/GhidraProvider.js",
|
ghidraProvider: "dist/ghidra/GhidraProvider.js",
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** Compile source-owned fixtures using explicitly supplied, pinned toolchains. */
|
||||||
|
export async function buildRecoveryFixtures(root, prefix) {
|
||||||
|
if (!prefix)
|
||||||
|
throw new Error(
|
||||||
|
"verify:javascript:recovery requires REA_JAVASCRIPT_FIXTURE_TOOLS pointing to the isolated esbuild/webpack installation",
|
||||||
|
);
|
||||||
|
const require = createRequire(join(prefix, "package.json"));
|
||||||
|
const esbuild = require("esbuild");
|
||||||
|
const webpack = require("webpack");
|
||||||
|
const webpackVersion = require("webpack/package.json").version;
|
||||||
|
if (esbuild.version !== "0.25.10" || webpackVersion !== "5.101.3")
|
||||||
|
throw new Error(
|
||||||
|
"Fixture compilers must be esbuild 0.25.10 and webpack 5.101.3",
|
||||||
|
);
|
||||||
|
const source = fileURLToPath(
|
||||||
|
new URL("../fixtures/javascript-recovery/entry.mjs", import.meta.url),
|
||||||
|
);
|
||||||
|
const webpackOutput = join(root, "webpack");
|
||||||
|
await mkdir(webpackOutput);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const compiler = webpack({
|
||||||
|
mode: "production",
|
||||||
|
entry: source,
|
||||||
|
parallelism: 1,
|
||||||
|
optimization: { concatenateModules: false },
|
||||||
|
output: { path: webpackOutput, filename: "bundle.js" },
|
||||||
|
});
|
||||||
|
compiler.run((error, stats) => {
|
||||||
|
compiler.close((closeError) => {
|
||||||
|
if (error || closeError) return reject(error ?? closeError);
|
||||||
|
if (!stats || stats.hasErrors())
|
||||||
|
return reject(new Error(stats?.toString() ?? "No webpack stats"));
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const esbuildPath = join(root, "esbuild.js");
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: [source],
|
||||||
|
outfile: esbuildPath,
|
||||||
|
bundle: true,
|
||||||
|
format: "iife",
|
||||||
|
minify: true,
|
||||||
|
logLevel: "silent",
|
||||||
|
});
|
||||||
|
// One self-contained source fixture avoids unresolved imports in the oracle.
|
||||||
|
const helperSource = await readFile(
|
||||||
|
fileURLToPath(
|
||||||
|
new URL("../fixtures/javascript-recovery/helpers.mjs", import.meta.url),
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const plain = await esbuild.transform(
|
||||||
|
helperSource.replaceAll("export ", "") +
|
||||||
|
"\nglobalThis.recoveryFixture = {greet, total};",
|
||||||
|
{ minify: true },
|
||||||
|
);
|
||||||
|
const minifiedPath = join(root, "minified.js");
|
||||||
|
await writeFile(minifiedPath, plain.code);
|
||||||
|
if (plain.code.length === 0)
|
||||||
|
throw new Error("Expected a real minifier output");
|
||||||
|
return {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "minified",
|
||||||
|
path: minifiedPath,
|
||||||
|
expectedFormat: "unknown",
|
||||||
|
expectedModules: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webpack",
|
||||||
|
path: join(webpackOutput, "bundle.js"),
|
||||||
|
expectedFormat: "webpack5",
|
||||||
|
expectedModules: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "esbuild-scope-hoisted",
|
||||||
|
path: esbuildPath,
|
||||||
|
expectedFormat: "unknown",
|
||||||
|
expectedModules: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "heuristic-aggressive",
|
||||||
|
path: minifiedPath,
|
||||||
|
expectedFormat: "unknown",
|
||||||
|
expectedModules: 1,
|
||||||
|
options: { extraction_mode: "heuristic", rewrite_level: "aggressive" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inspection-minimal",
|
||||||
|
path: join(webpackOutput, "bundle.js"),
|
||||||
|
expectedFormat: "webpack5",
|
||||||
|
expectedModules: 2,
|
||||||
|
options: { extraction_mode: "inspection", rewrite_level: "minimal" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toolchains: { esbuild: esbuild.version, webpack: webpackVersion },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { Client } from "@modelcontextprotocol/client";
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||||
|
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||||
|
import { javascriptRecoveryResultSchema } from "../dist/domain/javascriptRecovery.js";
|
||||||
|
import { buildRecoveryFixtures } from "./lib/javascript-recovery-fixtures.mjs";
|
||||||
|
import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
|
||||||
|
|
||||||
|
const OFFICIAL_BINARY_SHA256 =
|
||||||
|
"e8c7ca052974604197389f6ace7464e4dd24dce64e2730971521bd6606ec9c9e";
|
||||||
|
if (!process.env.REA_WAKARU_COMMAND)
|
||||||
|
throw new Error(
|
||||||
|
"Provide REA_WAKARU_COMMAND for the official Wakaru 1.13.0 Linux x64 binary",
|
||||||
|
);
|
||||||
|
const entrypoint =
|
||||||
|
process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "rea-real-recovery-"));
|
||||||
|
const environment = { ...process.env, REA_LOG_LEVEL: "silent" };
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
args: [entrypoint, "mcp"],
|
||||||
|
env: environment,
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
const client = new Client({ name: "real-recovery", version: "1" });
|
||||||
|
const run = promisify(execFile);
|
||||||
|
const cli = async (...args) => {
|
||||||
|
const { stdout } = await run(
|
||||||
|
process.execPath,
|
||||||
|
[entrypoint, ...args, "--json"],
|
||||||
|
{ env: environment, timeout: 150000, maxBuffer: 32 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
return parseEvidence(JSON.parse(stdout));
|
||||||
|
};
|
||||||
|
const call = async (name, args) => {
|
||||||
|
const result = await client.callTool({ name, arguments: args }, undefined, {
|
||||||
|
timeout: 150000,
|
||||||
|
});
|
||||||
|
assert.notEqual(result.isError, true, mcpTextValue(result));
|
||||||
|
return parseEvidence(JSON.parse(mcpTextValue(result)).evidence);
|
||||||
|
};
|
||||||
|
const fingerprint = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||||
|
const counts = [];
|
||||||
|
try {
|
||||||
|
const fixtures = await buildRecoveryFixtures(
|
||||||
|
root,
|
||||||
|
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
|
||||||
|
);
|
||||||
|
await client.connect(transport);
|
||||||
|
for (const fixture of fixtures.files) {
|
||||||
|
for (const surface of ["cli", "stdio_mcp"]) {
|
||||||
|
const output = join(root, `${fixture.name}-${surface}`);
|
||||||
|
const flags =
|
||||||
|
fixture.options === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
"--extraction-mode",
|
||||||
|
fixture.options.extraction_mode,
|
||||||
|
"--rewrite-level",
|
||||||
|
fixture.options.rewrite_level,
|
||||||
|
];
|
||||||
|
const evidence =
|
||||||
|
surface === "cli"
|
||||||
|
? await cli(
|
||||||
|
"recover-javascript-sources",
|
||||||
|
fixture.path,
|
||||||
|
output,
|
||||||
|
...flags,
|
||||||
|
)
|
||||||
|
: await call("recover_javascript_sources", {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: output,
|
||||||
|
...fixture.options,
|
||||||
|
});
|
||||||
|
const result = javascriptRecoveryResultSchema.parse(
|
||||||
|
evidence.normalized_result,
|
||||||
|
);
|
||||||
|
await assertRecoveredArtifacts(evidence, result, fixture);
|
||||||
|
const analysis =
|
||||||
|
surface === "cli"
|
||||||
|
? await cli(
|
||||||
|
"analyze-javascript-application",
|
||||||
|
result.analysis_input.input_path,
|
||||||
|
)
|
||||||
|
: await call("analyze_javascript_application", result.analysis_input);
|
||||||
|
assert.equal(analysis.normalized_result.statistics.parse_failures, 0);
|
||||||
|
assert.equal(
|
||||||
|
analysis.normalized_result.statistics.parsed_javascript_files,
|
||||||
|
result.modules.length,
|
||||||
|
);
|
||||||
|
const recoveredEntry =
|
||||||
|
result.modules.find(
|
||||||
|
(module) => module.reported_filename === "entry.js",
|
||||||
|
) ?? result.modules[0];
|
||||||
|
assert.ok(recoveredEntry);
|
||||||
|
const oracle =
|
||||||
|
'await import((await import("node:url")).pathToFileURL(process.argv[1])); const f=globalThis.recoveryFixture; console.log(JSON.stringify([f.greet(null),f.greet("REA"),f.total([1,2,3])]));';
|
||||||
|
for (const selected of fixture.options?.extraction_mode === "inspection"
|
||||||
|
? []
|
||||||
|
: [fixture.path, recoveredEntry.artifact.path]) {
|
||||||
|
const execution = await run(
|
||||||
|
process.execPath,
|
||||||
|
["--input-type=module", "-e", oracle, selected],
|
||||||
|
{ env: environment, timeout: 10000 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(JSON.parse(execution.stdout), [
|
||||||
|
"Hello world",
|
||||||
|
"Hello REA",
|
||||||
|
6,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
counts.push({
|
||||||
|
fixture: fixture.name,
|
||||||
|
surface,
|
||||||
|
modules: result.modules.length,
|
||||||
|
maps: result.modules.filter((module) => module.source_map !== null)
|
||||||
|
.length,
|
||||||
|
finite_fixture_oracle:
|
||||||
|
fixture.options?.extraction_mode !== "inspection",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
engine: "wakaru 1.13.0",
|
||||||
|
toolchains: fixtures.toolchains,
|
||||||
|
results: counts,
|
||||||
|
verified: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await transport.close();
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRecoveredArtifacts(evidence, result, fixture) {
|
||||||
|
assert.deepEqual(result.options, {
|
||||||
|
extraction_mode: "structural",
|
||||||
|
rewrite_level: "standard",
|
||||||
|
...fixture.options,
|
||||||
|
});
|
||||||
|
if (fixture.options?.extraction_mode === "inspection")
|
||||||
|
assert.equal(result.reported_safety, "inspection-only");
|
||||||
|
assert.equal(result.engine.executable.sha256, OFFICIAL_BINARY_SHA256);
|
||||||
|
assert.equal(result.engine.executed_source_revision, null);
|
||||||
|
assert.equal(evidence.confidence, "derived");
|
||||||
|
assert.equal(result.runtime_equivalence, "unknown");
|
||||||
|
assert.equal(result.status, "complete");
|
||||||
|
assert.equal(result.reported_format, fixture.expectedFormat);
|
||||||
|
assert.equal(result.modules.length, fixture.expectedModules);
|
||||||
|
assert.equal(result.source.sha256, fingerprint(await readFile(fixture.path)));
|
||||||
|
for (const file of [
|
||||||
|
result.source.published_copy,
|
||||||
|
result.report,
|
||||||
|
result.provenance,
|
||||||
|
result.manifest,
|
||||||
|
...result.modules.flatMap((module) => [
|
||||||
|
module.artifact,
|
||||||
|
...(module.source_map ? [module.source_map] : []),
|
||||||
|
]),
|
||||||
|
]) {
|
||||||
|
const bytes = await readFile(file.path);
|
||||||
|
assert.equal(bytes.length, file.bytes);
|
||||||
|
assert.equal(fingerprint(bytes), file.sha256);
|
||||||
|
}
|
||||||
|
for (const module of result.modules) {
|
||||||
|
assert.equal(module.provenance.original_sha256, result.source.sha256);
|
||||||
|
assert.equal(module.provenance.reported_input, result.source.snapshot_path);
|
||||||
|
for (const range of module.provenance.byte_ranges)
|
||||||
|
assert.ok(range.start <= range.end && range.end <= result.source.bytes);
|
||||||
|
if (module.source_map) {
|
||||||
|
const map = JSON.parse(await readFile(module.source_map.path, "utf8"));
|
||||||
|
for (const source of map.sources)
|
||||||
|
assert.equal(
|
||||||
|
resolve(dirname(module.source_map.path), source),
|
||||||
|
result.source.published_copy.path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -413,9 +413,16 @@ async function verifyPageScopedTransport(provider, proxy, origin) {
|
|||||||
target_id: target,
|
target_id: target,
|
||||||
observation_ms: 5_000,
|
observation_ms: 5_000,
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
progress: {
|
||||||
|
report(event) {
|
||||||
|
if (event.phase === "browser_observation" && event.completed === 1)
|
||||||
|
proxy.disconnectClients();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await delay(100);
|
|
||||||
proxy.disconnectClients();
|
|
||||||
const disconnected = await disconnectedPromise;
|
const disconnected = await disconnectedPromise;
|
||||||
if (
|
if (
|
||||||
!disconnected.ok ||
|
!disconnected.ok ||
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ 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: "26"
|
version: "26"
|
||||||
tool_count: 126
|
tool_count: 127
|
||||||
catalog_digest: "de30f3145fb936a97fcb7bcc58ed9f20a974bae59b239dbf24664c145eff4275"
|
catalog_digest: "0efbf17dddbdf52f84d4ca1f9a3e007a0d77c2623be62ce0a69844d617bf16da"
|
||||||
---
|
---
|
||||||
|
|
||||||
# REA
|
# REA
|
||||||
|
|||||||
@@ -333,3 +333,25 @@ describe("capability inventory: caller guidance", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("optional JavaScript recovery availability", () => {
|
||||||
|
it("keeps optional recovery target-free and does not disable other static workflows", () => {
|
||||||
|
expect(entry("recover_javascript_sources", status())).toMatchObject({
|
||||||
|
available: false,
|
||||||
|
reason: "provider_missing",
|
||||||
|
remediation: expect.stringContaining("REA_WAKARU_COMMAND"),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
entry("recover_javascript_sources", status(), {
|
||||||
|
processCaptureEnabled: true,
|
||||||
|
javascriptRecoveryEnabled: true,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
available: true,
|
||||||
|
surface: "application",
|
||||||
|
});
|
||||||
|
expect(entry("analyze_javascript_application", status())).toMatchObject({
|
||||||
|
available: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type AvailabilityPolicy = {
|
|||||||
readonly firmwareInspectionEnabled?: boolean;
|
readonly firmwareInspectionEnabled?: boolean;
|
||||||
readonly firmwareExtractionEnabled?: boolean;
|
readonly firmwareExtractionEnabled?: boolean;
|
||||||
readonly androidAnalysisEnabled?: boolean;
|
readonly androidAnalysisEnabled?: boolean;
|
||||||
|
readonly javascriptRecoveryEnabled?: boolean;
|
||||||
readonly browserObservationEnabled?: boolean;
|
readonly browserObservationEnabled?: boolean;
|
||||||
readonly browserScenarioEnabled?: boolean;
|
readonly browserScenarioEnabled?: boolean;
|
||||||
readonly electronObservationEnabled?: boolean;
|
readonly electronObservationEnabled?: boolean;
|
||||||
@@ -236,6 +237,14 @@ const workflowAvailabilityFor = ({
|
|||||||
kind,
|
kind,
|
||||||
policy,
|
policy,
|
||||||
}: AvailabilityContext): Availability | null => {
|
}: AvailabilityContext): Availability | null => {
|
||||||
|
if (name === "recover_javascript_sources")
|
||||||
|
return policy.javascriptRecoveryEnabled === true
|
||||||
|
? { reason: "available", remediation: null }
|
||||||
|
: {
|
||||||
|
reason: "provider_missing",
|
||||||
|
remediation:
|
||||||
|
"On Linux x64, provide an absolute REA_WAKARU_COMMAND for Wakaru 1.13.0 and util-linux prlimit. No binary target is required.",
|
||||||
|
};
|
||||||
if (kind === "firmware-provider") {
|
if (kind === "firmware-provider") {
|
||||||
const enabled =
|
const enabled =
|
||||||
name === "inspect_firmware_regions"
|
name === "inspect_firmware_regions"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import type { JavaScriptRecoveryInput } from "../domain/javascriptRecovery.js";
|
||||||
|
import type { Result } from "../domain/result.js";
|
||||||
|
import type {
|
||||||
|
AnalysisExecution,
|
||||||
|
ExecutionOptions,
|
||||||
|
} from "./AnalysisProvider.js";
|
||||||
|
|
||||||
|
/** Typed boundary for upstream source recovery without exposing its protocol. */
|
||||||
|
export interface JavaScriptRecoveryPort {
|
||||||
|
recover(
|
||||||
|
input: JavaScriptRecoveryInput,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||||
|
import { err, ok } from "../domain/result.js";
|
||||||
|
import type {
|
||||||
|
JavaScriptRecoveryInput,
|
||||||
|
JavaScriptRecoveryResult,
|
||||||
|
} from "../domain/javascriptRecovery.js";
|
||||||
|
import { createAnalysisExecution } from "./AnalysisProvider.js";
|
||||||
|
import { JavaScriptRecoveryService } from "./JavaScriptRecoveryService.js";
|
||||||
|
|
||||||
|
const artifact = { path: "/tmp/bundle.js", sha256: "a".repeat(64), bytes: 20 };
|
||||||
|
const recoveryResult = (): JavaScriptRecoveryResult => ({
|
||||||
|
source: {
|
||||||
|
...artifact,
|
||||||
|
snapshot_path: "/tmp/work/inputs/bundle.js",
|
||||||
|
published_copy: artifact,
|
||||||
|
},
|
||||||
|
engine: {
|
||||||
|
id: "fixture-recovery",
|
||||||
|
version: "1",
|
||||||
|
executable: artifact,
|
||||||
|
audited_source_revision: "pinned",
|
||||||
|
executed_source_revision: null,
|
||||||
|
},
|
||||||
|
options: { extraction_mode: "structural", rewrite_level: "standard" },
|
||||||
|
status: "complete",
|
||||||
|
detected_formats: [],
|
||||||
|
reported_safety: "normal",
|
||||||
|
reported_strategy: "structural",
|
||||||
|
reported_format: "unknown",
|
||||||
|
total: 0,
|
||||||
|
failed: 0,
|
||||||
|
modules: [],
|
||||||
|
warnings: [],
|
||||||
|
report: artifact,
|
||||||
|
provenance: artifact,
|
||||||
|
manifest: artifact,
|
||||||
|
analysis_input: null,
|
||||||
|
runtime_equivalence: "unknown",
|
||||||
|
limitations: ["Not executed"],
|
||||||
|
});
|
||||||
|
const providerIdentity = {
|
||||||
|
id: "fixture-recovery",
|
||||||
|
name: "Fixture recovery",
|
||||||
|
version: "1",
|
||||||
|
};
|
||||||
|
const input = { path: artifact.path, output_directory: "/tmp/output" };
|
||||||
|
|
||||||
|
describe("JavaScript recovery workflow", () => {
|
||||||
|
it("passes normalized requests to a typed port and returns derived Evidence", async () => {
|
||||||
|
const received: JavaScriptRecoveryInput[] = [];
|
||||||
|
const service = new JavaScriptRecoveryService({
|
||||||
|
recover: async (request) => {
|
||||||
|
received.push(request);
|
||||||
|
return ok(
|
||||||
|
createAnalysisExecution(recoveryResult(), providerIdentity, {
|
||||||
|
subject: { ...artifact, format: "javascript" },
|
||||||
|
limitations: ["Not executed"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.recover(input);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
expect(received).toEqual([
|
||||||
|
{ ...input, extraction_mode: "structural", rewrite_level: "standard" },
|
||||||
|
]);
|
||||||
|
expect(result.value).toMatchObject({
|
||||||
|
operation: "recover_javascript_sources",
|
||||||
|
confidence: "derived",
|
||||||
|
authority: "shipped-artifact",
|
||||||
|
provider: providerIdentity,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("preserves a missing provider reason", async () => {
|
||||||
|
const failure = new AnalysisCapabilityUnavailableError(
|
||||||
|
"fixture-recovery",
|
||||||
|
"recover_javascript_sources",
|
||||||
|
"fixture missing engine",
|
||||||
|
);
|
||||||
|
const result = await new JavaScriptRecoveryService({
|
||||||
|
recover: async () => err(failure),
|
||||||
|
}).recover(input);
|
||||||
|
expect(result).toEqual(err(failure));
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
"invalid-result",
|
||||||
|
"missing-subject",
|
||||||
|
"subject-mismatch",
|
||||||
|
"engine-mismatch",
|
||||||
|
])("rejects %s returned across the port", async (mode) => {
|
||||||
|
const service = new JavaScriptRecoveryService({
|
||||||
|
recover: async () =>
|
||||||
|
ok(
|
||||||
|
createAnalysisExecution(
|
||||||
|
mode === "invalid-result" ? {} : recoveryResult(),
|
||||||
|
mode === "engine-mismatch"
|
||||||
|
? { ...providerIdentity, id: "another-engine" }
|
||||||
|
: providerIdentity,
|
||||||
|
mode === "missing-subject"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
subject: {
|
||||||
|
path:
|
||||||
|
mode === "subject-mismatch" ? "/wrong.js" : artifact.path,
|
||||||
|
sha256: artifact.sha256,
|
||||||
|
format: "javascript" as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const result = await service.recover(input);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error("expected invalid port output");
|
||||||
|
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||||
|
});
|
||||||
|
it.each(["path", "output_directory"])(
|
||||||
|
"rejects relative %s before acquiring the recovery engine",
|
||||||
|
async (field) => {
|
||||||
|
let invoked = false;
|
||||||
|
const service = new JavaScriptRecoveryService({
|
||||||
|
recover: async () => {
|
||||||
|
invoked = true;
|
||||||
|
return err(
|
||||||
|
new AnalysisCapabilityUnavailableError(
|
||||||
|
"fixture",
|
||||||
|
"recover_javascript_sources",
|
||||||
|
"engine missing",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.recover({
|
||||||
|
...input,
|
||||||
|
[field]: "relative.js",
|
||||||
|
});
|
||||||
|
expect(invoked).toBe(false);
|
||||||
|
if (result.ok) throw new Error("expected invalid path");
|
||||||
|
expect(result.error._tag).toBe("AnalysisInputError");
|
||||||
|
expect(result.error).toMatchObject({
|
||||||
|
issues: [{ path: [field], reason: "invalid_format" }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("rejects malformed and pre-cancelled input before acquiring the port", async () => {
|
||||||
|
const service = new JavaScriptRecoveryService({
|
||||||
|
recover: async () => {
|
||||||
|
throw new Error("port must not execute");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const invalid = await service.recover({});
|
||||||
|
if (invalid.ok) throw new Error("expected malformed input");
|
||||||
|
expect(invalid.error._tag).toBe("AnalysisInputError");
|
||||||
|
const cancelled = await service.recover(input, {
|
||||||
|
signal: AbortSignal.abort(),
|
||||||
|
});
|
||||||
|
if (cancelled.ok) throw new Error("expected cancellation");
|
||||||
|
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { isAbsolute } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AnalysisCancelledError,
|
||||||
|
AnalysisInputError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
} from "../domain/analysisErrorCore.js";
|
||||||
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||||
|
import {
|
||||||
|
javascriptRecoveryInputSchema,
|
||||||
|
javascriptRecoveryResultSchema,
|
||||||
|
} from "../domain/javascriptRecovery.js";
|
||||||
|
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||||
|
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||||
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
|
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||||
|
import type { JavaScriptRecoveryPort } from "./JavaScriptRecoveryPort.js";
|
||||||
|
|
||||||
|
const OPERATION = "recover_javascript_sources";
|
||||||
|
|
||||||
|
/** Shared CLI/MCP parsing and Evidence for derived JavaScript artifacts. */
|
||||||
|
export class JavaScriptRecoveryService {
|
||||||
|
constructor(readonly provider: JavaScriptRecoveryPort) {}
|
||||||
|
|
||||||
|
/** Recover sources and bind the result to its original immutable input. */
|
||||||
|
async recover(
|
||||||
|
input: unknown,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Result<Evidence, AnalysisError>> {
|
||||||
|
if (options?.signal?.aborted === true)
|
||||||
|
return err(new AnalysisCancelledError(OPERATION));
|
||||||
|
const parsed = javascriptRecoveryInputSchema.safeParse(input);
|
||||||
|
if (!parsed.success)
|
||||||
|
return err(
|
||||||
|
new AnalysisInputError(
|
||||||
|
OPERATION,
|
||||||
|
{ cause: parsed.error },
|
||||||
|
projectInputIssues(parsed.error.issues, input),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const invalidPaths = (["path", "output_directory"] as const).filter(
|
||||||
|
(field) => !isAbsolute(parsed.data[field]),
|
||||||
|
);
|
||||||
|
if (invalidPaths.length > 0)
|
||||||
|
return err(
|
||||||
|
new AnalysisInputError(
|
||||||
|
OPERATION,
|
||||||
|
undefined,
|
||||||
|
invalidPaths.map((field) => ({
|
||||||
|
path: [field],
|
||||||
|
reason: "invalid_format",
|
||||||
|
message: `${field} must be an absolute filesystem path on this host.`,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const execution = await this.provider.recover(parsed.data, options);
|
||||||
|
if (!execution.ok) return execution;
|
||||||
|
const result = javascriptRecoveryResultSchema.safeParse(
|
||||||
|
execution.value.result,
|
||||||
|
);
|
||||||
|
if (!result.success || execution.value.subject === null)
|
||||||
|
return err(
|
||||||
|
new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
"Recovery did not return a valid result bound to the original source",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
result.data.source.path !== parsed.data.path ||
|
||||||
|
execution.value.subject.path !== result.data.source.path ||
|
||||||
|
execution.value.subject.sha256 !== result.data.source.sha256 ||
|
||||||
|
execution.value.provider.id !== result.data.engine.id ||
|
||||||
|
execution.value.provider.version !== result.data.engine.version
|
||||||
|
)
|
||||||
|
return err(
|
||||||
|
new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
"Recovery source or engine identity disagrees with its Evidence binding",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return ok(
|
||||||
|
createEvidence(execution.value.subject, execution.value.provider, {
|
||||||
|
operation: OPERATION,
|
||||||
|
parameters: jsonObjectSchema.parse(parsed.data),
|
||||||
|
result: result.data,
|
||||||
|
rawResult: execution.value.rawResult,
|
||||||
|
locations: execution.value.locations,
|
||||||
|
limitations: execution.value.limitations,
|
||||||
|
confidence: "derived",
|
||||||
|
authority: "shipped-artifact",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
|||||||
import { registerProcessCommands } from "./cli/processCommands.js";
|
import { registerProcessCommands } from "./cli/processCommands.js";
|
||||||
import { registerBrowserCommands } from "./cli/browserCommands.js";
|
import { registerBrowserCommands } from "./cli/browserCommands.js";
|
||||||
import { registerWebScriptCommands } from "./cli/webScriptCommands.js";
|
import { registerWebScriptCommands } from "./cli/webScriptCommands.js";
|
||||||
|
import { registerJavaScriptRecoveryCommands } from "./cli/javascriptRecoveryCommands.js";
|
||||||
import { registerAdvancedBrowserCommands } from "./cli/browserAdvancedCommands.js";
|
import { registerAdvancedBrowserCommands } from "./cli/browserAdvancedCommands.js";
|
||||||
import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js";
|
import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js";
|
||||||
import { registerElectronCommands } from "./cli/electronCommands.js";
|
import { registerElectronCommands } from "./cli/electronCommands.js";
|
||||||
@@ -56,6 +57,7 @@ export const createCli = (
|
|||||||
registerProcessCommands(cli, logger, environment);
|
registerProcessCommands(cli, logger, environment);
|
||||||
registerBrowserCommands(cli, logger);
|
registerBrowserCommands(cli, logger);
|
||||||
registerWebScriptCommands(cli, logger);
|
registerWebScriptCommands(cli, logger);
|
||||||
|
registerJavaScriptRecoveryCommands(cli, logger, environment);
|
||||||
registerAdvancedBrowserCommands(cli, logger);
|
registerAdvancedBrowserCommands(cli, logger);
|
||||||
registerBrowserScenarioCommands(cli, logger);
|
registerBrowserScenarioCommands(cli, logger);
|
||||||
registerElectronCommands(cli, logger);
|
registerElectronCommands(cli, logger);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { z } from "incur";
|
||||||
|
import { JavaScriptRecoveryService } from "../application/JavaScriptRecoveryService.js";
|
||||||
|
import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js";
|
||||||
|
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||||
|
import { logCliCommand } from "../cliLogging.js";
|
||||||
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
import type { CliInstance } from "./types.js";
|
||||||
|
import { withCommandCancellation } from "./commandCancellation.js";
|
||||||
|
|
||||||
|
/** Share source recovery with MCP while keeping CLI spelling at this boundary. */
|
||||||
|
export const registerJavaScriptRecoveryCommands = (
|
||||||
|
cli: CliInstance,
|
||||||
|
logger: Logger,
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
): void => {
|
||||||
|
const service = new JavaScriptRecoveryService(
|
||||||
|
createJavaScriptRecoveryProvider(environment),
|
||||||
|
);
|
||||||
|
cli.command(CLI_COMMANDS.recoverJavaScriptSources, {
|
||||||
|
description:
|
||||||
|
"Recover readable JavaScript modules with source provenance and verified files",
|
||||||
|
args: z.object({
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.describe("Absolute UTF-8 JavaScript input; never executed"),
|
||||||
|
outputDirectory: z.string().describe("Absolute absent output directory"),
|
||||||
|
}),
|
||||||
|
options: z.object({
|
||||||
|
extractionMode: z
|
||||||
|
.enum(["structural", "heuristic", "inspection"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Structural boundaries, heuristic fallback, or inspection-only regions",
|
||||||
|
),
|
||||||
|
rewriteLevel: z
|
||||||
|
.enum(["minimal", "standard", "aggressive"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Upstream syntax recovery level; runtime equivalence remains unknown",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
run: ({ args, options }) =>
|
||||||
|
withCommandCancellation((signal) =>
|
||||||
|
logCliCommand(
|
||||||
|
logger,
|
||||||
|
CLI_COMMANDS.recoverJavaScriptSources,
|
||||||
|
async () => {
|
||||||
|
const result = await service.recover(
|
||||||
|
{
|
||||||
|
path: args.path,
|
||||||
|
output_directory: args.outputDirectory,
|
||||||
|
...(options.extractionMode === undefined
|
||||||
|
? {}
|
||||||
|
: { extraction_mode: options.extractionMode }),
|
||||||
|
...(options.rewriteLevel === undefined
|
||||||
|
? {}
|
||||||
|
: { rewrite_level: options.rewriteLevel }),
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return result.ok
|
||||||
|
? result.value
|
||||||
|
: projectAnalysisError(result.error);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -61,6 +61,7 @@ export const CLI_COMMANDS = Object.freeze({
|
|||||||
inspectWebPage: "inspect-web-page",
|
inspectWebPage: "inspect-web-page",
|
||||||
analyzeWebBundle: "analyze-web-bundle",
|
analyzeWebBundle: "analyze-web-bundle",
|
||||||
exportWebScripts: "export-web-scripts",
|
exportWebScripts: "export-web-scripts",
|
||||||
|
recoverJavaScriptSources: "recover-javascript-sources",
|
||||||
observeWebSession: "observe-web-session",
|
observeWebSession: "observe-web-session",
|
||||||
discoverWebMcpTools: "discover-webmcp-tools",
|
discoverWebMcpTools: "discover-webmcp-tools",
|
||||||
compareWebCaptures: "compare-web-captures",
|
compareWebCaptures: "compare-web-captures",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { JavaScriptRecoveryPort } from "../application/JavaScriptRecoveryPort.js";
|
||||||
|
import { WakaruProvider } from "../javascript/recovery/WakaruProvider.js";
|
||||||
|
import type { WakaruLauncher } from "../javascript/recovery/WakaruCommand.js";
|
||||||
|
|
||||||
|
/** Construct optional recovery without acquiring tools, processes or workspaces. */
|
||||||
|
export const createJavaScriptRecoveryProvider = (
|
||||||
|
environment: Readonly<Record<string, string | undefined>> = process.env,
|
||||||
|
launcher?: WakaruLauncher,
|
||||||
|
): JavaScriptRecoveryPort => new WakaruProvider(environment, launcher);
|
||||||
@@ -16,6 +16,7 @@ import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
|||||||
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
||||||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||||
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||||
|
import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascriptRecoveryToolContracts.js";
|
||||||
import { TOOL_EFFECTS } from "./toolEffects.js";
|
import { TOOL_EFFECTS } from "./toolEffects.js";
|
||||||
import { toolContract } from "./toolContracts.js";
|
import { toolContract } from "./toolContracts.js";
|
||||||
const contractJsonSchema = (schema: z.ZodType) =>
|
const contractJsonSchema = (schema: z.ZodType) =>
|
||||||
@@ -77,6 +78,7 @@ describe("tool contract surface", () => {
|
|||||||
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||||
...APPLICATION_TOOL_CONTRACTS,
|
...APPLICATION_TOOL_CONTRACTS,
|
||||||
...WEB_SCRIPT_TOOL_CONTRACTS,
|
...WEB_SCRIPT_TOOL_CONTRACTS,
|
||||||
|
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||||
...SESSION_TOOL_CONTRACTS,
|
...SESSION_TOOL_CONTRACTS,
|
||||||
].map(({ name }) => name);
|
].map(({ name }) => name);
|
||||||
expect(Object.keys(TOOL_EFFECTS).sort()).toEqual(names.sort());
|
expect(Object.keys(TOOL_EFFECTS).sort()).toEqual(names.sort());
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
javascriptRecoveryInputSchema,
|
||||||
|
javascriptRecoveryResultSchema,
|
||||||
|
} from "../domain/javascriptRecovery.js";
|
||||||
|
import { toolContractMetadata } from "./toolEffects.js";
|
||||||
|
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||||
|
import type { ToolContract } from "./toolContractTypes.js";
|
||||||
|
|
||||||
|
/** Derived script artifacts from optional upstream recovery adapters. */
|
||||||
|
export const JAVASCRIPT_RECOVERY_TOOL_CONTRACTS = [
|
||||||
|
{
|
||||||
|
name: "recover_javascript_sources",
|
||||||
|
...toolContractMetadata("recover_javascript_sources"),
|
||||||
|
kind: "application",
|
||||||
|
description:
|
||||||
|
"Recover readable modules from one local UTF-8 JavaScript script/bundle into an absent directory, using caller-supplied Wakaru 1.13.0 on Linux x64. Returns original/derived digests, extraction byte ranges, emitted source maps, warnings and analysis_input inline. Structural splitting may fall back to one module; original names and runtime equivalence remain unknown. No application code execution. Requires util-linux prlimit; one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB input, 128 MiB output, 10000 entries and 8 MiB per diagnostic/report stream.",
|
||||||
|
inputSchema: javascriptRecoveryInputSchema,
|
||||||
|
outputSchema: evidenceResultOf(javascriptRecoveryResultSchema),
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
title: "Recover a captured production bundle",
|
||||||
|
input: {
|
||||||
|
path: "/tmp/web-scripts/app.js",
|
||||||
|
output_directory: "/tmp/recovered-app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const satisfies readonly ToolContract[];
|
||||||
@@ -14,6 +14,8 @@ import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.
|
|||||||
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
||||||
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
||||||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||||
|
import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascriptRecoveryToolContracts.js";
|
||||||
|
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||||
|
|
||||||
const GROUPS = {
|
const GROUPS = {
|
||||||
official: OFFICIAL_TOOL_CONTRACTS,
|
official: OFFICIAL_TOOL_CONTRACTS,
|
||||||
@@ -28,6 +30,8 @@ const GROUPS = {
|
|||||||
electron: ELECTRON_TOOL_CONTRACTS,
|
electron: ELECTRON_TOOL_CONTRACTS,
|
||||||
javascript_runtime_observation: JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
javascript_runtime_observation: JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||||
application: APPLICATION_TOOL_CONTRACTS,
|
application: APPLICATION_TOOL_CONTRACTS,
|
||||||
|
javascript_recovery: JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||||
|
web_script_export: WEB_SCRIPT_TOOL_CONTRACTS,
|
||||||
session: SESSION_TOOL_CONTRACTS,
|
session: SESSION_TOOL_CONTRACTS,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
|||||||
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
|
||||||
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||||
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
|
||||||
|
import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascriptRecoveryToolContracts.js";
|
||||||
import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
||||||
import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
|
import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
|
||||||
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
||||||
@@ -30,6 +31,7 @@ export const TOOL_CONTRACTS = [
|
|||||||
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
|
||||||
...APPLICATION_TOOL_CONTRACTS,
|
...APPLICATION_TOOL_CONTRACTS,
|
||||||
...WEB_SCRIPT_TOOL_CONTRACTS,
|
...WEB_SCRIPT_TOOL_CONTRACTS,
|
||||||
|
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||||
...SESSION_TOOL_CONTRACTS,
|
...SESSION_TOOL_CONTRACTS,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,12 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
|||||||
idempotent: false,
|
idempotent: false,
|
||||||
}),
|
}),
|
||||||
reconcile_javascript_runtime: evidence,
|
reconcile_javascript_runtime: evidence,
|
||||||
|
recover_javascript_sources: effects({
|
||||||
|
mutatesSession: true,
|
||||||
|
writesFilesystem: true,
|
||||||
|
launchesProcess: true,
|
||||||
|
idempotent: false,
|
||||||
|
}),
|
||||||
trace_application_feature: evidence,
|
trace_application_feature: evidence,
|
||||||
trace_javascript_semantics: evidence,
|
trace_javascript_semantics: evidence,
|
||||||
compare_application_versions: evidence,
|
compare_application_versions: evidence,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { javascriptRecoveryInputSchema } from "./javascriptRecovery.js";
|
||||||
|
|
||||||
|
describe("JavaScript recovery requests", () => {
|
||||||
|
it("defaults omitted transform metadata without requiring engine confirmations", () => {
|
||||||
|
expect(
|
||||||
|
javascriptRecoveryInputSchema.parse({
|
||||||
|
path: "/tmp/bundle.js",
|
||||||
|
output_directory: "/tmp/recovered",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
path: "/tmp/bundle.js",
|
||||||
|
output_directory: "/tmp/recovered",
|
||||||
|
extraction_mode: "structural",
|
||||||
|
rewrite_level: "standard",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{ path: "", output_directory: "/tmp/recovered" },
|
||||||
|
{ path: "/tmp/bundle.js" },
|
||||||
|
{
|
||||||
|
path: "/tmp/bundle.js",
|
||||||
|
output_directory: "/tmp/recovered",
|
||||||
|
approve: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tmp/bundle.js",
|
||||||
|
output_directory: "/tmp/recovered",
|
||||||
|
extraction_mode: "invented",
|
||||||
|
},
|
||||||
|
])("rejects malformed input %j", (input) => {
|
||||||
|
expect(javascriptRecoveryInputSchema.safeParse(input).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { digestSchema } from "./digests.js";
|
||||||
|
import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js";
|
||||||
|
|
||||||
|
/** Recover derived readable modules from one explicitly selected local script. */
|
||||||
|
export const javascriptRecoveryInputSchema = z.strictObject({
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("Absolute path to a UTF-8 JavaScript file; never executed"),
|
||||||
|
output_directory: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("Absolute absent output directory; parent must exist"),
|
||||||
|
extraction_mode: z
|
||||||
|
.enum(["structural", "heuristic", "inspection"])
|
||||||
|
.default("structural")
|
||||||
|
.describe(
|
||||||
|
"Structural module boundaries, heuristic fallback, or finer inspection-only regions",
|
||||||
|
),
|
||||||
|
rewrite_level: z
|
||||||
|
.enum(["minimal", "standard", "aggressive"])
|
||||||
|
.default("standard")
|
||||||
|
.describe(
|
||||||
|
"Upstream syntax recovery level; runtime equivalence remains unverified",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileSchema = z.strictObject({
|
||||||
|
path: z.string().min(1),
|
||||||
|
sha256: digestSchema,
|
||||||
|
bytes: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rangeSchema = z.strictObject({
|
||||||
|
start: z.number().int().nonnegative(),
|
||||||
|
end: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Complete published artifacts and exact producer provenance for source recovery. */
|
||||||
|
export const javascriptRecoveryResultSchema = z.strictObject({
|
||||||
|
source: fileSchema.extend({
|
||||||
|
snapshot_path: z.string().min(1),
|
||||||
|
published_copy: fileSchema,
|
||||||
|
}),
|
||||||
|
engine: z.strictObject({
|
||||||
|
id: z.string().min(1),
|
||||||
|
version: z.string().min(1),
|
||||||
|
executable: fileSchema,
|
||||||
|
audited_source_revision: z.string().min(1),
|
||||||
|
executed_source_revision: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
options: z.strictObject({
|
||||||
|
extraction_mode:
|
||||||
|
javascriptRecoveryInputSchema.shape.extraction_mode.removeDefault(),
|
||||||
|
rewrite_level:
|
||||||
|
javascriptRecoveryInputSchema.shape.rewrite_level.removeDefault(),
|
||||||
|
}),
|
||||||
|
status: z.enum(["complete", "partial"]),
|
||||||
|
detected_formats: z.array(z.string()),
|
||||||
|
reported_safety: z.string(),
|
||||||
|
reported_strategy: z.string(),
|
||||||
|
reported_format: z.string(),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
failed: z.number().int().nonnegative(),
|
||||||
|
modules: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
reported_filename: z.string().min(1),
|
||||||
|
reported_status: z.string().min(1),
|
||||||
|
artifact: fileSchema,
|
||||||
|
source_map: fileSchema.nullable(),
|
||||||
|
provenance: z.strictObject({
|
||||||
|
reported_input: z.string(),
|
||||||
|
original_path: z.string(),
|
||||||
|
original_sha256: digestSchema,
|
||||||
|
extraction: z.string(),
|
||||||
|
byte_ranges: z.array(rangeSchema),
|
||||||
|
context_byte_ranges: z.array(rangeSchema),
|
||||||
|
range_semantics: z.literal("half-open-utf8-bytes"),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
warnings: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
filename: z.string(),
|
||||||
|
kind: z.string(),
|
||||||
|
is_error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
report: fileSchema,
|
||||||
|
provenance: fileSchema,
|
||||||
|
manifest: fileSchema,
|
||||||
|
analysis_input: z
|
||||||
|
.strictObject({
|
||||||
|
input_path: analyzeJavaScriptApplicationInputSchema.shape.input_path,
|
||||||
|
format: z.literal("directory"),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
runtime_equivalence: z.literal("unknown"),
|
||||||
|
limitations: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type JavaScriptRecoveryInput = z.output<
|
||||||
|
typeof javascriptRecoveryInputSchema
|
||||||
|
>;
|
||||||
|
export type JavaScriptRecoveryResult = z.output<
|
||||||
|
typeof javascriptRecoveryResultSchema
|
||||||
|
>;
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,201 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { lstat, open, readdir, writeFile } from "node:fs/promises";
|
||||||
|
import { isAbsolute, join } from "node:path";
|
||||||
|
import { TextDecoder } from "node:util";
|
||||||
|
import {
|
||||||
|
AnalysisCancelledError,
|
||||||
|
AnalysisInputError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
AnalysisTimeoutError,
|
||||||
|
} from "../../domain/analysisErrorCore.js";
|
||||||
|
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
|
||||||
|
|
||||||
|
const OPERATION = "recover_javascript_sources";
|
||||||
|
|
||||||
|
/** Fingerprint exact selected or derived bytes. */
|
||||||
|
export const recoveryDigest = (bytes: Uint8Array): string =>
|
||||||
|
createHash("sha256").update(bytes).digest("hex");
|
||||||
|
|
||||||
|
/** Read a stable regular file without retaining bytes during engine fingerprinting. */
|
||||||
|
const scanRecoveryFile = async (
|
||||||
|
path: string,
|
||||||
|
maximum: number,
|
||||||
|
consume: (chunk: Buffer) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
try {
|
||||||
|
const before = await handle.stat();
|
||||||
|
if (!before.isFile() || before.size > maximum)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`Expected a regular file no larger than ${String(maximum)} bytes: ${path}`,
|
||||||
|
);
|
||||||
|
let bytes = 0;
|
||||||
|
for await (const raw of handle.createReadStream({ autoClose: false })) {
|
||||||
|
checkRecoveryCancellation(signal);
|
||||||
|
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||||
|
bytes += chunk.length;
|
||||||
|
if (bytes > maximum)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`File exceeds ${String(maximum)} bytes: ${path}`,
|
||||||
|
);
|
||||||
|
hash.update(chunk);
|
||||||
|
consume(chunk);
|
||||||
|
}
|
||||||
|
const after = await handle.stat();
|
||||||
|
const current = await lstat(path);
|
||||||
|
if (
|
||||||
|
before.dev !== current.dev ||
|
||||||
|
before.ino !== current.ino ||
|
||||||
|
before.size !== bytes ||
|
||||||
|
before.mtimeMs !== after.mtimeMs ||
|
||||||
|
before.ctimeMs !== after.ctimeMs ||
|
||||||
|
before.size !== after.size
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`File identity changed while reading: ${path}`,
|
||||||
|
);
|
||||||
|
return { sha256: hash.digest("hex"), bytes };
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fingerprint a regular file incrementally with bounded memory. */
|
||||||
|
export const fingerprintRecoveryFile = async (
|
||||||
|
path: string,
|
||||||
|
maximum: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => scanRecoveryFile(path, maximum, () => {}, signal);
|
||||||
|
|
||||||
|
/** Read only a regular file through one handle with a bounded, stable identity. */
|
||||||
|
export const readRecoveryFile = async (
|
||||||
|
path: string,
|
||||||
|
maximum: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Buffer> => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const result = await scanRecoveryFile(
|
||||||
|
path,
|
||||||
|
maximum,
|
||||||
|
(chunk) => chunks.push(chunk),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return Buffer.concat(chunks, result.bytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Snapshot one caller-selected UTF-8 script before invoking the engine. */
|
||||||
|
export const snapshotRecoveryInput = async (
|
||||||
|
path: string,
|
||||||
|
snapshotPath: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
if (!isAbsolute(path))
|
||||||
|
throw recoveryInputFailure(
|
||||||
|
"path",
|
||||||
|
"Select an absolute JavaScript file path",
|
||||||
|
);
|
||||||
|
let bytes: Buffer;
|
||||||
|
try {
|
||||||
|
bytes = await readRecoveryFile(path, RECOVERY_LIMITS.inputBytes, signal);
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (cause instanceof AnalysisCancelledError) throw cause;
|
||||||
|
throw recoveryInputFailure(
|
||||||
|
"path",
|
||||||
|
`Cannot snapshot UTF-8 JavaScript input ${path}: ${recoveryFailureMessage(cause)}`,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
snapshot_path: snapshotPath,
|
||||||
|
sha256: recoveryDigest(bytes),
|
||||||
|
bytes: bytes.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Inventory staging without following symlinks or publishing special files. */
|
||||||
|
export const inventoryRecoveryFiles = async (
|
||||||
|
root: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<
|
||||||
|
readonly { relativePath: string; path: string; bytes: number }[]
|
||||||
|
> => {
|
||||||
|
const files: { relativePath: string; path: string; bytes: number }[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
let entries = 0;
|
||||||
|
const visit = async (directory: string, prefix: string): Promise<void> => {
|
||||||
|
const metadata = await lstat(directory);
|
||||||
|
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`Unsafe staging directory: ${directory}`,
|
||||||
|
);
|
||||||
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||||
|
checkRecoveryCancellation(signal);
|
||||||
|
entries++;
|
||||||
|
const relativePath =
|
||||||
|
prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
const info = await lstat(path);
|
||||||
|
if (entries > RECOVERY_LIMITS.outputEntries)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
"Recovery staging exceeds the 10000-entry resource limit",
|
||||||
|
);
|
||||||
|
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile()))
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`Recovery produced a symlink or special file: ${relativePath}`,
|
||||||
|
);
|
||||||
|
if (info.isDirectory()) await visit(path, relativePath);
|
||||||
|
else {
|
||||||
|
bytes += info.size;
|
||||||
|
if (bytes > RECOVERY_LIMITS.outputBytes)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
"Recovery staging exceeds the 128 MiB resource limit",
|
||||||
|
);
|
||||||
|
files.push({ relativePath, path, bytes: info.size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await visit(root, "");
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Preserve actionable caller constraints in the ordinary error algebra. */
|
||||||
|
export const recoveryInputFailure = (
|
||||||
|
field: string,
|
||||||
|
message: string,
|
||||||
|
cause?: unknown,
|
||||||
|
): AnalysisInputError =>
|
||||||
|
new AnalysisInputError(OPERATION, { cause }, [
|
||||||
|
{ path: [field], reason: "invalid_value", message },
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Convert one caught boundary cause into diagnostic text without masking its type. */
|
||||||
|
export const recoveryFailureMessage = (cause: unknown): string =>
|
||||||
|
cause instanceof Error ? cause.message : String(cause);
|
||||||
|
|
||||||
|
/** Cancellation remains distinct from malformed source or provider output. */
|
||||||
|
export const checkRecoveryCancellation = (signal?: AbortSignal): void => {
|
||||||
|
if (signal?.aborted === true) throw new AnalysisCancelledError(OPERATION);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keep both tool invocations within one operation's execution budget. */
|
||||||
|
export const checkRecoveryDeadline = (
|
||||||
|
deadline: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): void => {
|
||||||
|
checkRecoveryCancellation(signal);
|
||||||
|
if (Date.now() >= deadline)
|
||||||
|
throw new AnalysisTimeoutError(OPERATION, RECOVERY_LIMITS.timeoutMs);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { runWakaruCommand } from "./WakaruCommand.js";
|
||||||
|
|
||||||
|
it("rejects an exhausted shared execution budget before launching the next command", async () => {
|
||||||
|
await expect(
|
||||||
|
runWakaruCommand({
|
||||||
|
command: "/selected/wakaru",
|
||||||
|
limiter: "/usr/bin/prlimit",
|
||||||
|
arguments: ["--version"],
|
||||||
|
cwd: "/tmp/unused",
|
||||||
|
environment: {},
|
||||||
|
deadline: Date.now() - 1,
|
||||||
|
launcher: async () => {
|
||||||
|
throw new Error("An expired budget must not launch");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
_tag: "AnalysisTimeoutError",
|
||||||
|
operation: "recover_javascript_sources",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves caller cancellation before acquiring an expired execution budget", async () => {
|
||||||
|
await expect(
|
||||||
|
runWakaruCommand({
|
||||||
|
command: "/selected/wakaru",
|
||||||
|
limiter: "/usr/bin/prlimit",
|
||||||
|
arguments: ["--version"],
|
||||||
|
cwd: "/tmp/unused",
|
||||||
|
environment: {},
|
||||||
|
deadline: Date.now() - 1,
|
||||||
|
signal: AbortSignal.abort(),
|
||||||
|
launcher: async () => {
|
||||||
|
throw new Error("A cancelled request must not launch");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { access, realpath } from "node:fs/promises";
|
||||||
|
import { isAbsolute } from "node:path";
|
||||||
|
import {
|
||||||
|
AnalysisCancelledError,
|
||||||
|
AnalysisCapabilityUnavailableError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
} from "../../domain/analysisErrorCore.js";
|
||||||
|
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||||
|
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||||
|
import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js";
|
||||||
|
import {
|
||||||
|
ProviderProcessSupervisor,
|
||||||
|
spawnOwnedProviderProcess,
|
||||||
|
type OwnedProviderProcessSpawnOptions,
|
||||||
|
type SpawnedOwnedProviderProcess,
|
||||||
|
} from "../../process/ProviderProcess.js";
|
||||||
|
import {
|
||||||
|
checkRecoveryDeadline,
|
||||||
|
inventoryRecoveryFiles,
|
||||||
|
fingerprintRecoveryFile,
|
||||||
|
recoveryFailureMessage,
|
||||||
|
} from "./RecoveryFiles.js";
|
||||||
|
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
|
||||||
|
|
||||||
|
const OPERATION = "recover_javascript_sources";
|
||||||
|
|
||||||
|
/** Inject the owned process launcher while retaining production protocol parsing. */
|
||||||
|
export type WakaruLauncher = (
|
||||||
|
options: OwnedProviderProcessSpawnOptions,
|
||||||
|
) => Promise<SpawnedOwnedProviderProcess>;
|
||||||
|
|
||||||
|
/** Resolve and fingerprint explicitly configured tools; acquire nothing at startup. */
|
||||||
|
export const resolveWakaruCommand = async (
|
||||||
|
environment: Readonly<Record<string, string | undefined>>,
|
||||||
|
) => {
|
||||||
|
const configured = environment.REA_WAKARU_COMMAND;
|
||||||
|
if (
|
||||||
|
process.platform !== "linux" ||
|
||||||
|
process.arch !== "x64" ||
|
||||||
|
configured === undefined ||
|
||||||
|
!isAbsolute(configured)
|
||||||
|
)
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
"wakaru",
|
||||||
|
OPERATION,
|
||||||
|
"Provide an absolute REA_WAKARU_COMMAND pointing to Wakaru 1.13.0 on Linux x64; no tool is installed by REA",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const command = await realpath(configured);
|
||||||
|
await access(command, constants.R_OK | constants.X_OK);
|
||||||
|
const fingerprint = await fingerprintRecoveryFile(
|
||||||
|
command,
|
||||||
|
RECOVERY_LIMITS.outputBytes,
|
||||||
|
);
|
||||||
|
const limiter =
|
||||||
|
environment.REA_JAVASCRIPT_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
||||||
|
if (!isAbsolute(limiter))
|
||||||
|
throw new TypeError("REA_JAVASCRIPT_PRLIMIT_COMMAND must be absolute");
|
||||||
|
await access(limiter, constants.X_OK);
|
||||||
|
return { command, limiter, ...fingerprint };
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
"wakaru",
|
||||||
|
OPERATION,
|
||||||
|
`Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type WakaruCommandContext = {
|
||||||
|
command: string;
|
||||||
|
limiter: string;
|
||||||
|
arguments: readonly string[];
|
||||||
|
cwd: string;
|
||||||
|
deadline: number;
|
||||||
|
environment: Readonly<Record<string, string | undefined>>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
launcher?: WakaruLauncher;
|
||||||
|
staging?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Run one bounded command and verify cleanup before consuming its output. */
|
||||||
|
export const runWakaruCommand = async (context: WakaruCommandContext) => {
|
||||||
|
checkRecoveryDeadline(context.deadline, context.signal);
|
||||||
|
const runId = `rea-wakaru-${randomUUID()}`;
|
||||||
|
const spawned = await (context.launcher ?? spawnOwnedProviderProcess)({
|
||||||
|
command: context.limiter,
|
||||||
|
arguments: [
|
||||||
|
`--as=${String(RECOVERY_LIMITS.addressSpaceBytes)}`,
|
||||||
|
`--fsize=${String(RECOVERY_LIMITS.outputBytes)}`,
|
||||||
|
"--core=0",
|
||||||
|
"--cpu=120",
|
||||||
|
"--",
|
||||||
|
context.command,
|
||||||
|
...context.arguments,
|
||||||
|
],
|
||||||
|
expectedCommand: null,
|
||||||
|
runId,
|
||||||
|
cwd: context.cwd,
|
||||||
|
hostEnvironment: context.environment,
|
||||||
|
env: { TMPDIR: context.cwd, RAYON_NUM_THREADS: "1", NO_COLOR: "1" },
|
||||||
|
}).catch((cause: unknown) => {
|
||||||
|
throw new ProviderAdapterError("wakaru", OPERATION, {
|
||||||
|
cause,
|
||||||
|
diagnostics: {
|
||||||
|
reason: "Failed to launch configured Wakaru through util-linux prlimit",
|
||||||
|
command: context.command,
|
||||||
|
detail: recoveryFailureMessage(cause),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let oversized = false;
|
||||||
|
const supervisor = new ProviderProcessSupervisor(
|
||||||
|
{
|
||||||
|
...spawned,
|
||||||
|
ownsProcessLifetime: true,
|
||||||
|
cleanup:
|
||||||
|
spawned.cleanup ?? (() => cleanupOwnedProcessGroup(spawned.ownership)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onDiagnostic: (event) => {
|
||||||
|
if (
|
||||||
|
event.type === "output" &&
|
||||||
|
event.totalBytes > RECOVERY_LIMITS.reportBytes
|
||||||
|
)
|
||||||
|
oversized = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
while (!(await supervisor.waitForExit(100))) {
|
||||||
|
checkRecoveryDeadline(context.deadline, context.signal);
|
||||||
|
if (oversized)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
"Wakaru diagnostic stream exceeds 8 MiB",
|
||||||
|
);
|
||||||
|
if (context.staging !== undefined)
|
||||||
|
await inventoryRecoveryFiles(context.staging, context.signal);
|
||||||
|
}
|
||||||
|
checkRecoveryDeadline(context.deadline, context.signal);
|
||||||
|
if (oversized)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
"Wakaru diagnostic stream exceeds 8 MiB",
|
||||||
|
);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
failure = cause;
|
||||||
|
}
|
||||||
|
const stopped = await supervisor.stop();
|
||||||
|
const snapshot = supervisor.snapshot();
|
||||||
|
supervisor.dispose();
|
||||||
|
if (stopped.status === "incomplete")
|
||||||
|
throw new ProviderCleanupError("wakaru", [runId, context.cwd], {
|
||||||
|
reason: stopped.reason,
|
||||||
|
previous_error: failure instanceof Error ? failure.message : null,
|
||||||
|
exit_code: snapshot.exitCode ?? null,
|
||||||
|
signal: snapshot.signal ?? null,
|
||||||
|
});
|
||||||
|
if (failure !== undefined) throw failure;
|
||||||
|
if (context.signal?.aborted === true)
|
||||||
|
throw new AnalysisCancelledError(OPERATION);
|
||||||
|
return {
|
||||||
|
exit_code: snapshot.exitCode ?? null,
|
||||||
|
signal: snapshot.signal ?? null,
|
||||||
|
stdout: snapshot.stdout.text,
|
||||||
|
stderr: snapshot.stderr.text,
|
||||||
|
command: context.command,
|
||||||
|
arguments: [...context.arguments],
|
||||||
|
resource_limiter: context.limiter,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
createAnalysisExecution,
|
||||||
|
type ExecutionOptions,
|
||||||
|
} from "../../application/AnalysisProvider.js";
|
||||||
|
import type { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
|
||||||
|
import {
|
||||||
|
AnalysisCapabilityUnavailableError,
|
||||||
|
AnalysisOutputError,
|
||||||
|
} from "../../domain/analysisErrorCore.js";
|
||||||
|
import type { JavaScriptRecoveryInput } from "../../domain/javascriptRecovery.js";
|
||||||
|
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||||
|
import {
|
||||||
|
fingerprintRecoveryFile,
|
||||||
|
inventoryRecoveryFiles,
|
||||||
|
readRecoveryFile,
|
||||||
|
recoveryDigest,
|
||||||
|
type snapshotRecoveryInput,
|
||||||
|
} from "./RecoveryFiles.js";
|
||||||
|
import {
|
||||||
|
runWakaruCommand,
|
||||||
|
type resolveWakaruCommand,
|
||||||
|
type WakaruLauncher,
|
||||||
|
} from "./WakaruCommand.js";
|
||||||
|
import { parseWakaruReports } from "./WakaruReport.js";
|
||||||
|
import { publishWakaruArtifacts } from "./WakaruPublication.js";
|
||||||
|
import {
|
||||||
|
RECOVERY_LIMITS,
|
||||||
|
WAKARU_RELEASE,
|
||||||
|
WAKARU_PROVIDER_IDENTITY,
|
||||||
|
} from "./WakaruRelease.js";
|
||||||
|
|
||||||
|
const OPERATION = "recover_javascript_sources";
|
||||||
|
const MODES = {
|
||||||
|
structural: "strict",
|
||||||
|
heuristic: "auto",
|
||||||
|
inspection: "inspect",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type RecoveryWorkspace = {
|
||||||
|
root: string;
|
||||||
|
tree: SafeOutputTree;
|
||||||
|
input: JavaScriptRecoveryInput;
|
||||||
|
source: Awaited<ReturnType<typeof snapshotRecoveryInput>>;
|
||||||
|
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||||
|
environment: Readonly<Record<string, string | undefined>>;
|
||||||
|
launcher: WakaruLauncher | undefined;
|
||||||
|
options: ExecutionOptions | undefined;
|
||||||
|
deadline: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Consume the pinned producer representation in a private workspace. */
|
||||||
|
const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||||
|
const { root, input, source, engine, environment, launcher, options } =
|
||||||
|
context;
|
||||||
|
const staging = join(root, "modules");
|
||||||
|
const commandContext = {
|
||||||
|
...engine,
|
||||||
|
cwd: root,
|
||||||
|
deadline: context.deadline,
|
||||||
|
environment: environment,
|
||||||
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||||
|
...(launcher === undefined ? {} : { launcher: launcher }),
|
||||||
|
};
|
||||||
|
const version = await runWakaruCommand({
|
||||||
|
...commandContext,
|
||||||
|
arguments: ["--version"],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
version.exit_code !== 0 ||
|
||||||
|
version.stdout.trim() !== `wakaru ${WAKARU_RELEASE.version}`
|
||||||
|
)
|
||||||
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
"wakaru",
|
||||||
|
OPERATION,
|
||||||
|
`This adapter requires Wakaru ${WAKARU_RELEASE.version}; reported version: ${version.stdout.trim()}; exit: ${String(version.exit_code)}; stderr: ${version.stderr}`,
|
||||||
|
);
|
||||||
|
const run = await runWakaruCommand({
|
||||||
|
...commandContext,
|
||||||
|
staging,
|
||||||
|
arguments: [
|
||||||
|
source.snapshot_path,
|
||||||
|
`--unpack=${MODES[input.extraction_mode]}`,
|
||||||
|
`--level=${input.rewrite_level}`,
|
||||||
|
"--provenance",
|
||||||
|
"--emit-source-map",
|
||||||
|
"--json",
|
||||||
|
"-o",
|
||||||
|
staging,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const files = await inventoryRecoveryFiles(staging, options?.signal);
|
||||||
|
const snapshot = await readRecoveryFile(
|
||||||
|
source.snapshot_path,
|
||||||
|
RECOVERY_LIMITS.inputBytes,
|
||||||
|
options?.signal,
|
||||||
|
);
|
||||||
|
const parsed = await parseWakaruReports({
|
||||||
|
execution: run,
|
||||||
|
provenancePath: join(staging, "provenance.json"),
|
||||||
|
snapshotPath: source.snapshot_path,
|
||||||
|
sourceBytes: snapshot,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
return { version, run, parsed };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Publish validated recovery and retain exact source and engine bindings. */
|
||||||
|
export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||||
|
const { root, tree, input, source, engine, options } = context;
|
||||||
|
const staging = join(root, "modules");
|
||||||
|
const { version, run, parsed } = await executeWakaruRecovery(context);
|
||||||
|
const result = await publishWakaruArtifacts({
|
||||||
|
tree,
|
||||||
|
staging,
|
||||||
|
input,
|
||||||
|
source,
|
||||||
|
engine,
|
||||||
|
parsed,
|
||||||
|
execution: run,
|
||||||
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
recoveryDigest(
|
||||||
|
await readRecoveryFile(
|
||||||
|
input.path,
|
||||||
|
RECOVERY_LIMITS.inputBytes,
|
||||||
|
options?.signal,
|
||||||
|
),
|
||||||
|
) !== source.sha256
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`Original input changed during recovery: ${input.path}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await fingerprintRecoveryFile(
|
||||||
|
engine.command,
|
||||||
|
RECOVERY_LIMITS.outputBytes,
|
||||||
|
options?.signal,
|
||||||
|
)
|
||||||
|
).sha256 !== engine.sha256
|
||||||
|
)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
"Configured engine bytes changed during recovery",
|
||||||
|
);
|
||||||
|
return createAnalysisExecution(result, WAKARU_PROVIDER_IDENTITY, {
|
||||||
|
rawResult: jsonValueSchema.parse({
|
||||||
|
version,
|
||||||
|
execution: run,
|
||||||
|
report: parsed.report,
|
||||||
|
provenance: parsed.provenance,
|
||||||
|
}),
|
||||||
|
subject: {
|
||||||
|
path: source.path,
|
||||||
|
sha256: source.sha256,
|
||||||
|
format: "javascript",
|
||||||
|
},
|
||||||
|
locations: [
|
||||||
|
{ kind: "artifact-path", path: source.path },
|
||||||
|
...result.modules.map((module) => ({
|
||||||
|
kind: "artifact-path" as const,
|
||||||
|
path: module.artifact.path,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
limitations: result.limitations,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { JavaScriptRecoveryPort } from "../../application/JavaScriptRecoveryPort.js";
|
||||||
|
import {
|
||||||
|
type AnalysisExecution,
|
||||||
|
type ExecutionOptions,
|
||||||
|
} from "../../application/AnalysisProvider.js";
|
||||||
|
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
|
||||||
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
|
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||||
|
import type { JavaScriptRecoveryInput } from "../../domain/javascriptRecovery.js";
|
||||||
|
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||||
|
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||||
|
import { err, ok, type Result } from "../../domain/result.js";
|
||||||
|
import {
|
||||||
|
checkRecoveryCancellation,
|
||||||
|
checkRecoveryDeadline,
|
||||||
|
recoveryFailureMessage,
|
||||||
|
recoveryInputFailure,
|
||||||
|
snapshotRecoveryInput,
|
||||||
|
} from "./RecoveryFiles.js";
|
||||||
|
import { resolveWakaruCommand, type WakaruLauncher } from "./WakaruCommand.js";
|
||||||
|
import { prepareWakaruExecution } from "./WakaruExecution.js";
|
||||||
|
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
|
||||||
|
|
||||||
|
const OPERATION = "recover_javascript_sources";
|
||||||
|
|
||||||
|
/** Stateless adapter; each recovery owns its snapshot, process and publication. */
|
||||||
|
export class WakaruProvider implements JavaScriptRecoveryPort {
|
||||||
|
constructor(
|
||||||
|
readonly environment: Readonly<
|
||||||
|
Record<string, string | undefined>
|
||||||
|
> = process.env,
|
||||||
|
readonly launcher?: WakaruLauncher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Recover one script using validated upstream reports and verified output bytes. */
|
||||||
|
async recover(
|
||||||
|
input: JavaScriptRecoveryInput,
|
||||||
|
options?: ExecutionOptions,
|
||||||
|
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||||
|
let root: string | undefined;
|
||||||
|
let tree: SafeOutputTree | undefined;
|
||||||
|
let failure: unknown;
|
||||||
|
let execution: AnalysisExecution | undefined;
|
||||||
|
const deadline = Date.now() + RECOVERY_LIMITS.timeoutMs;
|
||||||
|
try {
|
||||||
|
checkRecoveryCancellation(options?.signal);
|
||||||
|
const engine = await resolveWakaruCommand(this.environment);
|
||||||
|
root = await mkdtemp(join(tmpdir(), "rea-wakaru-"));
|
||||||
|
const inputs = join(root, "inputs");
|
||||||
|
const staging = join(root, "modules");
|
||||||
|
await mkdir(inputs, { mode: 0o700 });
|
||||||
|
await mkdir(staging, { mode: 0o700 });
|
||||||
|
const source = await snapshotRecoveryInput(
|
||||||
|
input.path,
|
||||||
|
join(inputs, "bundle.js"),
|
||||||
|
options?.signal,
|
||||||
|
);
|
||||||
|
tree = await SafeOutputTree.create(input.output_directory).catch(
|
||||||
|
(cause: unknown) => {
|
||||||
|
throw recoveryInputFailure(
|
||||||
|
"output_directory",
|
||||||
|
`Cannot create new recovery directory ${input.output_directory}: ${recoveryFailureMessage(cause)}`,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
execution = await prepareWakaruExecution({
|
||||||
|
root,
|
||||||
|
tree,
|
||||||
|
input,
|
||||||
|
source,
|
||||||
|
engine,
|
||||||
|
environment: this.environment,
|
||||||
|
launcher: this.launcher,
|
||||||
|
options,
|
||||||
|
deadline,
|
||||||
|
});
|
||||||
|
checkRecoveryDeadline(deadline, options?.signal);
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
root = undefined;
|
||||||
|
checkRecoveryDeadline(deadline, options?.signal);
|
||||||
|
await tree.commit();
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
failure = cause;
|
||||||
|
}
|
||||||
|
if (failure !== undefined) {
|
||||||
|
const residuals: string[] = [];
|
||||||
|
try {
|
||||||
|
await tree?.rollback();
|
||||||
|
} catch {
|
||||||
|
if (tree !== undefined) residuals.push(tree.outputRoot);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
root !== undefined &&
|
||||||
|
!(failure instanceof AnalysisError && failure.cleanupIncomplete)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
residuals.push(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (residuals.length > 0)
|
||||||
|
return err(
|
||||||
|
new ProviderCleanupError("wakaru", residuals, {
|
||||||
|
previous_error: recoveryFailureMessage(failure),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return err(
|
||||||
|
failure instanceof AnalysisError
|
||||||
|
? failure
|
||||||
|
: new ProviderAdapterError("wakaru", OPERATION, {
|
||||||
|
cause: failure,
|
||||||
|
diagnostics: { reason: recoveryFailureMessage(failure) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return execution === undefined
|
||||||
|
? err(
|
||||||
|
new AnalysisOutputError(OPERATION, "Recovery returned no execution"),
|
||||||
|
)
|
||||||
|
: ok(execution);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import type { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
|
||||||
|
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||||
|
import {
|
||||||
|
javascriptRecoveryResultSchema,
|
||||||
|
type JavaScriptRecoveryInput,
|
||||||
|
type JavaScriptRecoveryResult,
|
||||||
|
} from "../../domain/javascriptRecovery.js";
|
||||||
|
import { readRecoveryFile, recoveryDigest } from "./RecoveryFiles.js";
|
||||||
|
import {
|
||||||
|
recoveryRanges,
|
||||||
|
validateRecoverySourceMap,
|
||||||
|
type parseWakaruReports,
|
||||||
|
} from "./WakaruReport.js";
|
||||||
|
import {
|
||||||
|
RECOVERY_LIMITATIONS,
|
||||||
|
RECOVERY_LIMITS,
|
||||||
|
WAKARU_RELEASE,
|
||||||
|
} from "./WakaruRelease.js";
|
||||||
|
import type {
|
||||||
|
resolveWakaruCommand,
|
||||||
|
runWakaruCommand,
|
||||||
|
} from "./WakaruCommand.js";
|
||||||
|
|
||||||
|
type PublicationContext = {
|
||||||
|
tree: SafeOutputTree;
|
||||||
|
staging: string;
|
||||||
|
input: JavaScriptRecoveryInput;
|
||||||
|
source: {
|
||||||
|
path: string;
|
||||||
|
snapshot_path: string;
|
||||||
|
sha256: string;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||||
|
parsed: Awaited<ReturnType<typeof parseWakaruReports>>;
|
||||||
|
execution: Awaited<ReturnType<typeof runWakaruCommand>>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
type PublishedFile = JavaScriptRecoveryResult["manifest"];
|
||||||
|
type RecoveryWriter = (path: string, bytes: Buffer) => Promise<PublishedFile>;
|
||||||
|
|
||||||
|
/** Write validated artifacts to one exclusively owned, rollback-capable tree. */
|
||||||
|
export const publishWakaruArtifacts = async (
|
||||||
|
context: PublicationContext,
|
||||||
|
): Promise<JavaScriptRecoveryResult> => {
|
||||||
|
const { tree, source, parsed, staging, signal } = context;
|
||||||
|
const write = async (path: string, bytes: Buffer) => {
|
||||||
|
const sha256 = recoveryDigest(bytes);
|
||||||
|
const file = await tree.write(path, Readable.from([bytes]), sha256, signal);
|
||||||
|
return {
|
||||||
|
path: join(tree.outputRoot, file.relativePath),
|
||||||
|
sha256: file.sha256,
|
||||||
|
bytes: file.bytesWritten,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const snapshot = await readRecoveryFile(
|
||||||
|
source.snapshot_path,
|
||||||
|
RECOVERY_LIMITS.inputBytes,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (recoveryDigest(snapshot) !== source.sha256)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"recover_javascript_sources",
|
||||||
|
"The input snapshot changed during recovery",
|
||||||
|
);
|
||||||
|
const publishedCopy = await write("inputs/bundle.js", snapshot);
|
||||||
|
const modules: JavaScriptRecoveryResult["modules"] = [];
|
||||||
|
for (const module of parsed.report.modules) {
|
||||||
|
modules.push(await publishModule(context, module, write));
|
||||||
|
}
|
||||||
|
const report = await write(
|
||||||
|
"reports/stdout.json",
|
||||||
|
Buffer.from(context.execution.stdout),
|
||||||
|
);
|
||||||
|
const provenance = await write(
|
||||||
|
"reports/provenance.json",
|
||||||
|
await readRecoveryFile(
|
||||||
|
join(staging, "provenance.json"),
|
||||||
|
RECOVERY_LIMITS.reportBytes,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const details = {
|
||||||
|
source: { ...source, published_copy: publishedCopy },
|
||||||
|
engine: {
|
||||||
|
id: "wakaru",
|
||||||
|
version: WAKARU_RELEASE.version,
|
||||||
|
executable: {
|
||||||
|
path: context.engine.command,
|
||||||
|
sha256: context.engine.sha256,
|
||||||
|
bytes: context.engine.bytes,
|
||||||
|
},
|
||||||
|
audited_source_revision: WAKARU_RELEASE.revision,
|
||||||
|
executed_source_revision: null,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
extraction_mode: context.input.extraction_mode,
|
||||||
|
rewrite_level: context.input.rewrite_level,
|
||||||
|
},
|
||||||
|
status: parsed.status,
|
||||||
|
detected_formats: parsed.report.detected_formats,
|
||||||
|
reported_safety: parsed.report.safety,
|
||||||
|
reported_strategy: parsed.provenance.strategy,
|
||||||
|
reported_format: parsed.provenance.format,
|
||||||
|
total: parsed.report.total,
|
||||||
|
failed: parsed.report.failed,
|
||||||
|
modules,
|
||||||
|
warnings: parsed.report.warnings,
|
||||||
|
report,
|
||||||
|
provenance,
|
||||||
|
analysis_input:
|
||||||
|
modules.length === 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
input_path: join(tree.outputRoot, "modules"),
|
||||||
|
format: "directory" as const,
|
||||||
|
},
|
||||||
|
runtime_equivalence: "unknown" as const,
|
||||||
|
limitations: [...RECOVERY_LIMITATIONS],
|
||||||
|
};
|
||||||
|
const manifest = await write(
|
||||||
|
"manifest.json",
|
||||||
|
Buffer.from(`${JSON.stringify(details, null, 2)}\n`),
|
||||||
|
);
|
||||||
|
return javascriptRecoveryResultSchema.parse({ ...details, manifest });
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishModule = async (
|
||||||
|
context: PublicationContext,
|
||||||
|
module: PublicationContext["parsed"]["report"]["modules"][number],
|
||||||
|
write: RecoveryWriter,
|
||||||
|
): Promise<JavaScriptRecoveryResult["modules"][number]> => {
|
||||||
|
const { staging, source, parsed, signal } = context;
|
||||||
|
const artifact = await write(
|
||||||
|
`modules/${module.filename}`,
|
||||||
|
await readRecoveryFile(
|
||||||
|
join(staging, module.filename),
|
||||||
|
RECOVERY_LIMITS.outputBytes,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const mapPath = join(staging, `${module.filename}.map`);
|
||||||
|
let sourceMap: JavaScriptRecoveryResult["modules"][number]["source_map"] =
|
||||||
|
null;
|
||||||
|
const map = await readRecoveryFile(
|
||||||
|
mapPath,
|
||||||
|
RECOVERY_LIMITS.outputBytes,
|
||||||
|
signal,
|
||||||
|
).catch((cause: unknown) => {
|
||||||
|
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||||
|
return null;
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
if (map !== null) {
|
||||||
|
validateRecoverySourceMap(map, module.filename);
|
||||||
|
sourceMap = await write(`modules/${module.filename}.map`, map);
|
||||||
|
}
|
||||||
|
const provenance = parsed.provenance.modules[module.filename];
|
||||||
|
if (provenance === undefined)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"recover_javascript_sources",
|
||||||
|
`Missing module provenance: ${module.filename}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
reported_filename: module.filename,
|
||||||
|
reported_status: module.status,
|
||||||
|
artifact,
|
||||||
|
source_map: sourceMap,
|
||||||
|
provenance: {
|
||||||
|
reported_input: provenance.input,
|
||||||
|
original_path: source.path,
|
||||||
|
original_sha256: source.sha256,
|
||||||
|
extraction: provenance.extraction,
|
||||||
|
byte_ranges: recoveryRanges(provenance.ranges),
|
||||||
|
context_byte_ranges: recoveryRanges(provenance.context_ranges ?? []),
|
||||||
|
range_semantics: "half-open-utf8-bytes",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/** Unchanged upstream source pin; executed bytes are identified separately. */
|
||||||
|
export const WAKARU_RELEASE = {
|
||||||
|
version: "1.13.0",
|
||||||
|
revision: "6070266d24b32951fa2fe1d2dad5b4313bd5c81b",
|
||||||
|
repository: "https://github.com/pionxzh/wakaru",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Exact provider identity shared by executions and the generated catalog. */
|
||||||
|
export const WAKARU_PROVIDER_IDENTITY = {
|
||||||
|
id: "wakaru",
|
||||||
|
name: "Wakaru",
|
||||||
|
version: WAKARU_RELEASE.version,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Per-operation resource bounds for the verified Linux adapter. */
|
||||||
|
export const RECOVERY_LIMITS = {
|
||||||
|
inputBytes: 64 * 1024 * 1024,
|
||||||
|
outputBytes: 128 * 1024 * 1024,
|
||||||
|
outputEntries: 10000,
|
||||||
|
reportBytes: 8 * 1024 * 1024,
|
||||||
|
addressSpaceBytes: 1024 * 1024 * 1024,
|
||||||
|
timeoutMs: 120000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Public limitations for transformations and extraction provenance. */
|
||||||
|
export const RECOVERY_LIMITATIONS = [
|
||||||
|
"Recovered sources are derived artifacts; original variable names and runtime equivalence are not established.",
|
||||||
|
"Extraction ranges refer to half-open UTF-8 byte offsets in the original input, not rewritten line positions or whole-bundle coverage.",
|
||||||
|
"Emitted source maps are preserved as reported by the engine; their mapping accuracy is not independently established.",
|
||||||
|
"Inspection mode may produce non-executable regions. No input or recovered application code is executed.",
|
||||||
|
"Linux adapter: one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB input, 128 MiB output, 10000 output entries and 8 MiB per diagnostic/report stream. Resource limits are per operation, not aggregate host containment.",
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
normalizeArtifactPath,
|
||||||
|
ArtifactPathRegistry,
|
||||||
|
} from "../../artifacts/ArtifactPaths.js";
|
||||||
|
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||||
|
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||||
|
import type { JavaScriptRecoveryResult } from "../../domain/javascriptRecovery.js";
|
||||||
|
import { readRecoveryFile, recoveryFailureMessage } from "./RecoveryFiles.js";
|
||||||
|
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
|
||||||
|
import type { runWakaruCommand } from "./WakaruCommand.js";
|
||||||
|
|
||||||
|
const warningSchema = z.strictObject({
|
||||||
|
filename: z.string(),
|
||||||
|
kind: z.string(),
|
||||||
|
is_error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
const reportSchema = z.strictObject({
|
||||||
|
detected_formats: z.array(z.string()),
|
||||||
|
safety: z.string(),
|
||||||
|
modules: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
filename: z.string().min(1),
|
||||||
|
kind: z.literal("javascript"),
|
||||||
|
status: z.literal("decompiled"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
warnings: z.array(warningSchema),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
failed: z.number().int().nonnegative(),
|
||||||
|
elapsed_ms: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
const rangeSchema = z.tuple([
|
||||||
|
z.number().int().nonnegative(),
|
||||||
|
z.number().int().nonnegative(),
|
||||||
|
]);
|
||||||
|
const provenanceSchema = z.strictObject({
|
||||||
|
format: z.string(),
|
||||||
|
strategy: z.string(),
|
||||||
|
modules: z.record(
|
||||||
|
z.string(),
|
||||||
|
z.strictObject({
|
||||||
|
input: z.string(),
|
||||||
|
ranges: z.array(rangeSchema),
|
||||||
|
extraction: z.string(),
|
||||||
|
context_ranges: z.array(rangeSchema).optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const OPERATION = "recover_javascript_sources";
|
||||||
|
|
||||||
|
type ReportContext = {
|
||||||
|
execution: Awaited<ReturnType<typeof runWakaruCommand>>;
|
||||||
|
provenancePath: string;
|
||||||
|
snapshotPath: string;
|
||||||
|
sourceBytes: Buffer;
|
||||||
|
files: readonly { relativePath: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse exact pinned producer JSON before projecting provider-neutral artifacts. */
|
||||||
|
export const parseWakaruReports = async (context: ReportContext) => {
|
||||||
|
const { execution, provenancePath } = context;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (execution.exit_code !== 0 && execution.exit_code !== 1)
|
||||||
|
throw new TypeError(
|
||||||
|
"Wakaru did not complete recovery (expected exit 0 or a reported partial exit 1)",
|
||||||
|
);
|
||||||
|
const report = reportSchema.parse(JSON.parse(execution.stdout));
|
||||||
|
const provenance = provenanceSchema.parse(
|
||||||
|
JSON.parse(
|
||||||
|
(
|
||||||
|
await readRecoveryFile(provenancePath, RECOVERY_LIMITS.reportBytes)
|
||||||
|
).toString("utf8"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (report.total !== report.modules.length || report.failed > report.total)
|
||||||
|
throw new TypeError("Reported module totals disagree");
|
||||||
|
if (
|
||||||
|
execution.exit_code === 1 &&
|
||||||
|
report.failed === 0 &&
|
||||||
|
!report.warnings.some((warning) => warning.is_error)
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
"Wakaru exited 1 without a reported partial recovery reason",
|
||||||
|
);
|
||||||
|
validateModuleSet(report, provenance, context);
|
||||||
|
return {
|
||||||
|
report,
|
||||||
|
provenance,
|
||||||
|
status:
|
||||||
|
report.failed > 0 ||
|
||||||
|
report.warnings.some((warning) => warning.is_error) ||
|
||||||
|
execution.exit_code === 1
|
||||||
|
? ("partial" as const)
|
||||||
|
: ("complete" as const),
|
||||||
|
};
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
throw new ProviderAdapterError("wakaru", OPERATION, {
|
||||||
|
cause,
|
||||||
|
diagnostics: {
|
||||||
|
reason: `Unusable Wakaru recovery report: ${recoveryFailureMessage(cause)}`,
|
||||||
|
exit_code: execution.exit_code,
|
||||||
|
signal: execution.signal,
|
||||||
|
stdout: execution.stdout,
|
||||||
|
stderr: execution.stderr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateModuleSet = (
|
||||||
|
report: z.output<typeof reportSchema>,
|
||||||
|
provenance: z.output<typeof provenanceSchema>,
|
||||||
|
context: ReportContext,
|
||||||
|
): void => {
|
||||||
|
const { files, snapshotPath, sourceBytes } = context;
|
||||||
|
const registry = new ArtifactPathRegistry();
|
||||||
|
const expected = new Set(["provenance.json"]);
|
||||||
|
const observed = new Set(files.map((file) => file.relativePath));
|
||||||
|
for (const module of report.modules) {
|
||||||
|
const path = normalizeArtifactPath(module.filename);
|
||||||
|
if (path !== module.filename || !/\.(?:js|mjs|cjs)$/u.test(path))
|
||||||
|
throw new TypeError(`Unsupported module path: ${module.filename}`);
|
||||||
|
registry.add(path, "file");
|
||||||
|
if (!observed.has(path))
|
||||||
|
throw new TypeError(`Missing reported module file: ${path}`);
|
||||||
|
expected.add(path);
|
||||||
|
if (observed.has(`${path}.map`)) expected.add(`${path}.map`);
|
||||||
|
const entry = provenance.modules[path];
|
||||||
|
if (entry === undefined || entry.input !== snapshotPath)
|
||||||
|
throw new TypeError(
|
||||||
|
`Module provenance does not bind the selected input snapshot: ${path}`,
|
||||||
|
);
|
||||||
|
validateRanges(
|
||||||
|
[...entry.ranges, ...(entry.context_ranges ?? [])],
|
||||||
|
sourceBytes,
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.keys(provenance.modules).length !== report.modules.length)
|
||||||
|
throw new TypeError("Provenance and reported module sets disagree");
|
||||||
|
for (const path of observed)
|
||||||
|
if (!expected.has(path))
|
||||||
|
throw new TypeError(`Unreported recovery artifact: ${path}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRanges = (
|
||||||
|
ranges: readonly [number, number][],
|
||||||
|
bytes: Buffer,
|
||||||
|
filename: string,
|
||||||
|
): void => {
|
||||||
|
for (const [start, end] of ranges) {
|
||||||
|
if (
|
||||||
|
start > end ||
|
||||||
|
end > bytes.length ||
|
||||||
|
!utf8Boundary(bytes, start) ||
|
||||||
|
!utf8Boundary(bytes, end)
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
`Invalid half-open UTF-8 byte range [${String(start)},${String(end)}) for ${filename}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const utf8Boundary = (bytes: Buffer, offset: number): boolean => {
|
||||||
|
const value = bytes[offset];
|
||||||
|
return value === undefined || (value & 0xc0) !== 0x80;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Translate extraction byte ranges without claiming rewritten line positions. */
|
||||||
|
export const recoveryRanges = (
|
||||||
|
ranges: readonly [number, number][],
|
||||||
|
): JavaScriptRecoveryResult["modules"][number]["provenance"]["byte_ranges"] =>
|
||||||
|
ranges.map(([start, end]) => ({ start, end }));
|
||||||
|
|
||||||
|
/** Validate a source map's representation; preserve its unverified mapping bytes. */
|
||||||
|
export const validateRecoverySourceMap = (
|
||||||
|
bytes: Buffer,
|
||||||
|
filename: string,
|
||||||
|
): void => {
|
||||||
|
const schema = z.object({
|
||||||
|
version: z.literal(3),
|
||||||
|
sources: z.array(z.string()),
|
||||||
|
names: z.array(z.string()),
|
||||||
|
mappings: z.string(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
schema.parse(JSON.parse(bytes.toString("utf8")));
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`Invalid emitted source map for ${filename}: ${recoveryFailureMessage(cause)}`,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -16,6 +16,10 @@ import { registerArtifactTools } from "./registerArtifactTools.js";
|
|||||||
import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js";
|
import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js";
|
||||||
import { registerBrowserTools } from "./registerBrowserTools.js";
|
import { registerBrowserTools } from "./registerBrowserTools.js";
|
||||||
import { registerWebScriptTool } from "./registerWebScriptTool.js";
|
import { registerWebScriptTool } from "./registerWebScriptTool.js";
|
||||||
|
import { registerJavaScriptRecoveryTool } from "./registerJavaScriptRecoveryTool.js";
|
||||||
|
import { JavaScriptRecoveryService } from "../application/JavaScriptRecoveryService.js";
|
||||||
|
import type { JavaScriptRecoveryPort } from "../application/JavaScriptRecoveryPort.js";
|
||||||
|
import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js";
|
||||||
import { registerElectronTools } from "./registerElectronTools.js";
|
import { registerElectronTools } from "./registerElectronTools.js";
|
||||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||||
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
||||||
@@ -45,6 +49,7 @@ const ACTIVE_TARGET_INSTRUCTIONS =
|
|||||||
export interface CreateServerOptions {
|
export interface CreateServerOptions {
|
||||||
readonly logger?: Logger;
|
readonly logger?: Logger;
|
||||||
readonly firmwareAnalysis?: FirmwareAnalysisPort;
|
readonly firmwareAnalysis?: FirmwareAnalysisPort;
|
||||||
|
readonly javascriptRecovery?: JavaScriptRecoveryPort;
|
||||||
readonly androidAnalysis?: AndroidAnalysisPort;
|
readonly androidAnalysis?: AndroidAnalysisPort;
|
||||||
readonly browserObservation?: BrowserObservationPort;
|
readonly browserObservation?: BrowserObservationPort;
|
||||||
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
||||||
@@ -77,6 +82,11 @@ const installSessionToolAvailability = (
|
|||||||
((process.platform === "linux" || process.platform === "darwin") &&
|
((process.platform === "linux" || process.platform === "darwin") &&
|
||||||
isAbsolute(process.env.REA_JADX_MCP_JAR ?? "")),
|
isAbsolute(process.env.REA_JADX_MCP_JAR ?? "")),
|
||||||
browserObservationEnabled: options.browserObservation !== undefined,
|
browserObservationEnabled: options.browserObservation !== undefined,
|
||||||
|
javascriptRecoveryEnabled:
|
||||||
|
options.javascriptRecovery !== undefined ||
|
||||||
|
(process.platform === "linux" &&
|
||||||
|
process.arch === "x64" &&
|
||||||
|
isAbsolute(process.env.REA_WAKARU_COMMAND ?? "")),
|
||||||
browserScenarioEnabled: options.browserScenarioCapture !== undefined,
|
browserScenarioEnabled: options.browserScenarioCapture !== undefined,
|
||||||
electronObservationEnabled: options.electronObservation !== undefined,
|
electronObservationEnabled: options.electronObservation !== undefined,
|
||||||
electronAutomationEnabled:
|
electronAutomationEnabled:
|
||||||
@@ -155,6 +165,14 @@ export const createServer = (
|
|||||||
recordEvidence,
|
recordEvidence,
|
||||||
);
|
);
|
||||||
registerObservationTools(toolContext);
|
registerObservationTools(toolContext);
|
||||||
|
registerJavaScriptRecoveryTool(
|
||||||
|
server,
|
||||||
|
new JavaScriptRecoveryService(
|
||||||
|
options.javascriptRecovery ?? createJavaScriptRecoveryProvider(),
|
||||||
|
),
|
||||||
|
toolLogger,
|
||||||
|
recordEvidence,
|
||||||
|
);
|
||||||
registerGuidedPrompts(server, analysis, session);
|
registerGuidedPrompts(server, analysis, session);
|
||||||
if (session !== undefined) {
|
if (session !== undefined) {
|
||||||
registerSessionTools(server, session, toolLogger, {
|
registerSessionTools(server, session, toolLogger, {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { McpServer } from "@modelcontextprotocol/server";
|
||||||
|
import type { JavaScriptRecoveryService } from "../application/JavaScriptRecoveryService.js";
|
||||||
|
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||||
|
import { toolContract } from "../contracts/toolContracts.js";
|
||||||
|
import type { Logger } from "../logger.js";
|
||||||
|
import { logToolExecution } from "./toolLogging.js";
|
||||||
|
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||||
|
import { toCallToolResult } from "./toolResult.js";
|
||||||
|
|
||||||
|
/** Bind optional recovery to its exact named contract without acquiring an engine. */
|
||||||
|
export const registerJavaScriptRecoveryTool = (
|
||||||
|
server: McpServer,
|
||||||
|
service: JavaScriptRecoveryService,
|
||||||
|
logger: Logger,
|
||||||
|
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||||
|
): void => {
|
||||||
|
const contract = toolContract("recover_javascript_sources");
|
||||||
|
server.registerTool(
|
||||||
|
contract.name,
|
||||||
|
toolRegistrationOptions(contract),
|
||||||
|
async (input, context) => {
|
||||||
|
const result = await logToolExecution(logger, contract.name, () =>
|
||||||
|
service.recover(input, { signal: context.mcpReq.signal }),
|
||||||
|
);
|
||||||
|
if (!result.ok) return toCallToolResult(result, contract);
|
||||||
|
const recorded = recordEvidence?.(result.value);
|
||||||
|
return recorded !== undefined && !recorded.ok
|
||||||
|
? toCallToolResult(recorded, contract)
|
||||||
|
: toCallToolResult(result, contract);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ export interface SessionAvailabilityDefaults {
|
|||||||
| "electronAutomationEnabled"
|
| "electronAutomationEnabled"
|
||||||
| "v8InspectorObservationEnabled"
|
| "v8InspectorObservationEnabled"
|
||||||
| "androidAnalysisEnabled"
|
| "androidAnalysisEnabled"
|
||||||
|
| "javascriptRecoveryEnabled"
|
||||||
| "firmwareInspectionEnabled"
|
| "firmwareInspectionEnabled"
|
||||||
| "firmwareExtractionEnabled"
|
| "firmwareExtractionEnabled"
|
||||||
>;
|
>;
|
||||||
@@ -36,6 +37,8 @@ export const sessionAvailabilityPolicy = (
|
|||||||
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
|
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
|
||||||
androidAnalysisEnabled:
|
androidAnalysisEnabled:
|
||||||
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
|
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
|
||||||
|
javascriptRecoveryEnabled:
|
||||||
|
defaults.optionalFeatures?.javascriptRecoveryEnabled ?? false,
|
||||||
browserObservationEnabled:
|
browserObservationEnabled:
|
||||||
defaults.optionalFeatures?.browserObservationEnabled ?? false,
|
defaults.optionalFeatures?.browserObservationEnabled ?? false,
|
||||||
browserScenarioEnabled:
|
browserScenarioEnabled:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { expect, onTestFinished } from "vitest";
|
|||||||
|
|
||||||
import { createAndroidAnalysisProvider } from "../../src/composition/android.js";
|
import { createAndroidAnalysisProvider } from "../../src/composition/android.js";
|
||||||
import { createFirmwareAnalysisProvider } from "../../src/composition/firmware.js";
|
import { createFirmwareAnalysisProvider } from "../../src/composition/firmware.js";
|
||||||
|
import { createJavaScriptRecoveryProvider } from "../../src/composition/javascriptRecovery.js";
|
||||||
import { createServer } from "../../src/server/createServer.js";
|
import { createServer } from "../../src/server/createServer.js";
|
||||||
import { createTestBinarySession } from "../fixtures/binarySession.js";
|
import { createTestBinarySession } from "../fixtures/binarySession.js";
|
||||||
import { writeOrderedZip } from "../fixtures/artifactEntryOrder.js";
|
import { writeOrderedZip } from "../fixtures/artifactEntryOrder.js";
|
||||||
@@ -18,6 +19,7 @@ const connect = async (environment: NodeJS.ProcessEnv) => {
|
|||||||
const server = createServer(session, session, {
|
const server = createServer(session, session, {
|
||||||
androidAnalysis: createAndroidAnalysisProvider(environment),
|
androidAnalysis: createAndroidAnalysisProvider(environment),
|
||||||
firmwareAnalysis: createFirmwareAnalysisProvider(environment),
|
firmwareAnalysis: createFirmwareAnalysisProvider(environment),
|
||||||
|
javascriptRecovery: createJavaScriptRecoveryProvider(environment),
|
||||||
});
|
});
|
||||||
const client = new Client({ name: "composition-parity", version: "1" });
|
const client = new Client({ name: "composition-parity", version: "1" });
|
||||||
onTestFinished(async () => {
|
onTestFinished(async () => {
|
||||||
@@ -86,3 +88,81 @@ cliTest.skipIf(process.platform !== "linux")(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
cliTest(
|
||||||
|
"preserves optional JavaScript recovery configuration through CLI and MCP",
|
||||||
|
async ({ cli }) => {
|
||||||
|
const root = await createTestTempDirectory("rea-recovery-composition-");
|
||||||
|
const path = join(root, "bundle.js");
|
||||||
|
await writeFile(path, "globalThis.fixture = 1;");
|
||||||
|
const environment = {
|
||||||
|
REA_LOG_LEVEL: "silent",
|
||||||
|
REA_WAKARU_COMMAND: "relative-unavailable-tool",
|
||||||
|
};
|
||||||
|
const cliResult = await cli.run({
|
||||||
|
arguments: [
|
||||||
|
"recover-javascript-sources",
|
||||||
|
path,
|
||||||
|
join(root, "output"),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
environment,
|
||||||
|
timeoutMs: 10000,
|
||||||
|
});
|
||||||
|
expect(cliResult.exitCode).toBe(1);
|
||||||
|
expect(cliResult.json).toMatchObject({ code: "capability_unavailable" });
|
||||||
|
const client = await connect(environment);
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: "recover_javascript_sources",
|
||||||
|
arguments: { path, output_directory: join(root, "output") },
|
||||||
|
});
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.structuredContent).toEqual({ error: cliResult.json });
|
||||||
|
expect(JSON.stringify(response.structuredContent)).toContain(
|
||||||
|
"REA_WAKARU_COMMAND",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
cliTest(
|
||||||
|
"returns recovery path diagnostics before missing-engine errors in CLI and MCP",
|
||||||
|
async ({ cli }) => {
|
||||||
|
const root = await createTestTempDirectory("rea-recovery-path-parity-");
|
||||||
|
const environment = {
|
||||||
|
REA_LOG_LEVEL: "silent",
|
||||||
|
REA_WAKARU_COMMAND: "relative-unavailable-tool",
|
||||||
|
};
|
||||||
|
const client = await connect(environment);
|
||||||
|
for (const field of ["path", "output_directory"] as const) {
|
||||||
|
const input = {
|
||||||
|
path: join(root, "bundle.js"),
|
||||||
|
output_directory: join(root, "output"),
|
||||||
|
[field]: "relative.js",
|
||||||
|
};
|
||||||
|
const result = await cli.run({
|
||||||
|
arguments: [
|
||||||
|
"recover-javascript-sources",
|
||||||
|
input.path,
|
||||||
|
input.output_directory,
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
environment,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: "recover_javascript_sources",
|
||||||
|
arguments: input,
|
||||||
|
});
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.structuredContent).toEqual({ error: result.json });
|
||||||
|
expect(result.json).toMatchObject({
|
||||||
|
code: "invalid_request",
|
||||||
|
details: {
|
||||||
|
operation: "recover_javascript_sources",
|
||||||
|
issues: [{ path: [field], reason: "invalid_format" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
BINWALK_PROVIDER_IDENTITY,
|
BINWALK_PROVIDER_IDENTITY,
|
||||||
UNBLOB_PROVIDER_IDENTITY,
|
UNBLOB_PROVIDER_IDENTITY,
|
||||||
} from "../../../src/firmware/FirmwareRelease.js";
|
} from "../../../src/firmware/FirmwareRelease.js";
|
||||||
|
import { WAKARU_PROVIDER_IDENTITY } from "../../../src/javascript/recovery/WakaruRelease.js";
|
||||||
import { JADX_PROVIDER_IDENTITY } from "../../../src/android/JadxRelease.js";
|
import { JADX_PROVIDER_IDENTITY } from "../../../src/android/JadxRelease.js";
|
||||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +103,7 @@ describe("canonical product catalog", () => {
|
|||||||
JADX_PROVIDER_IDENTITY,
|
JADX_PROVIDER_IDENTITY,
|
||||||
BINWALK_PROVIDER_IDENTITY,
|
BINWALK_PROVIDER_IDENTITY,
|
||||||
UNBLOB_PROVIDER_IDENTITY,
|
UNBLOB_PROVIDER_IDENTITY,
|
||||||
|
WAKARU_PROVIDER_IDENTITY,
|
||||||
GHIDRA_PROVIDER_IDENTITY,
|
GHIDRA_PROVIDER_IDENTITY,
|
||||||
IDA_PROVIDER_IDENTITY,
|
IDA_PROVIDER_IDENTITY,
|
||||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { javascriptRecoveryResultSchema } from "../../../src/domain/javascriptRecovery.js";
|
||||||
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||||
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||||
|
import {
|
||||||
|
assertRecoveryCleanup,
|
||||||
|
recoveryFixture,
|
||||||
|
} from "../../fixtures/javascript-recovery/provider.js";
|
||||||
|
|
||||||
|
describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
||||||
|
"Wakaru recovery boundary",
|
||||||
|
() => {
|
||||||
|
it("publishes exact bytes and mappings with source identity and no runtime-equivalence claim", async () => {
|
||||||
|
const fixture = await recoveryFixture();
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
expect(parseEvidence(result.value)).toEqual(result.value);
|
||||||
|
const value = javascriptRecoveryResultSchema.parse(
|
||||||
|
result.value.normalized_result,
|
||||||
|
);
|
||||||
|
expect(value).toMatchObject({
|
||||||
|
status: "complete",
|
||||||
|
total: 1,
|
||||||
|
failed: 0,
|
||||||
|
runtime_equivalence: "unknown",
|
||||||
|
analysis_input: {
|
||||||
|
input_path: join(fixture.output, "modules"),
|
||||||
|
format: "directory",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(value.source.path).toBe(fixture.path);
|
||||||
|
expect(value.source.published_copy.sha256).toBe(value.source.sha256);
|
||||||
|
expect(value.modules[0]?.provenance.byte_ranges).toEqual([
|
||||||
|
{ start: 0, end: value.source.bytes },
|
||||||
|
]);
|
||||||
|
expect(value.engine.executed_source_revision).toBeNull();
|
||||||
|
expect(await readFile(value.source.published_copy.path, "utf8")).toBe(
|
||||||
|
"module.exports = 42;",
|
||||||
|
);
|
||||||
|
expect(result.value.confidence).toBe("derived");
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"missing-provenance",
|
||||||
|
"malformed-report",
|
||||||
|
"duplicate",
|
||||||
|
"missing-file",
|
||||||
|
"escaping",
|
||||||
|
"extra-file",
|
||||||
|
"symlink",
|
||||||
|
"directory-symlink",
|
||||||
|
"bad-map",
|
||||||
|
"source-mismatch",
|
||||||
|
"overflow-range",
|
||||||
|
"extra-provenance",
|
||||||
|
"snapshot-change",
|
||||||
|
"source-change",
|
||||||
|
"engine-change",
|
||||||
|
"startup-error",
|
||||||
|
])(
|
||||||
|
"rejects %s, rolls back publication and cleans its processes",
|
||||||
|
async (mode) => {
|
||||||
|
const fixture = await recoveryFixture(mode);
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error("expected boundary failure");
|
||||||
|
const error = projectAnalysisError(result.error);
|
||||||
|
expect(["execution_failure", "unreadable_output"]).toContain(
|
||||||
|
error.code,
|
||||||
|
);
|
||||||
|
expect(error.details).toBeDefined();
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("rejects byte ranges splitting a UTF-8 code point", async () => {
|
||||||
|
const fixture = await recoveryFixture("utf8-range", "🎯");
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error("expected invalid UTF-8 boundary");
|
||||||
|
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
|
||||||
|
"UTF-8 byte range",
|
||||||
|
);
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains valid partial output and exact failure diagnostics", async () => {
|
||||||
|
const fixture = await recoveryFixture("partial");
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
const value = javascriptRecoveryResultSchema.parse(
|
||||||
|
result.value.normalized_result,
|
||||||
|
);
|
||||||
|
expect(value).toMatchObject({
|
||||||
|
status: "partial",
|
||||||
|
failed: 1,
|
||||||
|
warnings: [{ is_error: true, kind: "parse_error" }],
|
||||||
|
});
|
||||||
|
expect(value.modules).toHaveLength(1);
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
||||||
|
"Wakaru recovery lifecycle",
|
||||||
|
() => {
|
||||||
|
it("rejects an existing output without modifying it or launching recovery", async () => {
|
||||||
|
const fixture = await recoveryFixture();
|
||||||
|
await mkdir(fixture.output);
|
||||||
|
await writeFile(join(fixture.output, "keep.txt"), "keep");
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) throw new Error("expected existing-output rejection");
|
||||||
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||||
|
code: "invalid_request",
|
||||||
|
details: { issues: [{ path: ["output_directory"] }] },
|
||||||
|
});
|
||||||
|
expect(await readFile(join(fixture.output, "keep.txt"), "utf8")).toBe(
|
||||||
|
"keep",
|
||||||
|
);
|
||||||
|
expect(fixture.launches).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an active recovery and verifies owned cleanup", async () => {
|
||||||
|
const fixture = await recoveryFixture("hang");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const resultPromise = fixture.service.recover(
|
||||||
|
{ path: fixture.path, output_directory: fixture.output },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
while (true) {
|
||||||
|
const run = fixture.launches[1];
|
||||||
|
if (
|
||||||
|
run?.cwd !== undefined &&
|
||||||
|
(await access(join(run.cwd, "recovery-started")).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
if (Date.now() > deadline)
|
||||||
|
throw new Error("fixture recovery did not start");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
controller.abort();
|
||||||
|
const result = await resultPromise;
|
||||||
|
if (result.ok) throw new Error("expected cancellation");
|
||||||
|
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports cleanup failure and retains the workspace named by the error", async () => {
|
||||||
|
const fixture = await recoveryFixture("cleanup-failure");
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("expected cleanup failure");
|
||||||
|
expect(projectAnalysisError(result.error).code).toBe(
|
||||||
|
"cleanup_incomplete",
|
||||||
|
);
|
||||||
|
const launch = fixture.launches[0];
|
||||||
|
if (launch?.cwd === undefined)
|
||||||
|
throw new Error("missing process ownership");
|
||||||
|
await expect(access(launch.cwd)).resolves.toBeUndefined();
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an incompatible engine with its observed version", async () => {
|
||||||
|
const fixture = await recoveryFixture("wrong-version");
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("expected version failure");
|
||||||
|
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
|
||||||
|
"wakaru 0.0.0",
|
||||||
|
);
|
||||||
|
expect(projectAnalysisError(result.error).code).toBe(
|
||||||
|
"capability_unavailable",
|
||||||
|
);
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||||
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||||
|
import { expect, it, onTestFinished } from "vitest";
|
||||||
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||||
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||||
|
import { createServer } from "../../../src/server/createServer.js";
|
||||||
|
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||||
|
import {
|
||||||
|
assertRecoveryCleanup,
|
||||||
|
recoveryFixture,
|
||||||
|
} from "../../fixtures/javascript-recovery/provider.js";
|
||||||
|
|
||||||
|
it.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
||||||
|
"advertises and executes recovery through the named SDK contract without a binary target",
|
||||||
|
async () => {
|
||||||
|
const fixture = await recoveryFixture();
|
||||||
|
const session = createTestBinarySession(() => {
|
||||||
|
throw new Error("no binary provider should start");
|
||||||
|
});
|
||||||
|
const server = createServer(session, session, {
|
||||||
|
javascriptRecovery: fixture.provider,
|
||||||
|
});
|
||||||
|
const client = new Client({ name: "recovery-contract", version: "1" });
|
||||||
|
onTestFinished(async () => {
|
||||||
|
await client.close();
|
||||||
|
await server.close();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
const [clientTransport, serverTransport] =
|
||||||
|
InMemoryTransport.createLinkedPair();
|
||||||
|
await server.connect(serverTransport);
|
||||||
|
await client.connect(clientTransport);
|
||||||
|
const tools = await client.listTools();
|
||||||
|
const advertised = tools.tools.find(
|
||||||
|
(tool) => tool.name === "recover_javascript_sources",
|
||||||
|
);
|
||||||
|
if (advertised === undefined || advertised.outputSchema === undefined)
|
||||||
|
throw new Error("recovery tool must advertise both schemas");
|
||||||
|
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||||
|
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||||
|
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||||
|
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||||
|
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||||
|
const args = { path: fixture.path, output_directory: fixture.output };
|
||||||
|
expect(ajv.validate(inputSchema, args)).toBe(true);
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: "recover_javascript_sources",
|
||||||
|
arguments: args,
|
||||||
|
});
|
||||||
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||||
|
expect(
|
||||||
|
ajv.validate(outputSchema, response.structuredContent),
|
||||||
|
JSON.stringify(ajv.errors),
|
||||||
|
).toBe(true);
|
||||||
|
const parsed = toolContract(
|
||||||
|
"recover_javascript_sources",
|
||||||
|
).outputSchema.parse(response.structuredContent);
|
||||||
|
const evidence = parseEvidence(parsed.evidence);
|
||||||
|
expect(parsed.result).toEqual(evidence.normalized_result);
|
||||||
|
expect(parsed.evidence_id).toBe(evidence.evidence_id);
|
||||||
|
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -19,6 +19,13 @@ describe("incremental module import boundaries", () => {
|
|||||||
["src/server/probe.ts", "../browser/CdpBrowserProvider.js", false],
|
["src/server/probe.ts", "../browser/CdpBrowserProvider.js", false],
|
||||||
["src/application/probe.ts", "../browser/V8InspectorProvider.js", false],
|
["src/application/probe.ts", "../browser/V8InspectorProvider.js", false],
|
||||||
["src/composition/probe.ts", "../browser/CdpBrowserProvider.js", true],
|
["src/composition/probe.ts", "../browser/CdpBrowserProvider.js", true],
|
||||||
|
[
|
||||||
|
"src/application/probe.ts",
|
||||||
|
"../javascript/recovery/WakaruProvider.js",
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
["src/domain/probe.ts", "../javascript/recovery/WakaruReport.js", false],
|
||||||
|
["src/server/probe.ts", "../javascript/recovery/WakaruProvider.js", false],
|
||||||
["src/domain/probe.ts", "./result.js", true],
|
["src/domain/probe.ts", "./result.js", true],
|
||||||
["src/contracts/probe.ts", "../domain/result.js", true],
|
["src/contracts/probe.ts", "../domain/result.js", true],
|
||||||
["src/application/probe.ts", "../domain/result.js", true],
|
["src/application/probe.ts", "../domain/result.js", true],
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
import { readFile, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const mode = process.env.REA_RECOVERY_FIXTURE_MODE ?? "normal";
|
||||||
|
if (args.includes("--version")) {
|
||||||
|
console.log(mode === "wrong-version" ? "wakaru 0.0.0" : "wakaru 1.13.0");
|
||||||
|
} else {
|
||||||
|
const input = args[0];
|
||||||
|
const output = args[args.indexOf("-o") + 1];
|
||||||
|
const bytes = await readFile(input);
|
||||||
|
await writeFile(join(dirname(output), "recovery-started"), "started");
|
||||||
|
if (mode === "hang") await new Promise(() => setInterval(() => {}, 1000));
|
||||||
|
if (mode === "startup-error") {
|
||||||
|
console.error("fixture: unsupported producer dependency");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (mode === "snapshot-change")
|
||||||
|
await writeFile(input, Buffer.alloc(bytes.length, 0x61));
|
||||||
|
if (mode === "engine-change")
|
||||||
|
await writeFile(
|
||||||
|
process.env.REA_RECOVERY_ENGINE_PATH,
|
||||||
|
"#!/bin/sh\nexit 1\n",
|
||||||
|
);
|
||||||
|
if (mode === "source-change")
|
||||||
|
await writeFile(process.env.REA_RECOVERY_ORIGINAL_PATH, "changed original");
|
||||||
|
const filename = mode === "escaping" ? "../escaped.js" : "module.js";
|
||||||
|
const modules = [{ filename, kind: "javascript", status: "decompiled" }];
|
||||||
|
if (mode === "duplicate") modules.push(modules[0]);
|
||||||
|
if (mode !== "missing-file" && mode !== "escaping") {
|
||||||
|
if (mode === "symlink") await symlink(input, join(output, filename));
|
||||||
|
else
|
||||||
|
await writeFile(join(output, filename), "export const recovered = 42;\n");
|
||||||
|
}
|
||||||
|
if (mode === "extra-file")
|
||||||
|
await writeFile(join(output, "extra.js"), "unreported");
|
||||||
|
if (mode === "directory-symlink")
|
||||||
|
await symlink(dirname(input), join(output, "nested"));
|
||||||
|
if (mode === "bad-map") await writeFile(join(output, "module.js.map"), "{}");
|
||||||
|
if (mode === "normal")
|
||||||
|
await writeFile(
|
||||||
|
join(output, "module.js.map"),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 3,
|
||||||
|
file: "module.js",
|
||||||
|
sources: ["../inputs/bundle.js"],
|
||||||
|
names: [],
|
||||||
|
mappings: "AAAA",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ranges =
|
||||||
|
mode === "overflow-range"
|
||||||
|
? [[0, bytes.length + 1]]
|
||||||
|
: mode === "utf8-range"
|
||||||
|
? [[0, 2]]
|
||||||
|
: [[0, bytes.length]];
|
||||||
|
const provenance = {
|
||||||
|
format: "unknown",
|
||||||
|
strategy: "structural",
|
||||||
|
modules: {
|
||||||
|
[filename]: {
|
||||||
|
input: mode === "source-mismatch" ? "/different.js" : input,
|
||||||
|
ranges,
|
||||||
|
extraction: "structural",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (mode === "extra-provenance")
|
||||||
|
provenance.modules["extra.js"] = provenance.modules[filename];
|
||||||
|
if (mode !== "missing-provenance")
|
||||||
|
await writeFile(
|
||||||
|
join(output, "provenance.json"),
|
||||||
|
JSON.stringify(provenance),
|
||||||
|
);
|
||||||
|
const report = {
|
||||||
|
detected_formats: [],
|
||||||
|
safety: "normal",
|
||||||
|
modules,
|
||||||
|
warnings:
|
||||||
|
mode === "partial"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
filename,
|
||||||
|
kind: "parse_error",
|
||||||
|
is_error: true,
|
||||||
|
message: "fixture transform failed; retained source",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
total: modules.length,
|
||||||
|
failed: mode === "partial" ? 1 : 0,
|
||||||
|
elapsed_ms: 1,
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
mode === "malformed-report" ? "{invalid}" : JSON.stringify(report),
|
||||||
|
);
|
||||||
|
if (mode === "partial") process.exitCode = 1;
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
import { access, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { expect, onTestFinished } from "vitest";
|
||||||
|
import { JavaScriptRecoveryService } from "../../../src/application/JavaScriptRecoveryService.js";
|
||||||
|
import { createJavaScriptRecoveryProvider } from "../../../src/composition/javascriptRecovery.js";
|
||||||
|
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||||
|
import { createTestTempDirectory } from "../temporaryDirectory.js";
|
||||||
|
|
||||||
|
/** Inject a producer through the production factory and real owned processes. */
|
||||||
|
export const recoveryFixture = async (
|
||||||
|
mode = "normal",
|
||||||
|
source = "module.exports = 42;",
|
||||||
|
) => {
|
||||||
|
const root = await createTestTempDirectory("rea-recovery-fixture-");
|
||||||
|
const path = join(root, "selected.js");
|
||||||
|
await writeFile(path, source);
|
||||||
|
const command = join(root, "fixture-wakaru");
|
||||||
|
await writeFile(command, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||||
|
const launches: {
|
||||||
|
cwd: string | undefined;
|
||||||
|
pid: number | undefined;
|
||||||
|
args: readonly string[];
|
||||||
|
}[] = [];
|
||||||
|
const provider = createJavaScriptRecoveryProvider(
|
||||||
|
{ ...process.env, REA_WAKARU_COMMAND: command },
|
||||||
|
async (options) => {
|
||||||
|
const start = options.arguments.indexOf("--") + 2;
|
||||||
|
const child = await spawnOwnedProviderProcess({
|
||||||
|
...options,
|
||||||
|
command: process.execPath,
|
||||||
|
expectedCommand: process.execPath,
|
||||||
|
arguments: [
|
||||||
|
fileURLToPath(new URL("./producer.mjs", import.meta.url)),
|
||||||
|
...options.arguments.slice(start),
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
...options.env,
|
||||||
|
REA_RECOVERY_FIXTURE_MODE: mode,
|
||||||
|
REA_RECOVERY_ORIGINAL_PATH: path,
|
||||||
|
REA_RECOVERY_ENGINE_PATH: command,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
launches.push({
|
||||||
|
cwd: options.cwd,
|
||||||
|
pid: child.process.pid,
|
||||||
|
args: options.arguments,
|
||||||
|
});
|
||||||
|
if (mode === "cleanup-failure") {
|
||||||
|
onTestFinished(async () => {
|
||||||
|
if (options.cwd !== undefined)
|
||||||
|
await rm(options.cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...child,
|
||||||
|
cleanup: async () => ({
|
||||||
|
cleaned: false,
|
||||||
|
reason: "injected cleanup refusal",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
service: new JavaScriptRecoveryService(provider),
|
||||||
|
path,
|
||||||
|
output: join(root, "output"),
|
||||||
|
root,
|
||||||
|
launches,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Verify the owned process groups and workspaces are absent. */
|
||||||
|
export const assertRecoveryCleanup = async (
|
||||||
|
launches: Awaited<ReturnType<typeof recoveryFixture>>["launches"],
|
||||||
|
): Promise<void> => {
|
||||||
|
for (const launch of launches) {
|
||||||
|
const pid = launch.pid;
|
||||||
|
if (pid !== undefined)
|
||||||
|
expect(() => process.kill(-pid, 0)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "ESRCH" }),
|
||||||
|
);
|
||||||
|
if (launch.cwd !== undefined)
|
||||||
|
await expect(access(launch.cwd)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
+1
Submodule third_party/wakaru added at 6070266d24
+1
-1
@@ -37,7 +37,7 @@ const TEST_PROJECTS = [
|
|||||||
name: "adapters",
|
name: "adapters",
|
||||||
include: [
|
include: [
|
||||||
"src/*.test.ts",
|
"src/*.test.ts",
|
||||||
"src/{artifacts,browser,dotnet,firmware,ghidra,hopper,ida,native,process,reference,server,windows}/**/*.test.ts",
|
"src/{artifacts,browser,dotnet,firmware,ghidra,hopper,ida,javascript,native,process,reference,server,windows}/**/*.test.ts",
|
||||||
],
|
],
|
||||||
pool: "forks" as const,
|
pool: "forks" as const,
|
||||||
maxWorkers: MAX_TEST_WORKERS,
|
maxWorkers: MAX_TEST_WORKERS,
|
||||||
|
|||||||
Reference in New Issue
Block a user