dsh-plugin-rea 2.0.7 → 2.0.9

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/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
- $profiles = @(Get-ChildItem (Join-Path $env:USERPROFILE '.dsh\profiles') -Directory -ErrorAction SilentlyContinue)
110
- $ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $env:USERPROFILE '.dsh\profiles\desktop' }
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) { Ok 'MCP 行指向的 shim 已就位' }
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 崩掉且不可见,
@@ -83,7 +83,10 @@ through `failOnStartupError: false` without breaking the harness.
83
83
  ```
84
84
  dsh starts
85
85
  └─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
86
- ├─ bundle.json present & components verify → exec REA (normal path)
86
+ ├─ mirrored copy differs from the installed plugin → re-exec that copy, once
87
+ ├─ a working `rea` on PATH is at least as new as the bundle
88
+ │ → exec it (the 1.x contract)
89
+ ├─ bundle.json present & components verify → exec the provisioned REA
87
90
  ├─ missing, auto-provision allowed → start setup detached, print
88
91
  │ remediation, exit non-zero
89
92
  └─ missing, auto-provision refused → print exact command, exit
@@ -129,6 +132,26 @@ Two notes on stages that exist because their absence was a bug:
129
132
  harness's own dialect, `args[0]` became `{"[object Object]": "..."}` and the
130
133
  row could not launch at all. Any future expression with a ternary needs the
131
134
  quotes; `audit-manifest.mjs` rejects the bare form.
135
+ - **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
136
+ machine that already has one should not be provisioned. Before touching bundle
137
+ state the shim asks whether `rea` is on PATH and answers `--version`; if it
138
+ does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
139
+ the row execs that install exactly as the 1.x row did. The probe is asynchronous
140
+ and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
141
+ `REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
142
+ wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
143
+ on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
144
+ unorderable pair (a prerelease on either side) keeps the bundle.
145
+ - **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
146
+ runs no setup, so without this the copy keeps executing the code it was made
147
+ from — invisibly, because it still works. The copy therefore compares its own
148
+ hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
149
+ when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
150
+ mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
151
+ copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
152
+ the last one because the harness strips `DSH_*` from an MCP child, so the
153
+ duplicate-registration check would otherwise read `~/.dsh` on a machine with a
154
+ custom home. `--shim-status` reports `mirror.stale` instead of hopping.
132
155
  - **3 (fetch).** Route order is deliberate and environment-dependent: direct
133
156
  first, then the system proxy, and on Windows the .NET route ahead of both
