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:
N0zoM1z0
2026-10-07 12:46:13 +08:00
committed by GitHub
parent 4a6c17ce83
commit 166dcd21d0
60 changed files with 2840 additions and 25 deletions
+1
View File
@@ -7,6 +7,7 @@ on:
- "scripts/verify-real-browser.mjs"
- "scripts/verify-browser-network.mjs"
- "scripts/verify-browser-scripts.mjs"
- "scripts/verify-browser-dom-destinations.mjs"
- "scripts/lib/browser-*.mjs"
- "scripts/fixtures/browser-network-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
+3
View File
@@ -10,3 +10,6 @@
[submodule "third_party/ghidra-nativeaot"]
path = third_party/ghidra-nativeaot
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
View File
@@ -52,7 +52,8 @@
"**/ida/**",
"**/android/**",
"**/firmware/**",
"**/browser/*Provider.*"
"**/browser/*Provider.*",
"**/javascript/**"
],
"message": "Provider imports belong behind typed ports or in production composition."
}
@@ -81,6 +82,7 @@
"**/browser/**",
"**/dotnet/**",
"**/firmware/**",
"**/javascript/**",
"**/ghidra/**",
"**/hopper/**",
"**/ida/**",
@@ -113,6 +115,7 @@
"**/android/**",
"**/firmware/**",
"**/browser/*Provider.*",
"**/javascript/**",
"**/composition/**"
],
"message": "Application workflows use typed ports; production construction belongs in composition."
@@ -157,7 +160,8 @@
"**/main.*",
"**/composition/**",
"**/android/**",
"**/firmware/**"
"**/firmware/**",
"**/javascript/**"
],
"message": "Domain tests may import only their owning domain and inward dependencies."
}
+6 -1
View File
@@ -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 |
| 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 |
| 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 |
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
versions, and missing-source states remain inline. See
[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).
- **.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
View File
@@ -223,7 +223,7 @@ rea setup
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
| سير عمل التطبيقات | 8 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| سير عمل التطبيقات | 9 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
## خطة العمل
+1 -1
View File
@@ -212,7 +212,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
| アプリワークフロー | 8 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
| アプリワークフロー | 9 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
## ロードマップ
+1 -1
View File
@@ -213,7 +213,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
| 앱 워크플로 | 8 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 앱 워크플로 | 9 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
## 로드맵
+1 -1
View File
@@ -212,7 +212,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
| 应用工作流 | 8 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 应用工作流 | 9 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
+90
View File
@@ -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.
+16 -8
View File
@@ -10,7 +10,7 @@
"skill_version": "26"
},
"tools": {
"total": 126,
"total": 127,
"families": [
{
"id": "direct",
@@ -179,7 +179,7 @@
{
"id": "application",
"surface": "application-workflow",
"count": 8,
"count": 9,
"tools": [
"build_reconstruction_obligation_ledger",
"compare_application_versions",
@@ -187,6 +187,7 @@
"compare_source_to_bundle",
"evaluate_reconstruction_coverage",
"export_web_scripts",
"recover_javascript_sources",
"trace_application_feature",
"trace_javascript_semantics"
]
@@ -462,6 +463,12 @@
"name": "Unblob",
"version": "26.6.4",
"capabilities": ["extract_firmware"]
},
{
"id": "wakaru",
"name": "Wakaru",
"version": "1.13.0",
"capabilities": ["recover_javascript_sources"]
}
],
"setup_clients": [
@@ -539,7 +546,7 @@
}
],
"cli": {
"primary_count": 81,
"primary_count": 82,
"commands": [
"address-to-file-offset",
"analyze",
@@ -608,6 +615,7 @@
"providers",
"read-bytes",
"reconcile-javascript-runtime",
"recover-javascript-sources",
"resolve-native-call-targets",
"search",
"search-android-classes",
@@ -632,15 +640,15 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 81,
"mcp_tools": 126,
"cli_commands": 82,
"mcp_tools": 127,
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "3d17936cd393a494987f7f52a133f23f9b582718d57719b2e06c678a7c5ca20a",
"tools_sha256": "1f1acf182aa87964404e65f3acb00c44f1c392373c5a8a6f40c250da8b94c274",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "de30f3145fb936a97fcb7bcc58ed9f20a974bae59b239dbf24664c145eff4275",
"providers_sha256": "7949ddcf2eb5ba41b022bb3dfa1951ab96dbf5640285fa7fd5d35b0e4a5bdab3"
"combined_sha256": "0efbf17dddbdf52f84d4ca1f9a3e007a0d77c2623be62ce0a69844d617bf16da",
"providers_sha256": "0bccfa9588376a90350d18abded999db7e1d0e4424a61a21d03d392a1c5418fe"
}
}
}
+14
View File
@@ -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.
Neither optional toolchain is a base-lane prerequisite. See
[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": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "50b3311f68cf2b73686c3b96ceb5f1cbe9054df7886ddf88b5c701f999c6c9cc"
"sha256": "6dfe4f9c24f9438982910f3170a7b0b1798761da6ad9a487d73e662f4c23e9ba"
}
],
"claims": [
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "./node_modules/knip/schema.json",
"entry": ["src/main.ts", "src/cli.ts", "src/contracts/errorSchemas.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"],
"ignoreDependencies": ["electron"],
"ignoreDependencies": ["electron", "esbuild", "webpack"],
"ignoreExportsUsedInFile": true,
"rules": {
"files": "error",
+2 -1
View File
@@ -177,7 +177,8 @@
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.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: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": {
"*.{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);
}
+7
View File
@@ -67,6 +67,8 @@ export const toolFamilyCatalog = (sources) => {
contracts: [
...sources.applicationContracts.APPLICATION_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,
contracts: sources.webScriptContracts.WEB_SCRIPT_TOOL_CONTRACTS,
},
{
identity: sources.javascriptRecoveryProvider.WAKARU_PROVIDER_IDENTITY,
contracts:
sources.javascriptRecoveryContracts.JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
},
]
.map(({ identity, contracts }) => ({
id: identity.id,
+3
View File
@@ -55,6 +55,9 @@ const SOURCE_PATHS = {
"dist/contracts/javascriptRuntimeObservationToolContracts.js",
applicationContracts: "dist/contracts/applicationToolContracts.js",
webScriptContracts: "dist/contracts/webScriptToolContracts.js",
javascriptRecoveryContracts:
"dist/contracts/javascriptRecoveryToolContracts.js",
javascriptRecoveryProvider: "dist/javascript/recovery/WakaruRelease.js",
supportedClients: "dist/application/SupportedClients.js",
hopperProvider: "dist/hopper/HopperProvider.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 },
};
}
+189
View File
@@ -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,
);
}
}
}
+9 -2
View File
@@ -413,9 +413,16 @@ async function verifyPageScopedTransport(provider, proxy, origin) {
target_id: target,
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;
if (
!disconnected.ok ||
+2 -2
View File
@@ -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.
metadata:
version: "26"
tool_count: 126
catalog_digest: "de30f3145fb936a97fcb7bcc58ed9f20a974bae59b239dbf24664c145eff4275"
tool_count: 127
catalog_digest: "0efbf17dddbdf52f84d4ca1f9a3e007a0d77c2623be62ce0a69844d617bf16da"
---
# 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,
});
});
});
+9
View File
@@ -36,6 +36,7 @@ export type AvailabilityPolicy = {
readonly firmwareInspectionEnabled?: boolean;
readonly firmwareExtractionEnabled?: boolean;
readonly androidAnalysisEnabled?: boolean;
readonly javascriptRecoveryEnabled?: boolean;
readonly browserObservationEnabled?: boolean;
readonly browserScenarioEnabled?: boolean;
readonly electronObservationEnabled?: boolean;
@@ -236,6 +237,14 @@ const workflowAvailabilityFor = ({
kind,
policy,
}: 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") {
const enabled =
name === "inspect_firmware_regions"
+15
View File
@@ -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",
}),
);
}
}
+2
View File
@@ -13,6 +13,7 @@ import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
import { registerProcessCommands } from "./cli/processCommands.js";
import { registerBrowserCommands } from "./cli/browserCommands.js";
import { registerWebScriptCommands } from "./cli/webScriptCommands.js";
import { registerJavaScriptRecoveryCommands } from "./cli/javascriptRecoveryCommands.js";
import { registerAdvancedBrowserCommands } from "./cli/browserAdvancedCommands.js";
import { registerBrowserScenarioCommands } from "./cliBrowserScenarioCommands.js";
import { registerElectronCommands } from "./cli/electronCommands.js";
@@ -56,6 +57,7 @@ export const createCli = (
registerProcessCommands(cli, logger, environment);
registerBrowserCommands(cli, logger);
registerWebScriptCommands(cli, logger);
registerJavaScriptRecoveryCommands(cli, logger, environment);
registerAdvancedBrowserCommands(cli, logger);
registerBrowserScenarioCommands(cli, logger);
registerElectronCommands(cli, logger);
+69
View File
@@ -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);
},
),
),
});
};
+1
View File
@@ -61,6 +61,7 @@ export const CLI_COMMANDS = Object.freeze({
inspectWebPage: "inspect-web-page",
analyzeWebBundle: "analyze-web-bundle",
exportWebScripts: "export-web-scripts",
recoverJavaScriptSources: "recover-javascript-sources",
observeWebSession: "observe-web-session",
discoverWebMcpTools: "discover-webmcp-tools",
compareWebCaptures: "compare-web-captures",
+9
View File
@@ -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);
+2
View File
@@ -16,6 +16,7 @@ import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
import { WEB_SCRIPT_TOOL_CONTRACTS } from "./webScriptToolContracts.js";
import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascriptRecoveryToolContracts.js";
import { TOOL_EFFECTS } from "./toolEffects.js";
import { toolContract } from "./toolContracts.js";
const contractJsonSchema = (schema: z.ZodType) =>
@@ -77,6 +78,7 @@ describe("tool contract surface", () => {
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
...APPLICATION_TOOL_CONTRACTS,
...WEB_SCRIPT_TOOL_CONTRACTS,
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
].map(({ name }) => name);
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[];
+4
View File
@@ -14,6 +14,8 @@ import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.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 = {
official: OFFICIAL_TOOL_CONTRACTS,
@@ -28,6 +30,8 @@ const GROUPS = {
electron: ELECTRON_TOOL_CONTRACTS,
javascript_runtime_observation: JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
application: APPLICATION_TOOL_CONTRACTS,
javascript_recovery: JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
web_script_export: WEB_SCRIPT_TOOL_CONTRACTS,
session: SESSION_TOOL_CONTRACTS,
} as const;
+2
View File
@@ -9,6 +9,7 @@ import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
import { JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS } from "./javascriptRuntimeObservationToolContracts.js";
import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.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 { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
@@ -30,6 +31,7 @@ export const TOOL_CONTRACTS = [
...JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS,
...APPLICATION_TOOL_CONTRACTS,
...WEB_SCRIPT_TOOL_CONTRACTS,
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
] as const;
+6
View File
@@ -221,6 +221,12 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
idempotent: false,
}),
reconcile_javascript_runtime: evidence,
recover_javascript_sources: effects({
mutatesSession: true,
writesFilesystem: true,
launchesProcess: true,
idempotent: false,
}),
trace_application_feature: evidence,
trace_javascript_semantics: evidence,
compare_application_versions: evidence,
+34
View File
@@ -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);
});
});
+109
View File
@@ -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
+201
View File
@@ -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" });
});
+176
View File
@@ -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,
};
};
+170
View File
@@ -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,
});
};
+127
View File
@@ -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",
},
};
};
+32
View File
@@ -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;
+199
View File
@@ -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 },
);
}
};
+18
View File
@@ -16,6 +16,10 @@ import { registerArtifactTools } from "./registerArtifactTools.js";
import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js";
import { registerBrowserTools } from "./registerBrowserTools.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 { registerEnhancedTools } from "./registerEnhancedTools.js";
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
@@ -45,6 +49,7 @@ const ACTIVE_TARGET_INSTRUCTIONS =
export interface CreateServerOptions {
readonly logger?: Logger;
readonly firmwareAnalysis?: FirmwareAnalysisPort;
readonly javascriptRecovery?: JavaScriptRecoveryPort;
readonly androidAnalysis?: AndroidAnalysisPort;
readonly browserObservation?: BrowserObservationPort;
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
@@ -77,6 +82,11 @@ const installSessionToolAvailability = (
((process.platform === "linux" || process.platform === "darwin") &&
isAbsolute(process.env.REA_JADX_MCP_JAR ?? "")),
browserObservationEnabled: options.browserObservation !== undefined,
javascriptRecoveryEnabled:
options.javascriptRecovery !== undefined ||
(process.platform === "linux" &&
process.arch === "x64" &&
isAbsolute(process.env.REA_WAKARU_COMMAND ?? "")),
browserScenarioEnabled: options.browserScenarioCapture !== undefined,
electronObservationEnabled: options.electronObservation !== undefined,
electronAutomationEnabled:
@@ -155,6 +165,14 @@ export const createServer = (
recordEvidence,
);
registerObservationTools(toolContext);
registerJavaScriptRecoveryTool(
server,
new JavaScriptRecoveryService(
options.javascriptRecovery ?? createJavaScriptRecoveryProvider(),
),
toolLogger,
recordEvidence,
);
registerGuidedPrompts(server, analysis, session);
if (session !== undefined) {
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);
},
);
};
+3
View File
@@ -16,6 +16,7 @@ export interface SessionAvailabilityDefaults {
| "electronAutomationEnabled"
| "v8InspectorObservationEnabled"
| "androidAnalysisEnabled"
| "javascriptRecoveryEnabled"
| "firmwareInspectionEnabled"
| "firmwareExtractionEnabled"
>;
@@ -36,6 +37,8 @@ export const sessionAvailabilityPolicy = (
defaults.optionalFeatures?.firmwareExtractionEnabled ?? false,
androidAnalysisEnabled:
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
javascriptRecoveryEnabled:
defaults.optionalFeatures?.javascriptRecoveryEnabled ?? false,
browserObservationEnabled:
defaults.optionalFeatures?.browserObservationEnabled ?? false,
browserScenarioEnabled:
@@ -5,6 +5,7 @@ import { expect, onTestFinished } from "vitest";
import { createAndroidAnalysisProvider } from "../../src/composition/android.js";
import { createFirmwareAnalysisProvider } from "../../src/composition/firmware.js";
import { createJavaScriptRecoveryProvider } from "../../src/composition/javascriptRecovery.js";
import { createServer } from "../../src/server/createServer.js";
import { createTestBinarySession } from "../fixtures/binarySession.js";
import { writeOrderedZip } from "../fixtures/artifactEntryOrder.js";
@@ -18,6 +19,7 @@ const connect = async (environment: NodeJS.ProcessEnv) => {
const server = createServer(session, session, {
androidAnalysis: createAndroidAnalysisProvider(environment),
firmwareAnalysis: createFirmwareAnalysisProvider(environment),
javascriptRecovery: createJavaScriptRecoveryProvider(environment),
});
const client = new Client({ name: "composition-parity", version: "1" });
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,
UNBLOB_PROVIDER_IDENTITY,
} 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 { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
import {
@@ -102,6 +103,7 @@ describe("canonical product catalog", () => {
JADX_PROVIDER_IDENTITY,
BINWALK_PROVIDER_IDENTITY,
UNBLOB_PROVIDER_IDENTITY,
WAKARU_PROVIDER_IDENTITY,
GHIDRA_PROVIDER_IDENTITY,
IDA_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/application/probe.ts", "../browser/V8InspectorProvider.js", false],
["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/contracts/probe.ts", "../domain/result.js", true],
["src/application/probe.ts", "../domain/result.js", true],
+98
View File
@@ -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
View File
@@ -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",
});
}
};
Vendored Submodule
+1
Submodule third_party/wakaru added at 6070266d24
+1 -1
View File
@@ -37,7 +37,7 @@ const TEST_PROJECTS = [
name: "adapters",
include: [
"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,
maxWorkers: MAX_TEST_WORKERS,