mirror of
https://github.com/czcdr/dsh-plugin-rea.git
synced 2026-11-07 10:25:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b06b4ae987 | ||
|
|
b5e915a1a5 | ||
|
|
273153243a | ||
|
|
5c71bf87f7 | ||
|
|
849863841d | ||
|
|
85933748ac | ||
|
|
8e2acfdffc | ||
|
|
4d8a08266d |
No files matched your search
@@ -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
@@ -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 下限**,所以不是只改一个版本号 ——
|
||||
|
||||
@@ -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
@@ -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` 时这一步会被**直接拒绝**,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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`,不进入那条路径。
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+553
-11
@@ -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 });
|
||||
}
|
||||
});
|
||||
Generated
+2194
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user