diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1190fc8..6f7656a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 80bc8d5..41fa39f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **仅文档,无行为变更。** diff --git a/README.md b/README.md index f98e1b6..ca60892 100644 --- a/README.md +++ b/README.md @@ -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 `/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 diff --git a/README.zh-CN.md b/README.zh-CN.md index a9d9605..9acf38e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 二进制启动 `/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` 也照常生效。 diff --git a/assets/make-screenshots.ps1 b/assets/make-screenshots.ps1 index 1ad3729..922f582 100644 --- a/assets/make-screenshots.ps1 +++ b/assets/make-screenshots.ps1 @@ -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') diff --git a/assets/screenshot-1.png b/assets/screenshot-1.png index 338b730..cc316b7 100644 Binary files a/assets/screenshot-1.png and b/assets/screenshot-1.png differ diff --git a/bin/rea-shim.mjs b/bin/rea-shim.mjs index 3c52e2e..d0f8193 100644 --- a/bin/rea-shim.mjs +++ b/bin/rea-shim.mjs @@ -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 /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); diff --git a/check-updates.ps1 b/check-updates.ps1 index b88e28a..a64dad5 100644 --- a/check-updates.ps1 +++ b/check-updates.ps1 @@ -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 在 \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 + # \node_modules\rea-agents\scripts\rea.mjs -> \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 ),不依赖 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 ', ')) } diff --git a/docs/bundle-design.md b/docs/bundle-design.md index 42fac53..bd506c7 100644 --- a/docs/bundle-design.md +++ b/docs/bundle-design.md @@ -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 /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 /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 | diff --git a/manifest/bundle.manifest.json b/manifest/bundle.manifest.json index c5c362a..fc31fb0 100644 --- a/manifest/bundle.manifest.json +++ b/manifest/bundle.manifest.json @@ -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." } } } diff --git a/package.json b/package.json index 6385737..6a43f9a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/audit-manifest.mjs b/scripts/audit-manifest.mjs index 8ddb03f..f3cf118 100644 --- a/scripts/audit-manifest.mjs +++ b/scripts/audit-manifest.mjs @@ -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; diff --git a/scripts/rea-bundle-bootstrap.ps1 b/scripts/rea-bundle-bootstrap.ps1 index 74814a6..4e98050 100644 --- a/scripts/rea-bundle-bootstrap.ps1 +++ b/scripts/rea-bundle-bootstrap.ps1 @@ -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 } } diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 4917628..d230b01 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -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=-`, 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 diff --git a/scripts/setup.test.mjs b/scripts/setup.test.mjs index 3eead1e..d68d05c 100644 --- a/scripts/setup.test.mjs +++ b/scripts/setup.test.mjs @@ -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=-`, + // 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 }); + } +});