bundle: give each shim copy the bundle root it should have

The previous round made the shim resolve its bundle from its own location, so a
custom DSH_HOME could not send it looking in ~/.dsh. That is right for the
mirrored copy the published row launches — but wrong for the checkout copy a
human runs, whose own directory is the plugin, not a bundle:

* `npm run bundle:status` reported "not provisioned" on a fully provisioned
  machine, and the status line setup prints pointed at that same copy;
* a bare `node bin/rea-shim.mjs mcp` would have provisioned ~800 MB into the
  plugin directory.

Only a checkout ships cordis.patch.yml, so that file now discriminates: the
mirrored copy keeps using its own position (an MCP child has no DSH_HOME to
consult), while the checkout resolves the same root setup.mjs writes — including
a custom --root, which is why the status hint now names the mirrored copy.

The audit guards the discriminator by rejecting a mirror list that would copy it
along; mirroring it would silently flip a mirrored copy back to the DSH_HOME
rule.

The README's "install the plugin first" paragraph was also false: the row points
at a file setup creates, so before the first provisioning run it exits with
MODULE_NOT_FOUND and nothing starts a background provisioning. It now says so and
names the one manual step.

Verified: 16/16 unit tests on Windows and on Linux — three of them spawn the shim
in both layouts, and reverting the fix turns the checkout one red; audit clean
with 5/5 deliberate sabotages caught; 25/25 end-to-end path checks; and the row →
shim → MCP handshake still returning rea 6.4.0 with 170 tools.
This commit is contained in:
czcdr committed 2026-10-14 18:38:21 +08:00
1 parent 0bf5c3eee2
commit 9f818e1f05
7 files changed
+145 -18

No files matched your search

