setup/shim: fix eleven verified defects, and audit the gaps that hid them

Every item below was confirmed in the code (or reproduced) before it was changed,
and each now has a regression test that fails when its fix is reverted.

- `--only` rewrote bundle.json from this run's results alone, erasing every other
  component's record: the shim stopped exporting JAVA_HOME/GHIDRA_INSTALL_DIR and
  the next full run re-extracted them. Untouched records are carried over, and
  only while their recorded path still exists.
- `--only` skipped the requires check entirely, so `--only apktool` resolved
  {jvm} to a vendor/jdk that only a full run creates and failed on a machine whose
  JDK was recorded next to it.
- A complete .part was not treated as finished work: it logged "already complete"
  and then asked for `Range: bytes=<size>-` (416, four attempts, reported failed).
  It is verified and renamed now.
- The Windows PowerShell route deleted the partial file before every attempt, and
  the Node fallback reused a stale `have`, appending a full 200 response onto the
  bytes a failed route had already written. The route resumes from what is on
  disk, and the fallback re-reads the file's real length.
- POSIX wrappers landed 0644 while REA spawns them without a shell (EACCES); a
  POSIX entry marked `executable` is now set 0755.
- apktool's wrapper was pinned to `master` with a hard checksum, so one upstream
  commit was enough to break every new install. Pinned to the immutable v3.0.3
  tag, with the hashes measured from it.
- `--reuse` adopted a JDK because `javac` existed and a Ghidra because
  `analyzeHeadless` existed. A candidate must now pass the component's own probe,
  and a `file` probe finally honours `expect.contains`.
- A malformed bundle.json failed permanently: setup could not get far enough to
  replace the file it could not parse, and nothing started setup. Setup tolerates
  it, and the shim provisions for `malformed` as well as `missing`.
- A failed run kept a fresh lock that made the shim claim "provisioning started in
  the background" for six hours while nothing ran. The lock records its state and
  pid, and a dead holder no longer blocks a retry.
- A successful `--json` run never released the lock, because the removal sat
  inside the non-`--json` branch.
- The shim's fallback REA path omitted `node_modules`, so an older bundle.json
  without paths.reaEntry reported a working install as gone.
- bootstrap.ps1 threw on a non-numeric `node -v` (an int cast under
  ErrorActionPreference=Stop) instead of falling through to the portable download,
  and could exit 0 when `&` failed; it reads REA's engines from the manifest pin
  instead of a second hardcoded copy, and -DryRun no longer creates directories
  while claiming nothing changed.
- check-updates.ps1 looked for REA in the npm global root and for Ghidra in a
  user-level variable, which is the 1.x story: on a 2.x machine the REA section
  pointed at the wrong object, the doctor self-check was skipped entirely, and the
  bundle drift check silently skipped unrecorded pins while printing "all pins
  match". It reads bundle.json first now.

audit-manifest.mjs gained four checks: manifest components vs INSTALL_ORDER,
pins.nodeEngines vs package.json engines, the REA pin probe against the next
patch/minor, and the POSIX wrapper exec flags. validate.yml gained a
windows-latest job, because the PowerShell download route, the zip extraction and
the bootstrap were Windows-only code that no CI ever ran.

33 unit tests (32 pass, 1 platform skip), audit clean, on Windows and WSL.
This commit is contained in:
czcdr committed 2026-10-16 23:26:08 +08:00
1 parent 98e3bd0863
commit 975ac5aac8
15 files changed
+1229 -165

No files matched your search

