dsh-plugin-rea 2.0.7 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +10 -4
- package/README.zh-CN.md +8 -3
- package/bin/rea-shim.mjs +243 -76
- package/check-updates.ps1 +33 -5
- package/package.json +1 -1
- package/scripts/setup.mjs +25 -0
- package/scripts/setup.test.mjs +181 -0
- package/scripts/verify-branch.ps1 +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
|
4
4
|
|
|
5
|
+
## 2.0.8 — 2026-10-18
|
|
6
|
+
|
|
7
|
+
**2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
|
|
8
|
+
|
|
9
|
+
### 修复
|
|
10
|
+
|
|
11
|
+
- **重复注册检测在自定义 `DSH_HOME` 下失效**:harness 会从 MCP 子进程里抹掉所有 `DSH_*`,镜像副本因此读
|
|
12
|
+
`~/.dsh` —— 那是错的文件,上游写进真实 home 的第二条 `serverName: rea` 永远报不出来。`setup.mjs` 现在把运行时
|
|
13
|
+
的 home 记进 `mirror.json` 的 `dshHome`,shim 优先读环境、其次读这条记录。`check-updates.ps1` 的 profile 段也
|
|
14
|
+
改用同一条 home 规则(此前硬编码 `$env:USERPROFILE`,自定义 home 时报「找不到 profile」)。
|
|
15
|
+
- **镜像启动器不会自己更新**:行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 这份**副本**,而市场升级
|
|
16
|
+
只换包、不跑 setup —— 副本会一直跑它被复制时的代码,于是新版本里的修复对老用户静默失效(旧副本照常工作,最难发现)。
|
|
17
|
+
现在镜像启动时会与 `mirror.json` 记录的插件目录比对哈希,不同就转发执行插件里的那一份(一跳,带
|
|
18
|
+
`REA_BUNDLE_SHIM_REEXEC` 守卫,且只从镜像指向插件,不会反向)。`--shim-status` 新增 `mirror.stale`,
|
|
19
|
+
`check-updates.ps1` 也会报「镜像落后,重跑 setup」;`mirror.json` 增加 `shimSha256` 与 `pluginVersion` 便于追溯。
|
|
20
|
+
**2.0.8 之前的版本自身无法完成这一步**(副本里没有这段代码),从那些版本升级仍需手动跑一次 setup。
|
|
21
|
+
- **探针递归**:`probeRea` 派生候选时没带 `REA_BUNDLE_DELEGATED=1`,而防自委派的守卫只在**委派**前生效。若 PATH 上是「调用本 shim 的 wrapper」,
|
|
22
|
+
探针 → wrapper → 新 shim → 再探针……实测并发 node 峰值 83+、杀掉后仍自我再生。现在探针环境同时带 `REA_BUNDLE_DELEGATED=1` 与
|
|
23
|
+
`REA_BUNDLE_AUTO_PROVISION=0`:嵌套运行直接走 bundle 并返回,实测 0.2 秒、零额外进程。
|
|
24
|
+
- **超时探针泄漏孙进程**:`spawnSync` 的超时只杀它自己启动的 cmd.exe,而 `taskkill /T` 在 `spawnSync` 回收之后调用已找不到父进程 ——
|
|
25
|
+
每次超时留一个 REA/ping 在跑。探针改为异步 `spawn` + 自己的计时器:超时发生在父进程仍存活时,`taskkill /T /F` 能真正清掉整棵树。
|
|
26
|
+
实测 8.3 秒放弃、`rejected=["timeout"]`、无残留进程。
|
|
27
|
+
- **Windows 信号处理器是死代码**:`child.kill("SIGTERM")` 在 Windows 上是 TerminateProcess,不可捕获;控制台的 Ctrl+C 又直达子进程。
|
|
28
|
+
该分支已删除,并如实写明限制:**在 Windows 上强制结束 shim 不会带走被委派的 REA**(Node 没有 job object 可用)。
|
|
29
|
+
- **`comspec()` 不校验**:`ComSpec` 指向不存在的文件时,正常的 `.cmd` REA 被判 `spawn-failed`(`auto` 静默退回 bundle,`system` 直接 78)。
|
|
30
|
+
现在校验为常规文件,否则回落到 `%SystemRoot%\System32\cmd.exe`;实测 `ComSpec` 指向不存在路径仍能识别 6.9.0。
|
|
31
|
+
- **文案**:显式路径是目录时报「does not exist」,改为「is not a file」。
|
|
32
|
+
|
|
33
|
+
### 验证
|
|
34
|
+
|
|
35
|
+
- 单测 **67 条**(2.0.7 是 60 条,本轮 +7:wrapper 递归必须被判为 `exit 78` 且 20 秒内收敛;`ComSpec` 失效仍识别 `.cmd` REA;
|
|
36
|
+
镜像自愈的转发/守卫/`stale` 上报/同内容不转发;`DSH_HOME` 被抹时从 `mirror.json` 找到真实 home 并报出重复注册):
|
|
37
|
+
Windows 64 过 + 3 平台跳过。
|
|
38
|
+
- `verify-branch.ps1` 全绿(39 项),含「两份 README 必须写出 package.json 里的 `engines.dsh` 原文」;`audit-manifest` clean;
|
|
39
|
+
`host-compat` 10 covered / 5 correctly outside。
|
|
40
|
+
- 手工复现三例:wrapper 递归 0.2 秒收敛、0 残留进程;挂起候选 8.3 秒放弃且无 `PING` 残留;`ComSpec` 指向空路径仍识别系统 REA。
|
|
41
|
+
|
|
42
|
+
|
|
5
43
|
## 2.0.7 — 2026-10-18
|
|
6
44
|
|
|
7
45
|
**机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
|
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ by the shim itself, which provisions in the background.
|
|
|
127
127
|
|
|
128
128
|
### Which DSH versions
|
|
129
129
|
|
|
130
|
-
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`, and that declaration is the answer the plugin market
|
|
130
|
+
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
|
|
131
131
|
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
|
|
132
132
|
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
|
|
133
133
|
two disagree:
|
|
@@ -230,9 +230,9 @@ Which one? Upstream's is the configuration-only route: it expects Node on REA's
|
|
|
230
230
|
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
|
|
231
231
|
`mcp__rea__*` surface are the same either way.
|
|
232
232
|
|
|
233
|
-
The shim reports the conflict on startup
|
|
234
|
-
|
|
235
|
-
lists it under `duplicateRegistration`.
|
|
233
|
+
The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
|
|
234
|
+
`mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
|
|
235
|
+
environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
|
|
236
236
|
|
|
237
237
|
## Verify
|
|
238
238
|
|
|
@@ -337,6 +337,12 @@ back on the next start.
|
|
|
337
337
|
|
|
338
338
|
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
|
|
339
339
|
checkout? Overwrite the directory and reinstall.
|
|
340
|
+
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
|
|
341
|
+
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
|
|
342
|
+
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
|
|
343
|
+
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
|
|
344
|
+
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
|
|
345
|
+
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
|
|
340
346
|
- **Upgrading from `2.0.0-bundle.1`**: that release mirrored the shim and `setup.mjs` but not the manifest,
|
|
341
347
|
and recorded nothing about the plugin checkout, so a bundle it provisioned cannot repair itself — its
|
|
342
348
|
background provisioning dies on the missing manifest, invisibly, and its own lock suppresses retries for
|
package/README.zh-CN.md
CHANGED
|
@@ -118,7 +118,7 @@ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
|
|
|
118
118
|
|
|
119
119
|
### 支持哪些 DSH 版本
|
|
120
120
|
|
|
121
|
-
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`,这就是插件市场读的那句话:有它,卡片显示
|
|
121
|
+
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
|
|
122
122
|
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
|
|
123
123
|
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
|
|
124
124
|
|
|
@@ -216,8 +216,8 @@ duplicate server name 报错并加载失败,最终留下哪一行取决于加
|
|
|
216
216
|
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
|
|
217
217
|
`mcp__rea__*` 工具面相同。
|
|
218
218
|
|
|
219
|
-
shim
|
|
220
|
-
|
|
219
|
+
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
|
|
220
|
+
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
|
|
221
221
|
|
|
222
222
|
## 验证
|
|
223
223
|
|
|
@@ -314,6 +314,11 @@ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-boot
|
|
|
314
314
|
工具就回来了。
|
|
315
315
|
|
|
316
316
|
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
|
|
317
|
+
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
|
|
318
|
+
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
|
|
319
|
+
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
|
|
320
|
+
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
|
|
321
|
+
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
|
|
317
322
|
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
|
|
318
323
|
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
|
|
319
324
|
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
|
package/bin/rea-shim.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* and the message is the operator's remediation.
|
|
16
16
|
*/
|
|
17
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
18
19
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
19
20
|
import { homedir } from "node:os";
|
|
20
21
|
import path from "node:path";
|
|
@@ -32,6 +33,36 @@ function dshHome() {
|
|
|
32
33
|
return path.join(homedir(), ".dsh");
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The DSH home recorded by setup, or null.
|
|
38
|
+
*
|
|
39
|
+
* Only the mirrored copy needs this: the harness strips `DSH_*` from an MCP
|
|
40
|
+
* child, so with a custom home the environment cannot answer. Read once per
|
|
41
|
+
* process — there is one bundle root per process and this is a startup query.
|
|
42
|
+
*/
|
|
43
|
+
let recordedDshHomeCache = null;
|
|
44
|
+
function recordedDshHome(root) {
|
|
45
|
+
if (recordedDshHomeCache !== null) return recordedDshHomeCache;
|
|
46
|
+
let value = null;
|
|
47
|
+
try {
|
|
48
|
+
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
|
49
|
+
if (typeof record?.dshHome === "string" && record.dshHome.trim() !== "" && path.isAbsolute(record.dshHome)) {
|
|
50
|
+
value = path.resolve(record.dshHome);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
value = null;
|
|
54
|
+
}
|
|
55
|
+
recordedDshHomeCache = value;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The home to look in when the environment has been scrubbed. */
|
|
60
|
+
function dshHomeFor(root) {
|
|
61
|
+
const fromEnv = process.env.DSH_HOME;
|
|
62
|
+
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
|
|
63
|
+
return recordedDshHome(root) ?? path.join(homedir(), ".dsh");
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
/**
|
|
36
67
|
* Where setup writes the machine-local truth.
|
|
37
68
|
*
|
|
@@ -104,13 +135,16 @@ function provisioningScript(root) {
|
|
|
104
135
|
*
|
|
105
136
|
* The parse is deliberately small: the two files it cares about are block
|
|
106
137
|
* mappings with one id/serverName per row, and anything it cannot attribute is
|
|
107
|
-
* ignored
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
138
|
+
* ignored — a missed hint is acceptable, a false alarm is not.
|
|
139
|
+
*
|
|
140
|
+
* Finding the file is the part that needed care: the harness strips every DSH_*
|
|
141
|
+
* variable from an MCP child, so a mirrored copy with a custom DSH_HOME used to
|
|
142
|
+
* read `~/.dsh` — the wrong file, and therefore a conflict it could never report.
|
|
143
|
+
* `setup.mjs` writes the home it ran under into `mirror.json`, and that record is
|
|
144
|
+
* what this uses when the environment has nothing to say.
|
|
111
145
|
*/
|
|
112
|
-
function foreignReaRegistration() {
|
|
113
|
-
const patch = path.join(
|
|
146
|
+
function foreignReaRegistration(root) {
|
|
147
|
+
const patch = path.join(dshHomeFor(root), "cordis.patch.yml");
|
|
114
148
|
let text;
|
|
115
149
|
try {
|
|
116
150
|
text = readFileSync(patch, "utf8");
|
|
@@ -516,7 +550,11 @@ function cmdQuote(value) {
|
|
|
516
550
|
*/
|
|
517
551
|
function comspec() {
|
|
518
552
|
const fromEnv = process.env.ComSpec ?? process.env.COMSPEC;
|
|
519
|
-
|
|
553
|
+
// A ComSpec that names nothing is a real (if unusual) state, and trusting it
|
|
554
|
+
// would judge every `.cmd` REA "spawn-failed" — i.e. report the machine as
|
|
555
|
+
// having no REA because its shell pointer is broken. Fall back to the path
|
|
556
|
+
// Windows itself would use.
|
|
557
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "" && isFileSync(fromEnv)) return fromEnv;
|
|
520
558
|
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows";
|
|
521
559
|
return path.join(systemRoot, "System32", "cmd.exe");
|
|
522
560
|
}
|
|
@@ -527,50 +565,77 @@ function comspec() {
|
|
|
527
565
|
* A file existing on PATH is not the same as a working install: npx leftovers,
|
|
528
566
|
* half-removed globals and a shim whose Node is gone all sit on PATH and all
|
|
529
567
|
* fail at once. One bounded `--version` separates them, and a slow or hanging
|
|
530
|
-
* answer counts as "not usable"
|
|
568
|
+
* answer counts as "not usable" — the fallback (the bundle) still works, while
|
|
531
569
|
* delegating to a broken binary would take the tools down with it.
|
|
570
|
+
*
|
|
571
|
+
* Two things here are not incidental:
|
|
572
|
+
*
|
|
573
|
+
* * The probe runs with `REA_BUNDLE_DELEGATED=1`. A candidate that is itself a
|
|
574
|
+
* wrapper around this shim would otherwise be probed by a nested shim that
|
|
575
|
+
* probes the wrapper — measured: a process chain that keeps respawning
|
|
576
|
+
* itself. The flag makes the nested run take the bundle path and return.
|
|
577
|
+
* * It is asynchronous, not `spawnSync` with a timeout. `spawnSync` reaps the
|
|
578
|
+
* child it started, and its timeout kills only that child: for a `.cmd` shim
|
|
579
|
+
* that means cmd.exe dies while the REA inside it keeps running and holds
|
|
580
|
+
* the pipes. Killing the TREE is only possible while the parent is alive,
|
|
581
|
+
* which means owning the wait ourselves.
|
|
532
582
|
*/
|
|
533
583
|
function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_MS } = {}) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (!
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
584
|
+
return new Promise((resolve) => {
|
|
585
|
+
// `existsSync` says nothing about the shape: a DIRECTORY named `rea.cmd`
|
|
586
|
+
// exists, fails to spawn, and would be reported as a broken install instead
|
|
587
|
+
// of as a name collision. Only a regular file is a candidate.
|
|
588
|
+
if (!isFileSync(file)) {
|
|
589
|
+
resolve({ ok: false, reason: "not a file" });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const useShell = platform === "win32" && /\.(cmd|bat)$/i.test(file);
|
|
593
|
+
// `REA_BUNDLE_AUTO_PROVISION=0` is part of the same guard: if the candidate
|
|
594
|
+
// turns out to be a wrapper around THIS shim, the nested run must not start
|
|
595
|
+
// an 800 MB provisioning job just because a probe asked it for a version.
|
|
596
|
+
const env = { ...process.env, REA_BUNDLE_DELEGATED: "1", REA_BUNDLE_AUTO_PROVISION: "0" };
|
|
597
|
+
// `cmd /d /s /c "<line>"`: Windows only. Two details are load-bearing.
|
|
598
|
+
// `/s` strips the first and last quote, so the whole line is quoted; and the
|
|
599
|
+
// arguments are passed VERBATIM, because Node otherwise backslash-escapes the
|
|
600
|
+
// quotes it sees and cmd.exe does not understand `\"` — every candidate then
|
|
601
|
+
// answers "exit 1", which reads as "no REA on this machine".
|
|
602
|
+
const child = useShell
|
|
603
|
+
? spawn(comspec(), ["/d", "/s", "/c", `"${[cmdQuote(file), "--version"].join(" ")}"`], {
|
|
604
|
+
env,
|
|
605
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
606
|
+
windowsHide: true,
|
|
607
|
+
windowsVerbatimArguments: true,
|
|
608
|
+
})
|
|
609
|
+
: spawn(file, ["--version"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
610
|
+
let output = "";
|
|
611
|
+
let settled = false;
|
|
612
|
+
let timer = null;
|
|
613
|
+
const finish = (result) => {
|
|
614
|
+
if (settled) return;
|
|
615
|
+
settled = true;
|
|
616
|
+
if (timer !== null) clearTimeout(timer);
|
|
617
|
+
resolve(result);
|
|
618
|
+
};
|
|
619
|
+
timer = setTimeout(() => {
|
|
620
|
+
killTree(child.pid, platform);
|
|
621
|
+
finish({ ok: false, reason: "timeout" });
|
|
622
|
+
}, timeout);
|
|
623
|
+
child.stdout.on("data", (chunk) => { output += String(chunk); });
|
|
624
|
+
child.stderr.on("data", (chunk) => { output += String(chunk); });
|
|
625
|
+
child.on("error", () => finish({ ok: false, reason: "spawn-failed" }));
|
|
626
|
+
child.on("close", (code) => {
|
|
627
|
+
if (code !== 0) {
|
|
628
|
+
finish({ ok: false, reason: `exit ${String(code)}` });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// A REA install prints its version. Anything else (a shim that swallowed
|
|
632
|
+
// the flag, an unrelated program that shares the name) is not a usable CLI.
|
|
633
|
+
const version = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/.exec(output.trim())?.[1] ?? null;
|
|
634
|
+
finish(version === null
|
|
635
|
+
? { ok: false, reason: "no version in --version output" }
|
|
636
|
+
: { ok: true, version });
|
|
637
|
+
});
|
|
638
|
+
});
|
|
574
639
|
}
|
|
575
640
|
|
|
576
641
|
/**
|
|
@@ -580,7 +645,7 @@ function probeRea(file, { platform = process.platform, timeout = PROBE_TIMEOUT_M
|
|
|
580
645
|
* provisioner installed is the `bundle` mode, and delegating to it would be a
|
|
581
646
|
* loop through a path the bundle already covers.
|
|
582
647
|
*/
|
|
583
|
-
function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
|
|
648
|
+
async function detectSystemRea({ env = process.env, platform = process.platform, root, pluginRoot = PLUGIN_ROOT } = {}) {
|
|
584
649
|
const rejected = [];
|
|
585
650
|
let profiled = 0;
|
|
586
651
|
const explicit = (env.REA_BUNDLE_SYSTEM_REA ?? "").trim();
|
|
@@ -609,7 +674,7 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
|
|
|
609
674
|
continue;
|
|
610
675
|
}
|
|
611
676
|
profiled += 1;
|
|
612
|
-
const probe = probeRea(candidate, { platform });
|
|
677
|
+
const probe = await probeRea(candidate, { platform });
|
|
613
678
|
if (probe.ok) return { found: true, file: candidate, version: probe.version };
|
|
614
679
|
if (rejected.length < 8) rejected.push({ file: candidate, reason: probe.reason });
|
|
615
680
|
}
|
|
@@ -624,7 +689,9 @@ function detectSystemRea({ env = process.env, platform = process.platform, root,
|
|
|
624
689
|
if (explicitPath !== null && explicitRejection !== null) {
|
|
625
690
|
reason = `REA_BUNDLE_SYSTEM_REA was rejected (${explicitRejection.reason}): ${explicitPath}`;
|
|
626
691
|
} else if (explicitPath !== null && !isFileSync(explicitPath)) {
|
|
627
|
-
reason =
|
|
692
|
+
reason = existsSync(explicitPath)
|
|
693
|
+
? `REA_BUNDLE_SYSTEM_REA is not a file: ${explicitPath}`
|
|
694
|
+
: `REA_BUNDLE_SYSTEM_REA does not exist: ${explicitPath}`;
|
|
628
695
|
} else {
|
|
629
696
|
reason = profiled === 0 ? "no rea on PATH" : "no usable rea on PATH";
|
|
630
697
|
}
|
|
@@ -660,7 +727,7 @@ function compareDotted(a, b) {
|
|
|
660
727
|
* honoured literally 鈥?`system` fails loudly rather than provisioning 800 MB
|
|
661
728
|
* behind an operator who asked for their own REA.
|
|
662
729
|
*/
|
|
663
|
-
function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
|
730
|
+
async function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false, env = process.env, platform = process.platform } = {}) {
|
|
664
731
|
const preference = launchPreference(env);
|
|
665
732
|
if (preference.value === "bundle") {
|
|
666
733
|
return { preference: preference.value, invalid: preference.invalid, mode: "bundle", reason: "pinned by REA_BUNDLE_LAUNCH=bundle" };
|
|
@@ -678,7 +745,7 @@ function resolveLaunch({ root, bundleVersion = null, bundleProvisioned = false,
|
|
|
678
745
|
reason: "REA_BUNDLE_DELEGATED=1: already running under a delegated system REA, refusing to delegate again",
|
|
679
746
|
};
|
|
680
747
|
}
|
|
681
|
-
const system = detectSystemRea({ env, platform, root });
|
|
748
|
+
const system = await detectSystemRea({ env, platform, root });
|
|
682
749
|
if (system.found) {
|
|
683
750
|
const order = bundleVersion === null ? null : compareDotted(system.version, bundleVersion);
|
|
684
751
|
const pinned = preference.value === "system";
|
|
@@ -747,25 +814,14 @@ function launchSystemRea(system, argv) {
|
|
|
747
814
|
windowsVerbatimArguments: true,
|
|
748
815
|
})
|
|
749
816
|
: spawn(system.file, args, { env, stdio: "inherit", windowsHide: true });
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
// keeps the inherited pipes open, which looks exactly like "the tools still
|
|
759
|
-
// work after I stopped the harness". `taskkill /T` is what actually ends the
|
|
760
|
-
// tree, so that is what a signal asks for here.
|
|
761
|
-
if (process.platform === "win32") {
|
|
762
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
763
|
-
process.on(signal, () => {
|
|
764
|
-
killTree(child.pid, "win32");
|
|
765
|
-
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
}
|
|
817
|
+
// No signal forwarding on Windows, deliberately. `child.kill()` there is
|
|
818
|
+
// TerminateProcess on the cmd.exe WRAPPER — the REA inside it survives, still
|
|
819
|
+
// holding the inherited pipes — and a console Ctrl+C is delivered to the whole
|
|
820
|
+
// process group anyway, so the child already has it. A hard kill of this shim
|
|
821
|
+
// (what a supervisor or Task Manager does) runs no JavaScript at all, so the
|
|
822
|
+
// wrapper must not pretend otherwise: on Windows the delegated REA outlives a
|
|
823
|
+
// forcibly killed shim. On POSIX the child is in this process's group and gets
|
|
824
|
+
// the terminal's signal directly; `process.exit` below then ends the shim.
|
|
769
825
|
child.on("error", (error) => {
|
|
770
826
|
process.stderr.write(`rea-shim: could not start ${system.file}\n ${String(error && error.message)}\n`);
|
|
771
827
|
process.exit(EX_CONFIG);
|
|
@@ -780,6 +836,101 @@ function launchSystemRea(system, argv) {
|
|
|
780
836
|
return null;
|
|
781
837
|
}
|
|
782
838
|
|
|
839
|
+
/**
|
|
840
|
+
* The mirror problem.
|
|
841
|
+
*
|
|
842
|
+
* The published row launches `<bundle>/bin/rea-shim.mjs` — a COPY made by
|
|
843
|
+
* `setup.mjs`. Upgrading the plugin from the market replaces the package but runs
|
|
844
|
+
* no setup, so the copy keeps executing the code it was made from: every fix in
|
|
845
|
+
* a new plugin version reaches a user only if they happen to re-run setup. That
|
|
846
|
+
* is invisible (the old copy keeps working) and it is exactly the wrong
|
|
847
|
+
* direction for a safety fix.
|
|
848
|
+
*
|
|
849
|
+
* The repair has to live in the copy, because the copy is what runs. So the
|
|
850
|
+
* copy compares itself with the plugin's own `bin/rea-shim.mjs`, recorded in
|
|
851
|
+
* `mirror.json` by setup, and re-execs that file when the two differ. One hop,
|
|
852
|
+
* guarded by an environment flag, and only from mirror to checkout — the
|
|
853
|
+
* checkout copy has no `mirror.json` and would find itself anyway.
|
|
854
|
+
*
|
|
855
|
+
* A plugin version older than 2.0.8 cannot do this for its own upgrades: there
|
|
856
|
+
* is nothing in it to run. `--shim-status` reports `mirror.stale` so the
|
|
857
|
+
* condition is at least visible, and `check-updates.ps1` says to re-run setup.
|
|
858
|
+
*/
|
|
859
|
+
function sha256(file) {
|
|
860
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Where the installed plugin lives according to setup's record, or null. */
|
|
864
|
+
function pluginCheckout(root) {
|
|
865
|
+
try {
|
|
866
|
+
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
|
867
|
+
const dir = record?.pluginRoot;
|
|
868
|
+
if (typeof dir !== "string" || dir.trim() === "") return null;
|
|
869
|
+
const shim = path.join(dir, "bin", "rea-shim.mjs");
|
|
870
|
+
return isFileSync(shim) ? shim : null;
|
|
871
|
+
} catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Whether this copy is behind the plugin it was mirrored from.
|
|
878
|
+
*
|
|
879
|
+
* `self` is this file, `source` the checkout's. A checkout copy (it ships
|
|
880
|
+
* `cordis.patch.yml`) is never stale against itself; a mirror with no record, or
|
|
881
|
+
* whose recorded checkout is gone, is not re-pointed anywhere.
|
|
882
|
+
*/
|
|
883
|
+
function describeMirror(root, { self = fileURLToPath(import.meta.url) } = {}) {
|
|
884
|
+
let record = null;
|
|
885
|
+
try {
|
|
886
|
+
record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
|
887
|
+
} catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
const source = pluginCheckout(root);
|
|
891
|
+
let stale = false;
|
|
892
|
+
let reason = "no usable plugin checkout in mirror.json";
|
|
893
|
+
if (source !== null) {
|
|
894
|
+
if (path.resolve(source) === path.resolve(self)) {
|
|
895
|
+
reason = "this copy is the plugin checkout";
|
|
896
|
+
} else {
|
|
897
|
+
try {
|
|
898
|
+
stale = sha256(source) !== sha256(self);
|
|
899
|
+
reason = stale ? "the plugin ships a different launcher than this mirrored copy" : "this copy matches the plugin";
|
|
900
|
+
} catch {
|
|
901
|
+
reason = "could not compare the two launchers";
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
pluginRoot: typeof record?.pluginRoot === "string" ? record.pluginRoot : null,
|
|
907
|
+
mirroredAt: typeof record?.mirroredAt === "string" ? record.mirroredAt : null,
|
|
908
|
+
stale,
|
|
909
|
+
reason,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Run the checkout's launcher instead of this copy, once. */
|
|
914
|
+
function reexecFromCheckout(source, argv) {
|
|
915
|
+
const child = spawn(process.execPath, [source, ...argv], {
|
|
916
|
+
stdio: "inherit",
|
|
917
|
+
env: { ...process.env, REA_BUNDLE_SHIM_REEXEC: "1" },
|
|
918
|
+
windowsHide: true,
|
|
919
|
+
});
|
|
920
|
+
child.on("error", () => {
|
|
921
|
+
// Falling back is better than failing: the copy still knows how to launch.
|
|
922
|
+
process.stderr.write(`rea-shim: could not run ${source}; continuing with the mirrored copy.\n`);
|
|
923
|
+
});
|
|
924
|
+
child.on("exit", (code, signal) => {
|
|
925
|
+
if (signal) {
|
|
926
|
+
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
|
927
|
+
process.exit(128 + (signals[signal] ?? 0));
|
|
928
|
+
}
|
|
929
|
+
process.exit(code ?? 0);
|
|
930
|
+
});
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
|
|
783
934
|
/**
|
|
784
935
|
* Usage text, also what a bare invocation prints.
|
|
785
936
|
*
|
|
@@ -808,7 +959,7 @@ function usage() {
|
|
|
808
959
|
].join("\n");
|
|
809
960
|
}
|
|
810
961
|
|
|
811
|
-
function main() {
|
|
962
|
+
async function main() {
|
|
812
963
|
const argv = process.argv.slice(2);
|
|
813
964
|
|
|
814
965
|
if (argv.includes("--shim-help") || argv.length === 0) {
|
|
@@ -817,12 +968,21 @@ function main() {
|
|
|
817
968
|
}
|
|
818
969
|
|
|
819
970
|
const root = bundleRoot();
|
|
971
|
+
// Self-healing before anything else: a mirrored copy that predates the plugin
|
|
972
|
+
// it was made from would otherwise keep running that older code forever, and a
|
|
973
|
+
// market upgrade never re-runs setup. One hop, guarded, and never for the
|
|
974
|
+
// status query — there the staleness is reported instead.
|
|
975
|
+
const mirror = describeMirror(root);
|
|
976
|
+
if (mirror !== null && mirror.stale && process.env.REA_BUNDLE_SHIM_REEXEC !== "1" && !argv.includes("--shim-status")) {
|
|
977
|
+
const source = pluginCheckout(root);
|
|
978
|
+
if (source !== null) return reexecFromCheckout(source, argv);
|
|
979
|
+
}
|
|
820
980
|
const loaded = readBundleConfig(root);
|
|
821
981
|
// Decide what to run before asking whether the bundle is provisioned: a
|
|
822
982
|
// machine with its own working REA must not be sent into an ~800 MB
|
|
823
983
|
// provisioning run just because this bundle has no state yet.
|
|
824
984
|
const bundleVersion = loaded.ok ? (loaded.config.versions?.reaAgents ?? null) : null;
|
|
825
|
-
const launch = resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
|
|
985
|
+
const launch = await resolveLaunch({ root, bundleVersion, bundleProvisioned: loaded.ok });
|
|
826
986
|
if (launch.invalid !== null) {
|
|
827
987
|
process.stderr.write(
|
|
828
988
|
`rea-shim: REA_BUNDLE_LAUNCH="${launch.invalid}" is not one of ${LAUNCH_MODES.join("/")}; using auto.\n`,
|
|
@@ -831,7 +991,7 @@ function main() {
|
|
|
831
991
|
// Upstream can add a second row for the same serverName; the shim cannot stop
|
|
832
992
|
// DSH from loading both rows, but saying so is better than doubled tools with
|
|
833
993
|
// no stated cause. The status report carries the same fact for diagnostics.
|
|
834
|
-
const duplicate = foreignReaRegistration();
|
|
994
|
+
const duplicate = foreignReaRegistration(root);
|
|
835
995
|
if (duplicate && !argv.includes("--shim-status")) {
|
|
836
996
|
process.stderr.write(
|
|
837
997
|
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
|
|
@@ -860,6 +1020,10 @@ function main() {
|
|
|
860
1020
|
},
|
|
861
1021
|
};
|
|
862
1022
|
if (launch.system?.rejected?.length) report.launch.rejected = launch.system.rejected;
|
|
1023
|
+
// A mirrored copy that lags the plugin keeps working, which is exactly why
|
|
1024
|
+
// nobody notices it: the row runs old code until the mirror is refreshed.
|
|
1025
|
+
// Reported here so `--shim-status` answers "am I running what I installed?".
|
|
1026
|
+
if (mirror !== null) report.mirror = mirror;
|
|
863
1027
|
if (duplicate) report.duplicateRegistration = duplicate;
|
|
864
1028
|
if (loaded.ok) {
|
|
865
1029
|
report.paths = loaded.config.paths ?? {};
|
|
@@ -972,5 +1136,8 @@ function main() {
|
|
|
972
1136
|
return null;
|
|
973
1137
|
}
|
|
974
1138
|
|
|
975
|
-
|
|
1139
|
+
// The launch decision awaits a probe, so `main` is async; the status it returns
|
|
1140
|
+
// is this process's exit code, and a launch path resolves to null while the
|
|
1141
|
+
// child (already wired to `process.exit`) keeps the loop alive.
|
|
1142
|
+
const status = await main();
|
|
976
1143
|
if (typeof status === "number") process.exit(status);
|
package/check-updates.ps1
CHANGED
|
@@ -73,9 +73,13 @@ function Test-NodeEngines([string]$node, [string]$engines) {
|
|
|
73
73
|
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
|
|
74
74
|
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
|
|
75
75
|
$bundleRootEarly = $env:REA_BUNDLE_ROOT
|
|
76
|
+
# Resolve the home unconditionally: the profile section below needs it whether or
|
|
77
|
+
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
|
|
78
|
+
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
|
|
79
|
+
# "no profile" on exactly the machines that use the override.
|
|
80
|
+
$homeEarly = $env:DSH_HOME
|
|
81
|
+
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
|
76
82
|
if (-not $bundleRootEarly) {
|
|
77
|
-
$homeEarly = $env:DSH_HOME
|
|
78
|
-
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
|
79
83
|
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
|
|
80
84
|
}
|
|
81
85
|
$bundleState = $null
|
|
@@ -106,8 +110,12 @@ $reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaP
|
|
|
106
110
|
if (-not $ProfileDir) {
|
|
107
111
|
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
|
|
108
112
|
else {
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
|
|
114
|
+
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
|
|
115
|
+
# so on a custom DSH_HOME the profile section reported "no profile found"
|
|
116
|
+
# while the rest of the run had already resolved that home correctly.
|
|
117
|
+
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
|
|
118
|
+
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
|
|
@@ -340,7 +348,27 @@ if (-not (Test-Path $manifestPath)) {
|
|
|
340
348
|
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
|
|
341
349
|
}
|
|
342
350
|
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
|
|
343
|
-
if (Test-Path $mirrored) {
|
|
351
|
+
if (Test-Path $mirrored) {
|
|
352
|
+
Ok 'MCP 行指向的 shim 已就位'
|
|
353
|
+
# The row launches this COPY, so a plugin upgrade that does not re-run
|
|
354
|
+
# setup leaves the old launcher in place — visible only as "the new
|
|
355
|
+
# version behaves like the old one". Compare it with the checkout the
|
|
356
|
+
# mirror record names.
|
|
357
|
+
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
|
|
358
|
+
if (Test-Path $mirrorRecord) {
|
|
359
|
+
try {
|
|
360
|
+
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
361
|
+
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
|
|
362
|
+
if ($pluginShim -and (Test-Path $pluginShim)) {
|
|
363
|
+
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
|
|
364
|
+
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
|
|
365
|
+
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
|
|
366
|
+
} else {
|
|
367
|
+
Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
|
|
368
|
+
}
|
|
369
|
+
} catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
344
372
|
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
|
|
345
373
|
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
|
|
346
374
|
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-rea",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
4
4
|
"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。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/scripts/setup.mjs
CHANGED
|
@@ -1636,6 +1636,31 @@ function mirrorLauncher(context) {
|
|
|
1636
1636
|
*/
|
|
1637
1637
|
function writeMirrorRecord(context, source) {
|
|
1638
1638
|
const record = { schema: 1, pluginRoot: source, mirroredAt: new Date().toISOString() };
|
|
1639
|
+
// Provenance for the self-healing check: the mirrored shim compares itself
|
|
1640
|
+
// with `<pluginRoot>/bin/rea-shim.mjs` and re-execs that one when the plugin
|
|
1641
|
+
// has moved on. These two fields are what `--shim-status` shows when the
|
|
1642
|
+
// comparison cannot be made (missing checkout, unreadable file).
|
|
1643
|
+
try {
|
|
1644
|
+
record.shimSha256 = createHash("sha256")
|
|
1645
|
+
.update(readFileSync(path.join(context.root, "bin", "rea-shim.mjs")))
|
|
1646
|
+
.digest("hex");
|
|
1647
|
+
} catch {
|
|
1648
|
+
/* the copy is missing or unreadable; the shim reports the comparison itself */
|
|
1649
|
+
}
|
|
1650
|
+
try {
|
|
1651
|
+
record.pluginVersion = JSON.parse(readFileSync(path.join(source, "package.json"), "utf8")).version ?? null;
|
|
1652
|
+
} catch {
|
|
1653
|
+
record.pluginVersion = null;
|
|
1654
|
+
}
|
|
1655
|
+
// The MCP child the harness starts has every DSH_* variable stripped, so the
|
|
1656
|
+
// mirrored shim cannot re-derive a CUSTOM home from its environment — it would
|
|
1657
|
+
// read `~/.dsh` and miss a duplicate `serverName: rea` living in the real one.
|
|
1658
|
+
// Record the home setup ran under; the shim prefers the environment, then this.
|
|
1659
|
+
try {
|
|
1660
|
+
record.dshHome = dshHome();
|
|
1661
|
+
} catch {
|
|
1662
|
+
record.dshHome = null;
|
|
1663
|
+
}
|
|
1639
1664
|
try {
|
|
1640
1665
|
mkdirSync(context.root, { recursive: true });
|
|
1641
1666
|
writeFileSync(path.join(context.root, "mirror.json"), JSON.stringify(record, null, 2) + "\n");
|
package/scripts/setup.test.mjs
CHANGED
|
@@ -1564,3 +1564,184 @@ test("--shim-print-env reports the system launch instead of a child environment"
|
|
|
1564
1564
|
f.clean();
|
|
1565
1565
|
}
|
|
1566
1566
|
});
|
|
1567
|
+
|
|
1568
|
+
test("a candidate that wraps this shim is rejected instead of recursing", () => {
|
|
1569
|
+
// The probe asks the candidate for `--version`; a wrapper answers by running
|
|
1570
|
+
// this very shim, which would probe the wrapper again. The probe therefore runs
|
|
1571
|
+
// with REA_BUNDLE_DELEGATED=1, so the nested run takes the bundle path and
|
|
1572
|
+
// returns. Without that, one wrapper on PATH spawns a process chain that keeps
|
|
1573
|
+
// respawning after the parent is killed.
|
|
1574
|
+
const f = launchFixture();
|
|
1575
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1576
|
+
const shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
|
|
1577
|
+
const wrapper = path.join(f.bin, process.platform === "win32" ? "rea.cmd" : "rea");
|
|
1578
|
+
if (process.platform === "win32") {
|
|
1579
|
+
writeFileSync(wrapper, `@echo off\r\n"${process.execPath}" "${shim}" %*\r\n`);
|
|
1580
|
+
} else {
|
|
1581
|
+
writeFileSync(wrapper, `#!/bin/sh\nexec "${process.execPath}" "${shim}" "$@"\n`);
|
|
1582
|
+
chmodSync(wrapper, 0o755);
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
const started = Date.now();
|
|
1586
|
+
const run = runShimIsolated(
|
|
1587
|
+
{ PATH: f.emptyPath, REA_BUNDLE_SYSTEM_REA: wrapper, REA_BUNDLE_ROOT: f.bundleRoot, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "" },
|
|
1588
|
+
["--shim-status"],
|
|
1589
|
+
);
|
|
1590
|
+
const elapsed = Date.now() - started;
|
|
1591
|
+
const report = JSON.parse(run.stdout);
|
|
1592
|
+
assert.equal(report.launch.mode, "bundle");
|
|
1593
|
+
assert.deepEqual(report.launch.rejected.map(r => r.reason), ["exit 78"]);
|
|
1594
|
+
assert.ok(elapsed < 20000, `the probe must not recurse; it took ${elapsed} ms`);
|
|
1595
|
+
} finally {
|
|
1596
|
+
f.clean();
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
test("a ComSpec that points at nothing falls back to the real shell", () => {
|
|
1601
|
+
// Trusting ComSpec blindly would judge every .cmd REA "spawn-failed" — i.e.
|
|
1602
|
+
// report the machine as having no REA because its shell pointer is broken.
|
|
1603
|
+
const f = launchFixture();
|
|
1604
|
+
mkdirSync(f.bin, { recursive: true });
|
|
1605
|
+
const rea = writeFakeRea(f.bin, { version: "6.9.0" });
|
|
1606
|
+
try {
|
|
1607
|
+
const run = runShimIsolated(
|
|
1608
|
+
{
|
|
1609
|
+
PATH: f.emptyPath,
|
|
1610
|
+
ComSpec: path.join(f.root, "no-such-cmd.exe"),
|
|
1611
|
+
REA_BUNDLE_SYSTEM_REA: rea,
|
|
1612
|
+
REA_BUNDLE_ROOT: f.bundleRoot,
|
|
1613
|
+
REA_BUNDLE_AUTO_PROVISION: "0",
|
|
1614
|
+
REA_BUNDLE_LAUNCH: "",
|
|
1615
|
+
},
|
|
1616
|
+
["--shim-status"],
|
|
1617
|
+
);
|
|
1618
|
+
const report = JSON.parse(run.stdout);
|
|
1619
|
+
if (process.platform === "win32") {
|
|
1620
|
+
assert.equal(report.launch.mode, "system", JSON.stringify(report.launch));
|
|
1621
|
+
assert.equal(report.launch.systemRea.version, "6.9.0");
|
|
1622
|
+
} else {
|
|
1623
|
+
// POSIX never consults ComSpec; the assertion above would be incidental.
|
|
1624
|
+
assert.equal(report.launch.mode, "system");
|
|
1625
|
+
}
|
|
1626
|
+
} finally {
|
|
1627
|
+
f.clean();
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
/* --------------------------------------------------- the mirrored launcher */
|
|
1632
|
+
|
|
1633
|
+
// The row launches `<bundle>/bin/rea-shim.mjs`, a COPY. A market upgrade replaces
|
|
1634
|
+
// the package and runs no setup, so without this the copy keeps running the code
|
|
1635
|
+
// it was made from — every fix silently stops at the old mirror. These tests pin
|
|
1636
|
+
// the repair: the copy re-execs the plugin's launcher when they differ, once.
|
|
1637
|
+
|
|
1638
|
+
function mirrorFixture() {
|
|
1639
|
+
const root = mkdtempSync(path.join(tmpdir(), "rea-mirror-"));
|
|
1640
|
+
const bundle = path.join(root, "bundle");
|
|
1641
|
+
const checkout = path.join(root, "checkout");
|
|
1642
|
+
mkdirSync(path.join(bundle, "bin"), { recursive: true });
|
|
1643
|
+
mkdirSync(path.join(checkout, "bin"), { recursive: true });
|
|
1644
|
+
copyFileSync(path.join(REPO_ROOT, "bin", "rea-shim.mjs"), path.join(bundle, "bin", "rea-shim.mjs"));
|
|
1645
|
+
writeFileSync(path.join(bundle, "mirror.json"), JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }));
|
|
1646
|
+
return { root, bundle, checkout, clean() { rmSync(root, { recursive: true, force: true }); } };
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function checkoutStub(checkout, marker) {
|
|
1650
|
+
const file = path.join(checkout, "bin", "rea-shim.mjs");
|
|
1651
|
+
writeFileSync(
|
|
1652
|
+
file,
|
|
1653
|
+
'import { writeFileSync } from "node:fs";\n' +
|
|
1654
|
+
`writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
|
|
1655
|
+
"process.exit(7);\n",
|
|
1656
|
+
);
|
|
1657
|
+
return file;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
test("a mirrored launcher re-execs the plugin's launcher when they differ", () => {
|
|
1661
|
+
const f = mirrorFixture();
|
|
1662
|
+
const marker = path.join(f.root, "checkout-ran");
|
|
1663
|
+
checkoutStub(f.checkout, marker);
|
|
1664
|
+
try {
|
|
1665
|
+
const run = runShimIsolated({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["mcp"]);
|
|
1666
|
+
assert.equal(run.status, 7, `expected the checkout's status, got ${run.status}: ${run.stderr}`);
|
|
1667
|
+
assert.equal(existsSync(marker), true, "the checkout launcher must have run");
|
|
1668
|
+
} finally {
|
|
1669
|
+
f.clean();
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
test("the re-exec happens once, and a pinned guard stops it", () => {
|
|
1674
|
+
const f = mirrorFixture();
|
|
1675
|
+
const marker = path.join(f.root, "checkout-ran");
|
|
1676
|
+
checkoutStub(f.checkout, marker);
|
|
1677
|
+
try {
|
|
1678
|
+
const run = runShimIsolated(
|
|
1679
|
+
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", REA_BUNDLE_SHIM_REEXEC: "1" },
|
|
1680
|
+
["mcp"],
|
|
1681
|
+
);
|
|
1682
|
+
// The guard is set, so the mirrored copy runs its own logic: bundle pinned and
|
|
1683
|
+
// nothing provisioned means EX_CONFIG, and the checkout never starts.
|
|
1684
|
+
assert.equal(run.status, 78, run.stderr);
|
|
1685
|
+
assert.equal(existsSync(marker), false, "the guard must prevent a second hop");
|
|
1686
|
+
} finally {
|
|
1687
|
+
f.clean();
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
test("--shim-status reports a stale mirror instead of re-execing it", () => {
|
|
1692
|
+
const f = mirrorFixture();
|
|
1693
|
+
const marker = path.join(f.root, "checkout-ran");
|
|
1694
|
+
checkoutStub(f.checkout, marker);
|
|
1695
|
+
try {
|
|
1696
|
+
const run = runShimIsolated({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["--shim-status"]);
|
|
1697
|
+
const report = JSON.parse(run.stdout);
|
|
1698
|
+
assert.equal(report.mirror.stale, true, JSON.stringify(report.mirror));
|
|
1699
|
+
assert.match(report.mirror.reason, /different launcher/);
|
|
1700
|
+
assert.equal(existsSync(marker), false, "status is a query; it must not start anything");
|
|
1701
|
+
} finally {
|
|
1702
|
+
f.clean();
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test("an identical mirror is not stale and is not re-exec'd", () => {
|
|
1707
|
+
const f = mirrorFixture();
|
|
1708
|
+
const same = path.join(f.checkout, "bin", "rea-shim.mjs");
|
|
1709
|
+
copyFileSync(path.join(f.bundle, "bin", "rea-shim.mjs"), same);
|
|
1710
|
+
try {
|
|
1711
|
+
const run = runShimIsolated({ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" }, ["--shim-status"]);
|
|
1712
|
+
const report = JSON.parse(run.stdout);
|
|
1713
|
+
assert.equal(report.mirror.stale, false, JSON.stringify(report.mirror));
|
|
1714
|
+
assert.equal(report.mirror.reason, "this copy matches the plugin");
|
|
1715
|
+
} finally {
|
|
1716
|
+
f.clean();
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
test("a mirrored shim finds the real DSH home when the environment is scrubbed", () => {
|
|
1721
|
+
// The harness strips DSH_* from an MCP child, so with a custom DSH_HOME the
|
|
1722
|
+
// old code read ~/.dsh — the wrong file — and could never report upstream's
|
|
1723
|
+
// duplicate `serverName: rea`. setup records the home; the shim must use it.
|
|
1724
|
+
const f = mirrorFixture();
|
|
1725
|
+
const home = path.join(f.root, "custom-home");
|
|
1726
|
+
mkdirSync(home, { recursive: true });
|
|
1727
|
+
writeFileSync(
|
|
1728
|
+
path.join(home, "cordis.patch.yml"),
|
|
1729
|
+
"- insert:\n - id: rea-mcp\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: rea\n",
|
|
1730
|
+
);
|
|
1731
|
+
writeFileSync(
|
|
1732
|
+
path.join(f.bundle, "mirror.json"),
|
|
1733
|
+
JSON.stringify({ schema: 1, pluginRoot: f.checkout, mirroredAt: "2026-10-18T00:00:00Z", dshHome: home }),
|
|
1734
|
+
);
|
|
1735
|
+
try {
|
|
1736
|
+
const run = runShimIsolated(
|
|
1737
|
+
// DSH_HOME="" models the scrubbed environment: the variable exists, empty.
|
|
1738
|
+
{ REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", DSH_HOME: "" },
|
|
1739
|
+
["--shim-status"],
|
|
1740
|
+
);
|
|
1741
|
+
const report = JSON.parse(run.stdout);
|
|
1742
|
+
assert.equal(report.duplicateRegistration?.id, "rea-mcp", run.stdout);
|
|
1743
|
+
assert.equal(report.duplicateRegistration.patch, path.join(home, "cordis.patch.yml"));
|
|
1744
|
+
} finally {
|
|
1745
|
+
f.clean();
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
@@ -90,6 +90,14 @@ Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $r
|
|
|
90
90
|
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
|
|
91
91
|
$compat = & node scripts\host-compat.mjs 2>&1
|
|
92
92
|
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
|
|
93
|
+
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
|
|
94
|
+
# host-compat and the audit all agree while the sentence a human reads names a
|
|
95
|
+
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
|
|
96
|
+
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
|
|
97
|
+
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
|
|
98
|
+
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
|
|
99
|
+
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
|
|
100
|
+
}
|
|
93
101
|
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
|
|
94
102
|
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
|
|
95
103
|
}
|