+16 -3
View File
@@ -32,15 +32,28 @@
`path.resolve`(相对 root 会让 `bundle.json` 记下相对路径,shim 随即误判组件"已消失"并重新供给)。
- **`fail()` 的修复命令**不再指向"存在但必然崩溃"的镜像副本;没有可信副本时给出明确指引。
- **`check-updates.ps1`** 增加「镜像 manifest 是否就位」检查,指向上面那条升级路径。
- **shim 的两份副本各自解析出正确的 bundle root。** 上面那条"用自身位置"只在**镜像副本**上成立:
插件 checkout 里的那份是插件而不是 bundle,于是 `npm run bundle:status` 会在已供给的机器上报
"未供给",而裸跑 `node bin/rea-shim.mjs mcp` 会把约 800 MB 下载进**插件目录**。现在用
`cordis.patch.yml`(只有 checkout 会带)区分两者:镜像副本用自身位置(MCP 子进程里读不到
`DSH_HOME`),checkout 副本按与 setup 相同的规则解析。setup 结尾打印的 `status:` 命令也改为指向
镜像副本,这样它对自定义 `--root` 同样成立。
- **README 的「先装插件也可以」改为诚实描述。** 发布的行指向的镜像 shim **由 setup 创建**,所以在第一次
供给之前该行以 `MODULE_NOT_FOUND` 退出 —— 没有任何东西能启动后台供给。首次必须手动跑一次
`setup.mjs`,此后才由 shim 自动补供给。
### 回归防护
- `audit-manifest.mjs` 新增四项,全部做过反向验证(**故意写坏 4/4 都被抓住**):`!!js` 裸标量 mapping
检测、`DSH_HOME` 三态(未设/空/已设)求值一致性、README 与 patch 表达式不得漂移、镜像清单必须含 manifest。
- `audit-manifest.mjs` 新增五项,全部做过反向验证(**故意写坏 5/5 都被抓住**):`!!js` 裸标量 mapping
检测、`DSH_HOME` 三态(未设/空/已设)求值一致性、README 与 patch 表达式不得漂移、镜像清单必须含
manifest、镜像清单**不得**携带 `cordis.patch.yml`(它是 shim 区分两份副本的判据,被镜像进去会让镜像
副本退回 `DSH_HOME` 规则)。
- `validate.yml`:YAML 步骤注册 `!!js` 标量构造器(原先 `safe_load` 直接拒绝这个仓库要发布的行 ——
**CI 一直是红的**);行契约步骤断言 `args[0]` 是标量、不是 mapping;新增 `bundle:test` 与 `bundle:audit` 两步。
- 单元测试新增三条**真的去 spawn shim**的用例(镜像布局、checkout 布局、`REA_BUNDLE_ROOT` 覆盖),
并做了反向验证:把 shim 改回"一律用自身位置"时,checkout 那条会红。
- 单元测试里的 POSIX 退化断言加平台守卫(`EXE` 为空时 `endsWith("java")` 恒真,在 Linux 上必红;
WSL 实测修前 12/13、修后 13/13)。
WSL 实测修前 12/13、修后 16/16)。
### 升级说明
+11 -3
View File
@@ -86,9 +86,12 @@ If Node is already installed, you can skip the bootstrap and run provisioning di
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
```
To install the plugin **first** instead, that also works: the MCP row starts, finds no bundle state, reports
exactly what to run, and starts provisioning in the background. The harness is never blocked —
`failOnStartupError: false` is deliberate.
Installing the plugin **first** also works, but it takes two steps. The row resolves to
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, and that file is created by setup — so until the first
provisioning run the row exits with `MODULE_NOT_FOUND`, and nothing is there to start a background
provisioning (the harness itself is unaffected: `failOnStartupError: false` is deliberate). Run
`node scripts/setup.mjs` once; from then on the shim is in place, and every later missing state is picked up
by the shim itself, which provisions in the background.
## Configure — nothing
@@ -130,6 +133,11 @@ node bin/rea-shim.mjs --shim-status # resolved paths, versions, verificatio
node bin/rea-shim.mjs --shim-print-env # the environment the MCP child will see
```
Both commands answer with the same bundle whether you run them from the plugin directory (the checkout copy)
or from `<DSH_HOME>/plugins/rea-bundle` (the mirrored copy): the checkout resolves the location by the same
rule setup uses, while the mirror uses its own position — it cannot rely on `DSH_HOME`, which the harness
strips from an MCP child's environment.
Setting `REA_BUNDLE_AUTO_PROVISION=0` disables the first-run background provisioning.
To pin a different launch command on this machine (for example your own globally installed `rea`), override
+8 -2
View File
@@ -80,8 +80,10 @@ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#feat/one-plugin-b
node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
```
反过来**先装插件**也可以:那一行会启动、发现没有本机状态、把该跑的命令原样告诉你,并在后台开始供给。
harness 永远不会被卡住 —— `failOnStartupError: false` 是刻意的。
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
## 配置 —— 不需要
@@ -123,6 +125,10 @@ node bin/rea-shim.mjs --shim-status # 解析到的路径、版本、校验
node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
```
这两个命令在**插件目录**(checkout 副本)或 `<DSH_HOME>/plugins/rea-bundle`(镜像副本)里跑都给出同一
答案:checkout 副本按与 setup 相同的规则解析供给位置,镜像副本用自身位置(因为 MCP 子进程的环境里
`DSH_*` 会被剥掉,它不能依赖 `DSH_HOME`)。
设 `REA_BUNDLE_AUTO_PROVISION=0` 可关掉首次运行的后台自动供给。
想在本机把这一行改成别的启动命令(例如用你自己全局装的 `rea`),从 profile 自己的
+24 -8
View File
@@ -16,6 +16,7 @@
*/
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
@@ -24,24 +25,39 @@ const EX_CONFIG = 78;
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = path.resolve(HERE, "..");
/** DSH home, by the same rule setup.mjs and the published patch use. */
function dshHome() {
const fromEnv = process.env.DSH_HOME;
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
return path.join(homedir(), ".dsh");
}
/**
* Where setup writes the machine-local truth.
*
* The authoritative answer is this file's own location: the row launches
* `<bundle>/bin/rea-shim.mjs`, so the bundle root is the directory above `bin/`.
* Two copies of this file exist and they need different answers, so the copy has
* to be identified before the root can be:
*
* DSH_HOME must NOT be consulted here. The harness strips every `DSH_*` name from
* an MCP child's environment (case-insensitively, because Windows is), so this
* process never sees a custom DSH_HOME: falling back to `~/.dsh` would look for
* bundle.json in the wrong home, report "not provisioned" for a bundle sitting
* right next to it, and then start a second ~800 MB provisioning run there.
* * The **mirrored** copy at `<bundle>/bin/rea-shim.mjs` is what the published
* row launches, and its own location is the bundle root. It must not consult
* 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.
* * 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
* `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.
* REA_BUNDLE_ROOT stays as an explicit override for tests and unusual layouts.
*/
function bundleRoot() {
const override = process.env.REA_BUNDLE_ROOT;
if (override && override.trim() !== "") return path.resolve(override);
return PLUGIN_ROOT;
if (!existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"))) return PLUGIN_ROOT;
return path.join(dshHome(), "plugins", "rea-bundle");
}
/**
+13
View File
@@ -372,6 +372,19 @@ if (manifest) {
add("error", "scripts/setup.mjs", `mirrorLauncher() does not mirror ${needed}, which the mirrored copy reads at run time`);
}
}
// The shim tells its two copies apart by `cordis.patch.yml`, which only the
// checkout ships: a mirrored copy resolves its bundle from its own location
// (an MCP child has no DSH_HOME to consult), a checkout copy resolves the
// same root setup writes. Mirroring the discriminator would silently flip a
// mirrored copy back to the DSH_HOME rule — wrong wherever DSH_HOME is
// custom, and wrong in every MCP child, where it is stripped.
if (/cordis\.patch\.yml/.test(sourcesBody)) {
add(
"error",
"scripts/setup.mjs",
"the mirror list ships cordis.patch.yml, which bin/rea-shim.mjs uses to tell a checkout from a mirrored copy — a mirrored copy would then resolve its bundle through DSH_HOME, which an MCP child does not have",
);
}
}
}
+4 -1
View File
@@ -1363,7 +1363,10 @@ if (options.json) {
// lock, but the shim treats a lock older than six hours as stale.
rmSync(path.join(options.root, "setup.lock"), { force: true });
log("restart DSH (or reload the profile) so the MCP row reconnects.");
log(`status: node ${path.join(PLUGIN_ROOT, "bin", "rea-shim.mjs")} --shim-status`);
// Point at the mirrored copy, not at this checkout's: the mirror resolves its
// bundle from its own location, so this command is right for the root this run
// actually provisioned — including a custom --root.
log(`status: node ${path.join(options.root, "bin", "rea-shim.mjs")} --shim-status`);
} else {
log("some components are unmet; fix the cause and re-run this command.");
}
+69 -1
View File
@@ -11,10 +11,12 @@
* Runs with `node --test`. No network, no filesystem writes outside a temp dir.
*/
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
/* ------------------------------------------------- probe token substitution */
@@ -191,3 +193,69 @@ test("byte sizes format without a floating-point surprise", () => {
assert.equal(mib(569649598), "543.3");
assert.equal(mib(8044989), "7.7");
});
/* ------------------------------------------------------ the shim's two homes */
// The shim runs from two places and must answer differently in each:
// * the mirrored copy the published row launches sits at <bundle>/bin/ and must
// use its own location — an MCP child has no DSH_HOME to consult, because the
// harness strips every DSH_* name from it;
// * the checkout copy a human runs (`npm run bundle:status`) is a plugin, not a
// bundle, and must resolve the same root setup.mjs writes.
// Both mistakes are silent: the checkout claims "not provisioned" on a machine
// that is provisioned, and a bare `node bin/rea-shim.mjs mcp` provisions ~800 MB
// into the plugin directory.
const SHIM_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "rea-shim.mjs");
function shimBundleRoot(shimFile, env) {
const run = spawnSync(process.execPath, [shimFile, "--shim-status"], {
encoding: "utf8",
env: { ...process.env, REA_BUNDLE_AUTO_PROVISION: "0", ...env },
});
// --shim-status prints its report even when no bundle exists yet (exit 78).
return JSON.parse(run.stdout).bundleRoot;
}
test("a mirrored shim takes its bundle root from its own location, not DSH_HOME", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-mirror-"));
try {
const mirror = path.join(root, "plugins", "rea-bundle");
mkdirSync(path.join(mirror, "bin"), { recursive: true });
copyFileSync(SHIM_SOURCE, path.join(mirror, "bin", "rea-shim.mjs"));
assert.equal(shimBundleRoot(path.join(mirror, "bin", "rea-shim.mjs"), { DSH_HOME: path.join(root, "elsewhere") }), mirror);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("a checkout shim resolves the bundle root setup.mjs would write", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-checkout-"));
try {
const checkout = path.join(root, "checkout");
mkdirSync(path.join(checkout, "bin"), { recursive: true });
copyFileSync(SHIM_SOURCE, path.join(checkout, "bin", "rea-shim.mjs"));
// The discriminator: only a plugin checkout ships cordis.patch.yml.
writeFileSync(path.join(checkout, "cordis.patch.yml"), "- insert: []\n");
const home = path.join(root, "dsh-home");
assert.equal(
shimBundleRoot(path.join(checkout, "bin", "rea-shim.mjs"), { DSH_HOME: home }),
path.join(home, "plugins", "rea-bundle"),
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("REA_BUNDLE_ROOT still overrides both layouts", () => {
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-override-"));
try {
const checkout = path.join(root, "checkout");
mkdirSync(path.join(checkout, "bin"), { recursive: true });
copyFileSync(SHIM_SOURCE, path.join(checkout, "bin", "rea-shim.mjs"));
writeFileSync(path.join(checkout, "cordis.patch.yml"), "- insert: []\n");
const forced = path.join(root, "forced");
assert.equal(shimBundleRoot(path.join(checkout, "bin", "rea-shim.mjs"), { REA_BUNDLE_ROOT: forced }), forced);
} finally {
rmSync(root, { recursive: true, force: true });
}
});