134
157
  because it follows the GitHub release-asset redirect that the Node routes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-rea",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
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
@@ -954,6 +954,21 @@ function reuseExisting(name, root) {
954
954
 
955
955
  /* ------------------------------------------------------------------- manifest */
956
956
 
957
+ /**
958
+ * JSON from disk, tolerating the UTF-8 BOM a Windows editor writes.
959
+ *
960
+ * `JSON.parse` rejects a leading BOM, and every caller in this file treats a
961
+ * parse failure as "no record". A BOM-ed mirror.json therefore meant "this is
962
+ * not a mirrored run", so setup went on to re-mirror the previous manifest;
963
+ * a BOM-ed bundle.json meant "nothing is provisioned". `bin/rea-shim.mjs`
964
+ * carries the same helper — the two must agree, or the same file means two
965
+ * different things depending on which one read it (a hand-edited record with
966
+ * PowerShell's BOM is exactly the case that produced this).
967
+ */
968
+ function readJson(file) {
969
+ return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
970
+ }
971
+
957
972
  /**
958
973
  * Where this run's plugin files actually live.
959
974
  *
@@ -973,7 +988,7 @@ function sourceRoot() {
973
988
  const self = path.resolve(PLUGIN_ROOT);
974
989
  if (!samePath(self, options.root)) return self;
975
990
  try {
976
- const record = JSON.parse(readFileSync(path.join(options.root, "mirror.json"), "utf8"));
991
+ const record = readJson(path.join(options.root, "mirror.json"));
977
992
  const recorded = record?.pluginRoot;
978
993
  if (typeof recorded === "string" && recorded.trim() !== "") {
979
994
  const candidate = path.resolve(recorded);
@@ -987,7 +1002,7 @@ function sourceRoot() {
987
1002
 
988
1003
  function loadManifest() {
989
1004
  const file = path.join(sourceRoot(), "manifest", "bundle.manifest.json");
990
- const parsed = JSON.parse(readFileSync(file, "utf8"));
1005
+ const parsed = readJson(file);
991
1006
  return parsed;
992
1007
  }
993
1008
 
@@ -1002,7 +1017,7 @@ function loadManifest() {
1002
1017
  function readBundleJson(file) {
1003
1018
  if (!existsSync(file)) return null;
1004
1019
  try {
1005
- const parsed = JSON.parse(readFileSync(file, "utf8"));
1020
+ const parsed = readJson(file);
1006
1021
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1007
1022
  } catch (error) {
1008
1023
  log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
@@ -1636,6 +1651,31 @@ function mirrorLauncher(context) {
1636
1651
  */
1637
1652
  function writeMirrorRecord(context, source) {
1638
1653
  const record = { schema: 1, pluginRoot: source, mirroredAt: new Date().toISOString() };
1654
+ // Provenance for the self-healing check: the mirrored shim compares itself
1655
+ // with `<pluginRoot>/bin/rea-shim.mjs` and re-execs that one when the plugin
1656
+ // has moved on. These two fields are what `--shim-status` shows when the
1657
+ // comparison cannot be made (missing checkout, unreadable file).
1658
+ try {
1659
+ record.shimSha256 = createHash("sha256")
1660
+ .update(readFileSync(path.join(context.root, "bin", "rea-shim.mjs")))
1661
+ .digest("hex");
1662
+ } catch {
1663
+ /* the copy is missing or unreadable; the shim reports the comparison itself */
1664
+ }
1665
+ try {
1666
+ record.pluginVersion = readJson(path.join(source, "package.json")).version ?? null;
1667
+ } catch {
1668
+ record.pluginVersion = null;
1669
+ }
1670
+ // The MCP child the harness starts has every DSH_* variable stripped, so the
1671
+ // mirrored shim cannot re-derive a CUSTOM home from its environment — it would
1672
+ // read `~/.dsh` and miss a duplicate `serverName: rea` living in the real one.
1673
+ // Record the home setup ran under; the shim prefers the environment, then this.
1674
+ try {
1675
+ record.dshHome = dshHome();
1676
+ } catch {
1677
+ record.dshHome = null;
1678
+ }
1639
1679
  try {
1640
1680
  mkdirSync(context.root, { recursive: true });
1641
1681
  writeFileSync(path.join(context.root, "mirror.json"), JSON.stringify(record, null, 2) + "\n");
@@ -1313,13 +1313,13 @@ function writeFakeRea(dir, { version = "6.9.0", exitForVersion = 0, runExit = 0,
1313
1313
  return file;
1314
1314
  }
1315
1315
 
1316
- function runShimIsolated(env, args) {
1316
+ function runShimIsolated(env, args, shim = path.join(REPO_ROOT, "bin", "rea-shim.mjs")) {
1317
1317
  const merged = { ...process.env, ...env };
1318
1318
  // Windows keeps PATH and Path as separate variables (both are "in" the
1319
1319
  // environment), and a child that reads Path while the test set PATH would see
1320
1320
  // the machine's real search path instead of the fixture's.
1321
1321
  if (process.platform === "win32" && Object.prototype.hasOwnProperty.call(env, "PATH")) delete merged.Path;
1322
- return spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), ...args], {
1322
+ return spawnSync(process.execPath, [shim, ...args], {
1323
1323
  encoding: "utf8",
1324
1324
  timeout: 60000,
1325
1325
  env: merged,
@@ -1564,3 +1564,328 @@ 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
+ // The mirrored copy is what the row launches, and the checkout guard means a
1666
+ // checkout shim refuses to hop at all — so the fixture must be exercised as
1667
+ // the mirror it is meant to be.
1668
+ const run = runShimIsolated(
1669
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
1670
+ ["mcp"],
1671
+ path.join(f.bundle, "bin", "rea-shim.mjs"),
1672
+ );
1673
+ assert.equal(run.status, 7, `expected the checkout's status, got ${run.status}: ${run.stderr}`);
1674
+ assert.equal(existsSync(marker), true, "the checkout launcher must have run");
1675
+ } finally {
1676
+ f.clean();
1677
+ }
1678
+ });
1679
+
1680
+ test("the re-exec happens once, and a pinned guard stops it", () => {
1681
+ const f = mirrorFixture();
1682
+ const marker = path.join(f.root, "checkout-ran");
1683
+ checkoutStub(f.checkout, marker);
1684
+ try {
1685
+ const run = runShimIsolated(
1686
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", REA_BUNDLE_SHIM_REEXEC: "1" },
1687
+ ["mcp"],
1688
+ path.join(f.bundle, "bin", "rea-shim.mjs"),
1689
+ );
1690
+ // The guard is set, so the mirrored copy runs its own logic: bundle pinned and
1691
+ // nothing provisioned means EX_CONFIG, and the checkout never starts.
1692
+ assert.equal(run.status, 78, run.stderr);
1693
+ assert.equal(existsSync(marker), false, "the guard must prevent a second hop");
1694
+ } finally {
1695
+ f.clean();
1696
+ }
1697
+ });
1698
+
1699
+ test("--shim-status reports a stale mirror instead of re-execing it", () => {
1700
+ const f = mirrorFixture();
1701
+ const marker = path.join(f.root, "checkout-ran");
1702
+ checkoutStub(f.checkout, marker);
1703
+ try {
1704
+ const run = runShimIsolated(
1705
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
1706
+ ["--shim-status"],
1707
+ path.join(f.bundle, "bin", "rea-shim.mjs"),
1708
+ );
1709
+ const report = JSON.parse(run.stdout);
1710
+ assert.equal(report.mirror.stale, true, JSON.stringify(report.mirror));
1711
+ assert.match(report.mirror.reason, /different launcher/);
1712
+ assert.equal(existsSync(marker), false, "status is a query; it must not start anything");
1713
+ } finally {
1714
+ f.clean();
1715
+ }
1716
+ });
1717
+
1718
+ test("a checkout shim does not hop into the installed plugin", () => {
1719
+ const f = mirrorFixture();
1720
+ const marker = path.join(f.root, "checkout-ran");
1721
+ checkoutStub(f.checkout, marker);
1722
+ try {
1723
+ // The repository's own shim is a CHECKOUT (it ships cordis.patch.yml). A
1724
+ // developer running it must keep testing the edit in front of them, not a
1725
+ // different plugin that the bundle's record happens to name.
1726
+ const run = runShimIsolated(
1727
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
1728
+ ["mcp"],
1729
+ );
1730
+ assert.equal(run.status, 78, run.stderr);
1731
+ assert.equal(existsSync(marker), false, "a checkout must never hop into another copy");
1732
+ } finally {
1733
+ f.clean();
1734
+ }
1735
+ });
1736
+
1737
+ test("a re-exec whose file vanishes mid-flight falls back to the mirrored copy", () => {
1738
+ // The upgrade race the spawn handshake cannot see: the file existed when the
1739
+ // mirror compared hashes, node started, and the file was replaced before node
1740
+ // read it — node exits 1 with MODULE_NOT_FOUND. Passing that through turns a
1741
+ // millisecond race into a dead row, so a non-zero exit with the source gone
1742
+ // must fall back instead.
1743
+ const f = mirrorFixture();
1744
+ const marker = path.join(f.root, "checkout-ran");
1745
+ const file = path.join(f.checkout, "bin", "rea-shim.mjs");
1746
+ writeFileSync(
1747
+ file,
1748
+ 'import { rmSync, writeFileSync } from "node:fs";\n' +
1749
+ `writeFileSync(${JSON.stringify(marker)}, "checkout");\n` +
1750
+ `rmSync(${JSON.stringify(file)});\n` +
1751
+ "process.exit(3);\n",
1752
+ );
1753
+ try {
1754
+ const run = runShimIsolated(
1755
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
1756
+ ["mcp"],
1757
+ path.join(f.bundle, "bin", "rea-shim.mjs"),
1758
+ );
1759
+ assert.equal(existsSync(marker), true, "the checkout launcher did run");
1760
+ assert.equal(run.status, 78, `expected the local copy's status, got ${run.status}: ${run.stderr}`);
1761
+ assert.match(run.stderr, /running the mirrored copy instead/);
1762
+ } finally {
1763
+ f.clean();
1764
+ }
1765
+ });
1766
+
1767
+ test("an identical mirror is not stale and is not re-exec'd", () => {
1768
+ const f = mirrorFixture();
1769
+ const same = path.join(f.checkout, "bin", "rea-shim.mjs");
1770
+ copyFileSync(path.join(f.bundle, "bin", "rea-shim.mjs"), same);
1771
+ try {
1772
+ const run = runShimIsolated(
1773
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle" },
1774
+ ["--shim-status"],
1775
+ path.join(f.bundle, "bin", "rea-shim.mjs"),
1776
+ );
1777
+ const report = JSON.parse(run.stdout);
1778
+ assert.equal(report.mirror.stale, false, JSON.stringify(report.mirror));
1779
+ assert.equal(report.mirror.reason, "this copy matches the plugin");
1780
+ } finally {
1781
+ f.clean();
1782
+ }
1783
+ });
1784
+
1785
+ test("a mirrored shim finds the real DSH home when the environment is scrubbed", () => {
1786
+ // The harness strips DSH_* from an MCP child, so with a custom DSH_HOME the
1787
+ // old code read ~/.dsh — the wrong file — and could never report upstream's
1788
+ // duplicate `serverName: rea`. setup records the home; the shim must use it.
1789
+ const f = mirrorFixture();
1790
+ const home = path.join(f.root, "custom-home");
1791
+ mkdirSync(home, { recursive: true });
1792
+ writeFileSync(
1793
+ path.join(home, "cordis.patch.yml"),
1794
+ "- insert:\n - id: rea-mcp\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n serverName: rea\n",
1795
+ );
1796
+ writeFileSync(
1797
+ path.join(f.bundle, "mirror.json"),
1798
+ JSON.stringify({ schema: 1, pluginRoot: f.checkout, mirroredAt: "2026-10-18T00:00:00Z", dshHome: home }),
1799
+ );
1800
+ try {
1801
+ const run = runShimIsolated(
1802
+ // DSH_HOME="" models the scrubbed environment: the variable exists, empty.
1803
+ { REA_BUNDLE_ROOT: f.bundle, REA_BUNDLE_AUTO_PROVISION: "0", REA_BUNDLE_LAUNCH: "bundle", DSH_HOME: "" },
1804
+ ["--shim-status"],
1805
+ );
1806
+ const report = JSON.parse(run.stdout);
1807
+ assert.equal(report.duplicateRegistration?.id, "rea-mcp", run.stdout);
1808
+ assert.equal(report.duplicateRegistration.patch, path.join(home, "cordis.patch.yml"));
1809
+ } finally {
1810
+ f.clean();
1811
+ }
1812
+ });
1813
+
1814
+ /* ------------------------------------ records a Windows editor has BOM-ed */
1815
+
1816
+ // `bin/rea-shim.mjs` strips the UTF-8 BOM that PowerShell and Notepad write;
1817
+ // setup.mjs kept parsing raw. Both programs read the same two records, so a BOM
1818
+ // used to mean two different things: the shim found the checkout, setup did not.
1819
+ // A mirrored setup then re-mirrored the previous manifest and provisioned from
1820
+ // it, and a BOM-ed bundle.json read as "nothing is provisioned". These tests pin
1821
+ // setup's side of the contract, both files.
1822
+
1823
+ /** A checkout with a manifest whose node probe accepts any node, so `--only node` is deterministic. */
1824
+ function bomFixtureCheckout(root) {
1825
+ const checkout = path.join(root, "checkout");
1826
+ mkdirSync(path.join(checkout, "scripts"), { recursive: true });
1827
+ mkdirSync(path.join(checkout, "manifest"), { recursive: true });
1828
+ copyFileSync(SETUP_SOURCE, path.join(checkout, "scripts", "setup.mjs"));
1829
+ const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
1830
+ manifest.components.node.probe.expect.regex = "^v";
1831
+ writeFileSync(path.join(checkout, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2));
1832
+ writeFileSync(path.join(checkout, "package.json"), JSON.stringify({ name: "dsh-plugin-rea", version: "0.0.0-bom" }));
1833
+ return checkout;
1834
+ }
1835
+
1836
+ test("a BOM-ed mirror.json still points a mirrored setup at the real checkout", () => {
1837
+ const root = mkdtempSync(path.join(tmpdir(), "rea-bom-mirror-"));
1838
+ try {
1839
+ const checkout = bomFixtureCheckout(root);
1840
+ const bundle = path.join(root, "bundle");
1841
+ mkdirSync(path.join(bundle, "scripts"), { recursive: true });
1842
+ copyFileSync(SETUP_SOURCE, path.join(bundle, "scripts", "setup.mjs"));
1843
+ // Exactly what `Out-File` / `Set-Content` leaves behind, and what a human
1844
+ // editing the record by hand produces on Windows.
1845
+ writeFileSync(
1846
+ path.join(bundle, "mirror.json"),
1847
+ "\uFEFF" + JSON.stringify({ schema: 1, pluginRoot: checkout, mirroredAt: "2026-10-18T00:00:00Z" }),
1848
+ );
1849
+ // The bundle has no manifest of its own: only the record can supply one.
1850
+ assert.equal(existsSync(path.join(bundle, "manifest", "bundle.manifest.json")), false);
1851
+
1852
+ const run = spawnSync(
1853
+ process.execPath,
1854
+ [path.join(bundle, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle],
1855
+ { encoding: "utf8", timeout: 120000 },
1856
+ );
1857
+ assert.equal(run.status, 0, run.stderr);
1858
+ // loadManifest() runs before any component: reaching the plan proves the
1859
+ // record was parsed. A BOM-blind parse fell through to the bundle and died
1860
+ // on a missing manifest.
1861
+ assert.match(run.stdout, /\[plan\] node/);
1862
+ } finally {
1863
+ rmSync(root, { recursive: true, force: true });
1864
+ }
1865
+ });
1866
+
1867
+ test("a BOM-ed bundle.json is read instead of treated as unprovisioned", () => {
1868
+ const root = mkdtempSync(path.join(tmpdir(), "rea-bom-bundle-"));
1869
+ try {
1870
+ const checkout = bomFixtureCheckout(root);
1871
+ const bundle = path.join(root, "bundle");
1872
+ mkdirSync(bundle, { recursive: true });
1873
+ const args = [path.join(checkout, "scripts", "setup.mjs"), "--dry-run", "--only", "node", "--root", bundle];
1874
+
1875
+ // Control: with no record at all the component is planned, so the assertion
1876
+ // below cannot pass for a reason unrelated to reading the file.
1877
+ const fresh = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
1878
+ assert.equal(fresh.status, 0, fresh.stderr);
1879
+ assert.match(fresh.stdout, /\[plan\] node/);
1880
+
1881
+ writeFileSync(
1882
+ path.join(bundle, "bundle.json"),
1883
+ "\uFEFF" + JSON.stringify({ schema: 1, components: { node: { path: process.execPath, version: "v-bom-test" } } }, null, 2),
1884
+ );
1885
+ const run = spawnSync(process.execPath, args, { encoding: "utf8", timeout: 120000 });
1886
+ assert.equal(run.status, 0, run.stderr);
1887
+ assert.match(run.stdout, /already provisioned \(v-bom-test\)/, run.stdout);
1888
+ } finally {
1889
+ rmSync(root, { recursive: true, force: true });
1890
+ }
1891
+ });
@@ -17,6 +17,31 @@ foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\aud
17
17
  Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
18
18
  }
19
19
 
20
+ Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
21
+ # bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
22
+ # A BOM strip on one side only makes one file mean "there is a checkout" to the
23
+ # shim and "there is none" to setup, and the mirrored setup then re-mirrors the
24
+ # previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
25
+ $jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
26
+ @'
27
+ const fs = require("node:fs");
28
+ const blind = [];
29
+ for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
30
+ const text = fs.readFileSync(file, "utf8");
31
+ for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
32
+ // readJson() itself is the strip; anything else must not parse a file raw.
33
+ if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
34
+ blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
35
+ }
36
+ }
37
+ if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
38
+ }
39
+ console.log("bom-blind reads: " + JSON.stringify(blind));
40
+ if (blind.length) process.exit(1);
41
+ '@ | Set-Content $jsBom -Encoding utf8
42
+ & node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
43
+ Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
44
+
20
45
  Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
21
46
  # This script checks itself too: it is edited as often as the others, and an editor
22
47
  # 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
90
115
  Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
91
116
  $compat = & node scripts\host-compat.mjs 2>&1
92
117
  Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
118
+ # Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
119
+ # host-compat and the audit all agree while the sentence a human reads names a
120
+ # different ceiling. That is exactly how `<0.3.0` survived in the docs after the
121
+ # declaration moved to `<0.3.0-0`, so the comparison lives here now.
122
+ foreach ($doc in @('README.md', 'README.zh-CN.md')) {
123
+ $text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
124
+ Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
125
+ # The pin example is copy-pasteable, so it must name a ref that will exist: the
126
+ # version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
127
+ # then v2.0.8 while the tree was 2.0.9), and both times every other signal was
128
+ # green because nothing compared the two.
129
+ Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
130
+ }
93
131
  foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
94
132
  Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
95
133
  }