8 Commits
Author SHA1 Message Date
czcdr b06b4ae987 chore: refresh the marketplace screenshots for 2.0.16
The market page renders `assets/screenshot-1.png` straight from GitHub HEAD, so
whatever is committed here is what shoppers see. It still said "DSH desktop runtime
0.2.0-rc.2" and "2026-10-26" — two months and several host releases behind what this
plugin was actually measured against.

- the host version became a variable (`$hostMeasured`); it has no manifest field to
  read because it is not a property of this package, so it carries a comment saying
  it must move with the measurement
- `$measuredOn` is 2026-11-07, the date of the last real `--reuse` run (smoke
  imported java.exe in 31 s), and the stale hardcoded defaults (6.7.0 / 24.21.0)
  were brought up to the current pins so a failed manifest read no longer paints
  old numbers
- both PNGs regenerated; screenshot-2 carries no version text and is unchanged in
  content

The npm tarball for 2.0.16 still holds the previous images — that is harmless, because
the market reads these files from HEAD, not from the package.
2026-11-07 14:46:21 +08:00
czcdr b5e915a1a5 fix: hash the frida binding before importing it, and quarantine a mismatch (2.0.16)
The third audit cleared the four P0s and the npm-fallback change, and rejected
2.0.15 on one point: the Frida gate hashed AFTER it imported. `verify()` — which
imports frida and enumerates devices — ran before `judgeBinding()` compared the
digest, so a mismatched native module was executed inside the provisioning process
and then reported as rejected (the audit measured `moduleImportedBeforeRejection:
true` on a Linux copy). Writing `available: false` afterwards does not undo an
import, and the shim does not consume that field either, so nothing stopped REA
from loading the same file on the next tool call.

- `inspect()` is now the first thing that touches the binding: path + SHA-256, no
  import. `verify()` only runs when the digest matches the recorded one, or when
  this platform has no expectation at all (then `verified: false`, as before).
- after `npm rebuild frida`, the result is inspected BEFORE it is imported too — a
  rebuild can produce a binding that exists, loads, and is not the measured one.
- a mismatch now MOVES the file to `frida_binding.node.rejected` instead of only
  recording a verdict: REA reaches `build/frida_binding.node` directly, so taking it
  off that path is the only thing that actually stops the load. Quarantined rather
  than deleted, so the mismatch stays inspectable; if the move itself fails the
  message says so and tells the operator to remove the file by hand.
- the message is now "moved it to … before any import, so nothing can load it",
  which is what the code does — the old "rather than loading an unverified native
  module" was the line 2.0.15 could not back up.

Two regression tests, both injecting a frida package whose `index.js` writes a
marker and then throws, so "was this module imported?" is a fact on disk:

