mirror of
https://github.com/czcdr/dsh-plugin-rea.git
synced 2026-11-07 10:25:59 -10:00
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:
1 parent
98e3bd0863
commit
975ac5aac8
15 files changed
+1229
-165
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
**仅文档,无行为变更。**
|
||||
|
||||
@@ -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
@@ -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` 也照常生效。
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ', '))
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user