9 Commits
Author SHA1 Message Date
czcdr 95803a6833 docs: date the 2.0.9 entry for its release 2026-10-18 15:35:07 +08:00
czcdr 7b02aa95b6 setup: strip the BOM from the records setup itself reads (2.0.9)
2.0.9 taught the shim to drop a leading U+FEFF but left setup.mjs parsing raw.
Both programs read the same mirror.json and bundle.json, so a BOM made one file
mean different things: the shim found the checkout, setup did not — and a
mirrored setup re-mirrored the previous manifest and provisioned from it.
bundle.json was read as "nothing is provisioned" for the same reason.

Both sides now use the same readJson(). verify-branch.ps1 and CI fail on a bare
JSON.parse(readFileSync(...)) in either file, and two tests cover a BOM-ed
mirror.json and a BOM-ed bundle.json; both fail without the strip (checked).
2026-10-18 15:16:40 +08:00
czcdr ebb912ace2 ci: keep the README check's inline script quote-free
The step runs its JavaScript inside `node -e "..."`, so a double quote in a
comment ended the shell string early: bash then ran the tail as commands and node
saw a truncated script ("Unexpected end of input"). The step failed on both
runners while the same comparison passed locally in verify-branch.ps1, which is
exactly the split that hides a broken gate.

The comment is quote-free now, and the extracted script runs clean from the repo
root (`readme range ok`).
2026-10-18 15:03:37 +08:00
czcdr 3f413e1989 shim: settle the re-exec only when it fails, and tolerate BOM-ed JSON (2.0.9)
Review of 2.0.9 found that the fallback still could not fire in the case it was written for, and that a hand-edited mirror.json behaved as if absent.

- The re-exec promise resolved true at the `spawn` event, which fires before the child has read anything. A later "it actually failed" could no longer be seen, so a non-zero exit with the source gone fell through to `resolve(false)`, was ignored, and the process ended 0 - silently, with the message never printed. It now resolves ONLY on failure: while the child lives this process is parked and the child's exit is the exit. Measured before/after with a checkout launcher that deletes itself and exits 3: exit 0 (no output) -> exit 78 with "could not run ... running the mirrored copy instead".
- That also covers the realistic upgrade race, which is not a spawn failure at all: node starts, finds its file replaced, and exits 1 with MODULE_NOT_FOUND. A non-zero exit whose source no longer exists now falls back instead of being passed through.
- JSON reads strip a leading U+FEFF. JSON.parse rejects it, and every caller treats a parse failure as "no record", so a BOM (which Windows editors write) silently disabled the mirror check and made bundle.json look unprovisioned. Measured with PowerShell-written fixtures.

