mirror of
https://github.com/czcdr/dsh-plugin-rea.git
synced 2026-11-07 10:25:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
74ea74434c | ||
|
|
0a8113c68f | ||
|
|
bdb3a4fc18 | ||
|
|
052a526b74 | ||
|
|
5f594f20d8 | ||
|
|
f712f24291 | ||
|
|
eac1e66079 | ||
|
|
e2e92f23b5 | ||
|
|
e53ac33ec9 | ||
|
|
a1faeaa178 | ||
|
|
ae23840b51 |
No files matched your search
@@ -0,0 +1,101 @@
|
||||
name: Release
|
||||
|
||||
# Two independent jobs, both triggered by publishing a GitHub Release:
|
||||
# 1. pack — builds the tarball, renames it to the version-free asset name and
|
||||
# attaches it to the release (the market's `latest/download` link
|
||||
# depends on that exact name staying free of a version number).
|
||||
# 2. publish — publishes to npm. Enabled only after this repository is
|
||||
# configured as a Trusted Publisher on npmjs.com and the repo
|
||||
# variable NPM_TRUSTED_PUBLISHING is set to "true"; until then the
|
||||
# job is skipped, so a release never fails on missing credentials.
|
||||
#
|
||||
# Setup for job 2 (one time, by the package owner):
|
||||
# npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions:
|
||||
# Organization/user: czcdr Repository: dsh-plugin-rea
|
||||
# Workflow filename: release.yml Environment: (leave empty)
|
||||
# then: repo Settings → Secrets and variables → Actions → Variables →
|
||||
# NPM_TRUSTED_PUBLISHING = true
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
pack:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Verify the released tag matches package.json
|
||||
run: |
|
||||
tag="${{ github.event.release.tag_name }}"
|
||||
ver="v$(node -p "require('./package.json').version")"
|
||||
if [ -n "$tag" ] && [ "$tag" != "$ver" ]; then
|
||||
echo "FAIL: release tag $tag does not match package.json $ver"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag matches: ${tag:-<manual run>}"
|
||||
|
||||
- name: Pack and attach a version-free tarball
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
npm pack
|
||||
mv dsh-plugin-rea-*.tgz dsh-plugin-rea.tgz
|
||||
ls -l dsh-plugin-rea.tgz
|
||||
if [ -n "${{ github.event.release.tag_name }}" ]; then
|
||||
gh release upload "${{ github.event.release.tag_name }}" dsh-plugin-rea.tgz --clobber
|
||||
fi
|
||||
|
||||
publish:
|
||||
needs: pack
|
||||
if: ${{ vars.NPM_TRUSTED_PUBLISHING == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: npm CLI must support OIDC trusted publishing (>= 11.15.0)
|
||||
# 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
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Bundle manifest sanity
|
||||
run: |
|
||||
node -e "
|
||||
const p = require('./package.json');
|
||||
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', '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: JSON files parse
|
||||
run: |
|
||||
node -e "
|
||||
for (const f of ['package.json', 'screenshots.json']) {
|
||||
JSON.parse(require('fs').readFileSync(f, 'utf8'));
|
||||
console.log('json ok:', f);
|
||||
}
|
||||
"
|
||||
|
||||
- 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 — 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', 'index.mjs', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
|
||||
'screenshots.json', 'assets/screenshot-1.png', 'assets/screenshot-2.png',
|
||||
];
|
||||
const missing = need.filter((f) => !files.includes(f));
|
||||
if (missing.length) {
|
||||
console.error('FAIL: the npm payload is missing ' + missing.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('npm payload ok: ' + files.length + ' files, all market-facing files included');
|
||||
"
|
||||
|
||||
- name: YAML files parse
|
||||
run: |
|
||||
python3 -m pip install --quiet --disable-pip-version-check pyyaml
|
||||
python3 - <<'PY'
|
||||
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)
|
||||
print('yaml ok:', f)
|
||||
PY
|
||||
|
||||
- name: The shipped patches stay machine-independent
|
||||
run: |
|
||||
set -e
|
||||
# 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
|
||||
fi
|
||||
if grep -nE '/(home|Users)/[A-Za-z0-9._-]+' $targets; then
|
||||
echo "FAIL: a shipped patch contains an absolute POSIX home path"
|
||||
exit 1
|
||||
fi
|
||||
echo "patches ok: no machine-specific path"
|
||||
|
||||
- name: The row keeps its contract
|
||||
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
|
||||
assert row['name'] == '@deepseek-ai/dsh-mcp-client', row
|
||||
cfg = row['config']
|
||||
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
|
||||
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
|
||||
+382
-1
@@ -2,13 +2,394 @@
|
||||
|
||||
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
||||
|
||||
## 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 真正跑在仓库上。
|
||||
|
||||
- **CI 首次真正进仓库**:1.0.4 的 CHANGELOG 与 README 都写了 `validate.yml` / `release.yml`,
|
||||
但 `.github/` 当时没有提交,GitHub 上 `actions/workflows` 是空的——现已推送并在 push 上跑通。
|
||||
- **校验范围扩到示例**:机器路径检查从只查 `cordis.patch.yml` 扩到 `examples/*.yml`(读者会照抄的文件);
|
||||
新增一步断言 npm 包必须带齐 `screenshots.json`、`assets/`、`examples/`——`files[]` 的回归守卫。
|
||||
- **`examples/cordis.patch.with-env.yml` 不再提供可照抄的假路径**:原来的 `C:\tools\...` 是编造值,
|
||||
现改为 `<your Ghidra 12.1.x extraction directory>`,并补上 `!!js process.env.GHIDRA_INSTALL_DIR`
|
||||
这一「安装者零改配置」的写法(entry-list dialect 支持 `!!js`;引擎变量本来就会被 MCP 子进程继承)。
|
||||
- **`check-updates.ps1` 加固**:`Test-NodeEngines` 去掉会先于函数体抛异常的类型约束;
|
||||
两个 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 → 201 字符(1.0.4 只是改写,仍会被 npm 列表页截断)。
|
||||
- **文档口径修正**:README 的 harness 版本拆成「桌面运行时 / 自带 dsh CLI」两个口径;
|
||||
投稿条目注释里的 Release 版本改为当前值。
|
||||
|
||||
## 1.0.4 — 2026-10-13
|
||||
|
||||
按外部评审修订:发布物不再携带任何机器相关路径。
|
||||
|
||||
- **发布用的那一行改为可移植**:Windows 用 `cmd.exe /c rea mcp`(走 PATH),macOS/Linux 用
|
||||
`command: rea` + `args: ['mcp']`;`cordis.patch.yml` 里**不再出现** `D:\...` 之类的绝对路径。
|
||||
- **新增 `examples/`**:`cordis.patch.posix.yml`(POSIX 变体)、`cordis.patch.with-env.yml`(需要为
|
||||
MCP 子进程固定引擎环境时的写法)。
|
||||
- **机器相关值改放本机 profile 覆盖**(`id: mcp-rea` 的 id-targeted config override)。README 两个
|
||||
语言版本都补了这一工作流;profile 覆盖永不随插件发布。
|
||||
- **`check-updates.ps1` 重写**:Node engines 判断改为按 `^`/`~`/`>=` 逐项匹配(此前 Node 26+ 会误报,
|
||||
且 `[version]'6.5.0-rc.1'` 会抛异常);node/rea/npm 不在 PATH 时有降级路径;profile 结构访问加防御;
|
||||
识别本机 profile 是否覆盖了 `mcp-rea`。
|
||||
- **`files` 白名单补齐** `screenshots.json`、`assets/`、`examples/`(npm 包里也带上展示图与示例)。
|
||||
- **`package.json` description 缩短**(npm 列表页会截断长描述)。
|
||||
- **新增 CI**:`.github/workflows/validate.yml` 校验 manifest/JSON/YAML,并**守住"发布物不得含机器
|
||||
绝对路径"**这条约束;`.github/workflows/release.yml` 在发 Release 时自动打包成版本无关资产名并上传,
|
||||
另含(默认关闭的)npm Trusted Publishing 发布任务。
|
||||
- **README 顶部去掉 awesome 徽章**:本插件尚未被该列表收录,收录后再加回。
|
||||
|
||||
## 1.0.3 — 2026-10-12
|
||||
|
||||
仓库展示与发布范围调整,无行为变更。
|
||||
|
||||
- 新增 `assets/screenshot-1.png`、`assets/screenshot-2.png` 与 `screenshots.json`(市场详情页展示用,
|
||||
由 `assets/make-screenshots.ps1` 可复现生成)。
|
||||
- 维护记录改为**仅本机保留**(不再随仓库或 tarball 发布)。
|
||||
- 维护记录改为**仅本机保留**:本机维护记录 `docs/project-notes.md` 既不进仓库也不进 tarball;
|
||||
投稿条目 `docs/marketplace-entry.yml` 留在仓库里,但不进 npm 包。
|
||||
|
||||
## 1.0.2 — 2026-10-12
|
||||
|
||||
|
||||
@@ -1,207 +1,353 @@
|
||||
# dsh-plugin-rea
|
||||
|
||||
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
||||
[](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>**
|
||||
> - 问题反馈: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 scopes the engine environment variables (`GHIDRA_INSTALL_DIR`,
|
||||
`JAVA_HOME`) 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):
|
||||
**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 (dsh desktop runtime) | **0.2.0-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 |
|
||||
|
||||
**Prerequisites.** Node.js (a version REA accepts), the REA CLI installed globally
|
||||
(`npm i -g rea-agents`), and for native analysis a Ghidra 12.1.x extraction directory (Hopper / IDA
|
||||
are alternatives, under their own licences). Static JavaScript/Electron and .NET analysis need no
|
||||
native engine.
|
||||
## Two lines: 1.x and 2.x
|
||||
|
||||
**Install.** Add this repository as a bundle (the repo declares `dsh.bundle.patch`, so it is
|
||||
installable directly):
|
||||
| | **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` |
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea
|
||||
powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1 -Reuse
|
||||
```
|
||||
|
||||
A prebuilt tarball is attached to [Releases](https://github.com/czcdr/dsh-plugin-rea/releases)
|
||||
(version-free asset name, so the link keeps resolving). Or install a local checkout with
|
||||
`file:<path-to-this-repo>`.
|
||||
`-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:
|
||||
|
||||
**Configure — two machine-specific values.** Edit `cordis.patch.yml` before or after installing:
|
||||
```
|
||||
dsh plugin --profile <profile> add dsh-plugin-rea
|
||||
# or pin this release instead of tracking npm latest:
|
||||
dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
|
||||
```
|
||||
|
||||
1. `args[0]` — full path of the REA CLI entry: run `npm root -g`, then append
|
||||
`\rea-agents\scripts\rea.mjs` (`/rea-agents/scripts/rea.mjs` on POSIX).
|
||||
2. `env.GHIDRA_INSTALL_DIR` — your Ghidra extraction directory. Omit it only if you do not use Ghidra.
|
||||
> **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.
|
||||
|
||||
`command: node` resolves through `PATH`; replace it with a full path if `node` is not on the PATH the
|
||||
harness sees. `env.JAVA_HOME` is optional (only needed when `java` is not on `PATH`).
|
||||
If Node is already installed, you can skip the bootstrap and run provisioning directly:
|
||||
|
||||
**Verify.** Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
|
||||
```
|
||||
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Which DSH versions
|
||||
|
||||
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`, and that declaration is the answer the plugin market
|
||||
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.
|
||||
|
||||
## Configure — nothing
|
||||
|
||||
The shipped row carries **no machine path**:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Three details in that row are load-bearing, and all three were bugs first:
|
||||
|
||||
- **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 when it can see that file — a custom `DSH_HOME` is hidden
|
||||
from an MCP child, so it falls back to `~/.dsh` there — and `node bin/rea-shim.mjs --shim-status`
|
||||
lists it under `duplicateRegistration`.
|
||||
|
||||
## 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 (as in the author's setup)? Overwrite the directory and
|
||||
install it again through the plugin manager. Updating REA below does **not** update this plugin, and
|
||||
updating this plugin does not touch REA.
|
||||
- **Upstream REA**: update it with `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.
|
||||
| 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`、`JAVA_HOME`)限制在这个 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):REA `6.4.0`、DeepSeek Harness 桌面运行时 `0.2.0-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`)。
|
||||
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
|
||||
node scripts/setup.mjs # or: powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
|
||||
```
|
||||
|
||||
[Releases](https://github.com/czcdr/dsh-plugin-rea/releases) 附带预构建 tarball(资产名不带版本号,
|
||||
链接不会失效)。也可用本地目录安装:`file:<本仓库路径>`。
|
||||
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.
|
||||
|
||||
**配置(两个随机器不同的值)**:改 `cordis.patch.yml`
|
||||
- **This plugin** never updates itself. Installed from a market? Update it there. Installed from a `file:`
|
||||
checkout? Overwrite the directory and reinstall.
|
||||
- **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.
|
||||
|
||||
1. `args[0]` —— REA 入口文件的绝对路径:先跑 `npm root -g`,再拼上
|
||||
`\rea-agents\scripts\rea.mjs`(POSIX 用 `/`)。
|
||||
2. `env.GHIDRA_INSTALL_DIR` —— 你的 Ghidra 解压目录;不用 Ghidra 时可删掉这一行。
|
||||
`check-updates.ps1` (Windows PowerShell) reports the state of all of these at once.
|
||||
|
||||
`command: node` 走 PATH;若 harness 的 PATH 里没有 `node`,改成绝对路径。`env.JAVA_HOME`
|
||||
可选,仅当 `java` 不在 PATH 上时需要。
|
||||
## Still prefer the configuration-only 1.x line?
|
||||
|
||||
**验证。** 调用任一 `mcp__rea__*` 工具:`binary_session` 会给出引擎检查项
|
||||
(configuration / installation / headless / native_decompiler / java),应全部 `ok: true`;
|
||||
随后 `open_binary` 打开一个 PE → `binary_overview` → `procedure_pseudo_code`。
|
||||
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:
|
||||
|
||||
**范围与注意。** REA 在 6.4.0 暴露 170+ 个工具,注入后每个会话的工具列表都会变大;行内设置
|
||||
`failOnStartupError: false`,因此 REA 缺失也不会阻塞 harness 启动。部分 REA 工具会启动进程、
|
||||
写文件、访问网络,按 DSH 的权限策略执行。请仅分析你已获得授权的目标。
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
**更新:两件事,且都要手动。**
|
||||
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).
|
||||
|
||||
- **本插件**不会自动更新。**从插件市场安装的,就在市场里更新**;本地 `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 并调整字段名。
|
||||
## License
|
||||
|
||||
`check-updates.ps1`(Windows PowerShell)会把上述四项一次跑完。
|
||||
|
||||
**许可。** 本适配器为 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.
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
# 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.0
|
||||
```
|
||||
|
||||
> **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"`,这就是插件市场读的那句话:有它,卡片显示
|
||||
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 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`。范围之外的宿主可能照样能用,只是没有替它声明。
|
||||
|
||||
## 配置 —— 不需要
|
||||
|
||||
发布的那一行**不含任何机器路径**:
|
||||
|
||||
```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 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
|
||||
退回 `~/.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.4**,在健康的 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:` 检出的就覆盖目录重装。
|
||||
- **从 `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 各自保留自己的许可。
|
||||
**本包不再分发任何上游代码或二进制** —— 供给过程是从各组件的官方源逐个拉取。
|
||||
+23
-10
@@ -1,10 +1,23 @@
|
||||
<#
|
||||
生成市场展示图(assets/screenshot-*.png)。
|
||||
可复现:改完文案后重跑本脚本即可,无需设计工具。
|
||||
注意:Windows PowerShell 5.1 按 ANSI 读 .ps1,本文件必须保存为 UTF-8 with BOM。
|
||||
|
||||
运行方式:
|
||||
Windows PowerShell 5.1 : powershell -NoProfile -File .\make-screenshots.ps1
|
||||
PowerShell 7 (pwsh) : pwsh -NoProfile -File ./make-screenshots.ps1
|
||||
两者都可用;脚本内部用 OutDir 参数,默认写到本文件所在目录。
|
||||
|
||||
注意:Windows PowerShell 5.1 按 ANSI 读取 .ps1,本文件必须保存为 UTF-8 with BOM
|
||||
(用 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
|
||||
|
||||
@@ -66,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
|
||||
@@ -99,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 |
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* rea-shim — the process the DSH MCP row launches.
|
||||
*
|
||||
* Why this exists: the published `cordis.patch.yml` must carry no machine path,
|
||||
* yet REA needs absolute paths for every dependency. The shim is the seam: it
|
||||
* reads one machine-local JSON file, exports exactly the variables REA reads,
|
||||
* and execs the vendored REA CLI with the arguments the row was given.
|
||||
*
|
||||
* It downloads nothing, writes nothing, and never touches PATH.
|
||||
*
|
||||
* Exit codes: 0 child exited normally * child's own status
|
||||
* 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
|
||||
* `failOnStartupError: false`, so the harness stays up
|
||||
* and the message is the operator's remediation.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const EX_CONFIG = 78;
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = path.resolve(HERE, "..");
|
||||
|
||||
/** DSH home, by the same rule setup.mjs and the published patch use. */
|
||||
function dshHome() {
|
||||
const fromEnv = process.env.DSH_HOME;
|
||||
if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
|
||||
return path.join(homedir(), ".dsh");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where setup writes the machine-local truth.
|
||||
*
|
||||
* Two copies of this file exist and they need different answers, so the copy has
|
||||
* to be identified before the root can be:
|
||||
*
|
||||
* * The **mirrored** copy at `<bundle>/bin/rea-shim.mjs` is what the published
|
||||
* row launches, and its own location is the bundle root. It must not consult
|
||||
* DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
|
||||
* environment (case-insensitively, because Windows is), so a custom DSH_HOME
|
||||
* would send it to `~/.dsh`, where it would report "not provisioned" for a
|
||||
* bundle sitting right next to it — and start a second ~800 MB run there.
|
||||
* * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
|
||||
* `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
|
||||
* directory is the plugin, not the bundle, so resolving to itself would report
|
||||
* "not provisioned" on a fully provisioned machine — and a bare
|
||||
* `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
|
||||
*
|
||||
* Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
|
||||
* REA_BUNDLE_ROOT stays as an explicit override for tests and unusual layouts.
|
||||
*/
|
||||
function bundleRoot() {
|
||||
const override = process.env.REA_BUNDLE_ROOT;
|
||||
if (override && override.trim() !== "") return path.resolve(override);
|
||||
if (!existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"))) return PLUGIN_ROOT;
|
||||
return path.join(dshHome(), "plugins", "rea-bundle");
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory can provision only when it holds both files a run reads: the script
|
||||
* and the manifest it loads at startup. A bundle provisioned before the manifest
|
||||
* was mirrored has the script but not the manifest, so recommending that copy
|
||||
* would send the operator straight into an ENOENT.
|
||||
*/
|
||||
function usableSetup(dir) {
|
||||
if (typeof dir !== "string" || dir.trim() === "") return null;
|
||||
const base = path.resolve(dir);
|
||||
const script = path.join(base, "scripts", "setup.mjs");
|
||||
const manifest = path.join(base, "manifest", "bundle.manifest.json");
|
||||
return existsSync(script) && existsSync(manifest) ? script : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which setup.mjs to run.
|
||||
*
|
||||
* The mirrored copy is a snapshot: after a plugin upgrade it still holds the
|
||||
* previous manifest, so it would provision the previous versions forever. setup
|
||||
* writes <bundle>/mirror.json naming the real checkout; prefer that while it is
|
||||
* usable, and fall back to the local copy — which is also all a first run has.
|
||||
*/
|
||||
function provisioningScript(root) {
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
|
||||
const recorded = usableSetup(record && record.pluginRoot);
|
||||
if (recorded) return recorded;
|
||||
} catch {
|
||||
/* no record, or not readable: the local copy is the only option */
|
||||
}
|
||||
return usableSetup(PLUGIN_ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream REA 6.5.0 can add a second registration for the same MCP server:
|
||||
* `rea setup --client dsh` writes a row with id "rea-mcp" and serverName "rea"
|
||||
* into `<DSH_HOME>/cordis.patch.yml`, while this bundle ships id "mcp-rea" with
|
||||
* the same serverName. DSH does not dedupe those rows: it rejects the later entry
|
||||
* with a duplicate-server-name error, so which REA row survives depends on load
|
||||
* order. The shim cannot prevent that, but it can name the conflict instead of
|
||||
* leaving a missing row to be diagnosed from "the tools are gone".
|
||||
*
|
||||
* The parse is deliberately small: the two files it cares about are block
|
||||
* mappings with one id/serverName per row, and anything it cannot attribute is
|
||||
* ignored — a missed hint is acceptable, a false alarm is not. The mirrored shim
|
||||
* cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
|
||||
* this falls back to `~/.dsh` like the rest of the bundle; the row has the same
|
||||
* limitation.
|
||||
*/
|
||||
function foreignReaRegistration() {
|
||||
const patch = path.join(dshHome(), "cordis.patch.yml");
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(patch, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2").trim();
|
||||
// A row starts at `- id:`; the block runs to the next row, so a serverName can
|
||||
// never be attributed to a row that does not contain it.
|
||||
for (const block of text.split(/\r?\n(?=\s*-\s+id:\s*)/)) {
|
||||
const id = /^\s*-\s+id:\s*(.+?)\s*$/m.exec(block);
|
||||
const server = /^\s+serverName:\s*(.+?)\s*$/m.exec(block);
|
||||
if (!id || !server || unquote(server[1]) !== "rea") continue;
|
||||
const rowId = unquote(id[1]);
|
||||
if (rowId !== "mcp-rea") return { patch, id: rowId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
|
||||
* `state: "running"` is written when a run is started (setup's own pid once it
|
||||
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
|
||||
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
|
||||
* plain-number lock is still honoured as "running".
|
||||
*/
|
||||
const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function readLock(lock) {
|
||||
try {
|
||||
const raw = readFileSync(lock, "utf8").trim();
|
||||
if (raw.startsWith("{")) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
}
|
||||
const at = Number(raw);
|
||||
return Number.isFinite(at) ? { state: "running", at } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the process recorded in the lock still alive? null means "cannot tell". */
|
||||
function pidAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "ESRCH" ? false : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On first run there is nothing to launch. Provisioning is a long download, so
|
||||
* it is started detached: the harness may kill this row (or the whole session)
|
||||
* long before 800 MB arrive, and the downloader is resumable and idempotent, so
|
||||
* a killed attempt is simply continued on the next start.
|
||||
*
|
||||
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
|
||||
* Returns { started, reason, logPath } — the caller words its message from
|
||||
* `reason`, because "provisioning started in the background" was also printed
|
||||
* for a lock left behind by a run that had already failed, and for one whose
|
||||
* process was long gone.
|
||||
*/
|
||||
function maybeAutoProvision(root) {
|
||||
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return { started: false, reason: "disabled" };
|
||||
const setupScript = provisioningScript(root);
|
||||
if (!setupScript) return { started: false, reason: "no-setup" };
|
||||
const lock = path.join(root, "setup.lock");
|
||||
const logPath = path.join(root, "logs", "setup.log");
|
||||
if (existsSync(lock)) {
|
||||
// A crashed run must not wedge provisioning forever: honour the lock only
|
||||
// while it is fresh; setup removes it itself when it finishes cleanly.
|
||||
const record = readLock(lock);
|
||||
const age = record && Number.isFinite(record.at) ? Date.now() - record.at : Infinity;
|
||||
const fresh = Number.isFinite(age) && age < STALE_LOCK_MS;
|
||||
if (fresh && record?.state === "failed") {
|
||||
// The run gave up and left the lock behind so it would not be retried in a
|
||||
// loop. Claiming it is still provisioning was a six-hour lie: nothing runs,
|
||||
// and every restart repeated the sentence.
|
||||
return {
|
||||
started: false,
|
||||
reason: "last-run-failed",
|
||||
exit: record.exit,
|
||||
failedAt: record.at,
|
||||
ageMs: age,
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
if (fresh) {
|
||||
const alive = pidAlive(record?.pid);
|
||||
if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
|
||||
}
|
||||
// Reached when the lock is older than the freshness window, or its holder is
|
||||
// gone and never wrote an outcome (harness restart, power loss, a kill): what
|
||||
// it records is not happening any more. This removal is the whole point of the
|
||||
// freshness rule — without it the exclusive create below failed with EEXIST
|
||||
// *forever*, auto-provisioning never ran again, and main() printed nothing at
|
||||
// all for that outcome. The lock's own "honour it only while it is fresh"
|
||||
// comment described behaviour the code did not have.
|
||||
try {
|
||||
rmSync(lock, { force: true });
|
||||
} catch {
|
||||
// A read-only bundle root (system-wide install, chmod 555) cannot drop the
|
||||
// stale lock either. Returning the same "cannot write here" outcome as the
|
||||
// exclusive create below keeps the row alive: an uncaught EACCES killed the
|
||||
// shim with a stack trace, which is louder than the old silence but just as
|
||||
// unable to start REA.
|
||||
return { started: false, reason: "unwritable", logPath };
|
||||
}
|
||||
}
|
||||
try {
|
||||
mkdirSync(path.join(root, "logs"), { recursive: true });
|
||||
// Exclusive create: check-then-write is not atomic, so two rows starting at
|
||||
// once could both decide to provision. EEXIST means another run owns it.
|
||||
writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
|
||||
/* a read-only bundle root must never break the harness */
|
||||
return { started: false, reason: "unwritable" };
|
||||
}
|
||||
try {
|
||||
const child = spawn(process.execPath, [setupScript, "--root", root], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
// Hand the lock to the detached downloader, so a later start that finds this
|
||||
// row dead still sees the run that is actually doing the work as alive.
|
||||
if (Number.isInteger(child.pid)) {
|
||||
try {
|
||||
writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
|
||||
} catch {
|
||||
/* the lock is advisory: the row keeps working without it */
|
||||
}
|
||||
}
|
||||
return { started: true, reason: "started", logPath };
|
||||
} catch {
|
||||
return { started: false, reason: "spawn-failed", logPath };
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message, detail) {
|
||||
const lines = [
|
||||
"",
|
||||
" dsh-plugin-rea (bundle): REA is not provisioned on this machine.",
|
||||
"",
|
||||
" " + message,
|
||||
];
|
||||
if (detail) lines.push("", " detail: " + detail);
|
||||
// Point at the real checkout when the record allows it: running the mirrored
|
||||
// copy is fine, but it can only ever provision the manifest it was mirrored
|
||||
// with — and a bundle mirrored before the manifest travelled with it has no
|
||||
// usable copy at all, so the honest answer is then "run the plugin's own script".
|
||||
const setupScript = provisioningScript(bundleRoot());
|
||||
lines.push(
|
||||
"",
|
||||
" Finish setup with:",
|
||||
setupScript
|
||||
? " node \"" + setupScript + "\""
|
||||
: " node \"<installed plugin directory>/scripts/setup.mjs\"",
|
||||
);
|
||||
if (!setupScript) {
|
||||
lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
" Then restart DSH (or reload the profile) so the MCP row reconnects.",
|
||||
"",
|
||||
" The harness keeps running: this row declares failOnStartupError: false.",
|
||||
"",
|
||||
);
|
||||
process.stderr.write(lines.join("\n"));
|
||||
return EX_CONFIG;
|
||||
}
|
||||
|
||||
function readBundleConfig(root) {
|
||||
const file = path.join(root, "bundle.json");
|
||||
if (!existsSync(file)) return { ok: false, code: "missing", file };
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
return { ok: false, code: "malformed", file, detail: String(error && error.message) };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, code: "malformed", file, detail: "bundle.json is not an object" };
|
||||
}
|
||||
return { ok: true, file, config: parsed };
|
||||
}
|
||||
|
||||
/** Resolve the vendored REA entry, or report precisely what is missing. */
|
||||
function resolveRea(config, root) {
|
||||
const paths = config.paths ?? {};
|
||||
const node = paths.node;
|
||||
// The fallback has to match where setup actually installs the package:
|
||||
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
|
||||
// fallback without that segment named a directory that never exists. It only
|
||||
// fires when an older bundle.json has no paths.reaEntry — which is exactly when
|
||||
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
|
||||
const reaEntry = paths.reaEntry
|
||||
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
|
||||
|
||||
if (typeof node !== "string" || node.trim() === "") {
|
||||
return { ok: false, reason: "bundle.json has no paths.node" };
|
||||
}
|
||||
if (!existsSync(node)) {
|
||||
return { ok: false, reason: `the recorded Node runtime is gone: ${node}` };
|
||||
}
|
||||
if (!existsSync(reaEntry)) {
|
||||
return { ok: false, reason: `the vendored REA CLI is gone: ${reaEntry}` };
|
||||
}
|
||||
return { ok: true, node, reaEntry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export only what REA actually reads, and only when the bundle recorded it.
|
||||
* An absent variable is better than a wrong one: REA then reports its own
|
||||
* availability with its own remediation text.
|
||||
*/
|
||||
function childEnvironment(config) {
|
||||
const env = { ...process.env };
|
||||
const paths = config.paths ?? {};
|
||||
|
||||
const set = (name, value) => {
|
||||
if (typeof value === "string" && value.trim() !== "") env[name] = value;
|
||||
};
|
||||
|
||||
// Analysis engines.
|
||||
set("GHIDRA_INSTALL_DIR", paths.ghidra);
|
||||
set("JAVA_HOME", paths.jdk);
|
||||
// Optional providers/tools; wired only when the bundle provisioned them.
|
||||
set("REA_ADB_PATH", paths.adb);
|
||||
set("REA_APKTOOL_COMMAND", paths.apktoolCommand);
|
||||
set("REA_JADX_MCP_JAR", paths.jadxJar);
|
||||
set("REA_BROWSER_EXECUTABLE", paths.browser);
|
||||
set("REA_ILSPY_CMD_PATH", paths.ilspycmd);
|
||||
|
||||
// Keep the vendored Node first for any grandchild the bridge may spawn.
|
||||
if (typeof paths.node === "string" && paths.node.trim() !== "") {
|
||||
const nodeDir = path.dirname(paths.node);
|
||||
const current = env.PATH ?? "";
|
||||
const parts = current.split(path.delimiter).filter((part) => part !== "");
|
||||
if (!parts.some((part) => path.resolve(part) === path.resolve(nodeDir))) {
|
||||
env.PATH = [nodeDir, ...parts].join(path.delimiter);
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage text, also what a bare invocation prints.
|
||||
*
|
||||
* `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
|
||||
* shim can only forward the arguments a user typed — it cannot append a flag. So a
|
||||
* bare `rea-bundle-status`, which is exactly what that name invites, used to fall
|
||||
* through to the launch path: it started REA, and on an unprovisioned machine it
|
||||
* began an ~800 MB download. Reporting usage instead makes the command's behaviour
|
||||
* match its name, and the row is unaffected because it always passes `mcp`.
|
||||
*/
|
||||
function usage() {
|
||||
return [
|
||||
"rea-shim: launches the vendored REA MCP server for a DSH MCP row.",
|
||||
"",
|
||||
" node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
|
||||
" node bin/rea-shim.mjs --shim-status provisioning status, no launch",
|
||||
" node bin/rea-shim.mjs --shim-print-env the environment it would export",
|
||||
"",
|
||||
` bundle root: ${bundleRoot()}`,
|
||||
` plugin root: ${PLUGIN_ROOT}`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
if (argv.includes("--shim-help") || argv.length === 0) {
|
||||
process.stdout.write(usage());
|
||||
return 0;
|
||||
}
|
||||
|
||||
const root = bundleRoot();
|
||||
const loaded = readBundleConfig(root);
|
||||
// Upstream can add a second row for the same serverName; the shim cannot stop
|
||||
// DSH from loading both rows, but saying so is better than doubled tools with
|
||||
// no stated cause. The status report carries the same fact for diagnostics.
|
||||
const duplicate = foreignReaRegistration();
|
||||
if (duplicate && !argv.includes("--shim-status")) {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
|
||||
` ${duplicate.patch} also registers serverName "rea" (id "${duplicate.id}").\n` +
|
||||
` This bundle's row is id "mcp-rea" with the same serverName; DSH rejects the later\n` +
|
||||
` entry as a duplicate server name, so only one survives. Keep exactly one of them.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (argv.includes("--shim-status")) {
|
||||
const report = {
|
||||
bundleRoot: root,
|
||||
bundleFile: loaded.file,
|
||||
provisioned: loaded.ok,
|
||||
reason: loaded.ok ? undefined : loaded.code,
|
||||
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
|
||||
};
|
||||
if (duplicate) report.duplicateRegistration = duplicate;
|
||||
if (loaded.ok) {
|
||||
report.paths = loaded.config.paths ?? {};
|
||||
report.versions = loaded.config.versions ?? {};
|
||||
report.rea = resolveRea(loaded.config, root);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
||||
return loaded.ok ? 0 : EX_CONFIG;
|
||||
}
|
||||
|
||||
if (!loaded.ok) {
|
||||
const message =
|
||||
loaded.code === "missing"
|
||||
? `No bundle state at ${loaded.file}.`
|
||||
: `Bundle state at ${loaded.file} could not be parsed.`;
|
||||
// Both "missing" and "malformed" start a run. A malformed file used to fail
|
||||
// permanently: setup overwrites a bundle.json it cannot parse, but nothing
|
||||
// ever started setup, so every restart reported the same EX_CONFIG until the
|
||||
// operator deleted the file by hand. The lock stops a provisioning loop when
|
||||
// writing keeps failing.
|
||||
const provisioning = maybeAutoProvision(root);
|
||||
if (provisioning.started) {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
|
||||
);
|
||||
} else if (provisioning.reason === "last-run-failed") {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
|
||||
` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
|
||||
);
|
||||
} else if (provisioning.reason === "in-flight") {
|
||||
// A live run owns the lock. Saying nothing here is what made a wedged
|
||||
// provisioning attempt look like a machine that simply has no bundle.
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): provisioning is already running in the background.\n progress: ${provisioning.logPath}\n`,
|
||||
);
|
||||
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
|
||||
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
|
||||
);
|
||||
}
|
||||
return fail(message, loaded.detail);
|
||||
}
|
||||
|
||||
const rea = resolveRea(loaded.config, root);
|
||||
if (!rea.ok) return fail(rea.reason);
|
||||
|
||||
const env = childEnvironment(loaded.config);
|
||||
if (argv.includes("--shim-print-env")) {
|
||||
const interesting = Object.fromEntries(
|
||||
Object.entries(env).filter(([key]) =>
|
||||
/^(GHIDRA_INSTALL_DIR|JAVA_HOME|REA_[A-Z0-9_]+|PATH)$/.test(key),
|
||||
),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(interesting, null, 2) + "\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Pass through whatever the row supplied; 'mcp' in the normal case.
|
||||
const childArgs = [rea.reaEntry, ...(argv.length > 0 ? argv : ["mcp"])];
|
||||
const child = spawn(rea.node, childArgs, {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(
|
||||
`rea-shim: could not start ${rea.node}\n ${String(error && error.message)}\n`,
|
||||
);
|
||||
process.exit(EX_CONFIG);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
// Re-raising the signal is not portable: process.kill(pid, "SIGTERM")
|
||||
// throws ERR_UNKNOWN_SIGNAL on Windows. Report the conventional 128+n
|
||||
// status instead, which the harness reads the same way.
|
||||
const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
|
||||
process.exit(128 + (signals[signal] ?? 0));
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = main();
|
||||
if (typeof status === "number") process.exit(status);
|
||||
+287
-59
@@ -1,10 +1,11 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
检查 dsh-plugin-rea 的整条链路是否需要跟进上游更新。
|
||||
检查 dsh-plugin-rea 整条链路是否需要跟进上游更新。
|
||||
|
||||
.DESCRIPTION
|
||||
覆盖 4 类上游:REA(npm) / Node 运行时 / Ghidra 引擎 / DSH 插件登记。
|
||||
只读:不安装、不修改任何配置,只报告现状与建议动作。
|
||||
对 PATH 缺失、profile 结构缺失、预发布版本号均有防御。
|
||||
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -File .\check-updates.ps1
|
||||
@@ -20,20 +21,6 @@ param(
|
||||
[string]$ProfileDir
|
||||
)
|
||||
|
||||
# 默认值:自动定位 REA 全局包与 dsh profile(均可用参数覆盖)
|
||||
if (-not $ReaPackageDir) {
|
||||
$npmRoot = $null
|
||||
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' }
|
||||
}
|
||||
if (-not $ProfileDir) {
|
||||
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
|
||||
else {
|
||||
$profiles = @(Get-ChildItem "$env:USERPROFILE\.dsh\profiles" -Directory -ErrorAction SilentlyContinue)
|
||||
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { "$env:USERPROFILE\.dsh\profiles\desktop" }
|
||||
}
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Head($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
|
||||
function Ok($t) { Write-Host " [OK] $t" -ForegroundColor Green }
|
||||
@@ -43,45 +30,164 @@ function Info($t) { Write-Host " $t" -ForegroundColor Gray }
|
||||
|
||||
$actions = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
# 只取数字部分,丢掉 -rc.1 / +build 之类的后缀,避免 [version] 转换抛异常
|
||||
function Get-NumericVersion([string]$v) {
|
||||
if (-not $v) { return $null }
|
||||
$core = ($v -replace '^v', '') -replace '[-+].*$', ''
|
||||
try { return [version]$core } catch { return $null }
|
||||
}
|
||||
|
||||
# 判断 Node 版本是否满足 engines 字符串(支持 ^X.Y.Z / ~X.Y.Z / >=X.Y.Z,用 || 分隔)
|
||||
# 参数收 string 不收 [version]:类型约束在函数体之前生效,调用方一旦传入
|
||||
# 'abc' / '' / 'v24.21.0' 这类值就会抛 ParameterBindingArgumentTransformationException。
|
||||
function Test-NodeEngines([string]$node, [string]$engines) {
|
||||
if (-not $engines -or -not $node) { return $null }
|
||||
# 必须用新变量接收:$node 带 [string] 约束,赋 [version] 会被强制转回字符串,
|
||||
# 之后 $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+)$') {
|
||||
$base = [version]$Matches[1]
|
||||
if ($v.Major -eq $base.Major -and $v -ge $base) { return $true }
|
||||
} elseif ($t -match '^>=(\d+\.\d+\.\d+)$') {
|
||||
if ($v -ge [version]$Matches[1]) { return $true }
|
||||
} 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
|
||||
# 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
|
||||
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
|
||||
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
|
||||
$bundleRootEarly = $env:REA_BUNDLE_ROOT
|
||||
if (-not $bundleRootEarly) {
|
||||
$homeEarly = $env:DSH_HOME
|
||||
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
||||
$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' }
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- REA (npm)
|
||||
Head 'REA 本体(npm 包 rea-agents)'
|
||||
$installed = $null; $engines = $null; $binPath = $null
|
||||
$installed = $null; $engines = $null; $binPath = $null; $reaEntry = $null
|
||||
$pkgFile = Join-Path $ReaPackageDir 'package.json'
|
||||
if (Test-Path $pkgFile) {
|
||||
$pkg = Get-Content $pkgFile -Raw | ConvertFrom-Json
|
||||
$installed = $pkg.version; $engines = $pkg.engines.node
|
||||
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
|
||||
Ok "本地已安装: $installed"
|
||||
Info "入口(bin): $binPath ← 插件引用的就是它,跨版本稳定"
|
||||
Info "Node 要求: $engines"
|
||||
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
|
||||
try {
|
||||
$pkg = Get-Content $pkgFile -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$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 '/', '\') }
|
||||
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 {
|
||||
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')) {
|
||||
if (-not $r) { continue }
|
||||
try { $latest = (Invoke-RestMethod "$r/rea-agents/latest" -TimeoutSec 20).version; Info "最新版本($r): $latest"; break }
|
||||
catch { Warn "查询失败 $r : $($_.Exception.Message)" }
|
||||
}
|
||||
if ($installed -and $latest) {
|
||||
if ([version]$latest -gt [version]$installed) {
|
||||
$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 本身;本插件不会自动更新,有新版需在插件管理器里重装)")
|
||||
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 '已是最新版本' }
|
||||
} elseif (-not $latest) {
|
||||
Warn '未能取到上游最新版本(两个 registry 都查不通?)—— 已跳过版本比对'
|
||||
} else {
|
||||
Warn '本地没读到已安装版本(见上面那条)—— 已跳过版本比对'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- Node 运行时
|
||||
Head 'Node 运行时'
|
||||
$nodeV = (& node -v) -replace '^v', ''
|
||||
Info "当前 node: $nodeV"
|
||||
if ($engines) {
|
||||
if ($engines -match '\^24\.(\d+)\.' -and [version]$nodeV -ge [version]('24.' + $Matches[1] + '.0')) { Ok '满足当前 engines 要求' }
|
||||
elseif ($engines -match '\^22\.(\d+)\.' -and [version]$nodeV -ge [version]('22.' + $Matches[1] + '.0')) { Ok '满足当前 engines 要求' }
|
||||
else { Warn "请对照 engines 范围自行确认: $engines" }
|
||||
Info "上游若抬高 Node 下限,插件里的 command('node') 需换成满足要求的 node 可执行文件"
|
||||
if (Get-Command node -ErrorAction SilentlyContinue) {
|
||||
$nodeRaw = (& node -v 2>$null) -replace '^v', ''
|
||||
$nodeV = Get-NumericVersion $nodeRaw
|
||||
Info "当前 node: $nodeRaw"
|
||||
if ($nodeV -and $engines) {
|
||||
$ok = Test-NodeEngines $nodeV $engines
|
||||
if ($ok -eq $true) { Ok "满足当前 engines($engines)" }
|
||||
elseif ($ok -eq $false) { Bad "不满足 engines($engines)—— 请升级 Node;插件侧走 PATH,无需改配置" }
|
||||
else { Warn "无法解析 engines:$engines" }
|
||||
}
|
||||
} else {
|
||||
Warn 'node 不在 PATH —— 跳过 Node 检查;若 harness 也看不到它,MCP 服务器将无法启动'
|
||||
$actions.Add('把 node 加入 PATH,或在本机 profile 覆盖里给 mcp-rea 的 command 写绝对路径')
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ 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"
|
||||
@@ -89,48 +195,170 @@ if ($gd -and (Test-Path $gd)) {
|
||||
if (Test-Path $props) {
|
||||
$v = (Select-String -Path $props -Pattern '^application\.version=' | Select-Object -First 1).Line -replace '^application\.version=', ''
|
||||
Ok "已安装版本: $v"
|
||||
if ($v -notmatch '^12\.1\.') { Bad "REA 当前要求 Ghidra 12.1.x,而你装的是 $v —— 升级 REA 后才有望支持更高版本" }
|
||||
} else { Bad "缺少 Ghidra\application.properties —— 目录不完整" }
|
||||
} else { Bad "GHIDRA_INSTALL_DIR 未设置或目录不存在(当前值: $gd)" }
|
||||
|
||||
if ($v -notmatch '^12\.1\.') { Bad "REA 当前要求 Ghidra 12.1.x,当前为 $v —— 先确认新版 REA 是否放开版本门槛" }
|
||||
} else { Bad '缺少 Ghidra\application.properties —— 目录不完整' }
|
||||
} else {
|
||||
Warn "GHIDRA_INSTALL_DIR 未设置或目录不存在(当前值: $gd)"
|
||||
Info '原生分析会报引擎不可用;静态 JavaScript/Electron 与 .NET 分析不受影响'
|
||||
}
|
||||
try {
|
||||
$rel = Invoke-RestMethod 'https://api.github.com/repos/NationalSecurityAgency/ghidra/releases/latest' -TimeoutSec 25 -Headers @{ 'User-Agent' = 'rea-check' }
|
||||
Info "Ghidra 官方最新: $($rel.tag_name) ($($rel.published_at))"
|
||||
if ($rel.tag_name -notmatch 'Ghidra_12\.1') { Warn '官方已有更新;请先确认 REA 是否放开版本门槛,再升级 Ghidra' }
|
||||
if ($rel.tag_name -notmatch 'Ghidra_12\.1') { Warn '官方已有更新;升级前先确认 REA 是否接受该版本' }
|
||||
} catch { Warn "GitHub API 查询失败(可跳过): $($_.Exception.Message)" }
|
||||
|
||||
# --------------------------------------------------------- REA 引擎自检
|
||||
Head 'REA 引擎自检(rea doctor)'
|
||||
$env:GHIDRA_INSTALL_DIR = $gd
|
||||
try {
|
||||
$raw = & rea doctor --format json 2>$null
|
||||
$doc = ($raw -join "`n") | ConvertFrom-Json
|
||||
$g = $doc.providerInspections | Where-Object { $_.id -eq 'ghidra' }
|
||||
if ($g) {
|
||||
if ($g.available) { Ok "ghidra provider 可用,版本 $($g.providerVersion)" } else { Bad 'ghidra provider 不可用 —— 运行 rea doctor 看 remediation' }
|
||||
$fails = @($g.checks | Where-Object { -not $_.ok })
|
||||
if ($fails.Count -eq 0) { Ok '全部子项通过(configuration/installation/headless/native_decompiler/java)' }
|
||||
else { $fails | ForEach-Object { Bad "$($_.name): $($_.detail)" } }
|
||||
# 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 ($doc.identity.cli_package_version) { Info "doctor 识别到的 CLI 版本: $($doc.identity.cli_package_version),MCP 工具数: $($doc.identity.catalog.counts.mcp_tools)" }
|
||||
} catch { Warn "rea doctor 解析失败,请手动运行: rea doctor" }
|
||||
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)
|
||||
Warn 'rea 不在 PATH —— 回退到 node <包内入口>'
|
||||
} else { Warn 'rea 与 node 均不可用 —— 跳过引擎自检' }
|
||||
}
|
||||
if ($reaCmd) {
|
||||
try {
|
||||
$raw = & $reaCmd @reaArgs doctor --format json 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { Warn "rea doctor 退出码 $LASTEXITCODE —— 报告了不健康状态,下面按输出解析" }
|
||||
$doc = ($raw -join "`n") | ConvertFrom-Json
|
||||
$g = @($doc.providerInspections) | Where-Object { $_.id -eq 'ghidra' }
|
||||
if ($g) {
|
||||
if ($g.available) { Ok "ghidra provider 可用,版本 $($g.providerVersion)" }
|
||||
else { Bad 'ghidra provider 不可用 —— 运行 rea doctor 看 remediation' }
|
||||
$fails = @($g.checks | Where-Object { -not $_.ok })
|
||||
if ($fails.Count -eq 0) { Ok '全部子项通过(configuration/installation/headless/native_decompiler/java)' }
|
||||
else { $fails | ForEach-Object { Bad "$($_.name): $($_.detail)" } }
|
||||
}
|
||||
if ($doc.identity.cli_package_version) {
|
||||
$toolCount = $null
|
||||
if ($doc.identity.catalog -and $doc.identity.catalog.counts) { $toolCount = $doc.identity.catalog.counts.mcp_tools }
|
||||
Info "doctor 识别到的 CLI 版本: $($doc.identity.cli_package_version)$(if ($toolCount) { ",MCP 工具数: $toolCount" })"
|
||||
}
|
||||
} catch { Warn "rea doctor 解析失败,请手动运行: rea doctor" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- DSH 插件登记状态
|
||||
Head 'DSH 插件登记'
|
||||
$profPkg = Join-Path $ProfileDir 'package.json'
|
||||
if (Test-Path $profPkg) {
|
||||
$p = Get-Content $profPkg -Raw | ConvertFrom-Json
|
||||
$dep = $p.dependencies.'dsh-plugin-rea'
|
||||
$bundled = @($p.dsh.profile.bundles) -contains 'dsh-plugin-rea'
|
||||
if ($dep) { Ok "依赖已登记: dsh-plugin-rea = $dep" } else { Bad '依赖未登记 —— 用 plugin_manager 重新安装本插件' }
|
||||
if ($bundled) { Ok '已列入 dsh.profile.bundles(装载层已生效)' } else { Bad '未列入 bundles —— 插件不会加载' }
|
||||
Info '本插件不会自动更新:从插件市场安装的在市场里更新;本地 file: 目录方式则覆盖目录后在插件管理器里重装'
|
||||
if ($dep -like 'file:*') {
|
||||
$p2 = $dep -replace '^file:', ''
|
||||
if (Test-Path $p2) { Ok "插件目录存在: $p2" } else { Bad "插件目录不存在: $p2 —— 移走了目录就会加载失败" }
|
||||
}
|
||||
try {
|
||||
$p = Get-Content $profPkg -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$dep = $null
|
||||
if ($p.PSObject.Properties.Name -contains 'dependencies' -and $p.dependencies) { $dep = $p.dependencies.'dsh-plugin-rea' }
|
||||
$bundles = @()
|
||||
if ($p.PSObject.Properties.Name -contains 'dsh' -and $p.dsh -and $p.dsh.profile -and $p.dsh.profile.bundles) { $bundles = @($p.dsh.profile.bundles) }
|
||||
if ($dep) { Ok "依赖已登记: dsh-plugin-rea = $dep" } else { Bad '依赖未登记 —— 用插件管理器重新安装本插件' }
|
||||
if ($bundles -contains 'dsh-plugin-rea') { Ok '已列入 dsh.profile.bundles(装载层已生效)' }
|
||||
elseif ($bundles.Count -eq 0) { Warn '未读到 dsh.profile.bundles(profile 结构可能不同)' }
|
||||
else { Bad '未列入 bundles —— 插件不会加载' }
|
||||
Info '本插件不会自动更新:从插件市场安装的在市场里更新;本地 file: 目录方式则覆盖目录后在插件管理器里重装'
|
||||
if ($dep -like 'file:*') {
|
||||
$p2 = $dep -replace '^file:', ''
|
||||
if (Test-Path $p2) { Ok "插件目录存在: $p2" } else { Bad "插件目录不存在: $p2 —— 移走目录会加载失败" }
|
||||
}
|
||||
} catch { Warn "profile package.json 解析失败: $($_.Exception.Message)" }
|
||||
} else { Warn "找不到 profile: $profPkg" }
|
||||
|
||||
$profPatch = Join-Path $ProfileDir 'cordis.patch.yml'
|
||||
if (Test-Path $profPatch) {
|
||||
if (Select-String -Path $profPatch -Pattern 'id:\s*mcp-rea' -Quiet) {
|
||||
Info '检测到本机 profile 覆盖了 mcp-rea(机器相关值应放在这里,不随插件发布)'
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------- 一体包(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 已就位' }
|
||||
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 '无需动作:整条链路与上游保持一致' }
|
||||
|
||||
+37
-24
@@ -1,26 +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.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# TWO VALUES ARE MACHINE-SPECIFIC — edit them for your machine:
|
||||
# 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.
|
||||
#
|
||||
# 1. args[0] full path to the rea CLI entry file.
|
||||
# Run: npm root -g
|
||||
# then append: \rea-agents\scripts\rea.mjs
|
||||
# (POSIX: <npm root -g>/rea-agents/scripts/rea.mjs)
|
||||
#
|
||||
# 2. env.GHIDRA_INSTALL_DIR
|
||||
# the directory where Ghidra was extracted,
|
||||
# e.g. D:\ghidra\ghidra_12.1.4_PUBLIC
|
||||
# Leave it out only if you do not use Ghidra.
|
||||
#
|
||||
# `command: node` is resolved through PATH; point it at a full path (for
|
||||
# example D:\nodejs\node.exe) if node is not on the PATH the harness sees.
|
||||
# env.JAVA_HOME is optional — keep it when `java` is not on PATH.
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -28,11 +33,19 @@
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: 'node'
|
||||
command: !!js process.execPath
|
||||
args:
|
||||
- 'D:\dev\npm-global\node_modules\rea-agents\scripts\rea.mjs'
|
||||
# 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:
|
||||
GHIDRA_INSTALL_DIR: 'D:\ghidra\ghidra_12.1.4_PUBLIC'
|
||||
JAVA_HOME: 'F:\game\xbox'
|
||||
# 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,188 @@
|
||||
# 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)
|
||||
├─ bundle.json present & components verify → exec REA (normal path)
|
||||
├─ 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.
|
||||
- **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,12 +10,14 @@
|
||||
# - 一个 PR 最多 3 条。
|
||||
#
|
||||
# 若已发布 Release tarball,可取消下面 tarball 行的注释(资产名必须不带版本号)。
|
||||
# 本仓库已在 v1.0.0 Release 中附带 dsh-plugin-rea.tgz,故该行已启用。
|
||||
# 本仓库的 Release 一直附带 dsh-plugin-rea.tgz(资产名不带版本号,永远指向最新 Release),故该行保持启用。
|
||||
# 注意:脚本里 npm 映射优先于 tarball(installTargetFor:有 npm 名就走 npm),
|
||||
# 所以这行只是 npm 不可用时的兜底,不会改变市场默认的安装方式。
|
||||
|
||||
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
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
+30
-4
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "dsh-plugin-rea",
|
||||
"version": "1.0.3",
|
||||
"description": "Third-party adapter that registers the upstream REA (Reverse Engineer Anything) MCP server into DeepSeek Harness as mcp__rea__* tools. REA itself is 'one MCP for reverse engineering across binaries, applications, and runtime behavior' (github.com/morluto/rea, MIT) — native binaries, JavaScript/Electron apps, .NET assemblies, websites, Android APKs, firmware and more; this plugin only composes the configuration row and scopes its engine env vars. · 第三方适配插件:把上游 REA(逆向一切)的 MCP 服务注册进 DeepSeek Harness,得到 mcp__rea__* 工具。逆向能力由上游提供(github.com/morluto/rea,MIT);本插件只做配置组合与引擎环境变量隔离。",
|
||||
"version": "2.0.6",
|
||||
"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,12 +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"
|
||||
"check-updates.ps1",
|
||||
"screenshots.json",
|
||||
"assets/"
|
||||
],
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
@@ -40,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"
|
||||
},
|
||||
"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,199 @@
|
||||
#!/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.
|
||||
*
|
||||
* 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',
|
||||
'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.
|
||||
* @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 match = SEMVER.exec(version.trim())
|
||||
if (match === null) return null
|
||||
const pre = match[4] === undefined
|
||||
? []
|
||||
: match[4].split('.').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,219 @@
|
||||
# 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 -------------------------------------------------------
|
||||
|
||||
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
|
||||
+1718
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
# 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=== 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 ' ')
|
||||
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