- the mismatched binding is never imported (no marker, output says "before any
  import");
- the binding is gone from the load path and present as `.rejected`.

Both skip on targets without a pinned digest — the only measured one is
`17.23.2/win32-x64` — and the skip message says the reason is "no expectation to
mismatch", not that the platform does not matter.

Tests: 160 (157 pass / 0 fail / 3 skipped). audit 24/24 hash-pinned clean,
dependency gate clean, host-compat 16 covered, verify-branch ALL CHECKS PASSED.
On this machine `--reuse` reports `fridaBindings — FRIDA_OK 3 — digest matches the
recorded one`, the Ghidra smoke imports java.exe in 31 s, exit 0.

Still open, unchanged from the last round: the shim rewrites the lock from its
parent process without a token comparison (low-priority race, not release
blocking), and Frida has a pinned digest for win32-x64 only.
2026-11-07 13:58:02 +08:00
czcdr 273153243a fix: close the second audit — rollback, existing-closure, frida retry, swap order (2.0.15)
A second audit of 5c71bf8 found 4 release-blocking gaps, all reproduced, all real.
Two are things 2.0.14 left undone (one of them documented as done), two are
defects 2.0.14 itself introduced. The common shape this time is the mirror of the
first round: the fixed path was not the path a user actually walks — rollback only
in the multi-file branch, closure floors only after a fresh install, the Frida
verdict written once, the old closure deleted before the new one was verified.

P0 — left undone by 2.0.14
- a plain archive had no rollback: the 2.0.14 note claimed "renamed to .backup,
  rolled back if the probe fails", but that lived only in the multi-file branch and
  `archive` still did rm -> rename -> return on a failed probe. Measured: exit 1,
  old known-good tree gone, broken tree in place, no .backup. Both branches now
  share the staging -> backup -> swap -> probe -> drop contract, and the message
  distinguishes "put back", "parked at", "nothing to restore".
- the advisory fix never reached existing installs: the `recorded` branch and the
  "already vendored" branch both returned before any closure check, so a 2.0.13 ->
  2.0.14 upgrade printed `already provisioned (6.8.0)` with zod still 4.4.3.
  `closureAdvisories()` reads both node_modules roots and runs before BOTH early
  returns; below a floor it reinstalls, and offline it fails instead of reporting
  health. Verified on this machine, which was in exactly that state: it reported
  zod 4.4.3 < 4.6.6, minimatch 10.2.6 < 11.0.1, glob 13.0.6 < 14.0.2, reinstalled
  from the audited lock to 4.6.7 / 11.0.5 / 14.0.4, and the closure has been
  vulnerable-free since.

P0 — introduced by 2.0.14
- the Frida verdict was cached forever: gated on `frida === undefined`, one
  transient failure became permanent (every later run read `available: false` and
  skipped), and a binding deleted after a good record was never re-checked. The
  check now runs on every non-dry-run.
- the swap deleted the old closure before verifying the new one: `.retired` was
  removed one line after the rename, so a closure that renamed fine and then could
  not start left the machine with no usable REA. `retired` now survives until
  BOTH `--version` and `doctor --format json` answer, and `restorePreviousClosure()`
  puts it back otherwise. doctor's verdict is "produced parseable JSON", not
  "healthy" — it exits non-zero for any unconfigured optional capability.

P1
- `npm ci` no longer degrades silently to an unlocked install: that fallback traded
  byte-reproducibility for a green run. It now fails closed with the reason, and
  `--allow-unlocked-npm-fallback` is the explicit opt-in.
- Frida binding digests: FRIDA_BINDING_SHA256 holds the ONE measured value
  (17.23.2/win32-x64). Match -> verified: true; no expectation for this platform ->
  usable but verified: false and the step says the digest was recorded, not
  checked; MISMATCH -> Frida disabled rather than loading an unmeasured native
  module. "We hashed what we got" is no longer reported as a verified chain.

P2
- the run lock is only deleted or rewritten when it still carries this run's token
  (or pid); otherwise it is left alone, because removing somebody else's lock is
  how a live run loses its brake.

Also: the offline reinstall message now names which of the three reasons applies
instead of calling all of them "not usable".

Tests: 158 (155 pass / 0 fail / 3 skipped), including the four fault injections the
audit asked for — archive probe failure restores the old install, a recorded 6.8.0
with zod 4.4.3 is not reported as provisioned, a stale frida verdict is re-checked,
and the swap ordering contract (retired outlives both probes). audit 24/24
hash-pinned clean, dependency gate clean, host-compat 16 covered, verify-branch
ALL CHECKS PASSED.
2026-11-07 13:03:42 +08:00
czcdr 5c71bf87f7 fix: report a missing Get-FileHash as "cannot verify", not as a defect
The Windows runner came back red on three check-updates assertions for one
reason: `Get-FileHash` does not exist there, the mirror check threw, and the catch
reported `mirror.json 解析失败` as [注意] — which counts as "needs action". A host
that cannot hash two files therefore looked like a machine with a broken bundle.

That is the same false red this release already removed from the `rea doctor` exit
code and from the bare-major pin comparison, so it gets the same treatment:

- the mirror check asks `Get-Command Get-FileHash` first and says [上游] when the
  capability is absent, rather than implying the mirror is inconsistent
- "mirror.json points at a plugin directory that no longer exists" and a
  mirror.json that will not parse are also [上游]: both are "cannot compare",
  not "compared and differs"

The test that constructs the stale-mirror warning now skips on a host without
Get-FileHash, because that scenario is unreachable there — asserting on a warning
the platform cannot emit would only produce another red that means nothing.

Verified: hardening.test 14/14 locally, full suite 154 (151 pass / 3 skipped).
2026-11-06 23:31:05 +08:00
czcdr 849863841d ci: gate the vendored dependency closure in both validate jobs
`npm run bundle:deps` now runs after the manifest audit on Linux and on Windows,
so a lock that drifts back onto an advisory-affected zod/minimatch/glob — or onto
a rea-agents release the manifest does not pin — fails the build instead of only
being caught on the machine that provisioned it. The gate is offline, which is the
point: one that needs the registry fails on a plane and passes during an outage.

The earlier commit had to leave this out because the push credential had no
GitHub `workflow` scope; with a scoped credential it lands here.
2026-11-06 23:19:29 +08:00
czcdr 85933748ac fix: force UTF-8 out of the PowerShell host in the check-updates tests
The Windows CI job went red on five check-updates assertions while Linux passed.
Two separate causes, both in the test harness rather than the shipped script:

1. A Windows runner's console answers in CP437, where every CJK character
   collapses to "?" irreversibly — the conclusion line arrived as
   `[??] ????:pin ?? 1 ?;?? 2 ?(????)`, which decodeOutput's GBK fallback cannot
   rescue. The child process now sets [Console]::OutputEncoding and
   $OutputEncoding to UTF-8 before running the script. This is a harness concern:
   the product's own output behaviour is untouched.

2. Replacing `-File` with `-Command` broke parameter binding. `& 'script.ps1'
   '-Registry' 'url'` hands PowerShell a positional string "-Registry" instead of
   binding the named parameter, so it became $Registry's value and the lookup
   requested "-Registry/rea-agents/latest" (measured: HTTP 502). Parameter names
   stay unquoted now; only values are quoted.

Both were caught by CI and reproduced locally before the fix, verified after:
hardening.test 14/14, full suite 154 (151 pass / 3 skipped / 0 fail).
2026-11-06 23:18:08 +08:00
czcdr 8e2acfdffc fix: close the 18-item audit and adapt to DSH 0.2.2-rc.1 (2.0.14)
An external audit of 2.0.13 found that install state could lie: a partially
failed install was recorded as a success, a broken recorded path was vouched for
by a different working copy, and an expired lock was deleted out from under a
live process. Every finding shares one shape — the thing verified was not the
thing being delivered — so the fix in each case is to make the probe look at the
artefact that will actually be used, and to keep "cannot prove" distinct from
"proved false". All 18 items are addressed. No upstream component moved: REA
6.8.0 / Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1.

Provisioning (scripts/setup.mjs)
- multi-file components are all-or-nothing: every entry must arrive, files are
  staged in <target>.staging and swapped in atomically only when complete, the
  previous directory is kept as .backup and restored if the swap or the probe
  fails, and the record carries one {id,version,sha256} per entry with the
  primary chosen by kind instead of by manifest order
- a recorded component is probed AT its recorded path (independent resolve
  context) and its version is checked against the manifest pin; JDK matches by
  major floor; an enabled Ghidra gate override is honoured so a plain setup can
  no longer silently re-pin over one
- JSON state files are written through writeJsonAtomic(); an unparseable
  setup.lock is replaced instead of throwing; --dry-run writes nothing at all
- Frida's native binding is repaired by naming the one package whose install
  script is load-bearing (npm rebuild frida) and then proved with
  import('frida') + enumerateDevices(); checked in main, so existing bundles
  (i.e. every upgrade) get fixed too, and reported as skip rather than failing
- the tar fallback passes filter='data' to Python's extractall

Runtime (bin/rea-shim.mjs)
- one definition of bundleUsable = loaded.ok && resolveRea(...).ok drives the
  launch decision, the --shim-status exit code and the repair path; an unusable
  bundle no longer wins against a working system REA, and status returns 78
- liveness outranks age for a running lock: a live pid is never deleted, an
  expired one is reported as in-flight-stale; locks with no pid keep the old
  time-based rule; setup heartbeats `at` from log(); shim->setup handoff uses a
  token (REA_BUNDLE_LOCK_TOKEN) instead of a pid

Dependencies (scripts/vendor-lock/, scripts/check-dependency-advisories.mjs)
- REA pins zod exactly at 4.4.3, which sits inside GHSA-mrhx-c7xc-m3w5; the
  closure is now replayed from an audited lock with npm ci and overrides to zod
  4.6.7 / minimatch 11.0.5 / glob 14.0.4, verified afterwards against 4.6.6
- an offline gate fails the build if the lock drifts back, and CI runs it

Host adaptation (scripts/host-compat.mjs)
- HOST_RELEASES 10 -> 16: 0.2.2-rc.1, 0.2.2-alpha.3, 0.2.1-rc.5, 0.2.2-alpha.2,
  0.2.1-rc.4, 0.2.1-rc.3, all three-source verified and in release-date order;
  engines.dsh and OUTSIDE_HOSTS unchanged

Also fixed while verifying (not in the audit)
- a bare-major pin ("21") was treated as a family prefix, so 25.0.5 counted as
  drift while setup counted it as satisfied; it is a floor, in all three files
- check-updates.ps1 no longer reports a false red: `rea doctor` exits non-zero
  for any unconfigured optional capability, so that exit code is now [upstream]
  and the parsed provider checks decide
- two new JSON reads went around the BOM-tolerant reader; routed through
  readJsonSafe

Tests: 154 (151 pass / 0 fail / 3 skipped), audit 24/24 hash-pinned clean,
dependency gate clean, host-compat 16 covered, verify-branch ALL CHECKS PASSED.
2026-11-06 23:08:33 +08:00
czcdr 4d8a08266d fix: derive the node-adoption guard from the manifest, not from 6.7.0's floor 2026-10-26 21:32:57 +08:00
24 changed files with 6754 additions and 269 deletions

No files matched your search

+12
View File
@@ -228,6 +228,15 @@ jobs:
# patch-wiring agreement between the row, the shim and setup.mjs.
run: npm run bundle:audit
- name: Vendored dependency advisories
# Provisioning resolves REA's closure from scripts/vendor-lock. This fails
# the build if that lock drifts back onto an advisory-affected zod,
# minimatch or glob, or onto a rea-agents release the manifest does not
# pin. It is offline on purpose: a gate that needs the registry fails on a
# plane and passes during a registry outage, which is the wrong shape for
# a build check.
run: npm run bundle:deps
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
@@ -251,3 +260,6 @@ jobs:
- name: Bundle manifest audit (Windows)
run: npm run bundle:audit
- name: Vendored dependency advisories (Windows)
run: npm run bundle:deps
+283
View File
@@ -2,6 +2,289 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 2.0.16 — 2026-11-07
**第三轮审计:2.0.15 的 4 个 P0 与无锁 npm fallback 全部验收通过,唯一否决项是 Frida 的哈希门禁
「先加载、后校验」—— 而发布说明已经把它写成完成的安全闸门。这一版把那句话变成事实。
其余没有任何新发现的数据丢失、回滚或依赖回归。**
### 修复:哈希必须在导入之前
- **顺序反了(`scripts/setup.mjs` `ensureFridaBindings()`)**。2.0.15 是
`const before = verify()`(`import('frida')` + `enumerateDevices()`)→ `judgeBinding(digestOf())`。
审计给一个 Linux 副本注入不匹配摘要后实测 `moduleImportedBeforeRejection: true`:**模块在拒绝之前就被执行了**,
事后写一条 `verified` 不能撤销一次原生模块的加载。现在 `inspect()` 是第一个接触到这个文件的东西 ——
纯路径 + SHA-256,不 import;只有哈希匹配(或该平台没有受信值)才会走到 `verify()`。
rebuild 之后同样先 `inspect()` 再 `verify()`:一次 rebuild 完全可以产出一个"存在、能加载、但不是被测量过的那份"绑定。
- **不匹配时必须把绑定移走,不能只写状态**。记录 `available: false` 阻止不了 REA —— shim 不消费这个字段,
下一个想用 Frida 的工具照样会 `import` `build/frida_binding.node`。现在不匹配就把它改名为
`frida_binding.node.rejected`(隔离而不是销毁,留证据),失败时消息明写"没能移走,用 Frida 前先手工删掉它"。
- **措辞**:`rejectBinding()` 的消息现在是「moved it to … before any import, so nothing can load it」,
不再是没被兑现的「rather than loading an unverified native module」。
### 测试
两个回归测试,都注入一个**导入即有副作用**的假 frida 包(`index.js` 写一个 marker 再抛错,
于是"这个模块有没有被 import 过"是磁盘上的事实,不是推断):
- **不匹配的绑定从头到尾没有被 import**:marker 不存在 + 输出含 `before any import`;
- **不匹配的绑定被移出 REA 的加载路径**:`build/frida_binding.node` 不存在、`.rejected` 存在
(只写状态不算修好,REA 读的是那个路径)。
两条都按平台跳过:受信摘要目前只有 `17.23.2/win32-x64` 一条,别的平台没有"可以失配"的期望值 —— 跳过理由是
"没有期望值",不是"这个平台不重要"。
### 验证
```
单测 104(setup)+ 17(deps)+ 25(host-compat)+ 14(hardening)= 160,0 失败
顺序契约 inspect() → 早退拒绝 → verify(),用源码位置断言固定
manifest audit 24 条 hash-pinned,0 TOFU
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4(单副本)
宿主兼容 16 releases covered
verify-branch ALL CHECKS PASSED
真机 --reuse:fridaBindings「digest matches the recorded one」,smoke 42 s,exit 0
```
### 发版清单里仍开着的
- **shim 父进程直接重写 lock**(`bin/rea-shim.mjs` 的 'error' 处理与 PID 交接):极端时序下的低优先级竞争,
审计两轮都判为不阻断。未改。
- 其他平台的 Frida 受信摘要:只有 win32-x64 一条,其余平台是 `verified: false`(记录,不是验证)。
- `engines.node` 比桌面宿主自带 Node 严格、`dsh.client.inject` 的 `dsh-client-runtime`、Ghidra 默认 12.1.4 的
已知 CVE(**仍不得用于不可信样本**)、硬链接 containment —— 与上轮相同。
## 2.0.15 — 2026-11-07
**第一版 2.0.14 被外部审计判为「不建议发布」,4 个发版级缺口全部属实:两个是上一轮漏修/描述超实现,
两个是 2.0.14 自己引入的。上游组件仍然一个都没动。**
这四项的共同教训和上一轮一样,只是方向相反:上一轮修的是「验证的对象不是要交付的对象」,
这一轮全是「**修完的那条路径不是用户实际会走的那条**」—— 回滚只做在 multi-file 分支、依赖锁只在新装后校验、
Frida 结论只写一次、旧闭包在探针之前就被删了。
### P0:上一轮漏修 / 描述超实现
- **普通归档没有回滚(`scripts/setup.mjs`)**。2.0.14 的 CHANGELOG 写了「旧目录先改名 `.backup`,探针失败也回滚」,
但那只实现在 multi-file 分支;`archive` 分支仍是 `rmSync(target)` → `renameSync` → 探针失败只 `return null`。
审计的复现是 exit 1、旧 known-good 树消失、新坏树留在原地、`.backup` 不存在 —— 与发布说明正好相反。
现在两个分支同一契约:`staging → backup → 换入 → 探针 → 成功后删 backup`,rename 或探针任一失败都恢复旧目录,
并把「旧安装被放回去了 / 停在哪 / 本来就没有旧安装」三种结局分别写进那一行。
- **存量安装升级后 zod 仍是 4.4.3(`scripts/setup.mjs`)**。依赖修复只在新装路径上生效:`recorded` 分支和
「已 vendored」分支都在闭包检查之前就 `return` 了,于是一个 2.0.13 → 2.0.14 的升级会打印
`already provisioned (6.8.0)`、探针通过、Zod 仍是 4.4.3。新增 `closureAdvisories()`(读 `node_modules` 与
`rea-agents/node_modules` 两处的 zod/minimatch/glob 版本,与 `CLOSURE_FLOORS` 比对),**两个提前返回前都先过它**:
低于修复线就重新供给;离线则明确 FAIL,不再报健康。
### P0:2.0.14 引入的新缺陷
- **Frida 结论被永久缓存(`scripts/setup.mjs`)**。2.0.14 只在 `merged.reaAgents.frida === undefined` 时检查,
于是一次瞬时失败(`npm rebuild` 期间的网络抖动、文件被占)会变成永久判决:之后每次 setup 读到
`available: false` 就直接跳过,连重试都没有;反过来,`available: true` 之后绑定文件被删也不会复查。
现在**每次非 dry-run 都调用 `ensureFridaBindings()`** —— 它本来第一步就是 import + 设备枚举,重复跑就是正确的检查。
- **REA 闭包换入后探针失败会删掉旧闭包(`scripts/setup.mjs`)**。顺序是「旧 `node_modules` 改名 `.retired` →
换入新的 → **立刻删 retired** → 才跑新版本探针」,所以一个 rename 成功但自己起不来的闭包会让机器上没有任何可用 REA。
现在 `retired` 保留到 **`--version` 与 `doctor --format json` 都答得出来**为止,任一失败用 `restorePreviousClosure()`
放回旧闭包,并把「已放回 / 停在 retired / 本来没有旧闭包」写清楚。
`doctor` 的判据是「产出了可解析的 JSON」而不是「healthy」——它只要有任何可选能力未配置就退出非 0。
### P1
- **`npm ci` 失败不再静默降级为无锁安装(`scripts/setup.mjs`)**。原实现删掉 lock 再 `npm install`,
等于把「受审 lock 的字节复放」换成「按当时的 registry 范围重新解析」,而且不告诉用户。
现在默认**失败关闭**并说明原因(顺带点明重打包镜像会故意触发它);确实需要时用
**`--allow-unlocked-npm-fallback`** 显式接受(日志会写明这次是不复现的闭包)。
- **Frida 原生绑定的哈希:区分「记录」与「验证」(`scripts/setup.mjs`)**。新增 `FRIDA_BINDING_SHA256`
(`<frida 版本>/<platform>-<arch>` → 摘要),目前**只有一条**——`17.23.2/win32-x64` 的 `32c73230…`,
2026-11-06 在本机实测。三种结局现在不同:匹配 → `verified: true`;该平台没有受信值 → 可用但 **`verified: false`**,
step 行明写「digest recorded, no pinned value for this platform」;**不匹配 → 禁用 Frida**。
⚠️ **第一版 2.0.15 的顺序是反的**:`verify()`(import + 设备枚举)跑在哈希比较之前,所以不匹配的绑定
**已经被加载执行过**,"不加载未测量的原生模块"这句当时并不成立;而且只写 `available: false` 并不会让 REA
停止加载(shim 不消费该字段)。已在 2.0.16 修正,见该段。
记录里同时写入 `verified` 字段,`bundle.json` 不再暗示「记录了哈希 = 供应链已验证」。
### P2
- **锁释放前再次比对 token(`scripts/setup.mjs`)**:claimed 与释放之间记录可能被换掉(前一次运行死掉、人手工删、
shim 重新接管)。删除或覆盖别人的锁正好会拿掉一个活运行的刹车,所以现在先确认
`token === RUN_TOKEN || pid === process.pid`,不匹配就一行日志、什么都不动。
### 测试
审计要求的四个故障注入,四个都补上(旧代码下会红):
- 归档探针失败 → 旧安装被放回、`.backup` 不残留(真实 zip,primed cache,不联网);
- 记录的是 6.8.0 但闭包带 zod 4.4.3 → **不**出现 `already provisioned`,离线时明确退出 1;
- 记录里 `frida.available: true` 但盘上什么都没有 → 仍然执行检查并报 `not in the resolved closure`;
- REA 闭包换入的顺序契约:`retired` 的删除必须在 `--version` 与 `doctor` 之后,且 `restorePreviousClosure` 存在
(端到端注入需要真实 npm 装一个坏闭包,这里用结构断言,已在注释里说明理由)。
### 验证
```
单测 102(setup)+ 17(deps)+ 25(host-compat)+ 14(hardening)= 158,0 失败
manifest audit 24 条下载 entry 全部 SHA-256 钉死,0 TOFU
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4
宿主兼容 16 releases covered
verify-branch ALL CHECKS PASSED
真机 --reuse 全量:reaAgents 因闭包不达标重供给(zod 4.4.3 → 4.6.7),fridaBindings 复查通过
```
### 仍未解决的(与上一轮相同,别当成已解决)
1. `engines.node` 仍比桌面宿主自带的 Node 严格(市场不读它,只有 `engine-strict=true` 会拦)。
2. `dsh.client.inject` 里那个 `dsh-client-runtime` 保留(宿主静默跳过)。
3. Ghidra 默认 pin 仍是 12.1.4,带那四个 CVE —— 不要用它分析来源不可信的样本。
4. 硬链接逃逸没做 containment;minimatch/glob 在 REA 默认路径上的可达性仍未证实。
5. Frida 绑定只有 win32-x64 一条受信摘要,其余平台是 `verified: false`(记录,不是验证)。
## 2.0.14 — 2026-11-06
**这一版是对 2.0.13 的一次外部审计(18 项)的逐条修复,外加 DSH 宿主适配到 `0.2.2-rc.1`。
上游组件一个都没动:REA 6.8.0 / Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1 全部原地。**
审计的核心结论不是「有个洞」,而是**「安装状态会说谎」**:一次部分失败的安装会被记录成成功,一个坏路径能被另一份好安装背书,
一条超时的锁会被从活进程手里删掉。这四类问题的共同点是**验证的对象与实际对象不是同一个**,所以本轮的修法统一是
「让探针去看真正要交付的那份东西」。
### 高:安装状态造假
- **多文件组件部分失败会记录成新版本(`scripts/setup.mjs`)**。原先某个 entry 下载失败只是 `continue`,只要另一个文件落地就照跑探针,
再用 `placed[0]` 的路径/哈希配 `entries[0].version` 写记录 —— apktool 的 jar 下载失败、wrapper 成功时,目录里残留的 **2.0.0 jar
通过了 `java -jar --version`**,记录却写着 3.0.3 且哈希取自 wrapper;下一次运行直接 `already provisioned (3.0.3)` 不再下载。
现在:**全部 entry 先取全才继续**,缺一个就整体失败并打印「N of M entries were available; nothing was installed」;
所有文件先进 `<target>.staging`,**全就位后才原子换入**(旧目录留 `.backup`,探针失败同样回滚);
记录改为**每 entry 独立**的 `{id, version, sha256, tofu}` 数组,`jarName`/`sha256` 按**类型**(`.jar`)而不是列表顺序选主。
- **记录路径的探针没有绑定该路径(`scripts/setup.mjs`)**。`recorded` 分支直接 `runProbe(..., context)`,而 `{jvm}`/`{ghidra}`/`{node}`
是按 `context.resolved` 展开的 —— 于是「记录指向一个坏 Ghidra、vendor 下另有一份好的」时,探针拿好安装给坏路径背书。
现在用**独立 context 把该组件解析到 `recorded.path` 再探针**,并新增 **pin 校验**:记录版本必须等于 manifest 的 pin,
JDK 按**主版本下限**(`manifest` 钉 21 → 25.0.5 合格);唯一豁免是**已启用的 Ghidra 闸门覆盖**,
否则一次普通 setup 会把操作员显式打开的覆盖悄悄重钉回 12.1.4。
### 中:可用性与并发
- **损坏的 bundle 挡住 auto 回退,且状态命令照样退出 0(`bin/rea-shim.mjs`)**。`loaded.ok`(JSON 解析成功)被当成「已供给」,
于是 `paths.node` 已消失的 bundle 凭「记录版本更新」赢了可用的系统 REA(退出 78),`--shim-status` 也在 `rea.ok=false`
时返回 0。现在统一成 `bundleUsable = loaded.ok && resolveRea(...).ok`,**启动选择、状态退出码、修复路径三处共用**;
bundle 不可用而系统 REA 可用时回退,两者都不可用才进入修复。
- **六小时过期会删掉活进程的锁(`bin/rea-shim.mjs`)**。原规则只信任「新鲜」的锁,超时后**不问持有者是否还活着**就删 ——
慢下载或卡住的 PowerShell 步骤真的会跑过六小时,删锁后第二个 provisioning 会写同一份 cache、同一批 `.staging`
和同一份 `bundle.json`。现在**存活 pid 是硬证据,过期只是软证据**:pid 活着就永不删(超过窗口时以 `in-flight-stale`
报告并提示手工处理);只有 pid 证明已消失才回收。配套:`setup` 的 `log()` 兼作**心跳**(30 s 节流刷新 `at`),
shim→setup 的 PID 交接改为 **token 交接**(`REA_BUNDLE_LOCK_TOKEN`),关掉「setup 在自己的锁上输给自己、退出 75」的窄窗口。
- **JDK 探针不保证 21+(`manifest/bundle.manifest.json`)**。`jdk.compiler` 只区分完整 JDK 与裁剪运行时 —— 真实 Java 17.0.20
同样打印 `[email protected]`,于是 setup 接受并记录 `{version: "17.0.20", reused: true}`,Ghidra 之后才失败。
现在 `expect` 同时要求 `contains: jdk.compiler` **和** `regex` 匹配版本段 ≥ 21(21–29 / 30–99 / 100+),
8 组版本串正反验证(含本机真实 `[email protected]`)。
- **Frida 原生绑定缺失(`scripts/setup.mjs`)**。`rea-agents` 直接依赖 `frida`,而 frida 只有在 install 脚本里才会放
`build/frida_binding.node`;`--ignore-scripts`(正确的加固)导致 `import('frida')` 报 *Could not locate the bindings file*,
所有 Frida 工具全死而 run 报成功。**修法不是删 `--ignore-scripts`**,而是**点名这一个包**跑 `npm rebuild frida`,
再用 `import('frida')` + `enumerateDevices()` 验证(实测 `FRIDA_OK 3`)。检查放在 `main` 层,因此
**已记录 6.8.0 的存量 bundle(也就是每次升级)也会被修**,而不是只有全新安装;结果写进
`components.reaAgents.frida = {available, binding, sha256, devices}`。不可用时按 `browser` 的先例报 `skip`,不判失败。
### 中:状态文件与回滚
- **归档替换先删后 rename(`scripts/setup.mjs`)**:rename 失败会同时失去新旧两份。旧目录先改名为 `.backup`,
失败即回滚,探针失败也回滚(并明说「the previous install was put back」)。
⚠️ **这条在第一版 2.0.14 里只对多文件组件成立**,普通归档仍是「先删目标、再 rename、探针失败只 return」——
描述大于实现。已在 2.0.15 补齐,见该段。
- **`bundle.json` / `mirror.json` 原地覆盖写**:半文件会被读成「未供给」,进而触发一次全量重装。现在统一走
`writeJsonAtomic()`(同目录 `.part` + rename)。
### 低
- **损坏的 `setup.lock` 让手动 setup 抛 `SyntaxError`(`scripts/setup.mjs`)**:`readJson` 会抛,而它没被捕获,
与「不可读的锁会被替换」的注释相反。现在捕获并按陈旧锁处理。
- **`--dry-run` 仍创建 root/cache/`logs/setup.log`**:现在 dry-run **一个字节都不写**(`appendLog` 同步跳过)。
- **`bin/rea-guide.mjs` / `scripts/ghidra-gate-override.mjs` 忽略 `REA_BUNDLE_ROOT`**:与 setup/shim/bootstrap 的
root 解析顺序统一(`--root` → `REA_BUNDLE_ROOT` → `DSH_HOME` → `~/.dsh`),补救命令在该 root 非默认时带 `--root`。
- **`ghidra-gate-override.mjs` 先 patch 后写恢复记录**:中途失败留下半改状态且无恢复信息。现在**先把全部原值读进内存并
落盘记录(`schema: 2`,`status: "patching"`)再动第一个字节**,任一失败用内存原文回滚已改文件;回滚不全则**保留**
记录(`status: "rollback-failed"`)而不是丢掉线索。`--disable` 增加恢复后哈希校验。
- **`--disable` 信任记录里的相对路径**:新增 `containedPath()` —— resolve 后做前缀包含,并**逐段 `lstat`** 拒绝符号链接/junction。
实测 `..\..\..\outside-old.js` 与 junction 记录都被拒且 bundle 外文件未被触碰。
- **Python `tarfile.extractall()` 无 filter(纵深防御)**:补 `filter='data'`(3.12+ 默认、3.11.4+ 可用),
`except TypeError` 兜底老 Python。所有归档本就逐条 SHA-256 钉死,这一条防的是将来出现的未固定条目。
### 依赖安全:闭包改为从受审 lock 复放
`[email protected]` 对 zod 是**精确锁 `"4.4.3"`**,而 4.4.3 落在 GHSA-mrhx-c7xc-m3w5(<4.6.6,校验超长约束集合时内存放大)内。
实测放大器是 REA 自己的 `inspect_analysis_settings` 输入 schema(`.max()` 挂在数组上):合成 `.max(2)` 喂 25 个元素,
**4.4.3 产出 26 条 issue,4.6.7 只产出 1 条**。精确锁无法用 `dependencies` 范围挪动,`overrides` 是唯一不改发布 tarball 的杠杆;
且单独 `npm install [email protected]` 会装出**两份 zod**(REA 仍吃坏的那份),已实测否决。
- 新增 **`scripts/vendor-lock/`**(`package.json` + `package-lock.json`,lockfileVersion 3,183 条目):
`zod 4.6.7` / `minimatch 11.0.5` / `glob 14.0.4`。setup 把它拷进 `vendor/.npm-staging/` 跑 **`npm ci`**,
校验通过后才 rename 就位;`npm ci` 失败则删掉拷入的 lock 再走 `npm install` + 同值 overrides(同版本,字节不可复现,日志会明说)。
落地后**再读 `node_modules/zod/package.json` 的版本**,< 4.6.6 直接拒绝这个闭包。
- 新增 **`scripts/check-dependency-advisories.mjs`**(离线):检查每个包的**所有副本**(含嵌套)是否 ≥ 修复线、
锁里的 rea-agents 是否等于 manifest pin、`setup.mjs` 的 `CLOSURE_OVERRIDES` 与 lock 的 overrides 是否互洽。
已作为独立 step 接进 `.github/workflows/validate.yml` 的 Linux 与 Windows 两个 job(`npm run bundle:deps`);
`bundle:test` 也扩到四个测试文件。
- 兼容性实测(不是推断):`@electron/asar` 在 glob 14 / minimatch 11 下 create/list/extract/stat **往返通过**;
REA 真实 MCP stdio 握手 `tools/list` 仍是 **229/229 且每个都有 inputSchema**(与 zod 4.4.3 基线一致)。
- `frida` 的 minimatch 本来是死路径(绑定缺失),本轮修好绑定后变成可达 —— 已验证 `new Minimatch()` 在 11.0.5 上可用。
### DSH 宿主适配:0.2.1-rc.3 → 0.2.2-rc.1
`HOST_RELEASES` 从 10 条扩到 **16 条**,新增 `0.2.2-rc.1`、`0.2.2-alpha.3`、`0.2.1-rc.5`、`0.2.2-alpha.2`、`0.2.1-rc.4`、`0.2.1-rc.3`。
`engines.dsh` 声明未动(`>=0.1.7-rc.2 <0.3.0-0`,这 6 个版本全部在范围内),`OUTSIDE_HOSTS` 未放宽。
- 三源交叉核实(releases 页 / atom / npm registry):6 个 tag 全部真实存在,原有 10 条一条未被撤下;`0.2.2-rc.1` 是当前
`latest`。**排序按发布日而非 semver**:`0.2.1-rc.5`(10-29) 晚于 `0.2.2-alpha.2`(10-27),这一处倒序在代码注释与测试里钉死。
- 接口面逐条核对:6 个版本对本插件实际依赖的五处(patch 方言 / MCP 行 schema / 宿主插件 API / 客户端注入契约 /
市场 engines 判定)**都没有破坏性变化**。唯一沾边的是 `0.2.2-rc.1` 的 "Cordis Core 4.1.0 / Loader 1.1.0",
**已在本机 0.2.2-rc.1 上实测通过**:`GET /dsh-plugin-rea/guide-state` → 200,`mcp__rea__*` 可用。
- 市场侧的判定语义现场取证:`dshmarket/lib/verify.js` 与 `discovery-compatibility.js` 都只读 `engines.dsh`
(`includePrerelease: true`),**不读 `engines.node`**。
### 顺带修掉的两处「自相矛盾」
- **`"21"` 这种只有主版本的 pin 被当成「家族前缀」**(`scripts/pin-match.ps1`、`bin/rea-guide.mjs`):`25.0.5` 对 pin `21`
被判成漂移,于是 `check-updates.ps1` 说「重跑 setup 对齐」,而 setup 说「already provisioned (25.0.5) — major 25
satisfies the pin's major 21」。Ghidra 要求的是「JDK 21 **或更新**」,所以**只有主版本的 pin 是下限,不是前缀**;
三个文件的判定现在一致。
- **`check-updates.ps1` 的假红**:`rea doctor` 退出码 1 涵盖「任何可选能力未配置」(Hopper / IDA / Kuna / skill 副本 /
五个客户端登记),于是一台供给完好的机器也恒报「需要动作」。退出码降为 `[上游]`,实际状态由输出解析出来的
provider 检查决定 —— 假绿修完之后不能顺手留下假红。
### 验证
```
单测 154 项:151 过 / 0 失败 / 3 跳过
(setup.test 98 · deps.test 17 · host-compat.test 25 · hardening.test 14)
manifest audit 24 条下载 entry 全部 SHA-256 钉死,0 TOFU
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4,各单副本
宿主兼容 16 releases covered,5 correctly outside
verify-branch ALL CHECKS PASSED
```
真机(本机 `D:\dsh-home\plugins\rea-bundle`):`setup --dry-run` 7 个组件全部按 pin 正确 skip(Ghidra 12.2 覆盖与
JDK 25 均未被误判为漂移)→ 强制重装 apktool 走完整 staging 路径(无残留 `.staging`/`.backup`,目录内容恰好等于
manifest 的两个文件)→ `--reuse` 全量 8 组件 + `fridaBindings FRIDA_OK 3` → `probe 229 tools`、
`smoke 28 s 导入 java.exe`(Ghidra 12.2)、`shim resolved 6.8.0`,退出 0。`check-updates.ps1` 由退出 1 转为退出 0。
新增回归测试(旧代码下会红):部分失败不成假绿 + 第二次仍重试、记录路径不被 vendor 背书、pin 漂移不被信任、
JDK 17 被拒、dry-run 不写盘、坏锁不抛异常、`tarfile` 带 filter、坏 bundle 回退系统 REA、
`--shim-status` 对不可用 bundle 返回 78、活 pid 的过期锁不被删。
### 已知约束(本轮没有解决,别当成已解决)
1. **`engines.node` 仍是 `^22.23.4 || ^24.22.1 || >=26.12.1`**,而 `0.2.2-rc.1` 桌面宿主自带 Node **24.21.0**。
实测市场不读这个字段(只读 `engines.dsh`),npm 也只在默认配置下警告;**但 `engine-strict=true` 的用户会装不上**。
没放宽它的原因:这个字段当前被 `audit-manifest.mjs` 强制与 `manifest.pins.nodeEngines`(REA 的真实要求)逐字相等,
拆开两者是一次独立改动。运行期不受影响:shim 用 `bundle.json` 的 `paths.node`(24.22.1)启动 REA。
2. **`package.json` 的 `dsh.client.inject` 仍列出 `@deepseek-ai/dsh-client-runtime`**,而该包在 `0.2.2-rc.1` 宿主里
**一次都不出现**(它是构建期 `devDependency`)。宿主对未知 inject 项**静默跳过**(`dsh-client-modules` 的
`graphRows.get()` 取不到就什么也不做),且本插件 `client.js` 不 require 它,真实闸门是服务 `["slots"]` ——
所以卡片照常渲染,保留它是为了不损害旧宿主的适配。属于清理项,不是缺陷。
3. **Ghidra 默认 pin 仍是 12.1.4**(带那四个 CVE)。12.2 只能通过 `scripts/ghidra-gate-override.mjs` 显式开启,
它需要 JDK 25+ 且 REA 未验证。**在升级这个 pin 之前,不要用内置 Ghidra 分析来源不可信的样本。**
4. **硬链接逃逸没有做 containment**:同卷硬链接与 bundle 内文件共享 inode,纯路径检查区分不了;本轮只拒绝了
`../`、绝对路径、符号链接/junction。
5. **minimatch / glob 在 REA 默认 MCP 路径上的可达性仍未证实**:本轮做的是把它们抬到修复线上,不是证明可利用。
## 2.0.13 — 2026-10-26
**上游 REA 从 6.7.0 升到 6.8.0。这一次上游**同时抬高了 Node 的 engines 下限**,所以不是只改一个版本号 ——
+44 -12
View File
@@ -132,7 +132,7 @@ releases, checksum-verified). Then install the plugin for your profile:
```
dsh plugin --profile <profile> add dsh-plugin-rea
# or pin this release instead of tracking npm latest:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.13
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.16
```
> **On an Electron host (the DeepSeek Harness desktop app) the profile is owned by the application.**
@@ -167,13 +167,24 @@ two disagree:
| Host | Covered |
|---|---|
| `0.1.7-rc.2` … `0.2.1-rc.2` — every release in between, alpha and rc | yes |
| `0.1.7-rc.2` … `0.2.2-rc.1` — every release in between, alpha and rc (16 tags) | yes |
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2` and at
`dsh-v0.2.1-rc.2` exposes the same config surface the shipped row uses (`serverName`, `transport`, `command`, `args`,
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
simply not claimed.
2.0.14 checked the six tags published after 2026-10-23 individually (`0.2.1-rc.3`, `0.2.1-rc.4`, `0.2.2-alpha.2`,
`0.2.1-rc.5`, `0.2.2-alpha.3`, `0.2.2-rc.1`): none of their release notes touch the five surfaces this plugin depends on
(patch dialect, MCP row schema, host plugin API, client injection contract, market `engines` verdict). The one adjacent
item is `0.2.2-rc.1`'s Cordis Core 4.1.0 / Loader 1.1.0, and it was **measured on this machine**:
`GET /dsh-plugin-rea/guide-state` answered 200 and the `mcp__rea__*` tools were available.
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2`,
`dsh-v0.2.1-rc.2` and `dsh-v0.2.2-rc.1` exposes the same config surface the shipped row uses (`serverName`, `transport`,
`command`, `args`, `env`, `failOnStartupError`), and `dsh.bundle.patch` exists at all three. A host outside the range can
still work; it is simply not claimed.
> The market reads `engines.dsh` only, and **not** `engines.node` (measured in `dshmarket/lib/verify.js` and
> `discovery-compatibility.js`). A host whose bundled Node sits below this package's `engines.node` floor is therefore
> not blocked by the market; only someone running npm with `engine-strict=true` is stopped at install time. Run time is
> unaffected either way: REA is started with the `paths.node` recorded in `bundle.json`, not the host's Node.
### A REA you already have is used as-is
@@ -311,10 +322,10 @@ Author's machine, 2026-10-13 / 14 / 16 / 19 / 23 / 26:
| Component | Version |
|---|---|
| REA (`rea-agents`) | **6.8.0** (229 tools) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
| DeepSeek Harness | desktop runtime **0.2.2-rc.1** (the host 2.0.14 was measured on; full range in the table above) |
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
| Ghidra | 12.1.4 |
| JDK | Temurin 21 (provisioned) / Amazon Corretto 25.0.5 (adopted by the host) |
| Ghidra | 12.1.4 (this machine also runs 12.2 through the gate override — see below) |
| JDK | Temurin 21 (provisioned) / Amazon Corretto 25.0.5 (adopted; its major satisfies the pin `21`) |
| Node.js | **24.22.1** adopted (REA engines: `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`; ABI 137) |
| Host | Windows 11 x64 (build 26200) |
@@ -342,6 +353,17 @@ protocol `2024-11-05`; apktool still answers `3.0.3`, `adb` still reports 37.0.1
disk still hashes to `6e5eacf5…` — the value REA 6.8.0 requires. Executing 6.8.0's own release policy gave
`12.1.4 → verified`, `12.1.5 → compatible`, `12.2 → unsupported`, so Ghidra stays where it was.
On 2026-11-06 (2.0.14 — an 18-item external audit plus host adaptation) **no upstream component moved** (REA 6.8.0 /
Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1). The same chain was re-verified: `--dry-run`
skipped all 7 components on their pins (the Ghidra 12.2 gate override and JDK 25 were not mistaken for drift) →
a forced apktool re-provision went through the full staging path (no leftover `.staging`/`.backup`, and the directory
held exactly the two files the manifest declares) → a full `--reuse` run covered 8 components plus
`fridaBindings FRIDA_OK 3` → `probe cli=6.8.0 tools=229`, the Ghidra smoke imported `java.exe` in 28 s, and the shim
resolved 6.8.0, exit 0. Gates: **154 tests** (151 pass / 0 fail / 3 skipped), `audit` 24/24 hash-pinned clean,
the dependency gate at zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4, `host-compat` 16 releases, and
`verify-branch` ALL CHECKS PASSED. `check-updates.ps1` went from a false-green exit 0 to reporting honestly, and exits 0
on this machine once repaired.
## Scope and caveats
- REA exposes 229 tools at 6.8.0, so every session's tool list grows.
@@ -359,9 +381,19 @@ disk still hashes to `6e5eacf5…` — the value REA 6.8.0 requires. Executing 6
treat the plugin like any third-party code you install.
- On Linux, upstream's process-ownership checks need a **procps-compatible `ps`** on PATH; the
Ghidra/JADX and firmware paths use it. A minimal image without it fails those tools, not the install.
- `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
- `scripts/setup.mjs` resolves the REA CLI's closure with **`npm ci`** against the shipped
`scripts/vendor-lock/` (`package.json` + `package-lock.json`). REA pins zod **exactly** (`"4.4.3"`), and 4.4.3 is
inside GHSA-mrhx-c7xc-m3w5, so the closure is raised through `overrides` to **zod 4.6.7 / minimatch 11.0.5 /
glob 14.0.4**. `npm ci` verifies every tarball's sha512; if a repackaging mirror makes that fail with `EINTEGRITY`,
it falls back to "same versions via overrides + `npm install`" (same versions, not byte-reproducible, and the log
says so). After landing, the version in `node_modules/zod/package.json` is read once more and anything below 4.6.6
is refused. `node scripts/check-dependency-advisories.mjs` is the offline gate, wired into CI.
Package lifecycle scripts are still skipped on purpose — with exactly one named exception: **frida**, whose native
binding (`build/frida_binding.node`) only exists after its install script runs, so setup runs `npm rebuild frida`
for that one package and then proves it with `import('frida')` plus device enumeration (recorded under
`components.reaAgents.frida` in `bundle.json`). A missing binding makes the Frida-backed tools unavailable and the
rest of REA untouched, so it is reported as `skip` rather than failing the whole provision.
This step is also the **only** one that cannot use this bundle's
own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
environment, so on a network that needs a mirror, set one of those before provisioning
(`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
+38 -10
View File
@@ -120,7 +120,7 @@ profile 安装插件:
```
dsh plugin --profile <profile> add dsh-plugin-rea
# 或钉住这个版本,而不是跟着 npm latest 走:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.13
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.16
```
> **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
@@ -151,12 +151,22 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
| 宿主版本 | 是否覆盖 |
|---|---|
| `0.1.7-rc.2` … `0.2.1-rc.2`(中间的 alpha 与 rc 全部在内) | 是 |
| `0.1.7-rc.2` … `0.2.2-rc.1`(中间的 alpha 与 rc 全部在内,共 16 个 tag) | 是 |
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
2.0.14 逐个核对了 2026-10-23 之后发布的 6 个 tag(`0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.2-alpha.2`、`0.2.1-rc.5`、
`0.2.2-alpha.3`、`0.2.2-rc.1`):发布说明里没有触及本插件依赖的五处接口面(patch 方言、MCP 行 schema、
宿主插件 API、客户端注入契约、市场 engines 判定)。唯一沾边的是 `0.2.2-rc.1` 的 Cordis Core 4.1.0 / Loader 1.1.0,
**已在本机 0.2.2-rc.1 上实测**:`GET /dsh-plugin-rea/guide-state` 返回 200,`mcp__rea__*` 工具可用。
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2`、`dsh-v0.2.1-rc.2` 与
`dsh-v0.2.2-rc.1` 上暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、
`failOnStartupError`),并且都带 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
> 市场只读 `engines.dsh`(`dshmarket/lib/verify.js` 与 `discovery-compatibility.js` 现场取证),**不读
> `engines.node`**。所以宿主自带的 Node 比本包 `engines.node` 的下限低时,市场不会拦你;只有把 npm 配成
> `engine-strict=true` 的人会在 `npm install` 阶段被挡。运行期不受影响:REA 是用 `bundle.json` 里记录的
> `paths.node` 启动的,不是宿主那个 Node。
### 机器上已有 REA 就直接用它
@@ -283,10 +293,10 @@ Ghidra 的默认自动分析。如果大文件的第一次 `open_binary` → `li
| 组件 | 版本 |
|---|---|
| REA(`rea-agents`) | **6.8.0**(229 个工具) |
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
| DeepSeek Harness | desktop runtime **0.2.2-rc.1**(2.0.14 实测宿主;全范围见上面的版本表) |
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
| Ghidra | 12.1.4 |
| JDK | Temurin 21(供给)/ Amazon Corretto 25.0.5(宿主复用) |
| Ghidra | 12.1.4(本机另有 12.2 走闸门覆盖,见下) |
| JDK | Temurin 21(供给)/ Amazon Corretto 25.0.5(宿主复用;主版本下限满足 pin `21`) |
| Node.js | **24.22.1**(复用;REA engines:`^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`,ABI 137) |
| 宿主 | Windows 11 x64(build 26200) |
@@ -309,6 +319,15 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.8.0}
盘上的 jadx jar 仍是 `6e5eacf5…` —— 正是 6.8.0 要求的那个值。把 6.8.0 自己的发布策略加载执行,得到
`12.1.4 → verified`、`12.1.5 → compatible`、`12.2 → unsupported`,所以 Ghidra 原地不动。
2026-11-06(2.0.14,外部审计 18 项修复 + 宿主适配):**上游组件一个都没动**(REA 6.8.0 / Ghidra 12.1.4 / JDK 21 /
apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1)。同一条链路重验:`--dry-run` 7 个组件全部按 pin 正确 skip(Ghidra 12.2
的闸门覆盖与 JDK 25 都没有被误判成漂移)→ 强制重装 apktool 走完整 staging 路径(无残留 `.staging`/`.backup`,
目录内容恰好等于 manifest 声明的两个文件)→ `--reuse` 全量 8 个组件 + `fridaBindings FRIDA_OK 3` →
`probe cli=6.8.0 tools=229`、Ghidra 冒烟 28 s 导入 `java.exe`、shim 解析 6.8.0,退出 0。
门禁:单测 **154 项**(151 过 / 0 失败 / 3 跳过)、`audit` 24/24 hash-pinned clean、
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4、`host-compat` 16 releases、`verify-branch` ALL CHECKS PASSED。
`check-updates.ps1` 从「假绿退出 0」变成「如实报告」,本机修完后退出 0。
## 边界与注意事项
- REA 在 6.8.0 有 229 个工具,每个会话的工具列表都会变长。
@@ -326,8 +345,17 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.8.0}
管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
- Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
极简镜像里缺它只会让这些工具失败,不影响整体安装。
- `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
- `scripts/setup.mjs` 为 REA CLI 跑的是 **`npm ci`**,复放随包发布的 `scripts/vendor-lock/`
(`package.json` + `package-lock.json`):REA 对 zod 是精确锁 `"4.4.3"`,而 4.4.3 落在
GHSA-mrhx-c7xc-m3w5 里,所以闭包通过 `overrides` 抬到 **zod 4.6.7 / minimatch 11.0.5 / glob 14.0.4**。
`npm ci` 会逐 tarball 校验 sha512;若镜像重打包导致 `EINTEGRITY`,则退到「同版本 overrides + `npm install`」
(同版本、字节不可复现,日志会明说)。落地后还会再读一次 `node_modules/zod/package.json` 的版本,低于 4.6.6 直接拒绝。
`node scripts/check-dependency-advisories.mjs` 是离线闸门,已接进 CI。
供给过程**刻意跳过所有 lifecycle 脚本** —— 唯一的例外是 **frida**:它的原生绑定
(`build/frida_binding.node`)只在 install 脚本里才会放下,所以 setup 会点名这一个包跑 `npm rebuild frida`,
然后用 `import('frida')` + 设备枚举验证(记录进 `bundle.json` 的 `components.reaAgents.frida`)。
绑定缺失时 Frida 系工具不可用,但其余 200 多个工具不受影响,所以这一项报 `skip` 而不是让整次供给失败。
这一步也是**唯一**用不上
本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
`NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
+10 -5
View File
@@ -22,13 +22,18 @@ if (-not $OutDir -or $OutDir.Trim() -eq '') { $OutDir = $PSScriptRoot }
# 版本号从 manifest 读,不再手写成固定值。2.0.10 就踩过:pin 升到 6.7.0 之后,
# 这个脚本还写着 6.5.0 —— 现有 PNG 没问题(它们是 6.5.0 时生成的),但下一次
# 重绘会把旧版本号带进市场页,而且没有人会注意到,因为图片是二进制。
# 工具数(207)不是 manifest 字段,是人工核对过的常量:升 pin 时一起核一遍。
$reaPin = '6.7.0'
# 工具数(229)不是 manifest 字段,是人工核对过的常量:升 pin 时一起核一遍。
$reaPin = '6.8.0'
$reaTools = 229
$ghidraPin = '12.1.4'
$nodePin = '24.21.0'
$nodePin = '24.22.1'
# 截图是一次「实测」,这个日期必须是真的测量日;重绘时改成当天。
$measuredOn = '2026-10-26'
# 2026-11-07:2.0.16 发布前的最后一次真机 --reuse(smoke 31 s 导入 java.exe)。
$measuredOn = '2026-11-07'
# 宿主版本没有 manifest 字段可读 —— 它不是本包的属性。写死但必须跟着实测更新:
# 2.0.16 的实测宿主是 desktop runtime 0.2.2-rc.1,之前这里写的是 0.2.0-rc.2,
# 于是市场页在展示一个两个月前的宿主。
$hostMeasured = '0.2.2-rc.1'
$manifestFile = Join-Path (Split-Path -Parent $PSScriptRoot) 'manifest\bundle.manifest.json'
if (Test-Path $manifestFile) {
try {
@@ -147,7 +152,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 $reaPin($reaTools 个工具) + DSH 桌面运行时 0.2.0-rc.2 + Ghidra $ghidraMeasured + Node ${nodePin}(Windows 11 x64,$measuredOn)" $fSmall $muted 60 646
Draw-Text $g "实测 REA $reaPin($reaTools 个工具) + DSH 桌面运行时 $hostMeasured + Ghidra $ghidraMeasured + Node ${nodePin}(Windows 11 x64,$measuredOn)" $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: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

+73 -12
View File
@@ -18,12 +18,12 @@
* `index.mjs` serves the result as GET /dsh-plugin-rea/guide-state; `client.js`
* renders the guide once per plugin version. This file also runs standalone:
*
* node bin/rea-guide.mjs --json
* node bin/rea-guide.mjs --json [--bundle <bundle.json> | --root <dir>]
*/
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
/** Route served by index.mjs and read by client.js. One definition, two readers. */
@@ -61,12 +61,35 @@ export function readJsonFile(file) {
/** `$DSH_HOME` when set, otherwise the same `<home>/.dsh` fallback the shim uses. */
export function dshHome(env = process.env) {
const explicit = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
return explicit !== '' ? explicit : join(homedir(), '.dsh')
return explicit !== '' ? resolve(explicit) : join(homedir(), '.dsh')
}
/**
* The bundle root, by the same order scripts/setup.mjs (`defaultBundleRoot`),
* bin/rea-shim.mjs (`bundleRoot`) and scripts/rea-bundle-bootstrap.ps1 use:
* `REA_BUNDLE_ROOT` first, then `<DSH_HOME>/plugins/rea-bundle`.
*
* Reading only DSH_HOME here made a machine that provisions into a custom root
* look `missing` to the guide — the one screen whose entire job is to decide
* "does setup still have to run" — so it told an already-provisioned machine to
* provision again. The guide must answer for the root the rest of the system
* agrees on.
*/
export function bundleRoot(env = process.env) {
const override = typeof env.REA_BUNDLE_ROOT === 'string' ? env.REA_BUNDLE_ROOT.trim() : ''
return override !== '' ? resolve(override) : join(dshHome(env), 'plugins', 'rea-bundle')
}
/** Where setup.mjs records what it provisioned. */
export function defaultBundlePath(home = dshHome()) {
return join(home, 'plugins', 'rea-bundle', 'bundle.json')
export function defaultBundlePath(root = bundleRoot()) {
return join(root, 'bundle.json')
}
/** Windows paths are case-insensitive; comparing `resolve`d strings is not. */
function samePath(left, right) {
const a = resolve(left)
const b = resolve(right)
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
}
/**
@@ -78,9 +101,20 @@ export function defaultBundlePath(home = dshHome()) {
* both slow and the opposite of what "align the runtime" means. On a first
* install the flag costs nothing — every candidate is probed, and a machine with
* none downloads exactly as before.
*
* `--root` is added whenever the guide looked somewhere other than the default
* `<DSH_HOME>/plugins/rea-bundle` (a `REA_BUNDLE_ROOT` override, or an explicit
* `--bundle`/`--root`). Without it the copy-pasted command would provision the
* default root and leave the one the guide just complained about untouched.
*/
export function setupCommandFor(pluginRoot = PLUGIN_ROOT) {
return `node "${join(pluginRoot, 'scripts', 'setup.mjs')}" --reuse`
export function setupCommandFor(pluginRoot = PLUGIN_ROOT, root = null) {
const command = `node ${quotePath(join(pluginRoot, 'scripts', 'setup.mjs'))} --reuse`
return root ? `${command} --root ${quotePath(root)}` : command
}
/** Quote a path so a copy-pasted command survives spaces and quotes. */
function quotePath(value) {
return `"${String(value).replace(/"/g, '\\"')}"`
}
/**
@@ -97,7 +131,15 @@ export function reaPinMatches(installed, pinned) {
const want = String(pinned ?? '').trim()
if (have === '' || want === '') return false
if (have === want) return true
if (!want.includes('.')) return have.startsWith(`${want}.`)
if (want.includes('.')) return false
// A bare-major pin ("21") is a FLOOR, not a family prefix — the same rule
// `scripts/setup.mjs` applies when it decides a recorded component still
// satisfies the manifest. The prefix form accepted 21.0.12.1+1 and rejected
// 25.0.5, so the guide said "stale, run setup" on a machine that setup had just
// called "already provisioned (25.0.5) — major 25 satisfies the pin's major 21".
const haveMajor = Number.parseInt(have.split('.')[0], 10)
const wantMajor = Number.parseInt(want, 10)
if (Number.isFinite(haveMajor) && Number.isFinite(wantMajor)) return haveMajor >= wantMajor
return false
}
@@ -118,10 +160,20 @@ export function computeGuideState(options = {}) {
const {
pluginVersion = '',
pinnedRea = '',
bundlePath = defaultBundlePath(),
bundleRoot: rootOption = null,
bundlePath: bundlePathOption = null,
pluginRoot = PLUGIN_ROOT,
} = options
// Resolve the root the way setup/shim/bootstrap do, then honour a path a caller
// handed in directly (tests and `--bundle` pass a file, not a root).
const root = rootOption ? resolve(rootOption) : bundleRoot()
const bundlePath = bundlePathOption ? resolve(bundlePathOption) : defaultBundlePath(root)
const rootInEffect = bundlePathOption ? dirname(bundlePath) : root
// Only the default root needs no flag: setup.mjs resolves DSH_HOME itself, so
// the command stays shorter there and identical to what it always printed.
const setupRoot = samePath(rootInEffect, join(dshHome(), 'plugins', 'rea-bundle')) ? null : rootInEffect
const base = {
pluginVersion: String(pluginVersion),
pinnedRea: String(pinnedRea),
@@ -129,8 +181,9 @@ export function computeGuideState(options = {}) {
provisionedAt: null,
state: 'missing',
needsSetup: true,
bundleRoot: rootInEffect,
bundlePath,
setupCommand: setupCommandFor(pluginRoot),
setupCommand: setupCommandFor(pluginRoot, setupRoot),
detail: '',
}
@@ -191,12 +244,20 @@ export function localPackageFacts(pluginRoot = PLUGIN_ROOT) {
function main(argv) {
const asJson = argv.includes('--json')
const bundleFlag = argv.indexOf('--bundle')
const flagValue = (name) => {
const index = argv.indexOf(name)
return index !== -1 && argv[index + 1] ? argv[index + 1] : null
}
// `--root` mirrors setup.mjs; `--bundle` names the record file itself and is
// what the old CLI offered. Both are overrides of the same resolution chain.
const bundlePath = flagValue('--bundle')
const root = flagValue('--root')
const facts = localPackageFacts()
const state = computeGuideState({
pluginVersion: facts.pluginVersion,
pinnedRea: facts.pinnedRea,
...(bundleFlag !== -1 && argv[bundleFlag + 1] ? { bundlePath: argv[bundleFlag + 1] } : {}),
...(bundlePath ? { bundlePath } : {}),
...(root ? { bundleRoot: root } : {}),
})
if (asJson) {
+159 -64
View File
@@ -15,7 +15,7 @@
* and the message is the operator's remediation.
*/
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
@@ -255,51 +255,100 @@ function maybeAutoProvision(root) {
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.
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) {
if (record?.state === "failed") {
// A FAILED run keeps its lock on purpose: it is the brake that stops a
// broken setup from being re-downloaded in a loop, and every restart
// printing "in flight" over nothing was the six-hour lie this branch
// replaced. The verdict is read BEFORE the pid check because the pid that
// failed is necessarily gone by the time the record is read — ordering it
// the other way would delete the brake and retry a broken run forever.
// After the freshness window the brake is released so a fixed environment
// can try again.
if (fresh) {
return {
started: false,
reason: "last-run-failed",
exit: record.exit,
failedAt: record.at,
ageMs: age,
logPath,
};
}
try {
rmSync(lock, { force: true });
} catch {
// A read-only bundle root cannot drop the expired brake either; keep
// reporting the failure rather than starting a second run beside it.
return { started: false, reason: "last-run-failed", exit: record.exit, failedAt: record.at, ageMs: age, logPath };
}
} else {
// Liveness decides for a RUNNING lock — not age.
//
// The previous rule honoured only locks younger than six hours and deleted
// anything older WITHOUT asking whether its owner still ran. A slow
// download or a stuck PowerShell step passes six hours with a live pid, and
// deleting the lock then let a SECOND provisioning run write the same
// cache, the same `<component>.staging` directories and the same
// `bundle.json` as the first. `at` is refreshed by the run itself while it
// reports progress (setup's heartbeat), so an old timestamp now means
// "nothing has reported in a long time" — a weaker claim than "the process
// is gone", and the weaker claim must not authorise the deletion.
const hasPid = Number.isInteger(record?.pid) && record.pid > 0;
const alive = pidAlive(record?.pid);
if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
}
// Reached when the lock is older than the freshness window, or its holder is
// gone and never wrote an outcome (harness restart, power loss, a kill): what
// it records is not happening any more. This removal is the whole point of the
// freshness rule — without it the exclusive create below failed with EEXIST
// *forever*, auto-provisioning never ran again, and main() printed nothing at
// all for that outcome. The lock's own "honour it only while it is fresh"
// comment described behaviour the code did not have.
try {
rmSync(lock, { force: true });
} catch {
// A read-only bundle root (system-wide install, chmod 555) cannot drop the
// stale lock either. Returning the same "cannot write here" outcome as the
// exclusive create below keeps the row alive: an uncaught EACCES killed the
// shim with a stack trace, which is louder than the old silence but just as
// unable to start REA.
return { started: false, reason: "unwritable", logPath };
if (hasPid && alive !== false) {
// A stale-but-live lock is named as such instead of pretending to be a
// normal in-flight run: the operator's move is to look at the log (or
// delete the lock by hand), and neither is discoverable from "in flight".
return {
started: false,
reason: age < STALE_LOCK_MS ? "in-flight" : "in-flight-stale",
logPath,
ageMs: age,
pid: record.pid,
};
}
if (!hasPid && fresh) {
// A record with no pid has no owner to check — a legacy plain-number lock,
// or one a human wrote by hand. "Cannot tell" must not read as "alive"
// forever, so here the timestamp is the only evidence there is: honoured
// while fresh, released afterwards, exactly as before.
return { started: false, reason: "in-flight", logPath, ageMs: age, pid: null };
}
// Reached only when the recorded pid is provably gone: what the lock records
// is not happening any more, and no outcome was ever written (harness
// restart, power loss, a kill). This removal is what keeps a lost lock from
// wedging auto-provisioning *forever* behind an exclusive create that can
// only ever fail with EEXIST.
try {
rmSync(lock, { force: true });
} catch {
// A read-only bundle root (system-wide install, chmod 555) cannot drop the
// stale lock either. Returning the same "cannot write here" outcome as the
// exclusive create below keeps the row alive: an uncaught EACCES killed
// the shim with a stack trace, which is louder than the old silence but
// just as unable to start REA.
return { started: false, reason: "unwritable", logPath };
}
}
}
// Ownership token.
//
// The lock used to be handed from this shim to the detached setup through the
// PID alone: the shim wrote its own pid, spawned setup, and rewrote the record
// with `child.pid` afterwards. Between those two writes setup could already be
// running, read a live `pid` it did not own, and exit 75 — its own exclusive
// create losing to a lock that was meant for it. The window is narrow (100
// natural starts never hit it) but it is a race, not a preference, so the token
// is what setup recognises as "this lock is mine", and the pid becomes a hint.
const token = randomUUID();
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, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now(), token }), { flag: "wx" });
} catch (error) {
if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
/* a read-only bundle root must never break the harness */
@@ -311,6 +360,7 @@ function maybeAutoProvision(root) {
detached: true,
stdio: "ignore",
windowsHide: true,
env: { ...process.env, REA_BUNDLE_LOCK_TOKEN: token },
});
} catch {
// The lock created a few lines up is ours. Leaving it as `running` with this
@@ -351,10 +401,12 @@ function maybeAutoProvision(root) {
});
child.unref();
// 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.
// row dead still sees the run that is actually doing the work as alive. The
// token travels with it: setup recognises it as its own even if it reaches its
// own lock check before this line runs.
if (Number.isInteger(child.pid)) {
try {
writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid, token }));
} catch {
/* the lock is advisory: the row keeps working without it */
}
@@ -834,8 +886,16 @@ function compareDotted(a, b) {
* honoured literally — `system` fails loudly rather than provisioning 800 MB
* behind an operator who asked for their own REA.
*/
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, bundleUsable = null, env = process.env, platform = process.platform } = {}) {
const preference = launchPreference(env);
// `bundleProvisioned` says a record exists; `bundleUsable` says it can start
// REA. The comparison below has to use the second one — a bundle whose record
// is "newer" but whose runtime is missing is not a better answer than a system
// REA that works. Callers that pass only the first keep the old behaviour.
const bundleRunnable = bundleUsable === null ? bundleProvisioned : bundleUsable === true;
const bundleStateReason = bundleProvisioned && !bundleRunnable
? "the provisioned bundle cannot launch REA"
: null;
if (preference.value === "bundle") {
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
}
@@ -858,15 +918,17 @@ async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = f
const pinned = preference.value === "system";
// No provisioned build at all: there is nothing to compare against, and the
// machine's own REA is exactly what this mode exists to use.
if (!bundleProvisioned || pinned || (order !== null && order >= 0)) {
if (!bundleRunnable || pinned || (order !== null && order >= 0)) {
return {
preference: preference.value,
invalid: preference.invalid,
mode: "system",
reason: pinned
? `pinned by REA_BUNDLE_LAUNCH=system (system ${system.version}, bundle ${bundleVersion ?? "none"})`
: !bundleProvisioned
? `a working REA is on PATH (${system.version}) and this machine has no provisioned bundle`
: !bundleRunnable
? bundleStateReason !== null
? `a working REA is on PATH (${system.version}) and ${bundleStateReason}; using the system install`
: `a working REA is on PATH (${system.version}) and this machine has no provisioned bundle`
: `the REA on PATH (${system.version}) is at least as new as the provisioned one (${bundleVersion})`,
system,
};
@@ -1155,11 +1217,20 @@ async function main() {
}
}
const loaded = readBundleConfig(root);
// Decide what to run before asking whether the bundle is provisioned: a
// machine with its own working REA must not be sent into an ~800 MB
// provisioning run just because this bundle has no state yet.
const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
// One definition of "the bundle can actually launch REA", used by the launch
// decision, the status exit code and the repair path alike.
//
// `loaded.ok` only means `bundle.json` parsed. Treating that as "provisioned"
// decided three separate questions with the wrong answer: a bundle naming a
// `paths.node` that no longer exists still won the auto comparison against a
// system REA that worked (because its recorded version was "newer"), still
// printed `provisioned: true`, and still returned exit 0 from `--shim-status`
// while `rea.ok` was false — a monitor reading the exit code saw a healthy row
// that could not start.
const bundleRea = loaded.ok ? resolveRea(loaded.config, root) : null;
const bundleUsable = loaded.ok && bundleRea !== null && bundleRea.ok === true;
const bundleVersion = bundleUsable ? (loaded.config.versions?.reaAgents ?? null) : null;
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok, bundleUsable });
if (launch.invalid !== null) {
process.stderr.write(
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
@@ -1205,14 +1276,19 @@ async function main() {
if (loaded.ok) {
report.paths = loaded.config.paths ?? {};
report.versions = loaded.config.versions ?? {};
report.rea = resolveRea(loaded.config, root);
// `bundleUsable` is what makes the fields above trustworthy: paths and
// versions can be reported for a bundle that cannot start, and a caller
// reading only those would conclude the opposite of `rea.ok`.
report.bundleUsable = bundleUsable;
report.rea = bundleRea;
}
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
// The row works either way round — a system launch needs no bundle, a bundle
// launch needs a provisioned one — but a PINNED system launch with nothing to
// launch is a failure whether or not the bundle happens to be provisioned,
// and reporting 0 there is how a broken row looks healthy until it starts.
const usable = launch.mode === "system" ? launch.unusable !== true : loaded.ok;
// launch needs a bundle that can actually start REA. Reporting 0 off
// `loaded.ok` meant a parsed-but-broken bundle looked healthy until the row
// started and exited non-zero, which is the one thing a status command exists
// to prevent.
const usable = launch.mode === "system" ? launch.unusable !== true : bundleUsable;
return usable ? 0 : EX_CONFIG;
}
@@ -1237,16 +1313,22 @@ async function main() {
return launchSystemRea(launch.system, argv);
}
if (!loaded.ok) {
const message =
loaded.code === "missing"
if (!bundleUsable) {
// Two ways to get here, and they need different words: no usable state at
// all, or state that parses but names a runtime that is not there. The second
// case used to fall through to a bare `fail(rea.reason)` with no repair path
// at all, so a bundle whose `paths.node` had been deleted failed on every
// restart until a human ran setup by hand.
const message = !loaded.ok
? loaded.code === "missing"
? `No bundle state at ${loaded.file}.`
: `Bundle state at ${loaded.file} could not be parsed.`;
// 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.
: `Bundle state at ${loaded.file} could not be parsed.`
: `Bundle state at ${loaded.file} parsed, but it cannot launch REA: ${bundleRea.reason}`;
// Both "missing"/"malformed" and "unusable" 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(
@@ -1263,17 +1345,30 @@ async function main() {
process.stderr.write(
`\n dsh-plugin-rea (bundle): provisioning is already running in the background.\n progress: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "in-flight-stale") {
// The lock names a pid that still exists but has not reported progress for
// over six hours. Deleting it would risk two runs writing the same staging
// directories, so the decision is left to the operator, who can see whether
// that pid is really a downloader.
const minutes = Number.isFinite(provisioning.ageMs) ? Math.round(provisioning.ageMs / 60000) : null;
process.stderr.write(
`\n dsh-plugin-rea (bundle): a provisioning run still owns the lock but has not reported for ` +
`${minutes === null ? "a long time" : `${minutes} minute(s)`} (pid ${provisioning.pid ?? "?"}).\n` +
` progress: ${provisioning.logPath}\n` +
` If that process is gone, delete ${path.join(root, "setup.lock")} and restart.\n`,
);
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
process.stderr.write(
`\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}" --root "${root}"\n`,
);
}
return fail(message, loaded.detail);
return fail(message, loaded.ok ? undefined : loaded.detail);
}
const rea = resolveRea(loaded.config, root);
if (!rea.ok) return fail(rea.reason);
// Already resolved above, and `bundleUsable` is exactly that verdict: resolving
// a second time would open the door to the two answers disagreeing.
const rea = bundleRea;
const env = childEnvironment(loaded.config);
if (argv.includes("--shim-print-env")) {
+90 -24
View File
@@ -22,10 +22,22 @@ param(
)
$ErrorActionPreference = 'Continue'
# 判绿计数器。「注意」必须计入最终结论:2.0.13 里 pin 漂移只写一行 Warn、不进
# $actions,末尾照样打印「无需动作」,而那一行正是 pin 升级后唯一面向用户的信号。
$warnings = 0
$badCount = 0
# bundle 分支只在 bundle.json 存在时赋这三个值;结论处直接拿它们比较,所以先给 0
# ($null -eq 0 是 $false,会让「根本没供给」也走进「另有 N 项采纳」那条绿分支)。
$drift = 0
$unrecorded = 0
$adopted = 0
function Head($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
function Ok($t) { Write-Host " [OK] $t" -ForegroundColor Green }
function Warn($t) { Write-Host " [注意] $t" -ForegroundColor Yellow }
function Bad($t) { Write-Host " [动作] $t" -ForegroundColor Red }
function Warn($t) { $script:warnings++; Write-Host " [注意] $t" -ForegroundColor Yellow }
# 「上游」不是本机状态:registry 查不通、GitHub 最新版不是 12.1.x、本机没有 rea 可跑
# doctor —— 这些既不能证明 bundle 一致,也不构成待办,所以单独一类,不参与判绿。
function Note($t) { Write-Host " [上游] $t" -ForegroundColor DarkYellow }
function Bad($t) { $script:badCount++; Write-Host " [动作] $t" -ForegroundColor Red }
function Info($t) { Write-Host " $t" -ForegroundColor Gray }
$actions = [System.Collections.Generic.List[string]]::new()
@@ -164,7 +176,7 @@ $latest = $null
foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
if (-not $r) { continue }
try { $latest = (Invoke-RestMethod "$r/rea-agents/latest" -TimeoutSec 20).version; Info "最新版本($r): $latest"; break }
catch { Warn "查询失败 $r : $($_.Exception.Message)" }
catch { Note "查询失败 $r : $($_.Exception.Message)" }
}
if ($installed -and $latest) {
$vi = Get-NumericVersion $installed; $vl = Get-NumericVersion $latest
@@ -179,9 +191,9 @@ if ($installed -and $latest) {
Warn "同版本号但通道不同:$installed → $latest(预发布/正式)"
} else { Ok '已是最新版本' }
} elseif (-not $latest) {
Warn '未能取到上游最新版本(两个 registry 都查不通?)—— 已跳过版本比对'
Note '未能取到上游最新版本(两个 registry 都查不通?)—— 已跳过版本比对'
} else {
Warn '本地没读到已安装版本(见上面那条)—— 已跳过版本比对'
Note '本地没读到已安装版本(见上面那条)—— 已跳过版本比对'
}
# ------------------------------------------------------------- Node 运行时
@@ -194,7 +206,7 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
$ok = Test-NodeEngines $nodeV $engines
if ($ok -eq $true) { Ok "满足当前 engines($engines)" }
elseif ($ok -eq $false) { Bad "不满足 engines($engines)—— 请升级 Node;插件侧走 PATH,无需改配置" }
else { Warn "无法解析 engines:$engines" }
else { Note "无法解析 engines:$engines" }
}
} else {
Warn 'node 不在 PATH —— 跳过 Node 检查;若 harness 也看不到它,MCP 服务器将无法启动'
@@ -225,7 +237,21 @@ if ($gd -and (Test-Path $gd)) {
} else {
$v = $line -replace '^application\.version=', ''
Ok "已安装版本: $v"
if ($v -notmatch '^12\.1\.') { Bad "REA 当前要求 Ghidra 12.1.x,当前为 $v —— 先确认新版 REA 是否放开版本门槛" }
if ($v -notmatch '^12\.1\.') {
# 本机 --enable 过的闸门覆盖是**有记录的**偏离:REA 的闸门确实钉在 12.1.x,
# 但这台机器故意跑了更新的版本。把它报成 [动作] 等于劝人撤掉覆盖,而
# override 记录已经在下方的 bundle 分支单独列出 —— 所以这里只说明,不计数。
$gateOverride = $null
$gateOverrideFile = Join-Path $bundleRootEarly 'ghidra-gate-override.json'
if (Test-Path $gateOverrideFile) {
try { $gateOverride = Get-Content $gateOverrideFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $gateOverride = $null }
}
if ($gateOverride -and [string]$gateOverride.requestedVersion -eq $v) {
Info "REA 闸门钉在 12.1.x;当前 $v 是本机 --enable 的覆盖(不是 pin 漂移,记录见 bundle 分支)"
} else {
Bad "REA 当前要求 Ghidra 12.1.x,当前为 $v —— 先确认新版 REA 是否放开版本门槛"
}
}
}
} else { Bad '缺少 Ghidra\application.properties —— 目录不完整' }
} else {
@@ -235,8 +261,8 @@ if ($gd -and (Test-Path $gd)) {
try {
$rel = Invoke-RestMethod 'https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest' -TimeoutSec 25 -Headers @{ 'User-Agent' = 'rea-check' }
Info "Ghidra 官方最新: $($rel.tag_name) ($($rel.published_at))"
if ($rel.tag_name -notmatch 'Ghidra_12\.1') { Warn '官方已有更新;升级前先确认 REA 是否接受该版本' }
} catch { Warn "GitHub API 查询失败(可跳过): $($_.Exception.Message)" }
if ($rel.tag_name -notmatch 'Ghidra_12\.1') { Note '官方已有更新;升级前先确认 REA 是否接受该版本' }
} catch { Note "GitHub API 查询失败(可跳过): $($_.Exception.Message)" }
# --------------------------------------------------------- REA 引擎自检
Head 'REA 引擎自检(rea doctor)'
@@ -266,13 +292,24 @@ if (-not $reaCmd) {
if (-not $reaCmd) {
if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
$reaCmd = 'node'; $reaArgs = @($reaEntry)
Warn 'rea 不在 PATH —— 回退到 node <包内入口>'
} else { Warn 'rea 与 node 均不可用 —— 跳过引擎自检' }
Note 'rea 不在 PATH —— 回退到 node <包内入口>'
} else { Note 'rea 与 node 均不可用 —— 跳过引擎自检' }
}
if ($reaCmd) {
try {
$raw = & $reaCmd @reaArgs doctor --format json 2>$null
if ($LASTEXITCODE -ne 0) { Warn "rea doctor 退出码 $LASTEXITCODE —— 报告了不健康状态,下面按输出解析" }
if ($LASTEXITCODE -ne 0) {
# Not a machine-state warning.
#
# `rea doctor` exits non-zero while ANY optional capability is unconfigured:
# Hopper, IDA, Kuna, the skill copies, the five client registrations. A
# perfectly provisioned bundle therefore still exits 1, and treating that as
# a warning made every healthy machine report "需要动作" — a false red, which
# is the mirror image of the false green this script was just fixed for. The
# state that actually matters is parsed out of the output below (the provider
# inspection and its checks), and THAT drives Ok/Bad.
Note "rea doctor 退出码 $LASTEXITCODE(包含可选能力未配置;实际状态按下方输出解析)"
}
$doc = ($raw -join "`n") | ConvertFrom-Json
$g = @($doc.providerInspections) | Where-Object { $_.id -eq 'ghidra' }
if ($g) {
@@ -287,7 +324,7 @@ if ($reaCmd) {
if ($doc.identity.catalog -and $doc.identity.catalog.counts) { $toolCount = $doc.identity.catalog.counts.mcp_tools }
Info "doctor 识别到的 CLI 版本: $($doc.identity.cli_package_version)$(if ($toolCount) { ",MCP 工具数: $toolCount" })"
}
} catch { Warn "rea doctor 解析失败,请手动运行: rea doctor" }
} catch { Note "rea doctor 解析失败,请手动运行: rea doctor" }
}
# ---------------------------------------------------- DSH 插件登记状态
@@ -302,15 +339,15 @@ if (Test-Path $profPkg) {
if ($p.PSObject.Properties.Name -contains 'dsh' -and $p.dsh -and $p.dsh.profile -and $p.dsh.profile.bundles) { $bundles = @($p.dsh.profile.bundles) }
if ($dep) { Ok "依赖已登记: dsh-plugin-rea = $dep" } else { Bad '依赖未登记 —— 用插件管理器重新安装本插件' }
if ($bundles -contains 'dsh-plugin-rea') { Ok '已列入 dsh.profile.bundles(装载层已生效)' }
elseif ($bundles.Count -eq 0) { Warn '未读到 dsh.profile.bundles(profile 结构可能不同)' }
elseif ($bundles.Count -eq 0) { Note '未读到 dsh.profile.bundles(profile 结构可能不同)' }
else { Bad '未列入 bundles —— 插件不会加载' }
Info '本插件不会自动更新:从插件市场安装的在市场里更新;本地 file: 目录方式则覆盖目录后在插件管理器里重装'
if ($dep -like 'file:*') {
$p2 = $dep -replace '^file:', ''
if (Test-Path $p2) { Ok "插件目录存在: $p2" } else { Bad "插件目录不存在: $p2 —— 移走目录会加载失败" }
}
} catch { Warn "profile package.json 解析失败: $($_.Exception.Message)" }
} else { Warn "找不到 profile: $profPkg" }
} catch { Note "profile package.json 解析失败: $($_.Exception.Message)" }
} else { Note "找不到 profile: $profPkg" }
$profPatch = Join-Path $ProfileDir 'cordis.patch.yml'
if (Test-Path $profPatch) {
@@ -404,8 +441,11 @@ if (-not (Test-Path $manifestPath)) {
if (Test-PinMatch -Installed $installed -Pinned $pinned) { Info " $name = $installed(pin $pinned)" }
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
}
# 采纳(--reuse 的本机运行时)和本机覆盖(ghidra-gate-override)是**有记录的**
# 偏离,不是「忘了对齐」:它们既不是 drift,也不该变成一条警告把每台正常机器
# 都判成需要动作。所以这里用 [上游] 如实说明「并非全部精确等于 pin」。
if ($drift -eq 0 -and $unrecorded -eq 0 -and $adopted -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
elseif ($drift -eq 0 -and $unrecorded -eq 0) { Warn "共比对 $compared 项:其中 $adopted 项为采纳的本机运行时(按 engines 放行,见上),其余与 pin 一致" }
elseif ($drift -eq 0 -and $unrecorded -eq 0) { Note "共比对 $compared 项:其中 $adopted 项为采纳的本机运行时 / 本机覆盖(见上),其余与 pin 一致" }
elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
@@ -423,13 +463,23 @@ if (-not (Test-Path $manifestPath)) {
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
if ($pluginShim -and (Test-Path $pluginShim)) {
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
# `Get-FileHash` is missing on hosted Windows runners and in any host
# running PowerShell in a restricted language mode. That is a
# "cannot verify" answer, not an "inconsistent" one: reporting it as
# [注意] made such a machine look like it needed action, which is the
# same false red this script was just fixed for in the other
# direction. Ask whether the comparison is even possible first.
if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {
Note '本机没有 Get-FileHash,无法比对镜像 shim 是否最新 —— 跳过这一项'
} else {
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
}
} else {
Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
Note "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
}
} catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
} catch { Note "mirror.json 解析失败: $($_.Exception.Message)" }
}
}
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
@@ -452,7 +502,23 @@ if (-not (Test-Path $manifestPath)) {
# ------------------------------------------------------------------ 汇总
Head '结论'
if ($actions.Count -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
else { $actions | ForEach-Object { Bad $_ } }
# 判绿条件:没有待办、没有 pin 漂移、没有未记录版本、没有「注意」、没有「动作」行。
# 此前只看 $actions,于是「jdk 盘上为 25.0.5,manifest 已钉 21」这种漂移照样以
# 「无需动作:整条链路与上游保持一致」收尾、退出码 0 —— 假绿正是这么产生的。
$reasons = @()
if ($actions.Count -gt 0) { $reasons += "待办 $($actions.Count) 项" }
if ($drift -gt 0) { $reasons += "pin 漂移 $drift 项" }
if ($unrecorded -gt 0) { $reasons += "未记录版本 $unrecorded 项" }
if ($warnings -gt 0) { $reasons += "提醒 $warnings 条" }
if ($badCount -gt 0) { $reasons += "问题 $badCount 处" }
Write-Host ''
Write-Host '提示:DSH 侧更新(应用升级可能改变 MCP 客户端配置结构)后,请重跑本脚本并调用一次 mcp__rea__binary_session 验证。' -ForegroundColor Gray
if ($reasons.Count -eq 0) {
if ($drift -eq 0 -and $unrecorded -eq 0 -and $adopted -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
else { Ok "无需动作:能比对的项目均与 pin 一致(另有 $adopted 项采纳 / 本机覆盖,见上)" }
exit 0
}
# 先列待办,最后打印结论:末行必须永远是判绿/判红的结论,而不是它上面某条动作。
$actions | ForEach-Object { Bad $_ }
Write-Host (' [结论] 需要动作:' + ($reasons -join ';') + '(逐条见上)') -ForegroundColor Red
exit 1
+404
View File
@@ -0,0 +1,404 @@
# 宿主兼容性:DSH 0.2.1-rc.3 → 0.2.2-rc.1(2026-11-06)
`package.json` 的 `engines.dsh = ">=0.1.7-rc.2 <0.3.0-0"` 是市场判"兼容/未知"的唯一依据,
`scripts/host-compat.mjs` 的 `HOST_RELEASES` 是"本包适配过的 dsh 清单"。本轮把清单从 10 条补到 16 条,
并逐个核对新 release 有没有动本插件依赖的接口面。**结论:范围不用改,六个新 release 无一改动四个接口面;
清单补齐即可;另有两处与本次 release 无关、但需要 Lead 决策的旧问题(§4、§6)。**
---
## 0. 结论
| 项 | 结论 | 依据 |
|---|---|---|
| `engines.dsh` 要不要动 | **不动**。`0.2.2-rc.1` 仍在 `<0.3.0-0` 以内 | `node scripts/host-compat.mjs` 16 条全 `ok`,exit 0 |
| `HOST_RELEASES` | 10 → **16**(新增 6 条) | 6 个 tag 在 GitHub releases 与 npm 双向核实存在(§1) |
| floor 是否移动 | **不动**,仍是 `0.1.7-rc.2` | 没有更老版本被适配,也没有理由放弃它 |
| `OUTSIDE_HOSTS` | **不动**,仍 5 条 | 本期没有任何 0.3.x 发布,边界未变 |
| `satisfiesDeclaredRange` 窄语义 | **不动**,仍只认 `>=X <Y` | 放宽会与市场实现分叉 |
| 六个新 release 的接口面影响 | **全部无变化** | 逐条原文 + 本机运行时代码(§2) |
| 本机宿主版本 | **0.2.2-rc.1**(桌面版) | `app.asar` 内 `package.json`(§3) |
| Web 卡片在新宿主能否渲染 | **能** | `dsh-client-runtime` 是死条目但被静默跳过,`slots` 服务走的是宿主自身约定(§4) |
| 需要 Lead 改 `package.json` 的事项 | 2 条(可选清理 1 条、待决 1 条) | §6 |
---
## 1. 版本核实表
三个独立来源交叉:GitHub releases 页(`?page=1` / `?page=2`)、GitHub releases atom feed、npm registry。
- npm:`https://registry.npmjs.org/@deepseek-ai/dsh`(`versions` 42 条,`time` 给出每条的发布时间;**已发布版本在 npm 上不会被静默删除**,所以"在 `versions` 里"就是存在性的硬证据)
- GitHub 页:`https://github.com/deepseek-ai/deepseek-harness/releases?page=1` / `?page=2`(tag 形如 `dsh-v<ver>`)
- atom:`https://github.com/deepseek-ai/deepseek-harness/releases.atom`(只带最近 10 条,正好覆盖全部 6 个新版本,且**带 release body 全文**)
### 1.1 本轮新增的 6 个(用户截图里看到的)
| GitHub tag | npm 版本 | npm 发布(UTC) | GitHub release 发布(UTC) | 存在性 | 证据 |
|---|---|---|---|---|---|
| `dsh-v0.2.2-rc.1` | 0.2.2-rc.1 | 2026-11-04T09:20:32Z | 2026-11-04T13:07:14Z | **存在** | [npm](https://registry.npmjs.org/@deepseek-ai/dsh)、[releases](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.1) |
| `dsh-v0.2.2-alpha.3` | 0.2.2-alpha.3 | 2026-11-03T07:48:26Z | 2026-11-03T08:27:26Z | **存在** | 同上 |
| `dsh-v0.2.1-rc.5` | 0.2.1-rc.5 | 2026-10-29T14:15:23Z | 2026-10-30T02:12:26Z | **存在** | 同上 |
| `dsh-v0.2.2-alpha.2` | 0.2.2-alpha.2 | 2026-10-27T19:10:28Z | 2026-10-28T07:00:42Z | **存在** | 同上 |
| `dsh-v0.2.1-rc.4` | 0.2.1-rc.4 | 2026-10-26T15:28:13Z | 2026-10-28T03:08:57Z | **存在** | 同上 |
| `dsh-v0.2.1-rc.3` | 0.2.1-rc.3 | 2026-10-23T07:54:30Z | 2026-10-23T15:26:39Z | **存在** | 同上 |
两个时间列不同是正常的:npm `time` 是 tarball 发布时刻,GitHub `<updated>` 是 release 页面发布时刻,通常相差数小时。
`0.2.2-rc.1` 是**当前最新**:npm `dist-tags.latest = 0.2.2-rc.1`、`next = 0.2.2-rc.1`、`alpha = 0.2.2-alpha.3`;
GitHub releases 第 1 页第一条就是它。截至 2026-11-06 没有更新的发布。
### 1.2 清单里原有的 10 个
| npm 版本 | npm 发布(UTC) | GitHub tag | 证据 |
|---|---|---|---|
| 0.2.1-rc.2 | 2026-10-17T08:36:31Z | `dsh-v0.2.1-rc.2`(page 1) | npm `versions` + releases page 1 |
| 0.2.1-rc.1 | 2026-10-17T03:17:24Z | `dsh-v0.2.1-rc.1`(page 1) | 同上 |
| 0.2.1-alpha.5 | 2026-10-16T12:31:49Z | `dsh-v0.2.1-alpha.5`(page 1) | 同上 |
| 0.2.1-alpha.4 | 2026-10-15T13:08:08Z | `dsh-v0.2.1-alpha.4`(page 1) | 同上 |
| 0.2.1-alpha.3 | 2026-10-14T12:35:33Z | `dsh-v0.2.1-alpha.3`(page 2) | npm `versions` + releases page 2 |
| 0.2.1-alpha.2 | 2026-10-09T08:18:00Z | `dsh-v0.2.1-alpha.2`(page 2) | 同上 |
| 0.2.1-alpha.1 | 2026-10-03T04:53:22Z | `dsh-v0.2.1-alpha.1`(page 2) | 同上 |
| 0.2.0-rc.2 | 2026-09-29T09:56:27Z | `dsh-v0.2.0-rc.2`(page 2) | 同上 |
| 0.2.0-rc.1 | 2026-09-28T12:34:03Z | `dsh-v0.2.0-rc.1`(page 2) | 同上 |
| 0.1.7-rc.2 | 2026-09-24T14:18:11Z | `dsh-v0.1.7-rc.2`(page 2) | 同上 |
**10 条全部真实存在,没有一条被撤下**:全部出现在 npm `versions` 里(撤下的版本会从 `versions` 消失、只在 `time` 留痕),
且全部在 GitHub releases 页以 tag 出现。
`OUTSIDE_HOSTS` 里的 `0.1.7-rc.1`、`0.1.7-alpha.2` 也是真实 tag(page 2 可见)——它们是**真实存在但我们刻意不覆盖**的版本,
不是虚构的对照物。
### 1.3 排序说明(会误导下一个人,所以写进代码注释)
清单按**发布日**排序(GitHub 页面顺序),不是按 semver 优先级降序。两者在这一个位置冲突:
```
0.2.1-rc.5 发布于 2026-10-29/30
0.2.2-alpha.2 发布于 2026-10-27/28
```
按日期 `rc.5` 在前,按 semver `0.2.2-alpha.2`(更高)在前。清单取**日期序**,因为那是用户实际升级经过的顺序。
`scripts/host-compat.test.mjs` 专门 pin 了"全表 semver 降序、只有这一处例外",所以谁想把它"修正"成 semver 序都会红。
---
## 2. 六个新 release 对本插件的接口面影响
先把"接口面"从代码里问出来,而不是凭印象列。本插件实际依赖的只有五处:
| # | 接口面 | 出处 |
|---|---|---|
| A | `cordis.patch.yml` 的 Loader YAML 方言:`insert` 行、`id` 定向、`!!js` 表达式 | `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它) |
| B | MCP 行配置 schema:`name: '@deepseek-ai/dsh-mcp-client'`、`config.serverName: rea`、`config.transport: stdio`、`command`/`args`/`env`/`failOnStartupError` | `cordis.patch.yml` |
| C | 宿主插件 API:`export const name`、`export function apply(ctx)`、`ctx.inject(['webServer'], cb)`、`ctx.effect(disposer)`、`webServer.register({ kind: 'exact', path, handler })` | `index.mjs` |
| D | 客户端 bundle 契约:`window.__ModuleLoader__.load({ id, factory })`、`factory(require)` 里 `require('react')` / `require('react-dom')`、Cordis 服务级 `inject = ["slots"]`、`ctx.slots.inject('shell.overlay', …)` | `client.js` |
| E | 市场侧 `engines.dsh` 判定:`satisfiesRange(host, declared, { includePrerelease: true })` | 本机 `[email protected]` 的 `lib/discovery-compatibility.js:74`(见下) |
**E 的现场取证**(`D:\dsh-home\profiles\desktop\node_modules\dshmarket\lib\discovery-compatibility.js`):
```js
function rangeResult(hostVersion, declared) {
return satisfiesRange(hostVersion, declared, { includePrerelease: true });
}
```
与 `host-compat.mjs` 文件头写的语义完全一致,包括"市场读**两处**申报位置、顶层 `engines.dsh` 优先"
(同文件 `manifestFacts` 里的 `engines.dsh` / `dsh.engines.dsh` 双读,注释指向 issue #577)。
### 2.1 `dsh-v0.2.2-rc.1`(2026-11-04)—— 唯一需要实证的一个
相关原文([release](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.1)):
> * Update to Cordis Core 4.1.0, Loader 1.1.0, and Include 1.1.0, unifying configuration access, entry changes, and persistence behavior. by @shigma, @imccyu
> * Provide unified Cordis Fiber access to raw configuration, candidate resolution, and effective configuration, used consistently by settings and hot reload. by @shigma
> * Raise the minimum supported Node.js version to 24.9. Upgrade older Node.js runtimes before upgrading DSH. by @tianyicui
> * Activate tools conditionally per step with consistent model declarations, PTC bindings, and execution admission. **Plugins must migrate tools.resolve/executable and prompt-assembly integration.** by @LegGasai
> * Make Session Projection registration asynchronous, with plugins retaining a handle at startup. by @kermanx, @Magolor
> * Rename Custom plugin markets to Custom plugin indexes. Discover reads user-added index files, and index generation excludes private versions, tags, and default-branch packages. by @turtle2099, @turtle1999
> * Official bundle lists are now exported by `@deepseek-ai/dsh-official-bundles`; custom integrations must update imports from app-boot. by @turtle2099
> * Show rich-text plugin details in the plugin manager and improve page layout and navigation. by @turtle1999, @turtle2099
> * Support updating third-party plugin packages and attempt to restore the previous version if hot updates fail. by @turtle1999, @turtle2099
逐条判定:
| 条目 | 是否触及本插件 | 理由 |
|---|---|---|
| Cordis Core 4.0.0-rc.11 → **4.1.0**、Loader 1.0.0-rc.8 → **1.1.0**、Include → 1.1.0 | **触及(接口面 A)** | 本插件的 `cordis.patch.yml` 就是 Loader 的方言。release body 未宣告破坏性变更;**实测见下** |
| Fiber 统一配置访问 | 否 | 影响经 Fiber 读配置的插件;`index.mjs` 不读 `ctx` 配置,两行的 `config` 由 Loader 直接交给被插入的插件 |
| Node 下限 24.9 | 否(满足) | 本机宿主自带 node **24.21.0 ≥ 24.9** |
| `tools.resolve/executable` 迁移 | 否 | 本插件不注册原生 agent 工具,只有一条 MCP 行 + 一个 `webServer` 只读路由 |
| Session Projection 异步化 | 否 | 全仓 grep 无 `SessionProjection` / `sessionProjection` |
| 市场 markets → indexes、索引生成排除 private/tags/default-branch | 否(不影响判定语义) | 影响索引**生成**流程;`engines.dsh` 判定逻辑在 1.66.39 里仍是上面那行 `includePrerelease: true` |
| `app-boot` → `@deepseek-ai/dsh-official-bundles` | 否 | 本插件不 import `app-boot` |
| plugin manager 富文本 / 第三方包可更新 | 否 | 纯 UI + 更新流程 |
**接口面 A 的实测**(本会话就跑在这台 0.2.2-rc.1 宿主的 Web GUI 上):
```
GET http://127.0.0.1:31896/dsh-plugin-rea/guide-state
→ HTTP 200
{"pluginVersion":"2.0.13","pinnedRea":"6.8.0","installedRea":"6.8.0",
"provisionedAt":"2026-10-26T13:15:52.343Z","state":"ok","needsSetup":false, …}
```
这个 200 同时证明了三件事:`cordis.patch.yml` 的 `insert` 行被 4.1.0 的 Loader 接受并执行了(否则 `index.mjs` 不会 `apply`)、
`ctx.inject(['webServer'])` 在 4.1.0 上仍按预期解析、`webServer.register({kind:'exact'})` 的签名没变。
另外 `mcp__rea__*` 工具在本会话可用,说明接口面 B(`serverName: rea` + `transport: stdio`)在 0.2.2-rc.1 上正常工作。
> 未验证补记:Cordis 4.1.0 对 `!!js` 表达式求值作用域是否有变 —— 增量结论只覆盖**本插件实际用到的表达式形态**
> (`process.execPath`、`process.env.*` 字符串拼接、无 `path`/`require`),没做语法面全覆盖。
### 2.2 `dsh-v0.2.1-rc.4`(2026-10-26)
> * Fix diagnostics leaking into stdout in ACP mode. by @LegGasai, @tianyicui
接口面:否。这是宿主自身 ACP 模式的 stdout 洁净度修复,不改 MCP 行 schema。**值得记一笔**:它说明宿主在意
"诊断信息不能混进协议通道",与本插件 MCP 子进程只允许 stdout 输出协议帧的约定同向,不冲突。
### 2.3 `dsh-v0.2.2-alpha.2`(2026-10-27)
与 rc.1 同批 chores 的前身,条目基本一致(Node 24.9、Session Projection 异步化、Cordis Timer 同步、
`app-boot` → `dsh-official-bundles`、市场改名 indexes、`tools` 按步条件激活、PTC/birpc、SSH 协议 2→3)。逐条判定同 §2.1 表。
两条本版特有、需要单独说明的:
> * Synchronize Cordis Timer with its upstream implementation; **custom plugins must adapt to changed return values**. by @shigma
> * Report caller-cancelled run_code calls in PTC Mode as ABORTED rather than CODE_RUN_FAILED. SDK and Session consumers must update cancellation handling. by @kermanx
- Cordis Timer:本插件用的是**全局 `setTimeout`**,不是 `ctx.setTimeout` / Cordis Timer 服务 —— `client.js:223`、`client.js:283`、
`bin/rea-shim.mjs:756` 三处全部是 Node/浏览器的 `setTimeout`。不适用。
- PTC / `run_code`:本插件不使用 PTC。不适用。
### 2.4 `dsh-v0.2.1-rc.5`(2026-10-29)
只有一条:Improve how thinking content is displayed in conversations. by @LegGasai —— 纯展示层,接口面:否。
### 2.5 `dsh-v0.2.2-alpha.3`(2026-11-03)
新增/修复集中在统一右键与键盘菜单、文件预览目录树、变更请求与工作树、移动端布局、Trajectory 子调用折叠、
pi-ai 图片数量上限卸载、Markdown 链接行号、历史会话加载。chores 侧两条可能沾边的:
> * Allow plugins to supply dynamic pi-ai headers using Session identity and request purpose, rejecting invalid headers before dispatch. by @LegGasai
> * Allow plugins to supply navigation icons for their settings pages, restoring the default icon when unloaded. by @Magolor
两者都是**给插件的可选新增能力**(pi-ai 请求头、设置页导航图标),不注册就不用,不构成破坏。接口面:否。
### 2.6 `dsh-v0.2.1-rc.3`(2026-10-23)
> * Add More menus to the left sidebar, conversation views, and right sidebar for browsing and creating plugins. by @Yifffan, @imccyu
> * Fix New Session losing an unsent draft after switching sessions. by @imccyu
> * The DeepSeek API key adapter now updates its model list the same way as account sign-in. by @LegGasai
第一条沾"浏览和创建插件",但落在 UI 菜单层;不触及 `engines.dsh` 判定、不触及 `dsh.client.inject`。接口面:否。
### 2.7 小结
| release | A patch 方言 | B MCP 行 | C 宿主插件 API | D 客户端契约 | E 市场判定 |
|---|---|---|---|---|---|
| 0.2.2-rc.1 | 可能有变(Cordis 4.1.0)→ **实测通过** | 无变化 | 无变化(实测通过) | 无变化 | 无变化 |
| 0.2.2-alpha.3 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.1-rc.5 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.2-alpha.2 | 见 rc.1 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.1-rc.4 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
| 0.2.1-rc.3 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
---
## 3. 本机宿主实测
| 项 | 值 | 来源 |
|---|---|---|
| 桌面宿主版本 | **`@deepseek-ai/[email protected]`** | `D:\deepseekharnes\新建文件夹\resources\app.asar` 内 `package.json`(asar header → `files.package.json`,size 882,offset 207407341) |
| 运行时包 | `@deepseek-ai/[email protected]` | 同 asar 的 `dsh/package.json` |
| `hostProtocolVersion` | 4 | 同 asar 的 `dsh/desktop-runtime.json` |
| 宿主自带 Node | **v24.21.0** | `resources\runtime\primary-runtime\dependencies\node\bin\node.exe --version`;`runtime\versions.json` 里 `"node": "24.21.0"` |
| 宿主自带 pnpm | 11.28.5 | `runtime\versions.json` |
| `dsh-home` 运行时副本 | v24.21.0 | `D:\dsh-home\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe --version` |
| PATH 上的 node | **v24.22.1**(`F:\jiuguan\node.exe`) | `node --version` / `where.exe node` |
| npm-global CLI | `@deepseek-ai/[email protected]`,`dsh --version` → `0.1.7-rc.2` | `D:\dev\npm-global\node_modules\@deepseek-ai\dsh\package.json` |
| 已安装插件副本 | `[email protected]`,`engines.dsh` 同串 | `D:\dsh-home\profiles\desktop\node_modules\dsh-plugin-rea\package.json` |
| 已装的 REA | 6.8.0(provisionedAt 2026-10-26T13:15:52Z) | `D:\dsh-home\plugins\rea-bundle\bundle.json` + guide-state 路由 |
| 当前会话宿主 | 0.2.2-rc.1(本会话的 GUI 与 `mcp__rea__*` 工具都来自它) | 运行时上下文 + `GET /` 端口 31896 |
**注意两个容易混淆的"dsh 版本"**:`dsh --version` 报的 `0.1.7-rc.2` 是 npm 全局装的 **CLI**,
不是跑本会话的桌面宿主(0.2.2-rc.1)。用 CLI 的版本号判断本机宿主会得出完全相反的结论。
### 3.1 同机历史 asar 对照(用来定位"某个包什么时候消失")
| asar 路径 | 版本 | `@deepseek-ai/*` 包数 | 有 `dsh-client-ui-slots` 目录 | 有 `dsh-mcp-client` | 有 `dsh-official-bundles` |
|---|---|---|---|---|---|
| `D:\deepseekharnes\deepseekharnes\resources\app.asar` | 0.1.7-rc.2 | 284 | 有 | 有 | 无 |
| `D:\dsh-snapshot\20261106-2125\install\resources\app.asar` | 0.2.0-rc.2 | 289 | 有 | 有 | 无 |
| `D:\kimicode\修复\dsh-backup-20261106\program-before-repair\resources\app.asar` | 0.2.2-rc.1 | 331 | **无** | 有 | **有** |
| `D:\deepseekharnes\新建文件夹\resources\app.asar` | 0.2.2-rc.1 | 331 | **无** | 有 | **有** |
本机**没有** 0.2.1-rc.3/rc.4/rc.5、0.2.2-alpha.2/alpha.3 的桌面构建,所以"哪个版本把 `dsh-client-ui-slots` 从
`app.asar` 的 `node_modules` 里移走"**定位不到具体版本**。但 §4 证明这不需要定位——它不是接口面缺失。
---
## 4. `dsh.client.inject` 专项核实(Lead 的核心问题)
`package.json` 声明:
```json
"dsh": { "client": { "platform": "web", "inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots" ] } }
```
### 4.1 四个包在 0.2.2-rc.1 宿主里到底在不在
方法:解 asar header,遍历全部 3316 个目录 / 22102 个文件;再对整份 213 MB 做字节扫描定位每个名字的所有出现。
证据路径全部在 `D:\deepseekharnes\新建文件夹\resources\app.asar` 内。
| 包名 | 有 `node_modules/@deepseek-ai/<name>/` 目录? | 全 asar 出现次数 | 结论 |
|---|---|---|---|
| `dsh-client-connection` | **有**(`dsh/node_modules/@deepseek-ai/dsh-client-connection`,含 `lib/`、`locale/`) | 1 | **存在** |
| `dsh-client-locale` | **有**(同上结构) | 1 | **存在** |
| `dsh-client-ui-slots` | **无目录** | **89**(分布在 87 个文件的 `package.json` / `README` / `lib/client.js`,以及 `dsh-web-frontend/dist/assets/index-BgUyXZcc.js`) | **以"被引用"形式存在**:宿主自己的 8+ 个客户端包把它写进 `dsh.client.inject`,它被编进 Web 前端 bundle,不以独立包目录形式落盘 |
| `dsh-client-runtime` | **无目录** | **0(整份 asar 一次都没出现)** | **不存在**。npm 上最后一次发布是 `0.1.1-rc.2`(2026-08-21),在 0.1.7-rc.2 及之后的所有桌面宿主里都找不到它 |
`dsh-client-runtime` 的现实身份:它是 **build-time devDependency**。本机 `[email protected]` 的 `devDependencies` 里就有
`"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7"`,而同一个包的 `dsh.client.inject` 只列 `dsh-client-locale`、
`dsh-client-ui-settings`、`dsh-client-ui-theme` 三个**真实宿主包**。也就是说:我们的 `inject` 把构建期依赖误写成了运行期注入项。
### 4.2 宿主拿 `inject` 干什么,目标不存在会怎样
消费方是宿主包 `@deepseek-ai/dsh-client-modules`(node 半边扫 Loader 条目里的 `dsh.client`,
把 `window.__DSH_BOOT__` 的模块图组出来;client 半边按图装载)。三段源码:
**(1)校验**(`lib/index.js` 的 `parseDshClient`,`lib/client.js` 同款)——只校验类型,不校验存在:
```js
const inject = optionalStringArray(pkgName, "dsh.client.inject", decl.inject);
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
throw new Error(`client-modules: ${subject} ${field} must be a string array`);
```
**(2)装载**(`lib/client.js:791-794`)——**这就是答案**:
```js
for (const packageName of row.inject) {
const dependency = this.graphRows.get(packageName);
if (dependency !== void 0) await this.arriveDependency(row.id, dependency, [], visited);
}
```
`graphRows.get()` 取不到就**什么都不做**:不抛错、不告警、不写日志。**未知 inject 项 = 静默跳过。**
**(3)反证:宿主自己就在这么用。** 统计宿主 331 个 `@deepseek-ai` 包的 `dsh.client.inject`,共 412 条边,
其中有一类目标**根本没有包目录**,却被核心面板引用:
```
dsh-client-ui-primitives <- 6 个消费者:
dsh-client-ui-jobs, dsh-client-ui-schedule, dsh-client-ui-subagent,
dsh-experimental-client-ui-agent-team, …
```
这 6 个面板在 0.2.2-rc.1 上正常工作。所以"`inject` 目标没有独立包目录"是**宿主自身也在用的形态**,不是错误配置。
### 4.3 本插件客户端代码真正要的是什么
`client.js` 的 shape:
```js
window.__ModuleLoader__.load({
id: "dsh-plugin-rea",
factory: (require) => {
const react = require("react");
const reactDom = require("react-dom"); // 只在需要时
const inject = ["slots"]; // ← Cordis 服务名,不是包名
…
exports.inject = inject;
```
- `require(...)` 只要求 `react` / `react-dom`,**四个 inject 包一个都没 require**。所以 `dsh-client-runtime`
即使不存在,也不会在 `factory` 里炸。
- 真正决定"卡片能不能挂上去"的是 Cordis **服务级** `inject = ["slots"]`。这是宿主自己的约定,不是我们发明的:
在 0.2.2-rc.1 里,`dsh-client-resources`、`dsh-client-ui-brand-official`、`dsh-client-ui-action-cards`、
`dsh-client-ui-attachment`、`dsh-client-ui-conversation` 等 8+ 个核心客户端包写的都是 `inject = ["slots"]`(或含 `slots`)。
- 挂载点是 `ctx.slots.inject("shell.overlay", mount)`(`client.js:391`),并带 `typeof ctx.slots.inject === "function"`
的能力守卫——服务不在时不炸,只是不挂。
### 4.4 最终结论
> **能渲染。** 本插件的 Web 卡片(一次性 setup 引导)在 `0.2.2-rc.1` 上可以正常渲染,不需要为此改 `package.json`。
依据链:
1. `dsh.client.inject` 里的未知项被宿主**静默跳过**(`dsh-client-modules/lib/client.js:791-794` 源码);
2. 本插件的 `factory` 不 `require` 那四个包,缺任何一个都不会在 factory 内失败;
3. 卡片真正的闸门是 Cordis 服务 `slots`,宿主 8+ 核心客户端包用的是同一条约定,0.2.2-rc.1 仍然成立;
4. `dsh-client-ui-slots` 在宿主里"有引用、无目录"与 `dsh-client-ui-primitives` 完全同形,后者支撑着 6 个正常工作的核心面板;
5. 宿主日志(`…\@deepseek-ai\dsh-desktop\logs\crash-2026-11-06T*`)中没有任何 `client-modules` / `inject` 相关记录;
6. 旁证:同一宿主的 node 半边(`index.mjs`)已被证明在跑(`GET /dsh-plugin-rea/guide-state` → 200)。
### 4.5 交 Lead 的 `package.json` 待办(我不动 `package.json`)
| 优先级 | 改动 | 理由 | 不改的后果 |
|---|---|---|---|
| 低(清理) | `dsh.client.inject` 删掉 `"@deepseek-ai/dsh-client-runtime"` | 该包最后一次发布 0.1.1-rc.2(2026-08-21),早于本插件声明的整个 `>=0.1.7-rc.2` 区间;它是构建期 devDependency,被误写成了运行期注入项 | 无功能后果,但会误导后续维护者,并在 `dsh.client.external`/顺序敏感的场景下留一个永假的依赖边 |
留 `@deepseek-ai/dsh-client-connection` / `-locale` / `-ui-slots` 三个:前两个有实体目录,第三个与宿主自身核心包的用法同形。
---
## 5. 本次改动清单
| 文件 | 改动 |
|---|---|
| `scripts/host-compat.mjs` | `HOST_RELEASES` 10 → 16 条,新增 `0.2.2-rc.1` / `0.2.2-alpha.3` / `0.2.1-rc.5` / `0.2.2-alpha.2` / `0.2.1-rc.4` / `0.2.1-rc.3`(发布日序,最新在前);条目上方注释补"按发布日而非 semver 序"及唯一一处倒序;文件头补 "FLOOR AND CEILING" 段,记录 floor 未动、ceiling 未动。`OUTSIDE_HOSTS`、`satisfiesDeclaredRange`、`parseVersion`、`compareVersions`、`checkHostCoverage` **全部未动** |
| `scripts/host-compat.test.mjs` | **新增**,25 条测试(正反两面 + 三个负控) |
| `scripts/setup.test.mjs` | 仅 lead 授权的一处:`test("the release list this gate checks is the published one")` 里的 10 条字面量 → 16 条,附一句"不要改成 semver 序"的注释。其他区域未碰 |
| `docs/host-compat-2026-11-06.md` | **新增**(本文件) |
验证:
```
$ node scripts/host-compat.mjs
engines.dsh = >=0.1.7-rc.2 <0.3.0-0
ok 0.2.2-rc.1
…(16 条全 ok)…
ok 0.3.0 (must stay outside)
…(5 条全 ok)…
host range ok: 16 releases covered, 5 correctly outside
exit 0
$ node --test scripts/host-compat.test.mjs → tests 25, pass 25, fail 0
$ node --test ./scripts/setup.test.mjs → tests 88, pass 85, skipped 3, fail 0
```
---
## 6. 未覆盖 / 未验证(诚实清单)
1. **0.2.1-rc.3 / rc.4 / rc.5、0.2.2-alpha.2 / alpha.3 的桌面构建没有在本机实测过。** 本机只有 0.2.2-rc.1 的
`app.asar` 和两份更老的(0.1.7-rc.2、0.2.0-rc.2)。这五个版本的"接口面无变化"是**据 release body 原文 + 0.2.2-rc.1
运行时代码推出来的**,不是逐版本跑出来的。要变成"已验证"需要逐个装一遍。
2. **GitHub REST API 两次不可用**:第一次 `504`(网关超时),第二次 `403`(`API rate limit exceeded for 39.144.92.96`)。
版本、日期、release body 因此取自 releases HTML 页 + atom feed + npm registry 三处交叉,没有用 API。
3. **release body 只读了最近 10 条**(atom feed 的上限)。这正好覆盖全部 6 个新版本 + 原有 4 条,但 0.2.1-alpha.3 及更早的
原文没有逐条读(它们不是本轮新增,且 `HOST_RELEASES` 里早已在册)。
4. **中文段落是乱码来源**:atom feed 是 UTF-8,PowerShell `Get-Content` 按 ANSI 读,中文全部成 mojibake。
本文件引用的原文**一律取英文段落**,没有从乱码回推中文。
5. **Web GUI 的视觉验证没做**:`GET http://127.0.0.1:31896/` 返回 `401 dsh web authentication required`,
拿不到 `window.__DSH_BOOT__` 的运行时模块图(需要 `dsh web` 打印的 token)。§4.4 的"能渲染"结论来自
源码路径 + 服务名约定 + 日志无错,**不是截图**。要补的话:拿到 token 后取 `GET /` 的 boot graph,
确认存在 `id: "dsh-plugin-rea"` 的 row 且 `inject` 里那两条缺失项被跳过。
6. **`dsh-client-ui-slots` 到底哪个版本从 `app.asar/node_modules` 里消失,定位不到**(本机没有 0.2.1-rc.x 的桌面 asar)。
但这不影响结论:它在 0.2.2-rc.1 上是"有引用无目录",与宿主自身的 `dsh-client-ui-primitives` 同形。
7. **`@deepseek-ai/dsh-client-ui-primitives` 这类"无目录但被注入"的包在 Loader 侧如何解析,没有读到实现**。
只证明了结果(引用它们的 6 个核心面板正常工作)。
8. **`engines.node` 与宿主自带 node 的矛盾未决**:`package.json` 声明 `^22.23.4 || ^24.22.1 || >=26.12.1`,
而宿主自带 node 是 **24.21.0**(低于 24.22.1)。实际运行没出事,因为:
`cordis.patch.yml` 的 `command: process.execPath` 只用来起 `bin/rea-shim.mjs`(跑在宿主 24.21.0 上),
而 shim 随后 `spawn(rea.node, …)`(`bin/rea-shim.mjs:1386`),`rea.node` 取自 `bundle.json` 的
`paths.node = F:\jiuguan\node.exe` = **24.22.1** —— 真正跑 REA 的是满足声明的那个 node。
**未验证**的是:市场/安装前置校验是否会拿 `engines.node` 跟宿主自带 node 比。如果会,这条比 `dsh.client.inject`
更可能挡住安装。**需要 Lead 决策**(放宽下限到 `^24.21.0`,或维持现状并要求宿主升级),我不动 `package.json`。
9. **`0.3.0` 尚未发布**:`OUTSIDE_HOSTS` 里三条 `0.3.0*` 是防线,不是已存在的版本,无法核实。
10. **`deriveHostCompatibility` 的完整判定链没有逐条验证**:只核对了 `rangeResult` 那一行
(`includePrerelease: true`)与双读申报位置的事实。它内部对 peer 依赖、隐式上限的处理(`classifyPeer`、
`crossesImplicitCeiling`)没有展开 —— 本插件不声明 `peerDependencies`,不进入那条路径。
+225
View File
@@ -0,0 +1,225 @@
# 2.0.14 — 外部审计的逐条修复与 DSH 宿主适配(2026-11-06)
输入是一份针对 `dsh-plugin-rea 2.0.13`(提交 `4d8a082`)的外部审计(18 项),外加一次宿主版本适配请求。
本轮**没有升级任何上游组件**:REA 6.8.0 / Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1 全部原地。
结论先说:审计的主线判断是对的,而且比它自己写的更一致 —— **这七项动态缺陷的共同点是「验证的对象不是要交付的那个对象」**。
一次部分失败被记录成成功、一个坏路径被另一份好安装背书、一条超时的锁被从活进程手里删掉、一个探针只验证「同名组件在某处存在」。
所以本轮的修法也统一:让探针去看真正要交付的那份东西,并把「无法证明」和「证明失败」分开。
---
## 一、18 项审计条目:逐条状态
| # | 审计条目 | 严重性 | 状态 | 落点 |
|---|---|---|---|---|
| 1 | 多文件组件部分失败会记录成新版本并假绿 | 高 | **已修** | `scripts/setup.mjs` multi-file 段 |
| 2 | 已记录组件的探针没绑定 `recorded.path`(且不校验 pin) | 高 | **已修** | `scripts/setup.mjs` `recordedMatchesPin()` |
| 3 | 损坏 bundle 阻止 auto 回退,状态命令仍退出 0 | 中 | **已修** | `bin/rea-shim.mjs` `bundleUsable` |
| 4 | 六小时过期会删掉活进程的锁 | 中 | **已修** | `bin/rea-shim.mjs` + `setup.mjs` 心跳/token |
| 5 | JDK 探针不保证 21+ | 中 | **已修** | `manifest/bundle.manifest.json` |
| 6 | 当前策略使 Frida 原生绑定缺失 | 中 | **已修** | `scripts/setup.mjs` `ensureFridaBindings()` |
| 7 | 损坏的 `setup.lock` 让手动 setup 抛 `SyntaxError` | 低 | **已修** | `scripts/setup.mjs` `acquireRunLock()` |
| 8 | `check-updates.ps1` 假绿(drift/warn 不进结论) | 中 | **已修** | `check-updates.ps1` 判绿条件 |
| 9 | `bin/rea-guide.mjs` 忽略 `REA_BUNDLE_ROOT` | 低 | **已修** | root 解析链统一 |
| 10 | `ghidra-gate-override.mjs` 忽略自定义 root | 低 | **已修** | 同上 |
| 11 | `--dry-run` 仍创建 root/cache/log | 低 | **已修** | 一个字节都不写 |
| 12 | 归档替换先删后 rename,失败丢两份 | 中 | **已修** | `.backup` + 回滚 |
| 13 | `bundle.json`/`mirror.json` 非原子写 | 中 | **已修** | `writeJsonAtomic()` |
| 14 | override 先 patch 后写恢复记录 | 低 | **已修** | 先落盘再改 + 回滚 |
| 15 | override `--disable` 缺 vendor containment | 低 | **已修** | `containedPath()` + `lstat` |
| 16 | `tarfile.extractall()` 未用安全 filter | 纵深 | **已修** | `filter='data'` |
| 17 | Zod 4.4.3 落在 advisory 内(依赖闭包未锁) | 高 | **已修** | `scripts/vendor-lock/` + `npm ci` |
| 18 | `engines.node` 与宿主自带 Node 不一致 | 未列入 | **未解决,已记录** | 见 §五-1 |
---
## 二、关键缺陷的修法与实测证据
### 1. 多文件组件全有或全无(#1、#12)
复现形状:apktool 的 jar 下载失败、wrapper 成功,目标目录里残留 2.0.0 的 jar 通过了 `java -jar --version`,
记录写成 3.0.3 且哈希取自 wrapper,下一次运行 `already provisioned (3.0.3)` 不再下载。
修法四层:
1. **全部 entry 先取全**,缺一个即整体失败并打印 `N of M entries were available; nothing was installed`;
2. 所有文件先进 `<target>.staging`,**全就位后才原子换入**;
3. 旧目录改名 `.backup`,rename 失败或**探针失败**都回滚并明说 `the previous install was put back`;
4. 记录改成**每 entry 独立**的 `{id, version, sha256, tofu}`,`jarName`/`sha256` 按类型(`.jar`)而非列表顺序选主。
真机实测(本机 `D:\dsh-home\plugins\rea-bundle`,先手工备份 `vendor/apktool`):摘掉 `bundle.json` 的 apktool 记录后
`--only apktool` 走完整路径 —— jar 用缓存、wrapper 重新下载(2386 B,pin 版本),目录里**恰好两个文件**
(历史遗留的 `apktool.bat.bak-2348` 随旧目录被丢弃,这是「目录内容严格等于 manifest」的预期结果),
无残留 `.staging`/`.backup`,`java -jar apktool.jar --version` 答 `3.0.3`,退出 0。
### 2. 探针绑定记录路径 + pin 校验(#2)
`recorded` 分支现在用**独立 context** 把该组件解析到 `recorded.path` 再探针(与 reuse 分支同一手法),
并新增 `recordedMatchesPin()`:记录版本必须等于 manifest 的 pin;JDK 按**主版本下限**匹配。
一个必须处理的例外:**已启用的 Ghidra 闸门覆盖**。本机 manifest 钉 12.1.4、盘上是 12.2,
若 pin 校验不认 `ghidra-gate-override.json`,一次普通 setup 会把操作员显式打开的覆盖**悄悄重钉回 12.1.4** ——
这是「修一个新缺陷时制造另一个」。实测 `--dry-run`:
```
[skip] node — already provisioned (24.22.1) — pinned 24.22.1
[skip] jdk — already provisioned (25.0.5) — major 25 satisfies the pin's major 21
[skip] ghidra — already provisioned (12.2) — recorded 12.2 matches the enabled gate override
[skip] apktool — already provisioned (3.0.3) — pinned 3.0.3
[skip] jadxHeadlessMcp — already provisioned (0.7.1) — pinned 0.7.1
[skip] androidPlatformTools — already provisioned (latest (37.0.1)) — pinned latest (37.0.1)
[skip] reaAgents — already provisioned (6.8.0) — pinned 6.8.0
```
### 3. 统一「bundle 可用」判定(#3)
`loaded.ok`(JSON 解析成功)曾被当成「已供给」,一口气决定三件事:启动选择、状态退出码、是否进入修复。
现在 `bundleUsable = loaded.ok && resolveRea(...).ok`,三处共用:
- 启动选择:不可用的 bundle 不再凭「记录版本更新」赢过可用的系统 REA;
- `--shim-status`:不可用且无系统 REA → 退出 **78**(原来 0),报告新增 `bundleUsable` 字段;
- 修复路径:状态可解析但运行时缺失,现在也会进入 auto-provision(原来直接 `fail()`)。
### 4. 锁:存活 pid 是硬证据,过期只是软证据(#4)
原规则只信任 6 小时内的锁,超时后**不问持有者是否还活着**就删。慢下载确实会跑过六小时,删锁后第二个
provisioning 会写同一份 cache、同一批 `.staging` 和同一份 `bundle.json`。
现在:
- pid 活着 → **永不删**;超过窗口报 `in-flight-stale`(附 pid 与「多久没汇报」),由操作员决定;
- pid 证明已消失 → 回收;
- **无 pid 的旧式数字锁**(没有人可查)→ 仍按时间处理,否则「无法判断」会永远读成「活着」;
- 配套:`setup` 的 `log()` 兼作**心跳**(30 s 节流刷新 `at`)—— `spawnSync` 下载会饿死定时器,所以心跳挂在进度输出上;
- shim→setup 的交接从 **PID 交接改为 token 交接**(`REA_BUNDLE_LOCK_TOKEN`):原窗口里 setup 可能在 shim 改写
`child.pid` 之前就读到一个不属于自己的活 pid,于是倒在自己的锁上、退出 75(审计把它列为静态风险,100 次自然启动未命中)。
`failed` 记录**仍然优先于 pid 检查**:失败进程的 pid 必然已消失,顺序颠倒会把「阻止无限重试的刹车」当成陈旧锁删掉。
### 5. JDK 探针(#5)
`jdk.compiler` 只区分完整 JDK 与裁剪运行时 —— 真实 Java 17.0.20 同样打印 `[email protected]`。
`expect` 现在同时要求两件事(`runProbe` 对 `contains` 和 `regex` 是 AND):
`contains: jdk.compiler` **且** `regex` 匹配版本段 ≥ 21(21–29 / 30–99 / 100+)。8 组版本串正反验证通过,
含本机真实输出 `[email protected]`。
### 6. Frida 原生绑定(#6)
实测复现:`import('frida')` → `Could not locate the bindings file. Tried: … frida\build\frida_binding.node`。
原因是 frida 的 `install` 脚本(`prebuild-install`)是放置这个 .node 的**唯一**途径,而 `--ignore-scripts` 跳过了它。
修法不是删 `--ignore-scripts`(那是正确的加固),而是**点名这一个包**:`npm rebuild frida` → 用
`import('frida')` + `enumerateDevices()` 验证。修好后 135.7 MB 的 `frida_binding.node` 落地,
实测 `IMPORT OK` + 3 个设备(`local/local`、`socket/remote`、`barebone/remote`),即 `FRIDA_OK 3`。
**检查放在 `main` 层而不是 provisioning 路径里** —— 否则「已记录 6.8.0 的存量 bundle」(也就是每一次升级)
永远不会进入那段代码,绑定会一直缺着而每行都在说 `already provisioned`。结果写进
`components.reaAgents.frida = {available, binding, sha256, devices}`;不可用时按 `browser` 的先例报 `skip`,
不把一次可用供给判成失败(`step(..., "FAIL")` 会进 `failed` 数组并让 setup 退出 1)。
### 7. 依赖闭包(#17)
`[email protected]` 对 zod 是**精确锁 `"4.4.3"`**,所以「装一个 4.6.7 覆盖它」会得到**两份 zod**,REA 仍吃坏的那份
(实测否决)。`overrides` 是唯一不改发布 tarball 的杠杆,`npm ci` + lockfile 再补上「每次同一份字节」。
放大实测(同一个 `.max(2)` schema 喂 25 个元素):zod 4.4.3 → **26 条 issue**,4.6.7 → **1 条**;
REA 自己的 `inspectAnalysisSettingsInputSchema` 同样 26 → 1。
落地:`scripts/vendor-lock/`(lockfileVersion 3,183 条目)→ `zod 4.6.7` / `minimatch 11.0.5` / `glob 14.0.4`,
各单副本。兼容性实测:`@electron/asar` 在 glob 14 / minimatch 11 下 create/list/extract/stat **往返通过**;
REA 真实 MCP stdio 握手 `tools/list` 仍是 **229/229 且每个都有 inputSchema**(与 4.4.3 基线一致)。
---
## 三、修复过程中发现的、审计没提的问题
三处都是「修完之后自查」抓到的,不是审计输入的:
1. **`"21"` 这种只有主版本的 pin 被当成「家族前缀」**(`scripts/pin-match.ps1`、`bin/rea-guide.mjs`)。
`25.0.5` 对 pin `21` 被判成漂移 → `check-updates.ps1` 说「重跑 setup 对齐」,而 setup 说
「already provisioned (25.0.5) — major 25 satisfies the pin's major 21」。Ghidra 要求的是「JDK 21 **或更新**」,
所以**只有主版本的 pin 是下限,不是前缀**。三个文件现在的判定一致。
2. **`check-updates.ps1` 的假红**:`rea doctor` 退出码 1 涵盖「任何可选能力未配置」(Hopper / IDA / Kuna /
skill 副本 / 五个客户端登记),于是一台供给完好的机器恒报「需要动作」。退出码降为 `[上游]`,
实际状态由输出解析出的 provider 检查决定 —— 假绿修完之后不能顺手留下假红。
3. **依赖锁的新读取绕过了 BOM 处理**:`.mjs` 里新增的两处 `JSON.parse(readFileSync(...))` 会被
`verify-branch.ps1` 的「no BOM-blind JSON read」判定拦下(确有其事:带 BOM 的 lock 会被读成「锁里什么都没钉」)。
改为走 `readJsonSafe()`。附带发现 `verify-branch.ps1` 的这条检查会把**注释里**的同名字面量也算进去 ——
所以注释措辞一并改掉了。
---
## 四、DSH 宿主适配(0.2.1-rc.3 → 0.2.2-rc.1)
细节见 [`host-compat-2026-11-06.md`](host-compat-2026-11-06.md)。要点:
- `HOST_RELEASES` 10 → **16**,`engines.dsh` 声明与 `OUTSIDE_HOSTS` 都没动(6 个新版本全在范围内)。
- 6 个 tag 三源交叉核实(releases 页 / atom / npm registry)**全部真实存在**,原有 10 条一条未被撤下;
`0.2.2-rc.1` 是当前 `latest`。排序按**发布日**而非 semver(`0.2.1-rc.5` 晚于 `0.2.2-alpha.2`),这处倒序钉死在测试里。
- 接口面逐条核对:6 个版本都没有触及本插件依赖的五处表面。唯一沾边的是 `0.2.2-rc.1` 的
Cordis Core 4.1.0 / Loader 1.1.0,**已在本机 0.2.2-rc.1 上实测**:`GET /dsh-plugin-rea/guide-state` → 200。
- 现场取证市场语义:`dshmarket/lib/verify.js` 与 `discovery-compatibility.js` 都**只读 `engines.dsh`**,
不读 `engines.node`。
---
## 五、没有解决的,以及为什么
1. **`engines.node` 仍是 `^22.23.4 || ^24.22.1 || >=26.12.1`**,而 `0.2.2-rc.1` 桌面宿主自带 Node **24.21.0**。
实测市场不读这个字段,npm 默认也只警告 —— **但 `engine-strict=true` 的用户会装不上**。
没放宽的原因不是怕,而是它现在被 `audit-manifest.mjs` 强制与 `manifest.pins.nodeEngines`(REA 的真实要求)
**逐字相等**;拆开「插件的 Node 要求」与「REA 的 Node 要求」是一次独立改动,塞进本轮会让那条守护变模糊。
运行期不受影响:REA 用 `bundle.json` 的 `paths.node`(24.22.1)启动。
2. **`dsh.client.inject` 仍列出 `@deepseek-ai/dsh-client-runtime`**,该包在 `0.2.2-rc.1` 宿主里**一次都不出现**
(331 个 `@deepseek-ai` 包目录、整份 213 MB asar 字节扫描,0 命中;它是构建期 `devDependency`)。
宿主对未知 inject 项**静默跳过**(`dsh-client-modules/lib/client.js` 的 `graphRows.get()` 取不到就什么也不做),
且本插件 `client.js` 不 require 它,真实闸门是服务 `["slots"]`。所以卡片照常渲染;保留它是为了不损害旧宿主适配。
属清理项,不是缺陷。
3. **Ghidra 默认 pin 仍是 12.1.4**(带那四个 CVE)。审计明确说这不是本轮新发现,也明确说「不能只把 manifest 改成
12.2」—— 12.2 需要 JDK 25+ 且 REA 未验证,只能通过 `ghidra-gate-override.mjs` 显式开启。
**在升级这个 pin 之前,不要用内置 Ghidra 分析来源不可信的样本。**
4. **硬链接逃逸没做 containment**:同卷硬链接与 bundle 内文件共享 inode,纯路径检查区分不了。
本轮拒绝的是 `../`、绝对路径、符号链接/junction(均已实测)。
5. **minimatch / glob 在 REA 默认 MCP 路径上的可达性仍未证实**:本轮把它们抬到修复线上,没有证明可利用性。
`glob 13 → 14` 是跨大版本(asar 声明 `^13.0.2`),已用 asar 往返与 229 工具 schema 验证,但**没有**把真实
`.asar` 喂进 REA 的 asar 工具端到端跑一遍(没有现成样本)。
6. **lock 放在 `scripts/vendor-lock/` 而不是仓库根**:根级新目录不在 `package.json` 的 `files` 白名单里,
发出去会静默走降级路径。setup 同时认 `HERE/vendor-lock` 与 `PLUGIN_ROOT/vendor-lock`,
所以将来搬到根级只需加一条 `files`,代码不用动。已实测 `npm pack` 确实带上了这两个文件。
7. **CI 时序**:`bundle:deps` 已作为独立 step 接进 `validate.yml` 的两个 job,但它是在本轮**第二个**提交里补上的
—— 第一次推送用的凭据缺 GitHub `workflow` scope,服务端拒绝改 workflow 文件
(`refusing to allow an OAuth App to create or update workflow … without 'workflow' scope`),
所以那一次提交刻意排除了 workflow 改动。第二次换用带 `workflow` scope 的凭据后才补上,
期间 CI 的 Windows job 先红了一次(原因见 §三 的测试夹具 bug),已修复。
---
## 六、验证清单
```
单测 154 项:151 过 / 0 失败 / 3 跳过
setup.test 98 · deps.test 17 · host-compat.test 25 · hardening.test 14
manifest audit 24 条下载 entry 全部 SHA-256 钉死,0 TOFU,clean
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4,各单副本
宿主兼容 16 releases covered,5 correctly outside
verify-branch ALL CHECKS PASSED
```
新增回归测试(**在 2.0.13 的代码上会红**):部分失败不成假绿且第二次仍重试、记录路径不被 vendor 背书、
pin 漂移不被信任、JDK 17/11/9 被拒、`--dry-run` 不写盘、坏锁不抛异常、`tarfile` 带 filter、
坏 bundle 回退系统 REA、`--shim-status` 对不可用 bundle 返回 78、活 pid 的过期锁不被删。
真机链路:`--dry-run` 7 组件正确 skip → 强制重装 apktool 走 staging(无残留)→ `--reuse` 全量 8 组件 +
`fridaBindings FRIDA_OK 3` → `probe 229 tools` / `smoke 28 s` / `shim 6.8.0`,退出 0;
`check-updates.ps1` 由退出 1(假绿/假红混合)转为退出 0。
## 七、复现命令
```powershell
cd D:\dsh-plugin-rea
node --test ./scripts/setup.test.mjs ./scripts/deps.test.mjs ./scripts/host-compat.test.mjs ./scripts/hardening.test.mjs
node scripts/audit-manifest.mjs
node scripts/check-dependency-advisories.mjs
node scripts/host-compat.mjs
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-branch.ps1
node scripts/setup.mjs --dry-run --root "$env:DSH_HOME\plugins\rea-bundle"
```
+8 -1
View File
@@ -105,7 +105,14 @@
"license": "GPL-2.0-with-classpath-exception (Eclipse Temurin)",
"requires": [],
"unpack": { "stripRootComponents": 1, "moveTo": "jdk" },
"probe": { "argv": ["{jvm}", "--list-modules"], "expect": { "contains": "jdk.compiler" } },
"probe": {
"argv": ["{jvm}", "--list-modules"],
"expect": {
"contains": "jdk.compiler",
"regex": "jdk\\.compiler@(?:2[1-9]|[3-9]\\d|\\d{3,})\\b"
},
"why": "Two independent facts, and BOTH are required (runProbe applies contains and regex as an AND). `contains: jdk.compiler` separates a full JDK from a trimmed runtime, but it says nothing about the VERSION: a real Java 17.0.20 also prints `[email protected]`, and setup accepted a JAVA_HOME pointing at it, recorded `{version: 17.0.20, reused: true}`, and only then did Ghidra 12.1 (JDK 21+) fail later in a different process. The regex matches the version segment of that same line and demands major >= 21 (21-29 / 30-99 / 100+); 1x, single digits and two-digit values below 21 do not match. "
},
"sources": {
"win32-x64": {
"version": "21.0.12.1+1",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-plugin-rea",
"version": "2.0.13",
"version": "2.0.16",
"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",
"main": "./index.mjs",
@@ -73,7 +73,8 @@
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
"bundle:audit": "node ./scripts/audit-manifest.mjs",
"bundle:test": "node --test ./scripts/setup.test.mjs",
"bundle:deps": "node ./scripts/check-dependency-advisories.mjs",
"bundle:test": "node --test ./scripts/setup.test.mjs ./scripts/deps.test.mjs ./scripts/host-compat.test.mjs ./scripts/hardening.test.mjs",
"bundle:host-compat": "node ./scripts/host-compat.mjs"
},
"engines": {
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env node
/**
* Offline advisory gate for the vendored REA dependency closure.
*
* `scripts/setup.mjs` installs `rea-agents` into `<root>/vendor` from
* `vendor-lock/package-lock.json`. That lock is the only thing that decides which
* zod / minimatch / glob the vendored REA actually runs on, and it is regenerated
* by hand, so it can silently fall behind the advisories it was created for. This
* gate is the part that fails loudly:
*
* - every copy of each advisory-affected package in the lock must be at or above
* the first fixed version;
* - the lock must still pin the REA version the manifest asks for (a stale lock
* would install the wrong server, and setup.mjs would skip the lock only after
* burning a run);
* - the `overrides` that setup.mjs falls back to and the `overrides` recorded in
* vendor-lock/package.json must agree with each other and with the lock, so the
* degraded install path cannot resolve something the audited path would not.
*
* It reads files only: no registry, no network, no npm. Run it in CI next to the
* test suite (`node ./scripts/check-dependency-advisories.mjs`), or by hand after
* changing a pin.
*
* Exit codes: 0 = every floor met, 1 = a floor is violated / the lock is stale or
* inconsistent, 2 = the lockfile could not be read at all.
*
* Regenerating the lock after an upstream pin bump:
*
* cd scripts/vendor-lock
* npm install --package-lock-only --ignore-scripts
*
* and commit the resulting package-lock.json together with the manifest change.
*/
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = path.resolve(HERE, "..");
/**
* First fixed version per advisory, with the advisory that set it. The floors are
* the lower bound of "not affected"; the pins in vendor-lock are the versions the
* closure actually resolves and are expected to sit above them.
*/
export const ADVISORY_FLOORS = [
{
name: "zod",
floor: "4.6.6",
advisory: "GHSA-mrhx-c7xc-m3w5",
impact: "memory exhaustion while validating oversized constrained collections",
},
{
name: "minimatch",
floor: "11.0.1",
advisory: "GHSA-g3hv-75wg-m249",
impact: "globparts amplification on an attacker-chosen pattern",
},
{
name: "glob",
floor: "14.0.2",
advisory: "GHSA-3g3m-2fjr-6pw4",
impact: "AbortSignal cancellation bypass keeps traversing after a timeout",
},
];
/* Same comparison setup.mjs uses: dotted numerics, prerelease suffix ignored. */
export function compareVersions(left, right) {
const parts = (value) => String(value).split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
const a = parts(left);
const b = parts(right);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
const one = a[index] ?? 0;
const other = b[index] ?? 0;
if (one !== other) return one < other ? -1 : 1;
}
return 0;
}
/** Every installed copy of `name` in a lockfile, including nested duplicates. */
export function lockCopies(lock, name) {
const suffix = `node_modules/${name}`;
const found = [];
for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
if (key !== suffix && !key.endsWith(`/${suffix}`)) continue;
found.push({ location: key, version: entry?.version ?? null });
}
return found;
}
/**
* Judge one parsed lockfile.
*
* `setupSource` and `lockPackageSource` are raw texts (or null); the overrides they
* declare are compared against each other and against the resolved versions.
*/
export function evaluateAdvisories(lock, { manifestPin = null, setupSource = null, lockPackageSource = null } = {}) {
const rows = [];
const problems = [];
for (const advisory of ADVISORY_FLOORS) {
const copies = lockCopies(lock, advisory.name);
if (copies.length === 0) {
rows.push({ ...advisory, status: "absent", copies, detail: "not in the closure; no advisory applies" });
continue;
}
const below = copies.filter((copy) => !copy.version || compareVersions(copy.version, advisory.floor) < 0);
if (below.length > 0) {
rows.push({
...advisory,
status: "unsafe",
copies,
detail: `${below.map((copy) => `${copy.version ?? "unknown"} at ${copy.location}`).join(", ")} is below ${advisory.floor}`,
});
problems.push(`${advisory.name}: ${below.map((copy) => copy.version ?? "unknown").join(", ")} < ${advisory.floor} (${advisory.advisory})`);
continue;
}
const distinct = [...new Set(copies.map((copy) => copy.version))].join(", ");
rows.push({
...advisory,
status: "ok",
copies,
detail: `${distinct} across ${copies.length} cop${copies.length === 1 ? "y" : "ies"}`,
});
}
/* The lock has to match the manifest, or provisioning installs a different REA. */
const lockedRea = lock?.packages?.[""]?.dependencies?.["rea-agents"] ?? null;
if (manifestPin && lockedRea !== manifestPin) {
problems.push(
`the lock pins rea-agents@${lockedRea ?? "nothing"} but the manifest pins ${manifestPin}; regenerate scripts/vendor-lock/package-lock.json`,
);
}
/*
* setup.mjs falls back to `npm install` with its own overrides when the lock
* cannot be replayed. That path must not be able to resolve anything the audited
* one would refuse, so the same floors are applied to it, and the two override
* sets must name the same versions.
*/
const declared = [];
const fromSetup = setupSource ? parseOverrides(setupSource, "const CLOSURE_OVERRIDES") : null;
const fromLockPackage = lockPackageSource ? parseOverrides(lockPackageSource, '"overrides"') : null;
if (fromSetup) declared.push({ origin: "scripts/setup.mjs", overrides: fromSetup });
if (fromLockPackage) declared.push({ origin: "vendor-lock/package.json", overrides: fromLockPackage });
for (const { origin, overrides } of declared) {
for (const advisory of ADVISORY_FLOORS) {
const pinned = overrides[advisory.name];
if (!pinned) continue;
if (compareVersions(pinned, advisory.floor) < 0) {
problems.push(`${origin} overrides ${advisory.name} to ${pinned}, below the ${advisory.floor} floor (${advisory.advisory})`);
}
const resolved = lockCopies(lock, advisory.name).map((copy) => copy.version);
if (resolved.length > 0 && !resolved.every((version) => version === pinned)) {
problems.push(
`${origin} overrides ${advisory.name} to ${pinned} but the lock resolves ${[...new Set(resolved)].join(", ")}; the degraded install path would not match the audited one`,
);
}
}
}
if (declared.length === 2) {
for (const advisory of ADVISORY_FLOORS) {
const [first, second] = declared.map((entry) => entry.overrides[advisory.name] ?? null);
if (first !== second) {
problems.push(`overrides disagree for ${advisory.name}: setup.mjs ${first ?? "unset"} vs vendor-lock/package.json ${second ?? "unset"}`);
}
}
}
return { ok: problems.length === 0, rows, problems, lockedRea };
}
/**
* Reads a `{ name: "version", ... }` object out of JavaScript source or JSON text.
*
* setup.mjs is a script, not a module, so its override table is read as text; this
* is deliberately tolerant (it only fails when a key of interest is unreadable).
*/
export function parseOverrides(source, anchor) {
const objectStart = source.indexOf(anchor);
if (objectStart < 0) return null;
const braceAt = source.indexOf("{", objectStart + anchor.length);
const braceEnd = source.indexOf("}", braceAt);
if (braceAt < 0 || braceEnd < 0) return null;
const body = source.slice(braceAt + 1, braceEnd);
const overrides = {};
// JSON form: "zod": "4.6.7" | JS form: zod: "4.6.7" (also '"key": "value"')
for (const match of body.matchAll(/"?(?<name>[A-Za-z0-9@/._-]+)"?\s*:\s*"(?<version>[^"]+)"/g)) {
overrides[match.groups.name] = match.groups.version;
}
return overrides;
}
/** The shipped lock, or a caller-supplied one. */
export function resolveLockPath(candidates) {
const searched = candidates ?? [
path.join(HERE, "vendor-lock", "package-lock.json"),
path.join(PLUGIN_ROOT, "vendor-lock", "package-lock.json"),
];
return searched.find((file) => existsSync(file)) ?? null;
}
export function readJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
function main(argv) {
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write("usage: node ./scripts/check-dependency-advisories.mjs [--lock <package-lock.json>] [--json]\n");
return 0;
}
const lockFlag = argv.indexOf("--lock");
const lockPath = lockFlag >= 0 ? path.resolve(argv[lockFlag + 1] ?? "") : resolveLockPath();
if (!lockPath || !existsSync(lockPath)) {
process.stderr.write(
`dependency advisories: no lockfile at ${lockPath ?? "scripts/vendor-lock/package-lock.json"}\n` +
" the vendored closure is unverifiable without it; regenerate it with\n" +
" cd scripts/vendor-lock && npm install --package-lock-only --ignore-scripts\n",
);
return 2;
}
let lock;
try {
lock = readJson(lockPath);
} catch (error) {
process.stderr.write(`dependency advisories: ${lockPath} is not readable JSON: ${String(error?.message ?? error)}\n`);
return 2;
}
const manifestPath = path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json");
const manifestPin = existsSync(manifestPath) ? (readJson(manifestPath)?.pins?.reaAgents ?? null) : null;
const setupPath = path.join(PLUGIN_ROOT, "scripts", "setup.mjs");
const lockPackagePath = path.join(path.dirname(lockPath), "package.json");
const verdict = evaluateAdvisories(lock, {
manifestPin,
setupSource: existsSync(setupPath) ? readFileSync(setupPath, "utf8") : null,
lockPackageSource: existsSync(lockPackagePath) ? readFileSync(lockPackagePath, "utf8") : null,
});
if (argv.includes("--json")) {
process.stdout.write(`${JSON.stringify({ lock: lockPath, manifestPin, ...verdict }, null, 2)}\n`);
return verdict.ok ? 0 : 1;
}
const relative = path.relative(PLUGIN_ROOT, lockPath) || lockPath;
process.stdout.write("dependency advisories — vendored REA closure\n");
process.stdout.write(` lock ${relative} (lockfileVersion ${lock?.lockfileVersion ?? "?"}, ${Object.keys(lock?.packages ?? {}).length} entries)\n`);
process.stdout.write(` manifest rea-agents@${manifestPin ?? "unpinned"}, lock pins ${verdict.lockedRea ?? "nothing"}\n`);
for (const row of verdict.rows) {
const label = row.status === "ok" ? "ok " : row.status === "absent" ? "n/a " : "FAIL";
process.stdout.write(` ${label} ${row.name.padEnd(10)} ${row.detail} [floor ${row.floor}, ${row.advisory}]\n`);
}
if (!verdict.ok) {
for (const problem of verdict.problems) process.stderr.write(`FAIL ${problem}\n`);
return 1;
}
process.stdout.write("ok: every advisory-affected package is at or above its fixed version\n");
return 0;
}
const invokedDirectly =
typeof process.argv[1] === "string" && path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase();
if (invokedDirectly) {
process.exitCode = main(process.argv.slice(2));
}
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env node
/**
* Tests for the vendored dependency closure: the audited lockfile, the advisory
* gate that guards it, and the provisioning path that replays it.
*
* The defect these pin, measured on the closure `[email protected]` resolves from
* the registry alone: it pins zod exactly (`"zod": "4.4.3"`), 4.4.3 is inside
* GHSA-mrhx-c7xc-m3w5 (fixed in 4.6.6), and its `.max()` array validators turn a
* malformed tool call into an issue-object amplifier — 25 elements into a
* `.max(2)` schema produced 26 issues on 4.4.3 and 1 on 4.6.7. The same closure
* carried [email protected] (GHSA-g3hv-75wg-m249) and [email protected]
* (GHSA-3g3m-2fjr-6pw4) through @electron/asar and frida, and nothing recorded
* which versions an install produced, so two machines a month apart disagreed.
*
* No network and no npm: every check here is a file read or a spawned script.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
ADVISORY_FLOORS,
compareVersions,
evaluateAdvisories,
lockCopies,
parseOverrides,
} from "./check-dependency-advisories.mjs";
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const LOCK_DIR = path.join(REPO_ROOT, "scripts", "vendor-lock");
const LOCK_PATH = path.join(LOCK_DIR, "package-lock.json");
const SETUP_SOURCE = path.join(REPO_ROOT, "scripts", "setup.mjs");
const GATE = path.join(REPO_ROOT, "scripts", "check-dependency-advisories.mjs");
const MANIFEST = path.join(REPO_ROOT, "manifest", "bundle.manifest.json");
const REA_PIN = JSON.parse(readFileSync(MANIFEST, "utf8")).pins.reaAgents;
const readLock = () => JSON.parse(readFileSync(LOCK_PATH, "utf8"));
const cloneLock = () => JSON.parse(JSON.stringify(readLock()));
/** Runs the gate as CI would, against a caller-selected lockfile. */
function runGate(lockFile, extra = []) {
return spawnSync(process.execPath, [GATE, "--lock", lockFile, ...extra], {
cwd: REPO_ROOT,
encoding: "utf8",
timeout: 120000,
});
}
/** Writes `lock` into a throwaway directory and returns the file path. */
function fixtureLock(lock) {
const dir = mkdtempSync(path.join(tmpdir(), "rea-deps-lock-"));
const file = path.join(dir, "package-lock.json");
writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
return { dir, file };
}
/* ------------------------------------------- the shipped closure is not advisory-affected */
test("the shipped lock resolves zod, minimatch and glob at or above every floor", () => {
const lock = readLock();
for (const advisory of ADVISORY_FLOORS) {
const copies = lockCopies(lock, advisory.name);
assert.ok(copies.length > 0, `${advisory.name} must stay in the closure; the escape hatch below would hide its removal`);
for (const copy of copies) {
assert.ok(
compareVersions(copy.version, advisory.floor) >= 0,
`${advisory.name}@${copy.version} at ${copy.location} is below the ${advisory.floor} floor of ${advisory.advisory}`,
);
}
}
});
test("the lock pins the REA the manifest names", () => {
// A lock that pins a different REA is not a fix, it is a different server: the
// closure is resolved for one exact tarball's dependency list.
assert.equal(readLock().packages[""].dependencies["rea-agents"], REA_PIN);
});
test("every advisory-affected package resolves to exactly one version", () => {
// Two copies would mean one of them is the unpatched one; the gate checks all
// copies, and this asserts the closure does not rely on that leniency.
const lock = readLock();
for (const advisory of ADVISORY_FLOORS) {
const versions = [...new Set(lockCopies(lock, advisory.name).map((copy) => copy.version))];
assert.deepEqual(versions.length, 1, `${advisory.name} resolves to ${versions.join(", ")}`);
}
});
/* --------------------------------------------------------------- the gate itself */
test("the gate passes the shipped lock and prints the three rows", () => {
const run = spawnSync(process.execPath, [GATE], { cwd: REPO_ROOT, encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /dependency advisories/);
for (const advisory of ADVISORY_FLOORS) {
assert.match(run.stdout, new RegExp(`${advisory.name}\\s+\\S+`), `the report must name ${advisory.name}`);
assert.match(run.stdout, new RegExp(advisory.advisory), `the report must cite ${advisory.advisory}`);
}
assert.doesNotMatch(run.stderr, /FAIL/);
});
test("the gate fails a copy below the zod floor", () => {
const lock = cloneLock();
lock.packages["node_modules/zod"].version = "4.4.3";
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /zod: 4\.4\.3 < 4\.6\.6 \(GHSA-mrhx-c7xc-m3w5\)/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("the gate fails a NESTED copy below the minimatch floor even when the top copy is fine", () => {
// The amplification advisory is about whichever copy is imported; a patched
// top-level package with an unpatched duplicate underneath is the realistic
// regression when an upstream dependency gains its own minimatch.
const lock = cloneLock();
lock.packages["node_modules/incur/node_modules/minimatch"] = { version: "10.2.6", resolved: "fixture", integrity: "" };
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /minimatch: 10\.2\.6 < 11\.0\.1 \(GHSA-g3hv-75wg-m249\)/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("the gate fails a glob below the AbortSignal floor", () => {
const lock = cloneLock();
lock.packages["node_modules/glob"].version = "14.0.1";
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /glob: 14\.0\.1 < 14\.0\.2 \(GHSA-3g3m-2fjr-6pw4\)/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("a missing lockfile is unverifiable, not safe", () => {
const dir = mkdtempSync(path.join(tmpdir(), "rea-deps-missing-"));
try {
const run = runGate(path.join(dir, "package-lock.json"));
assert.equal(run.status, 2, `${run.stdout}\n${run.stderr}`);
assert.match(run.stderr, /no lockfile/);
assert.match(run.stderr, /npm install --package-lock-only/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a package that left the closure is reported, and is not a violation", () => {
// An upstream REA that stops depending on minimatch must not leave a gate that
// fails forever for a package nobody imports.
const lock = cloneLock();
delete lock.packages["node_modules/minimatch"];
const fixture = fixtureLock(lock);
try {
const run = runGate(fixture.file);
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /n\/a\s+minimatch\s+not in the closure/);
} finally {
rmSync(fixture.dir, { recursive: true, force: true });
}
});
test("a lock that lags the manifest is refused as stale", () => {
const verdict = evaluateAdvisories(readLock(), { manifestPin: "6.9.0" });
assert.equal(verdict.ok, false);
assert.ok(
verdict.problems.some((problem) => /pins rea-agents@6\.8\.0 but the manifest pins 6\.9\.0/.test(problem)),
verdict.problems.join("\n"),
);
});
/* --------------------------------- the degraded install path cannot undo the fix */
test("setup.mjs's fallback overrides sit at or above the floors", () => {
const overrides = parseOverrides(readFileSync(SETUP_SOURCE, "utf8"), "const CLOSURE_OVERRIDES");
assert.ok(overrides, "provisionReaAgents must keep a readable CLOSURE_OVERRIDES table for the no-lock fallback");
for (const advisory of ADVISORY_FLOORS) {
assert.equal(typeof overrides[advisory.name], "string", `${advisory.name} must be overridden explicitly`);
assert.ok(
compareVersions(overrides[advisory.name], advisory.floor) >= 0,
`setup.mjs overrides ${advisory.name} to ${overrides[advisory.name]}, below ${advisory.floor}`,
);
}
});
test("setup.mjs and vendor-lock/package.json declare the same overrides", () => {
const fromSetup = parseOverrides(readFileSync(SETUP_SOURCE, "utf8"), "const CLOSURE_OVERRIDES");
const fromLockPackage = parseOverrides(readFileSync(path.join(LOCK_DIR, "package.json"), "utf8"), '"overrides"');
for (const advisory of ADVISORY_FLOORS) {
assert.equal(
fromSetup[advisory.name],
fromLockPackage[advisory.name],
`the lock and the fallback disagree on ${advisory.name}; one of the two install paths would resolve differently`,
);
}
});
test("an override below a floor is caught even when the lock is clean", () => {
// The lock is what runs when `ci` works; the override table is what runs when it
// does not, and only the second one is edited by hand.
const sabotaged = `const CLOSURE_OVERRIDES = { zod: "4.4.3", minimatch: "10.2.6", glob: "13.0.7" };\n`;
const verdict = evaluateAdvisories(readLock(), {
manifestPin: REA_PIN,
setupSource: sabotaged,
lockPackageSource: readFileSync(path.join(LOCK_DIR, "package.json"), "utf8"),
});
assert.equal(verdict.ok, false);
for (const advisory of ADVISORY_FLOORS) {
assert.ok(
verdict.problems.some((problem) => problem.includes(advisory.name) && problem.includes(advisory.floor)),
`expected a floor complaint for ${advisory.name}; got:\n${verdict.problems.join("\n")}`,
);
}
});
test("setup.mjs's own zod floor is the advisory's fixed version", () => {
// ZOD_FLOOR decides whether a provisioned closure is accepted, so it has to be
// the advisory's number, not a value that drifted with a pin bump.
const source = readFileSync(SETUP_SOURCE, "utf8");
const match = source.match(/const ZOD_FLOOR = "([^"]+)";/);
assert.ok(match, "provisionReaAgents must verify the resolved zod against an explicit floor");
const zodFloor = ADVISORY_FLOORS.find((advisory) => advisory.name === "zod").floor;
assert.equal(compareVersions(match[1], zodFloor), 0, `setup.mjs floor ${match[1]} != advisory floor ${zodFloor}`);
});
/* ---------------------------------------------- provisioning replays the lock, in staging */
test("provisionReaAgents installs from the shipped lock, after staging, and never blind", () => {
const source = readFileSync(SETUP_SOURCE, "utf8");
const start = source.indexOf("async function provisionReaAgents(component, context) {");
assert.ok(start > 0, "provisionReaAgents must still exist");
const end = source.slice(start).search(/\r?\n\}\r?\n/);
assert.ok(end > 0, "provisionReaAgents must have a closing brace");
const body = source.slice(start, start + end);
// The old shape: one `npm install rea-agents@x --no-save` straight into the live
// vendor tree, which is neither reproducible nor safe to interrupt.
assert.doesNotMatch(body, /--no-save/, "a replayed lock replaces the blind `--no-save` install");
assert.match(body, /vendor-lock/, "the function must look for the audited lock");
assert.match(body, /npmRun\("ci"\)/, "the lock must be replayed with `npm ci`");
assert.match(body, /--ignore-scripts/, "lifecycle scripts stay off on both routes");
// Staging before the swap: `npm ci` deletes node_modules first, so running it in
// place would destroy a working install on any network failure.
const stagingAt = body.indexOf('.npm-staging');
const swapAt = body.indexOf("renameSync(modules, live)");
assert.ok(stagingAt > 0 && swapAt > stagingAt, "the closure must be installed into staging and renamed in afterwards");
// The fallback route must keep the patched pins, and the result is verified
// whatever route produced it.
assert.match(body, /npmRun\("install"\)/);
assert.match(body, /atLeast\(zodVersion, ZOD_FLOOR\)/);
});
test("compareVersions orders dotted versions numerically, not as strings", () => {
assert.equal(compareVersions("4.6.7", "4.6.6"), 1);
assert.equal(compareVersions("4.4.3", "4.6.6"), -1);
assert.equal(compareVersions("4.6.6", "4.6.6"), 0);
assert.equal(compareVersions("11.0.5", "11.0.1"), 1);
assert.equal(compareVersions("14.0.4", "14.0.2"), 1);
// The string-comparison trap: "10" < "9" lexically, but 10 is newer than 9.
assert.equal(compareVersions("10.2.6", "9.9.9"), 1);
});
test("the shipped lock is what the gate reads by default", () => {
assert.ok(existsSync(LOCK_PATH), "scripts/vendor-lock/package-lock.json must be committed");
assert.ok(existsSync(path.join(LOCK_DIR, "package.json")), "the lock's package.json must be committed next to it");
});
+158 -25
View File
@@ -16,8 +16,11 @@
*
* What it changes
* Only the version constant inside the *vendored* rea-agents copy, and only for the
* files that declare it. Original values are recorded in <root>/ghidra-gate-override.json
* so --disable restores them exactly.
* files that declare it. Every original value — plus the SHA-256 of each original
* file — is written to <root>/ghidra-gate-override.json BEFORE the first byte is
* patched, so an interrupted run is recoverable and --disable can prove it restored
* exactly what was there. A patch that fails partway through rolls back the files it
* already wrote and says so.
*
* What it does NOT do
* It does not verify the new release for you. Everything REA's own gate would have
@@ -26,12 +29,17 @@
* patch, which puts the bundle back on the pinned, verified release.
*
* Usage
* node scripts/ghidra-gate-override.mjs --status
* node scripts/ghidra-gate-override.mjs --status [--root <dir>]
* node scripts/ghidra-gate-override.mjs --enable --version 12.2 [--root <dir>]
* node scripts/ghidra-gate-override.mjs --disable [--root <dir>]
*
* The root is `--root`, else REA_BUNDLE_ROOT, else <DSH_HOME>/plugins/rea-bundle,
* else <home>/.dsh/plugins/rea-bundle — the same chain setup.mjs, the shim and
* scripts/rea-bundle-bootstrap.ps1 use.
*/
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
@@ -54,14 +62,69 @@ function parseArgs(argv) {
return out;
}
/** Same root rule as setup.mjs and the shim: DSH_HOME, else <home>/.dsh. */
/**
* Same root rule as scripts/setup.mjs, bin/rea-shim.mjs and
* scripts/rea-bundle-bootstrap.ps1: `--root`, then REA_BUNDLE_ROOT, then
* <DSH_HOME>/plugins/rea-bundle, then <home>/.dsh/plugins/rea-bundle.
*
* REA_BUNDLE_ROOT used to be ignored here, so on a machine that provisions into
* a custom root `--status` (and every patch) silently addressed the default
* root: either "no vendored REA" or — worse — a different installation.
*/
function defaultRoot() {
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== "" ? process.env.DSH_HOME : null;
const override = process.env.REA_BUNDLE_ROOT;
if (override && override.trim() !== "") return path.resolve(override);
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== "" ? path.resolve(process.env.DSH_HOME) : null;
if (home) return path.join(home, "plugins", "rea-bundle");
const profile = process.env.USERPROFILE || (process.env.HOMEDRIVE ?? "") + (process.env.HOMEPATH ?? "") || process.env.HOME;
return path.join(profile || homedir(), ".dsh", "plugins", "rea-bundle");
}
/** Case-insensitive on Windows, where two spellings can name one directory. */
function within(base, target) {
const norm = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
const from = norm(base);
const to = norm(target);
return to === from || to.startsWith(from + path.sep);
}
/**
* Resolve one record-supplied path inside the bundle, refusing escapes.
*
* The record is written by this script, but it lives in a directory the user can
* edit and a half-finished restore can mangle, and `--disable` joined
* `entry.file` straight onto the vendor root: a `"file": "../../../elsewhere"`
* entry would have made this script rewrite a file outside the bundle. Local
* write access is still required, so this is containment, not a remote escape.
*/
function containedPath(baseDir, relative, label) {
if (typeof relative !== "string" || relative.trim() === "") {
throw new Error(`${label} names no file`);
}
const base = path.resolve(baseDir);
const target = path.resolve(base, relative);
if (!within(base, target)) {
throw new Error(`${label} escapes ${base} (${relative}); refusing to touch it`);
}
// Resolve every segment by hand so a symlinked directory cannot carry the
// target outside the bundle after the string check above passed.
let current = base;
for (const segment of path.relative(base, target).split(path.sep)) {
if (segment === "") continue;
current = path.join(current, segment);
let stat;
try {
stat = lstatSync(current);
} catch {
break; // absent: the caller's existsSync decides what that means
}
if (stat.isSymbolicLink()) {
throw new Error(`${label} goes through the symbolic link ${current}; refusing to follow it`);
}
}
return target;
}
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
@@ -141,14 +204,26 @@ function main() {
let restored = 0;
let stale = 0;
for (const entry of active.files) {
const file = path.join(vendor, entry.file);
// `entry.file` comes out of a JSON file, not out of the vendor tree: keep
// every restore inside the bundle (no `..`, no symlinks) before touching it.
const file = containedPath(vendor, entry.file, `record entry ${JSON.stringify(entry.file)}`);
if (!existsSync(file)) {
stale += 1;
continue;
}
const text = readFileSync(file, "utf8");
if (new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`).test(text)) {
const patchedPattern = new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`);
if (patchedPattern.test(text)) {
replaceValue(file, entry.patched, entry.original);
if (typeof entry.originalSha256 === "string") {
const digest = createHash("sha256").update(readFileSync(file)).digest("hex");
if (digest !== entry.originalSha256) {
throw new Error(
`${entry.file} was restored but does not match the recorded original hash (${digest}); ` +
"the file changed outside this script — inspect it before restoring the rest",
);
}
}
restored += 1;
} else if (new RegExp(`${CONSTANT}\\s*=\\s*"${entry.original.replace(/\./g, "\\.")}"`).test(text)) {
stale += 1;
@@ -175,11 +250,19 @@ function main() {
return;
}
if (active) {
// A record that says its own rollback failed describes files that are still
// patched. It must not be mistaken for the "stale record" case below, which
// unlinks it: that would strand those files with no record of their originals.
if (active.status === "rollback-failed") {
throw new Error(
`${record} records a patch that could not be rolled back; run --disable to finish restoring before enabling again`,
);
}
// Re-provisioning rea-agents replaces the tree, so a record can outlive the patch
// it describes. That stale record must not block the next --enable, and it must
// not be trusted by --disable either: judge it by the files on disk.
const stillApplied = active.files.every((entry) => {
const file = path.join(vendor, entry.file);
const file = containedPath(vendor, entry.file, `record entry ${JSON.stringify(entry.file)}`);
if (!existsSync(file)) return false;
const text = readFileSync(file, "utf8");
return new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`).test(text);
@@ -191,32 +274,82 @@ function main() {
unlinkSync(record);
}
const files = [];
for (const entry of declarations) {
const relative = path.relative(vendor, entry.file);
replaceValue(entry.file, entry.value, version);
files.push({ file: relative, original: entry.value, patched: version });
}
// Read back: the whole point is that the gate now reads a value REA accepts.
for (const entry of files) {
const text = readFileSync(path.join(vendor, entry.file), "utf8");
if (!new RegExp(`${CONSTANT}\\s*=\\s*"${version.replace(/\./g, "\\.")}"`).test(text)) {
throw new Error(`readback failed for ${entry.file}`);
}
}
// Record every original BEFORE the first write to a vendored file: a failure
// halfway through used to leave patched files that no record described, and a
// record written afterwards cannot describe a process that died mid-loop.
const originals = declarations.map((entry) => {
const bytes = readFileSync(entry.file);
return {
file: path.relative(vendor, entry.file),
absolute: entry.file,
original: entry.value,
patched: version,
originalSha256: createHash("sha256").update(bytes).digest("hex"),
bytes,
};
});
const bundle = existsSync(path.join(root, "bundle.json")) ? readJson(path.join(root, "bundle.json")) : null;
const payload = {
schema: 1,
schema: 2,
requestedVersion: version,
originalVersion: values.length === 1 ? values[0] : values.join(","),
files,
files: originals.map(({ file, original, patched: patchedValue, originalSha256 }) => ({
file,
original,
patched: patchedValue,
originalSha256,
})),
ghidraPath: bundle?.paths?.ghidra ?? null,
enabledAt: new Date().toISOString(),
status: "patching",
warning:
"REA did not verify this Ghidra release. The gate existed because the bridge is only tested against the pinned one; re-running setup.mjs re-provisions rea-agents and removes this patch.",
};
// A crash between here and the final write still leaves a complete, verifiable
// description of what must be put back.
writeFileSync(record, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
console.log(`patched ${files.length} file(s) → ${CONSTANT} = "${version}"`);
const patched = [];
try {
for (const entry of originals) {
replaceValue(entry.absolute, entry.original, version);
patched.push(entry);
}
// Read back: the whole point is that the gate now reads a value REA accepts.
for (const entry of originals) {
const text = readFileSync(entry.absolute, "utf8");
if (!new RegExp(`${CONSTANT}\\s*=\\s*"${version.replace(/\./g, "\\.")}"`).test(text)) {
throw new Error(`readback failed for ${entry.file}`);
}
}
} catch (error) {
const failed = [];
for (const entry of patched) {
try {
writeFileSync(entry.absolute, entry.bytes);
} catch (rollbackError) {
failed.push(`${entry.file}: ${rollbackError && rollbackError.message ? rollbackError.message : String(rollbackError)}`);
}
}
const reason = error && error.message ? error.message : String(error);
if (failed.length > 0) {
// Keep the record: it still names every original value and hash, so a
// `--disable` can finish the restore this run could not.
writeFileSync(
record,
`${JSON.stringify({ ...payload, status: "rollback-failed", rollbackErrors: failed }, null, 2)}\n`,
"utf8",
);
throw new Error(
`patch failed (${reason}); ${patched.length - failed.length} file(s) rolled back, ` +
`${failed.length} could not be restored (see ${record}) — run --disable`,
);
}
unlinkSync(record);
throw new Error(`patch failed (${reason}); rolled back ${patched.length} file(s), nothing left changed`);
}
writeFileSync(record, `${JSON.stringify({ ...payload, status: "applied" }, null, 2)}\n`, "utf8");
console.log(`patched ${originals.length} file(s) → ${CONSTANT} = "${version}"`);
console.log(`recorded ${record}`);
console.log("restart dsh (or reload the profile) so the MCP row reconnects");
}
+517
View File
@@ -0,0 +1,517 @@
#!/usr/bin/env node
/**
* Regression tests for the "script consistency / state integrity" fixes.
*
* Three defects, one theme: a tool that answers for a *different* machine state
* than the one in front of it.
*
* 1. `check-updates.ps1` printed "无需动作" (and exited 0) while the same run
* had already reported a pin drift. Green now requires no actions, no
* drift, no unrecorded versions and no warning — and the exit code follows.
* 2. `bin/rea-guide.mjs` ignored REA_BUNDLE_ROOT, so a custom-root machine was
* reported `missing` by the one screen that exists to decide "run setup?".
* 3. `scripts/ghidra-gate-override.mjs` ignored REA_BUNDLE_ROOT too, patched
* files before it wrote the recovery record, and trusted `entry.file` from
* that record without keeping the target inside the bundle.
*
* The PowerShell tests build a throwaway bundle in a temp directory and point
* REA_BUNDLE_ROOT at it — the live install under DSH_HOME is never touched. The
* registry is a local stub, so these tests do not need the network and do not
* turn "upstream published a newer version today" into a red build.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = path.resolve(HERE, "..");
const GUIDE = path.join(PLUGIN_ROOT, "bin", "rea-guide.mjs");
const GHIDRA_OVERRIDE = path.join(PLUGIN_ROOT, "scripts", "ghidra-gate-override.mjs");
const CHECK_UPDATES = path.join(PLUGIN_ROOT, "check-updates.ps1");
const MANIFEST = JSON.parse(readFileSync(path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json"), "utf8"));
const PINS = MANIFEST.pins;
/** A temp directory that removes itself when the test ends. */
function tempDir(t, label) {
const dir = mkdtempSync(path.join(tmpdir(), `rea-hardening-${label}-`));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
/** Run one Node script and return its UTF-8 output. */
function runNode(script, args, env) {
const result = spawnSync(process.execPath, [script, ...args], {
env: { ...process.env, ...env },
encoding: "utf8",
windowsHide: true,
});
return { status: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
}
/* ------------------------------------------------------- 2. bin/rea-guide.mjs */
test("rea-guide: REA_BUNDLE_ROOT is the root the guide answers for", (t) => {
const base = tempDir(t, "guide-root");
const home = path.join(base, "home");
const root = path.join(base, "custom-root");
mkdirSync(home, { recursive: true });
mkdirSync(root, { recursive: true });
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ versions: { reaAgents: PINS.reaAgents } }));
const env = { DSH_HOME: home, REA_BUNDLE_ROOT: root };
const run = runNode(GUIDE, ["--json"], env);
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "ok", `a provisioned custom root must not read as missing: ${run.output}`);
assert.equal(path.resolve(state.bundlePath), path.resolve(root, "bundle.json"));
assert.equal(path.resolve(state.bundleRoot), path.resolve(root));
// The remedy has to name the root it is talking about, or the copy-pasted
// command provisions the default root and leaves this one untouched.
assert.match(state.setupCommand, /--root/);
assert.ok(state.setupCommand.includes(root), state.setupCommand);
});
test("rea-guide: a missing custom root is reported as missing, still with --root", (t) => {
const base = tempDir(t, "guide-missing");
const home = path.join(base, "home");
const root = path.join(base, "never-provisioned");
mkdirSync(home, { recursive: true });
const run = runNode(GUIDE, ["--json"], { DSH_HOME: home, REA_BUNDLE_ROOT: root });
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "missing");
assert.match(state.setupCommand, /--root/);
});
test("rea-guide: the default root keeps producing the plain --reuse command", (t) => {
const home = tempDir(t, "guide-default");
const run = runNode(GUIDE, ["--json"], { DSH_HOME: home, REA_BUNDLE_ROOT: "" });
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "missing");
assert.equal(path.resolve(state.bundlePath), path.resolve(home, "plugins", "rea-bundle", "bundle.json"));
assert.doesNotMatch(state.setupCommand, /--root/);
});
test("rea-guide: --root overrides both the environment and the default", (t) => {
const base = tempDir(t, "guide-flag");
const root = path.join(base, "from-flag");
mkdirSync(root, { recursive: true });
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ versions: { reaAgents: PINS.reaAgents } }));
const run = runNode(GUIDE, ["--json", "--root", root], { DSH_HOME: path.join(base, "home") });
assert.equal(run.status, 0, run.output);
const state = JSON.parse(run.output);
assert.equal(state.state, "ok", run.output);
assert.equal(path.resolve(state.bundlePath), path.resolve(root, "bundle.json"));
});
/* --------------------------------------------- 3/4/5. ghidra-gate-override.mjs */
const FILES = ["dist/a-policy.js", "dist/b-cli.js", "dist/c-mcp.js"];
/** A throwaway vendored rea-agents tree with the gate constant in three files. */
function makeVendor(t, label) {
const base = tempDir(t, label);
const root = path.join(base, "root");
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
mkdirSync(vendor, { recursive: true });
for (const relative of FILES) {
mkdirSync(path.join(vendor, path.dirname(relative)), { recursive: true });
writeFileSync(path.join(vendor, relative), `const SUPPORTED_GHIDRA_VERSION = "12.1.4";\n`);
}
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ paths: { ghidra: path.join(base, "ghidra") } }));
return { base, root, vendor, env: { DSH_HOME: path.join(base, "home"), REA_BUNDLE_ROOT: root } };
}
function declared(vendor) {
return FILES.map((relative) => {
const match = /SUPPORTED_GHIDRA_VERSION = "([^"]+)"/.exec(readFileSync(path.join(vendor, relative), "utf8"));
return match ? match[1] : null;
});
}
test("ghidra-gate-override: REA_BUNDLE_ROOT and --root select the root", (t) => {
const fixture = makeVendor(t, "gate-status");
const fromEnv = runNode(GHIDRA_OVERRIDE, ["--status"], fixture.env);
assert.equal(fromEnv.status, 0, fromEnv.output);
assert.equal(path.resolve(JSON.parse(fromEnv.output).root), path.resolve(fixture.root));
const fromFlag = runNode(GHIDRA_OVERRIDE, ["--status", "--root", fixture.root], {
DSH_HOME: path.join(fixture.base, "elsewhere"),
REA_BUNDLE_ROOT: "",
});
assert.equal(fromFlag.status, 0, fromFlag.output);
assert.equal(path.resolve(JSON.parse(fromFlag.output).root), path.resolve(fixture.root));
});
test("ghidra-gate-override: --enable then --disable round-trips on a custom root", (t) => {
const fixture = makeVendor(t, "gate-roundtrip");
const enable = runNode(GHIDRA_OVERRIDE, ["--enable", "--version", "12.2"], fixture.env);
assert.equal(enable.status, 0, enable.output);
assert.deepEqual(declared(fixture.vendor), ["12.2", "12.2", "12.2"]);
const recordPath = path.join(fixture.root, "ghidra-gate-override.json");
const record = JSON.parse(readFileSync(recordPath, "utf8"));
assert.equal(record.status, "applied");
assert.equal(record.files.length, FILES.length);
for (const entry of record.files) {
// The point of recording first: --disable can prove it restored the bytes.
assert.equal(typeof entry.originalSha256, "string");
assert.equal(entry.originalSha256.length, 64);
}
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
assert.equal(disable.status, 0, disable.output);
assert.deepEqual(declared(fixture.vendor), ["12.1.4", "12.1.4", "12.1.4"]);
assert.equal(existsSync(recordPath), false);
});
test("ghidra-gate-override: originals are recorded before the first patch", (t) => {
const fixture = makeVendor(t, "gate-record-first");
// A read-only last file fails the third write. The first two must be rolled
// back, and the run must say so — a half-applied patch with no record is what
// the old order produced when it died in the middle.
const readOnly = path.join(fixture.vendor, FILES[FILES.length - 1]);
chmodSync(readOnly, 0o444);
let enable;
try {
enable = runNode(GHIDRA_OVERRIDE, ["--enable", "--version", "12.2"], fixture.env);
} finally {
chmodSync(readOnly, 0o666);
}
assert.equal(enable.status, 1, enable.output);
assert.match(enable.output, /rolled back 2 file\(s\), nothing left changed/);
assert.deepEqual(declared(fixture.vendor), ["12.1.4", "12.1.4", "12.1.4"]);
assert.equal(existsSync(path.join(fixture.root, "ghidra-gate-override.json")), false);
});
test("ghidra-gate-override: --disable refuses a record that escapes the vendor tree", (t) => {
const fixture = makeVendor(t, "gate-escape");
const outside = path.join(fixture.base, "outside.js");
writeFileSync(outside, `const SUPPORTED_GHIDRA_VERSION = "12.2";\n`);
const escape = path.relative(fixture.vendor, outside); // ../../../../outside.js
writeFileSync(
path.join(fixture.root, "ghidra-gate-override.json"),
JSON.stringify({
schema: 1,
requestedVersion: "12.2",
originalVersion: "12.1.4",
files: [{ file: escape, original: "12.1.4", patched: "12.2" }],
}),
);
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
assert.equal(disable.status, 1, disable.output);
assert.match(disable.output, /escapes/);
assert.match(readFileSync(outside, "utf8"), /"12\.2"/);
});
test("ghidra-gate-override: --disable refuses a record that goes through a symlink", (t) => {
const fixture = makeVendor(t, "gate-symlink");
const outside = path.join(fixture.base, "outside-dir");
mkdirSync(outside, { recursive: true });
writeFileSync(path.join(outside, "x.js"), `const SUPPORTED_GHIDRA_VERSION = "12.2";\n`);
const link = path.join(fixture.vendor, "linked");
try {
symlinkSync(outside, link, "junction");
} catch (error) {
t.skip(`this host cannot create a link: ${error.code || error.message}`);
return;
}
writeFileSync(
path.join(fixture.root, "ghidra-gate-override.json"),
JSON.stringify({
schema: 1,
requestedVersion: "12.2",
originalVersion: "12.1.4",
files: [{ file: "linked/x.js", original: "12.1.4", patched: "12.2" }],
}),
);
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
assert.equal(disable.status, 1, disable.output);
assert.match(disable.output, /symbolic link/);
assert.match(readFileSync(path.join(outside, "x.js"), "utf8"), /"12\.2"/);
});
/* ------------------------------------------------ 1. check-updates.ps1 (drift) */
function powerShell() {
const candidates = process.platform === "win32" ? ["powershell", "pwsh"] : [];
for (const command of candidates) {
const probe = spawnSync(command, ["-NoProfile", "-Command", "exit 0"], { windowsHide: true });
if (!probe.error && probe.status === 0) return command;
}
return null;
}
/**
* Windows PowerShell 5.1 writes host output in the console code page, so the
* bytes are not always UTF-8. Decode as UTF-8 first and fall back to GBK when
* the result contains replacement characters.
*/
function decodeOutput(buffer) {
if (!buffer || buffer.length === 0) return "";
const utf8 = buffer.toString("utf8");
if (!utf8.includes("\uFFFD")) return utf8;
try {
return new TextDecoder("gbk").decode(buffer);
} catch {
return utf8;
}
}
function runPowerShell(command, args, env) {
// Force UTF-8 out of the host process.
//
// A Windows CI runner answers in CP437, where every CJK character collapses to
// "?" — irreversibly, so `decodeOutput`'s GBK fallback cannot rescue it and
// every assertion below fails on a host where the script is perfectly fine.
// (Measured: the conclusion line arrived as
// `[??] ????:pin ?? 1 ?;?? 2 ?(????)`.) Setting this in the CHILD keeps the
// shipped script's behaviour untouched — a test harness must not change what
// the product prints just to make its own assertions match.
// Parameter NAMES must stay unquoted: `& 'script.ps1' '-Registry' 'url'` hands
// PowerShell a positional string "-Registry" instead of binding the parameter,
// which then became $Registry's VALUE and turned the lookup into a request for
// "-Registry/rea-agents/latest" (measured: a 502 and a red suite). `-File` did
// the parsing for us; `-Command` does not.
const token = (value) =>
String(value).startsWith("-") ? String(value) : `"${String(value).replace(/"/g, '""')}"`;
const invocation = [
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;",
"$OutputEncoding=[System.Text.Encoding]::UTF8;",
`& "${CHECK_UPDATES}"`,
...args.map(token),
].join(" ");
const result = spawnSync(command, ["-NoProfile", "-Command", invocation], {
env: { ...process.env, ...env },
windowsHide: true,
maxBuffer: 8 * 1024 * 1024,
});
return { status: result.status, output: decodeOutput(result.stdout) + decodeOutput(result.stderr) };
}
function lastMeaningfulLine(output) {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "")
.pop();
}
/** A bundle whose recorded versions all equal the manifest pins. */
function makeBundleFixture(t, label) {
const base = tempDir(t, label);
const root = path.join(base, "bundle");
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
const ghidra = path.join(root, "ghidra");
mkdirSync(path.join(vendor, "scripts"), { recursive: true });
mkdirSync(path.join(root, "bin"), { recursive: true });
mkdirSync(path.join(root, "manifest"), { recursive: true });
mkdirSync(path.join(ghidra, "Ghidra"), { recursive: true });
writeFileSync(
path.join(vendor, "package.json"),
JSON.stringify({ name: "rea-agents", version: PINS.reaAgents, engines: { node: PINS.nodeEngines } }),
);
// An empty entry is enough: the script only needs the path to exist, and an
// empty CLI exits 0, so the doctor section degrades to "cannot parse".
writeFileSync(path.join(vendor, "scripts", "rea.mjs"), "");
writeFileSync(path.join(ghidra, "Ghidra", "application.properties"), "application.version=12.1.4\n");
copyFileSync(path.join(PLUGIN_ROOT, "bin", "rea-shim.mjs"), path.join(root, "bin", "rea-shim.mjs"));
copyFileSync(
path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json"),
path.join(root, "manifest", "bundle.manifest.json"),
);
writeFileSync(
path.join(root, "mirror.json"),
JSON.stringify({ schema: 1, pluginRoot: PLUGIN_ROOT, mirroredAt: "2026-10-26T13:15:52.351Z", dshHome: base }),
);
const bundle = {
schema: 1,
provisionedAt: "2026-10-26T13:15:52.343Z",
paths: { reaEntry: path.join(vendor, "scripts", "rea.mjs"), ghidra },
versions: {
reaAgents: PINS.reaAgents,
ghidra: "12.1.4",
jdk: "21.0.12",
apktool: PINS.apktool,
jadxHeadlessMcp: PINS.jadxHeadlessMcp,
node: PINS.node,
},
components: {
node: { path: "node", how: "PATH", version: PINS.node, reused: true },
apktool: { path: path.join(root, "vendor", "apktool"), jarName: "apktool.jar", version: PINS.apktool, tofu: false },
jadxHeadlessMcp: { path: path.join(root, "vendor", "jadx"), jarName: "jadx.jar", version: PINS.jadxHeadlessMcp, tofu: false },
},
unverifiedChecksums: [],
};
writeFileSync(path.join(root, "bundle.json"), JSON.stringify(bundle, null, 2));
const home = path.join(base, "home");
const profile = path.join(home, "profiles", "desktop");
mkdirSync(profile, { recursive: true });
writeFileSync(
path.join(profile, "package.json"),
JSON.stringify({
dependencies: { "dsh-plugin-rea": `file:${PLUGIN_ROOT.replace(/\\/g, "/")}` },
dsh: { profile: { bundles: ["dsh-plugin-rea"] } },
}),
);
writeFileSync(path.join(profile, "cordis.patch.yml"), "plugins:\n - id: mcp-rea\n");
const env = { DSH_HOME: path.join(home), REA_BUNDLE_ROOT: root, DSH_PROFILE_DIR: profile };
return { base, root, vendor, ghidra, bundle, env };
}
/** A registry stub that answers the pin, so these tests never need the network. */
async function stubRegistry(t) {
const server = createServer((req, res) => {
if (req.url === "/rea-agents/latest") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ version: PINS.reaAgents }));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end("{}");
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
return `http://127.0.0.1:${server.address().port}`;
}
test("check-updates: a clean bundle is the only green run", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-clean");
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 0, `clean bundle must exit 0:\n${run.output}`);
assert.match(lastMeaningfulLine(run.output), /无需动作/, run.output);
});
test("check-updates: a pin drift is reported as needing action, not as green", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-drift");
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
bundle.versions.apktool = "3.0.2"; // pin says 3.0.3
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 1, `drift must not exit 0:\n${run.output}`);
assert.doesNotMatch(run.output, /无需动作/, run.output);
assert.match(run.output, /apktool 盘上为 3\.0\.2/);
const last = lastMeaningfulLine(run.output);
assert.match(last, /需要动作/, last);
assert.match(last, /pin 漂移 1 项/, last);
});
function hasFileHash(command) {
// The stale-mirror warning below is produced by comparing two file hashes, so a
// host without `Get-FileHash` (hosted Windows runners, restricted language mode)
// cannot construct that scenario at all. Skip honestly instead of asserting on a
// warning the platform can never emit — the script itself already reports the
// missing capability as [上游] rather than as "needs action".
const probe = spawnSync(command, ["-NoProfile", "-Command", "if (Get-Command Get-FileHash -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"], {
windowsHide: true,
});
return probe.status === 0;
}
test("check-updates: a warning alone is enough to stop the green verdict", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
if (!hasFileHash(command)) {
t.skip("this host has no Get-FileHash, so the stale-mirror warning cannot be produced");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-warn");
// Every pin matches; only the mirrored shim is stale (plugin upgraded, setup
// not re-run). That used to be a warning next to a green verdict.
writeFileSync(path.join(fixture.root, "bin", "rea-shim.mjs"), "// stale mirror\n");
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 1, `a warning must not exit 0:\n${run.output}`);
assert.doesNotMatch(run.output, /无需动作/, run.output);
assert.match(run.output, /镜像 shim 落后于插件/);
const last = lastMeaningfulLine(run.output);
assert.match(last, /需要动作/, last);
assert.match(last, /提醒/, last);
});
test("check-updates: an action item also produces a non-zero exit", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-action");
// components.node.reused stays true, but the recorded runtime no longer
// satisfies REA's engines — the case that used to print an action and exit 0.
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
bundle.versions.node = "16.20.2";
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.equal(run.status, 1, `an unhealthy node must not exit 0:\n${run.output}`);
const last = lastMeaningfulLine(run.output);
assert.match(last, /需要动作/, last);
});
/* ------------------------------------------------------------------- safety net */
test("check-updates: a recorded gate override is not drift", async (t) => {
const command = powerShell();
if (!command) {
t.skip("PowerShell is not available on this host");
return;
}
const registry = await stubRegistry(t);
const fixture = makeBundleFixture(t, "check-override");
// Ghidra pinned at 12.1.4 would match anyway; record 12.2 plus its override to
// prove the pair is reported as a deliberate local divergence, never as drift.
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
bundle.versions.ghidra = "12.2";
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
writeFileSync(path.join(fixture.ghidra, "Ghidra", "application.properties"), "application.version=12.2\n");
writeFileSync(
path.join(fixture.root, "ghidra-gate-override.json"),
JSON.stringify({ schema: 1, requestedVersion: "12.2", originalVersion: "12.1.4", files: [] }),
);
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
assert.doesNotMatch(run.output, /ghidra 盘上为 12\.2/, run.output);
// The version gate still knows REA pins 12.1.x; it must attribute the 12.2 to
// the recorded override instead of printing an action item.
assert.match(run.output, /本机 --enable 的覆盖/);
assert.equal(run.status, 0, `a recorded override is not a defect:\n${run.output}`);
const last = lastMeaningfulLine(run.output);
assert.doesNotMatch(last, /pin 漂移/, last);
});
+22
View File
@@ -24,6 +24,13 @@
* shape for a gate. When a release is added upstream, add it here too — that
* edit is the reminder to re-check the row against the new host.
*
* FLOOR AND CEILING: the floor stays at 0.1.7-rc.2 (nothing older was ever
* adapted), and the newest entry must still sit below the declared ceiling.
* The 2026-11-06 adaptation added the 0.2.2 line and 0.2.1-rc.3/4/5, all of
* which remain below `<0.3.0-0` — the ceiling was NOT raised to reach them, so
* a release past 0.3.0 arrives here as an `uncovered` failure and as a decision
* for a human, never as a silent widening of what the market advertises.
*
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
* in this ecosystem pass that option — dshmarket's discovery
@@ -51,8 +58,23 @@ const REPO_ROOT = path.join(HERE, '..')
* floor is the oldest release the plugin has been adapted to; everything above
* it up to the declared ceiling must keep working, so a release that is missing
* here is a release nobody checked.
*
* "Newest first" means BY PUBLISH DATE — the order the release page shows — and
* not semver precedence. The two disagree exactly once here: 0.2.1-rc.5
* (published 2026-10-30) came out after 0.2.2-alpha.2 (2026-10-27), so a reader
* who assumes descending semver will take 0.2.2-alpha.2 to be the newer of the
* two. Publication order is the sequence hosts actually upgrade through, so
* that is the order recorded; per-release dates are in
* docs/host-compat-2026-11-06.md rather than beside the literals, because a
* date on every line goes stale the first time someone appends without one.
*/
export const HOST_RELEASES = [
'0.2.2-rc.1', // 2026-11-04 — the host the 2026-11-06 adaptation was measured on
'0.2.2-alpha.3', // 2026-11-03
'0.2.1-rc.5', // 2026-10-30
'0.2.2-alpha.2', // 2026-10-27
'0.2.1-rc.4', // 2026-10-26
'0.2.1-rc.3', // 2026-10-23
'0.2.1-rc.2',
'0.2.1-rc.1',
'0.2.1-alpha.5',
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* Tests for scripts/host-compat.mjs — the gate that compares the declared
* `engines.dsh` string against the list of DSH releases this bundle was adapted
* to.
*
* Every test here exists because one of these failures is silent. The gate has
* no runtime feedback: it prints a line per release and exits, and the wrong
* verdict looks exactly like the right one in a CI log nobody reads closely. So
* each way it can lie gets a test that names the lie:
*
* - a range that is unparseable reads as `unknown` (an undeclared plugin,
* not a mis-declared one) — so the parser refuses shapes it cannot judge
* - a floor that drifted drops hosts the README still promises — so the
* drifted-floor control asserts the exact set it loses
* - a ceiling of `<0.3.0` admits `0.3.0-rc.1`, because a prerelease sorts
* BELOW its release once prereleases are included — the single most
* expensive mistake this package ever shipped
* - a zero-padded prerelease or a `v` prefix is a typo, and a gate that
* guesses at one puts the guess in charge of what the market advertises
* - an appended release lands in the wrong place in the list, so the next
* person reads the coverage claim backwards
*
* Runs with `node --test`. No network, no filesystem writes.
*/
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
HOST_RELEASES,
OUTSIDE_HOSTS,
checkHostCoverage,
compareVersions,
declaredHostRange,
parseVersion,
satisfiesDeclaredRange,
} from "./host-compat.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.join(HERE, "..");
/** The declaration this package actually ships; pinned by its own test too. */
const RANGE = ">=0.1.7-rc.2 <0.3.0-0";
/* --------------------------------------------------------- parseVersion */
test("parseVersion splits the tuple and keeps a prerelease", () => {
assert.deepEqual(parseVersion("0.2.1-rc.2"), { parts: [0, 2, 1], pre: ["rc", 2] });
assert.deepEqual(parseVersion("0.1.7-rc.2"), { parts: [0, 1, 7], pre: ["rc", 2] });
// A numeric prerelease identifier becomes a number, not the string "2": the
// comparison in compareVersions branches on that type.
assert.deepEqual(parseVersion("0.2.1-alpha.10"), { parts: [0, 2, 1], pre: ["alpha", 10] });
});
test("parseVersion drops build metadata, because precedence has none", () => {
// A host reporting `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. node-semver and
// this file both ignore the suffix; a gate that kept it would compare the
// string "7" against an alpha identifier and answer with nonsense.
assert.deepEqual(parseVersion("0.2.0-rc.2+build.7"), { parts: [0, 2, 0], pre: ["rc", 2] });
assert.deepEqual(parseVersion("0.2.0+build.7"), { parts: [0, 2, 0], pre: [] });
assert.equal(compareVersions("0.2.0-rc.2+build.7", "0.2.0-rc.2"), 0);
});
test("parseVersion refuses the shapes a typo produces instead of guessing", () => {
// Zero-padded numeric identifier: semver forbids it. Accepting one would let
// `0.2.1-01` compare equal to `0.2.1-1` and hide a typo in the release list.
assert.equal(parseVersion("0.2.1-01"), null);
assert.equal(parseVersion("0.2.1-rc.01"), null);
// A leading `v` is a tag, not a version.
assert.equal(parseVersion("v0.2.0-rc.2"), null);
// Too few / too many components, and outright garbage.
assert.equal(parseVersion("0.2"), null);
assert.equal(parseVersion("0.2.0.1"), null);
assert.equal(parseVersion(""), null);
assert.equal(parseVersion(undefined), null);
assert.equal(parseVersion(null), null);
assert.equal(parseVersion("latest"), null);
});
test("parseVersion accepts a single zero and a prerelease of one identifier", () => {
// `0` is legal and is load bearing: `<0.3.0-0` ends at the bare zero
// identifier, which is what makes every 0.3.0 prerelease sort above it.
assert.deepEqual(parseVersion("0.3.0-0"), { parts: [0, 3, 0], pre: [0] });
assert.deepEqual(parseVersion("0.2.1-rc"), { parts: [0, 2, 1], pre: ["rc"] });
});
/* ------------------------------------------------------- compareVersions */
test("a prerelease sorts below its release", () => {
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1"), -1);
assert.equal(compareVersions("0.2.1", "0.2.1-rc.2"), 1);
assert.equal(compareVersions("0.2.2-rc.1", "0.2.2"), -1);
assert.equal(compareVersions("0.2.1", "0.2.1"), 0);
});
test("prerelease identifiers order the way semver says, not the way strings do", () => {
// Numeric identifiers sort below alphanumeric ones.
assert.equal(compareVersions("0.3.0-0", "0.3.0-alpha.1"), -1);
assert.equal(compareVersions("0.3.0-rc.1", "0.3.0-0"), 1);
// alpha < rc. String order agrees with this one, so it alone proves nothing.
assert.equal(compareVersions("0.2.1-alpha.5", "0.2.1-rc.1"), -1);
// But string order DISAGREES here: "alpha.10" < "alpha.5" as text, and semver
// compares 10 > 5. This is the assertion that catches a localeCompare slip.
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
// A shorter identifier list sorts below a longer one that shares its prefix.
assert.equal(compareVersions("0.2.1-rc", "0.2.1-rc.1"), -1);
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1-rc.1"), 1);
});
test("compareVersions compares the tuple before the prerelease", () => {
// 0.2.2-alpha.3 is NEWER than 0.2.1-rc.5 even though "alpha" < "rc": the
// tuple decides first. The release list's one ordering exception depends on
// the opposite direction of this rule, so get it pinned.
assert.equal(compareVersions("0.2.2-alpha.3", "0.2.1-rc.5"), 1);
assert.equal(compareVersions("0.2.1-rc.5", "0.2.2-alpha.2"), -1);
});
test("compareVersions refuses unparseable input rather than returning a verdict", () => {
assert.throws(() => compareVersions("v0.2.0", "0.2.0"), /not a version/);
assert.throws(() => compareVersions("0.2.0", "0.2.1-01"), /not a version/);
});
/* ------------------------------------------------ satisfiesDeclaredRange */
test("the declared range admits every release from the floor up", () => {
assert.equal(satisfiesDeclaredRange("0.1.7-rc.2", RANGE), true, "the floor itself is inside");
assert.equal(satisfiesDeclaredRange("0.2.0-rc.1", RANGE), true);
assert.equal(satisfiesDeclaredRange("0.2.1-rc.5", RANGE), true);
assert.equal(satisfiesDeclaredRange("0.2.2-rc.1", RANGE), true, "the host this list was last measured on");
});
test("the next minor line falls outside, prereleases included", () => {
// This is the assertion the `<0.3.0-0` ceiling exists for. With
// `includePrerelease: true`, `0.3.0-rc.1 < 0.3.0` is true, so a plain
// `<0.3.0` ceiling would have admitted a host nobody audited.
assert.equal(satisfiesDeclaredRange("0.3.0", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.3.0-alpha.1", RANGE), false);
// And the boundary node itself is excluded: `< 0.3.0-0` is strict.
assert.equal(satisfiesDeclaredRange("0.3.0-0", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.4.0", RANGE), false);
});
test("versions below the floor fall outside", () => {
assert.equal(satisfiesDeclaredRange("0.1.7-rc.1", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.1.7-alpha.2", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.1.6-alpha.2", RANGE), false);
assert.equal(satisfiesDeclaredRange("0.1.7", RANGE), true, "the release of the floor line is above its rc");
});
test("build metadata does not change the verdict", () => {
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2+build.7", RANGE), true);
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1+build.7", RANGE), false);
});
test("only the `>=X <Y` shape is evaluated; anything else is refused", () => {
// Deliberately narrow. A wider parser here could disagree with the market's
// semver implementation on a range the manifest would then be trusted for,
// and the disagreement would surface as a wrong "compatible" badge.
for (const range of ["^0.2.0", "~0.2.0", "0.2.0", ">=0.1.7-rc.2", "<0.3.0-0", ">=0.1.7-rc.2 <0.3.0-0 || >=0.4.0-0", ">= 0.1.7-rc.2 <0.3.0-0 <0.4.0-0"]) {
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", range), /unsupported host range/, `must refuse ${range}`);
}
});
test("extra whitespace around the two comparators is tolerated", () => {
// A pasted range often carries it, and node-semver tolerates it too.
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2", " >=0.1.7-rc.2 <0.3.0-0 "), true);
});
/* ------------------------------------------------------ declaredHostRange */
test("declaredHostRange reads the real manifest and returns null when absent", () => {
assert.equal(typeof declaredHostRange(REPO_ROOT), "string");
assert.equal(declaredHostRange(path.join(HERE, "no-such-root-for-host-compat")), null);
});
/* ----------------------------------------------------- checkHostCoverage */
test("every listed release is covered and no outsider leaks in", () => {
const { covered, uncovered, leaked } = checkHostCoverage(RANGE);
assert.deepEqual(uncovered, [], `these releases fall outside ${RANGE}`);
assert.deepEqual(leaked, [], `the declaration also admits ${leaked.join(", ")}, which nobody tested`);
assert.deepEqual(covered, HOST_RELEASES, "covered must report the list in list order");
});
test("the drifted-floor control loses exactly the hosts a 0.2.0 floor would drop", () => {
// A negative control: a gate that always passes proves nothing. `>=0.2.0` is
// the shape someone writes after testing only on their own machine — and
// because a prerelease sorts BELOW its release, it drops the whole 0.2.0-rc
// line as well as 0.1.7, while looking like it names the running host.
const { uncovered } = checkHostCoverage(">=0.2.0 <0.3.0-0");
assert.deepEqual(uncovered, ["0.2.0-rc.2", "0.2.0-rc.1", "0.1.7-rc.2"]);
});
test("the leaked-host control notices a low floor and a raised ceiling", () => {
assert.deepEqual(checkHostCoverage(">=0.1.0 <0.3.0-0").leaked, ["0.1.7-rc.1", "0.1.7-alpha.2"]);
// The other half: a ceiling that reaches into 0.3.0 must be reported, not
// silently accepted. Every 0.3.0 entry has to show up.
assert.deepEqual(checkHostCoverage(">=0.1.7-rc.2 <0.4.0-0").leaked, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1"]);
// A plain `<0.3.0` ceiling is the expensive one: it admits the 0.3.0
// prereleases while still excluding 0.3.0 itself, so only the two prerelease
// entries surface. That asymmetry is exactly why this list is pinned.
assert.deepEqual(checkHostCoverage(">=0.1.7-rc.2 <0.3.0").leaked, ["0.3.0-rc.1", "0.3.0-alpha.1"]);
});
test("checkHostCoverage propagates the refusal for an unparseable range", () => {
assert.throws(() => checkHostCoverage("^0.2.0"), /unsupported host range/);
});
/* ------------------------------------------------------- the release list */
test("the 2026-11-06 adaptation is present, and the newest six are in front", () => {
const added = ["0.2.2-rc.1", "0.2.2-alpha.3", "0.2.1-rc.5", "0.2.2-alpha.2", "0.2.1-rc.4", "0.2.1-rc.3"];
assert.deepEqual(HOST_RELEASES.slice(0, added.length), added, "newest-first by publish date");
for (const version of added) {
assert.ok(HOST_RELEASES.includes(version), `${version} must be listed; an unlisted release is an unchecked one`);
}
// 0.2.2-rc.1 is the host this adaptation was measured on: if it is not the
// first entry, someone prepended without a reason.
assert.equal(HOST_RELEASES[0], "0.2.2-rc.1");
});
test("the list stays newest-first, with exactly one documented publication-order exception", () => {
// "Newest first" means by publish date. That is semver-descending everywhere
// EXCEPT 0.2.1-rc.5 (published after 0.2.2-alpha.2), which is the one
// inversion the list carries on purpose. Pinning the exception list rather
// than the whole array keeps this test from being a third copy of the data
// while still catching an append in the wrong place.
const inversions = [];
for (let index = 1; index < HOST_RELEASES.length; index += 1) {
const previous = HOST_RELEASES[index - 1];
const current = HOST_RELEASES[index];
if (compareVersions(previous, current) <= 0) inversions.push(`${previous} < ${current}`);
}
assert.deepEqual(inversions, ["0.2.1-rc.5 < 0.2.2-alpha.2"]);
});
test("the floor is still 0.1.7-rc.2, the oldest release ever adapted to", () => {
// Raising the floor silently drops hosts the README promises, which is the
// failure the file header calls out. If a future adaptation really does drop
// 0.1.7, this test is where the decision gets recorded.
assert.equal(HOST_RELEASES[HOST_RELEASES.length - 1], "0.1.7-rc.2");
// And the list never runs below the floor it claims.
for (const version of HOST_RELEASES) {
assert.ok(compareVersions(version, "0.1.7-rc.2") >= 0, `${version} sorts below the floor`);
}
});
test("the list carries no duplicate and every entry parses", () => {
assert.equal(new Set(HOST_RELEASES).size, HOST_RELEASES.length, "a duplicate would double-count coverage");
for (const version of HOST_RELEASES) {
assert.notEqual(parseVersion(version), null, `${version} is not a version this gate can evaluate`);
}
});
test("the outsider list is the audited one, and none of it is a shipped release", () => {
// Pinned so that "make the gate pass" can never be done by deleting an
// outsider. These five are the ones a wrong ceiling or a wrong floor admits.
assert.deepEqual(OUTSIDE_HOSTS, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1", "0.1.7-rc.1", "0.1.7-alpha.2"]);
for (const version of OUTSIDE_HOSTS) {
assert.ok(!HOST_RELEASES.includes(version), `${version} is both covered and required to be outside`);
}
});
test("the newest release stays inside the declared ceiling, without the ceiling moving", () => {
// The declared range is the thing the market reads. An adaptation that needed
// a wider ceiling would have to change `engines.dsh` — a decision for a human,
// and this assertion is what forces it to be one.
assert.equal(declaredHostRange(REPO_ROOT), RANGE);
assert.equal(satisfiesDeclaredRange(HOST_RELEASES[0], RANGE), true);
});
+17 -2
View File
@@ -23,6 +23,21 @@ function Test-PinMatch {
if ($installed -eq '' -or $pinned -eq '') { return $false }
$same = [string]::Equals($installed, $pinned, [System.StringComparison]::Ordinal)
if ($pinned.Contains('.')) { return $same }
# A major-only pin ("21") also matches its point releases ("21.0.12.1+1").
return ($same -or $installed.StartsWith($pinned + '.', [System.StringComparison]::Ordinal))
# A major-only pin ("21") names a FLOOR, not a family prefix.
#
# Ghidra 12.1 requires "JDK 21 or newer", so a machine whose JAVA_HOME holds
# 25.0.5 satisfies a pin that names 21. This function used to accept 21.0.12.1+1
# and reject 25.0.5, which told the operator to "re-run setup to align" while
# setup itself answered "already provisioned (25.0.5) - major 25 satisfies the
# pin's major 21". One of the two had to be wrong, and it was this one: the
# prefix rule cannot express "or newer", and a bare-major pin can mean nothing
# else.
$installedMajor = 0
$pinnedMajor = 0
$installedHead = ($installed -split '\.')[0]
if ([int]::TryParse($installedHead, [ref]$installedMajor) -and [int]::TryParse($pinned, [ref]$pinnedMajor)) {
return ($installedMajor -ge $pinnedMajor)
}
# Not two numbers (a "21-rc.1"-shaped pin, say): fall back to the literal match.
return $same
}
+1112 -101
View File
File diff suppressed because it is too large. Load diff
+553 -11
View File
@@ -348,10 +348,17 @@ test("--reuse still adopts a compatible node", (t) => {
// 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`);
//
// 2.0.13 raised that floor to ^22.23.4 || ^24.22.1 || >=26.12.1 and this guard
// still carried the 6.7.0-era numbers (22.19 / 24.11 / 26.0): the GitHub Linux
// runner's Node 22.2x passed the guard, the real probe then rejected it, and the
// adoption assert failed on CI while every Windows job stayed green. Ask the
// manifest — the same expression the probe uses — instead of restating it here.
const probe = new RegExp(
JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8")).components.node.probe.expect.regex,
);
if (!probe.test(`v${process.versions.node}`)) {
t.skip(`host Node ${process.versions.node} fails the manifest's node probe; the candidate would be rejected on purpose`);
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-node-ok-"));
@@ -1130,7 +1137,11 @@ test("--offline refuses the npm step instead of going online anyway", () => {
timeout: 120000,
});
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /--offline and the vendored REA/);
// The message names WHY it has to be reinstalled (no copy at all, a CLI that
// does not answer, or a closure below its advisory floors) instead of calling
// all three "not usable".
assert.match(run.stdout, /--offline, and the REA at .* has to be reinstalled/, run.stdout);
assert.match(run.stdout, /there is no vendored REA/, run.stdout);
// The other direction: a usable vendored copy is still accepted offline, so the
// flag does not turn into "reaAgents can never be provisioned".
@@ -1412,6 +1423,16 @@ test("the release list this gate checks is the published one", () => {
// Pinned literally: deleting an entry from the list would otherwise shrink the
// coverage claim silently, and every test below would still pass.
assert.deepEqual(HOST_RELEASES, [
// Newest first BY PUBLISH DATE, not by semver precedence: 0.2.1-rc.5 came
// out after 0.2.2-alpha.2, so it sits above it even though 0.2.2 is the
// newer line. Do not "fix" the order — scripts/host-compat.test.mjs pins
// this one inversion deliberately.
"0.2.2-rc.1",
"0.2.2-alpha.3",
"0.2.1-rc.5",
"0.2.2-alpha.2",
"0.2.1-rc.4",
"0.2.1-rc.3",
"0.2.1-rc.2",
"0.2.1-rc.1",
"0.2.1-alpha.5",
@@ -1576,6 +1597,10 @@ test("an older system REA does not displace a newer provisioned one unless pinne
mkdirSync(f.bundleRoot, { recursive: true });
const rea = writeFakeRea(f.bin, { version: "6.4.0" });
try {
// The bundle has to be launchable, not merely recorded: the launch decision
// now compares against whether the bundle can actually start REA, so a
// bundle.json naming no runtime is a broken bundle rather than a newer one.
addReaStub(f.bundleRoot, "6.5.0");
writeFileSync(
path.join(f.bundleRoot, "bundle.json"),
JSON.stringify({ schema: 1, paths: { node: process.execPath }, versions: { reaAgents: "6.5.0" } }),
@@ -2059,13 +2084,18 @@ test("a BOM-ed bundle.json is read instead of treated as unprovisioned", () => {
assert.equal(fresh.status, 0, fresh.stderr);
assert.match(fresh.stdout, /\[plan\] node/);
// A record is checked against the manifest pin before it is trusted, so the
// marker has to be the pin itself — a made-up version would exercise the
// "out of date" branch instead of proving that the BOM was decoded.
const bomNodeSources = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8")).components.node.sources;
const bomNodePin = (bomNodeSources[`${process.platform}-${process.arch}`] ?? bomNodeSources.all).version;
writeFileSync(
path.join(bundle, "bundle.json"),
"\uFEFF" + JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: "v-bom-test" } } }, null, 2),
"\uFEFF" + JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: bomNodePin } } }, null, 2),
);
const run = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, run.stderr);
assert.match(run.stdout, /already provisioned \(v-bom-test\)/, run.stdout);
assert.match(run.stdout, new RegExp(`already provisioned \\(${bomNodePin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)`), run.stdout);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -2096,11 +2126,21 @@ test("a UTF-16 bundle.json is read by setup instead of treated as unprovisioned"
assert.equal(fresh.status, 0, fresh.stderr);
assert.match(fresh.stdout, /\[plan\] node/);
const json = JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: "v-utf16" } } }, null, 2);
// The recorded version has to be the pin.
//
// This test is about DECODING the UTF-16 file, and it used to mark a
// successful decode with a made-up version ("v-utf16"). A record is now also
// checked against the manifest pin before it is trusted, so a made-up version
// exercises the "recorded but out of date" branch instead of this one — the
// decode would still be proven, but by a message about drift. Reading the pin
// from the manifest keeps the marker meaningful without hard-coding it.
const nodeSources = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8")).components.node.sources;
const nodePin = (nodeSources[`${process.platform}-${process.arch}`] ?? nodeSources.all).version;
const json = JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: nodePin } } }, null, 2);
writeFileSync(path.join(bundle, "bundle.json"), utf16le(json));
const run = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
assert.equal(run.status, 0, run.stderr);
assert.match(run.stdout, /already provisioned \(v-utf16\)/, run.stdout);
assert.match(run.stdout, new RegExp(`already provisioned \\(${nodePin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)`), run.stdout);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -2134,12 +2174,16 @@ function guideBundle(root, record, encoding) {
return file;
}
test("reaPinMatches pins a dotted version exactly and a bare major as a family", () => {
test("reaPinMatches pins a dotted version exactly and a bare major as a floor", () => {
assert.equal(reaPinMatches("6.7.0", "6.7.0"), true);
assert.equal(reaPinMatches("6.5.0", "6.7.0"), false);
assert.equal(reaPinMatches("6.7.0-rc.1", "6.7.0"), false);
assert.equal(reaPinMatches("21.0.12.1+1", "21"), true);
assert.equal(reaPinMatches("20.0.1", "21"), false);
// A bare-major pin is a FLOOR: Ghidra requires "JDK 21 or newer", so a machine
// on 25 satisfies a pin that names 21. The family-prefix rule said otherwise,
// which made the guide contradict setup about the same JAVA_HOME.
assert.equal(reaPinMatches("25.0.5", "21"), true, "a newer major satisfies a major-only pin");
assert.equal(reaPinMatches("20.0.1", "21"), false, "a lower major does not satisfy it");
assert.equal(reaPinMatches("", "6.7.0"), false);
assert.equal(reaPinMatches("6.7.0", ""), false);
});
@@ -2232,3 +2276,501 @@ test("check-updates names the portable commands it falls back to", () => {
const win = unsupportedMessage("win32");
assert.match(win, /No PowerShell/);
});
/* ============================================================================
* Regressions for the 2026-11-06 audit (`kimi.txt`).
*
* Each test below fails on the 2.0.13 code and passes after the fix it names, so
* the defect cannot come back without a red run. The two "double run" cases are
* the audit's explicit requirement: a first run that fails partway, and a second
* run that must retry rather than report the phantom success.
* ========================================================================== */
test("a half-fetched multi-file component fails outright instead of recording a version", async () => {
const payload = Buffer.from("jar-bytes-0123456789");
const server = createServer((req, res) => {
req.on("error", () => {});
res.on("error", () => {});
res.writeHead(200, { "content-type": "application/octet-stream", "content-length": String(payload.length) });
res.end(payload);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const root = mkdtempSync(path.join(tmpdir(), "rea-multifile-atomic-"));
try {
const bundle = path.join(root, "bundle");
const setup = makePluginTree(root, (manifest) => {
const component = testComponent(`http://127.0.0.1:${port}/apktool.jar`, payload, {
moveTo: "apktool",
saveAs: "apktool.jar",
probe: { file: "{vendor}/apktool/apktool.jar" },
});
component.kind = "multi-file";
// The second half of the component is unreachable. Port 1 on loopback is
// refused immediately, which is the "jar arrives, wrapper does not" shape
// the audit reproduced.
component.entries.byPlatform = {
[`${process.platform}-${process.arch}`]: [
{
id: "wrapper",
version: "1",
url: "http://127.0.0.1:1/apktool.bat",
format: "file",
size: 11,
sha256: "0".repeat(64),
moveTo: "apktool",
saveAs: "apktool.bat",
},
],
};
manifest.components.apktool = component;
});
const env = { ...process.env, REA_BUNDLE_FETCH_RETRIES: "1", REA_BUNDLE_CONNECT_TIMEOUT_MS: "2000" };
const first = await runToCompletion(process.execPath, [setup, "--only", "apktool", "--root", bundle], {
encoding: "utf8",
timeout: 180000,
env,
});
assert.equal(first.status, 1, `${first.stdout}\n${first.stderr}`);
assert.match(first.stdout, /nothing was installed/, first.stdout);
// All-or-nothing: the file that DID arrive must not be left in place.
assert.equal(existsSync(path.join(bundle, "vendor", "apktool", "apktool.jar")), false, "a partial multi-file component must not be placed");
const record = JSON.parse(readFileSync(path.join(bundle, "bundle.json"), "utf8"));
const apktool = (record.components ?? {}).apktool ?? {};
// "Unrecorded" is written as an explicit unavailable marker, which is fine —
// what must NOT happen is a version and a digest that describe files the
// component does not have, which is what made the next run print
// "already provisioned (3.0.3)" over a stale 2.0.0 jar.
assert.equal(apktool.unavailable, true, JSON.stringify(apktool));
assert.equal(apktool.version, undefined, "a component that could not be placed must not record a version");
assert.equal(apktool.sha256, undefined, "a component that could not be placed must not record a digest");
// The second run must try again. On 2.0.13 it printed "already provisioned".
const second = await runToCompletion(process.execPath, [setup, "--only", "apktool", "--root", bundle], {
encoding: "utf8",
timeout: 180000,
env,
});
assert.doesNotMatch(second.stdout, /already provisioned/, second.stdout);
} finally {
await new Promise((resolve) => server.close(resolve));
rmSync(root, { recursive: true, force: true });
}
});
test("a recorded component is probed at its recorded path, not at a vendor path", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-recorded-path-"));
try {
const checkout = bomFixtureCheckout(root);
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
const nodeSources = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8")).components.node.sources;
const nodePin = (nodeSources[`${process.platform}-${process.arch}`] ?? nodeSources.all).version;
// The record points at a path that does not exist. A vendor copy that WOULD
// verify must not be allowed to answer for it: on 2.0.13 the probe ran against
// the default vendor path and the broken record was kept.
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify({ schema: 1, components: { node: { path: path.join(root, "no-such-node"), version: nodePin } } }),
);
const run = spawnSync(process.execPath, [path.join(checkout, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 0, run.stderr);
assert.doesNotMatch(run.stdout, /already provisioned/, run.stdout);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a record whose version is not the manifest pin is not trusted", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-pin-drift-"));
try {
const checkout = bomFixtureCheckout(root);
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
// Path exists and the probe passes; only the version is wrong. That is a pin
// bump against an old install, and 2.0.13 reported "already provisioned".
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: "0.0.1-ancient" } } }),
);
const run = spawnSync(process.execPath, [path.join(checkout, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 0, run.stderr);
assert.match(run.stdout, /recorded but out of date/, run.stdout);
assert.doesNotMatch(run.stdout, /already provisioned/, run.stdout);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("the JDK probe rejects a full JDK older than 21", () => {
const probe = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8")).components.jdk.probe;
assert.equal(probe.expect.contains, "jdk.compiler", "the trimmed-runtime check must stay");
const pattern = new RegExp(probe.expect.regex, "m");
// A real Java 17 prints jdk.compiler too; only the version segment separates it.
assert.equal(pattern.test("[email protected]"), false, "Java 17 must be rejected");
assert.equal(pattern.test("[email protected]"), false, "Java 11 must be rejected");
assert.equal(pattern.test("jdk.compiler@9"), false, "Java 9 must be rejected");
assert.equal(pattern.test("[email protected]"), true, "the pinned JDK 21 must pass");
assert.equal(pattern.test("[email protected]"), true, "a newer major must pass");
assert.equal(pattern.test("[email protected]\[email protected]"), true, "a real --list-modules listing must pass");
});
test("--dry-run creates nothing on disk", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-dry-run-clean-"));
try {
const checkout = bomFixtureCheckout(root);
const bundle = path.join(root, "bundle-that-must-not-appear");
const run = spawnSync(process.execPath, [path.join(checkout, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
assert.equal(run.status, 0, run.stderr);
assert.equal(existsSync(bundle), false, "a dry run must not create the root");
assert.equal(existsSync(path.join(bundle, "logs", "setup.log")), false, "a dry run must not write a log");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("an unparseable setup.lock is replaced instead of throwing", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-broken-lock-"));
try {
const checkout = bomFixtureCheckout(root);
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
writeFileSync(path.join(bundle, "setup.lock"), "{broken");
const run = spawnSync(process.execPath, [path.join(checkout, "scripts", "setup.mjs"), "--only", "node", "--offline", "--root", bundle], {
encoding: "utf8",
timeout: 120000,
});
const combined = `${run.stdout}\n${run.stderr}`;
assert.doesNotMatch(combined, /SyntaxError/, combined);
assert.match(combined, /setup\.lock is not readable JSON/, combined);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("the tar extractor asks Python for its member filter", () => {
// Defense in depth rather than a live exploit: every archive is SHA-256 pinned.
// A bare extractall() is what a future unpinned entry would inherit, so the
// contract is pinned in the source.
const source = readFileSync(SETUP_SOURCE, "utf8");
assert.match(source, /filter='data'/, "the python3 tar fallback must pass filter='data'");
assert.doesNotMatch(source, /tarfile\.open\(sys\.argv\[1\]\)\.extractall\(sys\.argv\[2\]\)/, "the unfiltered extractall must be gone");
});
test("a parsed bundle that cannot launch REA falls back to a working system REA", () => {
const f = launchFixture();
mkdirSync(f.bin, { recursive: true });
mkdirSync(f.bundleRoot, { recursive: true });
const rea = writeFakeRea(f.bin, { version: "6.4.0" });
try {
// Records a NEWER version than the system one, but names a Node runtime that
// is gone. On 2.0.13 the recorded version decided the comparison, so the
// broken bundle won and the row exited 78 without trying the system REA.
writeFileSync(
path.join(f.bundleRoot, "bundle.json"),
JSON.stringify({ schema: 1, paths: { node: path.join(f.bundleRoot, "gone", "node.exe") }, versions: { reaAgents: "9.9.9" } }),
);
const run = runShimIsolated(
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: rea, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
["--shim-status"],
);
const report = JSON.parse(run.stdout);
assert.equal(report.provisioned, true, "the state exists, and the report must still say so");
assert.equal(report.bundleUsable, false, JSON.stringify(report.rea));
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
assert.equal(run.status, 0, "a working system REA is a healthy row");
} finally {
f.clean();
}
});
test("--shim-status does not exit 0 for a bundle that cannot launch REA and has no system REA", () => {
const f = launchFixture();
mkdirSync(f.bundleRoot, { recursive: true });
try {
writeFileSync(
path.join(f.bundleRoot, "bundle.json"),
JSON.stringify({ schema: 1, paths: { node: path.join(f.bundleRoot, "gone", "node.exe") }, versions: { reaAgents: "9.9.9" } }),
);
const run = runShimIsolated(
{ PATH: f.emptyPath, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
["--shim-status"],
);
const report = JSON.parse(run.stdout);
assert.equal(report.bundleUsable, false, JSON.stringify(report.rea));
assert.equal(report.launch.mode, "bundle");
assert.equal(run.status, 78, "an unusable bundle with nothing to fall back to must not look healthy");
} finally {
f.clean();
}
});
test("an expired lock whose owner is still alive is reported, not deleted", () => {
const f = launchFixture();
mkdirSync(f.bundleRoot, { recursive: true });
try {
const lock = path.join(f.bundleRoot, "setup.lock");
// Seven hours old — past the six-hour window — but the pid is this very
// process, so it is provably alive. 2.0.13 deleted it and started a SECOND
// provisioning run: slow downloads really do pass six hours.
writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() - 7 * 60 * 60 * 1000 }));
const run = runShimIsolated(
{ PATH: f.emptyPath, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_LAUNCH: "" },
["mcp"],
);
assert.match(run.stderr, /has not reported for/, run.stderr);
assert.equal(existsSync(lock), true, "a lock owned by a live pid must survive its own age");
} finally {
f.clean();
}
});
/* ============================================================================
* Audit round 2 (2026-11-07) — the four release-blocking gaps.
*
* Each one failed on 5c71bf8 and passes after its fix. Three are real fault
* injections (a probe that fails, an old closure on disk, a stale record); the
* fourth is a structural contract, because exercising it end to end needs a real
* npm install of a deliberately broken closure.
* ========================================================================== */
/** Real zip bytes on this platform, or null when no tool on hand can build one. */
function makeZipFixture(dir, innerName, text) {
const zipPath = path.join(dir, "fixture.zip");
const source = path.join(dir, innerName);
writeFileSync(source, text);
if (process.platform === "win32") {
// bsdtar ships with Windows 10+ and creates zip through -a.
const built = spawnSync("tar", ["-a", "-c", "-f", zipPath, "-C", dir, innerName], { windowsHide: true });
if (built.status === 0 && existsSync(zipPath)) return readFileSync(zipPath);
return null;
}
const built = spawnSync("python3", [
"-c",
"import sys,zipfile;z=zipfile.ZipFile(sys.argv[1],'w');z.write(sys.argv[2],sys.argv[3]);z.close()",
zipPath,
source,
innerName,
]);
if (built.status === 0 && existsSync(zipPath)) return readFileSync(zipPath);
return null;
}
test("a failed archive probe puts the previous install back", async (t) => {
const root = mkdtempSync(path.join(tmpdir(), "rea-archive-rollback-"));
try {
const payload = makeZipFixture(root, "inside.txt", "hello from inside\n");
if (!payload) {
t.skip("no tool on this host can build a zip fixture (tar -a / python3)");
return;
}
const bundle = path.join(root, "bundle");
// Primed cache: the component is satisfied without opening a socket, so this
// test exercises the swap/rollback path and nothing else.
mkdirSync(path.join(bundle, "cache"), { recursive: true });
writeFileSync(path.join(bundle, "cache", "fixture.zip"), payload);
// A known-good previous install, at the exact path the swap will use.
const installed = path.join(bundle, "vendor", "apktool");
mkdirSync(installed, { recursive: true });
writeFileSync(path.join(installed, "old-good.txt"), "the previous working install\n");
const setup = makePluginTree(root, (manifest) => {
// Overrides a name INSTALL_ORDER actually walks, because `--only` cannot
// reach a component the install order does not list. The probe names a file
// the archive does not contain, so it fails AFTER the swap — the case where
// 2.0.14 deleted the old tree and left a broken one in its place.
manifest.components.apktool = testArchive("http://127.0.0.1:9/fixture.zip", payload, {
moveTo: "apktool",
probe: { file: "{vendor}/apktool/must-not-exist.txt" },
});
});
const run = await runToCompletion(process.execPath, [setup, "--only", "apktool", "--offline", "--root", bundle], {
encoding: "utf8",
timeout: 180000,
});
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
assert.match(run.stdout, /the previous install was put back/, run.stdout);
assert.ok(existsSync(path.join(installed, "old-good.txt")), "the previous known-good install must survive a failed probe");
assert.equal(existsSync(path.join(bundle, "vendor", "apktool.backup")), false, "a successful rollback must not leave the backup behind");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a recorded REA whose closure still carries zod 4.4.3 is not reported as provisioned", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-old-closure-"));
try {
const bundle = path.join(root, "bundle");
const setup = makePluginTree(root, () => {});
addReaStub(bundle, REA_PIN);
const reaPath = path.join(bundle, "vendor", "node_modules", "rea-agents");
// Exactly what a 2.0.13 installation looks like: the pin matches, --version
// answers, and the closure underneath still carries the vulnerable zod.
const zodDir = path.join(bundle, "vendor", "node_modules", "zod");
mkdirSync(zodDir, { recursive: true });
writeFileSync(path.join(zodDir, "package.json"), JSON.stringify({ name: "zod", version: "4.4.3" }));
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify({
schema: 1,
paths: { node: process.execPath },
versions: { reaAgents: REA_PIN },
components: { reaAgents: { path: reaPath, version: REA_PIN } },
}),
);
const run = await runToCompletion(process.execPath, [setup, "--only", "reaAgents", "--offline", "--root", bundle], {
encoding: "utf8",
timeout: 180000,
});
assert.doesNotMatch(run.stdout, /already provisioned \(6\.8\.0\)/, run.stdout);
assert.match(run.stdout, /below its advisory floors/, run.stdout);
assert.match(run.stdout, /zod 4\.4\.3 < 4\.6\.6/, run.stdout);
// Offline, so the honest answer is failure — not "healthy" with the old closure.
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a recorded frida verdict is re-checked instead of trusted forever", async () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-frida-retry-"));
try {
const bundle = path.join(root, "bundle");
const setup = makePluginTree(root, () => {});
addReaStub(bundle, REA_PIN);
const reaPath = path.join(bundle, "vendor", "node_modules", "rea-agents");
// The record claims Frida is fine. Nothing on disk backs it: gating the check
// on `frida === undefined` meant this record was never revisited, so a binding
// deleted later (or one that failed once) stayed that way.
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify({
schema: 1,
paths: { node: process.execPath },
versions: { reaAgents: REA_PIN },
components: {
reaAgents: { path: reaPath, version: REA_PIN, frida: { available: true, binding: "gone.node", sha256: "0".repeat(64), devices: "FRIDA_OK 3" } },
},
}),
);
const run = await runToCompletion(process.execPath, [setup, "--only", "reaAgents", "--offline", "--root", bundle], {
encoding: "utf8",
timeout: 180000,
});
// The check ran again and said so, instead of accepting the stored verdict.
assert.match(run.stdout, /fridaBindings/, run.stdout);
assert.match(run.stdout, /not in the resolved closure/, run.stdout);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("the REA closure swap keeps the previous install until the new one answers", () => {
// Structural rather than behavioural: driving this end to end needs a real npm
// install of a deliberately broken closure, which is minutes of network per run.
// The ordering IS the guarantee — `retired` has to outlive both probes.
const source = readFileSync(SETUP_SOURCE, "utf8");
const swapAt = source.indexOf("renameSync(modules, live)");
const doctorAt = source.indexOf("const doctorAnswered");
const retireAt = source.indexOf("rmSync(retired, { recursive: true, force: true });", swapAt);
assert.ok(swapAt > 0, "the swap into the live directory must exist");
assert.ok(doctorAt > swapAt, "the new closure must be verified after the swap");
assert.ok(retireAt > doctorAt, "retired must not be deleted before --version and doctor both answer");
assert.match(source, /restorePreviousClosure/, "a failed verification must be able to put the old closure back");
});
/* ---- audit round 3: the frida gate must hash BEFORE it imports ------------ */
/**
* A frida package whose import has an observable side effect.
*
* `index.js` writes a marker and then throws, so "was this module imported?" is a
* fact on disk rather than an inference. That is what makes the ordering testable:
* 2.0.15 imported first and judged second, so the marker appeared even on the run
* that then reported the binding as rejected.
*/
function makeFridaFixture(root) {
const bundle = path.join(root, "bundle");
const setup = makePluginTree(root, () => {});
addReaStub(bundle, REA_PIN);
const fridaRoot = path.join(bundle, "vendor", "node_modules", "frida");
mkdirSync(path.join(fridaRoot, "build"), { recursive: true });
const marker = path.join(root, "frida-imported");
writeFileSync(
path.join(fridaRoot, "package.json"),
JSON.stringify({ name: "frida", version: "17.23.2", main: "index.js" }),
);
writeFileSync(
path.join(fridaRoot, "index.js"),
`require("fs").writeFileSync(${JSON.stringify(marker)}, "imported"); throw new Error("unmeasured binding");`,
);
const binding = path.join(fridaRoot, "build", "frida_binding.node");
writeFileSync(binding, "definitely not the measured binding");
writeFileSync(
path.join(bundle, "bundle.json"),
JSON.stringify({
schema: 1,
paths: { node: process.execPath },
versions: { reaAgents: REA_PIN },
components: { reaAgents: { path: path.join(bundle, "vendor", "node_modules", "rea-agents"), version: REA_PIN } },
}),
);
return { bundle, setup, binding, marker };
}
const FRIDA_PINNED_TARGET = process.platform === "win32" && process.arch === "x64";
test("a mismatched frida binding is never imported", async (t) => {
if (!FRIDA_PINNED_TARGET) {
t.skip("the only pinned frida digest is win32-x64/17.23.2, so no other target has an expectation to mismatch");
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-frida-hash-first-"));
try {
const { bundle, setup, marker } = makeFridaFixture(root);
const run = await runToCompletion(process.execPath, [setup, "--only", "reaAgents", "--offline", "--root", bundle], {
encoding: "utf8",
timeout: 180000,
});
assert.equal(existsSync(marker), false, `the mismatched module must not be imported at all:\n${run.stdout}`);
assert.match(run.stdout, /before any import/, run.stdout);
assert.match(run.stdout, /not the recorded/, run.stdout);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a mismatched frida binding is moved off the path REA loads", async (t) => {
if (!FRIDA_PINNED_TARGET) {
t.skip("the only pinned frida digest is win32-x64/17.23.2, so no other target has an expectation to mismatch");
return;
}
const root = mkdtempSync(path.join(tmpdir(), "rea-frida-quarantine-"));
try {
const { bundle, setup, binding } = makeFridaFixture(root);
await runToCompletion(process.execPath, [setup, "--only", "reaAgents", "--offline", "--root", bundle], {
encoding: "utf8",
timeout: 180000,
});
// REA reaches `build/frida_binding.node` directly, so recording a verdict is not
// enough — the file has to leave that path, and it is kept aside rather than
// destroyed so the mismatch can still be inspected afterwards.
assert.equal(existsSync(binding), false, "the distrusted binding must not remain where REA would load it");
assert.ok(existsSync(`${binding}.rejected`), "it is quarantined, not deleted");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "rea-bundle-vendor",
"version": "1.0.0",
"private": true,
"description": "Install root for the vendored REA MCP server closure. scripts/setup.mjs copies this pair into <root>/vendor/.npm-staging and runs `npm ci --ignore-scripts`, so provisioning resolves exactly the versions in package-lock.json. The `overrides` block is the audit fix: rea-agents pins zod exactly (\"4.4.3\"), which is inside GHSA-mrhx-c7xc-m3w5 (<4.6.6), and only an override can move an exact pin without editing the published tarball.",
"dependencies": {
"rea-agents": "6.8.0"
},
"overrides": {
"zod": "4.6.7",
"minimatch": "11.0.5",
"glob": "14.0.4"
}
}