mirror of
https://github.com/czcdr/dsh-plugin-rea.git
synced 2026-11-07 10:25:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95803a6833 | ||
|
|
7b02aa95b6 | ||
|
|
ebb912ace2 | ||
|
|
3f413e1989 | ||
|
|
edf3968319 | ||
|
|
e3b2a064af | ||
|
|
3b57a9d1ac | ||
|
|
5c22588001 | ||
|
|
0e3aa428bf | ||
|
|
37d2adb17a | ||
|
|
a6ee476bfd | ||
|
|
d0e2ffd7a6 | ||
|
|
27d3f1106c | ||
|
|
f6afdea83d | ||
|
|
975ac5aac8 | ||
|
|
98e3bd0863 | ||
|
|
3138bd15c3 | ||
|
|
83bd2c70fe | ||
|
|
84fc9b1611 | ||
|
|
9d86f828bf | ||
|
|
65a2963029 | ||
|
|
419235ad45 | ||
|
|
30beb7f622 | ||
|
|
9f818e1f05 | ||
|
|
0bf5c3eee2 | ||
|
|
8f1ea15f2a | ||
|
|
422e4f2454 | ||
|
|
4a6d2e0a79 | ||
|
|
4689a53237 | ||
|
|
b10b016a35 | ||
|
|
727ead6a14 | ||
|
|
f4d8910fcb | ||
|
|
818eb082be | ||
|
|
7aeb83ce30 | ||
|
|
3bcd313c7b | ||
|
|
904425d992 | ||
|
|
4ce902d58d |
No files matched your search
@@ -71,7 +71,31 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: npm CLI must support OIDC trusted publishing (>= 11.15.0)
|
||||
run: npm install -g npm@latest && npm --version
|
||||
# Pinned to the major that provides trusted publishing. `npm@latest` would
|
||||
# silently follow npm into a future major on release day, which is the one
|
||||
# moment a publishing pipeline should not acquire new behaviour.
|
||||
run: |
|
||||
npm install -g 'npm@^11'
|
||||
npm --version
|
||||
|
||||
- name: The version being published is tagged and carries its release asset
|
||||
# workflow_dispatch can start this job with no Release behind it, and then npm
|
||||
# and the GitHub tarball drift apart: the market's fallback link points at the
|
||||
# release asset, so publishing a version whose asset does not exist ships a 404
|
||||
# to half the install paths.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="v$(node -p "require('./package.json').version")"
|
||||
if ! git ls-remote --tags --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then
|
||||
echo "FAIL: no tag $ver on origin — publish the GitHub Release first"
|
||||
exit 1
|
||||
fi
|
||||
if ! gh release view "$ver" --json assets --jq '.assets[].name' | grep -qx 'dsh-plugin-rea.tgz'; then
|
||||
echo "FAIL: release $ver has no dsh-plugin-rea.tgz asset — the market link would 404"
|
||||
exit 1
|
||||
fi
|
||||
echo "$ver is tagged and carries the version-free tarball"
|
||||
|
||||
- name: Publish to npm via trusted publishing
|
||||
run: npm publish --provenance --access public
|
||||
+156
-11
@@ -11,6 +11,11 @@ permissions:
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
# The shim's launch decision reads this machine's REA. No runner has one, so
|
||||
# the default `auto` would happen to take the bundle path today — pin it so
|
||||
# that stays true if an image ever ships REA.
|
||||
env:
|
||||
REA_BUNDLE_LAUNCH: bundle
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -25,13 +30,74 @@ jobs:
|
||||
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
|
||||
if (!p.dsh || !p.dsh.bundle || !p.dsh.bundle.patch) fail('package.json is missing dsh.bundle.patch');
|
||||
if (p.private) fail('package.json must not be private to be publishable');
|
||||
for (const f of ['cordis.patch.yml', 'README.md', 'LICENSE', 'check-updates.ps1']) {
|
||||
for (const f of ['cordis.patch.yml', 'index.mjs', 'README.md', 'LICENSE', 'check-updates.ps1']) {
|
||||
if (!p.files.includes(f)) fail('files[] must ship ' + f);
|
||||
}
|
||||
if (!require('fs').existsSync(p.dsh.bundle.patch)) fail('dsh.bundle.patch target does not exist');
|
||||
// The market's update path rolls a build back when package.json points
|
||||
// at no existing entry (dshmarket's hasLoadableEntry); this package
|
||||
// shipped no main at all until 2.0.5, so every update of a healthy
|
||||
// profile failed with "updated build has no loadable entry".
|
||||
if (typeof p.main !== 'string') fail('package.json must declare main');
|
||||
if (!require('fs').existsSync(p.main)) fail('main points at no shipped file: ' + p.main);
|
||||
// engines.dsh is what the market reads to say "compatible" instead of
|
||||
// "unknown"; without it the plugin is reported as undeclared.
|
||||
if (!p.engines || typeof p.engines.dsh !== 'string') fail('package.json must declare engines.dsh');
|
||||
console.log('manifest ok');
|
||||
"
|
||||
|
||||
- name: Declared DSH range covers every released host
|
||||
# A wrong range is silent on both sides: unparseable reads as "undeclared"
|
||||
# and a drifted floor drops hosts the README still lists.
|
||||
run: node scripts/host-compat.mjs
|
||||
|
||||
- name: The READMEs quote the declared DSH range verbatim
|
||||
# The same comparison verify-branch.ps1 makes locally. Without it here, a
|
||||
# stale quote survives every CI signal: the JSON, host-compat and the
|
||||
# audit all agree while the sentence a human reads names another ceiling
|
||||
# (that is how `<0.3.0` outlived the move to `<0.3.0-0`).
|
||||
run: |
|
||||
node -e "
|
||||
const p = require('./package.json');
|
||||
const { readFileSync } = require('fs');
|
||||
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
|
||||
for (const doc of ['README.md', 'README.zh-CN.md']) {
|
||||
const text = readFileSync(doc, 'utf8');
|
||||
if (!text.includes(p.engines.dsh)) fail(doc + ' does not quote engines.dsh (' + p.engines.dsh + ')');
|
||||
// The pin example is copy-pasteable: it must name the version this
|
||||
// tree publishes, or the reader gets a ref that does not exist.
|
||||
if (!text.includes('dsh-plugin-rea#v' + p.version)) fail(doc + ' does not pin dsh-plugin-rea#v' + p.version);
|
||||
}
|
||||
console.log('readme range ok');
|
||||
"
|
||||
|
||||
- name: Both JSON readers tolerate a BOM
|
||||
# verify-branch.ps1 runs the same check locally. bin/rea-shim.mjs and
|
||||
# scripts/setup.mjs read the same mirror.json/bundle.json, so a strip on
|
||||
# one side only makes the same file say "there is a checkout" to the shim
|
||||
# and "there is none" to setup — which shipped once: setup re-mirrored the
|
||||
# previous manifest. A bare JSON.parse(readFileSync(...)) is that bug.
|
||||
run: |
|
||||
node - <<'JS'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const blind = [];
|
||||
for (const file of ['bin/rea-shim.mjs', 'scripts/setup.mjs']) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
|
||||
// readJson() itself is the strip; anything else must not parse a file raw.
|
||||
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
|
||||
blind.push(file + ':' + text.slice(0, m.index).split('\n').length);
|
||||
}
|
||||
}
|
||||
if (!text.includes('replace(/^\\uFEFF/')) blind.push(file + ': no U+FEFF strip');
|
||||
}
|
||||
if (blind.length) {
|
||||
console.error('FAIL: BOM-blind JSON read at ' + blind.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('bom-blind reads: none');
|
||||
JS
|
||||
|
||||
- name: JSON files parse
|
||||
run: |
|
||||
node -e "
|
||||
@@ -44,16 +110,15 @@ jobs:
|
||||
- name: The npm payload ships every market-facing file
|
||||
run: |
|
||||
# Regression guard for files[]: the market reads screenshots.json and the
|
||||
# images next to package.json, and a user copies examples/ by hand — none of
|
||||
# that is worth anything if npm pack drops it.
|
||||
# images next to package.json — none of that is worth anything if npm pack
|
||||
# drops it.
|
||||
node -e "
|
||||
const { execSync } = require('child_process');
|
||||
const meta = JSON.parse(execSync('npm pack --dry-run --json', { encoding: 'utf8' }))[0];
|
||||
const files = meta.files.map((f) => f.path);
|
||||
const need = [
|
||||
'cordis.patch.yml', 'README.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
|
||||
'cordis.patch.yml', 'index.mjs', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
|
||||
'screenshots.json', 'assets/screenshot-1.png', 'assets/screenshot-2.png',
|
||||
'examples/cordis.patch.posix.yml', 'examples/cordis.patch.with-env.yml',
|
||||
];
|
||||
const missing = need.filter((f) => !files.includes(f));
|
||||
if (missing.length) {
|
||||
@@ -67,8 +132,17 @@ jobs:
|
||||
run: |
|
||||
python3 -m pip install --quiet --disable-pip-version-check pyyaml
|
||||
python3 - <<'PY'
|
||||
import glob, sys, yaml
|
||||
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('examples/*.yml')) + sorted(glob.glob('.github/workflows/*.yml'))
|
||||
import glob, yaml
|
||||
# Every shipped row carries a `!!js` tag whose scalar the DSH loader
|
||||
# evaluates at runtime. safe_load knows nothing about it and rejects the
|
||||
# file this repository exists to ship, so register a scalar passthrough:
|
||||
# indentation, quoting and structure are still validated, which is what
|
||||
# this step is for.
|
||||
yaml.SafeLoader.add_constructor(
|
||||
'tag:yaml.org,2002:js',
|
||||
lambda loader, node: loader.construct_scalar(node),
|
||||
)
|
||||
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('.github/workflows/*.yml'))
|
||||
for f in files:
|
||||
with open(f, encoding='utf-8') as fh:
|
||||
yaml.safe_load(fh)
|
||||
@@ -78,10 +152,9 @@ jobs:
|
||||
- name: The shipped patches stay machine-independent
|
||||
run: |
|
||||
set -e
|
||||
# The row DSH loads, plus every example a reader is told to copy: none of
|
||||
# them may carry a real machine path. `<placeholders in angle brackets>`
|
||||
# are the intended form.
|
||||
targets=$(printf '%s\n' cordis.patch.yml examples/*.yml)
|
||||
# The row DSH loads may carry no real machine path; `<placeholders in angle
|
||||
# brackets>` are the intended form.
|
||||
targets=$(printf '%s\n' cordis.patch.yml)
|
||||
if grep -nE '[A-Za-z]:\\' $targets; then
|
||||
echo "FAIL: a shipped patch contains an absolute Windows path"
|
||||
exit 1
|
||||
@@ -96,6 +169,12 @@ jobs:
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import sys, yaml
|
||||
# The row carries `!!js` scalars, which the harness evaluates at load
|
||||
# time. Register a passthrough so this step can inspect the structure.
|
||||
yaml.SafeLoader.add_constructor(
|
||||
'tag:yaml.org,2002:js',
|
||||
lambda loader, node: loader.construct_scalar(node),
|
||||
)
|
||||
doc = yaml.safe_load(open('cordis.patch.yml', encoding='utf-8'))
|
||||
row = doc[0]['insert'][0]
|
||||
assert row['id'] == 'mcp-rea', row
|
||||
@@ -104,5 +183,71 @@ jobs:
|
||||
assert cfg['serverName'] == 'rea', cfg
|
||||
assert cfg['transport'] == 'stdio', cfg
|
||||
assert 'command' in cfg and 'args' in cfg, cfg
|
||||
# A bare `? a : b` scalar is read as an explicit key/value pair, so the
|
||||
# loader receives a mapping and the row cannot launch. Keep args[0] a
|
||||
# scalar: this is the exact shape that shipped broken once.
|
||||
for i, arg in enumerate(cfg['args']):
|
||||
assert isinstance(arg, str), f'args[{i}] is not a scalar: {arg!r} — quote the !!js scalar'
|
||||
print('row ok:', cfg['command'], cfg['args'])
|
||||
PY
|
||||
|
||||
- name: The harness's own dialect still parses the row
|
||||
run: |
|
||||
# The steps above use pyyaml; this one loads the row through the same
|
||||
# library the harness uses (js-yaml) with its own !!js type, so a shape
|
||||
# that only pyyaml tolerates cannot ship.
|
||||
npm install --no-save --silent --no-audit --no-fund js-yaml@4
|
||||
node - <<'JS'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const yaml = require('js-yaml');
|
||||
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
resolve: (data) => typeof data === 'string',
|
||||
construct: (data) => ({ __jsExpr: data }),
|
||||
});
|
||||
const doc = yaml.load(readFileSync('cordis.patch.yml', 'utf8'), { schema: yaml.JSON_SCHEMA.extend([JsExpr]) });
|
||||
const cfg = doc[0].insert[0].config;
|
||||
const args = cfg.args;
|
||||
if (typeof cfg.command !== 'object' || typeof cfg.command.__jsExpr !== 'string') throw new Error('command is not an expression node');
|
||||
if (typeof args[0] !== 'object' || typeof args[0].__jsExpr !== 'string') {
|
||||
throw new Error(`args[0] is not an expression node: ${JSON.stringify(args[0])}`);
|
||||
}
|
||||
const path = new Function('process', `return (${args[0].__jsExpr})`)({ env: { USERPROFILE: 'C:\\Users\\probe', HOME: '/home/probe' } });
|
||||
if (!path.endsWith('/.dsh/plugins/rea-bundle/bin/rea-shim.mjs')) throw new Error(`unset DSH_HOME resolves to ${path}`);
|
||||
console.log('dialect ok:', cfg.command.__jsExpr, '->', path);
|
||||
JS
|
||||
|
||||
- name: Unit tests for the pure logic
|
||||
# Probe-token substitution and root stripping broke silently during
|
||||
# development; these tests pin them. They are platform-sensitive by
|
||||
# design, which is exactly why they must run on Linux too.
|
||||
run: npm run bundle:test
|
||||
|
||||
- name: Bundle manifest audit
|
||||
# Checksum coverage, unresolved tokens, duplicate JSON keys, and the
|
||||
# patch-wiring agreement between the row, the shim and setup.mjs.
|
||||
run: npm run bundle:audit
|
||||
|
||||
windows:
|
||||
# The PowerShell download route, the tar/Expand-Archive extraction path and
|
||||
# scripts/rea-bundle-bootstrap.ps1 are Windows-only code, and an ubuntu-only
|
||||
# matrix never executed any of them — they were verified by hand, if at all.
|
||||
# The tests are platform-sensitive on purpose: each platform exercises the
|
||||
# download route it actually chooses.
|
||||
runs-on: windows-latest
|
||||
# Same pin as the ubuntu job: the launch decision must not depend on what the
|
||||
# runner image happens to have installed.
|
||||
env:
|
||||
REA_BUNDLE_LAUNCH: bundle
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Unit tests for the pure logic (Windows)
|
||||
run: npm run bundle:test
|
||||
|
||||
- name: Bundle manifest audit (Windows)
|
||||
run: npm run bundle:audit
|
||||
+468
-1
@@ -2,6 +2,473 @@
|
||||
|
||||
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
||||
|
||||
## 2.0.9 — 2026-10-18
|
||||
|
||||
**2.0.8 的两处发布前问题(假回退、乱码)与若干文档缺口;不改正常路径,只让失败路径说真话。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **`reexecFromCheckout` 的「回退」是假话**:spawn 失败时只打印「继续用镜像副本」,函数却返回了,`main()` 已经
|
||||
return —— 没有 `exit` 事件、事件循环排空、进程以 **exit 0 静默退出**,行看起来启动成功却没有任何工具。现在这个
|
||||
Promise **只在没接管时才 resolve**:子进程活着时本进程等待、由子进程的退出码决定;失败则 `main()` 继续用本地副本
|
||||
启动,并如实说明在回退。
|
||||
- **回退覆盖了升级竞态的主形态**:`spawn` 的目标是 `process.execPath`,spawn 级失败几乎只剩资源耗尽;真正常见的是
|
||||
「哈希比对通过 → node 启动 → 文件已被换掉」→ node 以 `MODULE_NOT_FOUND` 退 1。现在**退出码非 0 且源文件已不存在**
|
||||
时同样回退到本地副本(窗口毫秒级,代价是一次 `stat`),不再把竞态变成一行死掉的 MCP。
|
||||
- **乱码 `鈥?` 混进用户可见输出**:2.0.7 在 GBK 编辑器里碰过 `bin/rea-shim.mjs`,16 处破折号被写成 `鈥?`,其中两条是
|
||||
stderr 消息(首次自动供给、上次供给失败)—— 每个用户都会看到。全部还原为 `— `(含被吃掉的空格)。
|
||||
- **开发时可能测到「已安装插件」而不是自己的改动**:checkout 副本会顺着 bundle 的 `mirror.json` 转发到插件目录。
|
||||
现在 checkout(带 `cordis.patch.yml`)永不转发,只有镜像副本会;注释也改成与行为一致。
|
||||
- **带 BOM 的 `mirror.json` / `bundle.json` 被当成「没有记录」**:`JSON.parse` 拒绝前导 BOM,而每个调用方都把解析失败
|
||||
读成「无记录」。Windows 编辑器保存的 JSON 就会带 BOM,表现是镜像自愈静默不生效、bundle 被当成未供给。`bin/rea-shim.mjs`
|
||||
与 `scripts/setup.mjs` 现在都经各自的 `readJson()` 去掉 `U+FEFF` 再解析。**两个文件读的是同一份记录,所以缺一不可**:
|
||||
此前只有 shim 剥了 BOM,带 BOM 的 `mirror.json` 在 setup 眼里仍是「没有 checkout」,镜像 layout 下的 setup 于是把上一版
|
||||
manifest 又镜了一遍并从它供给;`bundle.json` 同理被读成未供给。`verify-branch.ps1` 与 CI 都加了守卫:这两个文件里
|
||||
出现裸的 `JSON.parse(readFileSync(...))` 即失败。
|
||||
|
||||
### 文档 / CI
|
||||
|
||||
- 两份 README 的钉版本示例改为**当前版本** `#v2.0.9`(2.0.8 那一轮已从 `#v2.0.0` 提到 `#v2.0.8`,版本号一动就得跟上,
|
||||
否则读者拿到的 ref 不存在);README 的「机器上已有 REA」一节补上 Windows 限制:**强制结束 shim 不会带走被委派的 REA**。
|
||||
- `docs/bundle-design.md`(随包发布)补上 2.0.7 的 launch mode 与 2.0.8 的镜像自愈,含 `mirror.json` 的
|
||||
`shimSha256` / `pluginVersion` / `dshHome` 三个字段与控制流图。
|
||||
- CI 新增两条:README 必须逐字引用 `engines.dsh`;README 的钉版本示例必须等于 `package.json` 的 version(`verify-branch.ps1`
|
||||
同步加了第二条,避免同一个漂移第三次发生)。
|
||||
- `systemReaCandidates` 的去重键改用传入的 `platform` 参数(测试缝一致,生产无变化)。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **71 条**(68 过 + 3 平台跳过;新增「checkout 副本不转发」「文件中途消失 → 回退」「带 BOM 的 `mirror.json`」
|
||||
「带 BOM 的 `bundle.json`」四条);镜像转发测试改为真的运行镜像副本。
|
||||
- `verify-branch.ps1` 44 项全绿;`audit-manifest` clean;`host-compat` 10 covered / 5 outside。
|
||||
|
||||
## 2.0.8 — 2026-10-18
|
||||
|
||||
**2.0.7 的启动检测在两种边界上会失控:一个包着本 shim 的 `rea` 会让探针递归成进程链;超时探针会漏掉一个孙进程。两条都已修,并补了复现测试。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **重复注册检测在自定义 `DSH_HOME` 下失效**:harness 会从 MCP 子进程里抹掉所有 `DSH_*`,镜像副本因此读
|
||||
`~/.dsh` —— 那是错的文件,上游写进真实 home 的第二条 `serverName: rea` 永远报不出来。`setup.mjs` 现在把运行时
|
||||
的 home 记进 `mirror.json` 的 `dshHome`,shim 优先读环境、其次读这条记录。`check-updates.ps1` 的 profile 段也
|
||||
改用同一条 home 规则(此前硬编码 `$env:USERPROFILE`,自定义 home 时报「找不到 profile」)。
|
||||
- **镜像启动器不会自己更新**:行执行的是 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 这份**副本**,而市场升级
|
||||
只换包、不跑 setup —— 副本会一直跑它被复制时的代码,于是新版本里的修复对老用户静默失效(旧副本照常工作,最难发现)。
|
||||
现在镜像启动时会与 `mirror.json` 记录的插件目录比对哈希,不同就转发执行插件里的那一份(一跳,带
|
||||
`REA_BUNDLE_SHIM_REEXEC` 守卫,且只从镜像指向插件,不会反向)。`--shim-status` 新增 `mirror.stale`,
|
||||
`check-updates.ps1` 也会报「镜像落后,重跑 setup」;`mirror.json` 增加 `shimSha256` 与 `pluginVersion` 便于追溯。
|
||||
**2.0.8 之前的版本自身无法完成这一步**(副本里没有这段代码),从那些版本升级仍需手动跑一次 setup。
|
||||
- **探针递归**:`probeRea` 派生候选时没带 `REA_BUNDLE_DELEGATED=1`,而防自委派的守卫只在**委派**前生效。若 PATH 上是「调用本 shim 的 wrapper」,
|
||||
探针 → wrapper → 新 shim → 再探针……实测并发 node 峰值 83+、杀掉后仍自我再生。现在探针环境同时带 `REA_BUNDLE_DELEGATED=1` 与
|
||||
`REA_BUNDLE_AUTO_PROVISION=0`:嵌套运行直接走 bundle 并返回,实测 0.2 秒、零额外进程。
|
||||
- **超时探针泄漏孙进程**:`spawnSync` 的超时只杀它自己启动的 cmd.exe,而 `taskkill /T` 在 `spawnSync` 回收之后调用已找不到父进程 ——
|
||||
每次超时留一个 REA/ping 在跑。探针改为异步 `spawn` + 自己的计时器:超时发生在父进程仍存活时,`taskkill /T /F` 能真正清掉整棵树。
|
||||
实测 8.3 秒放弃、`rejected=["timeout"]`、无残留进程。
|
||||
- **Windows 信号处理器是死代码**:`child.kill("SIGTERM")` 在 Windows 上是 TerminateProcess,不可捕获;控制台的 Ctrl+C 又直达子进程。
|
||||
该分支已删除,并如实写明限制:**在 Windows 上强制结束 shim 不会带走被委派的 REA**(Node 没有 job object 可用)。
|
||||
- **`comspec()` 不校验**:`ComSpec` 指向不存在的文件时,正常的 `.cmd` REA 被判 `spawn-failed`(`auto` 静默退回 bundle,`system` 直接 78)。
|
||||
现在校验为常规文件,否则回落到 `%SystemRoot%\System32\cmd.exe`;实测 `ComSpec` 指向不存在路径仍能识别 6.9.0。
|
||||
- **文案**:显式路径是目录时报「does not exist」,改为「is not a file」。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **67 条**(2.0.7 是 60 条,本轮 +7:wrapper 递归必须被判为 `exit 78` 且 20 秒内收敛;`ComSpec` 失效仍识别 `.cmd` REA;
|
||||
镜像自愈的转发/守卫/`stale` 上报/同内容不转发;`DSH_HOME` 被抹时从 `mirror.json` 找到真实 home 并报出重复注册):
|
||||
Windows 64 过 + 3 平台跳过。
|
||||
- `verify-branch.ps1` 全绿(39 项),含「两份 README 必须写出 package.json 里的 `engines.dsh` 原文」;`audit-manifest` clean;
|
||||
`host-compat` 10 covered / 5 correctly outside。
|
||||
- 手工复现三例:wrapper 递归 0.2 秒收敛、0 残留进程;挂起候选 8.3 秒放弃且无 `PING` 残留;`ComSpec` 指向空路径仍识别系统 REA。
|
||||
|
||||
|
||||
## 2.0.7 — 2026-10-18
|
||||
|
||||
**机器上已有可用的 REA 时,2.x 直接按 1.x 的方式跑它(环境检测 + 委派),不再供给;同时把 2.0.6 的宿主声明收紧到真的能证明的范围。**
|
||||
|
||||
### 新增:环境检测 → 跑 1.x 的那条路
|
||||
|
||||
- **启动时判断该跑谁**:`rea` 在 PATH 上且能应答 `--version`,就用它(`rea mcp`,用户自己的 Node 与 PATH);否则走供给的
|
||||
bundle。这正是 1.x 的契约,现在从 2.x 的行里也能到。
|
||||
- **版本优先级**:系统 REA 比供给的旧就不抢(6.4.0 是 170 个工具、6.5.0 是 207 个,静默回退工具数是真回归)。相等或更新
|
||||
才切;预发布/无法比较时保守留 bundle。
|
||||
- **覆盖**:`REA_BUNDLE_LAUNCH=auto|system|bundle`(默认 auto)、`REA_BUNDLE_SYSTEM_REA=<path>` 点名不在 PATH 上的二进制。
|
||||
`system` 在找不到时**报错退出**,而不是替用户去下 800 MB。
|
||||
- **`--shim-status` 报告决定**:`launch.{preference,mode,reason,unusable,systemRea,rejected}`;`--shim-print-env` 在 system
|
||||
模式下打印 `{launch,rea,version}`。
|
||||
- **防自委派**:被委派的子进程带 `REA_BUNDLE_DELEGATED=1`,一个调用本 shim 的 wrapper 不会让委派无限递归。
|
||||
|
||||
### 修复(两轮独立复核,逐条可复现)
|
||||
|
||||
- **委派参数被加引号**:`cmd /c` 下 `.cmd` 的 `%*` 收到的是 `"mcp"` 而不是 `mcp` —— 探针能过、委派是坏的。现在只给路径加
|
||||
引号,参数原样传(行固定传 `mcp`;需要 shell 引号的参数本就不在这条路上)。
|
||||
- **探测预算 3s 与真机同量级**:实测经 `.cmd` 的 `rea --version` 为 2.4s(并发 2.8–3.0s),3s 预算等于掷硬币。改为 8s;超时
|
||||
用 `taskkill /T /F` 杀进程树,不再只杀 cmd.exe 而留下一个占着管道的 REA。
|
||||
- **Windows 信号转发是错的**:`child.kill("SIGINT")` 只 TerminateProcess(cmd.exe),里面的 REA 继续跑;现在 Windows 走
|
||||
`taskkill /T`,POSIX 不重复转发(stdio inherit 已由进程组投递)。
|
||||
- **`--shim-status` 退出码**:`system` 被点名但不可用时曾返回 0(bundle 恰好已供给),实际启动却 78;现在按「这一行能不能
|
||||
起来」返回,并新增 `launch.unusable` 字段。
|
||||
- **候选判定**:`existsSync` 换成 `statSync().isFile()`(名为 `rea.cmd` 的目录曾被当成坏安装);PATH 项去引号、跳过相对项;
|
||||
显式路径被拒时理由如实报告。
|
||||
- **`fail()` 的补救命令补上 `--root`**:shim 用 `REA_BUNDLE_ROOT` 解析出的根与 setup 的默认根可以不同,照着旧提示执行会供给
|
||||
到另一个根。
|
||||
- **`engine.dsh` 上限收紧**:`<0.3.0` 在 includePrerelease 语义下会放行 `0.3.0-alpha.1`/`0.3.0-rc.1`;改为 `<0.3.0-0`,并用
|
||||
市场自己的 `satisfiesRange` 逐版本核对。
|
||||
- **`host-compat.mjs` 的测试加固**:发布清单逐项钉住;新增「低地板必须被判为泄漏」的反向对照(原实现把 `leaked` 改成 `[]`
|
||||
仍然全绿);`+build` 元数据按 semver 接受、`v` 前缀与零填充预发布按 semver 拒绝。
|
||||
- **环境独立性**:`setup.mjs` 自检的 shim 步骤钉 `bundle` 模式(否则机器上更新的 REA 会冒充本次供给的产物);CI 两个 job
|
||||
各加 job 级 `REA_BUNDLE_LAUNCH=bundle`;测试里的每个 shim 调用点都钉住模式,测试不再随机器变。
|
||||
- **`rea-bundle-bootstrap.ps1` 认 `REA_BUNDLE_ROOT`**:四份代码里只有它忽略这个覆盖,导致 bootstrap 把 800 MB 装到
|
||||
`<DSH_HOME>/plugins/rea-bundle`,而 shim/setup/check-updates 去 `REA_BUNDLE_ROOT` 找,看到空 bundle 再起一次。
|
||||
- 中文 README 的「更新到 2.0.4」措辞改正为「更新到 2.0.5」。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **60 条**(新增 12 条:6 条启动模式 + 1 条清单钉住 + 1 条泄漏反向对照 + 1 条 semver 容差 + 3 条 PATH/空格路径/委派退出码),
|
||||
Windows 57 过 + 3 平台跳过。
|
||||
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean;`host-compat.mjs` 10 covered / 5 correctly outside。
|
||||
- 真机:全局 `rea` 6.4.0 与供给的 6.5.0 并存时 `auto` 留 bundle(不降工具数),`REA_BUNDLE_LAUNCH=system` 才切过去。
|
||||
|
||||
|
||||
## 2.0.6 — 2026-10-18
|
||||
|
||||
**声明宿主版本:`engines.dsh` 覆盖 `0.1.7-rc.2` … `0.2.1-rc.2` 全部发布版本,市场卡片因此显示「兼容」而不是「未声明」。**
|
||||
|
||||
### 兼容声明
|
||||
|
||||
- **`engines.dsh: ">=0.1.7-rc.2 <0.3.0"`**:市场侧的 `deriveHostCompatibility` 只认两类声明 —— `engines.dsh`
|
||||
与指向 `@deepseek-ai/dsh*` 的 `peerDependencies`;两者都没有就是 `unknown`(卡片「兼容性未知」)。
|
||||
这里只声明前者,刻意不声明 peer:`@deepseek-ai/dsh-mcp-client` 在 npm 上只有 `0.0.1-rc.1`,
|
||||
而宿主随包内置的是 0.2.x,声明 peer 会让 pnpm 解析出一个与宿主不一致的版本,反而制造假冲突。
|
||||
- **范围来自核对,不是估计**:`scripts/host-compat.mjs` 内置 dsh 发布列表(0.1.7-rc.2 至 0.2.1-rc.2 共 10 个),
|
||||
逐个验证是否被声明覆盖,并反向确认 `0.3.0` 与 `0.1.7-rc.1`、`0.1.7-alpha.2` 落在范围之外。
|
||||
- **跨版本比对过的东西**:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
||||
配置面与发布的行一致(`serverName`/`transport`/`command`/`args`/`env`/`failOnStartupError` 均存在且同名),
|
||||
且两个 tag 都有 `dsh.bundle.patch`。范围声明的是「核对过的宿主」,不是「应该能用」。
|
||||
|
||||
### 上游(只更新仓库,暂不带入)
|
||||
|
||||
- 上游已发布 **rea-agents 6.6.0**(GitHub release `rea-agents-6.6.0`,2026-10-18);npm `latest` 仍是 **6.5.0**,
|
||||
所以本包的钉(`manifest/bundle.manifest.json` 的 `pins.reaAgents` 与 `components.reaAgents.sources.npm.version`)
|
||||
**保持 6.5.0 不动**。已核 6.6.0 的 `engines.node`(`^22.19.0 || ^24.11.0 || >=26.0.0`)与 `bin`
|
||||
(`scripts/rea.mjs`)均未变化,`optionalDependencies.frida` 为 17.23.2。
|
||||
- 计划:**2026-10-19** 随下一版把钉升到 6.6.0 并复跑真机;届时工具数、探针正则与文档一并更新。
|
||||
|
||||
### 修复 / 工程
|
||||
|
||||
- 新增 `scripts/host-compat.mjs`(纯函数 + CLI):semver 预发布排序、`>=floor <ceiling` 判定、发布列表覆盖检查。
|
||||
- `bundle:host-compat` npm script;`verify-branch.ps1` 新增 `engines.dsh` 存在性与覆盖率两项;
|
||||
`validate.yml` 新增「Declared DSH range covers every released host」步骤,清单检查也要求 `engines.dsh`。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **46 条**(新增 4 条:声明可解析、10 个发布版本全覆盖且 3 个未核对版本被排除、反向对照能抓出
|
||||
`>=0.2.0` 这种看似合理却漏掉整个 0.2.0-rc 线的写法、预发布排序符合 semver 而非字符串序):Windows 43 过 + 3 平台跳过。
|
||||
- `node scripts/host-compat.mjs`:10 个发布版本 covered、3 个 correctly outside。
|
||||
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean(两份 README 结构仍平行)。
|
||||
|
||||
## 2.0.5 — 2026-10-18
|
||||
|
||||
**市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
|
||||
(实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
|
||||
(只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
||||
构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
|
||||
的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
|
||||
所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
|
||||
- **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
|
||||
行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
|
||||
`serverName: rea`。
|
||||
|
||||
### 文档
|
||||
|
||||
- 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
|
||||
完整性无关、profile 未受损,以及 2.0.5 起的做法。
|
||||
|
||||
### 验证
|
||||
|
||||
- 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
|
||||
对仓库目录 `entryArtifactExists` 修复后为 `true`。
|
||||
- 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
|
||||
`apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
|
||||
- `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
|
||||
- `npm pack --dry-run`:20 个文件,含 `index.mjs`。
|
||||
|
||||
## 2.0.4 — 2026-10-17
|
||||
|
||||
**两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
|
||||
try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
|
||||
未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
|
||||
现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
|
||||
- **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
|
||||
`id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
|
||||
一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
|
||||
文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
|
||||
`duplicateRegistration` 下报告。
|
||||
|
||||
### 文档
|
||||
|
||||
- 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
|
||||
取舍(零依赖供给 vs 上游官方纯配置注册)。
|
||||
- v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
|
||||
- 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
|
||||
测试必红。
|
||||
- `verify-branch.ps1` 全绿。
|
||||
|
||||
## 2.0.3 — 2026-10-16
|
||||
|
||||
**修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
|
||||
|
||||
### 上游
|
||||
|
||||
- `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
|
||||
(`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
|
||||
GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
|
||||
新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
|
||||
`^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
|
||||
- Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
|
||||
但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
|
||||
|
||||
### 修复
|
||||
|
||||
- **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
|
||||
`null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
|
||||
750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
|
||||
setup 的解释器来满足,而不是整段跳过检查。
|
||||
- **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
|
||||
已经下好的文件报成失败)。现在校验通过即改名收下。
|
||||
- **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
|
||||
`have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
|
||||
长度发 Range 续传。
|
||||
- **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
|
||||
manifest 里标了 `executable` 的条目落盘时 chmod 0755。
|
||||
- **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
|
||||
hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
|
||||
- **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
|
||||
照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
|
||||
`application.version=` 断言此前是死配置)。
|
||||
- **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
|
||||
它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
|
||||
- **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
|
||||
lock 不再阻塞重试。
|
||||
- **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
|
||||
- **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
|
||||
- **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
|
||||
下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
|
||||
`exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
|
||||
目录(它承诺"什么都没改")。
|
||||
- **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
|
||||
`GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
|
||||
的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
|
||||
|
||||
### 审计与 CI
|
||||
|
||||
- `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
|
||||
engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
|
||||
- `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
|
||||
Windows 专属代码。
|
||||
- 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
|
||||
skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
|
||||
|
||||
### 第二轮修复(同一份**更新后**的清单,2026-10-16 晚)
|
||||
|
||||
- 🔴 **过期 lock 永久卡死自动供给(2.0.0–2.0.2 三版全带)**:`maybeAutoProvision()` 只在 lock 新鲜时
|
||||
处理它,过期的 lock 既不清除也不报告 → 落到独占创建 → `EEXIST` → 返回 `in-flight`,而 `main()` 对
|
||||
`in-flight` **一个字都不打印**。于是「下载被 kill / 断电」这一常态,在 6 小时后再启动就变成**永久不再
|
||||
自动供给且完全静默**。已按清单复现(7 小时死 lock:不启动、lock 原样、无任何提示;5 分钟 lock 对照组
|
||||
正常删锁重启),修法:**不新鲜或持有者已死就一律清锁**(这正是注释里"只在新鲜时尊重它"的本意),
|
||||
并给 `in-flight` / `spawn-failed` / `unwritable` 补上如实提示。
|
||||
- 🔴 **`examples/` 两个文件是 1.x 内容,却随 2.x 进了 npm 包**:`cordis.patch.posix.yml` 教人把整行换成
|
||||
`command: rea`、`cordis.patch.with-env.yml` 给的是 `cmd.exe /c rea` 的 1.x 行——对 2.x 全错(2.x 的行是
|
||||
跨平台的 `process.execPath` + bundle shim,照抄会整行绕过 bundle)。两份都已是孤儿(README 不再引用),
|
||||
故从 `main` **删除**,并同步 `package.json files[]` 与 `validate.yml` 的载荷/YAML/机器路径三项检查
|
||||
(v1 分支保留它们,在 1.x 语境里是对的)。npm 载荷由此 21 → 19 个文件。
|
||||
- 🟡 **`--offline` 管不住 `reaAgents`**:`provisionReaAgents()` 里 0 处 offline 判断,`npm install` 永远
|
||||
联网——与「`--offline` is a promise, not a hint」直接矛盾。现在 `--offline` 且内置副本不可用时**直接
|
||||
FAIL** 并说明原因(而可用的内置副本仍照常跳过)。
|
||||
- 🟡 **npm 那一步没有代理/镜像兜底**:压缩包有 direct→proxy→PowerShell 三路,npm 只读环境变量。失败时
|
||||
现在会打印这句提示,两份 README 的能力/注意里也写明 `HTTPS_PROXY` / `NPM_CONFIG_REGISTRY`。
|
||||
- 🟡 **POSIX 缺 `unzip`/`xz` 必现失败**:Ghidra 全平台只有 zip,而 POSIX 解压原先只有 `unzip` 一条路。
|
||||
现在 zip 依次尝试 `unzip` → `python3 -m zipfile` → **已供给 JDK 自带的 `jar`**(JDK 在 Ghidra 之前供给,
|
||||
等于保底),tar 依次 `tar` → `python3`;三者皆无时打印明确的「no zip extractor available」而不是干巴巴的
|
||||
「extraction failed」。
|
||||
- 🟡 **manifest 的 arm64 注释是错的**:Google 的 linux/darwin platform-tools 都是 x86_64 二进制,
|
||||
「The same archive serves x86-64 and arm64」不成立。三个条目改成如实描述(macOS 需 Rosetta 2、
|
||||
linux-arm64 上 probe 预期失败并被报为可选组件不可用)。
|
||||
- 🟡 **`docs/bundle-design.md` 与实现漂移**:§3 的「row points at `<plugin>/bin/rea-shim.mjs`」改成
|
||||
「按 `DSH_HOME` 算出 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(镜像副本)」;§7 的
|
||||
`vendor/rea-agents` 改成 `vendor/node_modules/rea-agents`。
|
||||
- 🟢 **`release.yml` 的 `workflow_dispatch` 可绕过 Release 直接发 npm**:publish job 现在先断言
|
||||
`v<package.json version>` 的 tag 在 origin 上存在、且该 Release 挂着版本无关的 `dsh-plugin-rea.tgz`
|
||||
资产,否则拒绝发布(否则 npm 与市场兜底链接会岔开)。
|
||||
- 🟢 **数值环境变量会变成 NaN**:`REA_BUNDLE_FETCH_RETRIES=abc` → `Math.max(1, NaN)` → 报
|
||||
「gave up after NaN attempt(s)」;NaN 的 socket 超时等价于 0(立即超时)。现在非正有限值一律回落到默认,
|
||||
且启动日志新增一行 `limits connect=… idle=… retries=…`,让覆盖生效可见。
|
||||
- 🟢 注释与整洁:`downloadViaPowerShell` 的文档说自己是 fallback,实际在 Windows 上是主路线(已改口径);
|
||||
去掉 setup.mjs 里 3 处对已静态导入成员的重复 `await import("node:fs")`(`readdirSync` 补进静态导入)。
|
||||
|
||||
## 2.0.2 — 2026-10-15
|
||||
|
||||
**仅文档,无行为变更。**
|
||||
|
||||
- 两份 README 的**末尾**(许可证之前)新增一节,说明**什么时候该用 1.x 线**以及它的安装方式
|
||||
(`dsh-plugin-rea@1.0.6` 或 `github:czcdr/dsh-plugin-rea#v1.0.6`)—— 此前 1.x 只出现在开头的版本说明与升级章节里。
|
||||
## 2.0.1 — 2026-10-15
|
||||
|
||||
**仅文档与版式,无行为变更。**
|
||||
|
||||
- 两份 README 的**语言行正下方**现在先说明「这是哪个版本、1.x 与 2.x 各做什么」——此前版本说明排在
|
||||
首屏那张大截图之后,npm 页面首屏看不到,容易被误判成「README 没改」;对比表与升级章节位置不变。
|
||||
- `docs/bundle-design.md` 的措辞跟上「`main` 已经是一体包线」这一事实(原先写的是「本分支新增、
|
||||
`main` 不变」)。
|
||||
- 为什么纯文档也发一个补丁版:npm 的包页渲染的是**发布进 tarball 的 README**,不重新发布就更新不了首屏。
|
||||
## 2.0.0 — 2026-10-15
|
||||
|
||||
**一体包线的首个正式版**(此前的 `2.0.0-bundle.1` / `2.0.0-bundle.2` 是它的两个预发布)。
|
||||
`main` 现在就是这一条;1.x 的纯配置适配器保留在
|
||||
[`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍可装 `dsh-plugin-rea@1.0.6`。
|
||||
|
||||
### 🔴 从 1.x 升级:先跑一次供给,再重启
|
||||
|
||||
1.x 的行指向 PATH 上的 `rea mcp`;2.x 的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
|
||||
而**那个文件只有 `setup.mjs` 会创建**。所以从 1.x 升上来后直接重启会:
|
||||
|
||||
- 让该行以 `MODULE_NOT_FOUND` 退出(harness 本身不受影响 —— `failOnStartupError: false` 是刻意的),
|
||||
于是 `mcp__rea__*` 工具消失,而且**不会得到任何修复提示**:会打提示的 shim 本身还不存在;
|
||||
- 正确做法是在插件目录跑一次 `node scripts/setup.mjs`(或 `scripts\rea-bundle-bootstrap.ps1 -Reuse`),
|
||||
再重启 dsh,工具就回来了。此后每一次「状态缺失」都由 shim 自己发现并在后台补供给。
|
||||
|
||||
(如果你在 profile 的 `cordis.patch.yml` 里按 id 覆盖过 `mcp-rea`(例如钉 `cmd.exe /c rea mcp`),
|
||||
那条覆盖会继续生效,升级不影响你。)
|
||||
|
||||
### 三个致命缺陷(按旧版说明走的用户每一步都会撞上)
|
||||
|
||||
- **MCP 行里的 `!!js` 标量没有加引号。** 裸标量同时含 `? ` 与 `: ` 时,YAML 把它读成**显式键值对**,
|
||||
loader 拿到的是 mapping 而不是表达式节点。用 harness 自己的方言实测(`JSON_SCHEMA` + `app.asar` 里的
|
||||
`!!js` 类型定义),`args[0]` 变成 `{"[object Object]": "..."}` —— 这一行**从来没有可能启动**。
|
||||
现改为双引号标量,内部 JS 字符串用单引号。
|
||||
- **`DSH_HOME` 的兜底少了一层 `.dsh`。** 行回退到裸 home,而 shim、`setup.mjs`、`bootstrap.ps1` 都回退到
|
||||
`<home>/.dsh`。全归档扫描确认 **DSH 从不设置 `DSH_HOME`**(宿主进程内 0 处写入,只在 shell 工具子进程的
|
||||
执行覆盖层注入),所以这是**默认路径**而不是边缘情况:行永远指向供给不会写的位置。
|
||||
- **镜像副本缺 manifest。** 镜像后的 `setup.mjs` 用它自己所在的位置推导 plugin root,于是 `loadManifest()`
|
||||
去读 `<bundle>/manifest/bundle.manifest.json` —— 一个只有镜像步骤才能创建的文件。由 shim 触发的后台供给
|
||||
会以裸 ENOENT 崩溃;`stdio: "ignore"` 让它完全不可见,而它已写下的 lock 又让重试被抑制六小时。
|
||||
|
||||
### 🟡 一并修掉的
|
||||
|
||||
- **shim 改用自身位置定位 bundle。** MCP 子进程的环境会剥掉所有 `DSH_*`(大小写不敏感),所以自定义
|
||||
`DSH_HOME` 时旧 shim 会去 `~/.dsh` 找 `bundle.json`、报"未供给",然后**在错位置再供给一次 800 MB**。
|
||||
`bundleRoot()` 现在返回 `<shim 所在目录>/..`,与 `DSH_HOME` 是否可见无关。
|
||||
- **新增镜像记录 `mirror.json`。** 真源运行会写下插件 checkout 的位置;镜像副本据此回到真源,插件升级后
|
||||
不会永远用旧 manifest 供给。记录失效(目录被移动/删除)时优雅回退到本地副本。
|
||||
- **空值环境变量。** `REA_BUNDLE_ROOT=""` / `DSH_HOME=""` 原先会把 root 算成 `""` 或相对路径 —— 前者
|
||||
`mkdir ''` 崩在任何日志之前,后者把 bundle 装进 cwd。现在统一 trim 后回退;`--root`/`--cache` 一律
|
||||
`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` 同样成立。
|
||||
- **裸跑 shim 不再启动任何东西。** `package.json` 把这个文件暴露为 `rea-bundle-status` bin,而 npm 的 bin
|
||||
转发只能原样传参、**无法追加 flag**,于是"裸跑一个叫 status 的命令"会掉进启动路径:在已供给的机器上
|
||||
启动 REA,在未供给的机器上**开始约 800 MB 下载** —— 与这个名字承诺的正好相反。现在无参数时打印用法并
|
||||
退出(真实退出码 0),MCP 行不受影响,因为它始终显式传 `mcp`。
|
||||
- **`--dry-run` 在裸机上误报失败并退出 1。** 计划项返回 `{ planned: true }`(没有 `path`),而只有真实
|
||||
`path` 才被计入 `context.resolved`,于是每个依赖前序组件的计划项都被判成"依赖不可用":在**裸机** —— 恰恰
|
||||
是 README 建议先跑 `--dry-run` 的那台机器 —— 整条链报 4 条 FAIL 且退出码为 1。现在计划项同样满足下游的
|
||||
`requires`。(本机 `--reuse` 会 adopt 成功,所以这个缺陷只在干净机器上可见。)
|
||||
- **`release.yml` 的 npm 自更新**由 `npm@latest` 钉到 `npm@^11`:发布当天不该让流水线获得新行为。
|
||||
- **`check-updates.ps1` 的 engines 判定**:不认识的 pattern(`22.x`、`>=22` 之类)此前返回"不满足",误导
|
||||
用户去升级一个本来就满足的 Node;现在返回"无法解析"。三条实测:`22.x` → 无法解析、`>=99.0.0` → 不满足、
|
||||
`^24.11.0` → 满足。
|
||||
- **README 的「先装插件也可以」改为诚实描述。** 发布的行指向的镜像 shim **由 setup 创建**,所以在第一次
|
||||
供给之前该行以 `MODULE_NOT_FOUND` 退出 —— 没有任何东西能启动后台供给。首次必须手动跑一次
|
||||
`setup.mjs`,此后才由 shim 自动补供给。
|
||||
|
||||
### 🔴 真机验证(Windows 11 x64,Electron 宿主 0.2.0-rc.2)当场抓到的
|
||||
|
||||
- **bootstrap 的便携 Node 路径必崩。** `$nodeFolder` 被连续剥了两次扩展名:`node-v24.21.0-win-x64.zip`
|
||||
→ `node-v24.21`,而官方 zip 解出来的目录是 `node-v24.21.0-win-x64`。于是在**没有任何 Node 的机器**上
|
||||
—— 也就是这个脚本唯一的读者 —— 它会先成功下载(35 MiB)、校验通过、解压完成,然后抛
|
||||
`the Node zip did not contain node-v24.21`。本机有 Node,永远走不到这段分支,所以单测、审计、WSL 与
|
||||
沙箱端到端全都看不见它;真机复现(清空 PATH 模拟无 Node)一次即中。
|
||||
现在只剥一次,且解压后**接受任何真的带 `node.exe` 的目录**:已经付过下载代价的机器不该因为一次命名
|
||||
漂移而卡住。`-DryRun` 也会去认已经解压好的便携 Node(只认、不下载),这正是下面那条回归检查能
|
||||
**零下载**判定它的原因。
|
||||
|
||||
### 🟡 真机验证顺带修掉的(判据都来自仓库自身)
|
||||
|
||||
- **manifest 的 `optional: true` 被忽略。** `androidPlatformTools` 明写可选("没有设备时这些工具只是报告
|
||||
没有设备"),而 `setup.mjs` 里 `optional` 出现 **0 次**:dl.google.com 不可达时整轮 exit 1 —— 其余组件
|
||||
都供给好了,却因为 adb 失败。现在可选组件的失败**照实报告**(`optional component(s) unavailable`),但
|
||||
不计入退出码、也不写进 `bundle.json`(shim 因此不会导出 `REA_ADB_PATH`);被别的组件 `requires` 的组件
|
||||
永不被当作可选,免得"依赖失败却退出 0"。
|
||||
- **`--reuse` 采纳 Node 只看"文件存在"。** manifest 的 `probe.why` 明写"reuse 路径必须在这里拒绝",但旧代码
|
||||
只做 `fileExists`:PATH 上第一个是 Node 20 的机器会把它记进 `bundle.json`,失败推迟到 REA 启动才以难读的
|
||||
形式出现。现在每个候选(`REA_BUNDLE_NODE` 与**每一个** PATH 命中)都要先过 manifest 的版本闸门;跑不起来
|
||||
的候选(Windows 上 `.cmd`/`.bat` 包装器即 EINVAL)同样拒绝,并把原因打出来。
|
||||
- **README:apktool 在 Windows 上实际不可用**(能力矩阵新增一行)——上游 `execFile` 不加 shell 直接起该命令,
|
||||
而 Windows 不能这样执行 `.bat`/`.cmd`。setup 的 apktool 探针走 `java -jar`(绕过包装器)所以报 `[ok]`,
|
||||
落差只在运行时暴露。
|
||||
- **README:Electron 宿主不能用 `dsh plugin --profile desktop …`**(CLI 硬编码拒绝 `desktop` 这个 profile 名),
|
||||
改为指向应用内插件管理器。
|
||||
|
||||
### 回归防护
|
||||
|
||||
- `audit-manifest.mjs` 新增六项,全部做过反向验证(**故意写坏 6/6 都被抓住**):`!!js` 裸标量 mapping
|
||||
检测、`DSH_HOME` 三态(未设/空/已设)求值一致性、README 与 patch 表达式不得漂移、镜像清单必须含
|
||||
manifest、镜像清单**不得**携带 `cordis.patch.yml`(它是 shim 区分两份副本的判据,被镜像进去会让镜像
|
||||
副本退回 `DSH_HOME` 规则)、**两份 README 结构平行**(节/子节数量,双语靠手工同步)。另外把
|
||||
「`DSH_HOME`/`USERPROFILE`/`HOME` 全空 → 根路径」这个**已知无害行为**钉成 warn(此前无覆盖):该情形下
|
||||
该行报 `MODULE_NOT_FOUND`,不写文件、不下载,harness 照常。
|
||||
- `validate.yml`:YAML 步骤注册 `!!js` 标量构造器(原先 `safe_load` 直接拒绝这个仓库要发布的行 ——
|
||||
**CI 一直是红的**);行契约步骤断言 `args[0]` 是标量、不是 mapping;新增 `bundle:test` 与 `bundle:audit` 两步。
|
||||
- `verify-branch.ps1`:dry-run 断言改为**不带 `--reuse`**(旧写法在本机 adopt 成功时正好掩盖了上面那个
|
||||
裸机缺陷),并新增"不得出现依赖不可用"一条;它现在也检查**自己**的 BOM,因为它是被编辑得最频繁的脚本。
|
||||
- `verify-branch.ps1` 新增第 8 节:**空 PATH + 官方 zip 布局**下,bootstrap 必须认出已解压的便携 Node、
|
||||
且不得计划下载(`-DryRun`,零下载、零副作用)。反向验证:把该脚本 checkout 回 HEAD 版(双重剥离)
|
||||
→ 两条断言立刻变红(`2 CHECK(S) FAILED`/`plan: download and extract ...`)。
|
||||
- 单元测试新增四条**真的去 spawn shim**的用例(镜像布局、checkout 布局、`REA_BUNDLE_ROOT` 覆盖、裸跑只
|
||||
打印用法),并逐条做过反向验证:把 shim 改回"一律用自身位置"时 checkout 那条会红;去掉裸跑守卫时裸跑
|
||||
那条会红(后者自带桩保护,即使回退也只会跑到桩、不会下载)。
|
||||
- 单元测试里的 POSIX 退化断言加平台守卫(`EXE` 为空时 `endsWith("java")` 恒真,在 Linux 上必红;
|
||||
WSL 实测修前 12/13、修后 17/17)。
|
||||
- 单元测试新增四条(共 **21** 条;Windows 21/21、WSL 真 Linux 21/21):`--reuse` 仍采纳兼容 Node、拒绝
|
||||
manifest 版本闸门不认的候选(用**改了正则的合成 manifest** 判定,跨平台确定,不依赖机器上恰好有什么)、
|
||||
拒绝跑不起来的候选、以及"可选组件失败不致命"。反向验证:把可选记账改回旧行为 → 那条立刻变红;把复用
|
||||
检查改回旧行为 → 另两条立刻变红;两次都确认了源码按字节复原。
|
||||
|
||||
### 升级说明
|
||||
|
||||
已部署过 `2.0.0-bundle.1` 的机器**无法自愈**(旧镜像既缺 manifest 也没有真源记录):请从**插件目录**
|
||||
重跑一次 `node scripts/setup.mjs`。
|
||||
|
||||
## 1.0.6 — 2026-10-13
|
||||
|
||||
文档结构调整,无行为变更。
|
||||
|
||||
- **README 拆成两份**:`README.md`(English,GitHub 默认展示)与 `README.zh-CN.md`(简体中文),
|
||||
两份顶部互相链接。此前是单文件里英文、中文各占一半,另一种语言的读者要先滚过整段才能到自己的部分。
|
||||
- **改为真正的分节标题**:原来大量用**粗体当小标题**,加上一个横跨半篇的引用块,整页读起来像一堵墙;
|
||||
现在每种语言各有一份正常的 `##` 结构,并把「安装」拆出「前置条件 / 安装插件」两小节。
|
||||
- **内容要点一条未删**:第三方适配声明(不含上游代码、不由上游维护、问题去向)、能力边界
|
||||
(无法完整还原源码 / 不可恢复清单 / 置信度与限制 / 正规获取渠道)、实测矩阵、三种引擎配置写法
|
||||
(OS 变量、`env:` 块、`!!js`)、profile 按 id 覆盖、`serverName` 唯一、验证步骤、
|
||||
`failOnStartupError: false` 与权限注意、更新语义(本插件 / REA / Ghidra / harness)、许可证归属。
|
||||
- `package.json` 的 `files` 与 `validate.yml` 的 npm 载荷断言同步收录 `README.zh-CN.md`。
|
||||
|
||||
## 1.0.5 — 2026-10-13
|
||||
|
||||
把 1.0.4 留下的形状问题收尾,并让承诺过的 CI 真正跑在仓库上。
|
||||
@@ -17,7 +484,7 @@
|
||||
两个 registry 都查不通时不再静默跳过版本比对;`rea` 优先解析 `rea.cmd`(与发布行的
|
||||
`cmd.exe /c rea` 对齐,此前 `Get-Command rea` 会先命中 `.ps1` 垫片);`rea doctor` 非零退出码会提示。
|
||||
- **`assets/make-screenshots.ps1` 补 BOM**:该文件自己的注释要求 UTF-8 with BOM,此前漏做。
|
||||
- **`package.json` description 真正缩短**:239 → 约 186 字符(1.0.4 只是改写,仍会被 npm 列表页截断)。
|
||||
- **`package.json` description 真正缩短**:239 → 201 字符(1.0.4 只是改写,仍会被 npm 列表页截断)。
|
||||
- **文档口径修正**:README 的 harness 版本拆成「桌面运行时 / 自带 dsh CLI」两个口径;
|
||||
投稿条目注释里的 Release 版本改为当前值。
|
||||
|
||||
|
||||
@@ -2,255 +2,381 @@
|
||||
|
||||
[](https://www.npmjs.com/package/dsh-plugin-rea)
|
||||
|
||||
把 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 接入 **DeepSeek Harness (dsh)**:
|
||||
一个**纯配置型 Cordis bundle**,复用官方 MCP 客户端插件 `@deepseek-ai/dsh-mcp-client`,
|
||||
把本机已安装的 `rea mcp`(stdio)注册成原生工具。装载后模型直接拥有 `mcp__rea__*` 工具。
|
||||
**English** · [简体中文](README.zh-CN.md)
|
||||
|
||||
> **与上游的关系(重要)**
|
||||
> 本仓库是**第三方适配封装,不是 REA 项目的组成部分**,也不包含 REA 的任何源码,
|
||||
> 更不由 REA 作者维护。它只做一件事:向 dsh 的配置组合里插入一条指向你本机 `rea` 安装的 MCP 服务器记录。
|
||||
> - 上游项目:**REA — Reverse Engineer Anything** — <https://github.com/morluto/rea>(MIT)
|
||||
> - 上游文档:<https://github.com/morluto/rea#what-you-can-analyze> · [中文](https://github.com/morluto/rea/blob/main/README_zh.md#可以分析什么) · 站点 <https://rea.tools/>
|
||||
> - 上游 npm 包:`rea-agents`
|
||||
> - 本插件仓库:**<https://github.com/czcdr/dsh-plugin-rea>** · npm:`dsh-plugin-rea`
|
||||
> - 问题反馈:REA 本身的问题请提到上游;本插件的问题提到 [本仓库 Issues](https://github.com/czcdr/dsh-plugin-rea/issues)。
|
||||
> **Which version is this? 2.x — and `main` is it.** 2.x provisions the runtime itself (Node, the REA CLI, a
|
||||
> JDK, Ghidra) and then wires the row: install the plugin, run `scripts/setup.mjs` once, restart dsh.
|
||||
> **1.x** is the older configuration-only adapter — it expects `rea` on PATH and Ghidra installed by you —
|
||||
> kept on the [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1) and on npm as `[email protected]`.
|
||||
> Full comparison: [Two lines: 1.x and 2.x](#two-lines-1x-and-2x) · upgrading: [Upgrading from 1.x](#upgrading-from-1x).
|
||||
|
||||
---
|
||||
Brings the upstream **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** MCP server into
|
||||
**DeepSeek Harness** (dsh), so its tools show up in a session as `mcp__rea__*`.
|
||||
|
||||

|
||||

|
||||
|
||||
## English
|
||||
## What this is
|
||||
|
||||
**What it does.** This plugin performs **no reverse engineering itself**. It connects the upstream REA
|
||||
MCP server to DeepSeek Harness, so the tools REA ships become callable in a session as
|
||||
`mcp__rea__*`.
|
||||
A **Cordis bundle with a provisioning layer**. `package.json` declares `dsh.bundle.patch`, and that patch
|
||||
inserts one row of the official MCP client plugin (`@deepseek-ai/dsh-mcp-client`). The row launches
|
||||
`bin/rea-shim.mjs` with the running Node executable; the shim reads the machine-local state that
|
||||
`scripts/setup.mjs` wrote and starts the REA CLI it provisioned. There is no analysis code here, and the
|
||||
published patch contains no machine path.
|
||||
|
||||
In upstream's own words, REA is *"one MCP for reverse engineering across binaries, applications, and
|
||||
runtime behavior"* — *"see a feature you like. Understand how it works, down to the binary level."* It
|
||||
*connects your agent to tools for inspecting native binaries, JavaScript and Electron apps, .NET
|
||||
assemblies, and websites*; analysis runs locally, and results include the evidence and limitations
|
||||
behind each conclusion. Native analysis can use an existing Hopper, Ghidra, or IDA installation, and
|
||||
static JavaScript analysis needs no native engine.
|
||||
> **Third-party adapter.** This repository is not part of REA, ships none of REA's code, and is not
|
||||
> maintained by REA's authors. It composes one configuration row and fetches upstream's own releases.
|
||||
> Upstream: [github.com/morluto/rea](https://github.com/morluto/rea) (MIT) ·
|
||||
> [docs](https://github.com/morluto/rea#what-you-can-analyze) ·
|
||||
> [rea.tools](https://rea.tools/) · npm package `rea-agents`.
|
||||
> REA's own problems go to its tracker; **this plugin's** problems go to
|
||||
> [this repository's issues](https://github.com/czcdr/dsh-plugin-rea/issues).
|
||||
|
||||
Upstream's own coverage list (`README.md#what-you-can-analyze`): native binaries — pseudocode,
|
||||
assembly, strings, symbols, calls and references; JavaScript/Electron apps — modules, imports, source
|
||||
maps, routes, IPC; websites — page structure, scripts, network observations, screenshots; .NET
|
||||
assemblies; Android APKs; Android devices over adb; JEB projects; Android resources; firmware;
|
||||
packages and assets; process behavior; plus offline ELF layout, EVM bytecode, recorded Linux crashes
|
||||
and saved network captures.
|
||||
**It adds exactly three things:** the tool registration under the `mcp__rea__*` names, the per-machine
|
||||
engine environment (`GHIDRA_INSTALL_DIR`, `JAVA_HOME`, `REA_ADB_PATH`, `REA_APKTOOL_COMMAND`,
|
||||
`REA_JADX_MCP_JAR`, `REA_BROWSER_EXECUTABLE`, …) scoped to that MCP child instead of the whole harness, and
|
||||
the provisioning that puts those components on disk.
|
||||
|
||||
**What this plugin adds.** Exactly two things: it registers that MCP server into DSH's tool layer
|
||||
under the `mcp__rea__*` names, and it lets the engine environment (`GHIDRA_INSTALL_DIR`, …) be scoped
|
||||
to that MCP child process instead of the whole harness.
|
||||
## What it can and cannot do
|
||||
|
||||
**What it can and cannot do.** The analysis is upstream's; this section only restates upstream's own
|
||||
contract so expectations are right.
|
||||
The analysis is upstream's; this section only restates upstream's own contract so expectations are right.
|
||||
|
||||
- **It cannot recover complete source code.** For native binaries upstream returns *pseudocode and
|
||||
assembly*, for Android APKs *decompiled methods*, for JEB units *pseudocode*, and for
|
||||
JavaScript/Electron *modules and their relations*. Upstream's tool contract states the pseudocode
|
||||
"is decompiler output, not original source and not text-equivalent" to another provider's output.
|
||||
- Not recoverable in practice: original identifier names, types, comments, macros and template
|
||||
expansion, build scripts and assets. Optimisation (inlining, reordering), symbol stripping,
|
||||
obfuscation, packing and VM protection only make the output worse.
|
||||
- Every result carries **confidence and limitations** (`observed` / `derived` / `inferred`, plus an
|
||||
explicit limitations list). Read them as evidence, never as recovered source.
|
||||
assembly*, for Android APKs *decompiled methods*, for JEB units *pseudocode*, and for JavaScript/Electron
|
||||
*modules and their relations*. Upstream's tool contract states the pseudocode "is decompiler output, not
|
||||
original source and not text-equivalent" to another provider's output.
|
||||
- Not recoverable in practice: original identifier names, types, comments, macros and template expansion,
|
||||
build scripts and assets. Optimisation (inlining, reordering), symbol stripping, obfuscation, packing and
|
||||
VM protection only make the output worse.
|
||||
- Every result carries **confidence and limitations** (`observed` / `derived` / `inferred`, plus an explicit
|
||||
limitations list). Read them as evidence, never as recovered source.
|
||||
- Good for: understanding behaviour, locating an implementation, interoperability and compatible
|
||||
re-implementation, security review, migration reference. To obtain actual source code, use the
|
||||
legitimate route — the upstream repository, vendor authorisation, or the vendor's own SDK.
|
||||
re-implementation, security review, migration reference. To obtain actual source code, use the legitimate
|
||||
route — the upstream repository, vendor authorisation, or the vendor's own SDK.
|
||||
|
||||
**Verified against** (author's machine, 2026-10-12/13):
|
||||
**What provisioning cannot buy you.** These are upstream host gates, not settings this plugin forgot:
|
||||
|
||||
| Component | Version |
|
||||
| Capability | Gate |
|
||||
|---|---|
|
||||
| REA (`rea-agents`) | **6.4.0** |
|
||||
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
|
||||
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness; npm latest `0.0.1-rc.1` |
|
||||
| Ghidra | 12.1.4 (REA currently accepts 12.1.x) |
|
||||
| Node.js | 24.21.0 (REA engines: `^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0`) |
|
||||
| Host | Windows 11 x64 |
|
||||
| Hopper provider | macOS only |
|
||||
| IDA provider | needs your own IDA and `REA_IDA_MCP_CONFIG`; REA never launches IDA |
|
||||
| firmware `binwalk` / `unblob` | Linux only |
|
||||
| Wakaru JS recovery, pwntools ELF/recorded-crash paths | Linux x64 only |
|
||||
| pwndbg / gdb core context | Linux, macOS |
|
||||
| native mitmproxy captures | Linux (`REA_MITMDUMP_COMMAND`); HAR needs no engine |
|
||||
| Apple toolchain (Mach-O, plist, asset catalog, LLDB) | macOS only |
|
||||
| `capture_process_scenario` | Linux/macOS PTY |
|
||||
| apktool (Android resource decoding) | available only when `REA_APKTOOL_COMMAND` points at something Node can **execute directly**: upstream `execFile`s that command without a shell, and Windows cannot execute a `.bat`/`.cmd` that way (EINVAL). This bundle wires the official `apktool.bat` as upstream documents, so on Windows the launcher reports no version and the capability stays unavailable; JADX (a jar) is unaffected |
|
||||
|
||||
The shipped row was exercised live: `list_documents` answered through the `cmd.exe` form, and
|
||||
`open_binary` completed with the `ghidra` provider while the engine directory came only from a
|
||||
profile-level override.
|
||||
## Two lines: 1.x and 2.x
|
||||
|
||||
**Prerequisites.** Node.js (a version REA accepts) and the REA CLI installed globally
|
||||
(`npm i -g rea-agents`). For native analysis, Ghidra 12.1.x (Hopper / IDA are alternatives under
|
||||
their own licences). Static JavaScript/Electron and .NET analysis need no engine.
|
||||
| | **2.x** — this line, `main` | **1.x** — [`v1`](https://github.com/czcdr/dsh-plugin-rea/tree/v1) |
|
||||
|---|---|---|
|
||||
| What it is | the adapter **plus provisioning**: it prepares Node, the REA CLI, a JDK and Ghidra, then wires the row | configuration-only adapter: one MCP row pointing at a `rea` you installed yourself |
|
||||
| What you need first | nothing for the runtime — provisioning fetches what is missing (with `--reuse` it adopts an existing Ghidra/JDK instead) | Node.js on REA's engines, `npm i -g rea-agents`, and Ghidra 12.1.x for native analysis |
|
||||
| First run | run `scripts/setup.mjs` (or the bootstrap) once, then restart dsh | nothing to run |
|
||||
| The row launches | the running Node executable plus `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, which reads that machine's state | `cmd.exe /c rea mcp` on Windows, `rea mcp` on macOS/Linux |
|
||||
| Machine state | `<DSH_HOME>/plugins/rea-bundle/` — `bundle.json`, `vendor/`, `cache/` | your own REA and Ghidra installs |
|
||||
| Install | `dsh plugin --profile <profile> add dsh-plugin-rea` (npm `latest` ≥ 2.0.0) | `… add [email protected]`, or `github:czcdr/dsh-plugin-rea#v1.0.6` |
|
||||
|
||||
**Install.**
|
||||
Both lines ship the **same patch shape** — one `insert` row of `@deepseek-ai/dsh-mcp-client` — and offer the
|
||||
same `mcp__rea__*` surface; they differ in who prepares the runtime. Moving from 1.x to 2.x takes two
|
||||
commands: see [Upgrading from 1.x](#upgrading-from-1x).
|
||||
|
||||
Upstream also registers itself with dsh now (`rea setup --client dsh`); that route is essentially the
|
||||
1.x model maintained upstream — configuration only, your own Node/REA/Ghidra. This bundle is the
|
||||
zero-dependency route. Pick one of the three, and never keep two `serverName: rea` rows at once.
|
||||
|
||||
## Install
|
||||
|
||||
**Nothing to install first.** On a bare Windows x64 machine:
|
||||
|
||||
```
|
||||
powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1 -Reuse
|
||||
```
|
||||
|
||||
`-Reuse` adopts a Ghidra/JDK you already have; omit it to download both (about 775 MB from the official
|
||||
releases, checksum-verified). Then install the plugin for your profile:
|
||||
|
||||
```
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea
|
||||
# or, from npm:
|
||||
dsh plugin --profile <profile> add dsh-plugin-rea
|
||||
# or pin this release instead of tracking npm latest:
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
|
||||
```
|
||||
|
||||
A prebuilt tarball is attached to [Releases](https://github.com/czcdr/dsh-plugin-rea/releases)
|
||||
(version-free asset name, so the link keeps resolving).
|
||||
> **On an Electron host (the DeepSeek Harness desktop app) the profile is owned by the application.**
|
||||
> `dsh plugin --profile desktop …` is refused by the CLI (`profile "desktop" is managed exclusively by
|
||||
> the Electron application`) — install or update through the in-app plugin manager instead. The CLI form
|
||||
> above is for a profile you created yourself with the CLI under another name.
|
||||
|
||||
**Configure — usually nothing.** The shipped row carries **no machine-specific path**:
|
||||
If Node is already installed, you can skip the bootstrap and run provisioning directly:
|
||||
|
||||
- Windows: `command: cmd.exe`, `args: ['/c', 'rea', 'mcp']` — the npm shim is a `.cmd`, so a shell is
|
||||
required.
|
||||
- macOS/Linux: `command: rea`, `args: ['mcp']` — see
|
||||
[`examples/cordis.patch.posix.yml`](examples/cordis.patch.posix.yml).
|
||||
```
|
||||
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
|
||||
```
|
||||
|
||||
The only optional piece is the analysis engine. REA reads `GHIDRA_INSTALL_DIR` from its environment,
|
||||
so either:
|
||||
Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
|
||||
`^22.19.0 || ^24.11.0 || >=26.0.0` (check with `node -v`). A lower Node does not fail at install
|
||||
time; the MCP row fails later, which reads as silence.
|
||||
|
||||
1. set it as an **OS-level environment variable** and (re)start the harness, or
|
||||
2. pin it for the MCP child only — see
|
||||
[`examples/cordis.patch.with-env.yml`](examples/cordis.patch.with-env.yml).
|
||||
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.
|
||||
|
||||
Either way the variable reaches REA without any literal path: the client hands the parent
|
||||
environment to the child (minus names that look like secrets), and the patch dialect evaluates
|
||||
`!!js` expressions, so `GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR` also works.
|
||||
### Which DSH versions
|
||||
|
||||
**Keep machine-specific values out of the plugin.** Override the row from your own profile's
|
||||
`cordis.patch.yml` (id-targeted config override). That file is machine-local and never published:
|
||||
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`, and that declaration is the answer the plugin market
|
||||
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
|
||||
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
|
||||
two disagree:
|
||||
|
||||
| Host | Covered |
|
||||
|---|---|
|
||||
| `0.1.7-rc.2` … `0.2.1-rc.2` — every release in between, alpha and rc | yes |
|
||||
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
|
||||
|
||||
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2` and at
|
||||
`dsh-v0.2.1-rc.2` exposes the same config surface the shipped row uses (`serverName`, `transport`, `command`, `args`,
|
||||
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
|
||||
simply not claimed.
|
||||
|
||||
### A REA you already have is used as-is
|
||||
|
||||
2.x exists to supply what a machine is missing. If REA is already installed — `rea` on PATH, the 1.x setup — the row
|
||||
runs **that**, exactly as the 1.x line did, and nothing is provisioned:
|
||||
|
||||
| What the machine has | What the row runs |
|
||||
|---|---|
|
||||
| `rea` on PATH, at least as new as the provisioned build | your REA (`rea mcp`), with your Node and your PATH |
|
||||
| `rea` on PATH but older than the provisioned build | the provisioned bundle — a stale global CLI would take the tool count backwards (6.4.0 ships 170 tools, 6.5.0 ships 207) |
|
||||
| no `rea` on PATH | the provisioned bundle, provisioning itself on first use |
|
||||
|
||||
- `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the decision (`auto` is the default); `REA_BUNDLE_SYSTEM_REA=<path>`
|
||||
names a binary that is not on PATH.
|
||||
- The probe is the CLI's own `--version`, bounded at 8 s: a `.cmd` shim on Windows takes ~2.4 s to answer, so the check
|
||||
runs once per start and only when a `rea` actually exists.
|
||||
- `--shim-status` prints the decision — `launch.mode`, `launch.reason`, `launch.systemRea` — and its `launch.rejected`
|
||||
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
|
||||
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
|
||||
itself forever.
|
||||
- **On Windows a forced kill of the shim does not reap the delegated REA.** There is no job object here: Task Manager
|
||||
or a supervisor terminating the shim leaves your `rea` running with the row's pipes (a console Ctrl+C reaches the
|
||||
whole process group and does stop it). On POSIX the child is in the same group and dies with the terminal.
|
||||
|
||||
## Configure — nothing
|
||||
|
||||
The shipped row carries **no machine path**:
|
||||
|
||||
```yaml
|
||||
- id: mcp-rea
|
||||
config:
|
||||
env:
|
||||
GHIDRA_INSTALL_DIR: '<your Ghidra 12.1.x directory>'
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: !!js process.execPath
|
||||
args:
|
||||
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
|
||||
- mcp
|
||||
env:
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
failOnStartupError: false
|
||||
```
|
||||
|
||||
Only one row may use `serverName: rea` in a profile.
|
||||
Three details in that row are load-bearing, and all three were bugs first:
|
||||
|
||||
**Verify.** Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
|
||||
- **The `!!js` scalar is quoted.** A bare scalar containing `? ` and `: ` is not one scalar: YAML reads it as an explicit key/value pair, so the loader receives a *mapping* where an expression belongs. Measured against the harness's own dialect (`JSON_SCHEMA` plus the `!!js` type from `app.asar`), the unquoted form made `args[0]` into `{"[object Object]": "..."}` — an object the row cannot launch with. Quoting removes the ambiguity, and the JS string literals inside then use single quotes.
|
||||
- **Forward slashes, never backslashes.** A single-quoted YAML scalar keeps `\` literal, and JavaScript then consumes it: `'\plugins'` is not a path but the BEL control character, `'\rea-bundle'` becomes a carriage return and `'\bin'` a backspace — the path is destroyed silently. Forward slashes work on Windows and on POSIX.
|
||||
- **The `DSH_HOME` fallback is `<home>/.dsh`, not `<home>`.** The shim, `setup.mjs` and `bootstrap.ps1` all resolve an unset `DSH_HOME` to `<home>/.dsh/...`; a fallback that stopped one level higher made the row point at a file provisioning never writes — invisible on any machine where `DSH_HOME` happens to be set.
|
||||
- **No `path`/`require` in a `!!js` expression**: `ctx` is the loader scope, so the DSH_HOME fallback is written as plain concatenation.
|
||||
|
||||
`npm run bundle:audit` enforces the first three: it evaluates this exact expression with `DSH_HOME` unset, empty and set, and rejects a bare scalar that YAML would read as a mapping.
|
||||
|
||||
`ELECTRON_RUN_AS_NODE` only matters if the harness itself runs on Electron; it is harmless for a plain Node host, and stating it explicitly beats relying on it surviving the child-env scrub.
|
||||
|
||||
`command` is the running Node executable and `args[0]` is computed from `DSH_HOME`, so the same patch works
|
||||
on Windows, macOS and Linux without a PATH entry. Everything machine-specific lives in
|
||||
`<DSH_HOME>/plugins/rea-bundle/bundle.json`, written by setup and readable with:
|
||||
|
||||
```
|
||||
node bin/rea-shim.mjs --shim-status # resolved paths, versions, verification state
|
||||
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
|
||||
the row from the profile's own `cordis.patch.yml` — that file is machine-local and never published. Only one
|
||||
row may use `serverName: rea` in a profile.
|
||||
|
||||
### One REA registration only
|
||||
|
||||
Upstream REA 6.5.0 can register itself with DSH:
|
||||
|
||||
```
|
||||
rea setup --client dsh
|
||||
```
|
||||
|
||||
That writes a row with `id: rea-mcp` and `serverName: rea` into `<DSH_HOME>/cordis.patch.yml`. This
|
||||
bundle's row is `id: mcp-rea` with the same `serverName`, and DSH rejects the later of two entries
|
||||
that share a server name — so which REA row survives depends on load order. **Use one, not both.**
|
||||
|
||||
Which one? Upstream's is the configuration-only route: it expects Node on REA's engines,
|
||||
`rea-agents` reachable, and a Ghidra/JDK you provisioned yourself. This bundle is the
|
||||
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
|
||||
`mcp__rea__*` surface are the same either way.
|
||||
|
||||
The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
|
||||
`mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
|
||||
environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
|
||||
|
||||
## Verify
|
||||
|
||||
Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
|
||||
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`), all of which should be
|
||||
`ok: true`. Then `open_binary` a PE, `binary_overview`, and `procedure_pseudo_code`.
|
||||
|
||||
**Scope and caveats.** REA exposes 170+ tools at 6.4.0, so every session's tool list grows; the row
|
||||
sets `failOnStartupError: false` so a missing REA never blocks harness startup. Some REA tools launch
|
||||
processes, write files and access the network — DSH's permission policy applies. Analyse only
|
||||
artifacts you are authorized to inspect.
|
||||
Provisioning itself ends with two probes: `rea doctor --json` (reported, with unmet checks named) and the
|
||||
exact shim launch the MCP row will use.
|
||||
|
||||
**Updating — two independent things, both manual.**
|
||||
## Verified against
|
||||
|
||||
- **This plugin** never updates itself. Installed from a plugin market? Update it **in that market**.
|
||||
Installed from a local `file:` checkout? Overwrite the directory and install it again. Updating REA
|
||||
below does **not** update this plugin, and updating this plugin does not touch REA.
|
||||
- **Upstream REA**: `npm i -g rea-agents@latest`. Its `bin` entry (`scripts/rea.mjs`) is stable across
|
||||
releases, so this needs no change here.
|
||||
- **Ghidra** is version-gated by REA (12.1.x today): update REA first, then check that it accepts a
|
||||
newer Ghidra.
|
||||
- **DeepSeek Harness**: a harness upgrade may change the MCP client's config schema — inspect the row
|
||||
with `Config.listConfigs name=@deepseek-ai/dsh-mcp-client` and adjust the field names.
|
||||
Author's machine, 2026-10-13 / 14 / 16:
|
||||
|
||||
`check-updates.ps1` (Windows PowerShell) reports the state of all four at once, and
|
||||
`.github/workflows/validate.yml` enforces the bundle's shape — including "the shipped patch must not
|
||||
contain a machine-specific path" — in CI.
|
||||
| Component | Version |
|
||||
|---|---|
|
||||
| REA (`rea-agents`) | **6.5.0** (207 tools) |
|
||||
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
|
||||
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
|
||||
| Ghidra | 12.1.4 |
|
||||
| JDK | Temurin 21 (provisioned) / Microsoft OpenJDK 25.0.4 (adopted) |
|
||||
| Node.js | 24.21.0 (REA engines: `^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0`) |
|
||||
| Host | Windows 11 x64 (build 26200) |
|
||||
|
||||
**License.** MIT (this adapter). REA is MIT and owned by its authors; Ghidra is Apache-2.0 and owned
|
||||
by the NSA. No upstream code is redistributed here.
|
||||
Exercised live: a clean `setup.mjs --reuse` run reached `setup complete`; an MCP `initialize` handshake
|
||||
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.5.0}`; and the whole
|
||||
chain ran under the real desktop host — its own Electron binary launched
|
||||
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
|
||||
call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
|
||||
|
||||
---
|
||||
Not exercised end to end here, and therefore not claimed: the ~775 MB JDK + Ghidra download branch (planned
|
||||
by `--dry-run`, but this machine adopted its existing installs with `--reuse`), and first runs on
|
||||
macOS/Linux.
|
||||
|
||||
## 中文
|
||||
## Scope and caveats
|
||||
|
||||
**作用。** 本插件**自身不做任何逆向分析**。它把上游 REA 的 MCP 服务接入 DeepSeek Harness,
|
||||
让 REA 提供的那套工具在会话里变成可直接调用的 `mcp__rea__*`。
|
||||
- REA exposes 207 tools at 6.5.0, so every session's tool list grows.
|
||||
- Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
|
||||
exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
|
||||
hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
|
||||
`bundle.json` lists which components are in that state under `unverifiedChecksums`.
|
||||
- Downloads are slow on some networks (measured 0.35–0.54 MiB/s from the GitHub release CDN here, so Ghidra
|
||||
alone takes ~25–45 min). Fetching is resumable on both routes: a partial `.part` is continued with a Range
|
||||
request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
|
||||
- Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
|
||||
Analyse only artifacts you are authorized to inspect.
|
||||
- The MCP child and the engines it launches are ordinary processes running as **you**. DSH's tool
|
||||
approvals govern what the model asks for, not what that child may read, write or send on its own;
|
||||
treat the plugin like any third-party code you install.
|
||||
- On Linux, upstream's process-ownership checks need a **procps-compatible `ps`** on PATH; the
|
||||
Ghidra/JADX and firmware paths use it. A minimal image without it fails those tools, not the install.
|
||||
- `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
|
||||
carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
|
||||
skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
|
||||
own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
|
||||
environment, so on a network that needs a mirror, set one of those before provisioning
|
||||
(`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
|
||||
instead of quietly going online.
|
||||
- On POSIX, extraction falls back when the usual tools are missing: a zip is opened with `unzip`, then
|
||||
`python3 -m zipfile`, then the provisioned JDK's `jar`; a tarball with `tar`, then `python3`. A minimal
|
||||
image that has none of them fails with an explicit "no zip extractor available" line rather than a bare
|
||||
"extraction failed". This matters because Ghidra is a zip on every platform and `unzip` is not installed
|
||||
everywhere by default.
|
||||
|
||||
按上游自述,REA 是「**通过一个 MCP 服务,逆向分析二进制文件、应用程序和运行时行为**」——
|
||||
「看到喜欢的功能,弄清它的工作原理,深入到二进制层面」。它「将智能体连接到分析工具,用于检查
|
||||
原生二进制文件、JavaScript 和 Electron 应用、.NET 程序集以及网站」;分析在本机运行,结果包含
|
||||
支撑各项结论的证据和相关限制。原生分析可以使用已有的 Hopper、Ghidra 或 IDA,静态 JavaScript 分析
|
||||
不需要原生引擎。
|
||||
## Updating — three independent things
|
||||
|
||||
上游列出的可分析目标(原文见 [README_zh.md#可以分析什么](https://github.com/morluto/rea/blob/main/README_zh.md#可以分析什么)):
|
||||
原生二进制文件——伪代码、汇编、字符串、符号、调用和引用;JavaScript / Electron——模块、导入、
|
||||
source map、路由与 IPC;网站——页面结构、脚本、网络观察结果与截图;.NET 程序集;Android APK;
|
||||
Android 设备(adb);JEB 项目;Android 资源;固件;软件包与资源;进程行为;以及离线 ELF 布局、
|
||||
EVM 字节码、已记录的 Linux 崩溃与已保存的网络捕获。
|
||||
### Updating from the market
|
||||
|
||||
**本插件在此之上只增加两件事**:把该 MCP 服务注册进 DSH 的工具层(工具名 `mcp__rea__*`),
|
||||
以及让引擎环境变量(`GHIDRA_INSTALL_DIR` 等)只作用于这个 MCP 子进程,而不是整个 harness。
|
||||
Update it where you installed it. Updating **to 2.0.5 or later** is routine; updating an installed 2.0.1–2.0.4
|
||||
*through the in-app market* failed with this, three times on a healthy profile:
|
||||
|
||||
**能做什么、不能做什么(重要)。** 分析能力由上游提供;这里只复述上游自己的契约,避免预期错位。
|
||||
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
||||
|
||||
- **无法完整还原源代码。** 上游对原生二进制返回的是**伪代码与汇编**,对 Android APK 返回
|
||||
**反编译的方法**,对 JEB 单元返回**伪代码**,对 JavaScript/Electron 返回**模块及其关系**。
|
||||
上游的工具契约明确写着:伪代码是反编译器输出,**不是原始源码**,且与其他提供方的输出
|
||||
**不保证文本等价**。
|
||||
- 实践中不可恢复的内容:原始标识符命名、类型、注释、宏与模板展开、构建脚本与资源;
|
||||
优化(内联、指令重排)、去符号(strip)、混淆、加壳与 VM 保护只会让可读性更差。
|
||||
- 每条结论都自带**置信度与限制**(`observed` / `derived` / `inferred`,另有 limitations 列表):
|
||||
请把它们当作证据使用,而不要当作"已还原的源码"。
|
||||
- 适合做:理解行为、定位实现、互操作与兼容实现、安全审计、迁移参考。
|
||||
**要拿到真正的源码,请走正规渠道**——上游代码仓库、厂商授权,或厂商自己的 SDK。
|
||||
That message is not about a broken tarball. On every update the market asks whether the new build's
|
||||
`package.json` points at an entry file that exists (`hasLoadableEntry` in `dshmarket`), and rolls the build back
|
||||
when the answer is false. A bundle may legitimately own no entry — but only if a package it *mounts* has one, and
|
||||
the package this bundle mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather than in
|
||||
`node_modules`, so every location that test probes comes back empty. The 1.x and 2.0.x manifests also declared
|
||||
no `main` at all, so the answer was false even though the profile was working; the rollback was then reported as
|
||||
unverifiable for the same reason. Nothing in the profile was actually damaged — the update simply did not apply.
|
||||
|
||||
**已验证版本**(作者机器,2026-10-12/13):REA `6.4.0`、DeepSeek Harness 桌面运行时 `0.2.0-rc.2`
|
||||
(自带 dsh CLI `0.1.7-rc.2`)、Ghidra `12.1.4`、Node `24.21.0`、Windows 11 x64。行提供方
|
||||
`@deepseek-ai/dsh-mcp-client` 由 harness 自带(npm 最新 `0.0.1-rc.1`)。发布用的这一行**实测跑通过**:
|
||||
`list_documents` 在 `cmd.exe` 形式下正常应答,`open_binary` 走 `ghidra` 提供方成功,
|
||||
而引擎目录仅来自本机 profile 覆盖。
|
||||
2.0.5 declares `main: ./index.mjs`. That file is a real, deliberately empty Cordis plugin: it registers nothing,
|
||||
spawns nothing, touches no state — the working row is still inserted by `cordis.patch.yml` — and it exists so the
|
||||
question "can this package load?" has an honest answer, and so `import 'dsh-plugin-rea'` resolves. On an older
|
||||
version, update to `>=2.0.5`; if the market refuses before that version exists, install `[email protected]`
|
||||
(Desktop: through the plugin manager) instead of updating in place.
|
||||
|
||||
**前置条件。** 满足 REA 要求的 Node;全局安装 REA(`npm i -g rea-agents`)。原生分析需要
|
||||
Ghidra 12.1.x(Hopper / IDA 为替代方案,各自需授权);静态 JavaScript/Electron 与 .NET 分析
|
||||
不需要引擎。
|
||||
### Upgrading from 1.x
|
||||
|
||||
**安装。**
|
||||
Install 2.x, then run provisioning **once** before restarting dsh:
|
||||
|
||||
```
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea
|
||||
# 或从 npm 安装:
|
||||
dsh plugin --profile <profile> add dsh-plugin-rea
|
||||
node scripts/setup.mjs # or: powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
|
||||
```
|
||||
|
||||
[Releases](https://github.com/czcdr/dsh-plugin-rea/releases) 附带预构建 tarball(资产名不带版本号,
|
||||
链接不会失效)。
|
||||
Why it matters: the 1.x row launched `rea mcp` from PATH, while the 2.x row launches
|
||||
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` — a file only `setup.mjs` creates. Upgrade and restart
|
||||
without provisioning and that row exits with `MODULE_NOT_FOUND`: the harness stays up
|
||||
(`failOnStartupError: false` is deliberate), but the `mcp__rea__*` tools are gone and you get **no
|
||||
remediation message**, because the shim that prints one does not exist yet. After the setup run they are
|
||||
back on the next start.
|
||||
|
||||
**配置——通常什么都不用改。** 发布出去的那一行**不含任何机器相关路径**:
|
||||
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
|
||||
checkout? Overwrite the directory and reinstall.
|
||||
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
|
||||
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
|
||||
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
|
||||
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
|
||||
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
|
||||
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
|
||||
- **Upgrading from `2.0.0-bundle.1`**: that release mirrored the shim and `setup.mjs` but not the manifest,
|
||||
and recorded nothing about the plugin checkout, so a bundle it provisioned cannot repair itself — its
|
||||
background provisioning dies on the missing manifest, invisibly, and its own lock suppresses retries for
|
||||
six hours. Re-run `node scripts/setup.mjs` **from the plugin directory** once; it re-mirrors all three
|
||||
files and writes the record that lets later runs find the checkout. `check-updates.ps1` reports this state.
|
||||
- **Provisioned components**: re-run `node scripts/setup.mjs`; it re-verifies what is on disk and only
|
||||
fetches what is missing or wrong. Pins live in `manifest/bundle.manifest.json`.
|
||||
- **Upstream REA / Ghidra**: update the pins in the manifest, then re-run setup. REA version-gates Ghidra
|
||||
(12.1.x today), so update REA first.
|
||||
|
||||
- Windows:`command: cmd.exe`、`args: ['/c', 'rea', 'mcp']` —— npm 的 shim 是 `.cmd`,必须经 shell。
|
||||
- macOS/Linux:`command: rea`、`args: ['mcp']` —— 见
|
||||
[`examples/cordis.patch.posix.yml`](examples/cordis.patch.posix.yml)。
|
||||
`check-updates.ps1` (Windows PowerShell) reports the state of all of these at once.
|
||||
|
||||
唯一可选的是分析引擎。REA 从环境读取 `GHIDRA_INSTALL_DIR`,二选一:
|
||||
## Still prefer the configuration-only 1.x line?
|
||||
|
||||
1. 设为**系统级环境变量**,然后重启 harness(让 MCP 子进程继承);
|
||||
2. 只为这个 MCP 子进程指定 —— 见
|
||||
[`examples/cordis.patch.with-env.yml`](examples/cordis.patch.with-env.yml)。
|
||||
1.x is the adapter **without** provisioning: one row that launches `rea mcp` from PATH and expects Ghidra to be
|
||||
installed by you. It is the right choice when you already manage REA and Ghidra yourself, when you do not want
|
||||
the plugin fetching anything, or when you want the row to keep pointing at your own `rea` binary:
|
||||
|
||||
两种方式都不需要在插件里写死路径:客户端会把父进程环境传给子进程(只剔除名字像密钥的变量),
|
||||
而补丁方言支持 `!!js` 表达式,所以 `GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR`
|
||||
同样可用。
|
||||
|
||||
**机器相关值不要写进插件。** 用你自己的 profile `cordis.patch.yml` **按 id 覆盖**这一行即可;
|
||||
该文件只在你本机、永不随插件发布:
|
||||
|
||||
```yaml
|
||||
- id: mcp-rea
|
||||
config:
|
||||
env:
|
||||
GHIDRA_INSTALL_DIR: '<你的 Ghidra 12.1.x 目录>'
|
||||
```
|
||||
dsh plugin --profile <profile> add [email protected]
|
||||
# or pin the branch instead of the version:
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
|
||||
```
|
||||
|
||||
同一 profile 内 `serverName: rea` 只能有一条记录。
|
||||
Both lines publish the same patch shape and the same `mcp__rea__*` surface. 1.x is maintained on the
|
||||
[`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1), where its own install notes live (Node on REA's
|
||||
engines, `npm i -g rea-agents`, Ghidra 12.1.x for native analysis).
|
||||
|
||||
**验证。** 调用任一 `mcp__rea__*` 工具:`binary_session` 会给出引擎检查项
|
||||
(configuration / installation / headless / native_decompiler / java),应全部 `ok: true`;
|
||||
随后 `open_binary` 打开一个 PE → `binary_overview` → `procedure_pseudo_code`。
|
||||
## License
|
||||
|
||||
**范围与注意。** REA 在 6.4.0 暴露 170+ 个工具,注入后每个会话的工具列表都会变大;行内设置
|
||||
`failOnStartupError: false`,因此 REA 缺失也不会阻塞 harness 启动。部分 REA 工具会启动进程、
|
||||
写文件、访问网络,按 DSH 的权限策略执行。请仅分析你已获得授权的目标。
|
||||
|
||||
**更新:两件事,且都要手动。**
|
||||
|
||||
- **本插件**不会自动更新。**从插件市场安装的,就在市场里更新**;本地 `file:` 目录方式安装的,
|
||||
覆盖目录后在插件管理器里重装。下面更新 REA **不会**更新本插件,反过来也一样。
|
||||
- **上游 REA**:用 `npm i -g rea-agents@latest` 更新。它的 `bin` 入口(`scripts/rea.mjs`)
|
||||
跨版本稳定,所以本插件无需改动。
|
||||
- **Ghidra**:受 REA 版本门槛限制(当前 12.1.x),先升 REA,再确认它接受新版 Ghidra。
|
||||
- **DeepSeek Harness**:升级可能改变 MCP 客户端配置结构,用
|
||||
`Config.listConfigs name=@deepseek-ai/dsh-mcp-client` 看该行 schema 并调整字段名。
|
||||
|
||||
`check-updates.ps1`(Windows PowerShell)会把上述四项一次跑完;
|
||||
`.github/workflows/validate.yml` 在 CI 里守住 bundle 的形状(含「发布物不得含机器路径」这一条)。
|
||||
|
||||
**许可。** 本适配器为 MIT;REA 为 MIT,版权归其作者;Ghidra 为 Apache-2.0,版权归 NSA。
|
||||
本仓库不再分发上游代码。
|
||||
MIT (this adapter). REA is MIT and owned by its authors; Ghidra is Apache-2.0 and owned by the NSA; Temurin
|
||||
is GPLv2+CE; Node.js is MIT; apktool, jadx-headless-mcp and Android platform-tools keep their own licences.
|
||||
**No upstream code or binary is redistributed in this package** — provisioning fetches each component from
|
||||
its official source.
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
# dsh-plugin-rea
|
||||
|
||||
[](https://www.npmjs.com/package/dsh-plugin-rea)
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||
> **这是哪个版本?2.x —— 而 `main` 就是它。** 2.x 自己准备运行时(Node、REA CLI、JDK、Ghidra)再把那一行接好:
|
||||
> 装插件 → 跑一次 `scripts/setup.mjs` → 重启 dsh。
|
||||
> **1.x** 是更早的纯配置适配器 —— 它假设 `rea` 在 PATH 上、Ghidra 由你自己装好 —— 保留在
|
||||
> [`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍是 `[email protected]`。
|
||||
> 完整对比:[两条线:1.x 与 2.x](#两条线1x-与-2x) · 升级:[从 1.x 升级](#从-1x-升级)。
|
||||
|
||||
把上游 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 的 MCP 服务接入
|
||||
**DeepSeek Harness**(dsh),让它的工具在会话里以 `mcp__rea__*` 出现。
|
||||
|
||||

|
||||
|
||||
## 这是什么
|
||||
|
||||
一个**带供给层的 Cordis bundle**。`package.json` 声明 `dsh.bundle.patch`,该 patch 插入一行官方 MCP 客户端
|
||||
插件(`@deepseek-ai/dsh-mcp-client`)。这一行用当前运行的 Node 可执行文件启动 `bin/rea-shim.mjs`,由 shim
|
||||
读取 `scripts/setup.mjs` 写下的本机状态,再拉起它供给的 REA CLI。这里没有任何分析代码,且**发布出去的
|
||||
patch 里不含任何机器路径**。
|
||||
|
||||
> **第三方适配。** 本仓库不属于 REA,不携带 REA 的任何代码,也不由 REA 作者维护;它只组装一行配置,
|
||||
> 并从上游官方源拉取上游自己的发布物。
|
||||
> 上游:[github.com/morluto/rea](https://github.com/morluto/rea)(MIT)·
|
||||
> [中文文档](https://github.com/morluto/rea/blob/main/README_zh.md#可以分析什么) ·
|
||||
> [rea.tools](https://rea.tools/) · npm 包 `rea-agents`。
|
||||
> REA 自身的问题请提给上游;**本插件**的问题提到
|
||||
> [本仓库 issues](https://github.com/czcdr/dsh-plugin-rea/issues)。
|
||||
|
||||
**它只做三件事**:注册 `mcp__rea__*` 这批工具名;把每台机器各自的引擎环境
|
||||
(`GHIDRA_INSTALL_DIR`、`JAVA_HOME`、`REA_ADB_PATH`、`REA_APKTOOL_COMMAND`、`REA_JADX_MCP_JAR`、
|
||||
`REA_BROWSER_EXECUTABLE` 等)限定在那一个 MCP 子进程上、而不是整个 harness;以及把这些组件装到盘上。
|
||||
|
||||
## 能做什么、不能做什么
|
||||
|
||||
分析能力属上游,本节只是复述上游自己的契约,避免预期错位。
|
||||
|
||||
- **它无法还原完整源码。** 原生二进制上游给的是*伪代码与汇编*,APK 是*反编译方法*,JEB 单元是*伪代码*,
|
||||
JavaScript/Electron 是*模块及其关系*。上游的工具契约明确写着伪代码"是反编译器输出,不是原始源码,
|
||||
也不与另一 provider 的输出文本等价"。
|
||||
- 实际上拿不回来的:原始标识符名、类型、注释、宏与模板展开、构建脚本与资源。优化(内联、重排)、
|
||||
符号剥离、混淆、加壳、VM 保护只会让输出更差。
|
||||
- 每个结果都带**置信度与限制**(`observed` / `derived` / `inferred`,外加一份明确的 limitations 列表)。
|
||||
请当证据读,别当还原出来的源码。
|
||||
- 适合:理解行为、定位实现、互操作与兼容重写、安全审阅、迁移参考。要拿真正的源码,走正规渠道 ——
|
||||
上游仓库、厂商授权,或厂商自己的 SDK。
|
||||
|
||||
**供给解决不了的部分。** 这些是上游的宿主门槛,不是本插件漏配了什么:
|
||||
|
||||
| 能力 | 门槛 |
|
||||
|---|---|
|
||||
| Hopper provider | 仅 macOS |
|
||||
| IDA provider | 需要你自己的 IDA 与 `REA_IDA_MCP_CONFIG`;REA 从不启动 IDA |
|
||||
| 固件 `binwalk` / `unblob` | 仅 Linux |
|
||||
| Wakaru JS 还原、pwntools 的 ELF/宕机转储路径 | 仅 Linux x64 |
|
||||
| pwndbg / gdb 核心上下文 | 仅 Linux、macOS |
|
||||
| 原生 mitmproxy 抓包 | 仅 Linux(`REA_MITMDUMP_COMMAND`);HAR 不需要引擎 |
|
||||
| 苹果工具链(Mach-O、plist、资源目录、LLDB) | 仅 macOS |
|
||||
| `capture_process_scenario` | 仅 Linux/macOS 的 PTY |
|
||||
| apktool(Android 资源解码) | 仅当 `REA_APKTOOL_COMMAND` 指向 Node 能**直接执行**的文件时才可用:上游用 `execFile` 不加 shell 直接起这个命令,而 Windows 上 Node 无法直接执行 `.bat`/`.cmd`(EINVAL)。本包按上游文档接的是官方 `apktool.bat`,所以在 Windows 上它拿不到版本、该能力不可用;JADX 走 jar,不受影响 |
|
||||
|
||||
## 两条线:1.x 与 2.x
|
||||
|
||||
| | **2.x** —— 当前这条,`main` | **1.x** —— [`v1`](https://github.com/czcdr/dsh-plugin-rea/tree/v1) |
|
||||
|---|---|---|
|
||||
| 是什么 | 适配器**加供给**:自己准备 Node、REA CLI、JDK、Ghidra,再把那一行接好 | 纯配置适配器:一行 MCP 行,指向你自己装好的 `rea` |
|
||||
| 前置 | 运行时什么都不用装 —— 缺什么它去取(`--reuse` 则复用机器上已有的 Ghidra/JDK) | Node.js(满足 REA engines)、`npm i -g rea-agents`,原生分析还要 Ghidra 12.1.x |
|
||||
| 首次运行 | 先跑一次 `scripts/setup.mjs`(或引导脚本),再重启 dsh | 不用跑任何东西 |
|
||||
| 那一行启动什么 | 当前 Node 可执行文件 + `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(由 shim 读本机状态) | Windows 上 `cmd.exe /c rea mcp`,macOS/Linux 上 `rea mcp` |
|
||||
| 机器状态 | `<DSH_HOME>/plugins/rea-bundle/` —— `bundle.json`、`vendor/`、`cache/` | 你自己的 REA 与 Ghidra 安装 |
|
||||
| 安装 | `dsh plugin --profile <profile> add dsh-plugin-rea`(npm `latest` ≥ 2.0.0) | `… add [email protected]`,或 `github:czcdr/dsh-plugin-rea#v1.0.6` |
|
||||
|
||||
两条线发布的是**同一种 patch 形态**(一条 `insert` 行,`@deepseek-ai/dsh-mcp-client`),对外也提供同一套
|
||||
`mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
|
||||
[从 1.x 升级](#从-1x-升级)。
|
||||
|
||||
上游现在也能自己注册进 dsh(`rea setup --client dsh`):那基本就是上游维护的 1.x 模式 —— 只写配置,
|
||||
Node/REA/Ghidra 由你准备。本包是零依赖路线。三条路选一条,不要同时留两行 `serverName: rea`。
|
||||
|
||||
## 安装
|
||||
|
||||
**前置什么都不用装。** 在干净的 Windows x64 机器上:
|
||||
|
||||
```
|
||||
powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1 -Reuse
|
||||
```
|
||||
|
||||
`-Reuse` 会复用你已有的 Ghidra/JDK;去掉它则从官方发布下载两者(约 775 MB,逐个校验和验证)。然后为你的
|
||||
profile 安装插件:
|
||||
|
||||
```
|
||||
dsh plugin --profile <profile> add dsh-plugin-rea
|
||||
# 或钉住这个版本,而不是跟着 npm latest 走:
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.9
|
||||
```
|
||||
|
||||
> **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
|
||||
> 会被 CLI 直接拒绝(`profile "desktop" is managed exclusively by the Electron application`),请改用**应用内的
|
||||
> 插件管理器**安装/更新;上面这条 CLI 只适用于你自己用 CLI 建的非 `desktop` profile。
|
||||
|
||||
如果机器上已经有 Node,可以跳过引导脚本直接供给:
|
||||
|
||||
```
|
||||
node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
|
||||
```
|
||||
|
||||
如果 Node 由你自备(而不是让 setup 去取),它必须满足 REA 的 engines:
|
||||
`^22.19.0 || ^24.11.0 || >=26.0.0`(用 `node -v` 核对)。版本偏低不会在安装时失败,而是到 MCP 行
|
||||
启动时才失败 —— 表现就是"没有反应"。
|
||||
|
||||
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
|
||||
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
|
||||
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
|
||||
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
|
||||
|
||||
### 支持哪些 DSH 版本
|
||||
|
||||
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
|
||||
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
|
||||
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
|
||||
|
||||
| 宿主版本 | 是否覆盖 |
|
||||
|---|---|
|
||||
| `0.1.7-rc.2` … `0.2.1-rc.2`(中间的 alpha 与 rc 全部在内) | 是 |
|
||||
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
|
||||
|
||||
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
||||
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
|
||||
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
|
||||
|
||||
### 机器上已有 REA 就直接用它
|
||||
|
||||
2.x 存在的意义是补上机器缺的东西。如果 REA 已经装好——`rea` 在 PATH 上,也就是 1.x 的形态——这一行就**直接跑它**,
|
||||
和 1.x 一模一样,不再做任何供给:
|
||||
|
||||
| 机器上的情况 | 这一行跑什么 |
|
||||
|---|---|
|
||||
| `rea` 在 PATH 上,且不比供给的版本旧 | 你自己那份(`rea mcp`),用你的 Node 和你的 PATH |
|
||||
| `rea` 在 PATH 上,但比供给的版本旧 | 供给出来的 bundle —— 旧的全局 CLI 会把工具数往回退(6.4.0 是 170 个,6.5.0 是 207 个) |
|
||||
| PATH 上没有 `rea` | 供给出来的 bundle,首次使用时自行供给 |
|
||||
|
||||
- `REA_BUNDLE_LAUNCH=auto|system|bundle` 覆盖这个判断(默认 `auto`);`REA_BUNDLE_SYSTEM_REA=<path>` 用来点名一个
|
||||
不在 PATH 上的可执行文件。
|
||||
- 探测用的是 CLI 自己的 `--version`,上限 8 秒:Windows 上的 `.cmd` 垫片要 ~2.4 秒才答得出来,所以这个检查每次启动
|
||||
只做一次,而且只在真的存在 `rea` 时才做。
|
||||
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
|
||||
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
|
||||
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
|
||||
- **Windows 上强制结束 shim 不会带走被委派的 REA**:这里没有 job object,任务管理器或 supervisor 终止 shim 时,
|
||||
你自己的 `rea` 会继续占着这一行的管道(控制台 Ctrl+C 会发给整个进程组,那种情况下会一起停)。POSIX 上子进程
|
||||
在同一进程组,随终端一起退出。
|
||||
|
||||
## 配置 —— 不需要
|
||||
|
||||
发布的那一行**不含任何机器路径**:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: !!js process.execPath
|
||||
args:
|
||||
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
|
||||
- mcp
|
||||
env:
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
failOnStartupError: false
|
||||
```
|
||||
|
||||
这一行里有四个"承重"细节,而且**都先以 bug 的形式出现过**:
|
||||
|
||||
- **`!!js` 标量必须加引号。** 裸标量里同时出现 `? ` 和 `: ` 时,它就不是一个标量了:YAML 会把它读成显式键值对,于是 loader 拿到的是一个 **mapping** 而不是表达式节点。用 harness 自己的方言实测(`JSON_SCHEMA` + `app.asar` 里的 `!!js` 类型),不加引号时 `args[0]` 变成 `{"[object Object]": "..."}` —— 一个行根本无法用来启动的对象。加引号消除歧义,内部的 JS 字符串改用单引号。
|
||||
- **必须用正斜杠,不能用反斜杠。** YAML 单引号标量会保留 `\` 字面量,接着 JavaScript 会把它吃掉:`'\plugins'` 不是路径而是 BEL 控制字符,`'\rea-bundle'` 变成回车、`'\bin'` 变成退格 —— 路径被静默摧毁。正斜杠在 Windows 与 POSIX 上都成立。
|
||||
- **`DSH_HOME` 的兜底是 `<home>/.dsh`,不是 `<home>`。** shim、`setup.mjs`、`bootstrap.ps1` 三者都把未设置的 `DSH_HOME` 解析到 `<home>/.dsh/...`;兜底少这一层,行就会指向供给永远不会写的位置 —— 而在 `DSH_HOME` 恰好被设置的机器上,这个错误完全不可见。
|
||||
- **`!!js` 表达式里没有 `path`/`require`**:`ctx` 是 loader 作用域,所以 DSH_HOME 的兜底写成纯字符串拼接。
|
||||
|
||||
`npm run bundle:audit` 会把前三条钉住:它用「`DSH_HOME` 未设 / 为空 / 已设」三种情形求值这个表达式,并拒绝会被 YAML 读成 mapping 的裸标量。
|
||||
|
||||
`ELECTRON_RUN_AS_NODE` 只在 harness 本身跑在 Electron 上时才有意义;对纯 Node 宿主无害,而显式写出来胜过依赖它恰好能在子进程环境清洗中存活。
|
||||
|
||||
`command` 是当前运行的 Node 可执行文件,`args[0]` 由 `DSH_HOME` 在启动时算出,所以同一份 patch 在
|
||||
Windows、macOS、Linux 上都成立,且不依赖 PATH 上的 `rea`。所有机器相关的真相都放在
|
||||
`<DSH_HOME>/plugins/rea-bundle/bundle.json`,由 setup 写入,可随时查看:
|
||||
|
||||
```
|
||||
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 自己的
|
||||
`cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
|
||||
`serverName: rea`。
|
||||
|
||||
### 只保留一条 REA 注册
|
||||
|
||||
上游 REA 6.5.0 起可以自己注册进 DSH:
|
||||
|
||||
```
|
||||
rea setup --client dsh
|
||||
```
|
||||
|
||||
它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
|
||||
`id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
|
||||
duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
|
||||
|
||||
选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
|
||||
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
|
||||
`mcp__rea__*` 工具面相同。
|
||||
|
||||
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
|
||||
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
|
||||
|
||||
## 验证
|
||||
|
||||
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
|
||||
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`),应当全为 `ok: true`。
|
||||
然后 `open_binary` 一个 PE、`binary_overview`、`procedure_pseudo_code`。
|
||||
|
||||
供给本身结束时也会跑两道探针:`rea doctor --json`(如实报告,并点名未满足项),以及**与 MCP 行完全相同的
|
||||
shim 启动**。
|
||||
|
||||
## 实测环境
|
||||
|
||||
作者机器,2026-10-13 / 14 / 16:
|
||||
|
||||
| 组件 | 版本 |
|
||||
|---|---|
|
||||
| REA(`rea-agents`) | **6.5.0**(207 个工具) |
|
||||
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
|
||||
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
|
||||
| Ghidra | 12.1.4 |
|
||||
| JDK | Temurin 21(供给)/ Microsoft OpenJDK 25.0.4(复用) |
|
||||
| Node.js | 24.21.0(REA engines:`^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0`) |
|
||||
| 宿主 | Windows 11 x64(build 26200) |
|
||||
|
||||
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
|
||||
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}`;以及**真实桌面宿主下的完整链路** ——
|
||||
宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
|
||||
的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
|
||||
|
||||
本机**没有**端到端跑过、因此不做承诺的:约 775 MB 的 JDK + Ghidra 下载分支(`--dry-run` 有计划,本机用
|
||||
`--reuse` 复用了已有安装),以及 macOS/Linux 的首次运行。
|
||||
|
||||
## 边界与注意事项
|
||||
|
||||
- REA 在 6.5.0 有 207 个工具,每个会话的工具列表都会变长。
|
||||
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
|
||||
Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
|
||||
`bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
|
||||
- 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
|
||||
下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
|
||||
而不是重下。
|
||||
- **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
|
||||
同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
|
||||
(环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
|
||||
- REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
|
||||
- MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
|
||||
管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
|
||||
- Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
|
||||
极简镜像里缺它只会让这些工具失败,不影响整体安装。
|
||||
- `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
|
||||
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
|
||||
本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
|
||||
`NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
|
||||
`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
|
||||
而不是悄悄联网。
|
||||
- POSIX 上解压有兜底:zip 依次尝试 `unzip` → `python3 -m zipfile` → 已供给 JDK 自带的 `jar`;tar 包依次
|
||||
尝试 `tar` → `python3`。三者都没有的最小镜像会得到一句明确的「no zip extractor available」,而不是
|
||||
干巴巴的「extraction failed」。Ghidra 在所有平台都只有 zip,而 `unzip` 并非每个发行版默认安装,
|
||||
所以这条不是理论问题。
|
||||
|
||||
## 更新 —— 三件互相独立的事
|
||||
|
||||
### 在市场里更新
|
||||
|
||||
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
|
||||
2.0.5**,在健康的 profile 上连续失败了三次:
|
||||
|
||||
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
||||
|
||||
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
|
||||
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
|
||||
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
||||
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
|
||||
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
|
||||
破坏,只是这次更新没有生效。
|
||||
|
||||
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
|
||||
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
|
||||
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
|
||||
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
|
||||
|
||||
### 从 1.x 升级
|
||||
|
||||
装上 2.x 后,**先跑一次供给**,再重启 dsh:
|
||||
|
||||
```
|
||||
node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
|
||||
```
|
||||
|
||||
为什么必须先跑:1.x 的行从 PATH 上启动 `rea mcp`,而 2.x 的行启动
|
||||
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` —— 那个文件**只有 `setup.mjs` 会创建**。升级后直接重启,
|
||||
该行会以 `MODULE_NOT_FOUND` 退出:harness 本身照常(`failOnStartupError: false` 是刻意的),但
|
||||
`mcp__rea__*` 工具会消失,而且**不会有任何修复提示** —— 会打提示的 shim 本身还不存在。跑完 setup 再启动,
|
||||
工具就回来了。
|
||||
|
||||
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
|
||||
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
|
||||
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
|
||||
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
|
||||
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
|
||||
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
|
||||
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
|
||||
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
|
||||
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
|
||||
并写下让后续运行找到真源的记录。`check-updates.ps1` 会报告这个状态。
|
||||
- **供给的组件**:重跑 `node scripts/setup.mjs`,它会重新校验盘上的东西,只补缺失或不对的。
|
||||
版本钉在 `manifest/bundle.manifest.json`。
|
||||
- **上游 REA / Ghidra**:改清单里的钉子再重跑 setup。REA 对 Ghidra 有版本闸门(目前 12.1.x),
|
||||
所以先更新 REA。
|
||||
|
||||
`check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。
|
||||
|
||||
## 还想用纯配置的 1.x 线?
|
||||
|
||||
1.x 是**不带供给**的适配器:一行配置,从 PATH 上启动 `rea mcp`,并假设 Ghidra 由你自己装好。这些情况它是更合适的选择 ——
|
||||
你已经自己管理 REA 与 Ghidra、不希望插件去下载任何东西、或者想让那一行继续指向你自己的 `rea`:
|
||||
|
||||
```
|
||||
dsh plugin --profile <profile> add [email protected]
|
||||
# 或钉住分支而不是版本号:
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v1.0.6
|
||||
```
|
||||
|
||||
两条线发布的 patch 形态与 `mcp__rea__*` 工具面完全相同。1.x 维护在
|
||||
[`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),它自己的安装说明也在那里(Node 满足 REA engines、
|
||||
`npm i -g rea-agents`、原生分析需要 Ghidra 12.1.x)。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT(本适配器)。REA 是 MIT、归其作者;Ghidra 是 Apache-2.0、归 NSA;Temurin 是 GPLv2+CE;Node.js 是
|
||||
MIT;apktool、jadx-headless-mcp、Android platform-tools 各自保留自己的许可。
|
||||
**本包不再分发任何上游代码或二进制** —— 供给过程是从各组件的官方源逐个拉取。
|
||||
@@ -11,7 +11,13 @@
|
||||
(用 pwsh 7 编辑则无此限制,但保持 BOM 对两者都安全)。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([string]$OutDir = (Join-Path $PSScriptRoot '.'))
|
||||
param([string]$OutDir)
|
||||
|
||||
# The documented invocation (`powershell -File .\make-screenshots.ps1`) died right here:
|
||||
# "Cannot bind argument to parameter 'Path' because it is an empty string" — $PSScriptRoot
|
||||
# is not reliably populated while parameter defaults are bound, so this script had never
|
||||
# actually regenerated a screenshot. Resolve the default in the body instead.
|
||||
if (-not $OutDir -or $OutDir.Trim() -eq '') { $OutDir = $PSScriptRoot }
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
@@ -73,25 +79,25 @@ function Save-Png($bmp, $g, $path) { $g.Dispose(); $bmp.Save($path, [System.Draw
|
||||
# ---------------------------------------------------------------- 图 1:插件做了什么
|
||||
$r = New-Canvas; $bmp = $r[0]; $g = $r[1]
|
||||
Draw-Text $g 'dsh-plugin-rea:把 REA 接入 DeepSeek Harness' $fTitle $ink 60 46
|
||||
Draw-Text $g '第三方适配 · 自身不做逆向分析 · 只插入一条 MCP 客户端记录,并把引擎环境变量限制在该子进程内' $fSub $muted 62 98
|
||||
Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/JDK/Ghidra 后自动接好那一行' $fSub $muted 62 98
|
||||
|
||||
$rowY = 180; $boxH = 158; $boxW = 250
|
||||
Draw-Box $g 60 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
|
||||
Draw-Text $g 'DeepSeek Harness 会话' $fBox $ink 80 ($rowY + 18)
|
||||
Draw-Text $g "mcp__rea__* 工具`n(6.4.0 暴露 170+)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
|
||||
Draw-Text $g "mcp__rea__* 工具`n(6.5.0 暴露 207)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
|
||||
|
||||
Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
|
||||
Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
|
||||
Draw-Text $g "@deepseek-ai/dsh-mcp-client`nserverName = rea`ntransport = stdio`ncommand = node" $fBody $muted 380 ($rowY + 50)
|
||||
Draw-Text $g "@deepseek-ai/dsh-mcp-client`nserverName = rea / transport = stdio`ncommand = 当前 Node 可执行文件`nargs[0] = <DSH_HOME>/plugins/rea-bundle/`n bin/rea-shim.mjs" $fBody $muted 380 ($rowY + 50)
|
||||
Draw-Text $g '↑ 本插件插入的就是这一行' $fSmall $accent 360 ($rowY + $boxH + 8)
|
||||
|
||||
Draw-Box $g 660 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
|
||||
Draw-Text $g 'rea mcp(本机进程)' $fBox $ink 680 ($rowY + 18)
|
||||
Draw-Text $g "node …`n \rea-agents\scripts`n \rea.mjs mcp`nenv: GHIDRA_INSTALL_DIR`n JAVA_HOME(仅此处)" $fBody $muted 680 ($rowY + 50)
|
||||
Draw-Text $g 'rea-shim(供给后镜像到此)' $fBox $ink 680 ($rowY + 18)
|
||||
Draw-Text $g "读 bundle.json 里的本机真相`n导出 GHIDRA_INSTALL_DIR /`n JAVA_HOME / REA_*`n再拉起 bundle 内置的 REA" $fBody $muted 680 ($rowY + 50)
|
||||
|
||||
Draw-Box $g 960 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,240,253,244)) $accent2
|
||||
Draw-Text $g '分析引擎' $fBox $accent2 980 ($rowY + 18)
|
||||
Draw-Text $g "Ghidra 12.1.4(本机已装)`nHopper / IDA(可选,`n各自需授权)`n静态 JS / .NET 无需引擎" $fBody $muted 980 ($rowY + 50)
|
||||
Draw-Text $g '分析引擎(供给或复用)' $fBox $accent2 980 ($rowY + 18)
|
||||
Draw-Text $g "Ghidra 12.1.x / JDK 21:`n缺则下载并校验,`n已有则 --reuse 复用`n静态 JS / .NET 无需引擎" $fBody $muted 980 ($rowY + 50)
|
||||
|
||||
Draw-Arrow $g 310 ($rowY + 40) 358 ($rowY + 40) $muted
|
||||
Draw-Arrow $g 610 ($rowY + 40) 658 ($rowY + 40) $muted
|
||||
@@ -106,7 +112,7 @@ Draw-Line $g 358 446 150 446 $muted
|
||||
Draw-Arrow $g 150 446 150 342 $muted
|
||||
|
||||
Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 620
|
||||
Draw-Text $g '实测 REA 6.4.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-12)' $fSmall $muted 60 646
|
||||
Draw-Text $g '实测 REA 6.5.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-16)' $fSmall $muted 60 646
|
||||
Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
|
||||
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
+1212
File diff suppressed because it is too large.
Load diff
+191
-18
@@ -46,6 +46,10 @@ function Test-NodeEngines([string]$node, [string]$engines) {
|
||||
# 之后 $node.Major 为 $null,所有 ^ / ~ 分支都会判假(曾因此把 Node 24 判成不满足)。
|
||||
$v = Get-NumericVersion $node
|
||||
if (-not $v) { return $null }
|
||||
# 只要有一个 term 的形态不认识,整个判断就只能给"无法解析":返回 $false 会把
|
||||
# `22.x` / `>=22` 这类合法但未实现的 pattern 误报成"不满足",让用户去升级一个
|
||||
# 本来就满足的 Node。
|
||||
$recognized = $true
|
||||
foreach ($term in ($engines -split '\|\|')) {
|
||||
$t = $term.Trim()
|
||||
if ($t -match '^\^(\d+\.\d+\.\d+)$') {
|
||||
@@ -56,24 +60,62 @@ function Test-NodeEngines([string]$node, [string]$engines) {
|
||||
} elseif ($t -match '^~(\d+\.\d+\.\d+)$') {
|
||||
$base = [version]$Matches[1]
|
||||
if ($v.Major -eq $base.Major -and $v.Minor -eq $base.Minor -and $v -ge $base) { return $true }
|
||||
} else {
|
||||
$recognized = $false
|
||||
}
|
||||
}
|
||||
if (-not $recognized) { return $null }
|
||||
return $false
|
||||
}
|
||||
|
||||
# ------------------------------------------------------- 定位 REA 与 profile
|
||||
if (-not $ReaPackageDir) {
|
||||
$npmRoot = $null
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
|
||||
}
|
||||
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
|
||||
# 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
|
||||
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
|
||||
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
|
||||
$bundleRootEarly = $env:REA_BUNDLE_ROOT
|
||||
# Resolve the home unconditionally: the profile section below needs it whether or
|
||||
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
|
||||
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
|
||||
# "no profile" on exactly the machines that use the override.
|
||||
$homeEarly = $env:DSH_HOME
|
||||
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
||||
if (-not $bundleRootEarly) {
|
||||
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
|
||||
}
|
||||
$bundleState = $null
|
||||
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
|
||||
if (Test-Path $bundleJsonEarly) {
|
||||
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
|
||||
}
|
||||
$bundleReaPackageDir = $null
|
||||
$bundleReaEntry = $null
|
||||
if ($bundleState -and $bundleState.paths.reaEntry) {
|
||||
$bundleReaEntry = [string]$bundleState.paths.reaEntry
|
||||
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
|
||||
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
|
||||
}
|
||||
|
||||
if (-not $ReaPackageDir) {
|
||||
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
|
||||
$ReaPackageDir = $bundleReaPackageDir
|
||||
} else {
|
||||
$npmRoot = $null
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
|
||||
}
|
||||
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
|
||||
}
|
||||
}
|
||||
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
|
||||
if (-not $ProfileDir) {
|
||||
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
|
||||
else {
|
||||
$profiles = @(Get-ChildItem (Join-Path $env:USERPROFILE '.dsh\profiles') -Directory -ErrorAction SilentlyContinue)
|
||||
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $env:USERPROFILE '.dsh\profiles\desktop' }
|
||||
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
|
||||
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
|
||||
# so on a custom DSH_HOME the profile section reported "no profile found"
|
||||
# while the rest of the run had already resolved that home correctly.
|
||||
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
|
||||
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +129,21 @@ if (Test-Path $pkgFile) {
|
||||
$installed = $pkg.version; $engines = $pkg.engines.node
|
||||
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
|
||||
if ($binPath) { $reaEntry = Join-Path $ReaPackageDir ($binPath -replace '/', '\') }
|
||||
Ok "本地已安装: $installed"
|
||||
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
|
||||
if ($reaFromBundle) {
|
||||
Ok "一体包内置(vendored): $installed"
|
||||
Info "位置: $ReaPackageDir"
|
||||
Info "入口(bin): $binPath"
|
||||
} else {
|
||||
Ok "本地已安装: $installed"
|
||||
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
|
||||
}
|
||||
if ($engines) { Info "Node 要求: $engines" }
|
||||
} catch { Bad "package.json 解析失败: $($_.Exception.Message)" }
|
||||
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
|
||||
} else {
|
||||
if ($reaFromBundle) { Bad "找不到 $pkgFile —— 内置副本不完整?重跑 setup.mjs" }
|
||||
elseif ($bundleReaPackageDir) { Bad "找不到 $pkgFile —— 一体包内置副本也缺($bundleReaPackageDir);记录里没有 reaEntry" }
|
||||
else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
|
||||
}
|
||||
|
||||
$latest = $null
|
||||
foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
|
||||
@@ -103,12 +155,18 @@ if ($installed -and $latest) {
|
||||
$vi = Get-NumericVersion $installed; $vl = Get-NumericVersion $latest
|
||||
if ($vi -and $vl -and $vl -gt $vi) {
|
||||
Warn "有新版本:$installed → $latest"
|
||||
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
|
||||
if ($reaFromBundle) {
|
||||
$actions.Add("升级一体包内置 REA:把 manifest 的 pins.reaAgents / components.reaAgents.sources.npm.version 改成 $latest,然后重跑 node scripts\setup.mjs(内置副本不会自己更新)")
|
||||
} else {
|
||||
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
|
||||
}
|
||||
} elseif ($vi -and $vl -and $vl -eq $vi -and $installed -ne $latest) {
|
||||
Warn "同版本号但通道不同:$installed → $latest(预发布/正式)"
|
||||
} else { Ok '已是最新版本' }
|
||||
} else {
|
||||
} elseif (-not $latest) {
|
||||
Warn '未能取到上游最新版本(两个 registry 都查不通?)—— 已跳过版本比对'
|
||||
} else {
|
||||
Warn '本地没读到已安装版本(见上面那条)—— 已跳过版本比对'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- Node 运行时
|
||||
@@ -130,7 +188,14 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
|
||||
|
||||
# ------------------------------------------------------------ Ghidra 引擎
|
||||
Head 'Ghidra 引擎'
|
||||
$gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
|
||||
# bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
|
||||
# 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
|
||||
$gd = $null
|
||||
if ($bundleState -and $bundleState.paths.ghidra) {
|
||||
$bundleGhidra = [string]$bundleState.paths.ghidra
|
||||
if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
|
||||
}
|
||||
if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
|
||||
if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
|
||||
if ($gd -and (Test-Path $gd)) {
|
||||
Ok "GHIDRA_INSTALL_DIR = $gd"
|
||||
@@ -153,11 +218,28 @@ try {
|
||||
# --------------------------------------------------------- REA 引擎自检
|
||||
Head 'REA 引擎自检(rea doctor)'
|
||||
$env:GHIDRA_INSTALL_DIR = $gd
|
||||
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
|
||||
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
|
||||
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
|
||||
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
|
||||
# 2.x:优先用 bundle 内置的 CLI 和它自己的 Node。PATH 上的 rea 可能是另一个,
|
||||
# 甚至完全不在 PATH —— 那会让整段自检被静默跳过。这正是 1.x 口径的残留。
|
||||
$reaCmd = $null
|
||||
$reaArgs = @()
|
||||
if ($bundleReaEntry -and (Test-Path $bundleReaEntry)) {
|
||||
$bundleNode = $null
|
||||
if ($bundleState -and $bundleState.paths.node) {
|
||||
$candidateNode = [string]$bundleState.paths.node
|
||||
if (Test-Path $candidateNode) { $bundleNode = $candidateNode }
|
||||
}
|
||||
if (-not $bundleNode) { $bundleNode = (Get-Command node -ErrorAction SilentlyContinue).Source }
|
||||
if ($bundleNode) {
|
||||
$reaCmd = $bundleNode; $reaArgs = @($bundleReaEntry)
|
||||
Info '使用一体包内置 REA(node <bundle 入口>),不依赖 PATH 上的 rea'
|
||||
}
|
||||
}
|
||||
if (-not $reaCmd) {
|
||||
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
|
||||
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
|
||||
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
|
||||
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
|
||||
}
|
||||
if (-not $reaCmd) {
|
||||
if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
$reaCmd = 'node'; $reaArgs = @($reaEntry)
|
||||
@@ -214,6 +296,97 @@ if (Test-Path $profPatch) {
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------- 一体包(bundle 分支)
|
||||
# 设计文档承诺了这个分区:一体包把组件版本钉在 manifest 里,供应结果记在
|
||||
# bundle.json 里。两者不一致 = 有一次 pin 更新还没重新供给,或盘上的组件被换过。
|
||||
Head '一体包供给状态(bundle 分支)'
|
||||
$pluginRoot = $PSScriptRoot
|
||||
$manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
|
||||
$bundleRoot = $env:REA_BUNDLE_ROOT
|
||||
if (-not $bundleRoot) {
|
||||
$homeDir = $env:DSH_HOME
|
||||
if (-not $homeDir) { $homeDir = Join-Path $HOME '.dsh' }
|
||||
$bundleRoot = Join-Path $homeDir 'plugins\rea-bundle'
|
||||
}
|
||||
$bundleJson = Join-Path $bundleRoot 'bundle.json'
|
||||
|
||||
if (-not (Test-Path $manifestPath)) {
|
||||
Info '未找到 manifest/bundle.manifest.json —— 这不是一体包分支,跳过'
|
||||
} else {
|
||||
Info "bundle 根目录: $bundleRoot"
|
||||
try {
|
||||
$manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if (Test-Path $bundleJson) {
|
||||
$state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
Ok "已供给:$($state.provisionedAt)"
|
||||
$drift = 0
|
||||
$compared = 0
|
||||
$unrecorded = 0
|
||||
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
|
||||
if ($name -eq 'nodeEngines') { continue }
|
||||
$pinned = [string]$manifest.pins.$name
|
||||
$installed = ''
|
||||
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
|
||||
$installed = [string]$state.versions.$name
|
||||
}
|
||||
if (-not $installed) {
|
||||
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
|
||||
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
|
||||
$unrecorded++
|
||||
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
|
||||
continue
|
||||
}
|
||||
$compared++
|
||||
# Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
|
||||
$same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
|
||||
if ($same) { Info " $name = $installed(pin $pinned)" }
|
||||
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
|
||||
}
|
||||
if ($drift -eq 0 -and $unrecorded -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
|
||||
elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
|
||||
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
|
||||
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
|
||||
}
|
||||
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
|
||||
if (Test-Path $mirrored) {
|
||||
Ok 'MCP 行指向的 shim 已就位'
|
||||
# The row launches this COPY, so a plugin upgrade that does not re-run
|
||||
# setup leaves the old launcher in place — visible only as "the new
|
||||
# version behaves like the old one". Compare it with the checkout the
|
||||
# mirror record names.
|
||||
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
|
||||
if (Test-Path $mirrorRecord) {
|
||||
try {
|
||||
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
|
||||
if ($pluginShim -and (Test-Path $pluginShim)) {
|
||||
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
|
||||
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
|
||||
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
|
||||
} else {
|
||||
Warn "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
|
||||
}
|
||||
} catch { Warn "mirror.json 解析失败: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
|
||||
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
|
||||
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
|
||||
# 而且它已写下的 lock 会让重试被抑制 6 小时。
|
||||
$mirroredManifest = Join-Path $bundleRoot 'manifest\bundle.manifest.json'
|
||||
if (Test-Path $mirroredManifest) { Ok '镜像副本的 manifest 已就位' }
|
||||
else { Bad "镜像副本缺少 manifest: $mirroredManifest —— 请从插件目录重跑一次 node scripts/setup.mjs(旧镜像无法自愈)" }
|
||||
} else {
|
||||
Warn "尚未供给:$bundleJson 不存在 —— 运行 node scripts/setup.mjs(或直接装插件,行会自行触发)"
|
||||
}
|
||||
$pins = @()
|
||||
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
|
||||
if ($name -ne 'nodeEngines') { $pins += "$name=$($manifest.pins.$name)" }
|
||||
}
|
||||
Info ('manifest 钉子: ' + ($pins -join ', '))
|
||||
} catch { Warn "bundle 状态解析失败: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 汇总
|
||||
Head '结论'
|
||||
if ($actions.Count -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
|
||||
|
||||
+39
-31
@@ -1,33 +1,31 @@
|
||||
# dsh-plugin-rea — REA (Reverse Engineer Anything) as native DSH tools
|
||||
# dsh-plugin-rea — one-plugin bundle
|
||||
#
|
||||
# Configuration-only bundle: no Host/Client entry files. It inserts one row of
|
||||
# the official MCP client plugin and points it at the installed `rea mcp`
|
||||
# stdio server; the model then sees mcp__rea__<tool>.
|
||||
# Registers the REA MCP server as mcp__rea__* and launches it through the
|
||||
# bundle's own shim, which reads the machine-local state written by
|
||||
# `scripts/setup.mjs` and exports exactly the variables REA reads.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# PORTABLE BY DEFAULT — this file carries no machine-specific path.
|
||||
# PORTABLE BY CONSTRUCTION — no machine path appears in this file.
|
||||
# * `command` is the running Node executable (`process.execPath`), so the
|
||||
# launcher needs no PATH entry and no shim resolution by the OS.
|
||||
# * `args[0]` is computed at boot from DSH_HOME, where setup mirrors the shim.
|
||||
# * FORWARD SLASHES ONLY in the JS string literals below. A single-quoted YAML
|
||||
# scalar keeps backslashes literal, and JavaScript then eats them: '\plugins'
|
||||
# is not "\plugins" but the BEL control character, '\rea-bundle' becomes a
|
||||
# carriage return and '\bin' a backspace, so the path is silently destroyed.
|
||||
# Forward slashes are accepted on Windows and are correct on POSIX, so one
|
||||
# expression serves every platform.
|
||||
# * `path`/`require` are not in scope for a `!!js` expression (`ctx` is the
|
||||
# loader scope), hence plain string concatenation.
|
||||
# * No DSH_HOME (undefined/absent/empty) falls back to the SAME rule the shim
|
||||
# and setup.mjs use — `<home>/.dsh` — never to the bare home directory.
|
||||
# Getting this wrong is silent: setup would mirror the shim into
|
||||
# `<home>/.dsh/plugins/rea-bundle/bin/` while the row looked for it one level
|
||||
# up, so the row could never start on a machine that merely happened to have
|
||||
# DSH_HOME unset.
|
||||
#
|
||||
# Prerequisite: `rea` must be reachable from the PATH the harness sees.
|
||||
# (`npm i -g rea-agents` puts it there.)
|
||||
#
|
||||
# Windows : command: cmd.exe args: ['/c', 'rea', 'mcp'] (as below)
|
||||
# macOS/Linux: command: 'rea' args: ['mcp']
|
||||
# → see examples/cordis.patch.posix.yml
|
||||
#
|
||||
# Analysis engines: REA reads GHIDRA_INSTALL_DIR from its environment.
|
||||
# - Option 1 (recommended): set it as an OS-level environment variable,
|
||||
# then start/restart the harness so the MCP child inherits it.
|
||||
# - Option 2: put an `env:` block in the row —
|
||||
# see examples/cordis.patch.with-env.yml.
|
||||
# Without it, native analysis reports the engine as unavailable while every
|
||||
# other REA capability keeps working. Static JavaScript/Electron and .NET
|
||||
# analysis need no engine at all.
|
||||
#
|
||||
# Only ONE row may use serverName: rea in a profile. To adapt this bundle for
|
||||
# your machine without touching the upstream file, override `mcp-rea` from
|
||||
# your profile's own cordis.patch.yml (id-targeted config override) — that
|
||||
# file is never published.
|
||||
# ---------------------------------------------------------------------------
|
||||
# One row may use serverName: rea in a profile. To pin a different launch
|
||||
# command on this machine, override `mcp-rea` from the profile's own
|
||||
# cordis.patch.yml (id-targeted config override) — that file is never published.
|
||||
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
@@ -35,9 +33,19 @@
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: 'cmd.exe'
|
||||
command: !!js process.execPath
|
||||
args:
|
||||
- '/c'
|
||||
- 'rea'
|
||||
- 'mcp'
|
||||
# DOUBLE-QUOTED on purpose. A bare scalar containing "? " and ": " is not
|
||||
# one scalar at all: YAML reads `? a : b` as an explicit key/value pair,
|
||||
# so js-yaml hands the loader a mapping instead of an expression node and
|
||||
# args[0] becomes an object the launcher cannot use. Quoting makes the
|
||||
# scalar unambiguous; the JS string literals inside use single quotes so
|
||||
# they cannot collide with the YAML ones.
|
||||
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
|
||||
- mcp
|
||||
env:
|
||||
# Only meaningful when the harness itself runs on Electron; harmless
|
||||
# for a plain Node host. Explicit is better than relying on this
|
||||
# variable happening to survive the child-env scrub.
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
failOnStartupError: false
|
||||
@@ -0,0 +1,211 @@
|
||||
# one-plugin bundle — design notes
|
||||
|
||||
Goal: a fresh machine installs **one DSH plugin** and gets a working `mcp__rea__*`
|
||||
tool surface, without first installing Node, REA, a JDK, or Ghidra by hand.
|
||||
|
||||
`main` **is** this variant: the self-provisioning one-plugin bundle. The earlier
|
||||
**config-only adapter** (a single MCP row pointing at a `rea` the user installed
|
||||
themselves) is preserved on the [`v1` branch](https://github.com/czcdr/dsh-plugin-rea/tree/v1)
|
||||
and on npm as `[email protected]`; nothing here changes that line.
|
||||
|
||||
---
|
||||
|
||||
## 1. What upstream will and will not do
|
||||
|
||||
REA is explicit: it never installs or upgrades unrelated software, and every
|
||||
dependency is "caller-supplied" (`SKILL.md`, `install.sh`, and the remediation
|
||||
strings in `dist/`). That is a deliberate upstream boundary, not an oversight.
|
||||
Consequently **all provisioning must live on our side**, and it must not pretend
|
||||
to be REA.
|
||||
|
||||
Two upstream rules shape the runtime wiring:
|
||||
|
||||
- Only `GHIDRA_INSTALL_DIR` + `JAVA_HOME` (and `REA_IDA_MCP_CONFIG`,
|
||||
`HOPPER_LAUNCHER_PATH`) are ever published by `rea setup`. Every other knob —
|
||||
`REA_ADB_PATH`, `REA_APKTOOL_COMMAND`, `REA_JADX_MCP_JAR`,
|
||||
`REA_BROWSER_EXECUTABLE`, `REA_ILSPY_CMD_PATH` — must be supplied by the
|
||||
launching environment. We supply them per machine from one JSON file.
|
||||
- REA hard-requires an **absolute** Node/runtime path in practice: it rejects
|
||||
relative paths for `GHIDRA_INSTALL_DIR` and resolves Windows `java.exe` by
|
||||
walking PATH itself. We therefore never rely on PATH at all.
|
||||
|
||||
## 2. Verified facts this design rests on
|
||||
|
||||
Measured on Windows 11 x64, REA 6.5.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
|
||||
|
||||
| Fact | Evidence |
|
||||
| --- | --- |
|
||||
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.5.0) and `rea doctor --json` reported 207 tools. `healthy` is `false` on a bundle-only install: 6.5.0 also checks the host's own setup (Hopper, IDA registration, `rea setup` skill/registrations), which provisioning deliberately never touches — the row does not require `healthy`, only a CLI that runs and a catalog it can read |
|
||||
| `!!js` expressions are supported in this profile layer | the existing profile patch already used one; the loader evaluates them |
|
||||
| A row-level `env:` block reaches the MCP child | `binary_session` reported `registrationEnvironment` = `GHIDRA_INSTALL_DIR`, `JAVA_HOME` |
|
||||
| No fixed home/layout is promised | the disk layout differs per install; nothing may assume one |
|
||||
| Windows is an explicit POC boundary upstream | `DoctorDiagnostics`: "the experimental Windows x64 Ghidra P0 boundary" |
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Three responsibilities, kept strictly apart:
|
||||
|
||||
1. **Shim (runtime, per launch).** A tiny Node script the MCP row points at. It
|
||||
loads the machine-local `bundle.json`, exports the discovered paths into the
|
||||
child environment, and execs REA. It never downloads anything on the normal
|
||||
path and never writes to the plugin directory.
|
||||
2. **Setup (provisioning, explicit or first-run).** A Node script that resolves
|
||||
the component manifest, downloads what is missing into a cache, verifies
|
||||
checksums, unpacks, runs the readiness probe, and writes `bundle.json`.
|
||||
Idempotent and re-runnable; `--reuse` adopts pre-existing installs.
|
||||
3. **Patch (wiring, published).** `cordis.patch.yml` on `main`, carrying
|
||||
**no machine path**: the row computes `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`
|
||||
at load time from `DSH_HOME` alone, and that mirrored shim is responsible for the
|
||||
rest. (The checkout's own copy is what a human runs; the row never points at it.)
|
||||
|
||||
The published plugin therefore stays portable, while the machine-specific truth
|
||||
lives in one small JSON file under the DSH home.
|
||||
|
||||
```
|
||||
<dsh home>/plugins/rea-bundle/
|
||||
bundle.json # machine-local truth: resolved paths + versions + hashes
|
||||
mirror.json # which plugin checkout the mirrored files came from
|
||||
cache/ # downloaded archives (resumable, checksum-verified)
|
||||
vendor/ # unpacked payloads (node, jdk, ghidra, apktool, jadx, adb)
|
||||
logs/setup.log
|
||||
```
|
||||
|
||||
## 4. Why a shim instead of an `!!js` expression
|
||||
|
||||
`!!js` can inject values into the row at load time, but it cannot:
|
||||
download, verify, unpack, run a readiness probe, or report progress. It also
|
||||
couples the published patch to expressions that must parse on every platform.
|
||||
A shim is boring, testable, and can report an action's clear remediation text
|
||||
through `failOnStartupError: false` without breaking the harness.
|
||||
|
||||
## 5. Control flow
|
||||
|
||||
```
|
||||
dsh starts
|
||||
└─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
|
||||
├─ mirrored copy differs from the installed plugin → re-exec that copy, once
|
||||
├─ a working `rea` on PATH is at least as new as the bundle
|
||||
│ → exec it (the 1.x contract)
|
||||
├─ bundle.json present & components verify → exec the provisioned REA
|
||||
├─ missing, auto-provision allowed → start setup detached, print
|
||||
│ remediation, exit non-zero
|
||||
└─ missing, auto-provision refused → print exact command, exit
|
||||
```
|
||||
|
||||
The shim resolves its bundle root from **its own location**, not from `DSH_HOME`:
|
||||
the harness strips every `DSH_*` name from an MCP child's environment, so with a
|
||||
custom `DSH_HOME` the shim would look in `~/.dsh`, find nothing, and start a second
|
||||
provisioning run there.
|
||||
|
||||
`setup` stages, each idempotent and individually verifiable:
|
||||
|
||||
1. `detect` — host platform/arch, DSH home, plugin dir, existing installs
|
||||
2. `resolve` — manifest entry per component; candidate URLs are ordered
|
||||
3. `fetch` — download to cache with resume + retries; skip if checksum matches
|
||||
4. `verify` — sha256 against the manifest (and vendor checksum file when present)
|
||||
5. `unpack` — platform-native extraction, atomic into `vendor/`
|
||||
6. `wire` — write `bundle.json`, export the environment variables REA reads
|
||||
7. `mirror` — copy the launcher to the path the published patch computes
|
||||
8. `probe` — `rea doctor --json`, then launch the **mirrored** shim exactly as the row would
|
||||
9. `report` — human summary + exit code; every failure names its remediation
|
||||
|
||||
Two notes on stages that exist because their absence was a bug:
|
||||
|
||||
- **7 (mirror).** The patch points at `<bundle root>/bin/rea-shim.mjs`, and the
|
||||
plugin itself is not guaranteed to live anywhere the patch can compute. Setup
|
||||
therefore copies the launcher (and the setup script it re-invokes) into the
|
||||
bundle root. Without this, provisioning could succeed and the row would still
|
||||
fail — and the "install the plugin first, it provisions itself" path could
|
||||
never start, because the shim that does the provisioning would not exist at
|
||||
the path being launched. The copy list is exactly what a mirrored run reads
|
||||
back, **including `manifest/bundle.manifest.json`**: a mirrored `setup.mjs`
|
||||
resolves its plugin root to the bundle root, so `loadManifest()` reads
|
||||
`<bundle>/manifest/bundle.manifest.json` — a file only this stage can create.
|
||||
It also writes `<bundle>/mirror.json`, naming the checkout the files came
|
||||
from: the bundle outlives any particular checkout location, and without that
|
||||
record a later mirrored run can only copy the bundle onto itself (provisioning
|
||||
the manifest it was mirrored with, forever) and its remediation text would
|
||||
point the operator at a copy that cannot start.
|
||||
- **The row's `!!js` scalar is quoted.** A bare scalar containing `? ` and `: `
|
||||
is not one scalar: YAML reads it as an explicit key/value pair, so the loader
|
||||
receives a mapping where an expression belongs — measured against the
|
||||
harness's own dialect, `args[0]` became `{"[object Object]": "..."}` and the
|
||||
row could not launch at all. Any future expression with a ternary needs the
|
||||
quotes; `audit-manifest.mjs` rejects the bare form.
|
||||
- **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
|
||||
machine that already has one should not be provisioned. Before touching bundle
|
||||
state the shim asks whether `rea` is on PATH and answers `--version`; if it
|
||||
does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
|
||||
the row execs that install exactly as the 1.x row did. The probe is asynchronous
|
||||
and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
|
||||
`REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
|
||||
wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
|
||||
on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
|
||||
unorderable pair (a prerelease on either side) keeps the bundle.
|
||||
- **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
|
||||
runs no setup, so without this the copy keeps executing the code it was made
|
||||
from — invisibly, because it still works. The copy therefore compares its own
|
||||
hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
|
||||
when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
|
||||
mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
|
||||
copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
|
||||
the last one because the harness strips `DSH_*` from an MCP child, so the
|
||||
duplicate-registration check would otherwise read `~/.dsh` on a machine with a
|
||||
custom home. `--shim-status` reports `mirror.stale` instead of hopping.
|
||||
- **3 (fetch).** Route order is deliberate and environment-dependent: direct
|
||||
first, then the system proxy, and on Windows the .NET route ahead of both
|
||||
because it follows the GitHub release-asset redirect that the Node routes
|
||||
cannot. Neither "always direct" nor "always proxy" is correct — measured on
|
||||
the development machine, the local proxy answers github.com but returns 403
|
||||
for nodejs.org and 404 for dl.google.com, while the reverse also happens.
|
||||
|
||||
## 6. What is genuinely impossible on Windows
|
||||
|
||||
Declared up front so no release note over-promises. These are **upstream
|
||||
host gates**, not configuration gaps:
|
||||
|
||||
| Capability | Gate |
|
||||
| --- | --- |
|
||||
| Hopper provider | macOS only |
|
||||
| IDA provider | requires a user-owned IDA + `REA_IDA_MCP_CONFIG`; REA never launches it |
|
||||
| firmware `binwalk` / `unblob` | Linux only |
|
||||
| Wakaru JS recovery | Linux x64 only |
|
||||
| pwntools paths (ELF layout, recorded crash) | Linux x64 only |
|
||||
| pwndbg / gdb core context | Linux/macOS |
|
||||
| `mitmdump` native capture decode | Linux only (HAR needs no engine) |
|
||||
| Apple toolchain (Mach-O, plist, asset catalog, LLDB) | macOS only |
|
||||
| process capture (`capture_process_scenario`) | Linux/macOS PTY |
|
||||
|
||||
Everything else — native Ghidra analysis, static JS/Electron, .NET, archives,
|
||||
Android (JADX/apktool/adb), browser scenarios, EVM, HAR — is reachable on
|
||||
Windows and is what this bundle targets to complete.
|
||||
|
||||
## 7. Component matrix (Windows x64 first)
|
||||
|
||||
| Component | Needed for | Provisioned as |
|
||||
| --- | --- | --- |
|
||||
| Node runtime | running REA at all | reuse a compatible existing node, else portable `node.exe` |
|
||||
| `rea-agents` | the MCP server itself | vendored copy under `vendor/node_modules/rea-agents` |
|
||||
| Temurin JDK 21/25 | Ghidra headless + JADX bridge | portable JDK zip (full JDK, not a JRE) |
|
||||
| Ghidra 12.1.x | native analysis | official release zip, extracted |
|
||||
| apktool | Android resource decode | `apktool.bat` + jar in one directory |
|
||||
| jadx-headless-mcp jar | Android class/method decompile | pinned 0.7.1 jar (REA validates its classes) |
|
||||
| Android platform-tools | `adb` device tools | official zip |
|
||||
| a Chromium-family browser | browser/CDP scenarios | reuse an installed Edge/Chrome; never downloaded |
|
||||
| `ilspycmd` (optional) | .NET decompilation | optional; only wired when present |
|
||||
|
||||
Ghidra itself is **not** redistributed inside the plugin package: the release is
|
||||
~570 MB, which no npm package can carry. It is fetched from the official release
|
||||
and checksum-verified, or adopted from an existing install with `--reuse`.
|
||||
|
||||
## 8. Honest limits of this approach
|
||||
|
||||
- npm's per-package limits (unpacked/compressed size) rule out shipping the JDK
|
||||
and Ghidra inside the tarball. "One plugin install" therefore means *one
|
||||
install plus an automatic, checksum-verified fetch*, not a 600 MB npm package.
|
||||
- First run is network- and time-bound: the full payload is roughly 800 MB
|
||||
(Ghidra 570 MB, JDK 205 MB) plus the REA package. Progress is reported and the
|
||||
download is resumable; a pre-seeded cache makes it offline-capable.
|
||||
- Version pins age. `manifest/bundle.manifest.json` carries the pins, and
|
||||
`check-updates.ps1` gains a bundle section so drift is reported rather than
|
||||
discovered later.
|
||||
@@ -10,7 +10,7 @@
|
||||
# - 一个 PR 最多 3 条。
|
||||
#
|
||||
# 若已发布 Release tarball,可取消下面 tarball 行的注释(资产名必须不带版本号)。
|
||||
# 本仓库的 Release 一直附带 dsh-plugin-rea.tgz(当前 v1.0.5),故该行保持启用。
|
||||
# 本仓库的 Release 一直附带 dsh-plugin-rea.tgz(资产名不带版本号,永远指向最新 Release),故该行保持启用。
|
||||
# 注意:脚本里 npm 映射优先于 tarball(installTargetFor:有 npm 名就走 npm),
|
||||
# 所以这行只是 npm 不可用时的兜底,不会改变市场默认的安装方式。
|
||||
|
||||
@@ -18,6 +18,6 @@ url: https://github.com/czcdr/dsh-plugin-rea
|
||||
name: czcdr/dsh-plugin-rea
|
||||
category: tools
|
||||
description:
|
||||
en: 'Third-party adapter that registers the upstream REA (Reverse Engineer Anything) MCP server into DeepSeek Harness as mcp__rea__* tools. REA itself analyses native binaries (Hopper/Ghidra/IDA), JavaScript/Electron apps, .NET assemblies, websites, Android APKs and adb devices, firmware, and process captures.'
|
||||
zh: '第三方适配插件:把上游 REA(逆向一切,github.com/morluto/rea)的 MCP 服务注册进 DeepSeek Harness,提供 mcp__rea__* 工具。逆向分析能力由 REA 提供,覆盖原生二进制(Hopper/Ghidra/IDA)、JavaScript/Electron 应用、.NET 程序集、网站、Android APK 与 adb 设备、固件与进程捕获。'
|
||||
en: 'Third-party adapter that registers the upstream REA (Reverse Engineer Anything) MCP server into DeepSeek Harness as mcp__rea__* tools. 2.x provisions its own runtime (Node, the REA CLI, a JDK, Ghidra) under <DSH_HOME>/plugins/rea-bundle and launches REA through a shim, so a machine with nothing pre-installed works after one setup run; 1.x (branch v1, npm 1.0.6) is configuration-only and expects REA and Ghidra to be installed already. Analysis is upstream''s: native binaries (Hopper/Ghidra/IDA), JavaScript/Electron apps, .NET assemblies, websites, Android APKs and adb devices, firmware, and process captures.'
|
||||
zh: '第三方适配插件:把上游 REA(逆向一切,github.com/morluto/rea)的 MCP 服务接入 DeepSeek Harness,提供 mcp__rea__* 工具。2.x 自带供给:把 Node、REA CLI、JDK、Ghidra 装到 <DSH_HOME>/plugins/rea-bundle 下,再经 shim 启动 REA —— 干净机器跑一次 setup 即可用;1.x(v1 分支 / npm 1.0.6)是纯配置适配器,需要你自己装好 REA 与 Ghidra。逆向分析能力由 REA 提供,覆盖原生二进制(Hopper/Ghidra/IDA)、JavaScript/Electron 应用、.NET 程序集、网站、Android APK 与 adb 设备、固件与进程捕获。'
|
||||
tarball: https://github.com/czcdr/dsh-plugin-rea/releases/latest/download/dsh-plugin-rea.tgz
|
||||
@@ -1,18 +0,0 @@
|
||||
# macOS / Linux variant of the row.
|
||||
#
|
||||
# `npm i -g rea-agents` installs a `rea` executable on PATH, so no shell wrapper
|
||||
# is needed here (on Windows the shim is a .cmd/.ps1 file, which is why the
|
||||
# default cordis.patch.yml goes through cmd.exe).
|
||||
#
|
||||
# Copy the block below into cordis.patch.yml, replacing the Windows row.
|
||||
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: 'rea'
|
||||
args:
|
||||
- 'mcp'
|
||||
failOnStartupError: false
|
||||
@@ -1,42 +0,0 @@
|
||||
# Windows variant that also pins the analysis-engine environment for the MCP child.
|
||||
#
|
||||
# Use this when GHIDRA_INSTALL_DIR is NOT set as an OS-level environment variable
|
||||
# the harness can inherit — for example after setting it up just now, without
|
||||
# restarting the harness.
|
||||
#
|
||||
# The value below is a placeholder in angle brackets, not a real directory:
|
||||
# replace it with your own, or use the `!!js` form to read the variable the
|
||||
# harness already has. REA reads only GHIDRA_INSTALL_DIR; `java` comes from PATH
|
||||
# or JAVA_HOME.
|
||||
#
|
||||
# Copy the block into cordis.patch.yml, or — better — keep it out of the plugin
|
||||
# entirely and override the row from your profile's own cordis.patch.yml:
|
||||
#
|
||||
# - id: mcp-rea
|
||||
# config:
|
||||
# env:
|
||||
# GHIDRA_INSTALL_DIR: '<your Ghidra directory>'
|
||||
#
|
||||
# A profile-level override is machine-local and is never published.
|
||||
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: 'cmd.exe'
|
||||
args:
|
||||
- '/c'
|
||||
- 'rea'
|
||||
- 'mcp'
|
||||
env:
|
||||
# Replace with your own Ghidra 12.1.x extraction directory.
|
||||
# Leaving the placeholder in place is harmless: REA then reports the
|
||||
# native engine as unavailable, exactly as when the variable is unset.
|
||||
GHIDRA_INSTALL_DIR: '<your Ghidra 12.1.x extraction directory>'
|
||||
# Or read it from the environment the harness already has — the
|
||||
# entry-list dialect evaluates `!!js` expressions at activation:
|
||||
# GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR
|
||||
# JAVA_HOME: '<your JDK 21 or newer home>' # only when java is not on PATH
|
||||
failOnStartupError: false
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Package entry — the smallest thing that makes this bundle a *loadable* package.
|
||||
*
|
||||
* Everything this plugin DOES lives in `cordis.patch.yml`: it inserts one row of
|
||||
* `@deepseek-ai/dsh-mcp-client` that launches `bin/rea-shim.mjs`, and that row is
|
||||
* what the harness loads. This file registers nothing, starts nothing and
|
||||
* provisions nothing.
|
||||
*
|
||||
* It exists because a package that declares no `main`/`exports` is judged by its
|
||||
* consumers as "has no entry file", whether or not the loader ever needs one:
|
||||
*
|
||||
* - The market's update path (`hasLoadableEntry` in dshmarket) asks that
|
||||
* question on every update and, when the answer is false, treats the new
|
||||
* build as broken and rolls back. A bundle with no entry of its own is
|
||||
* *supposed* to survive the test through the packages it mounts — but the
|
||||
* package this one mounts (`@deepseek-ai/dsh-mcp-client`) is part of the
|
||||
* harness build, not of `node_modules`, so every location that test probes
|
||||
* comes back empty. Measured on 2026-10-18: updating an installed, working
|
||||
* 2.0.1 to 2.0.4 failed three times with "updated build has no loadable
|
||||
* entry", and the rollback was then reported as unverifiable for the same
|
||||
* reason.
|
||||
* - `npm pack` metadata and `import 'dsh-plugin-rea'` both resolve `main`;
|
||||
* absent, both fall back to `index.js`, which this repository never shipped.
|
||||
*
|
||||
* So this is a real module with a real Cordis plugin shape rather than a stub
|
||||
* for a checker: if the harness ever does load this package as a plugin, it
|
||||
* loads cleanly and does nothing.
|
||||
*/
|
||||
|
||||
/** Cordis plugin name. Also the package name, which is what the loader expects. */
|
||||
export const name = 'dsh-plugin-rea'
|
||||
|
||||
/**
|
||||
* Intentionally empty.
|
||||
*
|
||||
* The row that does the work is inserted by the bundle patch, not by this
|
||||
* function: registering the MCP client from here would create a second
|
||||
* `serverName: rea` entry, which the harness refuses as a duplicate (the same
|
||||
* collision `bin/rea-shim.mjs` warns about for upstream's `rea setup --client
|
||||
* dsh`). Doing nothing is the correct behaviour, and the function present keeps
|
||||
* this a valid Cordis plugin instead of an arbitrary module.
|
||||
*/
|
||||
export function apply() {}
|
||||
@@ -0,0 +1,406 @@
|
||||
{
|
||||
"$comment": [
|
||||
"Component manifest for the one-plugin bundle. Every entry is fetched from an",
|
||||
"official vendor source and verified before use. Fields:",
|
||||
" sha256 - REQUIRED for offline integrity. Vendor-published where one exists;",
|
||||
" otherwise measured once and recorded here, and drift is reported",
|
||||
" rather than silently accepted. 'unverified' means neither was done",
|
||||
" yet, and setup then pins the hash it observes on first fetch.",
|
||||
" size - expected byte count, used for truncation checks and resume math.",
|
||||
" license - license of the upstream project, not a redistribution claim.",
|
||||
" requires - components whose binaries this one needs to run.",
|
||||
" binaries - workspace-relative entries inside the unpacked directory that are",
|
||||
" directly spawnable; the first that exists becomes the component's",
|
||||
" recorded path so another component can depend on it.",
|
||||
" executable - a placed file that must carry the POSIX exec bit (a wrapper REA",
|
||||
" spawns without a shell); setup chmods exactly these to 0755.",
|
||||
"Nothing here is redistributed inside the npm package; setup downloads it."
|
||||
],
|
||||
"manifestVersion": 1,
|
||||
"generatedFor": "dsh-plugin-rea one-plugin bundle",
|
||||
"pins": {
|
||||
"reaAgents": "6.5.0",
|
||||
"ghidra": "12.1.4",
|
||||
"jdk": "21",
|
||||
"apktool": "3.0.3",
|
||||
"jadxHeadlessMcp": "0.7.1",
|
||||
"node": "24.21.0",
|
||||
"nodeEngines": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
||||
},
|
||||
"components": {
|
||||
"node": {
|
||||
"description": "Portable Node.js runtime used to execute vendored REA. Only provisioned when no compatible node is already reachable.",
|
||||
"kind": "archive",
|
||||
"license": "MIT (Node.js)",
|
||||
"requires": [],
|
||||
"unpack": { "stripRootComponents": 1, "moveTo": "node" },
|
||||
"binaries": ["bin/node", "node{exe}"],
|
||||
"probe": {
|
||||
"argv": ["{node}", "-v"],
|
||||
"expect": { "regex": "^v(22\\.(1[9]|[2-9][0-9])|24\\.(1[1-9]|[2-9][0-9])|2[6-9]\\.|[3-9][0-9]\\.)" },
|
||||
"why": "REA's engines are ^22.19.0 || ^24.11.0 || >=26.0.0. A loose '^v\\d+' accepted Node 20, which then fails at REA runtime; the reuse path must reject it here."
|
||||
},
|
||||
"sources": {
|
||||
"win32-x64": {
|
||||
"version": "24.21.0",
|
||||
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip",
|
||||
"format": "zip",
|
||||
"size": 37618919,
|
||||
"sha256": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-win-x64.zip)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "Checksum and unpack verified on this machine: the extracted node.exe runs standalone (v24.21.0), no installer and no registry writes."
|
||||
},
|
||||
"win32-arm64": {
|
||||
"version": "24.21.0",
|
||||
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-arm64.zip",
|
||||
"format": "zip",
|
||||
"size": 33679608,
|
||||
"sha256": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-win-arm64.zip), size confirmed by HEAD",
|
||||
"sha256VerifiedOn": "2026-10-13"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"version": "24.21.0",
|
||||
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 54203979,
|
||||
"sha256": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-darwin-x64.tar.gz)",
|
||||
"sha256VerifiedOn": "2026-10-13"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"version": "24.21.0",
|
||||
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 52909993,
|
||||
"sha256": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-darwin-arm64.tar.gz)",
|
||||
"sha256VerifiedOn": "2026-10-13"
|
||||
},
|
||||
"linux-x64": {
|
||||
"version": "24.21.0",
|
||||
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.xz",
|
||||
"format": "tar.xz",
|
||||
"size": 31890184,
|
||||
"sha256": "fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-linux-x64.tar.xz)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": ".tar.xz is not unpackable by Windows Expand-Archive; bsdtar handles it. A .tar.gz twin exists for every platform if xz support is ever in doubt."
|
||||
},
|
||||
"linux-arm64": {
|
||||
"version": "24.21.0",
|
||||
"url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.xz",
|
||||
"format": "tar.xz",
|
||||
"size": 30859684,
|
||||
"sha256": "6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt (line: node-v24.21.0-linux-arm64.tar.xz)",
|
||||
"sha256VerifiedOn": "2026-10-13"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jdk": {
|
||||
"description": "Full JDK (not a JRE) for Ghidra headless and the JADX bridge. REA validates it with 'java --list-modules' and requires jdk.compiler.",
|
||||
"kind": "archive",
|
||||
"license": "GPL-2.0-with-classpath-exception (Eclipse Temurin)",
|
||||
"requires": [],
|
||||
"unpack": { "stripRootComponents": 1, "moveTo": "jdk" },
|
||||
"probe": { "argv": ["{jvm}", "--list-modules"], "expect": { "contains": "jdk.compiler" } },
|
||||
"sources": {
|
||||
"win32-x64": {
|
||||
"version": "21.0.12.1+1",
|
||||
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1.zip",
|
||||
"format": "zip",
|
||||
"size": 205073461,
|
||||
"sha256": "f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e",
|
||||
"sha256Source": "Adoptium API /v3/assets/latest/21/hotspot (binary.package.checksum), cross-checked against the per-asset .zip.sha256.txt sidecar",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=windows&vendor=eclipse",
|
||||
"note": "Unpacked and exercised on this machine: bin/javac.exe and jmods are present, java --list-modules reports jdk.compiler, and REA's Ghidra provider accepted it as a 64-bit full JDK 21+."
|
||||
},
|
||||
"darwin-x64": {
|
||||
"version": "21.0.12.1+1",
|
||||
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_mac_hotspot_21.0.12.1_1.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 194316575,
|
||||
"sha256": "44db0f08196daf19a47f90d13388b0c943b67663cb537f998fe29e836fa842ce",
|
||||
"sha256Source": "Adoptium API (binary.package.checksum)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=mac&vendor=eclipse"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"version": "21.0.12.1+1",
|
||||
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.12.1_1.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 200073404,
|
||||
"sha256": "3623232f33a9c3baadf304480b2535f9a3cba8a58d42ecbb438ba267315d9998",
|
||||
"sha256Source": "Adoptium API (binary.package.checksum)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=aarch64&image_type=jdk&os=mac&vendor=eclipse"
|
||||
},
|
||||
"linux-x64": {
|
||||
"version": "21.0.12.1+1",
|
||||
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_linux_hotspot_21.0.12.1_1.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 207473347,
|
||||
"sha256": "ce79869e1307ed8ee1e2baa86a412b1eb5b75d10a01006d788a6f968bcfaee94",
|
||||
"sha256Source": "Adoptium API (binary.package.checksum)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=linux&vendor=eclipse"
|
||||
},
|
||||
"linux-arm64": {
|
||||
"version": "21.0.12.1+1",
|
||||
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.12.1_1.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 205641175,
|
||||
"sha256": "23e37e026f12f3e706f18938ff611db3032d075b09d0879a25d06718c773e223",
|
||||
"sha256Source": "Adoptium API (binary.package.checksum)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"metaApi": "https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=aarch64&image_type=jdk&os=linux&vendor=eclipse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ghidra": {
|
||||
"description": "Ghidra release, extracted. REA locates support/analyzeHeadless(.bat) and Features/Decompiler/os/<platform>/decompile[.exe] inside it.",
|
||||
"kind": "archive",
|
||||
"license": "Apache-2.0",
|
||||
"requires": ["jdk"],
|
||||
"unpack": { "stripRootComponents": 1, "moveTo": "ghidra" },
|
||||
"probe": { "file": "{ghidra}/Ghidra/application.properties", "expect": { "contains": "application.version=" } },
|
||||
"sources": {
|
||||
"all": {
|
||||
"version": "12.1.4",
|
||||
"url": "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.4_build/ghidra_12.1.4_PUBLIC_20260921.zip",
|
||||
"format": "zip",
|
||||
"size": 569649598,
|
||||
"sha256": "ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db",
|
||||
"sha256Source": "GitHub release body of the pinned tag: api.github.com/repos/NationalSecurityAgency/ghidra/releases/tags/Ghidra_12.1.4_build -> .body, line '* SHA-256: `ddac49f9...`'. No checksum asset exists (six candidate names 404). Always read the pinned tag, never releases/latest, because the body changes.",
|
||||
"releaseApi": "https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/tags/Ghidra_12.1.4_build",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "No platform split for the public release: one zip serves Windows/macOS/Linux. Unpacked and sniffed here (support/analyzeHeadless.bat and the win_x86_64 native decompiler are present, and REA reported provider version 12.1.4). Windows traps: a path containing '!' or '^' breaks Ghidra, and macOS native components are not shipped (they must be built with support/gradle/gradlew buildNatives). Ghidra requires JDK 21+; application.java.max is empty at this tag, so a newer JDK is permitted."
|
||||
}
|
||||
}
|
||||
},
|
||||
"apktool": {
|
||||
"description": "Android resource decoding. REA is pointed at the wrapper script, which must sit next to the jar.",
|
||||
"kind": "multi-file",
|
||||
"license": "Apache-2.0",
|
||||
"requires": ["jdk"],
|
||||
"entries": {
|
||||
"all": [
|
||||
{
|
||||
"id": "jar",
|
||||
"version": "3.0.3",
|
||||
"url": "https://github.com/iBotPeaches/Apktool/releases/download/v3.0.3/apktool_3.0.3.jar",
|
||||
"format": "file",
|
||||
"size": 15478013,
|
||||
"sha256": "dbf930b076c6b9be08d57c449cacefc3bdd6b71ebd59b3066fc0e1f5b14f9423",
|
||||
"sha256Source": "Vendor-published in the 3.0.3 release post (apktool.org/blog/apktool-3.0.3/), md5 50d98c920b005d2fcdc18d3d52e3f23b. Independently reproduced here: this is the hash of the 15,478,013-byte apktool_3.0.3.jar.",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"moveTo": "apktool",
|
||||
"saveAs": "apktool.jar"
|
||||
}
|
||||
],
|
||||
"byPlatform": {
|
||||
"win32-x64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/windows/apktool.bat",
|
||||
"format": "file",
|
||||
"size": 2386,
|
||||
"sha256": "d81a91bd694459a61a88e4a1a8184efc21130390ff7cb5c172f631551ad9842a",
|
||||
"sha256Source": "measured on 2026-10-16 from the immutable v3.0.3 tag (raw.githubusercontent.com publishes no checksum for the wrapper). The tag, not master: pinning master meant any upstream commit to the wrapper broke every new install with a hash mismatch, and four retries could not fix a download whose content had legitimately changed.",
|
||||
"sha256VerifiedOn": "2026-10-16",
|
||||
"notes": [
|
||||
"The wrapper pushd's to its own directory and resolves apktool.jar first, then globs apktool_*.jar, so the renamed jar next to it is found.",
|
||||
"The previous entry measured master's copy (2,348 bytes) and recorded that it returns exit 0 in 0.2 s while appending 'Press any key to continue . . .' to stdout. This is the tag's copy (2,386 bytes) and that runtime behaviour has NOT been re-measured; treat the trailing-text claim as unverified for this file.",
|
||||
"REA is pointed at this .bat (REA_APKTOOL_COMMAND), not at the jar, exactly as the wrapper expects."
|
||||
],
|
||||
"moveTo": "apktool",
|
||||
"saveAs": "apktool.bat"
|
||||
}
|
||||
],
|
||||
"darwin-x64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
|
||||
"format": "file",
|
||||
"size": 2709,
|
||||
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
|
||||
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag (the correct file for macOS; the linux script is a different file). The tag is immutable, so this pin cannot drift under a new install the way master's did.",
|
||||
"sha256VerifiedOn": "2026-10-16",
|
||||
"executable": true,
|
||||
"moveTo": "apktool",
|
||||
"saveAs": "apktool"
|
||||
}
|
||||
],
|
||||
"darwin-arm64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
|
||||
"format": "file",
|
||||
"size": 2709,
|
||||
"sha256": "3b9a95c41680c4a758b0b8d6e91e122b2ea3f1a189685a915613ca1ccba00fd9",
|
||||
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same osx wrapper serves arm64.",
|
||||
"sha256VerifiedOn": "2026-10-16",
|
||||
"executable": true,
|
||||
"moveTo": "apktool",
|
||||
"saveAs": "apktool"
|
||||
}
|
||||
],
|
||||
"linux-x64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
|
||||
"format": "file",
|
||||
"size": 2685,
|
||||
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
|
||||
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag.",
|
||||
"sha256VerifiedOn": "2026-10-16",
|
||||
"executable": true,
|
||||
"moveTo": "apktool",
|
||||
"saveAs": "apktool"
|
||||
}
|
||||
],
|
||||
"linux-arm64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
|
||||
"format": "file",
|
||||
"size": 2685,
|
||||
"sha256": "493a9c98df968a25c373ca9df871ca77730c82954be4959b74e90adaf91a0e77",
|
||||
"sha256Source": "measured on 2026-10-16 from the v3.0.3 tag; the same linux wrapper serves arm64. It is only correct where the platform-tools archive is too — see the arch caveat on androidPlatformTools.",
|
||||
"sha256VerifiedOn": "2026-10-16",
|
||||
"executable": true,
|
||||
"moveTo": "apktool",
|
||||
"saveAs": "apktool"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"probe": { "argv": ["{jvm}", "-jar", "{vendor}/apktool/apktool.jar", "--version"], "expect": { "regex": "\\d+\\.\\d+" } }
|
||||
},
|
||||
"jadxHeadlessMcp": {
|
||||
"description": "REA validates this exact jar's bridge classes; it is not interchangeable with a plain jadx build.",
|
||||
"kind": "file",
|
||||
"license": "Apache-2.0",
|
||||
"requires": ["jdk"],
|
||||
"probe": { "file": "{vendor}/jadx/jadx-headless-mcp-0.7.1-all.jar" },
|
||||
"entries": {
|
||||
"all": [
|
||||
{
|
||||
"id": "jar",
|
||||
"version": "0.7.1",
|
||||
"url": "https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar",
|
||||
"format": "file",
|
||||
"size": 42619858,
|
||||
"sha256": "6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75",
|
||||
"sha256Source": "Vendor-published sidecar asset releases/download/v0.7.1/jadx-headless-mcp.sha256 (98 bytes, one line); the value was independently reproduced here by hashing the downloaded 42,619,858-byte jar. 0.7.1 is the newest release and REA requires exactly this bridge build: the project's own build.gradle.kts documents that the fat jar must be shaded with duplicatesStrategy INCLUDE, otherwise only KotlinMetadataPlugin registers and every APK resolves to a single R class. Versions 0.6.0 and earlier predate that fix.",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"moveTo": "jadx",
|
||||
"saveAs": "jadx-headless-mcp-0.7.1-all.jar"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"androidPlatformTools": {
|
||||
"description": "adb, for REA's device tools. Optional: without a device these tools simply report no devices.",
|
||||
"kind": "archive",
|
||||
"license": "Android SDK Terms (Google)",
|
||||
"requires": [],
|
||||
"optional": true,
|
||||
"unpack": { "stripRootComponents": 1, "moveTo": "platform-tools" },
|
||||
"probe": { "argv": ["{vendor}/platform-tools/adb{exe}", "version"], "expect": { "contains": "Android Debug Bridge" } },
|
||||
"sources": {
|
||||
"win32-x64": {
|
||||
"version": "latest (37.0.1)",
|
||||
"url": "https://dl.google.com/android/repository/platform-tools-latest-windows.zip",
|
||||
"format": "zip",
|
||||
"size": 8044989,
|
||||
"sha256": "45f4d63113e895ebde0c90f194099a4676b6ac653bd28d54314a9e022bbc1a99",
|
||||
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 and the archive verified readable (15 entries incl. adb.exe). The archive's own platform-tools/source.properties reports Pkg.Revision=37.0.1, so the real pin is 'platform-tools 37.0.1'. Google's repository2-3.xml carries size+checksum for versioned archives if a vendor hash is ever required.",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "Rolling 'latest' URL pinned by observation; setup re-verifies against this value and reports drift. Android SDK components are not OSI-licensed and Google restricts redistribution, so this is fetched from Google at install time and never re-hosted. Also contains fastboot, sqlite3, mke2fs and the USB API DLLs."
|
||||
},
|
||||
"darwin-x64": {
|
||||
"version": "latest",
|
||||
"url": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip",
|
||||
"format": "zip",
|
||||
"size": 16110554,
|
||||
"sha256": "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d",
|
||||
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 by downloading the archive and hashing it locally (16,110,554 bytes)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "Rolling 'latest' URL pinned by observation; setup re-verifies against this value and reports drift."
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"version": "latest",
|
||||
"url": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip",
|
||||
"format": "zip",
|
||||
"size": 16110554,
|
||||
"sha256": "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d",
|
||||
"sha256Source": "Same darwin archive as x64 (measured 2026-10-13); Google publishes one darwin zip for both architectures.",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "The darwin archive contains x86_64 binaries. On Apple silicon they run only through Rosetta 2; without it adb cannot start and this optional component is reported unavailable."
|
||||
},
|
||||
"linux-x64": {
|
||||
"version": "latest",
|
||||
"url": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip",
|
||||
"format": "zip",
|
||||
"size": 9054187,
|
||||
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
|
||||
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 by downloading the archive and hashing it locally (9,054,187 bytes)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "The linux archive contains x86_64 binaries. Google publishes no arm64 platform-tools and this bundle does not emulate one, so on linux-arm64 the entry below is offered but its probe is expected to fail — reported as an optional component being unavailable, never as a broken install."
|
||||
},
|
||||
"linux-arm64": {
|
||||
"version": "latest",
|
||||
"url": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip",
|
||||
"format": "zip",
|
||||
"size": 9054187,
|
||||
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
|
||||
"sha256Source": "Google publishes no arm64 platform-tools; this is the same x86_64 linux archive as x64 (measured 2026-10-13), recorded so the mismatch is explicit instead of implied.",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "Kept so an arm64 host reports a precise failure instead of \"no source\". adb here is x86_64: it runs under qemu/binfmt where the host provides it, and otherwise the probe fails and the report names it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"reaAgents": {
|
||||
"description": "The REA MCP server itself, installed into vendor/node_modules so the bundle owns the runtime copy. The resolved entry point is vendor/node_modules/rea-agents/scripts/rea.mjs.",
|
||||
"kind": "npm",
|
||||
"license": "MIT",
|
||||
"requires": ["node"],
|
||||
"unpack": { "moveTo": "rea-agents" },
|
||||
"probe": {
|
||||
"argv": ["{node}", "{vendor}/node_modules/rea-agents/scripts/rea.mjs", "--version"],
|
||||
"expect": { "regex": "^6\\.5\\.0(\\s|$)" }
|
||||
},
|
||||
"sources": {
|
||||
"npm": {
|
||||
"version": "6.5.0",
|
||||
"registry": "https://registry.npmjs.org/rea-agents",
|
||||
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1650 entries in 6.5.0, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
|
||||
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod. 6.5.0 is the same engines/bin contract as 6.4.0 (^22.19.0 || ^24.11.0 || >=26.0.0, scripts/rea.mjs) and adds 37 MCP tools (170 -> 207 by rea doctor --json on 2026-10-16; the upstream contract files count 206) plus an optional frida dependency; --ignore-scripts means its install script never runs."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notProvisionable": {
|
||||
"$comment": "Upstream host gates. Listed so the setup report can explain an unavailable capability instead of silently omitting it.",
|
||||
"hopper": "macOS only (HOPPER_LAUNCHER_PATH).",
|
||||
"ida": "requires a user-owned IDA plus REA_IDA_MCP_CONFIG; REA never launches IDA.",
|
||||
"firmware": "binwalk/unblob are Linux-only (REA_BINWALK_COMMAND / REA_UNBLOB_COMMAND).",
|
||||
"wakaru": "Linux x64 only (REA_WAKARU_COMMAND).",
|
||||
"pwntools": "Linux x64 only (REA_PWNTOOLS_PYTHON) — ELF layout and recorded-crash tools.",
|
||||
"pwndbg": "Linux/macOS only (gdb + REA_PWNDBG_GDB).",
|
||||
"mitmdump": "Linux only, native mitmproxy captures (REA_MITMDUMP_COMMAND); HAR needs no engine.",
|
||||
"appleToolchain": "macOS only (xcrun, plutil, assetutil, hdiutil, lldb).",
|
||||
"processCapture": "Linux/macOS PTY only.",
|
||||
"ilspycmd": "Optional .NET decompiler (REA_ILSPY_CMD_PATH). Only wired when a runnable ilspycmd already exists; the static managed provider works without it.",
|
||||
"browser": "Reused from the host (REA_BROWSER_EXECUTABLE). Never downloaded by this bundle."
|
||||
}
|
||||
}
|
||||
+28
-5
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "dsh-plugin-rea",
|
||||
"version": "1.0.5",
|
||||
"description": "Registers the upstream REA MCP server into DeepSeek Harness as mcp__rea__* tools — a third-party adapter, analysis is upstream's. · 把上游 REA 的 MCP 服务接入 DeepSeek Harness,提供 mcp__rea__* 工具(第三方适配,分析能力属上游)。",
|
||||
"version": "2.0.9",
|
||||
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
|
||||
"type": "module",
|
||||
"main": "./index.mjs",
|
||||
"license": "MIT",
|
||||
"author": "czcdr",
|
||||
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
|
||||
@@ -27,15 +28,24 @@
|
||||
"ghidra",
|
||||
"binary-analysis"
|
||||
],
|
||||
"bin": {
|
||||
"rea-bundle-setup": "./scripts/setup.mjs",
|
||||
"rea-bundle-status": "./bin/rea-shim.mjs"
|
||||
},
|
||||
"files": [
|
||||
"cordis.patch.yml",
|
||||
"index.mjs",
|
||||
"bin/",
|
||||
"scripts/",
|
||||
"manifest/",
|
||||
"docs/bundle-design.md",
|
||||
"README.md",
|
||||
"README.zh-CN.md",
|
||||
"LICENSE",
|
||||
"CHANGELOG.md",
|
||||
"check-updates.ps1",
|
||||
"screenshots.json",
|
||||
"assets/",
|
||||
"examples/"
|
||||
"assets/"
|
||||
],
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
@@ -43,6 +53,19 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"check-updates": "powershell -NoProfile -File ./check-updates.ps1"
|
||||
"check-updates": "powershell -NoProfile -File ./check-updates.ps1",
|
||||
"bundle:setup": "node ./scripts/setup.mjs",
|
||||
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
|
||||
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
|
||||
"bundle:audit": "node ./scripts/audit-manifest.mjs",
|
||||
"bundle:test": "node --test ./scripts/setup.test.mjs",
|
||||
"bundle:host-compat": "node ./scripts/host-compat.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.19.0 || ^24.11.0 || >=26.0.0",
|
||||
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* manifest audit — validate the component manifest before it is published.
|
||||
*
|
||||
* JSON.parse cannot see duplicate keys (the last one silently wins), and this
|
||||
* manifest is long enough that a duplicated field is easy to introduce while
|
||||
* editing. So the checks are:
|
||||
*
|
||||
* 1. duplicate object keys inside any single object (parse-level)
|
||||
* 2. every fetch entry has a sha256 that is either 64-hex or the literal
|
||||
* "unverified", never a placeholder or an invented value
|
||||
* 3. every fetch entry declares a size, except formats where the vendor does
|
||||
* not publish one
|
||||
* 4. every platform we claim to support has every entry it needs
|
||||
* 5. the apktool wrapper for a platform is the right script (the osx wrapper
|
||||
* is not the linux one) and jar+wrapper both land in the same directory
|
||||
* 6. every probe token is one the substitution step actually understands
|
||||
* 7. `requires` names refer to real components and cannot form a cycle
|
||||
*
|
||||
* Exit code 0 = clean, 1 = findings. Also usable as `npm run bundle:audit`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MANIFEST = path.join(HERE, "..", "manifest", "bundle.manifest.json");
|
||||
const raw = readFileSync(MANIFEST, "utf8");
|
||||
|
||||
const findings = [];
|
||||
const add = (severity, where, message) => findings.push({ severity, where, message });
|
||||
|
||||
/* -------------------------------------------------- 1. duplicate object keys */
|
||||
|
||||
/** Walk the raw text and report any key that appears twice in one object. */
|
||||
function findDuplicateKeys(text) {
|
||||
const duplicates = [];
|
||||
const stack = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === "{") {
|
||||
stack.push(new Set());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "}") {
|
||||
stack.pop();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < text.length) {
|
||||
if (text[i] === "\\") i += 2;
|
||||
else if (text[i] === '"') break;
|
||||
else i += 1;
|
||||
}
|
||||
const key = text.slice(start + 1, i);
|
||||
i += 1;
|
||||
// A key is followed by ':' after optional whitespace.
|
||||
let j = i;
|
||||
while (j < text.length && /\s/.test(text[j])) j += 1;
|
||||
if (text[j] === ":" && stack.length > 0) {
|
||||
const frame = stack[stack.length - 1];
|
||||
if (frame.has(key)) duplicates.push({ key, index: start });
|
||||
frame.add(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
const duplicates = findDuplicateKeys(raw);
|
||||
if (duplicates.length > 0) {
|
||||
const lineOf = (index) => raw.slice(0, index).split("\n").length;
|
||||
for (const duplicate of duplicates) {
|
||||
add("error", `line ${lineOf(duplicate.index)}`, `duplicate object key "${duplicate.key}" — JSON.parse silently keeps only the last one`);
|
||||
}
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
add("error", "manifest", `not valid JSON: ${String(error.message)}`);
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
/* ------------------------------------------- 2/3. every fetch entry is sound */
|
||||
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
const fetchEntries = [];
|
||||
for (const [componentName, component] of Object.entries(manifest.components ?? {})) {
|
||||
if (component.sources) {
|
||||
for (const [key, source] of Object.entries(component.sources)) {
|
||||
if (key === "npm") continue;
|
||||
fetchEntries.push({ where: `${componentName}.sources.${key}`, entry: source });
|
||||
}
|
||||
}
|
||||
if (component.entries) {
|
||||
for (const [key, list] of Object.entries(component.entries)) {
|
||||
if (key === "byPlatform") {
|
||||
for (const [platform, platformList] of Object.entries(list)) {
|
||||
for (const [index, entry] of platformList.entries()) {
|
||||
fetchEntries.push({ where: `${componentName}.entries.byPlatform.${platform}[${index}]`, entry });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [index, entry] of list.entries()) {
|
||||
fetchEntries.push({ where: `${componentName}.entries.${key}[${index}]`, entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { where, entry } of fetchEntries) {
|
||||
const sha = entry.sha256;
|
||||
if (sha === undefined) {
|
||||
add("error", where, "has no sha256 field");
|
||||
} else if (sha !== "unverified" && !HEX64.test(sha)) {
|
||||
add("error", where, `sha256 is neither 64 lowercase hex nor "unverified": ${JSON.stringify(sha)}`);
|
||||
}
|
||||
if (entry.size === undefined && entry.format !== "npm") {
|
||||
add("warn", where, "has no size, so a truncated transfer cannot be detected by length");
|
||||
}
|
||||
if (!entry.url) add("error", where, "has no url");
|
||||
if (entry.url && /localhost|127\.0\.0\.1/.test(entry.url)) add("error", where, "url points at loopback");
|
||||
if (entry.sha256 === "unverified" && !entry.sha256Source) {
|
||||
add("warn", where, "is unverified but does not say why");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- 4. platform coverage */
|
||||
|
||||
const DECLARED_PLATFORMS = ["win32-x64", "darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"];
|
||||
for (const [name, component] of Object.entries(manifest.components ?? {})) {
|
||||
if (!component.sources) continue;
|
||||
// An `all` source is platform-independent and an npm source has no platform
|
||||
// at all; neither is expected to enumerate every platform.
|
||||
if (component.sources.all || component.kind === "npm" || component.sources.npm) continue;
|
||||
for (const platform of DECLARED_PLATFORMS) {
|
||||
if (!component.sources[platform]) {
|
||||
add("error", `${name}.sources`, `no source for declared platform ${platform}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- 5. apktool wrapper mapping is not crossed */
|
||||
|
||||
const apktool = manifest.components?.apktool;
|
||||
if (apktool?.entries?.byPlatform) {
|
||||
const byPlatform = apktool.entries.byPlatform;
|
||||
const jarDirectory = apktool.entries.all?.[0]?.moveTo;
|
||||
for (const [platform, list] of Object.entries(byPlatform)) {
|
||||
for (const entry of list) {
|
||||
const wantsWindows = platform.startsWith("win32");
|
||||
const wantsOsx = platform.startsWith("darwin");
|
||||
if (wantsWindows && !entry.url.includes("/windows/")) {
|
||||
add("error", `apktool.entries.byPlatform.${platform}`, `expects the windows wrapper but points at ${entry.url}`);
|
||||
}
|
||||
if (wantsOsx && !entry.url.includes("/osx/")) {
|
||||
add("error", `apktool.entries.byPlatform.${platform}`, `macOS must use scripts/osx/apktool, not ${entry.url}`);
|
||||
}
|
||||
if (platform.startsWith("linux") && !entry.url.includes("/linux/")) {
|
||||
add("error", `apktool.entries.byPlatform.${platform}`, `expects the linux wrapper but points at ${entry.url}`);
|
||||
}
|
||||
if (entry.moveTo !== jarDirectory) {
|
||||
add("error", `apktool.entries.byPlatform.${platform}`, `wrapper lands in ${entry.moveTo} but the jar lands in ${jarDirectory}; the wrapper resolves the jar next to itself`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- 6. probe tokens */
|
||||
|
||||
const KNOWN_TOKENS = ["{root}", "{vendor}", "{vendorDir}", "{node}", "{jdk}", "{jvm}", "{javahome}", "{ghidra}", "{exe}"];
|
||||
const probes = [];
|
||||
for (const [name, component] of Object.entries(manifest.components ?? {})) {
|
||||
if (component.probe?.argv) for (const value of component.probe.argv) probes.push({ where: `${name}.probe`, value });
|
||||
if (component.probe?.file) probes.push({ where: `${name}.probe`, value: component.probe.file });
|
||||
}
|
||||
for (const { where, value } of probes) {
|
||||
for (const token of String(value).match(/\{[a-zA-Z]+\}/g) ?? []) {
|
||||
if (!KNOWN_TOKENS.includes(token)) add("error", where, `unknown probe token ${token} in ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- 7. requires integrity */
|
||||
|
||||
const names = new Set(Object.keys(manifest.components ?? {}));
|
||||
for (const [name, component] of Object.entries(manifest.components ?? {})) {
|
||||
for (const requirement of component.requires ?? []) {
|
||||
if (!names.has(requirement)) add("error", `${name}.requires`, `unknown component "${requirement}"`);
|
||||
if (requirement === name) add("error", `${name}.requires`, "depends on itself");
|
||||
}
|
||||
}
|
||||
// Cycle check over `requires`.
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
const walk = (name, trail) => {
|
||||
if (visited.has(name)) return;
|
||||
if (visiting.has(name)) {
|
||||
add("error", `${name}.requires`, `dependency cycle: ${[...trail, name].join(" -> ")}`);
|
||||
return;
|
||||
}
|
||||
visiting.add(name);
|
||||
for (const requirement of manifest.components[name]?.requires ?? []) {
|
||||
if (names.has(requirement)) walk(requirement, [...trail, name]);
|
||||
}
|
||||
visiting.delete(name);
|
||||
visited.add(name);
|
||||
};
|
||||
for (const name of names) walk(name, []);
|
||||
|
||||
/* ------------------------------------- 8. the published patch's JS escapes */
|
||||
|
||||
// This check exists because of a real bug: the row used
|
||||
// process.env.DSH_HOME + '\plugins\rea-bundle\bin\rea-shim.mjs'
|
||||
// YAML single quotes keep the backslashes literal, and JavaScript then eats
|
||||
// them — '\p' is not a path separator, '\r' is a carriage return and '\b' is a
|
||||
// backspace, so the evaluated path was a string full of control characters
|
||||
// that could never start. Nothing in a manifest audit would notice; evaluating
|
||||
// the expression does.
|
||||
const patchPath = path.join(HERE, "..", "cordis.patch.yml");
|
||||
const patchText = readFileSync(patchPath, "utf8");
|
||||
const controlChar = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/;
|
||||
|
||||
// Evaluate every JS string literal in the patch the way JavaScript would, so
|
||||
// the escape handling is the real one and not a re-implementation.
|
||||
const literals = [...patchText.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((match) => match[1]);
|
||||
const evaluated = literals.map((literal) => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function(`return '${literal}'`)();
|
||||
} catch {
|
||||
return literal;
|
||||
}
|
||||
});
|
||||
for (const value of evaluated) {
|
||||
if (controlChar.test(value)) {
|
||||
add(
|
||||
"error",
|
||||
"cordis.patch.yml",
|
||||
`a single-quoted literal evaluates to a string containing control characters (${JSON.stringify(value.slice(0, 60))}) — use forward slashes in JS string literals`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (/\\p|\\r|\\b|\\n|\\t/.test(patchText.replace(/^\s*#.*$/gm, ""))) {
|
||||
add("error", "cordis.patch.yml", "a JS string literal contains a backslash escape (\\p, \\r, \\b, ...); use forward slashes");
|
||||
}
|
||||
if (!/rea-shim\.mjs/.test(patchText)) {
|
||||
add("error", "cordis.patch.yml", "the patch does not reference rea-shim.mjs");
|
||||
}
|
||||
if (!/DSH_HOME/.test(patchText) || !/USERPROFILE|HOME/.test(patchText)) {
|
||||
add("error", "cordis.patch.yml", "no fallback for an unset DSH_HOME was found");
|
||||
}
|
||||
|
||||
/* --------------------------- 9. the row, the shim and setup agree on DSH_HOME */
|
||||
|
||||
// This check exists because of a real bug: the row fell back to the bare home
|
||||
// directory while the shim, setup.mjs and bootstrap.ps1 all fall back to
|
||||
// `<home>/.dsh`. setup therefore mirrored the launcher one level below where
|
||||
// the row looked for it, and the row could never start — but only on a machine
|
||||
// where DSH_HOME happened to be unset, so it stayed invisible. Evaluating the
|
||||
// published expression is the only way to catch it.
|
||||
// A bare `!!js` scalar containing "? " and ": " is not one scalar at all: YAML
|
||||
// reads `? a : b` as an explicit key/value pair, so the loader receives a
|
||||
// mapping instead of an expression node. Reproduced against the harness's own
|
||||
// schema (JSON_SCHEMA + the !!js Type from app.asar): args[0] became
|
||||
// {"[object Object]": "..."} and the row could not start. Quoting the scalar
|
||||
// removes the ambiguity, so this check requires quotes around any expression
|
||||
// that would otherwise be read as a mapping.
|
||||
for (const match of patchText.matchAll(/^[ \t]*-[ \t]*!!js[ \t]+(\S.*)$/gm)) {
|
||||
const scalar = match[1].trim();
|
||||
if (scalar.startsWith('"') || scalar.startsWith("'")) continue;
|
||||
if (/\?\s/.test(scalar) && /:\s/.test(scalar)) {
|
||||
add(
|
||||
"error",
|
||||
"cordis.patch.yml",
|
||||
`a bare !!js scalar contains "? " and ": " (${scalar.slice(0, 48)}...), which YAML reads as a mapping: quote the scalar so the loader receives an expression`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull the shim-path expression out of the row — quoted (required for a ternary)
|
||||
// or bare. The trailing YAML quote follows the expression's own closing quote,
|
||||
// so the match takes the rest of the line and strips the outer quoting.
|
||||
const rowMatch = patchText.match(/^[ \t]*-[ \t]*!!js[ \t]+(.+rea-shim\.mjs.*)$/m);
|
||||
let jsExpression = rowMatch ? rowMatch[1].trim() : null;
|
||||
if (jsExpression) {
|
||||
const first = jsExpression[0];
|
||||
const last = jsExpression[jsExpression.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
jsExpression = jsExpression.slice(1, -1);
|
||||
}
|
||||
}
|
||||
const shimSuffix = "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs";
|
||||
if (!jsExpression) {
|
||||
add("error", "cordis.patch.yml", "the !!js expression that computes the shim path was not found");
|
||||
} else {
|
||||
const evaluate = (env) => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
return new Function("process", `return ${jsExpression}`)({ env });
|
||||
};
|
||||
for (const [label, env] of [
|
||||
["DSH_HOME unset", { USERPROFILE: "C:\\Users\\probe", HOME: "/home/probe" }],
|
||||
["DSH_HOME empty", { DSH_HOME: " ", USERPROFILE: "C:\\Users\\probe", HOME: "/home/probe" }],
|
||||
["DSH_HOME unset, POSIX only", { HOME: "/home/probe" }],
|
||||
]) {
|
||||
let value;
|
||||
try {
|
||||
value = evaluate(env);
|
||||
} catch (error) {
|
||||
add("error", "cordis.patch.yml", `the !!js expression throws with ${label}: ${error && error.message}`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string" || !value.endsWith(shimSuffix)) {
|
||||
add(
|
||||
"error",
|
||||
"cordis.patch.yml",
|
||||
`with ${label} the row resolves to ${JSON.stringify(value)}; it must end in ${shimSuffix} — the shim, setup.mjs and bootstrap.ps1 all fall back to <home>/.dsh, so the row would look one level too high for a file provisioning writes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
let withHome;
|
||||
try {
|
||||
withHome = evaluate({ DSH_HOME: "/custom/home" });
|
||||
} catch (error) {
|
||||
withHome = `threw: ${error && error.message}`;
|
||||
}
|
||||
if (withHome !== "/custom/home/plugins/rea-bundle/bin/rea-shim.mjs") {
|
||||
add("error", "cordis.patch.yml", `with DSH_HOME set the row resolves to ${JSON.stringify(withHome)}, expected /custom/home/plugins/rea-bundle/bin/rea-shim.mjs`);
|
||||
}
|
||||
|
||||
// A machine with no DSH_HOME, USERPROFILE and HOME resolves the row to the
|
||||
// filesystem root. That is a known, harmless divergence rather than a bug: the
|
||||
// row reports MODULE_NOT_FOUND, writes and downloads nothing, and the harness
|
||||
// carries on (`failOnStartupError: false`). It cannot be reconciled from inside
|
||||
// a `!!js` expression either — setup.mjs resolves such a machine through
|
||||
// `os.homedir()`, which falls back to the passwd entry on POSIX. Pinned as a
|
||||
// warning so the behaviour stays known instead of being rediscovered as a bug.
|
||||
let rootless;
|
||||
try {
|
||||
rootless = evaluate({});
|
||||
} catch (error) {
|
||||
rootless = `threw: ${error && error.message}`;
|
||||
}
|
||||
if (rootless !== "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs") {
|
||||
add(
|
||||
"warn",
|
||||
"cordis.patch.yml",
|
||||
`with DSH_HOME, USERPROFILE and HOME all absent the row resolves to ${JSON.stringify(rootless)}; the documented behaviour is "/.dsh/plugins/rea-bundle/bin/rea-shim.mjs"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Both READMEs print this row so a reader can copy it, and a documented row
|
||||
// that has drifted from the shipped one is what people actually install.
|
||||
for (const doc of ["README.md", "README.zh-CN.md"]) {
|
||||
const text = readFileSync(path.join(HERE, "..", doc), "utf8");
|
||||
if (!text.includes(jsExpression)) {
|
||||
add("error", doc, "the documented !!js expression differs from cordis.patch.yml, so copied instructions would install the wrong row");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------- 9b. the two READMEs stay parallel */
|
||||
|
||||
// They are maintained by hand, and an edit that lands in only one of them leaves
|
||||
// half the readers with stale instructions — the patch expression above is the
|
||||
// one piece already compared, but sections drift the same way. The prose differs
|
||||
// by language, so the check compares the structure that must stay parallel.
|
||||
const headingCounts = (doc) => {
|
||||
const text = readFileSync(path.join(HERE, "..", doc), "utf8");
|
||||
const count = (marker) => (text.match(new RegExp(`^${marker} `, "gm")) ?? []).length;
|
||||
return { sections: count("##"), subsections: count("###") };
|
||||
};
|
||||
const englishHeadings = headingCounts("README.md");
|
||||
const chineseHeadings = headingCounts("README.zh-CN.md");
|
||||
if (englishHeadings.sections !== chineseHeadings.sections || englishHeadings.subsections !== chineseHeadings.subsections) {
|
||||
add(
|
||||
"error",
|
||||
"README.md",
|
||||
`the two READMEs drifted apart in structure: README.md has ${englishHeadings.sections} sections / ${englishHeadings.subsections} subsections, README.zh-CN.md has ${chineseHeadings.sections} / ${chineseHeadings.subsections}`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------- 10. the mirror list is complete */
|
||||
|
||||
// Another real bug: mirrorLauncher() copied the shim and setup.mjs but not the
|
||||
// manifest. The mirrored setup.mjs derives its plugin root from its own
|
||||
// location, so loadManifest() then read <bundle>/manifest/bundle.manifest.json
|
||||
// — a file nothing created — and every later run died with a bare ENOENT that
|
||||
// the detached auto-provision swallowed.
|
||||
const setupText = readFileSync(path.join(HERE, "..", "scripts", "setup.mjs"), "utf8");
|
||||
const mirrorBody = setupText.match(/function mirrorLauncher\(context\) \{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
if (!mirrorBody) {
|
||||
add("error", "scripts/setup.mjs", "mirrorLauncher() was not found, so the mirror list can no longer be verified");
|
||||
} else {
|
||||
// Only the array literal counts. Scanning the whole function body would also
|
||||
// match the explanatory comment above it, which is exactly how this check
|
||||
// first let a sabotaged mirror list pass.
|
||||
const sourcesBody = mirrorBody.match(/const sources = \[([\s\S]*?)\];/)?.[1];
|
||||
if (sourcesBody === undefined) {
|
||||
add("error", "scripts/setup.mjs", "the mirror list (const sources = [...]) was not found");
|
||||
} else {
|
||||
for (const needed of ["bin/rea-shim.mjs", "scripts/setup.mjs", "manifest/bundle.manifest.json"]) {
|
||||
if (!sourcesBody.includes(`"${needed}"`)) {
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- 11. setup and the manifest describe the same set */
|
||||
|
||||
// A component added to the manifest but not to setup.mjs's INSTALL_ORDER was
|
||||
// silently never provisioned, and nothing here said so. The reverse — a name in
|
||||
// INSTALL_ORDER with no manifest entry — is skipped by the loop's
|
||||
// `if (!component) continue`, so it is equally silent.
|
||||
const installOrderMatch = setupText.match(/const INSTALL_ORDER = \[([^\]]*)\]/);
|
||||
if (!installOrderMatch) {
|
||||
add("error", "scripts/setup.mjs", "INSTALL_ORDER was not found, so manifest/setup agreement cannot be checked");
|
||||
} else {
|
||||
const order = [...installOrderMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
||||
const manifestNames = Object.keys(manifest.components ?? {});
|
||||
for (const name of manifestNames) {
|
||||
if (!order.includes(name)) {
|
||||
add("error", "manifest.components", `"${name}" is not in scripts/setup.mjs INSTALL_ORDER, so setup would silently never provision it`);
|
||||
}
|
||||
}
|
||||
for (const name of order) {
|
||||
if (!manifestNames.includes(name)) {
|
||||
add("error", "scripts/setup.mjs", `INSTALL_ORDER names "${name}", which the manifest does not describe`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------- 12. the Node engines rule has one wording too */
|
||||
|
||||
// REA's engines are restated in the manifest pin, package.json and the node
|
||||
// probe regex. Only the pin and package.json can be compared as text, but a
|
||||
// drift between those two would advertise one range and enforce another.
|
||||
const declaredEngines = manifest.pins?.nodeEngines;
|
||||
let packageEngines = null;
|
||||
try {
|
||||
packageEngines = JSON.parse(readFileSync(path.join(HERE, "..", "package.json"), "utf8")).engines?.node ?? null;
|
||||
} catch (error) {
|
||||
add("error", "package.json", `could not be read: ${String(error && error.message)}`);
|
||||
}
|
||||
if (!declaredEngines) {
|
||||
add("error", "manifest.pins.nodeEngines", "is missing, so nothing states REA's engines for the reuse path");
|
||||
} else if (declaredEngines !== packageEngines) {
|
||||
add(
|
||||
"error",
|
||||
"manifest.pins.nodeEngines",
|
||||
`is ${JSON.stringify(declaredEngines)} but package.json engines.node is ${JSON.stringify(packageEngines)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------- 13. a pinned version's probe must not accept a prefix */
|
||||
|
||||
// The concern is a probe that accepts a *newer* release as the pin, so test the
|
||||
// expression against the versions that would mean that. The audit is not a
|
||||
// regex linter: `^6\.4\.0` legitimately rejects 6.4.1 and 6.5.0 — the failure
|
||||
// mode is only real when the next patch or minor still matches.
|
||||
const reaAgents = manifest.components?.reaAgents;
|
||||
const pinnedRea = reaAgents?.sources?.npm?.version;
|
||||
const reaProbe = reaAgents?.probe?.expect?.regex;
|
||||
if (pinnedRea && reaProbe) {
|
||||
let probe = null;
|
||||
try {
|
||||
probe = new RegExp(reaProbe, "m");
|
||||
} catch (error) {
|
||||
add("error", "reaAgents.probe.expect.regex", `is not a valid regular expression: ${String(error && error.message)}`);
|
||||
}
|
||||
if (probe) {
|
||||
if (!probe.test(pinnedRea)) {
|
||||
add("error", "reaAgents.probe.expect.regex", `does not match the pinned version ${pinnedRea}`);
|
||||
}
|
||||
const [major, minor, patch] = pinnedRea.split(".").map((part) => Number(part));
|
||||
for (const [label, next] of [
|
||||
["the next patch", `${major}.${minor}.${patch + 1}`],
|
||||
["the next minor", `${major}.${minor + 1}.0`],
|
||||
]) {
|
||||
if (Number.isFinite(next.split(".")[2]) && probe.test(next)) {
|
||||
add("error", "reaAgents.probe.expect.regex", `accepts ${next} (${label}), so a newer release passes as the pin`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------- 14. a POSIX wrapper must be marked executable */
|
||||
|
||||
// copyFileSync carries the archive's 0644 and nothing else changes a mode, while
|
||||
// REA spawns REA_APKTOOL_COMMAND without a shell: on Linux/macOS an unmarked
|
||||
// wrapper fails with EACCES even though the component probe (`java -jar`) passes.
|
||||
// Only the manifest can say which files need the bit, so require it here.
|
||||
const apktoolWrappers = manifest.components?.apktool?.entries?.byPlatform ?? {};
|
||||
for (const [platform, list] of Object.entries(apktoolWrappers)) {
|
||||
if (platform.startsWith("win32")) continue;
|
||||
for (const entry of list) {
|
||||
if (entry.executable !== true) {
|
||||
add(
|
||||
"error",
|
||||
`apktool.entries.byPlatform.${platform}`,
|
||||
`the ${platform} wrapper is not marked "executable": true, so it lands 0644 and cannot be spawned without a shell`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- summary */
|
||||
|
||||
const pinned = fetchEntries.filter((entry) => entry.entry.sha256 && entry.entry.sha256 !== "unverified").length;
|
||||
const tofu = fetchEntries.length - pinned;
|
||||
console.log(`manifest audit: ${fetchEntries.length} fetch entries — ${pinned} hash-pinned, ${tofu} TOFU`);
|
||||
const errors = findings.filter((finding) => finding.severity === "error");
|
||||
const warnings = findings.filter((finding) => finding.severity === "warn");
|
||||
for (const finding of findings) {
|
||||
console.log(` ${finding.severity === "error" ? "ERROR" : "warn "} ${finding.where}: ${finding.message}`);
|
||||
}
|
||||
console.log(errors.length === 0 ? `clean${warnings.length ? ` (${warnings.length} warning(s))` : ""}` : `${errors.length} error(s), ${warnings.length} warning(s)`);
|
||||
process.exit(errors.length === 0 ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* host-compat — does the range this package declares cover the DSH releases it
|
||||
* claims to support?
|
||||
*
|
||||
* `package.json` carries `engines.dsh`, and that single string is what the
|
||||
* plugin market reads to say "compatible" instead of "unknown" (the market's
|
||||
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
|
||||
* checks it against the running host version). A declaration is therefore load
|
||||
* bearing twice over: it decides whether the market HIDES this plugin from users
|
||||
* on a matching host, and it decides whether users on a host we never tested are
|
||||
* told it works.
|
||||
*
|
||||
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
|
||||
* the market then reports `unknown` — a plugin that looks undeclared rather than
|
||||
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
|
||||
* README still promises. Neither shows up as a test failure unless something
|
||||
* compares the STRING to the release list, which is what this file does.
|
||||
*
|
||||
* `HOST_RELEASES` is the dsh release line this package is built against, newest
|
||||
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
|
||||
* literal list rather than a GitHub query on purpose: a check that needs the
|
||||
* network fails on a plane and passes on a total outage, which is the wrong
|
||||
* shape for a gate. When a release is added upstream, add it here too — that
|
||||
* edit is the reminder to re-check the row against the new host.
|
||||
*
|
||||
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
|
||||
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
|
||||
* in this ecosystem pass that option — dshmarket's discovery
|
||||
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
|
||||
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
|
||||
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
|
||||
* prerelease must be named by a comparator on its own tuple), would disagree
|
||||
* with the verdict users actually see. The tests pin the option's effect.
|
||||
*
|
||||
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
|
||||
* covered by the declared range; exit 1 = at least one is not).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const REPO_ROOT = path.join(HERE, '..')
|
||||
|
||||
/**
|
||||
* Every dsh release this bundle is checked against, newest first.
|
||||
*
|
||||
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
|
||||
* floor is the oldest release the plugin has been adapted to; everything above
|
||||
* it up to the declared ceiling must keep working, so a release that is missing
|
||||
* here is a release nobody checked.
|
||||
*/
|
||||
export const HOST_RELEASES = [
|
||||
'0.2.1-rc.2',
|
||||
'0.2.1-rc.1',
|
||||
'0.2.1-alpha.5',
|
||||
'0.2.1-alpha.4',
|
||||
'0.2.1-alpha.3',
|
||||
'0.2.1-alpha.2',
|
||||
'0.2.1-alpha.1',
|
||||
'0.2.0-rc.2',
|
||||
'0.2.0-rc.1',
|
||||
'0.1.7-rc.2',
|
||||
]
|
||||
|
||||
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
|
||||
export const OUTSIDE_HOSTS = [
|
||||
'0.3.0',
|
||||
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
|
||||
// through: with `includePrerelease` they sort below `0.3.0` itself. The
|
||||
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
|
||||
// and these entries are what proves it does what it says.
|
||||
'0.3.0-rc.1',
|
||||
'0.3.0-alpha.1',
|
||||
'0.1.7-rc.1',
|
||||
'0.1.7-alpha.2',
|
||||
]
|
||||
|
||||
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
||||
|
||||
/**
|
||||
* Split a version into comparable parts.
|
||||
*
|
||||
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
|
||||
* and as node-semver does for comparison: a host that reports
|
||||
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
|
||||
* strict — a leading `v` or a zero-padded identifier is rejected rather than
|
||||
* guessed at, because this gate exists to catch a declaration nobody checked and
|
||||
* a guess would put the guess in charge.
|
||||
* @param {string} version - a plain semver string, no range operators.
|
||||
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
|
||||
*/
|
||||
export function parseVersion(version) {
|
||||
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
|
||||
const match = SEMVER.exec(cleaned)
|
||||
if (match === null) return null
|
||||
const raw = match[4] === undefined ? [] : match[4].split('.')
|
||||
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
|
||||
// accepting one would make this gate read a typo as a version.
|
||||
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
|
||||
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
|
||||
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions the way semver does, prerelease included.
|
||||
*
|
||||
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
|
||||
* prereleases an identifier that is numeric sorts below one that is not, which
|
||||
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
|
||||
* on a numeric comparison.
|
||||
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
|
||||
*/
|
||||
export function compareVersions(a, b) {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
if (left === null) throw new Error(`not a version: ${a}`)
|
||||
if (right === null) throw new Error(`not a version: ${b}`)
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
|
||||
}
|
||||
if (left.pre.length === 0 && right.pre.length === 0) return 0
|
||||
if (left.pre.length === 0) return 1
|
||||
if (right.pre.length === 0) return -1
|
||||
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
||||
const l = left.pre[index]
|
||||
const r = right.pre[index]
|
||||
if (l === undefined) return -1
|
||||
if (r === undefined) return 1
|
||||
if (l === r) continue
|
||||
const lNum = typeof l === 'number'
|
||||
const rNum = typeof r === 'number'
|
||||
if (lNum && rNum) return l < r ? -1 : 1
|
||||
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
||||
if (lNum !== rNum) return lNum ? -1 : 1
|
||||
return String(l) < String(r) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
|
||||
* ships, and the shape the market's `engines.dsh` is compared with).
|
||||
*
|
||||
* Deliberately narrow: a wider parser here could disagree with the market's
|
||||
* semver implementation on a range the manifest would then be trusted for. If a
|
||||
* future declaration needs `||` or `^`, this function has to grow with it.
|
||||
* @param {string} version - host version to test.
|
||||
* @param {string} range - the declared range.
|
||||
* @returns {boolean} whether the host is inside the declaration.
|
||||
*/
|
||||
export function satisfiesDeclaredRange(version, range) {
|
||||
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
|
||||
if (match === null) throw new Error(`unsupported host range: ${range}`)
|
||||
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The host range this repository declares, `engines.dsh`.
|
||||
* @param {string} [root] - repository root, for tests.
|
||||
* @returns {string | null} the declared range, or null when absent.
|
||||
*/
|
||||
export function declaredHostRange(root = REPO_ROOT) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
|
||||
const value = pkg?.engines?.dsh
|
||||
return typeof value === 'string' ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every release covered by the declaration, and every version that must not be.
|
||||
* @param {string} range - declared host range.
|
||||
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
|
||||
*/
|
||||
export function checkHostCoverage(range) {
|
||||
const covered = []
|
||||
const uncovered = []
|
||||
for (const version of HOST_RELEASES) {
|
||||
if (satisfiesDeclaredRange(version, range)) covered.push(version)
|
||||
else uncovered.push(version)
|
||||
}
|
||||
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
|
||||
return { covered, uncovered, leaked }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const range = declaredHostRange()
|
||||
if (range === null) {
|
||||
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
|
||||
process.exit(1)
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = checkHostCoverage(range)
|
||||
} catch (error) {
|
||||
console.error(`FAIL: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`engines.dsh = ${range}`)
|
||||
for (const version of HOST_RELEASES) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
|
||||
}
|
||||
for (const version of OUTSIDE_HOSTS) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
|
||||
}
|
||||
if (result.uncovered.length > 0) {
|
||||
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (result.leaked.length > 0) {
|
||||
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|
||||
@@ -0,0 +1,224 @@
|
||||
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
|
||||
#
|
||||
# Why this exists: setup.mjs is written in Node, so a machine with no Node
|
||||
# cannot run it. This script uses only PowerShell + Expand-Archive (no
|
||||
# downloads other than the official Node.js zip and then the components), gets a
|
||||
# portable Node, and hands the rest to scripts/setup.mjs.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1
|
||||
#
|
||||
# Options:
|
||||
# -Root <dir> bundle root (default: $env:DSH_HOME\plugins\rea-bundle,
|
||||
# falling back to ~\.dsh\plugins\rea-bundle)
|
||||
# -Reuse adopt an existing Ghidra/JDK instead of downloading them
|
||||
# -DryRun print the plan and stop
|
||||
#
|
||||
# Nothing is installed system-wide and no registry key is touched: the runtime
|
||||
# is unpacked next to the bundle and used from there.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Root,
|
||||
[switch]$Reuse,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
|
||||
|
||||
# --- where things go -------------------------------------------------------
|
||||
|
||||
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
|
||||
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
|
||||
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
|
||||
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
|
||||
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
|
||||
if (-not $Root -or $Root.Trim() -eq '') {
|
||||
$home2 = $env:DSH_HOME
|
||||
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
|
||||
$Root = Join-Path $home2 'plugins\rea-bundle'
|
||||
}
|
||||
$Root = [System.IO.Path]::GetFullPath($Root)
|
||||
$stack = Join-Path $Root 'stack'
|
||||
$cache = Join-Path $Root 'cache'
|
||||
$logs = Join-Path $Root 'logs'
|
||||
# -DryRun promises that nothing was changed, so it must not create these either:
|
||||
# it only reads them, and Test-Path / Get-ChildItem handle a missing path.
|
||||
if (-not $DryRun) {
|
||||
foreach ($dir in @($Root, $stack, $cache, $logs)) {
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
$pluginRoot = Split-Path -Parent $PSScriptRoot
|
||||
$setupScript = Join-Path $pluginRoot 'scripts\setup.mjs'
|
||||
if (-not (Test-Path $setupScript)) { throw "setup.mjs not found next to this script: $setupScript" }
|
||||
|
||||
Write-Step "bundle root : $Root"
|
||||
Write-Step "plugin root : $pluginRoot"
|
||||
Write-Step "platform : $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)"
|
||||
Write-Step "architecture: $env:PROCESSOR_ARCHITECTURE"
|
||||
|
||||
if ($env:PROCESSOR_ARCHITECTURE -notmatch '64') {
|
||||
throw "This bootstrap supports 64-bit Windows (PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE)."
|
||||
}
|
||||
|
||||
# --- Node: reuse if compatible, else fetch the portable zip -----------------
|
||||
#
|
||||
# The version and checksum come from the manifest, which is the single source of
|
||||
# truth the rest of provisioning already reads. Hard-coding a second copy here
|
||||
# meant two numbers that could drift apart silently.
|
||||
|
||||
$manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
|
||||
if (-not (Test-Path $manifestPath)) { throw "manifest not found: $manifestPath" }
|
||||
$manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
$hostKey = 'win32-' + $(if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' })
|
||||
$nodeSource = $manifest.components.node.sources.$hostKey
|
||||
if (-not $nodeSource) { throw "the manifest has no Node source for $hostKey" }
|
||||
$nodeVersion = $nodeSource.version
|
||||
$nodeSha256 = $nodeSource.sha256
|
||||
$nodeUrl = $nodeSource.url
|
||||
$nodeZipName = [System.IO.Path]::GetFileName(([uri]$nodeUrl).AbsolutePath)
|
||||
# The official archive unpacks to exactly one top-level directory named after the
|
||||
# zip: node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64. The extension is
|
||||
# therefore stripped EXACTLY ONCE. Stripping twice yielded 'node-v24.21', so the
|
||||
# checks below looked for <stack>\node-v24.21\node.exe and the run threw
|
||||
# "the Node zip did not contain node-v24.21" *after* a successful download,
|
||||
# checksum and extraction — on every machine that had no Node, which is the only
|
||||
# audience this script has. Invisible wherever a Node is already on PATH.
|
||||
$nodeFolder = [System.IO.Path]::GetFileNameWithoutExtension($nodeZipName)
|
||||
if ($nodeSha256 -eq 'unverified') { throw "the manifest pins no checksum for $hostKey; refusing to download unverified bytes" }
|
||||
Write-Step "Node source: $hostKey v$nodeVersion"
|
||||
|
||||
$script:NodeEngines = [string]$manifest.pins.nodeEngines
|
||||
if (-not $script:NodeEngines) { throw "the manifest pins no nodeEngines, so Node compatibility cannot be judged" }
|
||||
|
||||
# REA's engines come from the manifest pin, which the rest of provisioning already
|
||||
# treats as the single source of truth. This used to be a second copy of the same
|
||||
# numbers in a comment, and two copies drift: the audit now asserts that the pin
|
||||
# keeps the shape this parser understands.
|
||||
function Test-EnginesMatch([string]$Clean, [string]$Range) {
|
||||
$versions = @()
|
||||
foreach ($part in $Clean.Split('.')) {
|
||||
$n = 0
|
||||
if (-not [int]::TryParse($part, [ref]$n)) { return $false }
|
||||
$versions += $n
|
||||
}
|
||||
if ($versions.Count -lt 2) { return $false }
|
||||
foreach ($clause in ($Range -split '\|\|')) {
|
||||
$c = $clause.Trim()
|
||||
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
|
||||
if ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2]) { return $true }
|
||||
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
|
||||
if ($versions[0] -gt [int]$Matches[1] -or ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2])) { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-NodeUsable([string]$Exe) {
|
||||
if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
|
||||
try {
|
||||
$version = (& $Exe -v 2>$null)
|
||||
} catch { return $null }
|
||||
if (-not $version) { return $null }
|
||||
$clean = $version.TrimStart('v').Trim()
|
||||
# A version that is not three numbers (`vX.Y`) used to reach `[int]` and throw
|
||||
# under $ErrorActionPreference='Stop', aborting the whole bootstrap instead of
|
||||
# falling through to the portable download — the one path this script exists for.
|
||||
if (-not (Test-EnginesMatch $clean $script:NodeEngines)) { return $null }
|
||||
return [pscustomobject]@{ Path = (Resolve-Path $Exe).Path; Version = $clean }
|
||||
}
|
||||
|
||||
# The directory the zip actually created. The derived name is preferred, but any
|
||||
# directory carrying node.exe is accepted: a naming drift must not strand a machine
|
||||
# that has already downloaded and checksum-verified the bytes.
|
||||
function Get-PortableNodeDir {
|
||||
$exact = Join-Path $stack $nodeFolder
|
||||
if (Test-Path (Join-Path $exact 'node.exe')) { return $exact }
|
||||
$found = Get-ChildItem -Path $stack -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { Test-Path (Join-Path $_.FullName 'node.exe') } |
|
||||
Select-Object -First 1
|
||||
if ($found) { return $found.FullName }
|
||||
return $exact
|
||||
}
|
||||
|
||||
function Get-PortableNode([switch]$NoDownload) {
|
||||
$exe = Join-Path (Get-PortableNodeDir) 'node.exe'
|
||||
$found = Test-NodeUsable $exe
|
||||
if ($found) { Write-Step "portable Node present: $($found.Path) (v$($found.Version))"; return $found }
|
||||
# -DryRun still *looks* for the portable copy (that is the only way the plan can
|
||||
# tell the truth about whether a download is needed) but never fetches anything.
|
||||
if ($NoDownload) { return $null }
|
||||
|
||||
$zip = Join-Path $cache $nodeZipName
|
||||
if (Test-Path $zip) {
|
||||
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
if ($hash -ne $nodeSha256) { Write-Step "cached Node zip failed its checksum; re-downloading"; Remove-Item $zip -Force }
|
||||
}
|
||||
if (-not (Test-Path $zip)) {
|
||||
Write-Step "downloading $nodeZipName (~35 MiB)"
|
||||
$partial = "$zip.part"
|
||||
if (Test-Path $partial) { Remove-Item $partial -Force }
|
||||
# Invoke-WebRequest buffers the whole body before writing, so a slow mirror
|
||||
# looks frozen; say so plainly rather than appearing stuck.
|
||||
Invoke-WebRequest -Uri $nodeUrl -OutFile $partial -UseBasicParsing -TimeoutSec 1800
|
||||
Move-Item -LiteralPath $partial -Destination $zip -Force
|
||||
}
|
||||
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $nodeSha256) { throw "Node.js zip checksum mismatch.`n expected $nodeSha256`n actual $actual" }
|
||||
Write-Step "checksum ok: $actual"
|
||||
|
||||
Write-Step "extracting into $stack"
|
||||
Expand-Archive -LiteralPath $zip -DestinationPath $stack -Force
|
||||
$unpacked = Get-PortableNodeDir
|
||||
if (-not (Test-Path (Join-Path $unpacked 'node.exe'))) { throw "the Node zip left no node.exe under $stack (expected $nodeFolder)" }
|
||||
$found = Test-NodeUsable (Join-Path $unpacked 'node.exe')
|
||||
if (-not $found) { throw "the extracted node.exe could not be executed" }
|
||||
Write-Step "portable Node ready: $($found.Path) (v$($found.Version))"
|
||||
return $found
|
||||
}
|
||||
|
||||
$node = $null
|
||||
$candidates = @()
|
||||
if ($env:REA_BUNDLE_NODE) { $candidates += $env:REA_BUNDLE_NODE }
|
||||
$onPath = (Get-Command node -ErrorAction SilentlyContinue)
|
||||
if ($onPath) { $candidates += $onPath.Source }
|
||||
foreach ($candidate in $candidates) {
|
||||
$node = Test-NodeUsable $candidate
|
||||
if ($node) { Write-Step "using existing Node: $($node.Path) (v$($node.Version))"; break }
|
||||
}
|
||||
|
||||
# A portable Node already unpacked next to the bundle counts as an existing
|
||||
# install, and asking about it is what makes the -DryRun plan tell the truth.
|
||||
if (-not $node) { $node = Get-PortableNode -NoDownload:$DryRun }
|
||||
|
||||
if ($DryRun) {
|
||||
if ($node) { Write-Step "plan: use Node $($node.Path) (v$($node.Version))" }
|
||||
else { Write-Step "plan: download and extract $nodeZipName into $stack" }
|
||||
Write-Step "plan: node `"$setupScript`" --root `"$Root`"$(if ($Reuse) { ' --reuse' })"
|
||||
Write-Step "dry run complete; nothing was changed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $node) { $node = Get-PortableNode }
|
||||
|
||||
# --- hand over to setup.mjs ------------------------------------------------
|
||||
|
||||
$setupArgs = @($setupScript, '--root', $Root)
|
||||
if ($Reuse) { $setupArgs += '--reuse' }
|
||||
|
||||
Write-Step "running setup: node $($setupArgs -join ' ')"
|
||||
& $node.Path @setupArgs
|
||||
$code = $LASTEXITCODE
|
||||
|
||||
if ($code -eq 0) {
|
||||
Write-Step "done. Restart DeepSeek Harness so the mcp-rea row reconnects."
|
||||
} else {
|
||||
Write-Step "setup reported unmet components (exit $code). Re-run after fixing the cause."
|
||||
Write-Step "log: $(Join-Path $logs 'setup.log')"
|
||||
}
|
||||
exit $code
|
||||
+1762
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
||||
# verify-branch.ps1 — one-shot structural verification of the bundle branch.
|
||||
# Read-only: parses, audits and unit-tests; it does not provision anything.
|
||||
# Run from the branch root: powershell -NoProfile -File scripts\verify-branch.ps1
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
$fail = 0
|
||||
function Check($name, [bool]$ok, $detail) {
|
||||
if ($ok) { Write-Host (" PASS " + $name) -ForegroundColor Green }
|
||||
else { Write-Host (" FAIL " + $name + " " + $detail) -ForegroundColor Red; $script:fail++ }
|
||||
}
|
||||
|
||||
Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
|
||||
foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs')) {
|
||||
$out = & node --check $f 2>&1
|
||||
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
|
||||
}
|
||||
|
||||
Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
|
||||
# bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
|
||||
# A BOM strip on one side only makes one file mean "there is a checkout" to the
|
||||
# shim and "there is none" to setup, and the mirrored setup then re-mirrors the
|
||||
# previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
|
||||
$jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
|
||||
@'
|
||||
const fs = require("node:fs");
|
||||
const blind = [];
|
||||
for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
|
||||
// readJson() itself is the strip; anything else must not parse a file raw.
|
||||
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
|
||||
blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
|
||||
}
|
||||
}
|
||||
if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
|
||||
}
|
||||
console.log("bom-blind reads: " + JSON.stringify(blind));
|
||||
if (blind.length) process.exit(1);
|
||||
'@ | Set-Content $jsBom -Encoding utf8
|
||||
& node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
|
||||
|
||||
Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
|
||||
# This script checks itself too: it is edited as often as the others, and an editor
|
||||
# that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
|
||||
foreach ($f in @('check-updates.ps1','assets\make-screenshots.ps1','scripts\rea-bundle-bootstrap.ps1','scripts\verify-branch.ps1')) {
|
||||
$p = Join-Path $root $f
|
||||
$bytes = [System.IO.File]::ReadAllBytes($p)
|
||||
$bom = ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($p, [ref]$null, [ref]$errs) | Out-Null
|
||||
$count = @($errs).Count
|
||||
Check "$f parses" ($count -eq 0) "$count error(s): $(($errs | Select-Object -First 1).Message)"
|
||||
if ($f -ne 'scripts\rea-bundle-bootstrap.ps1') {
|
||||
# These two carry CJK text, which PowerShell 5.1 misreads without a BOM.
|
||||
Check "$f has UTF-8 BOM" $bom "first bytes: $(($bytes[0..2] | ForEach-Object { $_.ToString('x2') }) -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n=== 3. manifest audit ===" -ForegroundColor Cyan
|
||||
$audit = & node scripts\audit-manifest.mjs 2>&1
|
||||
$audit | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
Check 'audit clean' ($LASTEXITCODE -eq 0) 'see findings above'
|
||||
|
||||
Write-Host "`n=== 4. unit tests ===" -ForegroundColor Cyan
|
||||
$tests = & node --test scripts\setup.test.mjs 2>&1
|
||||
$testExit = $LASTEXITCODE
|
||||
$failing = @($tests | Select-String -Pattern 'not ok' ).Count
|
||||
Check 'node --test' ($testExit -eq 0) "$failing failing"
|
||||
|
||||
Write-Host "`n=== 5. the published patch is machine-path-free and escape-safe ===" -ForegroundColor Cyan
|
||||
$patch = Get-Content (Join-Path $root 'cordis.patch.yml') -Raw
|
||||
Check 'no drive letter in patch' (-not ($patch -match '[A-Za-z]:[\\/]')) 'a machine path leaked into the published patch'
|
||||
Check 'no backslash escape in JS literals' (-not ($patch -replace '(?m)^\s*#.*$','' -match '\\[prbnt]')) 'use forward slashes'
|
||||
Check 'references rea-shim.mjs' ($patch -match 'rea-shim\.mjs') ''
|
||||
Check 'has a DSH_HOME fallback' (($patch -match 'DSH_HOME') -and ($patch -match 'USERPROFILE|HOME')) ''
|
||||
$jsProbe = Join-Path $env:TEMP 'rea-verify-literals.cjs'
|
||||
@'
|
||||
const fs = require("node:fs");
|
||||
const text = fs.readFileSync("cordis.patch.yml", "utf8").replace(/^\s*#.*$/gm, "");
|
||||
const control = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/;
|
||||
const literals = [...text.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((m) => {
|
||||
try { return new Function("return '" + m[1] + "'")(); } catch { return m[1]; }
|
||||
});
|
||||
const bad = literals.filter((v) => control.test(v));
|
||||
console.log("literals: " + JSON.stringify(literals));
|
||||
if (bad.length) {
|
||||
console.error("control characters in: " + bad.map((v) => JSON.stringify(v)).join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
'@ | Set-Content $jsProbe -Encoding utf8
|
||||
& node $jsProbe 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
Check 'evaluated literals contain no control characters' ($LASTEXITCODE -eq 0) 'see above'
|
||||
|
||||
Write-Host "`n=== 6. package payload lists what the bundle needs ===" -ForegroundColor Cyan
|
||||
# -Encoding UTF8 is required: the package description contains CJK text, and
|
||||
# PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
|
||||
$pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
||||
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
|
||||
}
|
||||
# The market's update path asks whether package.json's main/exports points at a
|
||||
# file that exists, and rolls the update back when it does not (dshmarket's
|
||||
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
|
||||
# a healthy profile failed with "updated build has no loadable entry". Asserting
|
||||
# the pointer resolves keeps that from returning silently.
|
||||
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
|
||||
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
|
||||
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
|
||||
# plugin from hosts whose version does not match. An absent or unparseable
|
||||
# declaration therefore reads as "nothing to say" rather than as a defect, which
|
||||
# is why the coverage check runs here as well as in CI.
|
||||
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
|
||||
$compat = & node scripts\host-compat.mjs 2>&1
|
||||
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
|
||||
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
|
||||
# host-compat and the audit all agree while the sentence a human reads names a
|
||||
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
|
||||
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
|
||||
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
|
||||
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
|
||||
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
|
||||
# The pin example is copy-pasteable, so it must name a ref that will exist: the
|
||||
# version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
|
||||
# then v2.0.8 while the tree was 2.0.9), and both times every other signal was
|
||||
# green because nothing compared the two.
|
||||
Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
|
||||
}
|
||||
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
|
||||
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
|
||||
}
|
||||
|
||||
Write-Host "`n=== 7. dry-run still plans the whole chain ===" -ForegroundColor Cyan
|
||||
$env:REA_BUNDLE_ROOT = Join-Path $env:TEMP 'rea-verify-dryrun'
|
||||
# Deliberately WITHOUT --reuse. Adoption of whatever this machine happens to have
|
||||
# installed used to hide a defect that only a bare machine sees: every planned
|
||||
# component returned `{ planned: true }` with no path, so the dependents of each
|
||||
# plan entry were reported as "required component is unavailable" and the command
|
||||
# exited 1 — for the exact reader the README sends to `--dry-run`.
|
||||
$dry = & node scripts\setup.mjs --root $env:REA_BUNDLE_ROOT --dry-run 2>&1
|
||||
$planned = @($dry | Select-String -Pattern '^\[plan\]').Count
|
||||
$unmet = @($dry | Select-String -Pattern 'is unavailable').Count
|
||||
Check 'dry-run exits 0 without --reuse' ($LASTEXITCODE -eq 0) (($dry | Select-Object -Last 3) -join ' ')
|
||||
Check 'dry-run plans every component' ($planned -ge 4) "planned=$planned"
|
||||
Check 'dry-run reports no unavailable dependency' ($unmet -eq 0) "unavailable=$unmet"
|
||||
Remove-Item Env:\REA_BUNDLE_ROOT -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "`n=== 8. bootstrap recognises an unpacked portable Node (no download) ===" -ForegroundColor Cyan
|
||||
# A machine with no Node is this script's entire audience, so the portable path is
|
||||
# only ever exercised where it is least observable: it must therefore be tested
|
||||
# here, with an empty PATH and a stack laid out exactly as the official zip lays it
|
||||
# out. The folder name is derived from the manifest by a SINGLE extension strip
|
||||
# (node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64); a double strip used to make
|
||||
# the bootstrap throw "the Node zip did not contain node-v24.21" after a successful
|
||||
# download, checksum and extraction. -DryRun keeps this check download-free.
|
||||
$bootRoot = Join-Path $env:TEMP 'rea-verify-bootstrap'
|
||||
# -Encoding UTF8: the manifest carries non-ASCII text and PowerShell 5.1 would
|
||||
# otherwise read a BOM-less file as ANSI and fail ConvertFrom-Json.
|
||||
$manifest = Get-Content (Join-Path $root 'manifest\bundle.manifest.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$bootLayoutName = [System.IO.Path]::GetFileNameWithoutExtension(
|
||||
[System.IO.Path]::GetFileName(([uri]$manifest.components.node.sources.'win32-x64'.url).AbsolutePath))
|
||||
$bootLayout = Join-Path (Join-Path $bootRoot 'stack') $bootLayoutName
|
||||
New-Item -ItemType Directory -Path $bootLayout -Force | Out-Null
|
||||
$hostNode = (Get-Command node -ErrorAction SilentlyContinue).Source
|
||||
if ($hostNode) {
|
||||
$target = Join-Path $bootLayout 'node.exe'
|
||||
if (Test-Path $target) { Remove-Item $target -Force }
|
||||
try { New-Item -ItemType HardLink -Path $target -Target $hostNode -ErrorAction Stop | Out-Null }
|
||||
catch { Copy-Item $hostNode $target -Force }
|
||||
}
|
||||
$bootOut = & powershell -NoProfile -Command "`$env:PATH='C:\Windows\System32;C:\Windows'; & '$root\scripts\rea-bundle-bootstrap.ps1' -Root '$bootRoot' -Reuse -DryRun" 2>&1
|
||||
Check 'bootstrap finds the portable Node in the official layout' (@($bootOut | Select-String 'portable Node present').Count -ge 1) (($bootOut | Select-Object -Last 2) -join ' ')
|
||||
Check 'bootstrap plans no download when one is present' (@($bootOut | Select-String 'plan: download').Count -eq 0) (@($bootOut | Select-String 'plan:').Line -join ' | ')
|
||||
for ($i = 0; $i -lt 5; $i++) {
|
||||
try { Remove-Item -Recurse -Force $bootRoot -ErrorAction Stop; break } catch { Start-Sleep -Milliseconds 300 }
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ($fail -eq 0) { Write-Host 'ALL CHECKS PASSED' -ForegroundColor Green }
|
||||
else { Write-Host "$fail CHECK(S) FAILED" -ForegroundColor Red }
|
||||
exit $fail
|
||||
Reference in new issue
Block a user