Docs/CI: the README pin example names the version this tree publishes (#v2.0.9); both verify-branch.ps1 and validate.yml now fail when the pin does not equal package.json's version, and the README range check moved into the same step. A mutation (pin reverted to v2.0.8) fails the check and was restored.

Tests: 69 (66 pass + 3 skipped); the new one deletes the checkout launcher mid-flight and asserts the fallback. verify-branch 41 checks green, audit clean, host-compat 10 covered / 5 outside. Version 2.0.9, NOT published: no tag, no release, no npm.
2026-10-18 15:01:52 +08:00
czcdr edf3968319 shim: make the mirror fallback real, repair the mojibake, and close the doc gaps (2.0.9)
Two problems blocked publishing 2.0.8's follow-up, both found by an external review:

- reexecFromCheckout announced a fallback it did not perform. On a spawn error it printed "continuing with the mirrored copy" and returned; main() had already returned, no exit event followed, the loop drained, and the row exited 0 with no tools and no message. It now resolves a boolean: only a started child takes over the exit code, and a failure falls through to the local launch path with an honest message.
- bin/rea-shim.mjs carried 16 mojibake dashes (U+9225 + "?") from a GBK edit in 2.0.7, two of them in user-visible stderr ("first run - provisioning started", "nothing is running right now - read the log"). All restored to an em dash and the space the mis-decode had eaten.

A checkout copy no longer hops into the plugin a bundle's mirror.json names: only a mirror re-execs, a checkout (it ships cordis.patch.yml) never does. That was a real footgun - a developer running the checkout would silently test the installed plugin. The re-exec's comment now matches the code, and the mirror tests run the mirrored copy instead of the checkout.

Docs and CI: the README pin example moves from v2.0.0 (the most bug-prone release) to v2.0.8; the READMEs state the Windows limitation that a forced kill of the shim leaves the delegated REA running; docs/bundle-design.md gains the 2.0.7 launch mode and the 2.0.8 self-healing mirror, including mirror.json's shimSha256/pluginVersion/dshHome; and CI now checks that both READMEs quote engines.dsh verbatim, which previously only verify-branch.ps1 did. systemReaCandidates dedupes on the platform argument rather than process.platform.

Tests: 68 (65 pass + 3 skipped), one new for the checkout guard. verify-branch 39 checks green, audit clean, host-compat 10 covered / 5 outside. Version is 2.0.9 and NOT published: no tag, no release, no npm.
2026-10-18 14:47:44 +08:00
czcdr e3b2a064af docs: keep the declared range in the READMEs, and fix the home fallback (2.0.8)
An independent audit caught three non-blocking inconsistencies in the 2.0.8 tree:

- Both READMEs still quoted engines.dsh as ">=0.1.7-rc.2 <0.3.0" after the declaration moved to "<0.3.0-0" — the stale form the range's own negative test proves is wrong (it admits 0.3.0 prereleases). verify-branch now compares the sentence with package.json, so the drift cannot come back silently; a mutation (reverting one README) fails the check and the file was restored.
- CHANGELOG 2.0.8 still claimed 62 tests (59+3); the tree has 67 (64+3), seven of them new in this release.
- check-updates.ps1 computed $homeEarly only inside the "no REA_BUNDLE_ROOT" branch, so with the override set it stayed $null and Join-Path threw, degrading the profile section to "no profile". It is resolved unconditionally now and the profile lookup uses it.

Verified: 67 tests (64 pass + 3 skipped), audit clean, verify-branch ALL CHECKS PASSED with the new README check, and the home/profile block re-run with REA_BUNDLE_ROOT set resolves DSH_HOME and the real profile directory.
2026-10-18 14:17:07 +08:00
czcdr 3b57a9d1ac shim: find the real DSH home when the harness has scrubbed the environment (2.0.8)
The duplicate-registration check reads <DSH_HOME>/cordis.patch.yml, but an MCP child has every DSH_* variable stripped, so a mirrored copy with a custom home read ~/.dsh instead: the wrong file, and a conflict it could never report. setup.mjs now records the home it ran under in mirror.json (dshHome), and the shim prefers the environment, then that record, then ~/.dsh. The mirrored copy's own bundle root was never affected - it comes from its location - so this only changes which patch file the warning reads.

check-updates.ps1 had a second home algorithm in the same script: its profile section hardcoded $env:USERPROFILE, so on a custom DSH_HOME it reported "no profile found" while the rest of the run had resolved the home correctly. It now reuses the same rule.

Test: a bundle whose mirror.json names a home containing a foreign serverName: rea, run with DSH_HOME="" (the scrubbed shape), must report duplicateRegistration with that file; 67 tests total (64 pass + 3 skipped).
2026-10-18 14:09:16 +08:00
czcdr 5c22588001 shim: refresh a stale mirrored launcher from the installed plugin (2.0.8)
The row launches <bundle>/bin/rea-shim.mjs, a copy made by setup. A market upgrade replaces the package and runs no setup, so that copy kept executing the code it was made from: every fix in a new plugin version reached a user only if they happened to re-run setup, and an old copy keeps working, which is why nobody notices.

From 2.0.8 the copy compares itself with <mirror.json.pluginRoot>/bin/rea-shim.mjs and re-execs that file when the two differ: one hop, guarded by REA_BUNDLE_SHIM_REEXEC, and only mirror -> checkout (the checkout finds itself and does nothing). --shim-status reports mirror.{pluginRoot,mirroredAt,stale,reason}; setup records shimSha256 and pluginVersion; check-updates.ps1 reports a lagging mirror and says to re-run setup.

A version older than this one cannot repair its own upgrades - the copy has no such code - so upgrading from 2.0.0..2.0.7 still needs one setup run. The README says so in both languages.

Tests: 66 (63 pass + 3 skipped). Four new ones cover the re-exec, the guard, the stale report and the identical case.
2026-10-18 13:12:21 +08:00
czcdr 0e3aa428bf shim: stop the launch probe from recursing or leaking its tree (2.0.8)
2.0.7's probe spawned a candidate with the plain environment, so a `rea` on PATH that wraps this shim made the probe recurse: probe -> wrapper -> a fresh shim -> probe again. Measured 83+ concurrent node processes, still respawning after the parent was killed. The guard only covered delegation, not probing; the probe now runs with REA_BUNDLE_DELEGATED=1 and REA_BUNDLE_AUTO_PROVISION=0, so the nested run takes the bundle path and returns (0.2 s, no extra processes).

The timeout path leaked a grandchild: spawnSync's timeout reaps the child it started, so the following taskkill /T could not find the parent, and a hanging candidate left a live REA per attempt. The probe is now an async spawn with its own timer, which kills the tree while the parent is still alive (measured: 8.3 s, rejected ["timeout"], no leftover process).

Also: the Windows signal handlers are removed rather than kept as dead code (child.kill is TerminateProcess there, and a console Ctrl+C reaches the child directly), with the platform limitation stated where it matters; comspec() is validated and falls back to %SystemRoot%\System32\cmd.exe so a broken ComSpec no longer reads as "no REA"; and a directory named like the binary is reported as "not a file" instead of "does not exist".

Tests: 62 (59 pass + 3 skipped). Two of the new ones reproduce the recursion and the ComSpec case.
2026-10-18 13:10:03 +08:00
11 changed files with 948 additions and 116 deletions

No files matched your search

+47
View File
@@ -51,6 +51,53 @@ jobs:
# and a drifted floor drops hosts the README still lists.
run: node scripts/host-compat.mjs
- name: The READMEs quote the declared DSH range verbatim
# The same comparison verify-branch.ps1 makes locally. Without it here, a
# stale quote survives every CI signal: the JSON, host-compat and the
# audit all agree while the sentence a human reads names another ceiling
# (that is how `<0.3.0` outlived the move to `<0.3.0-0`).
run: |
node -e "
const p = require('./package.json');
const { readFileSync } = require('fs');
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
for (const doc of ['README.md', 'README.zh-CN.md']) {
const text = readFileSync(doc, 'utf8');
if (!text.includes(p.engines.dsh)) fail(doc + ' does not quote engines.dsh (' + p.engines.dsh + ')');
// The pin example is copy-pasteable: it must name the version this
// tree publishes, or the reader gets a ref that does not exist.
if (!text.includes('dsh-plugin-rea#v' + p.version)) fail(doc + ' does not pin dsh-plugin-rea#v' + p.version);
}
console.log('readme range ok');
"
- name: Both JSON readers tolerate a BOM
# verify-branch.ps1 runs the same check locally. bin/rea-shim.mjs and
# scripts/setup.mjs read the same mirror.json/bundle.json, so a strip on
# one side only makes the same file say "there is a checkout" to the shim
# and "there is none" to setup — which shipped once: setup re-mirrored the
# previous manifest. A bare JSON.parse(readFileSync(...)) is that bug.
run: |
node - <<'JS'
const { readFileSync } = require('node:fs');
const blind = [];
for (const file of ['bin/rea-shim.mjs', 'scripts/setup.mjs']) {
const text = readFileSync(file, 'utf8');
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
// readJson() itself is the strip; anything else must not parse a file raw.
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
blind.push(file + ':' + text.slice(0, m.index).split('\n').length);
}
}
if (!text.includes('replace(/^\\uFEFF/')) blind.push(file + ': no U+FEFF strip');
}
if (blind.length) {
console.error('FAIL: BOM-blind JSON read at ' + blind.join(', '));
process.exit(1);
}
console.log('bom-blind reads: none');
JS
- name: JSON files parse
run: |
node -e "
+78
View File
@@ -2,6 +2,84 @@
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
## 2.0.9 — 2026-10-18
**2.0.8 的两处发布前问题(假回退、乱码)与若干文档缺口;不改正常路径,只让失败路径说真话。**
### 修复
- **`reexecFromCheckout` 的「回退」是假话**:spawn 失败时只打印「继续用镜像副本」,函数却返回了,`main()` 已经
return —— 没有 `exit` 事件、事件循环排空、进程以 **exit 0 静默退出**,行看起来启动成功却没有任何工具。现在这个
Promise **只在没接管时才 resolve**:子进程活着时本进程等待、由子进程的退出码决定;失败则 `main()` 继续用本地副本
启动,并如实说明在回退。
- **回退覆盖了升级竞态的主形态**:`spawn` 的目标是 `process.execPath`,spawn 级失败几乎只剩资源耗尽;真正常见的是
「哈希比对通过 → node 启动 → 文件已被换掉」→ node 以 `MODULE_NOT_FOUND` 退 1。现在**退出码非 0 且源文件已不存在**
时同样回退到本地副本(窗口毫秒级,代价是一次 `stat`),不再把竞态变成一行死掉的 MCP。
- **乱码 `鈥?` 混进用户可见输出**:2.0.7 在 GBK 编辑器里碰过 `bin/rea-shim.mjs`,16 处破折号被写成 `鈥?`,其中两条是
stderr 消息(首次自动供给、上次供给失败)—— 每个用户都会看到。全部还原为 `— `(含被吃掉的空格)。
- **开发时可能测到「已安装插件」而不是自己的改动**:checkout 副本会顺着 bundle 的 `mirror.json` 转发到插件目录。
现在 checkout(带 `cordis.patch.yml`)永不转发,只有镜像副本会;注释也改成与行为一致。
- **带 BOM 的 `mirror.json` / `bundle.json` 被当成「没有记录」**:`JSON.parse` 拒绝前导 BOM,而每个调用方都把解析失败
读成「无记录」。Windows 编辑器保存的 JSON 就会带 BOM,表现是镜像自愈静默不生效、bundle 被当成未供给。`bin/rea-shim.mjs`
与 `scripts/setup.mjs` 现在都经各自的 `readJson()` 去掉 `U+FEFF` 再解析。**两个文件读的是同一份记录,所以缺一不可**:
此前只有 shim 剥了 BOM,带 BOM 的 `mirror.json` 在 setup 眼里仍是「没有 checkout」,镜像 layout 下的 setup 于是把上一版
manifest 又镜了一遍并从它供给;`bundle.json` 同理被读成未供给。`verify-branch.ps1` 与 CI 都加了守卫:这两个文件里
出现裸的 `JSON.parse(readFileSync(...))` 即失败。
### 文档 / CI
- 两份 README 的钉版本示例改为**当前版本** `#v2.0.9`(2.0.8 那一轮已从 `#v2.0.0` 提到 `#v2.0.8`,版本号一动就得跟上,
否则读者拿到的 ref 不存在);README 的「机器上已有 REA」一节补上 Windows 限制:**强制结束 shim 不会带走被委派的 REA**。
- `docs/bundle-design.md`(随包发布)补上 2.0.7 的 launch mode 与 2.0.8 的镜像自愈,含 `mirror.json` 的
`shimSha256` / `pluginVersion` / `dshHome` 三个字段与控制流图。
- CI 新增两条:README 必须逐字引用 `engines.dsh`;README 的钉版本示例必须等于 `package.json` 的 version(`verify-branch.ps1`
同步加了第二条,避免同一个漂移第三次发生)。
- `systemReaCandidates` 的去重键改用传入的 `platform` 参数(测试缝一致,生产无变化)。
### 验证
- 单测 **71 条**(68 过 + 3 平台跳过;新增「checkout 副本不转发」「文件中途消失 → 回退」「带 BOM 的 `mirror.json`」
「带 BOM 的 `bundle.json`」四条);镜像转发测试改为真的运行镜像副本。
- `verify-branch.ps1` 44 项全绿;`audit-manifest` clean;`host-compat` 10 covered / 5 outside。
## 2.0.8 — 2026-10-18
**2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
### 修复
- **重复注册检测在自定义 `DSH_HOME` 下失效**:harness 会从 MCP 子进程里抹掉所有 `DSH_*`,镜像副本因此读
`~/.dsh` —— 那是错的文件,上游写进真实 home 的第二条 `serverName: rea` 永远报不出来。`setup.mjs` 现在把运行时
的 home 记进 `mirror.json` 的 `dshHome`,shim 优先读环境、其次读这条记录。`check-updates.ps1` 的 profile 段也
改用同一条 home 规则(此前硬编码 `$env:USERPROFILE`,自定义 home 时报「找不到 profile」)。
- **镜像启动器不会自己更新**:行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 这份**副本**,而市场升级
只换包、不跑 setup —— 副本会一直跑它被复制时的代码,于是新版本里的修复对老用户静默失效(旧副本照常工作,最难发现)。
现在镜像启动时会与 `mirror.json` 记录的插件目录比对哈希,不同就转发执行插件里的那一份(一跳,带
`REA_BUNDLE_SHIM_REEXEC` 守卫,且只从镜像指向插件,不会反向)。`--shim-status` 新增 `mirror.stale`,
`check-updates.ps1` 也会报「镜像落后,重跑 setup」;`mirror.json` 增加 `shimSha256` 与 `pluginVersion` 便于追溯。
**2.0.8 之前的版本自身无法完成这一步**(副本里没有这段代码),从那些版本升级仍需手动跑一次 setup。
- **探针递归**:`probeRea` 派生候选时没带 `REA_BUNDLE_DELEGATED=1`,而防自委派的守卫只在**委派**前生效。若 PATH 上是「调用本 shim 的 wrapper」,
探针 → wrapper → 新 shim → 再探针……实测并发 node 峰值 83+、杀掉后仍自我再生。现在探针环境同时带 `REA_BUNDLE_DELEGATED=1` 与
`REA_BUNDLE_AUTO_PROVISION=0`:嵌套运行直接走 bundle 并返回,实测 0.2 秒、零额外进程。
- **超时探针泄漏孙进程**:`spawnSync` 的超时只杀它自己启动的 cmd.exe,而 `taskkill /T` 在 `spawnSync` 回收之后调用已找不到父进程 ——
每次超时留一个 REA/ping 在跑。探针改为异步 `spawn` + 自己的计时器:超时发生在父进程仍存活时,`taskkill /T /F` 能真正清掉整棵树。
实测 8.3 秒放弃、`rejected=["timeout"]`、无残留进程。
- **Windows 信号处理器是死代码**:`child.kill("SIGTERM")` 在 Windows 上是 TerminateProcess,不可捕获;控制台的 Ctrl+C 又直达子进程。
该分支已删除,并如实写明限制:**在 Windows 上强制结束 shim 不会带走被委派的 REA**(Node 没有 job object 可用)。
- **`comspec()` 不校验**:`ComSpec` 指向不存在的文件时,正常的 `.cmd` REA 被判 `spawn-failed`(`auto` 静默退回 bundle,`system` 直接 78)。
现在校验为常规文件,否则回落到 `%SystemRoot%\System32\cmd.exe`;实测 `ComSpec` 指向不存在路径仍能识别 6.9.0。
- **文案**:显式路径是目录时报「does not exist」,改为「is not a file」。
### 验证
- 单测 **67 条**(2.0.7 是 60 条,本轮 +7:wrapper 递归必须被判为 `exit 78` 且 20 秒内收敛;`ComSpec` 失效仍识别 `.cmd` REA;
镜像自愈的转发/守卫/`stale` 上报/同内容不转发;`DSH_HOME` 被抹时从 `mirror.json` 找到真实 home 并报出重复注册):
Windows 64 过 + 3 平台跳过。
- `verify-branch.ps1` 全绿(39 项),含「两份 README 必须写出 package.json 里的 `engines.dsh` 原文」;`audit-manifest` clean;
`host-compat` 10 covered / 5 correctly outside。
- 手工复现三例:wrapper 递归 0.2 秒收敛、0 残留进程;挂起候选 8.3 秒放弃且无 `PING` 残留;`ComSpec` 指向空路径仍识别系统 REA。
## 2.0.7 — 2026-10-18
**机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
+14 -5
View File
@@ -100,7 +100,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.0
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
```
> **On an Electron host (the DeepSeek Harness desktop app) the profile is owned by the application.**
@@ -127,7 +127,7 @@ by the shim itself, which provisions in the background.
### Which DSH versions
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`, and that declaration is the answer the plugin market
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
two disagree:
@@ -161,6 +161,9 @@ runs **that**, exactly as the 1.x line did, and nothing is provisioned:
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
itself forever.
- **On Windows a forced kill of the shim does not reap the delegated REA.** There is no job object here: Task Manager
or a supervisor terminating the shim leaves your `rea` running with the row's pipes (a console Ctrl+C reaches the
whole process group and does stop it). On POSIX the child is in the same group and dies with the terminal.
## Configure — nothing
@@ -230,9 +233,9 @@ Which one? Upstream's is the configuration-only route: it expects Node on REA's
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
`mcp__rea__*` surface are the same either way.
The shim reports the conflict on startup when it can see that file — a custom `DSH_HOME` is hidden
from an MCP child, so it falls back to `~/.dsh` there — and `node bin/rea-shim.mjs --shim-status`
lists it under `duplicateRegistration`.
The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
`mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
## Verify
@@ -337,6 +340,12 @@ back on the next start.
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
checkout? Overwrite the directory and reinstall.
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
- **Upgrading from `2.0.0-bundle.1`**: that release mirrored the shim and `setup.mjs` but not the manifest,
and recorded nothing about the plugin checkout, so a bundle it provisioned cannot repair itself — its
background provisioning dies on the missing manifest, invisibly, and its own lock suppresses retries for
+12 -4
View File
@@ -94,7 +94,7 @@ profile 安装插件:
```
dsh plugin --profile <profile> add dsh-plugin-rea
# 或钉住这个版本,而不是跟着 npm latest 走:
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
```
> **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
@@ -118,7 +118,7 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
### 支持哪些 DSH 版本
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`,这就是插件市场读的那句话:有它,卡片显示
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
@@ -149,6 +149,9 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
- **Windows 上强制结束 shim 不会带走被委派的 REA**:这里没有 job object,任务管理器或 supervisor 终止 shim 时,
你自己的 `rea` 会继续占着这一行的管道(控制台 Ctrl+C 会发给整个进程组,那种情况下会一起停)。POSIX 上子进程
在同一进程组,随终端一起退出。
## 配置 —— 不需要
@@ -216,8 +219,8 @@ duplicate server name 报错并加载失败,最终留下哪一行取决于加
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
`mcp__rea__*` 工具面相同。
shim 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
退回 `~/.dsh`;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
## 验证
@@ -314,6 +317,11 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
工具就回来了。
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
+331 -95
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* rea-shim 鈥?the process the DSH MCP row launches.
* rea-shim — the process the DSH MCP row launches.
*
* Why this exists: the published `cordis.patch.yml` must carry no machine path,
* yet REA needs absolute paths for every dependency. The shim is the seam: it
@@ -10,11 +10,12 @@
* It downloads nothing, writes nothing, and never touches PATH.
*
* Exit codes: 0 child exited normally * child's own status
* 78 EX_CONFIG bundle not provisioned (or unusable) 鈥?the row has
* 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
* `failOnStartupError: false`, so the harness stays up
* and the message is the operator's remediation.
*/
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
@@ -25,6 +26,19 @@ const EX_CONFIG = 78;
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = path.resolve(HERE, "..");
/**
* JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
*
* `JSON.parse` rejects a leading BOM, and every caller here reads a parse
* failure as "no record": a BOM would silently downgrade mirror.json to "no
* plugin checkout" and bundle.json to "not provisioned". Measured while testing
* this file — a hand-edited mirror.json written by Windows PowerShell behaved
* exactly that way.
*/
function readJson(file) {
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
}
/** DSH home, by the same rule setup.mjs and the published patch use. */
function dshHome() {
const fromEnv = process.env.DSH_HOME;
@@ -32,6 +46,36 @@ function dshHome() {
return path.join(homedir(), ".dsh");
}
/**
* The DSH home recorded by setup, or null.
*
* Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
* child, so with a custom home the environment cannot answer. Read once per
* process — there is one bundle root per process and this is a startup query.
*/
let recordedDshHomeCache = null;
function recordedDshHome(root) {
if (recordedDshHomeCache !== null) return recordedDshHomeCache;
let value = null;
try {
const record = readJson(path.join(root, "mirror.json"));
if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
value = path.resolve(record.dshHome);
}
} catch {
value = null;
}
recordedDshHomeCache = value;
return value;
}
/** The home to look in when the environment has been scrubbed. */
function dshHomeFor(root) {
const fromEnv = process.env.DSH_HOME;
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
}
/**
* Where setup writes the machine-local truth.
*
@@ -43,11 +87,11 @@ function dshHome() {
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
* would send it to `~/.dsh`, where it would report "not provisioned" for a
* bundle sitting right next to it 鈥?and start a second ~800 MB run there.
* bundle sitting right next to it — and start a second ~800 MB run there.
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
* directory is the plugin, not the bundle, so resolving to itself would report
* "not provisioned" on a fully provisioned machine 鈥?and a bare
* "not provisioned" on a fully provisioned machine — and a bare
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
*
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
@@ -80,11 +124,11 @@ function usableSetup(dir) {
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
* previous manifest, so it would provision the previous versions forever. setup
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
* usable, and fall back to the local copy 鈥?which is also all a first run has.
* usable, and fall back to the local copy — which is also all a first run has.
*/
function provisioningScript(root) {
try {
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
const record = readJson(path.join(root, "mirror.json"));
const recorded = usableSetup(record && record.pluginRoot);
if (recorded) return recorded;
} catch {
@@ -104,13 +148,16 @@ function provisioningScript(root) {
*
* The parse is deliberately small: the two files it cares about are block
* mappings with one id/serverName per row, and anything it cannot attribute is
* ignored 鈥?a missed hint is acceptable, a false alarm is not. The mirrored shim
* cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
* this falls back to `~/.dsh` like the rest of the bundle; the row has the same
* limitation.
* ignored — a missed hint is acceptable, a false alarm is not.
*
* Finding the file is the part that needed care: the harness strips every DSH_*
* variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
* read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
* `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
* what this uses when the environment has nothing to say.
*/
function foreignReaRegistration() {
const patch = path.join(dshHome(), "cordis.patch.yml");
function foreignReaRegistration(root) {
const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
let text;
try {
text = readFileSync(patch, "utf8");
@@ -133,7 +180,7 @@ function foreignReaRegistration() {
/**
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
* `state: "running"` is written when a run is started (setup's own pid once it
* exists), and the failing run rewrites it as `state: "failed"` 鈥?it is kept on
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
* plain-number lock is still honoured as "running".
*/
@@ -171,7 +218,7 @@ function pidAlive(pid) {
* a killed attempt is simply continued on the next start.
*
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
* Returns { started, reason, logPath } 鈥?the caller words its message from
* Returns { started, reason, logPath } — the caller words its message from
* `reason`, because "provisioning started in the background" was also printed
* for a lock left behind by a run that had already failed, and for one whose
* process was long gone.
@@ -208,7 +255,7 @@ function maybeAutoProvision(root) {
// 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
// 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.
@@ -265,7 +312,7 @@ function fail(message, detail) {
if (detail) lines.push("", " detail: " + detail);
// Point at the real checkout when the record allows it: running the mirrored
// copy is fine, but it can only ever provision the manifest it was mirrored
// with 鈥?and a bundle mirrored before the manifest travelled with it has no
// with — and a bundle mirrored before the manifest travelled with it has no
// usable copy at all, so the honest answer is then "run the plugin's own script".
const root = bundleRoot();
const setupScript = provisioningScript(root);
@@ -299,7 +346,7 @@ function readBundleConfig(root) {
if (!existsSync(file)) return { ok: false, code: "missing", file };
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf8"));
parsed = readJson(file);
} catch (error) {
return { ok: false, code: "malformed", file, detail: String(error && error.message) };
}
@@ -316,7 +363,7 @@ function resolveRea(config, root) {
// The fallback has to match where setup actually installs the package:
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
// fallback without that segment named a directory that never exists. It only
// fires when an older bundle.json has no paths.reaEntry 鈥?which is exactly when
// fires when an older bundle.json has no paths.reaEntry — which is exactly when
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
const reaEntry = paths.reaEntry
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
@@ -373,7 +420,7 @@ function childEnvironment(config) {
* the machine already has one, the honest thing to do is run it.
*
* The 1.x line was exactly that: `rea mcp` off PATH, user-managed Node and
* Ghidra. So "use the system install" is not a new code path invented here 鈥?it
* Ghidra. So "use the system install" is not a new code path invented here — it
* is the 1.x contract, reachable from the 2.x row. `auto` (the default) prefers
* the system install and falls back to the provisioned bundle; `bundle` pins the
* old behaviour; `system` insists (and fails loudly instead of provisioning
@@ -432,7 +479,7 @@ function systemReaNames(platform = process.platform) {
/**
* Every path worth probing, most explicit first.
*
* `REA_BUNDLE_SYSTEM_REA` names one binary outright 鈥?that is both the test
* `REA_BUNDLE_SYSTEM_REA` names one binary outright — that is both the test
* seam and a real operator need (an install that is not on the harness's PATH,
* which on Desktop is not the user's shell PATH).
*/
@@ -454,7 +501,9 @@ function systemReaCandidates({ env = process.env, platform = process.platform }
}
const seen = new Set();
return candidates.filter((candidate) => {
const key = process.platform === "win32" ? candidate.toLowerCase() : candidate;
// Keyed on the platform being RESOLVED, not on the one this process happens
// to run: the tests pass `platform` to exercise both shapes on one machine.
const key = platform === "win32" ? candidate.toLowerCase() : candidate;
if (seen.has(key)) return false;
seen.add(key);
return true;
@@ -516,7 +565,11 @@ function cmdQuote(value) {
*/
function comspec() {
const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv;
// A ComSpec that names nothing is a real (if unusual) state, and trusting it
// would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
// having no REA because its shell pointer is broken. Fall back to the path
// Windows itself would use.
if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
return path.join(systemRoot, "System32", "cmd.exe");
}
@@ -527,50 +580,77 @@ function comspec() {
* A file existing on PATH is not the same as a working install: npx leftovers,
* half-removed globals and a shim whose Node is gone all sit on PATH and all
* fail at once. One bounded `--version` separates them, and a slow or hanging
* answer counts as "not usable" 鈥?the fallback (the bundle) still works, while
* answer counts as "not usable" — the fallback (the bundle) still works, while
* delegating to a broken binary would take the tools down with it.
*
* Two things here are not incidental:
*
* * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
* wrapper around this shim would otherwise be probed by a nested shim that
* probes the wrapper — measured: a process chain that keeps respawning
* itself. The flag makes the nested run take the bundle path and return.
* * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
* child it started, and its timeout kills only that child: for a `.cmd` shim
* that means cmd.exe dies while the REA inside it keeps running and holds
* the pipes. Killing the TREE is only possible while the parent is alive,
* which means owning the wait ourselves.
*/
function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd` exists,
// fails to spawn, and would be reported as a broken install instead of as a
// name collision. Only a regular file is a candidate.
try {
if (!statSync(file).isFile()) return { ok: false, reason: "not a file" };
} catch {
return { ok: false, reason: "missing" };
}
const command = [file, "--version"];
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
// `/s` strips the first and last quote, so the whole line is quoted; and the
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
// answers "exit 1", which reads as "no REA on this machine".
const result = platform === "win32" && /\.(cmd|bat)$/i.test(file)
? spawnSync(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
encoding: "utf8",
timeout,
windowsHide: true,
windowsVerbatimArguments: true,
})
: spawnSync(command[0], command.slice(1), {
encoding: "utf8",
timeout,
windowsHide: true,
});
if (result.error) {
// A timed-out probe leaves its grandchild running: cmd.exe is killed, the
// REA inside it is not, and it keeps the inherited pipes open. Clean the tree
// before answering, or a hung candidate leaves a process behind per start.
if (result.error.code === "ETIMEDOUT") killTree(result.pid, platform);
return { ok: false, reason: result.error.code === "ETIMEDOUT" ? "timeout" : "spawn-failed" };
}
if (result.status !== 0) return { ok: false, reason: `exit ${String(result.status)}` };
const text = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(text)?.[1] ?? null;
// A REA install prints its version. Anything else (a shim that swallowed the
// flag, an unrelated program that shares the name) is not a usable CLI.
if (version === null) return { ok: false, reason: "no version in --version output" };
return { ok: true, version };
return new Promise((resolve) => {
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
// exists, fails to spawn, and would be reported as a broken install instead
// of as a name collision. Only a regular file is a candidate.
if (!isFileSync(file)) {
resolve({ ok: false, reason: "not a file" });
return;
}
const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
// `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
// turns out to be a wrapper around THIS shim, the nested run must not start
// an 800 MB provisioning job just because a probe asked it for a version.
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
// `/s` strips the first and last quote, so the whole line is quoted; and the
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
// answers "exit 1", which reads as "no REA on this machine".
const child = useShell
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
windowsVerbatimArguments: true,
})
: spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
let output = "";
let settled = false;
let timer = null;
const finish = (result) => {
if (settled) return;
settled = true;
if (timer !== null) clearTimeout(timer);
resolve(result);
};
timer = setTimeout(() => {
killTree(child.pid, platform);
finish({ ok: false, reason: "timeout" });
}, timeout);
child.stdout.on("data", (chunk) => { output += String(chunk); });
child.stderr.on("data", (chunk) => { output += String(chunk); });
child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
child.on("close", (code) => {
if (code !== 0) {
finish({ ok: false, reason: `exit ${String(code)}` });
return;
}
// A REA install prints its version. Anything else (a shim that swallowed
// the flag, an unrelated program that shares the name) is not a usable CLI.
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
finish(version === null
? { ok: false, reason: "no version in --version output" }
: { ok: true, version });
});
});
}
/**
@@ -580,7 +660,7 @@ function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_M
* provisioner installed is the `bundle` mode, and delegating to it would be a
* loop through a path the bundle already covers.
*/
function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
const rejected = [];
let profiled = 0;
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
@@ -609,7 +689,7 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
continue;
}
profiled += 1;
const probe = probeRea(candidate, { platform });
const probe = await probeRea(candidate, { platform });
if (probe.ok) return { found: true, file: candidate, version: probe.version };
if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
}
@@ -624,7 +704,9 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
if (explicitPath !== null && explicitRejection !== null) {
reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
} else if (explicitPath !== null && !isFileSync(explicitPath)) {
reason = `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
reason = existsSync(explicitPath)
? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
: `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
} else {
reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
}
@@ -657,10 +739,10 @@ function compareDotted(a, b) {
* the one this bundle provisioned would silently take the tool count back
* (6.4.0 ships 170 tools, 6.5.0 ships 207), so the newer of the two wins and
* the older is only used when asked for by name. `bundle` and `system` are
* honoured literally 鈥?`system` fails loudly rather than provisioning 800 MB
* honoured literally — `system` fails loudly rather than provisioning 800 MB
* behind an operator who asked for their own REA.
*/
function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
const preference = launchPreference(env);
if (preference.value === "bundle") {
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
@@ -678,7 +760,7 @@ function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false,
reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
};
}
const system = detectSystemRea({ env, platform, root });
const system = await detectSystemRea({ env, platform, root });
if (system.found) {
const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
const pinned = preference.value === "system";
@@ -747,25 +829,14 @@ function launchSystemRea(system, argv) {
windowsVerbatimArguments: true,
})
: spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
// Signal handling, per platform, because the two are not the same problem.
//
// POSIX: stdio is inherited and the child is in this process's group, so a
// terminal's Ctrl+C already reaches it. Forwarding again would deliver the
// signal twice.
//
// Windows: there is no process group to deliver to, and `child.kill("SIGINT")`
// is TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives and
// keeps the inherited pipes open, which looks exactly like "the tools still
// work after I stopped the harness". `taskkill /T` is what actually ends the
// tree, so that is what a signal asks for here.
if (process.platform === "win32") {
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
killTree(child.pid, "win32");
process.exit(signal === "SIGINT" ? 130 : 143);
});
}
}
// No signal forwarding on Windows, deliberately. `child.kill()` there is
// TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
// holding the inherited pipes — and a console Ctrl+C is delivered to the whole
// process group anyway, so the child already has it. A hard kill of this shim
// (what a supervisor or Task Manager does) runs no JavaScript at all, so the
// wrapper must not pretend otherwise: on Windows the delegated REA outlives a
// forcibly killed shim. On POSIX the child is in this process's group and gets
// the terminal's signal directly; `process.exit` below then ends the shim.
child.on("error", (error) => {
process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
process.exit(EX_CONFIG);
@@ -780,11 +851,145 @@ function launchSystemRea(system, argv) {
return null;
}
/**
* The mirror problem.
*
* The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
* `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
* no setup, so the copy keeps executing the code it was made from: every fix in
* a new plugin version reaches a user only if they happen to re-run setup. That
* is invisible (the old copy keeps working) and it is exactly the wrong
* direction for a safety fix.
*
* The repair has to live in the copy, because the copy is what runs. So the
* copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
* `mirror.json` by setup, and re-execs that file when the two differ. One hop,
* guarded by an environment flag, and only from mirror to checkout — the
* checkout copy has no `mirror.json` and would find itself anyway.
*
* A plugin version older than 2.0.8 cannot do this for its own upgrades: there
* is nothing in it to run. `--shim-status` reports `mirror.stale` so the
* condition is at least visible, and `check-updates.ps1` says to re-run setup.
*/
function sha256(file) {
return createHash("sha256").update(readFileSync(file)).digest("hex");
}
/** Where the installed plugin lives according to setup's record, or null. */
function pluginCheckout(root) {
try {
const record = readJson(path.join(root, "mirror.json"));
const dir = record?.pluginRoot;
if (typeof dir !== "string" || dir.trim() === "") return null;
const shim = path.join(dir, "bin", "rea-shim.mjs");
return isFileSync(shim) ? shim : null;
} catch {
return null;
}
}
/**
* Whether this copy is behind the plugin it was mirrored from.
*
* `self` is this file, `source` the checkout's. A checkout copy (it ships
* `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
* whose recorded checkout is gone, is not re-pointed anywhere.
*/
function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
let record = null;
try {
record = readJson(path.join(root, "mirror.json"));
} catch {
return null;
}
const source = pluginCheckout(root);
let stale = false;
let reason = "no usable plugin checkout in mirror.json";
if (source !== null) {
if (path.resolve(source) === path.resolve(self)) {
reason = "this copy is the plugin checkout";
} else {
try {
stale = sha256(source) !== sha256(self);
reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
} catch {
reason = "could not compare the two launchers";
}
}
}
return {
pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
stale,
reason,
};
}
/**
* Run the checkout's launcher instead of this copy, once.
*
* The promise resolves ONLY when the re-exec did not take over, and the caller
* must then continue with its own launch path. While the child is alive the
* promise stays pending and the child's own exit becomes this process's exit —
* settling it at the `spawn` event was wrong: the event fires before the child
* has done anything, so a later "it actually failed" could no longer be seen, and
* the two handlers disagreed about who owns the exit code. That mistake made the
* file-vanishes-mid-flight case exit 0 silently.
*
* The first version of this printed "continuing with the mirrored copy" on
* `error` and returned: nothing continued, no `exit` event followed a failed
* spawn, the loop drained, and the row saw exit 0 with no tools and no message.
* A fallback that is announced but not performed is worse than none, because it
* hides the failure it was written for.
*/
function reexecFromCheckout(source, argv) {
const child = spawn(process.execPath, [source, ...argv], {
stdio: "inherit",
env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
windowsHide: true,
});
return new Promise((resolve) => {
let started = false;
child.once("spawn", () => {
started = true;
});
child.once("error", () => {
if (started) return;
resolve(false);
});
child.once("exit", (code, signal) => {
if (!started) {
// Exit without a spawn event: the child never ran. Let the caller fall
// back rather than exiting 0 on its behalf.
resolve(false);
return;
}
if (signal) {
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
process.exit(128 + (signals[signal] ?? 0));
}
// A non-zero exit is normally the child's own answer and must be passed
// through untouched. One case is not the child's answer: an upgrade in
// flight replaced the file BETWEEN the check and node reading it, so node
// starts fine and dies with MODULE_NOT_FOUND. Passing that on turns a
// millisecond race into a dead row; if the source is gone by now, fall back
// to this copy instead. The window is small, the cost of covering it is one
// stat, and the alternative is a row that reports failure for a file that
// still exists a directory up.
if ((code ?? 0) !== 0 && !isFileSync(source)) {
resolve(false);
return;
}
process.exit(code ?? 0);
});
});
}
/**
* Usage text, also what a bare invocation prints.
*
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
* shim can only forward the arguments a user typed 鈥?it cannot append a flag. So a
* shim can only forward the arguments a user typed — it cannot append a flag. So a
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
* through to the launch path: it started REA, and on an unprovisioned machine it
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
@@ -808,7 +1013,7 @@ function usage() {
].join("\n");
}
function main() {
async function main() {
const argv = process.argv.slice(2);
if (argv.includes("--shim-help") || argv.length === 0) {
@@ -817,12 +1022,36 @@ function main() {
}
const root = bundleRoot();
// Self-healing before anything else: a mirrored copy that predates the plugin
// it was made from would otherwise keep running that older code forever, and a
// market upgrade never re-runs setup. One hop, guarded, and never for the
// status query — there the staleness is reported instead.
const mirror = describeMirror(root);
// A CHECKOUT copy is the source, not a mirror: hopping from it into whatever
// the bundle's record names would silently run a different plugin — a developer
// testing a local edit would be testing the installed one instead. The
// discriminator is the one bundleRoot() already uses: only the checkout ships
// cordis.patch.yml.
const isCheckout = existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"));
if (!isCheckout && mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
const source = pluginCheckout(root);
if (source !== null) {
// It resolves only when the re-exec did not take over; while the child
// runs, this process is parked and the child's exit is the exit.
await reexecFromCheckout(source, argv);
// The re-exec did not take over: a spawn failure, a child that exited
// before its spawn event, or one that started and found its own file gone
// (see the exit handler). Say so and run this copy for real — the point of
// the fallback.
process.stderr.write(`rea-shim: could not run ${source}; running the mirrored copy instead.\n`);
}
}
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 = resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
if (launch.invalid !== null) {
process.stderr.write(
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
@@ -831,7 +1060,7 @@ function main() {
// Upstream can add a second row for the same serverName; the shim cannot stop
// DSH from loading both rows, but saying so is better than doubled tools with
// no stated cause. The status report carries the same fact for diagnostics.
const duplicate = foreignReaRegistration();
const duplicate = foreignReaRegistration(root);
if (duplicate && !argv.includes("--shim-status")) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
@@ -860,6 +1089,10 @@ function main() {
},
};
if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
// A mirrored copy that lags the plugin keeps working, which is exactly why
// nobody notices it: the row runs old code until the mirror is refreshed.
// Reported here so `--shim-status` answers "am I running what I installed?".
if (mirror !== null) report.mirror = mirror;
if (duplicate) report.duplicateRegistration = duplicate;
if (loaded.ok) {
report.paths = loaded.config.paths ?? {};
@@ -909,12 +1142,12 @@ function main() {
const provisioning = maybeAutoProvision(root);
if (provisioning.started) {
process.stderr.write(
`\n dsh-plugin-rea (bundle): first run 鈥?provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "last-run-failed") {
process.stderr.write(
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
` Nothing is running right now 鈥?read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
);
} else if (provisioning.reason === "in-flight") {
// A live run owns the lock. Saying nothing here is what made a wedged
@@ -972,5 +1205,8 @@ function main() {
return null;
}
const status = main();
// The launch decision awaits a probe, so `main` is async; the status it returns
// is this process's exit code, and a launch path resolves to null while the
// child (already wired to `process.exit`) keeps the loop alive.
const status = await main();
if (typeof status === "number") process.exit(status);
+33 -5
View File
@@ -73,9 +73,13 @@ function Test-NodeEngines([string]$node, [string]$engines) {
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
$bundleRootEarly = $env:REA_BUNDLE_ROOT
# Resolve the home unconditionally: the profile section below needs it whether or
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
# "no profile" on exactly the machines that use the override.
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
if (-not $bundleRootEarly) {
$homeEarly = $env:DSH_HOME
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
}
$bundleState = $null
@@ -106,8 +110,12 @@ $reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaP
if (-not $ProfileDir) {
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
else {
$profiles = @(Get-ChildItem (Join-Path $env:USERPROFILE '.dsh\profiles') -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $env:USERPROFILE '.dsh\profiles\desktop' }
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
# so on a custom DSH_HOME the profile section reported "no profile found"
# while the rest of the run had already resolved that home correctly.
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
}
}
@@ -340,7 +348,27 @@ if (-not (Test-Path $manifestPath)) {
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
}
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
if (Test-Path $mirrored) { Ok 'MCP 行指向的 shim 已就位' }
if (Test-Path $mirrored) {
Ok 'MCP 行指向的 shim 已就位'
# The row launches this COPY, so a plugin upgrade that does not re-run
# setup leaves the old launcher in place — visible only as "the new
# version behaves like the old one". Compare it with the checkout the
# mirror record names.
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
if (Test-Path $mirrorRecord) {
try {
$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 让它换成新版本" }
} else {
Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
}
} catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
}
}
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
+24 -1
View File
@@ -83,7 +83,10 @@ through `failOnStartupError: false` without breaking the harness.
```
dsh starts
└─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
├─ bundle.json present & components verify → exec REA (normal path)
├─ mirrored copy differs from the installed plugin → re-exec that copy, once
├─ a working `rea` on PATH is at least as new as the bundle
│ → exec it (the 1.x contract)
├─ bundle.json present & components verify → exec the provisioned REA
├─ missing, auto-provision allowed → start setup detached, print
│ remediation, exit non-zero
└─ missing, auto-provision refused → print exact command, exit
@@ -129,6 +132,26 @@ Two notes on stages that exist because their absence was a bug:
harness's own dialect, `args[0]` became `{"[object Object]": "..."}` and the
row could not launch at all. Any future expression with a ternary needs the
quotes; `audit-manifest.mjs` rejects the bare form.
- **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
machine that already has one should not be provisioned. Before touching bundle
state the shim asks whether `rea` is on PATH and answers `--version`; if it
does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
the row execs that install exactly as the 1.x row did. The probe is asynchronous
and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
`REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
unorderable pair (a prerelease on either side) keeps the bundle.
- **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
runs no setup, so without this the copy keeps executing the code it was made
from — invisibly, because it still works. The copy therefore compares its own
hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
the last one because the harness strips `DSH_*` from an MCP child, so the
duplicate-registration check would otherwise read `~/.dsh` on a machine with a
custom home. `--shim-status` reports `mirror.stale` instead of hopping.
- **3 (fetch).** Route order is deliberate and environment-dependent: direct
first, then the system proxy, and on Windows the .NET route ahead of both
because it follows the GitHub release-asset redirect that the Node routes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-plugin-rea",
"version": "2.0.7",
"version": "2.0.9",
"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",
+43 -3
View File
@@ -954,6 +954,21 @@ function reuseExisting(name, root) {
/* ------------------------------------------------------------------- manifest */
/**
* JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
*
* `JSON.parse` rejects a leading BOM, and every caller in this file treats a
* parse failure as "no record". A BOM-ed mirror.json therefore meant "this is
* not a mirrored run", so setup went on to re-mirror the previous manifest;
* a BOM-ed bundle.json meant "nothing is provisioned". `bin/rea-shim.mjs`
* carries the same helper — the two must agree, or the same file means two
* different things depending on which one read it (a hand-edited record with
* PowerShell's BOM is exactly the case that produced this).
*/
function readJson(file) {
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
}
/**
* Where this run's plugin files actually live.
*
@@ -973,7 +988,7 @@ function sourceRoot() {
const self = path.resolve(PLUGIN_ROOT);
if (!samePath(self, options.root)) return self;
try {
const record = JSON.parse(readFileSync(path.join(options.root, "mirror.json"), "utf8"));
const record = readJson(path.join(options.root, "mirror.json"));
const recorded = record?.pluginRoot;
if (typeof recorded === "string" && recorded.trim() !== "") {
const candidate = path.resolve(recorded);
@@ -987,7 +1002,7 @@ function sourceRoot() {
function loadManifest() {
const file = path.join(sourceRoot(), "manifest", "bundle.manifest.json");
const parsed = JSON.parse(readFileSync(file, "utf8"));
const parsed = readJson(file);
return parsed;
}
@@ -1002,7 +1017,7 @@ function loadManifest() {
function readBundleJson(file) {
if (!existsSync(file)) return null;
try {
const parsed = JSON.parse(readFileSync(file, "utf8"));
const parsed = readJson(file);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch (error) {
log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
@@ -1636,6 +1651,31 @@ function mirrorLauncher(context) {
*/
function writeMirrorRecord(context, source) {
const record = { schema: 1, pluginRoot: source, mirroredAt: new Date().toISOString() };
// Provenance for the self-healing check: the mirrored shim compares itself
// with `<pluginRoot>/bin/rea-shim.mjs` and re-execs that one when the plugin
// has moved on. These two fields are what `--shim-status` shows when the
// comparison cannot be made (missing checkout, unreadable file).
try {
record.shimSha256 = createHash("sha256")
.update(readFileSync(path.join(context.root, "bin", "rea-shim.mjs")))
.digest("hex");
} catch {
/* the copy is missing or unreadable; the shim reports the comparison itself */
}
try {
record.pluginVersion = readJson(path.join(source, "package.json")).version ?? null;
} catch {
record.pluginVersion = null;
}
// The MCP child the harness starts has every DSH_* variable stripped, so the
// mirrored shim cannot re-derive a CUSTOM home from its environment — it would
// read `~/.dsh` and miss a duplicate `serverName: rea` living in the real one.
// Record the home setup ran under; the shim prefers the environment, then this.
try {
record.dshHome = dshHome();
} catch {
record.dshHome = null;
}
try {
mkdirSync(context.root, { recursive: true });
writeFileSync(path.join(context.root, "mirror.json"), JSON.stringify(record, null, 2) + "\n");
+327 -2
View File
@@ -1313,13 +1313,13 @@ function writeFakeRea(dir, { version = "6.9.0", exitForVersion = 0, runExit = 0,
return file;
}
function runShimIsolated(env, args) {
function runShimIsolated(env, args, shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs")) {
const merged = { ...process.env, ...env };
// Windows keeps PATH and Path as separate variables (both are "in" the
// environment), and a child that reads Path while the test set PATH would see
// the machine's real search path instead of the fixture's.
if (process.platform === "win32" && Object.prototype.hasOwnProperty.call(env, "PATH")) delete merged.Path;
return spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), ...args], {
return spawnSync(process.execPath, [shim, ...args], {
encoding: "utf8",
timeout: 60000,
env: merged,
@@ -1564,3 +1564,328 @@ test("--shim-print-env reports the system launch instead of a child environment"
f.clean();
}
});
test("a candidate that wraps this shim is rejected instead of recursing", () => {
// The probe asks the candidate for `--version`; a wrapper answers by running
// this very shim, which would probe the wrapper again. The probe therefore runs
// with REA_BUNDLE_DELEGATED=1, so the nested run takes the bundle path and
// returns. Without that, one wrapper on PATH spawns a process chain that keeps
// respawning after the parent is killed.
const f = launchFixture();
mkdirSync(f.bin, { recursive: true });
const shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
const wrapper = path.join(f.bin, process.platform === "win32" ? "rea.cmd" : "rea");
if (process.platform === "win32") {
writeFileSync(wrapper, `@echo off\r\n"${process.execPath}" "${shim}" %*\r\n`);
} else {
writeFileSync(wrapper, `#!/bin/sh\nexec "${process.execPath}" "${shim}" "$@"\n`);
chmodSync(wrapper, 0o755);
}
try {
const started = Date.now();
const run = runShimIsolated(
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: wrapper, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
["--shim-status"],
);
const elapsed = Date.now() - started;
const report = JSON.parse(run.stdout);
assert.equal(report.launch.mode, "bundle");
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["exit 78"]);
assert.ok(elapsed < 20000, `the probe must not recurse; it took ${elapsed} ms`);
} finally {
f.clean();
}
});
test("a ComSpec that points at nothing falls back to the real shell", () => {
// Trusting ComSpec blindly would judge every .cmd REA "spawn-failed" — i.e.
// report the machine as having no REA because its shell pointer is broken.
const f = launchFixture();
mkdirSync(f.bin, { recursive: true });
const rea = writeFakeRea(f.bin, { version: "6.9.0" });
try {
const run = runShimIsolated(
{
PATH: f.emptyPath,
ComSpec: path.join(f.root, "no-such-cmd.exe"),
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);
if (process.platform === "win32") {
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
assert.equal(report.launch.systemRea.version, "6.9.0");
} else {
// POSIX never consults ComSpec; the assertion above would be incidental.
assert.equal(report.launch.mode, "system");
}
} finally {
f.clean();
}
});
/* --------------------------------------------------- the mirrored launcher */
// The row launches `<bundle>/bin/rea-shim.mjs`, a COPY. A market upgrade replaces
// the package and runs no setup, so without this the copy keeps running the code
// it was made from — every fix silently stops at the old mirror. These tests pin
// the repair: the copy re-execs the plugin's launcher when they differ, once.
function mirrorFixture() {
const root = mkdtempSync(path.join(tmpdir(), "rea-mirror-"));
const bundle = path.join(root, "bundle");
const checkout = path.join(root, "checkout");
mkdirSync(path.join(bundle, "bin"), { recursive: true });
mkdirSync(path.join(checkout, "bin"), { recursive: true });
copyFileSync(path.join(REPO_ROOT, "bin", "rea-shim.mjs"), path.join(bundle, "bin", "rea-shim.mjs"));
writeFileSync(path.join(bundle, "mirror.json"), JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }));
return { root, bundle, checkout, clean() { rmSync(root, { recursive: true, force: true }); } };
}
function checkoutStub(checkout, marker) {
const file = path.join(checkout, "bin", "rea-shim.mjs");
writeFileSync(
file,
'import { writeFileSync } from "node:fs";\n' +
`writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
"process.exit(7);\n",
);
return file;
}
test("a mirrored launcher re-execs the plugin's launcher when they differ", () => {
const f = mirrorFixture();
const marker = path.join(f.root, "checkout-ran");
checkoutStub(f.checkout, marker);
try {
// The mirrored copy is what the row launches, and the checkout guard means a
// checkout shim refuses to hop at all — so the fixture must be exercised as
// the mirror it is meant to be.
const run = runShimIsolated(
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
["mcp"],
path.join(f.bundle, "bin", "rea-shim.mjs"),
);
assert.equal(run.status, 7, `expected the checkout's status, got ${run.status}: ${run.stderr}`);
assert.equal(existsSync(marker), true, "the checkout launcher must have run");
} finally {
f.clean();
}
});
test("the re-exec happens once, and a pinned guard stops it", () => {
const f = mirrorFixture();
const marker = path.join(f.root, "checkout-ran");
checkoutStub(f.checkout, marker);
try {
const run = runShimIsolated(
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", REA_BUNDLE_SHIM_REEXEC: "1" },
["mcp"],
path.join(f.bundle, "bin", "rea-shim.mjs"),
);
// The guard is set, so the mirrored copy runs its own logic: bundle pinned and
// nothing provisioned means EX_CONFIG, and the checkout never starts.
assert.equal(run.status, 78, run.stderr);
assert.equal(existsSync(marker), false, "the guard must prevent a second hop");
} finally {
f.clean();
}
});
test("--shim-status reports a stale mirror instead of re-execing it", () => {
const f = mirrorFixture();
const marker = path.join(f.root, "checkout-ran");
checkoutStub(f.checkout, marker);
try {
const run = runShimIsolated(
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
["--shim-status"],
path.join(f.bundle, "bin", "rea-shim.mjs"),
);
const report = JSON.parse(run.stdout);
assert.equal(report.mirror.stale, true, JSON.stringify(report.mirror));
assert.match(report.mirror.reason, /different launcher/);
assert.equal(existsSync(marker), false, "status is a query; it must not start anything");
} finally {
f.clean();
}
});
test("a checkout shim does not hop into the installed plugin", () => {
const f = mirrorFixture();
const marker = path.join(f.root, "checkout-ran");
checkoutStub(f.checkout, marker);
try {
// The repository's own shim is a CHECKOUT (it ships cordis.patch.yml). A
// developer running it must keep testing the edit in front of them, not a
// different plugin that the bundle's record happens to name.
const run = runShimIsolated(
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
["mcp"],
);
assert.equal(run.status, 78, run.stderr);
assert.equal(existsSync(marker), false, "a checkout must never hop into another copy");
} finally {
f.clean();
}
});
test("a re-exec whose file vanishes mid-flight falls back to the mirrored copy", () => {
// The upgrade race the spawn handshake cannot see: the file existed when the
// mirror compared hashes, node started, and the file was replaced before node
// read it — node exits 1 with MODULE_NOT_FOUND. Passing that through turns a
// millisecond race into a dead row, so a non-zero exit with the source gone
// must fall back instead.
const f = mirrorFixture();
const marker = path.join(f.root, "checkout-ran");
const file = path.join(f.checkout, "bin", "rea-shim.mjs");
writeFileSync(
file,
'import { rmSync, writeFileSync } from "node:fs";\n' +
`writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
`rmSync(${JSON.stringify(file)});\n` +
"process.exit(3);\n",
);
try {
const run = runShimIsolated(
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
["mcp"],
path.join(f.bundle, "bin", "rea-shim.mjs"),
);
assert.equal(existsSync(marker), true, "the checkout launcher did run");
assert.equal(run.status, 78, `expected the local copy's status, got ${run.status}: ${run.stderr}`);
assert.match(run.stderr, /running the mirrored copy instead/);
} finally {
f.clean();
}
});
test("an identical mirror is not stale and is not re-exec'd", () => {
const f = mirrorFixture();
const same = path.join(f.checkout, "bin", "rea-shim.mjs");
copyFileSync(path.join(f.bundle, "bin", "rea-shim.mjs"), same);
try {
const run = runShimIsolated(
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
["--shim-status"],
path.join(f.bundle, "bin", "rea-shim.mjs"),
);
const report = JSON.parse(run.stdout);
assert.equal(report.mirror.stale, false, JSON.stringify(report.mirror));
assert.equal(report.mirror.reason, "this copy matches the plugin");
} finally {
f.clean();
}
});
test("a mirrored shim finds the real DSH home when the environment is scrubbed", () => {
// The harness strips DSH_* from an MCP child, so with a custom DSH_HOME the
// old code read ~/.dsh — the wrong file — and could never report upstream's
// duplicate `serverName: rea`. setup records the home; the shim must use it.
const f = mirrorFixture();
const home = path.join(f.root, "custom-home");
mkdirSync(home, { recursive: true });
writeFileSync(
path.join(home, "cordis.patch.yml"),
"- insert:\n - id: rea-mcp\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: rea\n",
);
writeFileSync(
path.join(f.bundle, "mirror.json"),
JSON.stringify({ schema: 1, pluginRoot: f.checkout, mirroredAt: "2026-10-18T00:00:00Z", dshHome: home }),
);
try {
const run = runShimIsolated(
// DSH_HOME="" models the scrubbed environment: the variable exists, empty.
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", DSH_HOME: "" },
["--shim-status"],
);
const report = JSON.parse(run.stdout);
assert.equal(report.duplicateRegistration?.id, "rea-mcp", run.stdout);
assert.equal(report.duplicateRegistration.patch, path.join(home, "cordis.patch.yml"));
} finally {
f.clean();
}
});
/* ------------------------------------ records a Windows editor has BOM-ed */
// `bin/rea-shim.mjs` strips the UTF-8 BOM that PowerShell and Notepad write;
// setup.mjs kept parsing raw. Both programs read the same two records, so a BOM
// used to mean two different things: the shim found the checkout, setup did not.
// A mirrored setup then re-mirrored the previous manifest and provisioned from
// it, and a BOM-ed bundle.json read as "nothing is provisioned". These tests pin
// setup's side of the contract, both files.
/** A checkout with a manifest whose node probe accepts any node, so `--only node` is deterministic. */
function bomFixtureCheckout(root) {
const checkout = path.join(root, "checkout");
mkdirSync(path.join(checkout, "scripts"), { recursive: true });
mkdirSync(path.join(checkout, "manifest"), { recursive: true });
copyFileSync(SETUP_SOURCE, path.join(checkout, "scripts", "setup.mjs"));
const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
manifest.components.node.probe.expect.regex = "^v";
writeFileSync(path.join(checkout, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2));
writeFileSync(path.join(checkout, "package.json"), JSON.stringify({ name: "dsh-plugin-rea", version: "0.0.0-bom" }));
return checkout;
}
test("a BOM-ed mirror.json still points a mirrored setup at the real checkout", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-bom-mirror-"));
try {
const checkout = bomFixtureCheckout(root);
const bundle = path.join(root, "bundle");
mkdirSync(path.join(bundle, "scripts"), { recursive: true });
copyFileSync(SETUP_SOURCE, path.join(bundle, "scripts", "setup.mjs"));
// Exactly what `Out-File` / `Set-Content` leaves behind, and what a human
// editing the record by hand produces on Windows.
writeFileSync(
path.join(bundle, "mirror.json"),
"\uFEFF" + JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }),
);
// The bundle has no manifest of its own: only the record can supply one.
assert.equal(existsSync(path.join(bundle, "manifest", "bundle.manifest.json")), false);
const run = spawnSync(
process.execPath,
[path.join(bundle, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle],
{ encoding: "utf8", timeout: 120000 },
);
assert.equal(run.status, 0, run.stderr);
// loadManifest() runs before any component: reaching the plan proves the
// record was parsed. A BOM-blind parse fell through to the bundle and died
// on a missing manifest.
assert.match(run.stdout, /\[plan\] node/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a BOM-ed bundle.json is read instead of treated as unprovisioned", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-bom-bundle-"));
try {
const checkout = bomFixtureCheckout(root);
const bundle = path.join(root, "bundle");
mkdirSync(bundle, { recursive: true });
const args = [path.join(checkout, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle];
// Control: with no record at all the component is planned, so the assertion
// below cannot pass for a reason unrelated to reading the file.
const fresh = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
assert.equal(fresh.status, 0, fresh.stderr);
assert.match(fresh.stdout, /\[plan\] node/);
writeFileSync(
path.join(bundle, "bundle.json"),
"\uFEFF" + JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: "v-bom-test" } } }, 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);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+38
View File
@@ -17,6 +17,31 @@ foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\aud
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
}
Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
# bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
# A BOM strip on one side only makes one file mean "there is a checkout" to the
# shim and "there is none" to setup, and the mirrored setup then re-mirrors the
# previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
$jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
@'
const fs = require("node:fs");
const blind = [];
for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
const text = fs.readFileSync(file, "utf8");
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
// readJson() itself is the strip; anything else must not parse a file raw.
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
}
}
if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
}
console.log("bom-blind reads: " + JSON.stringify(blind));
if (blind.length) process.exit(1);
'@ | Set-Content $jsBom -Encoding utf8
& node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
# This script checks itself too: it is edited as often as the others, and an editor
# that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
@@ -90,6 +115,19 @@ Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $r
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
$compat = & node scripts\host-compat.mjs 2>&1
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
# host-compat and the audit all agree while the sentence a human reads names a
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
# The pin example is copy-pasteable, so it must name a ref that will exist: the
# version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
# then v2.0.8 while the tree was 2.0.9), and both times every other signal was
# green because nothing compared the two.
Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
}
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
}