+20
View File
@@ -163,3 +163,23 @@ jobs:
# Checksum coverage, unresolved tokens, duplicate JSON keys, and the
# patch-wiring agreement between the row, the shim and setup.mjs.
run: npm run bundle:audit
windows:
# The PowerShell download route, the tar/Expand-Archive extraction path and
# scripts/rea-bundle-bootstrap.ps1 are Windows-only code, and an ubuntu-only
# matrix never executed any of them — they were verified by hand, if at all.
# The tests are platform-sensitive on purpose: each platform exercises the
# download route it actually chooses.
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Unit tests for the pure logic (Windows)
run: npm run bundle:test
- name: Bundle manifest audit (Windows)
run: npm run bundle:audit
+55
View File
@@ -2,6 +2,61 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 2.0.3 — 2026-10-16
**修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
### 上游
- `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
(`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
`^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
- Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
### 修复
- **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
`null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
setup 的解释器来满足,而不是整段跳过检查。
- **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
已经下好的文件报成失败)。现在校验通过即改名收下。
- **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
`have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
长度发 Range 续传。
- **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
manifest 里标了 `executable` 的条目落盘时 chmod 0755。
- **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
- **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
`application.version=` 断言此前是死配置)。
- **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
- **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
lock 不再阻塞重试。
- **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
- **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
- **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
`exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
目录(它承诺"什么都没改")。
- **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
`GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
### 审计与 CI
- `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
- `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
Windows 专属代码。
- 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
## 2.0.2 — 2026-10-15
**仅文档,无行为变更。**
+6 -6
View File
@@ -179,11 +179,11 @@ exact shim launch the MCP row will use.
## Verified against
Author's machine, 2026-10-13 / 14:
Author's machine, 2026-10-13 / 14 / 16:
| Component | Version |
|---|---|
| REA (`rea-agents`) | **6.4.0** (170 tools) |
| REA (`rea-agents`) | **6.5.0** (207 tools) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
| Ghidra | 12.1.4 |
@@ -192,7 +192,7 @@ Author's machine, 2026-10-13 / 14:
| Host | Windows 11 x64 (build 26200) |
Exercised live: a clean `setup.mjs --reuse` run reached `setup complete`; an MCP `initialize` handshake
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.4.0}`; and the whole
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.5.0}`; and the whole
chain ran under the real desktop host — its own Electron binary launched
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
@@ -203,14 +203,14 @@ macOS/Linux.
## Scope and caveats
- REA exposes 170 tools at 6.4.0, so every session's tool list grows.
- REA exposes 207 tools at 6.5.0, so every session's tool list grows.
- Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
`bundle.json` lists which components are in that state under `unverifiedChecksums`.
- Downloads are slow on some networks (measured 0.35–0.54 MiB/s from the GitHub release CDN here, so Ghidra
alone takes ~25–45 min). Fetching is resumable: the cache is kept and re-verified, and a killed run
continues instead of restarting.
alone takes ~25–45 min). Fetching is resumable on both routes: a partial `.part` is continued with a Range
request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
- Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
Analyse only artifacts you are authorized to inspect.
- `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
+6 -5
View File
@@ -170,11 +170,11 @@ shim 启动**。
## 实测环境
作者机器,2026-10-13 / 14:
作者机器,2026-10-13 / 14 / 16:
| 组件 | 版本 |
|---|---|
| REA(`rea-agents`) | **6.4.0**(170 个工具) |
| REA(`rea-agents`) | **6.5.0**(207 个工具) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
| Ghidra | 12.1.4 |
@@ -183,7 +183,7 @@ shim 启动**。
| 宿主 | Windows 11 x64(build 26200) |
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}`;以及**真实桌面宿主下的完整链路** ——
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}`;以及**真实桌面宿主下的完整链路** ——
宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
@@ -192,12 +192,13 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}
## 边界与注意事项
- REA 在 6.4.0 有 170 个工具,每个会话的工具列表都会变长。
- REA 在 6.5.0 有 207 个工具,每个会话的工具列表都会变长。
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
`bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
- 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
下载是**可续传**的:缓存保留并重新校验,被中断的一轮会继续而不是从头再来。
下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
而不是重下。
- **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
(环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
+2 -2
View File
@@ -84,7 +84,7 @@ Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/
$rowY = 180; $boxH = 158; $boxW = 250
Draw-Box $g 60 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
Draw-Text $g 'DeepSeek Harness 会话' $fBox $ink 80 ($rowY + 18)
Draw-Text $g "mcp__rea__* 工具`n(6.4.0 暴露 170+)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
Draw-Text $g "mcp__rea__* 工具`n(6.5.0 暴露 207)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
@@ -112,7 +112,7 @@ Draw-Line $g 358 446 150 446 $muted
Draw-Arrow $g 150 446 150 342 $muted
Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 620
Draw-Text $g '实测 REA 6.4.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-14)' $fSmall $muted 60 646
Draw-Text $g '实测 REA 6.5.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-16)' $fSmall $muted 60 646
Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+97 -19
View File
@@ -15,7 +15,7 @@
* and the message is the operator's remediation.
*/
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
@@ -93,6 +93,40 @@ function provisioningScript(root) {
return usableSetup(PLUGIN_ROOT);
}
/**
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
* `state: "running"` is written when a run is started (setup's own pid once it
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
* plain-number lock is still honoured as "running".
*/
const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
function readLock(lock) {
try {
const raw = readFileSync(lock, "utf8").trim();
if (raw.startsWith("{")) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed;
}
const at = Number(raw);
return Number.isFinite(at) ? { state: "running", at } : null;
} catch {
return null;
}
}
/** Is the process recorded in the lock still alive? null means "cannot tell". */
function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return null;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === "ESRCH" ? false : null;
}
}
/**
* On first run there is nothing to launch. Provisioning is a long download, so
* it is started detached: the harness may kill this row (or the whole session)
@@ -100,34 +134,54 @@ function provisioningScript(root) {
* a killed attempt is simply continued on the next start.
*
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
* Returns the log path when a run was started, otherwise null.
* Returns { started, reason, logPath } — the caller words its message from
* `reason`, because "provisioning started in the background" was also printed
* for a lock left behind by a run that had already failed, and for one whose
* process was long gone.
*/
function maybeAutoProvision(root) {
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return null;
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return { started: false, reason: "disabled" };
const setupScript = provisioningScript(root);
if (!setupScript) return null;
if (!setupScript) return { started: false, reason: "no-setup" };
const lock = path.join(root, "setup.lock");
const logPath = path.join(root, "logs", "setup.log");
if (existsSync(lock)) {
// A crashed run must not wedge provisioning forever: honour the lock only
// while it is fresh; setup removes it itself when it finishes cleanly.
let age = Infinity;
try {
age = Date.now() - Number(readFileSync(lock, "utf8").trim() || 0);
} catch {
age = Infinity;
const record = readLock(lock);
const age = record && Number.isFinite(record.at) ? Date.now() - record.at : Infinity;
const fresh = Number.isFinite(age) && age < STALE_LOCK_MS;
if (fresh && record?.state === "failed") {
// The run gave up and left the lock behind so it would not be retried in a
// loop. Claiming it is still provisioning was a six-hour lie: nothing runs,
// and every restart repeated the sentence.
return {
started: false,
reason: "last-run-failed",
exit: record.exit,
failedAt: record.at,
ageMs: age,
logPath,
};
}
if (fresh) {
const alive = pidAlive(record?.pid);
if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
// The lock holder is gone and never wrote an outcome (harness restart,
// power loss, a kill), so the attempt is not running and the lock is not
// evidence of anything. Drop it and start again.
rmSync(lock, { force: true });
}
if (Number.isFinite(age) && age < 6 * 60 * 60 * 1000) return logPath;
}
try {
mkdirSync(path.join(root, "logs"), { recursive: true });
// Exclusive create: check-then-write is not atomic, so two rows starting at
// once could both decide to provision. EEXIST means another run owns it.
writeFileSync(lock, String(Date.now()), { flag: "wx" });
writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
} catch (error) {
if (error?.code === "EEXIST") return logPath;
if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
/* a read-only bundle root must never break the harness */
return null;
return { started: false, reason: "unwritable" };
}
try {
const child = spawn(process.execPath, [setupScript, "--root", root], {
@@ -136,9 +190,18 @@ function maybeAutoProvision(root) {
windowsHide: true,
});
child.unref();
return logPath;
// Hand the lock to the detached downloader, so a later start that finds this
// row dead still sees the run that is actually doing the work as alive.
if (Number.isInteger(child.pid)) {
try {
writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
} catch {
/* the lock is advisory: the row keeps working without it */
}
}
return { started: true, reason: "started", logPath };
} catch {
return null;
return { started: false, reason: "spawn-failed", logPath };
}
}
@@ -195,8 +258,13 @@ function readBundleConfig(root) {
function resolveRea(config, root) {
const paths = config.paths ?? {};
const node = paths.node;
// The fallback has to match where setup actually installs the package:
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
// fallback without that segment named a directory that never exists. It only
// fires when an older bundle.json has no paths.reaEntry — which is exactly when
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
const reaEntry = paths.reaEntry
?? path.join(root, "vendor", "rea-agents", "scripts", "rea.mjs");
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
if (typeof node !== "string" || node.trim() === "") {
return { ok: false, reason: "bundle.json has no paths.node" };
@@ -302,10 +370,20 @@ function main() {
loaded.code === "missing"
? `No bundle state at ${loaded.file}.`
: `Bundle state at ${loaded.file} could not be parsed.`;
const logPath = loaded.code === "missing" ? maybeAutoProvision(root) : null;
if (logPath) {
// Both "missing" and "malformed" start a run. A malformed file used to fail
// permanently: setup overwrites a bundle.json it cannot parse, but nothing
// ever started setup, so every restart reported the same EX_CONFIG until the
// operator deleted the file by hand. The lock stops a provisioning loop when
// writing keeps failing.
const provisioning = maybeAutoProvision(root);
if (provisioning.started) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${logPath}\n`,
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "last-run-failed") {
process.stderr.write(
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
);
}
return fail(message, loaded.detail);
+92 -17
View File
@@ -69,13 +69,40 @@ function Test-NodeEngines([string]$node, [string]$engines) {
}
# ------------------------------------------------------- 定位 REA 与 profile
if (-not $ReaPackageDir) {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
# 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
$bundleRootEarly = $env:REA_BUNDLE_ROOT
if (-not $bundleRootEarly) {
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
}
$bundleState = $null
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
if (Test-Path $bundleJsonEarly) {
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
}
$bundleReaPackageDir = $null
$bundleReaEntry = $null
if ($bundleState -and $bundleState.paths.reaEntry) {
$bundleReaEntry = [string]$bundleState.paths.reaEntry
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
}
if (-not $ReaPackageDir) {
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
$ReaPackageDir = $bundleReaPackageDir
} else {
$npmRoot = $null
if (Get-Command npm -ErrorAction SilentlyContinue) {
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
}
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
}
}
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
if (-not $ProfileDir) {
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
else {
@@ -94,11 +121,21 @@ if (Test-Path $pkgFile) {
$installed = $pkg.version; $engines = $pkg.engines.node
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
if ($binPath) { $reaEntry = Join-Path $ReaPackageDir ($binPath -replace '/', '\') }
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
if ($reaFromBundle) {
Ok "一体包内置(vendored): $installed"
Info "位置: $ReaPackageDir"
Info "入口(bin): $binPath"
} else {
Ok "本地已安装: $installed"
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
}
if ($engines) { Info "Node 要求: $engines" }
} catch { Bad "package.json 解析失败: $($_.Exception.Message)" }
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
} else {
if ($reaFromBundle) { Bad "找不到 $pkgFile —— 内置副本不完整?重跑 setup.mjs" }
elseif ($bundleReaPackageDir) { Bad "找不到 $pkgFile —— 一体包内置副本也缺($bundleReaPackageDir);记录里没有 reaEntry" }
else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
}
$latest = $null
foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
@@ -110,7 +147,11 @@ if ($installed -and $latest) {
$vi = Get-NumericVersion $installed; $vl = Get-NumericVersion $latest
if ($vi -and $vl -and $vl -gt $vi) {
Warn "有新版本:$installed → $latest"
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
if ($reaFromBundle) {
$actions.Add("升级一体包内置 REA:把 manifest 的 pins.reaAgents / components.reaAgents.sources.npm.version 改成 $latest,然后重跑 node scripts\setup.mjs(内置副本不会自己更新)")
} else {
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
}
} elseif ($vi -and $vl -and $vl -eq $vi -and $installed -ne $latest) {
Warn "同版本号但通道不同:$installed → $latest(预发布/正式)"
} else { Ok '已是最新版本' }
@@ -139,7 +180,14 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
# ------------------------------------------------------------ Ghidra 引擎
Head 'Ghidra 引擎'
$gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
# bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
# 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
$gd = $null
if ($bundleState -and $bundleState.paths.ghidra) {
$bundleGhidra = [string]$bundleState.paths.ghidra
if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
}
if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
if ($gd -and (Test-Path $gd)) {
Ok "GHIDRA_INSTALL_DIR = $gd"
@@ -162,11 +210,28 @@ try {
# --------------------------------------------------------- REA 引擎自检
Head 'REA 引擎自检(rea doctor)'
$env:GHIDRA_INSTALL_DIR = $gd
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
# 2.x:优先用 bundle 内置的 CLI 和它自己的 Node。PATH 上的 rea 可能是另一个,
# 甚至完全不在 PATH —— 那会让整段自检被静默跳过。这正是 1.x 口径的残留。
$reaCmd = $null
$reaArgs = @()
if ($bundleReaEntry -and (Test-Path $bundleReaEntry)) {
$bundleNode = $null
if ($bundleState -and $bundleState.paths.node) {
$candidateNode = [string]$bundleState.paths.node
if (Test-Path $candidateNode) { $bundleNode = $candidateNode }
}
if (-not $bundleNode) { $bundleNode = (Get-Command node -ErrorAction SilentlyContinue).Source }
if ($bundleNode) {
$reaCmd = $bundleNode; $reaArgs = @($bundleReaEntry)
Info '使用一体包内置 REA(node <bundle 入口>),不依赖 PATH 上的 rea'
}
}
if (-not $reaCmd) {
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
}
if (-not $reaCmd) {
if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
$reaCmd = 'node'; $reaArgs = @($reaEntry)
@@ -247,6 +312,8 @@ if (-not (Test-Path $manifestPath)) {
$state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
Ok "已供给:$($state.provisionedAt)"
$drift = 0
$compared = 0
$unrecorded = 0
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
if ($name -eq 'nodeEngines') { continue }
$pinned = [string]$manifest.pins.$name
@@ -254,13 +321,21 @@ if (-not (Test-Path $manifestPath)) {
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
$installed = [string]$state.versions.$name
}
if (-not $installed) { continue }
if (-not $installed) {
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
$unrecorded++
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
continue
}
$compared++
# Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
$same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
if ($same) { Info " $name = $installed(pin $pinned)" }
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
}
if ($drift -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
if ($drift -eq 0 -and $unrecorded -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
}
+2 -2
View File
@@ -31,11 +31,11 @@ Two upstream rules shape the runtime wiring:
## 2. Verified facts this design rests on
Measured on Windows 11 x64, REA 6.4.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
Measured on Windows 11 x64, REA 6.5.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
| Fact | Evidence |
| --- | --- |
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.4.0) and `rea doctor --json` reported 170 tools, `healthy: true` |
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.5.0) and `rea doctor --json` reported 207 tools. `healthy` is `false` on a bundle-only install: 6.5.0 also checks the host's own setup (Hopper, IDA registration, `rea setup` skill/registrations), which provisioning deliberately never touches — the row does not require `healthy`, only a CLI that runs and a catalog it can read |
| `!!js` expressions are supported in this profile layer | the existing profile patch already used one; the loader evaluates them |
| A row-level `env:` block reaches the MCP child | `binary_session` reported `registrationEnvironment` = `GHIDRA_INSTALL_DIR`, `JAVA_HOME` |
| No fixed home/layout is promised | the disk layout differs per install; nothing may assume one |
+42 -36
View File
@@ -12,12 +12,14 @@
" binaries - workspace-relative entries inside the unpacked directory that are",
" directly spawnable; the first that exists becomes the component's",
" recorded path so another component can depend on it.",
" executable - a placed file that must carry the POSIX exec bit (a wrapper REA",
" spawns without a shell); setup chmods exactly these to 0755.",
"Nothing here is redistributed inside the npm package; setup downloads it."
],
"manifestVersion": 1,
"generatedFor": "dsh-plugin-rea one-plugin bundle",
"pins": {
"reaAgents": "6.4.0",
"reaAgents": "6.5.0",
"ghidra": "12.1.4",
"jdk": "21",
"apktool": "3.0.3",
@@ -203,16 +205,16 @@
"win32-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/windows/apktool.bat",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/windows/apktool.bat",
"format": "file",
"size": 2348,
"sha256": "9440ec9d98efdef14136f4cefd79ac200d72baa016b5991d128234a3a3ef3b43",
"sha256Source": "measured on 2026-10-13 (raw.githubusercontent.com publishes no checksum for the wrapper); a single 2,348-byte file, so the archive-free case needs no TOFU",
"sha256VerifiedOn": "2026-10-13",
"size": 2386,
"sha256": "d81a91bd694459a61a88e4a1a8184efc21130390ff7cb5c172f631551ad9842a",
"sha256Source": "measured on 2026-10-16 from the immutable v3.0.3 tag (raw.githubusercontent.com publishes no checksum for the wrapper). The tag, not master: pinning master meant any upstream commit to the wrapper broke every new install with a hash mismatch, and four retries could not fix a download whose content had legitimately changed.",
"sha256VerifiedOn": "2026-10-16",
"notes": [
"The wrapper pushd's to its own directory and resolves apktool.jar first, then globs apktool_*.jar, so the renamed jar next to it is found.",
"Its final line is `for %%i in (%cmdcmdline%) do if /i \"%%~i\"==\"/c\" pause & exit /b`: measured here it returns exit 0 in 0.2 s, but it appends 'Press any key to continue . . .' to stdout. That trailing text is upstream's, not a provisioning defect; tools that parse apktool's stdout should tolerate it.",
"The previous entry measured master's copy (2,348 bytes) and recorded that it returns exit 0 in 0.2 s while appending 'Press any key to continue . . .' to stdout. This is the tag's copy (2,386 bytes) and that runtime behaviour has NOT been re-measured; treat the trailing-text claim as unverified for this file.",
"REA is pointed at this .bat (REA_APKTOOL_COMMAND), not at the jar, exactly as the wrapper expects."
],
"moveTo": "apktool",
@@ -222,13 +224,14 @@
"darwin-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"size": 2672,
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
"sha256Source": "measured on 2026-10-13 from the osx wrapper (the correct one for macOS; the linux script is a different file)",
"sha256VerifiedOn": "2026-10-13",
"size": 2709,
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag (the correct file for macOS; the linux script is a different file). The tag is immutable, so this pin cannot drift under a new install the way master's did.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -236,13 +239,14 @@
"darwin-arm64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
"format": "file",
"size": 2672,
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
"sha256Source": "measured on 2026-10-13 from the osx wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2709,
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same osx wrapper serves arm64.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -250,13 +254,14 @@
"linux-x64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"size": 2648,
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2685,
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -264,13 +269,14 @@
"linux-arm64": [
{
"id": "wrapper",
"version": "master",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
"version": "3.0.3",
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
"format": "file",
"size": 2648,
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
"sha256VerifiedOn": "2026-10-13",
"size": 2685,
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same linux wrapper serves arm64. It is only correct where the platform-tools archive is too — see the arch caveat on androidPlatformTools.",
"sha256VerifiedOn": "2026-10-16",
"executable": true,
"moveTo": "apktool",
"saveAs": "apktool"
}
@@ -370,14 +376,14 @@
"unpack": { "moveTo": "rea-agents" },
"probe": {
"argv": ["{node}", "{vendor}/node_modules/rea-agents/scripts/rea.mjs", "--version"],
"expect": { "regex": "^6\\.4\\.0" }
"expect": { "regex": "^6\\.5\\.0(\\s|$)" }
},
"sources": {
"npm": {
"version": "6.4.0",
"version": "6.5.0",
"registry": "https://registry.npmjs.org/rea-agents",
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1161 entries, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod."
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1650 entries in 6.5.0, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod. 6.5.0 is the same engines/bin contract as 6.4.0 (^22.19.0 || ^24.11.0 || >=26.0.0, scripts/rea.mjs) and adds 37 MCP tools (170 -> 207 by rea doctor --json on 2026-10-16; the upstream contract files count 206) plus an optional frida dependency; --ignore-scripts means its install script never runs."
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-plugin-rea",
"version": "2.0.2",
"version": "2.0.3",
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
"type": "module",
"license": "MIT",
+98
View File
@@ -430,6 +430,104 @@ if (manifest) {
}
}
/* ------------------------- 11. setup and the manifest describe the same set */
// A component added to the manifest but not to setup.mjs's INSTALL_ORDER was
// silently never provisioned, and nothing here said so. The reverse — a name in
// INSTALL_ORDER with no manifest entry — is skipped by the loop's
// `if (!component) continue`, so it is equally silent.
const installOrderMatch = setupText.match(/const INSTALL_ORDER = \[([^\]]*)\]/);
if (!installOrderMatch) {
add("error", "scripts/setup.mjs", "INSTALL_ORDER was not found, so manifest/setup agreement cannot be checked");
} else {
const order = [...installOrderMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
const manifestNames = Object.keys(manifest.components ?? {});
for (const name of manifestNames) {
if (!order.includes(name)) {
add("error", "manifest.components", `"${name}" is not in scripts/setup.mjs INSTALL_ORDER, so setup would silently never provision it`);
}
}
for (const name of order) {
if (!manifestNames.includes(name)) {
add("error", "scripts/setup.mjs", `INSTALL_ORDER names "${name}", which the manifest does not describe`);
}
}
}
/* ---------------------------- 12. the Node engines rule has one wording too */
// REA's engines are restated in the manifest pin, package.json and the node
// probe regex. Only the pin and package.json can be compared as text, but a
// drift between those two would advertise one range and enforce another.
const declaredEngines = manifest.pins?.nodeEngines;
let packageEngines = null;
try {
packageEngines = JSON.parse(readFileSync(path.join(HERE, "..", "package.json"), "utf8")).engines?.node ?? null;
} catch (error) {
add("error", "package.json", `could not be read: ${String(error && error.message)}`);
}
if (!declaredEngines) {
add("error", "manifest.pins.nodeEngines", "is missing, so nothing states REA's engines for the reuse path");
} else if (declaredEngines !== packageEngines) {
add(
"error",
"manifest.pins.nodeEngines",
`is ${JSON.stringify(declaredEngines)} but package.json engines.node is ${JSON.stringify(packageEngines)}`,
);
}
/* ------------------- 13. a pinned version's probe must not accept a prefix */
// The concern is a probe that accepts a *newer* release as the pin, so test the
// expression against the versions that would mean that. The audit is not a
// regex linter: `^6\.4\.0` legitimately rejects 6.4.1 and 6.5.0 — the failure
// mode is only real when the next patch or minor still matches.
const reaAgents = manifest.components?.reaAgents;
const pinnedRea = reaAgents?.sources?.npm?.version;
const reaProbe = reaAgents?.probe?.expect?.regex;
if (pinnedRea && reaProbe) {
let probe = null;
try {
probe = new RegExp(reaProbe, "m");
} catch (error) {
add("error", "reaAgents.probe.expect.regex", `is not a valid regular expression: ${String(error && error.message)}`);
}
if (probe) {
if (!probe.test(pinnedRea)) {
add("error", "reaAgents.probe.expect.regex", `does not match the pinned version ${pinnedRea}`);
}
const [major, minor, patch] = pinnedRea.split(".").map((part) => Number(part));
for (const [label, next] of [
["the next patch", `${major}.${minor}.${patch + 1}`],
["the next minor", `${major}.${minor + 1}.0`],
]) {
if (Number.isFinite(next.split(".")[2]) && probe.test(next)) {
add("error", "reaAgents.probe.expect.regex", `accepts ${next} (${label}), so a newer release passes as the pin`);
}
}
}
}
/* --------------------- 14. a POSIX wrapper must be marked executable */
// copyFileSync carries the archive's 0644 and nothing else changes a mode, while
// REA spawns REA_APKTOOL_COMMAND without a shell: on Linux/macOS an unmarked
// wrapper fails with EACCES even though the component probe (`java -jar`) passes.
// Only the manifest can say which files need the bit, so require it here.
const apktoolWrappers = manifest.components?.apktool?.entries?.byPlatform ?? {};
for (const [platform, list] of Object.entries(apktoolWrappers)) {
if (platform.startsWith("win32")) continue;
for (const entry of list) {
if (entry.executable !== true) {
add(
"error",
`apktool.entries.byPlatform.${platform}`,
`the ${platform} wrapper is not marked "executable": true, so it lands 0644 and cannot be spawned without a shell`,
);
}
}
}
/* ------------------------------------------------------------- summary */
const pinned = fetchEntries.filter((entry) => entry.entry.sha256 && entry.entry.sha256 !== "unverified").length;
+37 -10
View File
@@ -1,4 +1,4 @@
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
#
# Why this exists: setup.mjs is written in Node, so a machine with no Node
# cannot run it. This script uses only PowerShell + Expand-Archive (no
@@ -39,8 +39,12 @@ $Root = [System.IO.Path]::GetFullPath($Root)
$stack = Join-Path $Root 'stack'
$cache = Join-Path $Root 'cache'
$logs = Join-Path $Root 'logs'
foreach ($dir in @($Root, $stack, $cache, $logs)) {
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
# -DryRun promises that nothing was changed, so it must not create these either:
# it only reads them, and Test-Path / Get-ChildItem handle a missing path.
if (-not $DryRun) {
foreach ($dir in @($Root, $stack, $cache, $logs)) {
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
}
}
$pluginRoot = Split-Path -Parent $PSScriptRoot
@@ -84,6 +88,32 @@ $nodeFolder = [System.IO.Path]::GetFileNameWithoutExtension($nodeZipName)
if ($nodeSha256 -eq 'unverified') { throw "the manifest pins no checksum for $hostKey; refusing to download unverified bytes" }
Write-Step "Node source: $hostKey v$nodeVersion"
$script:NodeEngines = [string]$manifest.pins.nodeEngines
if (-not $script:NodeEngines) { throw "the manifest pins no nodeEngines, so Node compatibility cannot be judged" }
# REA's engines come from the manifest pin, which the rest of provisioning already
# treats as the single source of truth. This used to be a second copy of the same
# numbers in a comment, and two copies drift: the audit now asserts that the pin
# keeps the shape this parser understands.
function Test-EnginesMatch([string]$Clean, [string]$Range) {
$versions = @()
foreach ($part in $Clean.Split('.')) {
$n = 0
if (-not [int]::TryParse($part, [ref]$n)) { return $false }
$versions += $n
}
if ($versions.Count -lt 2) { return $false }
foreach ($clause in ($Range -split '\|\|')) {
$c = $clause.Trim()
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
if ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2]) { return $true }
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
if ($versions[0] -gt [int]$Matches[1] -or ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2])) { return $true }
}
}
return $false
}
function Test-NodeUsable([string]$Exe) {
if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
try {
@@ -91,13 +121,10 @@ function Test-NodeUsable([string]$Exe) {
} catch { return $null }
if (-not $version) { return $null }
$clean = $version.TrimStart('v').Trim()
$parts = $clean.Split('.')
if ($parts.Count -lt 2) { return $null }
$major = [int]$parts[0]
$minor = [int]$parts[1]
# REA's engines: ^22.19.0 || ^24.11.0 || >=26.0.0
$ok = ($major -eq 22 -and $minor -ge 19) -or ($major -eq 24 -and $minor -ge 11) -or ($major -ge 26)
if (-not $ok) { return $null }
# A version that is not three numbers (`vX.Y`) used to reach `[int]` and throw
# under $ErrorActionPreference='Stop', aborting the whole bootstrap instead of
# falling through to the portable download — the one path this script exists for.
if (-not (Test-EnginesMatch $clean $script:NodeEngines)) { return $null }
return [pscustomobject]@{ Path = (Resolve-Path $Exe).Path; Version = $clean }
}
+256 -63
View File
@@ -26,7 +26,7 @@
*/
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { appendFileSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
import { appendFileSync, chmodSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { homedir } from "node:os";
@@ -307,11 +307,20 @@ function openGet(url, { proxy, headers, connectTimeoutMs }) {
},
(res) => done(res, "direct"),
);
let connected = false;
req.on("socket", (socket) => {
socket.setTimeout(connectTimeout);
socket.once("connect", () => socket.setTimeout(idleTimeout));
socket.once("connect", () => {
connected = true;
socket.setTimeout(idleTimeout);
});
});
req.on("timeout", () => req.destroy(new Error(`no response within ${idleTimeout}ms`)));
// The socket carries the connect deadline until it connects and the idle
// deadline afterwards. Reporting the idle number for a connection that never
// happened sent the reader after the wrong setting.
req.on("timeout", () =>
req.destroy(new Error(connected ? `no data within ${idleTimeout}ms` : `no connection within ${connectTimeout}ms`)),
);
req.on("error", reject);
req.end();
return;
@@ -387,11 +396,27 @@ async function download(url, destination, expected, label) {
rmSync(temporary, { force: true });
have = 0;
}
if (have > 0 && have === expected.size) {
log(` ${label}: partial file already complete`);
} else {
log(` downloading ${label}${have > 0 ? ` (resuming at ${(have / 1048576).toFixed(1)} MiB)` : ""}`);
// A `.part` already holding every expected byte is finished work, not a resume
// point: a crash between the last write and the rename leaves exactly this
// shape. Resuming it sends `Range: bytes=<size>-`, which servers answer with
// 416, so four attempts later a complete download was reported failed (and on
// Windows the "resume" simply deleted it and fetched 570 MB again). Verify it
// and finish the rename instead.
if (have > 0 && expected.size && have === expected.size) {
const digest = sha256File(temporary);
const matches = !expected.sha256 || expected.sha256 === "unverified" || digest === expected.sha256;
if (matches) {
log(` ${label}: partial file was already complete; verified and kept`);
rmSync(destination, { force: true });
renameSync(temporary, destination);
return { sha256: digest, size: have, tofu: !expected.sha256 || expected.sha256 === "unverified" };
}
log(` ${label}: partial file is complete but the checksum does not match; starting over`);
rmSync(temporary, { force: true });
have = 0;
}
if (have > 0) log(` downloading ${label} (resuming at ${(have / 1048576).toFixed(1)} MiB)`);
else log(` downloading ${label}`);
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
@@ -412,29 +437,29 @@ async function download(url, destination, expected, label) {
let via = null;
let nodeFailure = null;
const tryPowerShellFirst = IS_WINDOWS;
const first = tryPowerShellFirst
? async () => {
rmSync(temporary, { force: true });
const written = downloadViaPowerShell(url, temporary);
log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
return "powershell";
}
: async () => {
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
return response.via;
};
const second = tryPowerShellFirst
? async () => {
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
return response.via;
}
: async () => {
rmSync(temporary, { force: true });
const written = downloadViaPowerShell(url, temporary);
return `powershell (${(written / 1048576).toFixed(1)} MiB)`;
};
const viaPowerShell = async () => {
// Resume through the .NET route as well: it is the Windows default, it
// honours the system proxy, and the 570 MB Ghidra archive is the transfer
// that matters. Deleting the partial file first — what this used to do —
// made every retry a full re-download, the opposite of the resumable
// behaviour this module promises. downloadViaPowerShell appends only when
// the server answers 206.
const written = downloadViaPowerShell(url, temporary, have);
log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
return "powershell";
};
const viaNode = async () => {
// Both routes write into the same .part. Trusting the `have` captured
// before this attempt appended a full 200 response onto the bytes the
// failed route had already written, producing a file that could only fail
// the size gate on the way out. Read the file's real length instead.
have = existsSync(temporary) ? statSync(temporary).size : 0;
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
return response.via;
};
const first = tryPowerShellFirst ? viaPowerShell : viaNode;
const second = tryPowerShellFirst ? viaNode : viaPowerShell;
try {
via = await first();
@@ -497,20 +522,33 @@ async function download(url, destination, expected, label) {
* length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s), so it is a fallback, and
* the result is verified by the same size + checksum gate as every other route.
*/
function downloadViaPowerShell(url, destination) {
function downloadViaPowerShell(url, destination, have = 0) {
const resume = Number.isFinite(Number(have)) && Number(have) > 0;
const script = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
`$url = '${url.replace(/'/g, "''")}'`,
`$out = '${destination.replace(/'/g, "''")}'`,
`$have = ${resume ? Math.trunc(Number(have)) : 0}`,
"$req = [System.Net.HttpWebRequest]::Create($url)",
"$req.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) dsh-plugin-rea-bundle'",
"$req.AllowAutoRedirect = $true",
// The response stage. The body-copy stage needs its own deadline: with only
// Timeout set, a stalled socket inside CopyTo hung forever (spawnSync runs
// with no total timeout by design, because a slow-but-alive 570 MB transfer
// is legitimate).
"$req.Timeout = 60000",
"$req.ReadWriteTimeout = 120000",
// Resume instead of starting over. A server that ignores the range answers
// 200, and the file is then rewritten from the start rather than appended to.
"if ($have -gt 0) { $req.AddRange($have) }",
"$resp = $req.GetResponse()",
"$code = [int]$resp.StatusCode",
"$stream = $resp.GetResponseStream()",
"$file = [System.IO.File]::Create($out)",
"if ($have -gt 0 -and $code -eq 206) { $mode = [System.IO.FileMode]::Append } else { $mode = [System.IO.FileMode]::Create }",
"$file = New-Object System.IO.FileStream($out, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)",
"$stream.CopyTo($file)",
"$file.Flush()",
"$file.Close()",
"$stream.Close()",
"$resp.Close()",
@@ -614,6 +652,12 @@ function extract(archive, destination, format) {
mkdirSync(destination, { recursive: true });
if (format === "zip") {
if (IS_WINDOWS) {
// bsdtar ships with Windows 10+ and unpacks a zip far faster than
// Expand-Archive's single-threaded .NET implementation — the 543 MB Ghidra
// archive is the case that matters. Expand-Archive stays as the fallback so
// a host without tar.exe still provisions.
const viaTar = spawnSync("tar", ["-xf", archive, "-C", destination], { stdio: options.json ? "ignore" : "inherit" });
if (viaTar.status === 0) return true;
const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command",
`Expand-Archive -LiteralPath '${archive.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`],
{ stdio: options.json ? "ignore" : "inherit" });
@@ -704,7 +748,28 @@ function substituteProbe(value, context) {
function runProbe(probe, context) {
if (probe.file) {
const file = substituteProbe(probe.file, context);
return { ok: existsSync(file), detail: file };
if (!existsSync(file)) return { ok: false, detail: `${file} is missing` };
// `expect` used to be ignored for file probes, which made the Ghidra probe's
// `expect.contains: "application.version="` dead configuration: any file at
// that path passed, including a directory or a stray placeholder. Read the
// file — these are small metadata files — and honour the contract. The bound
// exists so a probe can never be pointed at a 543 MB tree by accident.
const expect = probe.expect ?? {};
if (expect.contains || expect.regex) {
let text = "";
try {
text = readFileSync(file, "utf8").slice(0, 65536);
} catch (error) {
return { ok: false, detail: `${file} could not be read: ${String(error && error.message)}` };
}
if (expect.contains && !text.includes(expect.contains)) {
return { ok: false, detail: `${file} does not contain "${expect.contains}"` };
}
if (expect.regex && !new RegExp(expect.regex, "m").test(text)) {
return { ok: false, detail: `${file} does not match /${expect.regex}/` };
}
}
return { ok: true, detail: file };
}
if (!probe.argv) return { ok: true, detail: "no probe" };
const argv = probe.argv.map((value) => substituteProbe(value, context));
@@ -866,6 +931,25 @@ function loadManifest() {
return parsed;
}
/**
* Read a bundle.json, tolerating one that is not valid JSON.
*
* A half-written file (disk full, a kill mid-write) used to throw straight out of
* provisioning — the run died before it could replace the file it could not
* parse, and the shim then failed on it forever. Treating it as absent lets the
* normal path read what is usable and rewrite the record at the end.
*/
function readBundleJson(file) {
if (!existsSync(file)) return null;
try {
const parsed = JSON.parse(readFileSync(file, "utf8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch (error) {
log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
return null;
}
}
const INSTALL_ORDER = ["node", "jdk", "ghidra", "apktool", "jadxHeadlessMcp", "androidPlatformTools", "reaAgents"];
/* -------------------------------------------------------------------- stages */
@@ -878,8 +962,26 @@ async function provisionComponent(name, component, context) {
// makes the recorded-state check below redundant (and its message wrong).
const reused = reuseExisting(name, root);
if (reused) {
step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
return { path: reused.path, reused: true };
// A candidate is a claim until the component's own probe agrees. Adoption
// tested only that javac / analyzeHeadless / node exists, so a JDK 11 (Ghidra
// 12.1 needs 21+) or a Ghidra 11 install was recorded in bundle.json and the
// failure surfaced much later, inside REA. Run the same probe a provisioned
// copy has to pass, with the candidate planted so the probe's own tokens
// ({jvm}, {ghidra}) resolve to it instead of to a vendor path it never had.
const planted = context.resolved[name];
context.resolved[name] = { path: reused.path };
let probe;
try {
probe = runProbe(component.probe ?? {}, context);
} finally {
if (planted === undefined) delete context.resolved[name];
else context.resolved[name] = planted;
}
if (probe.ok) {
step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
return { path: reused.path, reused: true };
}
step(name, "plan", `not reusing ${reused.how} ${name} at ${reused.path}: ${probe.detail}`);
}
// 2) already present in the bundle and still verifiable?
@@ -889,8 +991,8 @@ async function provisionComponent(name, component, context) {
// deleted afterwards. Only a recorded path that exists AND passes the probe is
// trusted; anything else falls through to provisioning again.
if (existsSync(path.join(root, "bundle.json"))) {
const current = JSON.parse(readFileSync(path.join(root, "bundle.json"), "utf8"));
const recorded = current.components?.[name];
const current = readBundleJson(path.join(root, "bundle.json"));
const recorded = current?.components?.[name];
if (recorded && typeof recorded.path === "string" && existsSync(recorded.path)) {
const probe = runProbe(component.probe ?? {}, context);
if (probe.ok) {
@@ -957,6 +1059,18 @@ async function provisionComponent(name, component, context) {
const target = path.join(targetDirectory, entry.saveAs ?? archiveName);
const { copyFileSync } = await import("node:fs");
copyFileSync(archivePath, target);
// A wrapper script has to be executable. copyFileSync lands the 0644 that
// the archive carries and nothing else in this file changes a mode, while
// REA starts REA_APKTOOL_COMMAND through execFile/no shell — so on POSIX a
// 0644 wrapper fails with EACCES. The component's own probe misses it
// because it runs `java -jar` and never executes the wrapper.
if (entry.executable === true) {
try {
chmodSync(target, 0o755);
} catch {
/* a filesystem that cannot carry the bit must not fail the component */
}
}
placed.push({ target, sha256: fetched.sha256, tofu: fetched.tofu });
}
if (options.dryRun) return { planned: true };
@@ -1185,17 +1299,72 @@ const context = {
};
const results = {};
/**
* What a previous run recorded.
*
* `--only` names a subset of components, so this run's `results` describes only
* that subset — and writing bundle.json straight from `results` erased every
* other component: the JDK and Ghidra disappeared from `paths`, the shim stopped
* exporting JAVA_HOME/GHIDRA_INSTALL_DIR, so `setup.mjs --only androidPlatformTools`
* silently disabled native analysis on a fully provisioned machine, and the next
* full run had to unpack 750 MB again. Components this run does not touch are
* carried over instead.
*/
const previous = readBundleJson(path.join(options.root, "bundle.json"));
/** One untouched component's record, kept only while its recorded path exists. */
function carriedOver(name) {
const record = previous?.components?.[name];
if (!record || typeof record !== "object") return null;
if (typeof record.path === "string" && record.path !== "") {
if (existsSync(record.path)) return record;
return { ...record, path: null, unavailable: true, reason: "the recorded path is gone" };
}
return record;
}
for (const name of INSTALL_ORDER) {
const component = manifest.components[name];
if (!component) continue;
if (options.only && !options.only.includes(name)) continue;
const missingRequirement = (component.requires ?? []).find((requirement) => !context.resolved[requirement]);
if (missingRequirement && !options.only) {
step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
results[name] = null;
continue;
if (missingRequirement) {
// Under --only the requirement is not being provisioned this run, but the
// previous run's record still answers it — and that is the same answer a full
// run would use. Skipping the check entirely made `--only apktool` resolve
// {jvm} to the vendor/jdk that only a full run creates and report FAIL, on a
// machine whose JDK was recorded right next to it.
let carried = options.only ? carriedOver(missingRequirement) : null;
if (!carried && options.only) {
// A requirement can also be answered by the machine instead of the record.
// `node` always can: a Node is already running this script, and
// provisionReaAgents falls back to exactly that interpreter — without this,
// `--only reaAgents` on a fresh root failed on a "missing" node the run is
// itself made of. The others are answered when the caller passed --reuse.
if (missingRequirement === "node") {
carried = { path: process.execPath, reused: true, how: "the interpreter running setup" };
} else {
const reused = reuseExisting(missingRequirement, options.root);
if (reused) carried = { path: reused.path, reused: true, how: reused.how };
}
}
if (!carried || typeof carried.path !== "string" || !existsSync(carried.path)) {
step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
results[name] = null;
continue;
}
context.resolved[missingRequirement] = carried;
}
let resolvedValue = null;
try {
resolvedValue = await provisionComponent(name, component, context);
} catch (error) {
// An unexpected throw used to abort the run before it could write a summary
// or record why its lock is still there. Report it as this component's
// failure instead, so the exit code and the log stay truthful.
step(name, "FAIL", `unexpected error: ${String(error?.message ?? error)}`);
resolvedValue = null;
}
const resolvedValue = await provisionComponent(name, component, context);
results[name] = resolvedValue;
// A component satisfies another's `requires` when it has a real path — or when
// this run only *planned* it. In dry-run every component returns
@@ -1208,27 +1377,36 @@ for (const name of INSTALL_ORDER) {
}
}
/* Components this run does not touch keep the record the previous run wrote. */
const merged = { ...results };
for (const name of INSTALL_ORDER) {
if (Object.prototype.hasOwnProperty.call(merged, name)) continue;
if (!manifest.components[name]) continue;
const carried = carriedOver(name);
if (carried) merged[name] = carried;
}
/* browser is adopted, never downloaded */
const browser = reuseExisting("browser", options.root);
if (browser) step("browser", "skip", `adopting ${browser.path}`);
/* --------------------------------------------------------------- wire + probe */
const nodePath = typeof results.node?.path === "string" && existsSync(results.node.path)
? results.node.path
const nodePath = typeof merged.node?.path === "string" && existsSync(merged.node.path)
? merged.node.path
: process.execPath;
const paths = {
node: nodePath,
reaEntry: path.join(options.root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs"),
ghidra: results.ghidra?.path ?? null,
jdk: results.jdk?.path ?? null,
adb: results.androidPlatformTools?.path ? path.join(results.androidPlatformTools.path, `adb${EXE}`) : null,
apktoolCommand: results.apktool?.path
? path.join(results.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
ghidra: merged.ghidra?.path ?? null,
jdk: merged.jdk?.path ?? null,
adb: merged.androidPlatformTools?.path ? path.join(merged.androidPlatformTools.path, `adb${EXE}`) : null,
apktoolCommand: merged.apktool?.path
? path.join(merged.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
: null,
jadxJar: results.jadxHeadlessMcp?.path
? path.join(results.jadxHeadlessMcp.path, results.jadxHeadlessMcp.jarName ?? "")
jadxJar: merged.jadxHeadlessMcp?.path
? path.join(merged.jadxHeadlessMcp.path, merged.jadxHeadlessMcp.jarName ?? "")
: null,
browser: browser?.path ?? null,
ilspycmd: process.env.REA_ILSPY_CMD_PATH ?? null,
@@ -1242,17 +1420,17 @@ if (!options.dryRun) {
plugin: { name: "dsh-plugin-rea", root: sourceRoot() },
paths,
versions: {
reaAgents: results.reaAgents?.version ?? null,
ghidra: results.ghidra?.version ?? null,
jdk: results.jdk?.version ?? null,
apktool: results.apktool?.version ?? null,
jadxHeadlessMcp: results.jadxHeadlessMcp?.version ?? null,
node: results.node?.version ?? process.versions.node,
reaAgents: merged.reaAgents?.version ?? null,
ghidra: merged.ghidra?.version ?? null,
jdk: merged.jdk?.version ?? null,
apktool: merged.apktool?.version ?? null,
jadxHeadlessMcp: merged.jadxHeadlessMcp?.version ?? null,
node: merged.node?.version ?? process.versions.node,
},
components: Object.fromEntries(
Object.entries(results).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
Object.entries(merged).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
),
unverifiedChecksums: Object.entries(results)
unverifiedChecksums: Object.entries(merged)
.filter(([, value]) => value?.tofu)
.map(([name]) => name),
};
@@ -1417,6 +1595,25 @@ const failures = state.steps.filter((entry) => entry.status === "FAIL");
const failed = failures.filter((entry) => !optionalFailure(entry));
const optionalUnavailable = failures.filter(optionalFailure);
/*
* Lock bookkeeping is not presentation, and it used to sit inside the
* non-`--json` branch: a successful `--json` run therefore left the lock behind,
* and the shim then reported "provisioning started in the background" for six
* hours on a fully provisioned machine. A failing run keeps the lock on purpose —
* so a restart loop cannot re-download forever — but marks it failed, which lets
* the shim say what actually happened instead of claiming a run is in flight.
*/
const lockFile = path.join(options.root, "setup.lock");
if (failed.length === 0) {
rmSync(lockFile, { force: true });
} else {
try {
writeFileSync(lockFile, JSON.stringify({ state: "failed", pid: process.pid, at: Date.now(), exit: 1, log: logFilePath() }) + "\n");
} catch {
/* the lock is advisory: a read-only root must not break the report */
}
}
if (options.json) {
process.stdout.write(
JSON.stringify(
@@ -1436,10 +1633,6 @@ if (options.json) {
}
log("");
if (failed.length === 0) {
// Release the provisioning lock the shim set, so a later first run on a new
// machine (or after a manual wipe) can start again. A failed run keeps the
// lock, but the shim treats a lock older than six hours as stale.
rmSync(path.join(options.root, "setup.lock"), { force: true });
log("restart DSH (or reload the profile) so the MCP row reconnects.");
// Point at the mirrored copy, not at this checkout's: the mirror resolves its
// bundle from its own location, so this command is right for the root this run
+515 -4
View File
@@ -11,8 +11,10 @@
* Runs with `node --test`. No network, no filesystem writes outside a temp dir.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
@@ -319,7 +321,17 @@ function runSetupNodeOnly(setupPath, root, env) {
});
}
test("--reuse still adopts a compatible node", () => {
test("--reuse still adopts a compatible node", (t) => {
// The candidate is the host's own interpreter, so this test only means anything
// where the host satisfies REA's engines: on a Node 20 runner it fails for a
// reason that has nothing to do with the reuse check (the check is doing its
// job), while CI's pinned Node 22 never notices.
const [major, minor] = process.versions.node.split(".").map(Number);
const compatible = (major === 22 && minor >= 19) || (major === 24 && minor >= 11) || major >= 26;
if (!compatible) {
t.skip(`host Node ${process.versions.node} is outside REA's engines; the candidate would be rejected on purpose`);
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-node-ok-"));
try {
const run = runSetupNodeOnly(SETUP_SOURCE, root, { REA_BUNDLE_NODE: process.execPath });
@@ -395,7 +407,7 @@ test("an unavailable optional component is reported, not fatal", () => {
mkdirSync(stub, { recursive: true });
writeFileSync(
path.join(stub, "rea.mjs"),
'if (process.argv.includes("--version")) { console.log("6.4.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
'if (process.argv.includes("--version")) { console.log("6.5.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
);
const run = spawnSync(process.execPath, [SETUP_SOURCE, "--offline", "--only", "androidPlatformTools", "--root", root], {
@@ -416,3 +428,502 @@ test("an unavailable optional component is reported, not fatal", () => {
rmSync(root, { recursive: true, force: true });
}
});
/* =========================== this round's regressions =========================
*
* Each test below pins a defect that was verified in the code before it was
* changed. They run setup.mjs itself (never a mirror of it) against a throwaway
* plugin tree, so the whole wiring — manifest → download → place → wire — is the
* real one. Nothing here reaches the network: a component is either satisfied
* from a primed cache, or its download is *supposed* to return before opening a
* socket.
*/
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const REPO_SHIM = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
/** A throwaway plugin tree: setup.mjs, the real manifest, and the shim. */
function makePluginTree(root, mutateManifest) {
const plugin = path.join(root, "plugin");
mkdirSync(path.join(plugin, "scripts"), { recursive: true });
mkdirSync(path.join(plugin, "manifest"), { recursive: true });
mkdirSync(path.join(plugin, "bin"), { recursive: true });
copyFileSync(SETUP_SOURCE, path.join(plugin, "scripts", "setup.mjs"));
copyFileSync(REPO_SHIM, path.join(plugin, "bin", "rea-shim.mjs"));
const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
if (mutateManifest) mutateManifest(manifest);
writeFileSync(path.join(plugin, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
return path.join(plugin, "scripts", "setup.mjs");
}
/** A `file`-kind component, used to replace a real one in a test manifest. */
function testComponent(url, payload, extra = {}) {
return {
description: "test vehicle",
kind: "file",
license: "test",
requires: extra.requires ?? [],
optional: extra.optional ?? false,
probe: extra.probe ?? {},
entries: {
all: [
{
id: "payload",
version: "1",
url,
format: "file",
size: payload.length,
sha256: createHash("sha256").update(payload).digest("hex"),
moveTo: extra.moveTo ?? "payload",
saveAs: extra.saveAs ?? "payload.bin",
...(extra.executable ? { executable: true } : {}),
},
],
},
};
}
/** A stub vendored CLI, so the probe/shim stages have something to run. */
function addReaStub(bundleRoot, version = "6.5.0") {
const dir = path.join(bundleRoot, "vendor", "node_modules", "rea-agents", "scripts");
mkdirSync(dir, { recursive: true });
const identity = JSON.stringify({ cli_package_version: version, catalog: { counts: { mcp_tools: 206 } } });
writeFileSync(
path.join(dir, "rea.mjs"),
'if (process.argv.includes("--version")) { console.log(' + JSON.stringify(version) + '); }\n' +
"else { console.log(JSON.stringify({ healthy: true, checks: [], identity: " + identity + " })); }\n",
);
}
function runToCompletion(command, args, options) {
return new Promise((resolve) => {
const child = spawn(command, args, options);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.on("error", (error) => resolve({ status: -1, stdout, stderr: String(error) }));
child.on("close", (code) => resolve({ status: code, stdout, stderr }));
});
}
async function waitFor(predicate, timeoutMs = 8000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (predicate()) return true;
if (Date.now() > deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/* ------------------------------- --only keeps what it did not touch -------- */
test("--only keeps the records of the components it did not touch", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-only-keep-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
const jdkHome = path.join(root, "jdk-home");
mkdirSync(jdkHome, { recursive: true });
// A provisioned machine: the JDK and Node are recorded, and only adb is being
// topped up. Writing bundle.json from this run's results alone erased the
// other two, so the shim stopped exporting JAVA_HOME/GHIDRA_INSTALL_DIR.
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify(
{
schema: 1,
paths: { node: process.execPath, jdk: jdkHome, ghidra: null, adb: null },
versions: { reaAgents: "6.5.0", node: process.versions.node, jdk: "21" },
components: {
node: { path: process.execPath, version: process.versions.node },
jdk: { path: jdkHome, version: "21" },
},
},
null,
2,
) + "\n",
);
addReaStub(bundle);
const setup = makePluginTree(root, (manifest) => {
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional: true });
});
const run = spawnSync(process.execPath, [setup, "--offline", "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const written = JSON.parse(readFileSync(path.join(bundle, "bundle.json"), "utf8"));
assert.equal(written.components.jdk.path, jdkHome, "the jdk record must survive an --only run");
assert.equal(written.paths.jdk, jdkHome, "and so must the path the shim exports as JAVA_HOME");
assert.equal(written.components.node.path, process.execPath);
assert.equal(written.paths.node, process.execPath);
// The component that did run failed (offline), and stays unavailable: the
// merge must not invent a path for it.
assert.equal(written.paths.adb, null);
assert.equal(written.components.androidPlatformTools.unavailable, true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("--only satisfies a requirement from the record instead of skipping the check", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-only-requires-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
const jdkHome = path.join(root, "jdk-home");
mkdirSync(jdkHome, { recursive: true });
const setup = makePluginTree(root, () => {});
const args = (extra) => [setup, "--reuse", "--dry-run", "--only", "apktool", "--root", bundle, ...extra];
// JAVA_HOME is also a way "the machine" answers this requirement, so it is
// cleared: the first case needs the requirement to be genuinely unavailable.
const env = { ...process.env, JAVA_HOME: "" };
// No record at all: the requirement is genuinely unavailable, and that must be
// reported exactly as a full run reports it.
const missing = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
assert.match(missing.stdout, /required component "jdk" is unavailable/);
// With a recorded JDK the requirement is answered by the record, so apktool
// plans instead of failing on a vendor/jdk that --only never provisions.
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify({ schema: 1, paths: { jdk: jdkHome }, versions: { jdk: "21" }, components: { jdk: { path: jdkHome, version: "21" } } }, null, 2) + "\n",
);
const recorded = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
assert.equal(recorded.status, 0, `${recorded.stdout}\n${recorded.stderr}`);
assert.doesNotMatch(recorded.stdout, /required component "jdk" is unavailable/);
assert.match(recorded.stdout, /\[plan\] apktool/);
// And `--only reaAgents` on a fresh root must still work: the node it needs is
// the interpreter running setup, which is what provisionReaAgents already uses.
const fresh = path.join(root, "fresh");
const reaOnly = spawnSync(process.execPath, [setup, "--dry-run", "--only", "reaAgents", "--root", fresh], { encoding: "utf8", timeout: 120000, env });
assert.equal(reaOnly.status, 0, `${reaOnly.stdout}\n${reaOnly.stderr}`);
assert.doesNotMatch(reaOnly.stdout, /required component "node" is unavailable/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------- a complete .part is finished work */
test("a complete .part is verified and kept instead of re-requested", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-part-complete-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const payload = Buffer.from("the whole file, already on disk\n");
// The URL points at a closed port on purpose: the fixed code must complete
// without opening a socket. Before the fix it sent `Range: bytes=<size>-`,
// which a real server answers with 416, and four attempts later it reported a
// download that was already finished as failed.
const setup = makePluginTree(root, (manifest) => {
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", payload);
});
addReaStub(bundle);
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload);
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /was already complete; verified and kept/);
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------------- the PowerShell route resumes */
test("the PowerShell route resumes from the bytes already on disk", async (t) => {
if (process.platform !== "win32") {
// The Node route pins TLS (`httpsRequest`) whatever the URL scheme says, so a
// plain-HTTP fixture can only exercise the PowerShell route — which is also the
// Windows default and the one the resume defect was in.
t.skip("the PowerShell download route only exists on Windows");
return;
}
const payload = Buffer.alloc(1024 * 1024, 11);
const alreadyHave = 256 * 1024;
const seen = [];
const server = createServer((req, res) => {
req.on("error", () => {});
res.on("error", () => {});
const range = req.headers.range ?? null;
seen.push(range);
const start = range ? Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0) : 0;
if (range && start >= payload.length) {
res.writeHead(416);
res.end();
return;
}
const slice = payload.subarray(start);
const headers = { "content-type": "application/octet-stream", "content-length": String(slice.length) };
if (start > 0) headers["content-range"] = `bytes ${start}-${payload.length - 1}/${payload.length}`;
res.writeHead(start > 0 ? 206 : 200, headers);
res.end(slice);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const root = mkdtempSync(path.join(tmpdir(), "rea-ps-resume-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const setup = makePluginTree(root, (manifest) => {
manifest.components.androidPlatformTools = testComponent(`http://127.0.0.1:${port}/payload.bin`, payload);
});
addReaStub(bundle);
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload.subarray(0, alreadyHave));
const run = await runToCompletion(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.ok(
seen.some((header) => header && Number(/bytes=(\d+)-/.exec(header)?.[1]) === alreadyHave),
`expected a resume request starting at ${alreadyHave}; saw ${JSON.stringify(seen)}`,
);
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------------- a wrapper must be executable */
test("a wrapper marked executable lands with the exec bit on POSIX", (t) => {
if (process.platform === "win32") {
t.skip("POSIX exec bits do not exist on Windows; the flag is honoured but meaningless there");
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-exec-bit-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const marked = Buffer.from("#!/bin/sh\necho wrapper\n");
const plain = Buffer.from("not a script\n");
const setup = makePluginTree(root, (manifest) => {
const component = testComponent("http://127.0.0.1:9/wrapper.sh", marked, { executable: true, saveAs: "wrapper.sh", moveTo: "tool" });
component.entries.all.push({
id: "plain",
version: "1",
url: "http://127.0.0.1:9/plain.sh",
format: "file",
size: plain.length,
sha256: createHash("sha256").update(plain).digest("hex"),
moveTo: "tool",
saveAs: "plain.sh",
});
manifest.components.androidPlatformTools = component;
});
addReaStub(bundle);
// Prime the cache so no socket is opened and the mode is the only variable.
writeFileSync(path.join(bundle, "cache", "wrapper.sh"), marked);
writeFileSync(path.join(bundle, "cache", "plain.sh"), plain);
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
const wrapperMode = statSync(path.join(bundle, "vendor", "tool", "wrapper.sh")).mode;
const plainMode = statSync(path.join(bundle, "vendor", "tool", "plain.sh")).mode;
assert.notEqual(wrapperMode & 0o111, 0, `the marked wrapper must be executable, mode ${wrapperMode.toString(8)}`);
assert.equal(plainMode & 0o111, 0, "an unmarked file must keep the mode the archive gave it");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------- a file probe's expect is honoured */
test("a file probe honours expect.contains", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-file-probe-"));
try {
const make = (content) => {
const bundle = path.join(root, `bundle-${content.length}`);
mkdirSync(path.join(bundle, "cache"), { recursive: true });
const payload = Buffer.from(content);
const component = testComponent("http://127.0.0.1:9/marker.txt", payload, { saveAs: "marker.txt", moveTo: "probe" });
component.probe = { file: "{vendor}/probe/marker.txt", expect: { contains: "application.version=" } };
const setup = makePluginTree(path.join(root, `tree-${content.length}`), (manifest) => {
manifest.components.androidPlatformTools = component;
});
addReaStub(bundle);
writeFileSync(path.join(bundle, "cache", "marker.txt"), payload);
return spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
};
const ok = make("application.version=12.1.4\n");
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
// The file exists either way; only its content decides. Before the fix the
// `expect` on a file probe was dead configuration and this case passed.
const wrong = make("this is not a Ghidra properties file\n");
assert.equal(wrong.status, 1, `${wrong.stdout}\n${wrong.stderr}`);
assert.match(wrong.stdout, /does not contain "application\.version="/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------- a reused engine must pass its own probe */
test("a reused component must pass the probe a provisioned copy would face", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-reuse-probe-"));
try {
const install = path.join(root, "ghidra");
mkdirSync(path.join(install, "support"), { recursive: true });
mkdirSync(path.join(install, "Ghidra"), { recursive: true });
writeFileSync(path.join(install, "support", process.platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless"), "");
const properties = path.join(install, "Ghidra", "application.properties");
const setup = makePluginTree(root, (manifest) => {
// The real manifest needs a JDK for ghidra; this test is about the reuse
// probe, so the requirement is removed rather than faked.
manifest.components.ghidra.requires = [];
});
const bundle = path.join(root, "bundle");
const args = ["--reuse", "--dry-run", "--only", "ghidra", "--root", bundle];
const env = { ...process.env, GHIDRA_INSTALL_DIR: install };
writeFileSync(properties, "application.version=12.1.4\n");
const good = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
assert.equal(good.status, 0, `${good.stdout}\n${good.stderr}`);
assert.match(good.stdout, /reusing existing ghidra from GHIDRA_INSTALL_DIR/);
// Adoption used to test only that analyzeHeadless exists, so any directory
// shaped like a Ghidra install was recorded and the failure surfaced inside REA.
writeFileSync(properties, "not a Ghidra properties file\n");
const bad = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
assert.doesNotMatch(bad.stdout, /reusing existing ghidra/);
assert.match(bad.stdout, /not reusing GHIDRA_INSTALL_DIR ghidra/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
/* ------------------------------------------------------------- the shim's lock */
function makeBundleTree(root, { marker, lock }) {
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "bin"), { recursive: true });
mkdirSync(path.join(bundle, "scripts"), { recursive: true });
mkdirSync(path.join(bundle, "manifest"), { recursive: true });
copyFileSync(REPO_SHIM, path.join(bundle, "bin", "rea-shim.mjs"));
writeFileSync(path.join(bundle, "scripts", "setup.mjs"), 'import { writeFileSync } from "node:fs";\nwriteFileSync(' + JSON.stringify(marker) + ', "ran");\n');
writeFileSync(path.join(bundle, "manifest", "bundle.manifest.json"), "{}\n");
if (lock !== undefined) writeFileSync(path.join(bundle, "setup.lock"), lock);
return bundle;
}
function runShim(bundle, args) {
const env = { ...process.env, REA_BUNDLE_ROOT: bundle };
delete env.REA_BUNDLE_AUTO_PROVISION;
return spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), ...args], { encoding: "utf8", timeout: 60000, env });
}
test("a malformed bundle.json starts a provisioning run instead of failing forever", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-malformed-"));
try {
const marker = path.join(root, "setup-ran");
const bundle = makeBundleTree(root, { marker, lock: undefined });
writeFileSync(path.join(bundle, "bundle.json"), "{ this is not json");
const run = runShim(bundle, ["mcp"]);
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /provisioning started in the background/);
assert.ok(await waitFor(() => existsSync(marker)), "setup was never started for a malformed bundle.json");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a failed lock is reported as failed, not as provisioning in progress", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-failed-lock-"));
try {
const marker = path.join(root, "setup-ran");
const lock = JSON.stringify({ state: "failed", pid: 1, at: Date.now(), exit: 1 });
const bundle = makeBundleTree(root, { marker, lock });
// No bundle.json: the "missing" path, which used to print the "provisioning
// started" sentence for six hours while nothing was running at all.
const run = runShim(bundle, ["mcp"]);
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /the last provisioning attempt failed/);
assert.doesNotMatch(run.stderr, /provisioning started in the background/);
assert.equal(existsSync(marker), false, "a fresh failed lock must not trigger another run");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a lock whose holder is gone does not block provisioning", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-dead-lock-"));
try {
const marker = path.join(root, "setup-ran");
const dead = spawnSync(process.execPath, ["-e", ""]);
assert.ok(Number.isInteger(dead.pid), "could not obtain a pid to outlive");
const lock = JSON.stringify({ state: "running", pid: dead.pid, at: Date.now() });
const bundle = makeBundleTree(root, { marker, lock });
const run = runShim(bundle, ["mcp"]);
assert.equal(run.status, 78, run.stderr);
assert.match(run.stderr, /provisioning started in the background/);
assert.ok(await waitFor(() => existsSync(marker)), "a dead lock holder must not suppress the run");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("the fallback vendored-REA path matches where setup installs it", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-fallback-"));
try {
const bundle = path.join(root, "bundle");
// An older bundle.json has paths.node but no paths.reaEntry. The fallback used
// to omit `node_modules`, so a working install was reported as gone.
mkdirSync(bundle, { recursive: true });
addReaStub(bundle);
writeFileSync(path.join(bundle, "bundle.json"), JSON.stringify({ schema: 1, paths: { node: process.execPath } }, null, 2) + "\n");
const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
encoding: "utf8",
timeout: 60000,
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
});
const report = JSON.parse(run.stdout);
assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
assert.match(report.rea.reaEntry, /node_modules/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("--json releases the lock on success and marks it failed on failure", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-lock-json-"));
try {
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
addReaStub(bundle);
const component = (optional) =>
makePluginTree(path.join(root, optional ? "ok" : "bad"), (manifest) => {
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional });
});
// The lock is what tells the shim a run is in flight. A successful --json run
// used to leave it behind (the removal sat in the non---json branch), so a
// fully provisioned machine advertised "provisioning started" for six hours.
writeFileSync(path.join(bundle, "setup.lock"), JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }));
const ok = spawnSync(process.execPath, [component(true), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
assert.equal(existsSync(path.join(bundle, "setup.lock")), false, "a successful --json run must not leave the lock behind");
// A failing run keeps the lock on purpose, but records why, so the shim can
// report a failure instead of pretending a run is still going.
const bad = spawnSync(process.execPath, [component(false), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(bad.status, 1, `${bad.stdout}\n${bad.stderr}`);
const lock = JSON.parse(readFileSync(path.join(bundle, "setup.lock"), "utf8"));
assert.equal(lock.state, "failed");
assert.equal(lock.exit, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});