mirror of
https://github.com/czcdr/dsh-plugin-rea.git
synced 2026-11-07 10:25:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b06b4ae987 | ||
|
|
b5e915a1a5 | ||
|
|
273153243a | ||
|
|
5c71bf87f7 | ||
|
|
849863841d | ||
|
|
85933748ac | ||
|
|
8e2acfdffc | ||
|
|
4d8a08266d | ||
|
|
ed6b3c99b8 | ||
|
|
c55768233c | ||
|
|
10572a7958 | ||
|
|
513b09408b | ||
|
|
3d46fbcf02 | ||
|
|
508f3c24d6 | ||
|
|
b8b0f593cc | ||
|
|
a7dd23f375 | ||
|
|
1f5a9925c7 | ||
|
|
50864f0a9d | ||
|
|
1438672f9a | ||
|
|
212e20ae28 | ||
|
|
71252e26b8 | ||
|
|
2546cca893 | ||
|
|
7087d059be | ||
|
|
9a0f2708a1 | ||
|
|
95803a6833 | ||
|
|
7b02aa95b6 | ||
|
|
ebb912ace2 | ||
|
|
3f413e1989 | ||
|
|
edf3968319 | ||
|
|
e3b2a064af | ||
|
|
3b57a9d1ac | ||
|
|
5c22588001 | ||
|
|
0e3aa428bf | ||
|
|
37d2adb17a | ||
|
|
a6ee476bfd | ||
|
|
d0e2ffd7a6 | ||
|
|
27d3f1106c | ||
|
|
f6afdea83d | ||
|
|
975ac5aac8 | ||
|
|
98e3bd0863 | ||
|
|
3138bd15c3 | ||
|
|
83bd2c70fe | ||
|
|
84fc9b1611 | ||
|
|
9d86f828bf | ||
|
|
65a2963029 | ||
|
|
419235ad45 | ||
|
|
30beb7f622 | ||
|
|
9f818e1f05 | ||
|
|
0bf5c3eee2 | ||
|
|
8f1ea15f2a | ||
|
|
422e4f2454 | ||
|
|
4a6d2e0a79 | ||
|
|
4689a53237 | ||
|
|
b10b016a35 | ||
|
|
727ead6a14 | ||
|
|
f4d8910fcb | ||
|
|
818eb082be | ||
|
|
7aeb83ce30 | ||
|
|
3bcd313c7b | ||
|
|
904425d992 | ||
|
|
4ce902d58d | ||
|
|
74ea74434c | ||
|
|
0a8113c68f | ||
|
|
bdb3a4fc18 | ||
|
|
052a526b74 | ||
|
|
5f594f20d8 | ||
|
|
f712f24291 | ||
|
|
eac1e66079 | ||
|
|
e2e92f23b5 | ||
|
|
e53ac33ec9 | ||
|
|
a1faeaa178 | ||
|
|
ae23840b51 | ||
|
|
8f00a16ca1 | ||
|
|
4cc1cee632 | ||
|
|
57caa9f654 | ||
|
|
48dcd9ca20 | ||
|
|
f27514c38b | ||
|
|
d12b9b744d | ||
|
|
c310a0f212 | ||
|
|
22a4624942 | ||
|
|
8c554a316f | ||
|
|
8a9011ef42 | ||
|
|
b21b6d5abd | ||
|
|
940e4fee1f | ||
|
|
81b8cf885e | ||
|
|
82f06d97d1 |
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,265 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
# The shim's launch decision reads this machine's REA. No runner has one, so
|
||||
# the default `auto` would happen to take the bundle path today — pin it so
|
||||
# that stays true if an image ever ships REA.
|
||||
env:
|
||||
REA_BUNDLE_LAUNCH: bundle
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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: The READMEs quote the declared DSH range verbatim
|
||||
# The same comparison verify-branch.ps1 makes locally. Without it here, a
|
||||
# stale quote survives every CI signal: the JSON, host-compat and the
|
||||
# audit all agree while the sentence a human reads names another ceiling
|
||||
# (that is how `<0.3.0` outlived the move to `<0.3.0-0`).
|
||||
run: |
|
||||
node -e "
|
||||
const p = require('./package.json');
|
||||
const { readFileSync } = require('fs');
|
||||
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
|
||||
for (const doc of ['README.md', 'README.zh-CN.md']) {
|
||||
const text = readFileSync(doc, 'utf8');
|
||||
if (!text.includes(p.engines.dsh)) fail(doc + ' does not quote engines.dsh (' + p.engines.dsh + ')');
|
||||
// The pin example is copy-pasteable: it must name the version this
|
||||
// tree publishes, or the reader gets a ref that does not exist.
|
||||
if (!text.includes('dsh-plugin-rea#v' + p.version)) fail(doc + ' does not pin dsh-plugin-rea#v' + p.version);
|
||||
}
|
||||
console.log('readme range ok');
|
||||
"
|
||||
|
||||
- name: Both JSON readers tolerate a BOM
|
||||
# verify-branch.ps1 runs the same check locally. bin/rea-shim.mjs and
|
||||
# scripts/setup.mjs read the same mirror.json/bundle.json, so a strip on
|
||||
# one side only makes the same file say "there is a checkout" to the shim
|
||||
# and "there is none" to setup — which shipped once: setup re-mirrored the
|
||||
# previous manifest. A bare JSON.parse(readFileSync(...)) is that bug.
|
||||
run: |
|
||||
node - <<'JS'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const blind = [];
|
||||
for (const file of ['bin/rea-shim.mjs', 'scripts/setup.mjs']) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
|
||||
// readJson() itself is the strip; anything else must not parse a file raw.
|
||||
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
|
||||
blind.push(file + ':' + text.slice(0, m.index).split('\n').length);
|
||||
}
|
||||
}
|
||||
if (!text.includes('replace(/^\\uFEFF/')) blind.push(file + ': no U+FEFF strip');
|
||||
}
|
||||
if (blind.length) {
|
||||
console.error('FAIL: BOM-blind JSON read at ' + blind.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('bom-blind reads: none');
|
||||
JS
|
||||
|
||||
- name: JSON files parse
|
||||
run: |
|
||||
node -e "
|
||||
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
|
||||
|
||||
- name: Vendored dependency advisories
|
||||
# Provisioning resolves REA's closure from scripts/vendor-lock. This fails
|
||||
# the build if that lock drifts back onto an advisory-affected zod,
|
||||
# minimatch or glob, or onto a rea-agents release the manifest does not
|
||||
# pin. It is offline on purpose: a gate that needs the registry fails on a
|
||||
# plane and passes during a registry outage, which is the wrong shape for
|
||||
# a build check.
|
||||
run: npm run bundle:deps
|
||||
|
||||
windows:
|
||||
# The PowerShell download route, the tar/Expand-Archive extraction path and
|
||||
# scripts/rea-bundle-bootstrap.ps1 are Windows-only code, and an ubuntu-only
|
||||
# matrix never executed any of them — they were verified by hand, if at all.
|
||||
# The tests are platform-sensitive on purpose: each platform exercises the
|
||||
# download route it actually chooses.
|
||||
runs-on: windows-latest
|
||||
# Same pin as the ubuntu job: the launch decision must not depend on what the
|
||||
# runner image happens to have installed.
|
||||
env:
|
||||
REA_BUNDLE_LAUNCH: bundle
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Unit tests for the pure logic (Windows)
|
||||
run: npm run bundle:test
|
||||
|
||||
- name: Bundle manifest audit (Windows)
|
||||
run: npm run bundle:audit
|
||||
|
||||
- name: Vendored dependency advisories (Windows)
|
||||
run: npm run bundle:deps
|
||||
@@ -13,3 +13,6 @@ pnpm-debug.log*
|
||||
|
||||
# Local overrides: keep your machine-specific paths out of git
|
||||
cordis.patch.local.yml
|
||||
|
||||
# Local-only maintenance record — never publish this file
|
||||
docs/project-notes.md
|
||||
+1098
File diff suppressed because it is too large.
Load diff
@@ -1,177 +1,518 @@
|
||||
# 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).
|
||||
|
||||
---
|
||||
> REA **6.8.0** — this package's pin — still accepts Ghidra **12.1.x** only (12.2 is `unsupported`, and the whole
|
||||
> Ghidra decompile/analysis group disappears) and the jadx bridge **0.7.1** only (0.8.0 fails on its first call with
|
||||
> `Expected engine 0.7.1`, before any APK is loaded). Both gates were re-checked against 6.8.0 on 2026-10-26 and
|
||||
> neither one moved. JDK **21.0.13** is out upstream, but Adoptium publishes no Windows build of it, so there is
|
||||
> nothing to point at here — and it drops only three of the four eMudhra anchors. The trade-off is therefore "all
|
||||
> tools green, CVE still open": Ghidra 12.1.4's CVE-2026-100504 / 100505 / 100503 / 113604 stay in the chain — **do
|
||||
> not analyse untrusted samples with this environment** — and the JDK still trusts the eMudhra anchors. Evidence,
|
||||
> mitigations and revisit triggers: [docs/update-review-2026-10-24.md](docs/update-review-2026-10-24.md) and
|
||||
> [docs/update-review-2026-10-26.md](docs/update-review-2026-10-26.md).
|
||||
>
|
||||
> **Ghidra 12.2 itself works** — measured 2026-10-26: same 9 segments, same 341 procedures, byte-identical `entry`
|
||||
> pseudocode as 12.1.4, through REA's own bridge. What blocks it is REA's version-equality check, not the bridge.
|
||||
> If you want the CVE-fixed engine anyway, `node scripts/ghidra-gate-override.mjs --enable --version 12.2` rewrites
|
||||
> that one constant in the vendored copy, records what it changed in `<bundle>/ghidra-gate-override.json`, and
|
||||
> `--disable` restores it; re-running setup reverts it too. It needs **JDK 25+** (`application.java.min=25`).
|
||||
|
||||
## English
|
||||
> **You must run `node scripts/setup.mjs` yourself — once after installing, and once after every plugin
|
||||
> upgrade.** Nothing runs it for you: a market install or update replaces the package and stops there.
|
||||
> Since **2.0.8** a market upgrade keeps the *row* working (the mirrored launcher re-execs the new one), but it
|
||||
> does **not** move the bundle to the new pins — REA and the engines stay at the versions recorded by the last
|
||||
> setup run. Skip it on a first install and the row exits `MODULE_NOT_FOUND` with no tools and no message;
|
||||
> skip it after an upgrade and you keep running the old REA silently (`check-updates.ps1` reports the drift).
|
||||
> If you had enabled the Ghidra gate override (see §9 of the 2026-10-26 review), that setup run re-provisions the
|
||||
> bundled REA and the patch goes with it — re-enable it afterwards, or accept the pinned Ghidra again.
|
||||
> The full rule set is under [Updating](#updating--three-independent-things).
|
||||
|
||||
**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 first install and every plugin upgrade also show a one-time guide in the Web GUI.** It says whether the
|
||||
> runtime is missing or behind this package's pin, shows the exact `node "<install path>/scripts/setup.mjs"`
|
||||
> command for that install, and remembers the dismissal per plugin version. It is a notice only: it never runs
|
||||
> setup for you and never edits the bundle. Run it once, restart dsh, and the guide stays away until the next
|
||||
> plugin version.
|
||||
|
||||
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.
|
||||
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__*`.
|
||||
|
||||
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.
|
||||

|
||||
|
||||
**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 this is
|
||||
|
||||
**Verified against** (author's machine, 2026-10-12):
|
||||
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.
|
||||
|
||||
| Component | Version |
|
||||
> **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).
|
||||
|
||||
**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 it can and cannot do
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
**What provisioning cannot buy you.** These are upstream host gates, not settings this plugin forgot:
|
||||
|
||||
| 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.16
|
||||
```
|
||||
|
||||
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.23.4 || ^24.22.1 || >=26.12.1` (check with `node -v`). REA raised those floors in 6.8.0: the pinned
|
||||
24.22.1 is now the *oldest* acceptable 24.x, so a 24.11–24.21 runtime that 6.7.0 accepted is rejected here.
|
||||
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-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.2-rc.1` — every release in between, alpha and rc (16 tags) | yes |
|
||||
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
|
||||
|
||||
2.0.14 checked the six tags published after 2026-10-23 individually (`0.2.1-rc.3`, `0.2.1-rc.4`, `0.2.2-alpha.2`,
|
||||
`0.2.1-rc.5`, `0.2.2-alpha.3`, `0.2.2-rc.1`): none of their release notes touch the five surfaces this plugin depends on
|
||||
(patch dialect, MCP row schema, host plugin API, client injection contract, market `engines` verdict). The one adjacent
|
||||
item is `0.2.2-rc.1`'s Cordis Core 4.1.0 / Loader 1.1.0, and it was **measured on this machine**:
|
||||
`GET /dsh-plugin-rea/guide-state` answered 200 and the `mcp__rea__*` tools were available.
|
||||
|
||||
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2`,
|
||||
`dsh-v0.2.1-rc.2` and `dsh-v0.2.2-rc.1` exposes the same config surface the shipped row uses (`serverName`, `transport`,
|
||||
`command`, `args`, `env`, `failOnStartupError`), and `dsh.bundle.patch` exists at all three. A host outside the range can
|
||||
still work; it is simply not claimed.
|
||||
|
||||
> The market reads `engines.dsh` only, and **not** `engines.node` (measured in `dshmarket/lib/verify.js` and
|
||||
> `discovery-compatibility.js`). A host whose bundled Node sits below this package's `engines.node` floor is therefore
|
||||
> not blocked by the market; only someone running npm with `engine-strict=true` is stopped at install time. Run time is
|
||||
> unaffected either way: REA is started with the `paths.node` recorded in `bundle.json`, not the host's Node.
|
||||
|
||||
### A REA you already have is used as-is
|
||||
|
||||
2.x exists to supply what a machine is missing. If REA is already installed — `rea` on PATH, the 1.x setup — the row
|
||||
runs **that**, exactly as the 1.x line did, and nothing is provisioned:
|
||||
|
||||
| What the machine has | What the row runs |
|
||||
|---|---|
|
||||
| `rea` on PATH, at least as new as the provisioned build | your REA (`rea mcp`), with your Node and your PATH |
|
||||
| `rea` on PATH but older than the provisioned build | the provisioned bundle — a stale global CLI would take the tool count backwards (6.4.0 ships 170 tools, 6.7.0 ships 207, 6.8.0 ships 229) |
|
||||
| no `rea` on PATH | the provisioned bundle, provisioning itself on first use |
|
||||
|
||||
- `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the decision (`auto` is the default); `REA_BUNDLE_SYSTEM_REA=<path>`
|
||||
names a binary that is not on PATH.
|
||||
- The probe is the CLI's own `--version`, bounded at 8 s: a `.cmd` shim on Windows takes ~2.4 s to answer, so the check
|
||||
runs once per start and only when a `rea` actually exists.
|
||||
- `--shim-status` prints the decision — `launch.mode`, `launch.reason`, `launch.systemRea` — and its `launch.rejected`
|
||||
list says why a candidate was skipped (`"exit 7"`, `"inside the bundle"`, `"timeout"`).
|
||||
- A delegated child carries `REA_BUNDLE_DELEGATED=1`, so a wrapper that calls this shim cannot make it delegate to
|
||||
itself forever.
|
||||
- **On Windows a forced kill of the shim does not reap the delegated REA.** There is no job object here: Task Manager
|
||||
or a supervisor terminating the shim leaves your `rea` running with the row's pipes (a console Ctrl+C reaches the
|
||||
whole process group and does stop it). On POSIX the child is in the same group and dies with the terminal.
|
||||
|
||||
## Configure — nothing
|
||||
|
||||
The shipped row carries **no machine path**:
|
||||
|
||||
```yaml
|
||||
- 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.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || 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.
|
||||
- **The home fallback mirrors `os.homedir()`: `USERPROFILE`, then `HOMEDRIVE`+`HOMEPATH`, then `HOME`.**
|
||||
The middle pair is what a Windows service account gets instead of `USERPROFILE`; an expression that skipped
|
||||
it produced `/.dsh/...` — the root of the current drive — for exactly the accounts that cannot read that
|
||||
path. Only when all four are empty (a container account with no home at all) does the concatenation land on
|
||||
`/.dsh/...`, and `failOnStartupError: false` keeps that quiet, so the only symptom is "no `mcp__rea__*`
|
||||
tools". Set `DSH_HOME` explicitly on such an account.
|
||||
- **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.
|
||||
|
||||
A patch replaces the row's `config` **wholesale** — it does not deep-merge — so a partial override such as
|
||||
`config: {env: {...}}` also deletes `command`, `args` and `serverName`, and the row stops starting. Supply the
|
||||
complete config, or use `REA_BUNDLE_LAUNCH=system` (above), which needs no patch at all.
|
||||
|
||||
Ghidra and the JDK are not read from your environment at launch: `setup.mjs` records their absolute paths in
|
||||
`bundle.json` and the shim exports `GHIDRA_INSTALL_DIR` / `JAVA_HOME` from that record — which is why a
|
||||
non-standard install location works without touching the row. To adopt an existing install, point
|
||||
`GHIDRA_INSTALL_DIR` / `JAVA_HOME` at it and run `node scripts/setup.mjs --reuse`: the manifest's probe checks
|
||||
the candidate and falls back to a download when it does not match, so a wrong path is reported during setup
|
||||
instead of failing later inside a tool call.
|
||||
|
||||
### One REA registration only
|
||||
|
||||
Upstream REA 6.5.0 can register itself with DSH:
|
||||
|
||||
```
|
||||
rea setup --client dsh
|
||||
```
|
||||
|
||||
That writes a row with `id: rea-mcp` and `serverName: rea` into `<DSH_HOME>/cordis.patch.yml`. This
|
||||
bundle's row is `id: mcp-rea` with the same `serverName`, and DSH rejects the later of two entries
|
||||
that share a server name — so which REA row survives depends on load order. **Use one, not both.**
|
||||
|
||||
Which one? Upstream's is the configuration-only route: it expects Node on REA's engines,
|
||||
`rea-agents` reachable, and a Ghidra/JDK you provisioned yourself. This bundle is the
|
||||
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
|
||||
`mcp__rea__*` surface are the same either way.
|
||||
|
||||
The shim reports the conflict on startup — `setup.mjs` records the DSH home it ran under in
|
||||
`mirror.json`, and the mirrored copy reads that when the harness has stripped `DSH_*` from its
|
||||
environment; `node bin/rea-shim.mjs --shim-status` lists it under `duplicateRegistration`.
|
||||
|
||||
## Verify
|
||||
|
||||
Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
|
||||
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`), all of which should be
|
||||
`ok: true`. Then `open_binary` a PE, `binary_overview`, and `procedure_pseudo_code`.
|
||||
|
||||
**Scope and caveats.** REA exposes 170+ tools at 6.4.0, so every session's tool list grows; the row
|
||||
sets `failOnStartupError: false` so a missing REA never blocks harness startup. Some REA tools launch
|
||||
processes, write files and access the network — DSH's permission policy applies. Analyse only
|
||||
artifacts you are authorized to inspect.
|
||||
Provisioning itself ends with three checks: `rea doctor --json` (reported, with unmet checks named), a
|
||||
**Ghidra smoke import** that runs `rea analyze` on the JDK's own `bin/java` and fails the run when it
|
||||
cannot import (`--skip-smoke` omits it), and the exact shim launch the MCP row will use. The smoke step
|
||||
exists because `doctor` validates paths, versions and which Java would be chosen — it never imports a
|
||||
binary, so a broken bridge or a mismatched native addon used to surface only as an MCP timeout.
|
||||
|
||||
**Updating — two independent things, and both are manual.**
|
||||
**The first analysis of each new target is slow.** Measured here: 36 s for a small PE, 64 s for a 6 MB
|
||||
DLL — every import starts a JVM and runs Ghidra's default auto-analysis. If the first
|
||||
`open_binary` → `list_segments` on a large file times out, that is this cost and not a broken engine:
|
||||
call it again and the open target answers immediately, because it keeps its ephemeral project.
|
||||
|
||||
- **This plugin** never updates itself. When this repository has a newer revision, install it again
|
||||
through the plugin manager (`add github:czcdr/dsh-plugin-rea`), or re-point / overwrite your local
|
||||
`file:` checkout and let it reload. 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.
|
||||
## Verified against
|
||||
|
||||
`check-updates.ps1` (Windows PowerShell) reports the state of all four at once.
|
||||
Author's machine, 2026-10-13 / 14 / 16 / 19 / 23 / 26:
|
||||
|
||||
**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.
|
||||
| Component | Version |
|
||||
|---|---|
|
||||
| REA (`rea-agents`) | **6.8.0** (229 tools) |
|
||||
| DeepSeek Harness | desktop runtime **0.2.2-rc.1** (the host 2.0.14 was measured on; full range in the table above) |
|
||||
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
|
||||
| Ghidra | 12.1.4 (this machine also runs 12.2 through the gate override — see below) |
|
||||
| JDK | Temurin 21 (provisioned) / Amazon Corretto 25.0.5 (adopted; its major satisfies the pin `21`) |
|
||||
| Node.js | **24.22.1** adopted (REA engines: `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`; ABI 137) |
|
||||
| Host | Windows 11 x64 (build 26200) |
|
||||
|
||||
---
|
||||
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.8.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.
|
||||
|
||||
**作用。** 本插件**自身不做任何逆向分析**。它把上游 REA 的 MCP 服务接入 DeepSeek Harness,
|
||||
让 REA 提供的那套工具在会话里变成可直接调用的 `mcp__rea__*`。
|
||||
On 2026-10-23 the same chain was re-verified after the host Node moved to **24.22.1**: the vendored
|
||||
`rea-windows-x64.node` and both `conpty` addons loaded under ABI 137; `binary_overview` on `conpty.node`
|
||||
reported 10 segments / 1178 procedures / 618 strings; `procedure_pseudo_code` returned real decompiled
|
||||
output; and `java -version` from the vendored JDK answered `21.0.12.1`. The one thing that did **not**
|
||||
work on the first try was the first `list_segments` — it timed out at 64 s of cold Ghidra analysis, which
|
||||
is what the smoke step and the note above are for.
|
||||
|
||||
按上游自述,REA 是「**通过一个 MCP 服务,逆向分析二进制文件、应用程序和运行时行为**」——
|
||||
「看到喜欢的功能,弄清它的工作原理,深入到二进制层面」。它「将智能体连接到分析工具,用于检查
|
||||
原生二进制文件、JavaScript 和 Electron 应用、.NET 程序集以及网站」;分析在本机运行,结果包含
|
||||
支撑各项结论的证据和相关限制。原生分析可以使用已有的 Hopper、Ghidra 或 IDA,静态 JavaScript 分析
|
||||
不需要原生引擎。
|
||||
On 2026-10-26 the pin moved to **REA 6.8.0** and the chain was re-verified on the same machine: `setup.mjs
|
||||
--reuse` reported `probe cli=6.8.0 tools=229` and its Ghidra smoke imported `java.exe` in 26 s; an independent
|
||||
MCP handshake over stdio answered `serverInfo {name: rea, version: 6.8.0}` with **229 tools / 6 prompts** on
|
||||
protocol `2024-11-05`; apktool still answers `3.0.3`, `adb` still reports 37.0.1 (1.0.41), and the jadx jar on
|
||||
disk still hashes to `6e5eacf5…` — the value REA 6.8.0 requires. Executing 6.8.0's own release policy gave
|
||||
`12.1.4 → verified`, `12.1.5 → compatible`, `12.2 → unsupported`, so Ghidra stays where it was.
|
||||
|
||||
上游列出的可分析目标(原文见 [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 崩溃与已保存的网络捕获。
|
||||
On 2026-11-06 (2.0.14 — an 18-item external audit plus host adaptation) **no upstream component moved** (REA 6.8.0 /
|
||||
Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1). The same chain was re-verified: `--dry-run`
|
||||
skipped all 7 components on their pins (the Ghidra 12.2 gate override and JDK 25 were not mistaken for drift) →
|
||||
a forced apktool re-provision went through the full staging path (no leftover `.staging`/`.backup`, and the directory
|
||||
held exactly the two files the manifest declares) → a full `--reuse` run covered 8 components plus
|
||||
`fridaBindings FRIDA_OK 3` → `probe cli=6.8.0 tools=229`, the Ghidra smoke imported `java.exe` in 28 s, and the shim
|
||||
resolved 6.8.0, exit 0. Gates: **154 tests** (151 pass / 0 fail / 3 skipped), `audit` 24/24 hash-pinned clean,
|
||||
the dependency gate at zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4, `host-compat` 16 releases, and
|
||||
`verify-branch` ALL CHECKS PASSED. `check-updates.ps1` went from a false-green exit 0 to reporting honestly, and exits 0
|
||||
on this machine once repaired.
|
||||
|
||||
**本插件在此之上只增加两件事**:把该 MCP 服务注册进 DSH 的工具层(工具名 `mcp__rea__*`),
|
||||
以及把引擎环境变量(`GHIDRA_INSTALL_DIR`、`JAVA_HOME`)限制在这个 MCP 子进程内,而不是整个 harness。
|
||||
## Scope and caveats
|
||||
|
||||
**已验证版本**(作者机器,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`)。
|
||||
- REA exposes 229 tools at 6.8.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` resolves the REA CLI's closure with **`npm ci`** against the shipped
|
||||
`scripts/vendor-lock/` (`package.json` + `package-lock.json`). REA pins zod **exactly** (`"4.4.3"`), and 4.4.3 is
|
||||
inside GHSA-mrhx-c7xc-m3w5, so the closure is raised through `overrides` to **zod 4.6.7 / minimatch 11.0.5 /
|
||||
glob 14.0.4**. `npm ci` verifies every tarball's sha512; if a repackaging mirror makes that fail with `EINTEGRITY`,
|
||||
it falls back to "same versions via overrides + `npm install`" (same versions, not byte-reproducible, and the log
|
||||
says so). After landing, the version in `node_modules/zod/package.json` is read once more and anything below 4.6.6
|
||||
is refused. `node scripts/check-dependency-advisories.mjs` is the offline gate, wired into CI.
|
||||
Package lifecycle scripts are still skipped on purpose — with exactly one named exception: **frida**, whose native
|
||||
binding (`build/frida_binding.node`) only exists after its install script runs, so setup runs `npm rebuild frida`
|
||||
for that one package and then proves it with `import('frida')` plus device enumeration (recorded under
|
||||
`components.reaAgents.frida` in `bundle.json`). A missing binding makes the Frida-backed tools unavailable and the
|
||||
rest of REA untouched, so it is reported as `skip` rather than failing the whole provision.
|
||||
This 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 要求的 Node;全局安装 REA(`npm i -g rea-agents`);原生分析需要
|
||||
Ghidra 12.1.x 的解压目录(Hopper / IDA 为替代方案,各自需授权)。静态 JavaScript/Electron 与
|
||||
.NET 分析不需要原生引擎。
|
||||
## Updating — three independent things
|
||||
|
||||
**安装。**
|
||||
> **Two standing rules — for this release and for every later 2.x release.**
|
||||
>
|
||||
> `setup.mjs` reconciles the **bundle**, never the **plugin**: it reads the manifest of the plugin that is
|
||||
> installed *right now*. Installed 2.0.9? setup provisions 6.5.0 even after a newer release exists. The package changes
|
||||
> only through the market (or `npm i -g dsh-plugin-rea@<version>` / a `file:` reinstall); running setup more
|
||||
> often never substitutes for that.
|
||||
>
|
||||
> **And nothing runs it for you.** A market install or update replaces the package and stops; the first
|
||||
> `setup.mjs` — and one after every upgrade — is yours to run, from the plugin directory.
|
||||
>
|
||||
> 1. **The row executes a copy of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a
|
||||
> mirror made by setup, and a market upgrade replaces the package without replacing it — so the mirror can keep
|
||||
> running old code with **no symptom at all**. Since **2.0.8** the mirror compares itself with the launcher
|
||||
> recorded in `mirror.json` and re-execs the plugin's copy when the two differ (one hop, guarded by
|
||||
> `REA_BUNDLE_SHIM_REEXEC`). A mirror **older than 2.0.8 cannot do this**, so upgrading from **2.0.0–2.0.7**
|
||||
> still needs one `node scripts/setup.mjs`. `--shim-status` reports `mirror.stale`; `check-updates.ps1` says
|
||||
> when to re-run.
|
||||
> 2. **The mirrored `scripts/setup.mjs` is a copy too, and it has no self-heal of its own.** Running setup from the
|
||||
> bundle root therefore executes the version from when the bundle was created, not the one you just installed.
|
||||
> Run it once **from the plugin directory** to refresh that copy. Since **2.0.9** the copy strips the UTF-8 BOM
|
||||
> from `mirror.json` / `bundle.json` exactly as the shim does, so a BOM-ed record no longer reads as "no record"
|
||||
> on the setup side (which used to re-mirror the previous manifest).
|
||||
|
||||
### Updating from the market
|
||||
|
||||
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 指向的文件不存在),且未能验证恢复原版本文件
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
- **The row runs a COPY of this plugin's launcher.** `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` is a mirror
|
||||
made by setup, so a market upgrade replaces the package but not the file the row executes — until setup runs
|
||||
again. From **2.0.8** the mirror notices that the installed plugin ships a different launcher and re-execs it
|
||||
(one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`), so upgrades carry. On an older version the mirror stays behind
|
||||
silently; `--shim-status` now reports `mirror.stale`, and `check-updates.ps1` says to re-run setup. If you are
|
||||
upgrading from 2.0.0–2.0.7, run `node scripts/setup.mjs` once after the upgrade.
|
||||
- **The mirrored `scripts/setup.mjs` is a copy too.** A market upgrade replaces the package but not that copy,
|
||||
so a remediation command that points at `<DSH_HOME>/plugins/rea-bundle/scripts/setup.mjs` runs the setup from
|
||||
when the bundle was created — not the one you just installed. From **2.0.9** both the shim and setup strip a
|
||||
UTF-8 BOM from `mirror.json` / `bundle.json` (a BOM-ed record used to read as "no record" on the setup side,
|
||||
which re-mirrored the previous manifest). Running `node scripts/setup.mjs` once **from the plugin directory**
|
||||
refreshes that copy too.
|
||||
- **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. `npm run check-updates`
|
||||
forwards to it on Windows (PowerShell 5.1, then `pwsh`); on macOS/Linux it prints why the script cannot
|
||||
answer there and names the two portable commands instead — `node bin/rea-shim.mjs --shim-status` and
|
||||
`node scripts/setup.mjs --dry-run` — exiting non-zero rather than reporting a green it did not earn.
|
||||
|
||||
`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).
|
||||
|
||||
- **本插件**不会自动更新。本仓库有新版本时,用插件管理器再安装一次
|
||||
(`add github:czcdr/dsh-plugin-rea`),或覆盖/重新指向本地 `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.
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
# 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 **6.8.0**(本包钉子)只认 Ghidra **12.1.x**(12.2 判 `unsupported`,
|
||||
> Ghidra 系的反编译/分析工具整组消失)和 jadx 桥接 **0.7.1**(0.8.0 会在第一次调用就报
|
||||
> `Expected engine 0.7.1`,失败点在加载 APK 之前)。这两道闸门 2026-10-26 对着 6.8.0 重新核过,**一道都没动**。
|
||||
> JDK **21.0.13** 上游已发,但 Adoptium 仍没给 Windows 构建,本机无处可指,而且它只移除 4 个 eMudhra 锚里的
|
||||
> 3 个。所以现在的取舍是「工具全绿、CVE 未清」:Ghidra 12.1.4 的 CVE-2026-100504 / 100505 / 100503 / 113604
|
||||
> 仍在链上 —— **别拿这套环境分析来源不可信的样本**;JDK 也仍信任 eMudhra 锚。证据、缓解与重开条件:
|
||||
> [docs/update-review-2026-10-24.md](docs/update-review-2026-10-24.md) 与
|
||||
> [docs/update-review-2026-10-26.md](docs/update-review-2026-10-26.md)。
|
||||
>
|
||||
> **Ghidra 12.2 本身是可用的** —— 2026-10-26 实测:经 REA 自己的桥接,段表(9 段)、函数表(341 个)与 `entry`
|
||||
> 伪代码与 12.1.4 **逐字节相同**。挡住它的不是兼容性,是 REA 那条版本等值判定。如果你就是要用修掉 CVE 的那一版:
|
||||
> `node scripts/ghidra-gate-override.mjs --enable --version 12.2` 只改 vendored 副本里那一个常量、把改动记进
|
||||
> `<bundle>/ghidra-gate-override.json`,`--disable` 还原;重跑 setup 也会还原。它要求 **JDK 25+**
|
||||
> (`application.java.min=25`)。
|
||||
|
||||
> **`node scripts/setup.mjs` 要你自己跑 —— 装完跑一次,之后每次升级插件版本再跑一次。** 没有东西会替你跑:
|
||||
> 市场里的安装/更新只替换包,然后到此为止。从 **2.0.8** 起,市场升级能保证**那一行**继续可用(镜像启动器会
|
||||
> 转发执行新的那份),但它**不会**把 bundle 换到新的钉子 —— REA 与引擎仍是上一次 setup 时记下的版本。
|
||||
> 首次安装不跑:行以 `MODULE_NOT_FOUND` 退出,工具消失且没有任何提示;升级后不跑:仍旧跑旧 REA 且没有症状
|
||||
>(`check-updates.ps1` 会报出这条漂移)。若你开过 Ghidra 闸门覆盖,这一次 setup 会重新供给内置 REA、覆盖随之
|
||||
> 消失 —— 跑完再 `--enable` 恢复,或者接受回到钉住的那一版。完整规则见[更新](#更新--三件互相独立的事)。
|
||||
|
||||
> **首次安装与每次升级插件后,Web GUI 会显示一次操作指南。** 它会说明运行时是缺失、还是落后于本插件钉的版本,
|
||||
> 给出这次安装对应的完整 `node "<安装路径>/scripts/setup.mjs"` 命令,并按插件版本记住“已看过”。
|
||||
> 它只是提示:不会替你运行 setup,也不会改动 bundle。跑一次、重启 dsh,提示就会消失,直到下一个插件版本。
|
||||
|
||||
把上游 **[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.16
|
||||
```
|
||||
|
||||
> **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.23.4 || ^24.22.1 || >=26.12.1`(用 `node -v` 核对)。REA 在 6.8.0 抬了这条下限:钉住的 24.22.1
|
||||
现在是 24 线**最低**可接受版本,6.7.0 能吃下的 24.11–24.21 在这里会被拒。版本偏低不会在安装时失败,
|
||||
而是到 MCP 行启动时才失败 —— 表现就是"没有反应"。
|
||||
|
||||
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
|
||||
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
|
||||
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
|
||||
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
|
||||
|
||||
### 支持哪些 DSH 版本
|
||||
|
||||
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0-0"`,这就是插件市场读的那句话:有它,卡片显示
|
||||
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
|
||||
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
|
||||
|
||||
| 宿主版本 | 是否覆盖 |
|
||||
|---|---|
|
||||
| `0.1.7-rc.2` … `0.2.2-rc.1`(中间的 alpha 与 rc 全部在内,共 16 个 tag) | 是 |
|
||||
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
|
||||
|
||||
2.0.14 逐个核对了 2026-10-23 之后发布的 6 个 tag(`0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.2-alpha.2`、`0.2.1-rc.5`、
|
||||
`0.2.2-alpha.3`、`0.2.2-rc.1`):发布说明里没有触及本插件依赖的五处接口面(patch 方言、MCP 行 schema、
|
||||
宿主插件 API、客户端注入契约、市场 engines 判定)。唯一沾边的是 `0.2.2-rc.1` 的 Cordis Core 4.1.0 / Loader 1.1.0,
|
||||
**已在本机 0.2.2-rc.1 上实测**:`GET /dsh-plugin-rea/guide-state` 返回 200,`mcp__rea__*` 工具可用。
|
||||
|
||||
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2`、`dsh-v0.2.1-rc.2` 与
|
||||
`dsh-v0.2.2-rc.1` 上暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、
|
||||
`failOnStartupError`),并且都带 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
|
||||
|
||||
> 市场只读 `engines.dsh`(`dshmarket/lib/verify.js` 与 `discovery-compatibility.js` 现场取证),**不读
|
||||
> `engines.node`**。所以宿主自带的 Node 比本包 `engines.node` 的下限低时,市场不会拦你;只有把 npm 配成
|
||||
> `engine-strict=true` 的人会在 `npm install` 阶段被挡。运行期不受影响:REA 是用 `bundle.json` 里记录的
|
||||
> `paths.node` 启动的,不是宿主那个 Node。
|
||||
|
||||
### 机器上已有 REA 就直接用它
|
||||
|
||||
2.x 存在的意义是补上机器缺的东西。如果 REA 已经装好——`rea` 在 PATH 上,也就是 1.x 的形态——这一行就**直接跑它**,
|
||||
和 1.x 一模一样,不再做任何供给:
|
||||
|
||||
| 机器上的情况 | 这一行跑什么 |
|
||||
|---|---|
|
||||
| `rea` 在 PATH 上,且不比供给的版本旧 | 你自己那份(`rea mcp`),用你的 Node 和你的 PATH |
|
||||
| `rea` 在 PATH 上,但比供给的版本旧 | 供给出来的 bundle —— 旧的全局 CLI 会把工具数往回退(6.4.0 是 170 个,6.7.0 是 207 个,6.8.0 是 229 个) |
|
||||
| PATH 上没有 `rea` | 供给出来的 bundle,首次使用时自行供给 |
|
||||
|
||||
- `REA_BUNDLE_LAUNCH=auto|system|bundle` 覆盖这个判断(默认 `auto`);`REA_BUNDLE_SYSTEM_REA=<path>` 用来点名一个
|
||||
不在 PATH 上的可执行文件。
|
||||
- 探测用的是 CLI 自己的 `--version`,上限 8 秒:Windows 上的 `.cmd` 垫片要 ~2.4 秒才答得出来,所以这个检查每次启动
|
||||
只做一次,而且只在真的存在 `rea` 时才做。
|
||||
- `--shim-status` 会把判断结果打出来 —— `launch.mode`、`launch.reason`、`launch.systemRea`,`launch.rejected` 里
|
||||
写的是每个候选被跳过的原因(`"exit 7"`、`"inside the bundle"`、`"timeout"`)。
|
||||
- 被委派的子进程带着 `REA_BUNDLE_DELEGATED=1`,所以一个调用本 shim 的 wrapper 不会让它无限委派给自己。
|
||||
- **Windows 上强制结束 shim 不会带走被委派的 REA**:这里没有 job object,任务管理器或 supervisor 终止 shim 时,
|
||||
你自己的 `rea` 会继续占着这一行的管道(控制台 Ctrl+C 会发给整个进程组,那种情况下会一起停)。POSIX 上子进程
|
||||
在同一进程组,随终端一起退出。
|
||||
|
||||
## 配置 —— 不需要
|
||||
|
||||
发布的那一行**不含任何机器路径**:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: !!js process.execPath
|
||||
args:
|
||||
- !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || ((process.env.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || 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` 恰好被设置的机器上,这个错误完全不可见。
|
||||
- **主目录兜底与 `os.homedir()` 同序:`USERPROFILE` → `HOMEDRIVE`+`HOMEPATH` → `HOME`。** 中间那一对是
|
||||
Windows 服务账户拿到的形态(没有 `USERPROFILE`);跳过它,表达式会给这类账户算出 `/.dsh/...` —— 当前盘的根,
|
||||
而它们恰好读不到那个路径。只有四者全空(连主目录都没有的容器账户)才会落到 `/.dsh/...`,而
|
||||
`failOnStartupError: false` 会让它保持安静,症状只有「没有 `mcp__rea__*` 工具」。这类账户请显式设置 `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`。
|
||||
|
||||
覆盖是**整块替换** `config`(不做深合并):只写 `config: {env: …}` 会把 `command`、`args`、`serverName`
|
||||
一起删掉,这一行就起不来了。要么写完整的 config,要么改用 `REA_BUNDLE_LAUNCH=system`(见上),它不需要改 patch。
|
||||
|
||||
Ghidra 和 JDK **不是启动时从环境里读的**:`setup.mjs` 把它们的绝对路径记进 `bundle.json`,shim 再从那份
|
||||
记录导出 `GHIDRA_INSTALL_DIR` / `JAVA_HOME` —— 所以装在非标准路径也能用,不必动这一行。要采纳现成的安装,
|
||||
把 `GHIDRA_INSTALL_DIR` / `JAVA_HOME` 指向它并跑 `node scripts/setup.mjs --reuse`:manifest 里的探针会验证
|
||||
候选,不符合就回落到下载,**路径写错会在供给时就报出来**,而不是等到某次工具调用里才失效。
|
||||
|
||||
### 只保留一条 REA 注册
|
||||
|
||||
上游 REA 6.5.0 起可以自己注册进 DSH:
|
||||
|
||||
```
|
||||
rea setup --client dsh
|
||||
```
|
||||
|
||||
它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
|
||||
`id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
|
||||
duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
|
||||
|
||||
选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
|
||||
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
|
||||
`mcp__rea__*` 工具面相同。
|
||||
|
||||
shim 会在启动时提示冲突 —— `setup.mjs` 会把运行时的 DSH home 记进 `mirror.json`,镜像副本在 harness 抹掉
|
||||
`DSH_*` 之后就用这条记录去找那份文件;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
|
||||
|
||||
## 验证
|
||||
|
||||
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
|
||||
(`configuration` / `installation` / `headless` / `native_decompiler` / `java`),应当全为 `ok: true`。
|
||||
然后 `open_binary` 一个 PE、`binary_overview`、`procedure_pseudo_code`。
|
||||
|
||||
供给本身结束时跑三道检查:`rea doctor --json`(如实报告,并点名未满足项)、一次 **Ghidra 冒烟导入**
|
||||
(用 JDK 自带的 `bin/java` 跑 `rea analyze`,导不进就判本次供给失败;`--skip-smoke` 可关),以及
|
||||
**与 MCP 行完全相同的 shim 启动**。之所以要冒烟:`doctor` 只校验路径、版本与会用哪个 Java,**从不导入
|
||||
二进制**,桥接或 native addon 坏了,过去只能在第一次 MCP 调用超时时才撞见。
|
||||
|
||||
**每个新目标的第一次分析都很慢。** 本机实测:小 PE 36 s,6 MB 的 DLL 64 s —— 每次导入都要起 JVM 并跑
|
||||
Ghidra 的默认自动分析。如果大文件的第一次 `open_binary` → `list_segments` 超时,那是这个成本、不是引擎坏了:
|
||||
再调一次,已打开的目标会立刻返回(它保留着自己的临时项目)。
|
||||
|
||||
## 实测环境
|
||||
|
||||
作者机器,2026-10-13 / 14 / 16 / 19 / 23 / 26:
|
||||
|
||||
| 组件 | 版本 |
|
||||
|---|---|
|
||||
| REA(`rea-agents`) | **6.8.0**(229 个工具) |
|
||||
| DeepSeek Harness | desktop runtime **0.2.2-rc.1**(2.0.14 实测宿主;全范围见上面的版本表) |
|
||||
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
|
||||
| Ghidra | 12.1.4(本机另有 12.2 走闸门覆盖,见下) |
|
||||
| JDK | Temurin 21(供给)/ Amazon Corretto 25.0.5(宿主复用;主版本下限满足 pin `21`) |
|
||||
| Node.js | **24.22.1**(复用;REA engines:`^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`,ABI 137) |
|
||||
| 宿主 | Windows 11 x64(build 26200) |
|
||||
|
||||
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
|
||||
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.8.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 的首次运行。
|
||||
|
||||
2026-10-23 宿主 Node 换到 **24.22.1** 后,同一条链路重验过:vendor 里的 `rea-windows-x64.node` 与两个
|
||||
`conpty` addon 在 ABI 137 下加载成功;`conpty.node` 的 `binary_overview` 报出 10 个段 / 1178 个函数 /
|
||||
618 个字符串;`procedure_pseudo_code` 返回了真实反编译结果;vendor 里的 JDK `java -version` 回 `21.0.12.1`。
|
||||
唯一**第一次没成**的是 `list_segments`:它撞上 Ghidra 冷分析的 64 s 而超时 —— 这正是上面冒烟步与那条说明的由来。
|
||||
|
||||
2026-10-26 钉子换到 **REA 6.8.0** 后,同一条链路重验过:`setup.mjs --reuse` 报 `probe cli=6.8.0 tools=229`,
|
||||
末尾的 Ghidra 冒烟用 26 s 导入 `java.exe`;另起一次独立的 stdio MCP 握手回 `serverInfo {name: rea, version:
|
||||
6.8.0}`,协议 `2024-11-05`,**229 个工具 / 6 个 prompt**;apktool 仍答 `3.0.3`,`adb` 仍报 37.0.1 (1.0.41),
|
||||
盘上的 jadx jar 仍是 `6e5eacf5…` —— 正是 6.8.0 要求的那个值。把 6.8.0 自己的发布策略加载执行,得到
|
||||
`12.1.4 → verified`、`12.1.5 → compatible`、`12.2 → unsupported`,所以 Ghidra 原地不动。
|
||||
|
||||
2026-11-06(2.0.14,外部审计 18 项修复 + 宿主适配):**上游组件一个都没动**(REA 6.8.0 / Ghidra 12.1.4 / JDK 21 /
|
||||
apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1)。同一条链路重验:`--dry-run` 7 个组件全部按 pin 正确 skip(Ghidra 12.2
|
||||
的闸门覆盖与 JDK 25 都没有被误判成漂移)→ 强制重装 apktool 走完整 staging 路径(无残留 `.staging`/`.backup`,
|
||||
目录内容恰好等于 manifest 声明的两个文件)→ `--reuse` 全量 8 个组件 + `fridaBindings FRIDA_OK 3` →
|
||||
`probe cli=6.8.0 tools=229`、Ghidra 冒烟 28 s 导入 `java.exe`、shim 解析 6.8.0,退出 0。
|
||||
门禁:单测 **154 项**(151 过 / 0 失败 / 3 跳过)、`audit` 24/24 hash-pinned clean、
|
||||
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4、`host-compat` 16 releases、`verify-branch` ALL CHECKS PASSED。
|
||||
`check-updates.ps1` 从「假绿退出 0」变成「如实报告」,本机修完后退出 0。
|
||||
|
||||
## 边界与注意事项
|
||||
|
||||
- REA 在 6.8.0 有 229 个工具,每个会话的工具列表都会变长。
|
||||
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(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 跑的是 **`npm ci`**,复放随包发布的 `scripts/vendor-lock/`
|
||||
(`package.json` + `package-lock.json`):REA 对 zod 是精确锁 `"4.4.3"`,而 4.4.3 落在
|
||||
GHSA-mrhx-c7xc-m3w5 里,所以闭包通过 `overrides` 抬到 **zod 4.6.7 / minimatch 11.0.5 / glob 14.0.4**。
|
||||
`npm ci` 会逐 tarball 校验 sha512;若镜像重打包导致 `EINTEGRITY`,则退到「同版本 overrides + `npm install`」
|
||||
(同版本、字节不可复现,日志会明说)。落地后还会再读一次 `node_modules/zod/package.json` 的版本,低于 4.6.6 直接拒绝。
|
||||
`node scripts/check-dependency-advisories.mjs` 是离线闸门,已接进 CI。
|
||||
供给过程**刻意跳过所有 lifecycle 脚本** —— 唯一的例外是 **frida**:它的原生绑定
|
||||
(`build/frida_binding.node`)只在 install 脚本里才会放下,所以 setup 会点名这一个包跑 `npm rebuild frida`,
|
||||
然后用 `import('frida')` + 设备枚举验证(记录进 `bundle.json` 的 `components.reaAgents.frida`)。
|
||||
绑定缺失时 Frida 系工具不可用,但其余 200 多个工具不受影响,所以这一项报 `skip` 而不是让整次供给失败。
|
||||
这一步也是**唯一**用不上
|
||||
本插件自己的路由/代理回退的环节 —— 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.x 版本都如此。**
|
||||
>
|
||||
> `setup.mjs` 只对齐 **bundle**,不更新**插件包**:它读的是「当前已安装插件」的 manifest。装着 2.0.9 时跑 setup
|
||||
> 仍旧供给 6.5.0,哪怕已经有更新的一版发布。插件包只能从市场(或 `npm i -g dsh-plugin-rea@<版本>` / `file:` 重装)换,
|
||||
> **多跑几次 setup 也替代不了那一步**。
|
||||
>
|
||||
> **而且没有人会替你跑它。** 市场里的安装/更新只替换包,然后到此为止;第一次的 `setup.mjs`,以及之后每次升级后的
|
||||
> 那一次,都要你自己从插件目录跑。
|
||||
>
|
||||
> 1. **行执行的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 由 setup 复制,
|
||||
> 市场升级只换包、不换它,所以副本可能一直跑旧代码而**完全没有症状**。从 **2.0.8** 起,副本会与 `mirror.json`
|
||||
> 记录的启动器比对,不同就一跳转发执行插件里的那份(带 `REA_BUNDLE_SHIM_REEXEC` 守卫)。**2.0.0–2.0.7 的镜像
|
||||
> 做不到**,所以从它们升级仍需跑一次 `node scripts/setup.mjs`。`--shim-status` 会报 `mirror.stale`,
|
||||
> `check-updates.ps1` 会提示重跑。
|
||||
> 2. **镜像的 `scripts/setup.mjs` 同样是副本,而且它自己不会自愈**:从 bundle 根目录跑它,跑的是建 bundle 那一版
|
||||
> 的 setup,不是你刚装的这一版。要从**插件目录**跑一次来刷新这份副本。从 **2.0.9** 起,它会像 shim 一样剥掉
|
||||
> `mirror.json` / `bundle.json` 的 UTF-8 BOM —— 带 BOM 的记录以前在 setup 眼里等于「没有记录」,会把上一版
|
||||
> manifest 又镜一遍。
|
||||
|
||||
### 在市场里更新
|
||||
|
||||
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
|
||||
2.0.5**,在健康的 profile 上连续失败了三次:
|
||||
|
||||
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
||||
|
||||
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
|
||||
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
|
||||
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
||||
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
|
||||
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
|
||||
破坏,只是这次更新没有生效。
|
||||
|
||||
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
|
||||
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
|
||||
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
|
||||
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
|
||||
|
||||
### 从 1.x 升级
|
||||
|
||||
装上 2.x 后,**先跑一次供给**,再重启 dsh:
|
||||
|
||||
```
|
||||
node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
|
||||
```
|
||||
|
||||
为什么必须先跑:1.x 的行从 PATH 上启动 `rea mcp`,而 2.x 的行启动
|
||||
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` —— 那个文件**只有 `setup.mjs` 会创建**。升级后直接重启,
|
||||
该行会以 `MODULE_NOT_FOUND` 退出:harness 本身照常(`failOnStartupError: false` 是刻意的),但
|
||||
`mcp__rea__*` 工具会消失,而且**不会有任何修复提示** —— 会打提示的 shim 本身还不存在。跑完 setup 再启动,
|
||||
工具就回来了。
|
||||
|
||||
- **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
|
||||
- **行跑的是本插件启动器的「镜像副本」**:`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` 是 setup 复制过去的,
|
||||
所以市场升级只换包、不换行执行的那个文件 —— 直到再跑一次 setup。从 **2.0.8** 起,镜像会发现自己落后于已安装的
|
||||
插件,并转发执行插件里的那一份(一跳,带 `REA_BUNDLE_SHIM_REEXEC` 守卫),升级因此能生效。更早的版本里镜像会
|
||||
静默地停在旧代码;`--shim-status` 现在会报 `mirror.stale`,`check-updates.ps1` 也会提示重跑 setup。**从
|
||||
2.0.0–2.0.7 升级的话,升级后跑一次 `node scripts/setup.mjs`。**
|
||||
- **镜像的 `scripts/setup.mjs` 同样是副本。** 市场升级只换包、不换它,所以照着提示去跑
|
||||
`<DSH_HOME>/plugins/rea-bundle/scripts/setup.mjs` 时,跑的是建 bundle 那一版的 setup,而不是你刚装的这一版。
|
||||
从 **2.0.9** 起,shim 与 setup 都会剥掉 `mirror.json` / `bundle.json` 的 UTF-8 BOM(此前 setup 侧把带 BOM
|
||||
的记录读成「没有记录」,会把上一版 manifest 再镜一遍)。**从插件目录**跑一次 `node scripts/setup.mjs`,
|
||||
这份镜像副本也会一起刷新。
|
||||
- **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
|
||||
的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
|
||||
lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
|
||||
并写下让后续运行找到真源的记录。`check-updates.ps1` 会报告这个状态。
|
||||
- **供给的组件**:重跑 `node scripts/setup.mjs`,它会重新校验盘上的东西,只补缺失或不对的。
|
||||
版本钉在 `manifest/bundle.manifest.json`。
|
||||
- **上游 REA / Ghidra**:改清单里的钉子再重跑 setup。REA 对 Ghidra 有版本闸门(目前 12.1.x),
|
||||
所以先更新 REA。
|
||||
|
||||
`check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。`npm run check-updates` 在 Windows
|
||||
上转发给它(优先 PowerShell 5.1,其次 `pwsh`);在 macOS/Linux 上它会说明这个脚本为何在那里给不出答案,
|
||||
并列出两条可移植命令 —— `node bin/rea-shim.mjs --shim-status` 与 `node scripts/setup.mjs --dry-run` ——
|
||||
且以非零码退出,不会报一个自己没查出来的「全绿」。
|
||||
|
||||
## 还想用纯配置的 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 各自保留自己的许可。
|
||||
**本包不再分发任何上游代码或二进制** —— 供给过程是从各组件的官方源逐个拉取。
|
||||
@@ -0,0 +1,199 @@
|
||||
<#
|
||||
生成市场展示图(assets/screenshot-*.png)。
|
||||
可复现:改完文案后重跑本脚本即可,无需设计工具。
|
||||
|
||||
运行方式:
|
||||
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)
|
||||
|
||||
# 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 }
|
||||
|
||||
# 版本号从 manifest 读,不再手写成固定值。2.0.10 就踩过:pin 升到 6.7.0 之后,
|
||||
# 这个脚本还写着 6.5.0 —— 现有 PNG 没问题(它们是 6.5.0 时生成的),但下一次
|
||||
# 重绘会把旧版本号带进市场页,而且没有人会注意到,因为图片是二进制。
|
||||
# 工具数(229)不是 manifest 字段,是人工核对过的常量:升 pin 时一起核一遍。
|
||||
$reaPin = '6.8.0'
|
||||
$reaTools = 229
|
||||
$ghidraPin = '12.1.4'
|
||||
$nodePin = '24.22.1'
|
||||
# 截图是一次「实测」,这个日期必须是真的测量日;重绘时改成当天。
|
||||
# 2026-11-07:2.0.16 发布前的最后一次真机 --reuse(smoke 31 s 导入 java.exe)。
|
||||
$measuredOn = '2026-11-07'
|
||||
# 宿主版本没有 manifest 字段可读 —— 它不是本包的属性。写死但必须跟着实测更新:
|
||||
# 2.0.16 的实测宿主是 desktop runtime 0.2.2-rc.1,之前这里写的是 0.2.0-rc.2,
|
||||
# 于是市场页在展示一个两个月前的宿主。
|
||||
$hostMeasured = '0.2.2-rc.1'
|
||||
$manifestFile = Join-Path (Split-Path -Parent $PSScriptRoot) 'manifest\bundle.manifest.json'
|
||||
if (Test-Path $manifestFile) {
|
||||
try {
|
||||
$pinDoc = Get-Content $manifestFile -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($pinDoc.pins.reaAgents) { $reaPin = [string]$pinDoc.pins.reaAgents }
|
||||
if ($pinDoc.pins.ghidra) { $ghidraPin = [string]$pinDoc.pins.ghidra }
|
||||
if ($pinDoc.pins.node) { $nodePin = [string]$pinDoc.pins.node }
|
||||
} catch {
|
||||
Write-Warning "manifest 里的钉子读不到($($_.Exception.Message));用脚本内的默认值。"
|
||||
}
|
||||
}
|
||||
# 实测版本未必等于钉:本机若开过 Ghidra 闸门覆盖(scripts/ghidra-gate-override.mjs),
|
||||
# 跑的是 REA 未验证的那一版。图上写实测的那个,并标出钉值 —— 否则图会谎报「跑过什么」。
|
||||
$ghidraMeasured = $ghidraPin
|
||||
$overrideRecord = $null
|
||||
if ($env:DSH_HOME) {
|
||||
$overrideFile = Join-Path $env:DSH_HOME 'plugins\rea-bundle\ghidra-gate-override.json'
|
||||
if (Test-Path $overrideFile) {
|
||||
try {
|
||||
$overrideRecord = Get-Content $overrideFile -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($overrideRecord.requestedVersion) { $ghidraMeasured = "$($overrideRecord.requestedVersion)(闸门覆盖,钉 $ghidraPin)" }
|
||||
} catch { $overrideRecord = $null }
|
||||
}
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$W = 1280; $H = 720
|
||||
$bg = [System.Drawing.Color]::FromArgb(255, 248, 250, 252)
|
||||
$ink = [System.Drawing.Color]::FromArgb(255, 15, 23, 42)
|
||||
$muted = [System.Drawing.Color]::FromArgb(255, 100, 116, 139)
|
||||
$accent = [System.Drawing.Color]::FromArgb(255, 37, 99, 235)
|
||||
$accent2 = [System.Drawing.Color]::FromArgb(255, 22, 163, 74)
|
||||
$warn = [System.Drawing.Color]::FromArgb(255, 220, 38, 38)
|
||||
$line = [System.Drawing.Color]::FromArgb(255, 203, 213, 225)
|
||||
|
||||
$fTitle = New-Object System.Drawing.Font('Microsoft YaHei UI', 30, [System.Drawing.FontStyle]::Bold)
|
||||
$fSub = New-Object System.Drawing.Font('Microsoft YaHei UI', 15)
|
||||
$fBox = New-Object System.Drawing.Font('Microsoft YaHei UI', 15, [System.Drawing.FontStyle]::Bold)
|
||||
$fBody = New-Object System.Drawing.Font('Microsoft YaHei UI', 13)
|
||||
$fSmall = New-Object System.Drawing.Font('Microsoft YaHei UI', 11)
|
||||
$fChip = New-Object System.Drawing.Font('Microsoft YaHei UI', 13)
|
||||
|
||||
function New-Canvas {
|
||||
$bmp = New-Object System.Drawing.Bitmap $W, $H
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
|
||||
$g.Clear($bg)
|
||||
return @($bmp, $g)
|
||||
}
|
||||
function Draw-Text($g, $text, $font, $color, $x, $y, $w = 0) {
|
||||
$br = New-Object System.Drawing.SolidBrush $color
|
||||
if ($w -gt 0) { $r = New-Object System.Drawing.RectangleF $x, $y, $w, 400; $g.DrawString($text, $font, $br, $r) }
|
||||
else { $g.DrawString($text, $font, $br, $x, $y) }
|
||||
$br.Dispose()
|
||||
}
|
||||
function Draw-Box($g, $x, $y, $w, $h, $fill, $border, $radius = 12) {
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$d = $radius * 2
|
||||
$path.AddArc($x, $y, $d, $d, 180, 90); $path.AddArc($x + $w - $d, $y, $d, $d, 270, 90)
|
||||
$path.AddArc($x + $w - $d, $y + $h - $d, $d, $d, 0, 90); $path.AddArc($x, $y + $h - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$fb = New-Object System.Drawing.SolidBrush $fill
|
||||
$g.FillPath($fb, $path)
|
||||
$p = New-Object System.Drawing.Pen $border, 1.6
|
||||
$g.DrawPath($p, $path)
|
||||
$fb.Dispose(); $p.Dispose(); $path.Dispose()
|
||||
}
|
||||
function Draw-Arrow($g, $x1, $y1, $x2, $y2, $color, $width = 2.4) {
|
||||
$p = New-Object System.Drawing.Pen $color, $width
|
||||
$p.CustomEndCap = New-Object System.Drawing.Drawing2D.AdjustableArrowCap 6, 7
|
||||
$g.DrawLine($p, $x1, $y1, $x2, $y2)
|
||||
$p.Dispose()
|
||||
}
|
||||
function Draw-Line($g, $x1, $y1, $x2, $y2, $color, $width = 2.4) {
|
||||
$p = New-Object System.Drawing.Pen $color, $width
|
||||
$g.DrawLine($p, $x1, $y1, $x2, $y2)
|
||||
$p.Dispose()
|
||||
}
|
||||
function Save-Png($bmp, $g, $path) { $g.Dispose(); $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); $bmp.Dispose() }
|
||||
|
||||
# ---------------------------------------------------------------- 图 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 '第三方适配 · 自身不做逆向分析 · 自供给 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($reaPin 暴露 $reaTools)`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 / 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-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.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
|
||||
Draw-Arrow $g 910 ($rowY + 40) 958 ($rowY + 40) $muted
|
||||
|
||||
# Evidence 回传:引擎 → 结果框 → 会话(左侧折返)
|
||||
Draw-Box $g 360 404 850 84 $([System.Drawing.Color]::FromArgb(255,254,249,195)) $([System.Drawing.Color]::FromArgb(255,202,138,4))
|
||||
Draw-Text $g 'Evidence 回到会话(置信度 observed / derived / inferred + 明确的 limitations)' $fBox $ink 380 416
|
||||
Draw-Text $g '反编译器输出的是伪代码与汇编 —— 不是原始源码,也不保证文本等价' $fBody $muted 380 448
|
||||
Draw-Arrow $g 1085 340 1085 400 $muted
|
||||
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 $reaPin($reaTools 个工具) + DSH 桌面运行时 $hostMeasured + Ghidra $ghidraMeasured + Node ${nodePin}(Windows 11 x64,$measuredOn)" $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')
|
||||
|
||||
# ---------------------------------------------------------------- 图 2:范围与边界
|
||||
$r = New-Canvas; $bmp = $r[0]; $g = $r[1]
|
||||
Draw-Text $g '上游 REA 的分析范围(插件原样提供)' $fTitle $ink 60 44
|
||||
Draw-Text $g '下列目标类型来自上游 README「可以分析什么」;本插件不改变、不扩展这些能力' $fSub $muted 62 96
|
||||
|
||||
$chips = @(
|
||||
'原生二进制:伪代码/汇编/字符串/符号/调用与引用',
|
||||
'JavaScript / Electron:模块、导入、source map、路由、IPC',
|
||||
'.NET 程序集:元数据、CIL、原生依赖声明',
|
||||
'网站:页面结构、脚本、网络观察、截图',
|
||||
'Android APK:清单、类、反编译的方法、引用',
|
||||
'Android 设备:adb 包/进程/日志/截图/文件传输',
|
||||
'JEB 项目:单元、工件摘要、类型与方法伪代码',
|
||||
'Android 资源:解码清单、字符串表、语言区域',
|
||||
'固件:区域、提取结果、转交原生分析',
|
||||
'软件包与资源:清单、摘要、plist、Apple bundle',
|
||||
'进程行为:终端输出、交互、退出、文件系统观察',
|
||||
'离线 ELF 布局:节、段、符号/重定位、防护候选项',
|
||||
'EVM 字节码:分派选择器、字节偏移、可变性',
|
||||
'已记录的 Linux 崩溃:note、线程寄存器、映射候选',
|
||||
'已保存的网络捕获:HAR / mitmproxy 记录'
|
||||
)
|
||||
$cx = 60; $cy = 146; $cw = 570; $ch = 40; $gap = 8
|
||||
for ($i = 0; $i -lt $chips.Count; $i++) {
|
||||
$col = $i % 2; $rowi = [math]::Floor($i / 2)
|
||||
$x = $cx + $col * ($cw + 20); $y = $cy + $rowi * ($ch + $gap)
|
||||
Draw-Box $g $x $y $cw $ch $([System.Drawing.Color]::White) $line 8
|
||||
Draw-Text $g $chips[$i] $fChip $ink ($x + 14) ($y + 9)
|
||||
}
|
||||
|
||||
$by = $cy + 8 * ($ch + $gap) + 16
|
||||
Draw-Box $g 60 $by 1190 92 $([System.Drawing.Color]::FromArgb(255,254,242,242)) $warn
|
||||
Draw-Text $g '不能完整还原源代码' $fBox $warn 80 ($by + 10)
|
||||
Draw-Text $g '返回的是伪代码/汇编/反编译方法,不是原始源码;标识符、类型、注释、宏与模板、构建脚本与资源多不可恢复;不保证文本等价或可直接编译。' $fBody $ink 80 ($by + 38)
|
||||
Draw-Text $g '优化(内联/重排)、strip、混淆、加壳与 VM 保护会让可读性更差。要真正的源码,请走上游仓库、厂商授权或官方 SDK。' $fBody $muted 80 ($by + 60)
|
||||
|
||||
Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 686
|
||||
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-2.png')
|
||||
|
||||
Write-Host "written: $(Join-Path $OutDir 'screenshot-1.png')"
|
||||
Write-Host "written: $(Join-Path $OutDir 'screenshot-2.png')"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Facts behind the one-shot "run setup" guide.
|
||||
*
|
||||
* The Web GUI needs three things the browser cannot know by itself:
|
||||
*
|
||||
* - which version of THIS package is installed (the guide re-arms on upgrade),
|
||||
* - which REA version the manifest pins,
|
||||
* - which REA version the machine's bundle actually holds.
|
||||
*
|
||||
* The third one is the point. A plugin upgrade replaces the package and stops
|
||||
* there; `scripts/setup.mjs` is what moves the runtime to the new pin. So the
|
||||
* honest trigger is not "the page was never shown" but "the bundle on disk does
|
||||
* not match the pin this package ships". `computeGuideState` answers exactly
|
||||
* that, and stays a pure function of three values so it can be tested without a
|
||||
* harness, a browser or a provisioned bundle.
|
||||
*
|
||||
* `index.mjs` serves the result as GET /dsh-plugin-rea/guide-state; `client.js`
|
||||
* renders the guide once per plugin version. This file also runs standalone:
|
||||
*
|
||||
* node bin/rea-guide.mjs --json [--bundle <bundle.json> | --root <dir>]
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** Route served by index.mjs and read by client.js. One definition, two readers. */
|
||||
export const GUIDE_ROUTE = '/dsh-plugin-rea/guide-state'
|
||||
|
||||
/** Package root (the parent of bin/), used to build a copy-pasteable command. */
|
||||
export const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/**
|
||||
* Read a JSON file the way the shim and setup.mjs do.
|
||||
*
|
||||
* PowerShell 5.1's `>` and `Out-File` write UTF-16 LE by default, so a record a
|
||||
* user hand-edited can be UTF-16 while a record setup wrote is UTF-8. Treating
|
||||
* only UTF-8 as valid made a present bundle look absent, which is the same
|
||||
* silent re-provision the shim's reader had to be fixed for in 2.0.11.
|
||||
*/
|
||||
export function readJsonFile(file) {
|
||||
const bytes = readFileSync(file)
|
||||
if (bytes.length >= 2) {
|
||||
const [first, second] = bytes
|
||||
if (first === 0xff && second === 0xfe) {
|
||||
return JSON.parse(bytes.subarray(2).toString('utf16le'))
|
||||
}
|
||||
if (first === 0xfe && second === 0xff) {
|
||||
const swapped = Buffer.from(bytes.subarray(2))
|
||||
swapped.swap16()
|
||||
return JSON.parse(swapped.toString('utf16le'))
|
||||
}
|
||||
}
|
||||
let text = bytes.toString('utf8')
|
||||
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
/** `$DSH_HOME` when set, otherwise the same `<home>/.dsh` fallback the shim uses. */
|
||||
export function dshHome(env = process.env) {
|
||||
const explicit = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
|
||||
return explicit !== '' ? resolve(explicit) : join(homedir(), '.dsh')
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundle root, by the same order scripts/setup.mjs (`defaultBundleRoot`),
|
||||
* bin/rea-shim.mjs (`bundleRoot`) and scripts/rea-bundle-bootstrap.ps1 use:
|
||||
* `REA_BUNDLE_ROOT` first, then `<DSH_HOME>/plugins/rea-bundle`.
|
||||
*
|
||||
* Reading only DSH_HOME here made a machine that provisions into a custom root
|
||||
* look `missing` to the guide — the one screen whose entire job is to decide
|
||||
* "does setup still have to run" — so it told an already-provisioned machine to
|
||||
* provision again. The guide must answer for the root the rest of the system
|
||||
* agrees on.
|
||||
*/
|
||||
export function bundleRoot(env = process.env) {
|
||||
const override = typeof env.REA_BUNDLE_ROOT === 'string' ? env.REA_BUNDLE_ROOT.trim() : ''
|
||||
return override !== '' ? resolve(override) : join(dshHome(env), 'plugins', 'rea-bundle')
|
||||
}
|
||||
|
||||
/** Where setup.mjs records what it provisioned. */
|
||||
export function defaultBundlePath(root = bundleRoot()) {
|
||||
return join(root, 'bundle.json')
|
||||
}
|
||||
|
||||
/** Windows paths are case-insensitive; comparing `resolve`d strings is not. */
|
||||
function samePath(left, right) {
|
||||
const a = resolve(left)
|
||||
const b = resolve(right)
|
||||
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact command a user should run, quoted so a path with spaces survives.
|
||||
*
|
||||
* `--reuse` is not cosmetic. The guide appears mostly after an upgrade, when the
|
||||
* runtime is already on the machine: without the flag, setup re-fetches the
|
||||
* ~570 MB JDK + Ghidra pair even though the machine can answer for both, which is
|
||||
* both slow and the opposite of what "align the runtime" means. On a first
|
||||
* install the flag costs nothing — every candidate is probed, and a machine with
|
||||
* none downloads exactly as before.
|
||||
*
|
||||
* `--root` is added whenever the guide looked somewhere other than the default
|
||||
* `<DSH_HOME>/plugins/rea-bundle` (a `REA_BUNDLE_ROOT` override, or an explicit
|
||||
* `--bundle`/`--root`). Without it the copy-pasted command would provision the
|
||||
* default root and leave the one the guide just complained about untouched.
|
||||
*/
|
||||
export function setupCommandFor(pluginRoot = PLUGIN_ROOT, root = null) {
|
||||
const command = `node ${quotePath(join(pluginRoot, 'scripts', 'setup.mjs'))} --reuse`
|
||||
return root ? `${command} --root ${quotePath(root)}` : command
|
||||
}
|
||||
|
||||
/** Quote a path so a copy-pasted command survives spaces and quotes. */
|
||||
function quotePath(value) {
|
||||
return `"${String(value).replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin comparison, mirroring scripts/pin-match.ps1.
|
||||
*
|
||||
* A pin that carries a dot is a version: `6.7.0` matches only `6.7.0`.
|
||||
* A pin that is a bare major is a family: `21` also matches `21.0.12.1+1`.
|
||||
* The distinction exists because pins like `jdk: 21` legitimately resolve to a
|
||||
* patched build, while `reaAgents: 6.7.0` must not silently accept `6.7.0-rc.1`
|
||||
* or `6.5.0`.
|
||||
*/
|
||||
export function reaPinMatches(installed, pinned) {
|
||||
const have = String(installed ?? '').trim()
|
||||
const want = String(pinned ?? '').trim()
|
||||
if (have === '' || want === '') return false
|
||||
if (have === want) return true
|
||||
if (want.includes('.')) return false
|
||||
// A bare-major pin ("21") is a FLOOR, not a family prefix — the same rule
|
||||
// `scripts/setup.mjs` applies when it decides a recorded component still
|
||||
// satisfies the manifest. The prefix form accepted 21.0.12.1+1 and rejected
|
||||
// 25.0.5, so the guide said "stale, run setup" on a machine that setup had just
|
||||
// called "already provisioned (25.0.5) — major 25 satisfies the pin's major 21".
|
||||
const haveMajor = Number.parseInt(have.split('.')[0], 10)
|
||||
const wantMajor = Number.parseInt(want, 10)
|
||||
if (Number.isFinite(haveMajor) && Number.isFinite(wantMajor)) return haveMajor >= wantMajor
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the guide's state.
|
||||
*
|
||||
* Returned `state`:
|
||||
* - `missing` no bundle.json — a first install that never ran setup
|
||||
* - `unreadable` the file exists but cannot be parsed
|
||||
* - `unknown` parsed, but records no reaAgents version
|
||||
* - `stale` a REA version is recorded and it is not the pin
|
||||
* - `ok` recorded version matches the pin
|
||||
*
|
||||
* `needsSetup` is true for everything except `ok`: a guide that stays silent on
|
||||
* an unreadable record is a guide that hides the exact case it exists for.
|
||||
*/
|
||||
export function computeGuideState(options = {}) {
|
||||
const {
|
||||
pluginVersion = '',
|
||||
pinnedRea = '',
|
||||
bundleRoot: rootOption = null,
|
||||
bundlePath: bundlePathOption = null,
|
||||
pluginRoot = PLUGIN_ROOT,
|
||||
} = options
|
||||
|
||||
// Resolve the root the way setup/shim/bootstrap do, then honour a path a caller
|
||||
// handed in directly (tests and `--bundle` pass a file, not a root).
|
||||
const root = rootOption ? resolve(rootOption) : bundleRoot()
|
||||
const bundlePath = bundlePathOption ? resolve(bundlePathOption) : defaultBundlePath(root)
|
||||
const rootInEffect = bundlePathOption ? dirname(bundlePath) : root
|
||||
// Only the default root needs no flag: setup.mjs resolves DSH_HOME itself, so
|
||||
// the command stays shorter there and identical to what it always printed.
|
||||
const setupRoot = samePath(rootInEffect, join(dshHome(), 'plugins', 'rea-bundle')) ? null : rootInEffect
|
||||
|
||||
const base = {
|
||||
pluginVersion: String(pluginVersion),
|
||||
pinnedRea: String(pinnedRea),
|
||||
installedRea: null,
|
||||
provisionedAt: null,
|
||||
state: 'missing',
|
||||
needsSetup: true,
|
||||
bundleRoot: rootInEffect,
|
||||
bundlePath,
|
||||
setupCommand: setupCommandFor(pluginRoot, setupRoot),
|
||||
detail: '',
|
||||
}
|
||||
|
||||
if (!existsSync(bundlePath)) {
|
||||
return { ...base, detail: 'no bundle.json on this machine' }
|
||||
}
|
||||
|
||||
let record
|
||||
try {
|
||||
record = readJsonFile(bundlePath)
|
||||
} catch (error) {
|
||||
return {
|
||||
...base,
|
||||
state: 'unreadable',
|
||||
detail: `bundle.json could not be read: ${error && error.message ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
const installed = record && record.versions ? record.versions.reaAgents : record && record.reaAgents
|
||||
const provisionedAt = record && typeof record.provisionedAt === 'string' ? record.provisionedAt : null
|
||||
|
||||
if (typeof installed !== 'string' || installed.trim() === '') {
|
||||
return {
|
||||
...base,
|
||||
provisionedAt,
|
||||
state: 'unknown',
|
||||
detail: 'bundle.json records no reaAgents version',
|
||||
}
|
||||
}
|
||||
|
||||
const matched = reaPinMatches(installed, pinnedRea)
|
||||
return {
|
||||
...base,
|
||||
installedRea: installed,
|
||||
provisionedAt,
|
||||
state: matched ? 'ok' : 'stale',
|
||||
needsSetup: !matched,
|
||||
detail: matched
|
||||
? `bundle reaAgents ${installed} matches pin ${pinnedRea}`
|
||||
: `bundle reaAgents ${installed} does not match pin ${pinnedRea}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Read this package's own version and REA pin, for the route and the CLI. */
|
||||
export function localPackageFacts(pluginRoot = PLUGIN_ROOT) {
|
||||
const pkg = readJsonFile(join(pluginRoot, 'package.json'))
|
||||
let pinnedRea = ''
|
||||
try {
|
||||
const manifest = readJsonFile(join(pluginRoot, 'manifest', 'bundle.manifest.json'))
|
||||
pinnedRea = manifest && manifest.pins && typeof manifest.pins.reaAgents === 'string'
|
||||
? manifest.pins.reaAgents
|
||||
: ''
|
||||
} catch {
|
||||
/* A missing manifest is the audit's problem, not the guide's. */
|
||||
}
|
||||
return { pluginVersion: pkg && typeof pkg.version === 'string' ? pkg.version : '', pinnedRea }
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const asJson = argv.includes('--json')
|
||||
const flagValue = (name) => {
|
||||
const index = argv.indexOf(name)
|
||||
return index !== -1 && argv[index + 1] ? argv[index + 1] : null
|
||||
}
|
||||
// `--root` mirrors setup.mjs; `--bundle` names the record file itself and is
|
||||
// what the old CLI offered. Both are overrides of the same resolution chain.
|
||||
const bundlePath = flagValue('--bundle')
|
||||
const root = flagValue('--root')
|
||||
const facts = localPackageFacts()
|
||||
const state = computeGuideState({
|
||||
pluginVersion: facts.pluginVersion,
|
||||
pinnedRea: facts.pinnedRea,
|
||||
...(bundlePath ? { bundlePath } : {}),
|
||||
...(root ? { bundleRoot: root } : {}),
|
||||
})
|
||||
|
||||
if (asJson) {
|
||||
process.stdout.write(`${JSON.stringify(state, null, 2)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
process.stdout.write([
|
||||
`plugin ${state.pluginVersion}`,
|
||||
`pin ${state.pinnedRea}`,
|
||||
`bundle ${state.bundlePath}`,
|
||||
`state ${state.state}${state.needsSetup ? ' (setup needed)' : ''}`,
|
||||
`reaAgents ${state.installedRea || '-'}`,
|
||||
state.provisionedAt ? `provisionedAt ${state.provisionedAt}` : '',
|
||||
'',
|
||||
state.needsSetup ? `run: ${state.setupCommand}` : 'nothing to do',
|
||||
].filter((line) => line !== '').join('\n') + '\n')
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
||||
main(process.argv.slice(2))
|
||||
}
|
||||
+1415
File diff suppressed because it is too large.
Load diff
+452
-67
@@ -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,120 +21,504 @@ 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'
|
||||
# 判绿计数器。「注意」必须计入最终结论:2.0.13 里 pin 漂移只写一行 Warn、不进
|
||||
# $actions,末尾照样打印「无需动作」,而那一行正是 pin 升级后唯一面向用户的信号。
|
||||
$warnings = 0
|
||||
$badCount = 0
|
||||
# bundle 分支只在 bundle.json 存在时赋这三个值;结论处直接拿它们比较,所以先给 0
|
||||
# ($null -eq 0 是 $false,会让「根本没供给」也走进「另有 N 项采纳」那条绿分支)。
|
||||
$drift = 0
|
||||
$unrecorded = 0
|
||||
$adopted = 0
|
||||
function Head($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
|
||||
function Ok($t) { Write-Host " [OK] $t" -ForegroundColor Green }
|
||||
function Warn($t) { Write-Host " [注意] $t" -ForegroundColor Yellow }
|
||||
function Bad($t) { Write-Host " [动作] $t" -ForegroundColor Red }
|
||||
function Warn($t) { $script:warnings++; Write-Host " [注意] $t" -ForegroundColor Yellow }
|
||||
# 「上游」不是本机状态:registry 查不通、GitHub 最新版不是 12.1.x、本机没有 rea 可跑
|
||||
# doctor —— 这些既不能证明 bundle 一致,也不构成待办,所以单独一类,不参与判绿。
|
||||
function Note($t) { Write-Host " [上游] $t" -ForegroundColor DarkYellow }
|
||||
function Bad($t) { $script:badCount++; Write-Host " [动作] $t" -ForegroundColor Red }
|
||||
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 }
|
||||
}
|
||||
|
||||
# 版本比对规则放在独立文件里:这一行是用户唯一能看到的「pin 变了,该重跑 setup」
|
||||
# 信号,2.0.10 之前它假绿过(6.5.0 被判成与 pin 6.7.0 一致),所以需要一条不依赖
|
||||
# 网络、不依赖 profile 的回归测试 —— verify-branch.ps1 直接 dot-source 这个文件。
|
||||
$pinMatchFile = Join-Path $PSScriptRoot 'scripts\pin-match.ps1'
|
||||
if (Test-Path $pinMatchFile) {
|
||||
. $pinMatchFile
|
||||
} else {
|
||||
# 目录不完整(旧拷贝、手工搬走了一个文件):退化为精确比对。
|
||||
# 宁可多报一次「重跑 setup」,也不能再假绿。
|
||||
function Test-PinMatch {
|
||||
param([string]$Installed, [string]$Pinned)
|
||||
return [string]::Equals(([string]$Installed).Trim(), ([string]$Pinned).Trim(), [System.StringComparison]::Ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
# 判断 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
|
||||
# Resolve the home unconditionally: the profile section below needs it whether or
|
||||
# not REA_BUNDLE_ROOT was set, and computing it only inside the fallback branch
|
||||
# left it $null — `Join-Path $null 'profiles'` throws, so the section degraded to
|
||||
# "no profile" on exactly the machines that use the override.
|
||||
$homeEarly = $env:DSH_HOME
|
||||
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
||||
if (-not $bundleRootEarly) {
|
||||
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
|
||||
}
|
||||
$bundleState = $null
|
||||
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
|
||||
if (Test-Path $bundleJsonEarly) {
|
||||
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
|
||||
}
|
||||
$bundleReaPackageDir = $null
|
||||
$bundleReaEntry = $null
|
||||
if ($bundleState -and $bundleState.paths.reaEntry) {
|
||||
$bundleReaEntry = [string]$bundleState.paths.reaEntry
|
||||
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
|
||||
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
|
||||
}
|
||||
|
||||
if (-not $ReaPackageDir) {
|
||||
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
|
||||
$ReaPackageDir = $bundleReaPackageDir
|
||||
} else {
|
||||
$npmRoot = $null
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
|
||||
}
|
||||
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
|
||||
}
|
||||
}
|
||||
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
|
||||
if (-not $ProfileDir) {
|
||||
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
|
||||
else {
|
||||
# Same home rule the bundle uses everywhere else: DSH_HOME, then ~/.dsh.
|
||||
# Hardcoding $env:USERPROFILE here was a second algorithm in the same script,
|
||||
# so on a custom DSH_HOME the profile section reported "no profile found"
|
||||
# while the rest of the run had already resolved that home correctly.
|
||||
$profiles = @(Get-ChildItem (Join-Path $homeEarly 'profiles') -Directory -ErrorAction SilentlyContinue)
|
||||
$ProfileDir = if ($profiles.Count -gt 0) { $profiles[0].FullName } else { Join-Path $homeEarly 'profiles\desktop' }
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- 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)" }
|
||||
catch { Note "查询失败 $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) {
|
||||
Note '未能取到上游最新版本(两个 registry 都查不通?)—— 已跳过版本比对'
|
||||
} else {
|
||||
Note '本地没读到已安装版本(见上面那条)—— 已跳过版本比对'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- 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 { Note "无法解析 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"
|
||||
$props = Join-Path $gd 'Ghidra\application.properties'
|
||||
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)" }
|
||||
|
||||
$line = (Select-String -Path $props -Pattern '^application\.version=' | Select-Object -First 1).Line
|
||||
if (-not $line) {
|
||||
# "not 12.1.x" for an unreadable version sent the reader looking for a
|
||||
# newer Ghidra when the file is simply incomplete — a different problem
|
||||
# with a different fix.
|
||||
Bad 'Ghidra\application.properties 缺少 application.version 行 —— 文件不完整(重装该组件)'
|
||||
} else {
|
||||
$v = $line -replace '^application\.version=', ''
|
||||
Ok "已安装版本: $v"
|
||||
if ($v -notmatch '^12\.1\.') {
|
||||
# 本机 --enable 过的闸门覆盖是**有记录的**偏离:REA 的闸门确实钉在 12.1.x,
|
||||
# 但这台机器故意跑了更新的版本。把它报成 [动作] 等于劝人撤掉覆盖,而
|
||||
# override 记录已经在下方的 bundle 分支单独列出 —— 所以这里只说明,不计数。
|
||||
$gateOverride = $null
|
||||
$gateOverrideFile = Join-Path $bundleRootEarly 'ghidra-gate-override.json'
|
||||
if (Test-Path $gateOverrideFile) {
|
||||
try { $gateOverride = Get-Content $gateOverrideFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $gateOverride = $null }
|
||||
}
|
||||
if ($gateOverride -and [string]$gateOverride.requestedVersion -eq $v) {
|
||||
Info "REA 闸门钉在 12.1.x;当前 $v 是本机 --enable 的覆盖(不是 pin 漂移,记录见 bundle 分支)"
|
||||
} else {
|
||||
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' }
|
||||
} catch { Warn "GitHub API 查询失败(可跳过): $($_.Exception.Message)" }
|
||||
if ($rel.tag_name -notmatch 'Ghidra_12\.1') { Note '官方已有更新;升级前先确认 REA 是否接受该版本' }
|
||||
} catch { Note "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)
|
||||
Note 'rea 不在 PATH —— 回退到 node <包内入口>'
|
||||
} else { Note 'rea 与 node 均不可用 —— 跳过引擎自检' }
|
||||
}
|
||||
if ($reaCmd) {
|
||||
try {
|
||||
$raw = & $reaCmd @reaArgs doctor --format json 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
# Not a machine-state warning.
|
||||
#
|
||||
# `rea doctor` exits non-zero while ANY optional capability is unconfigured:
|
||||
# Hopper, IDA, Kuna, the skill copies, the five client registrations. A
|
||||
# perfectly provisioned bundle therefore still exits 1, and treating that as
|
||||
# a warning made every healthy machine report "需要动作" — a false red, which
|
||||
# is the mirror image of the false green this script was just fixed for. The
|
||||
# state that actually matters is parsed out of the output below (the provider
|
||||
# inspection and its checks), and THAT drives Ok/Bad.
|
||||
Note "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 { Note "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 '本插件不会自动更新:本仓库(github.com/czcdr/dsh-plugin-rea)有新版本时,用插件管理器重新安装一次即可'
|
||||
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) { Note '未读到 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 { Note "profile package.json 解析失败: $($_.Exception.Message)" }
|
||||
} else { Note "找不到 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(机器相关值应放在这里,不随插件发布)'
|
||||
}
|
||||
} else { Warn "找不到 profile: $profPkg" }
|
||||
}
|
||||
|
||||
# ------------------------------------------------- 一体包(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
|
||||
# 采纳的本机运行时(reused)不按 pin 精确比对,但也不能算进「全绿」。
|
||||
$adopted = 0
|
||||
# 本机 Ghidra 闸门覆盖(scripts/ghidra-gate-override.mjs):盘上跑的是 REA 未
|
||||
# 验证的 Ghidra 版本。这是一次**有记录的、可还原的**偏离,不是「忘了对齐」——
|
||||
# 不这样标注的话,这行会劝人去重跑 setup,而重跑正好会把覆盖悄悄抹掉。
|
||||
$ghidraOverride = $null
|
||||
$overrideFile = Join-Path (Split-Path $bundleJson -Parent) 'ghidra-gate-override.json'
|
||||
if (Test-Path $overrideFile) {
|
||||
try { $ghidraOverride = Get-Content $overrideFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $ghidraOverride = $null }
|
||||
}
|
||||
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 ($name -eq 'ghidra' -and $ghidraOverride -and [string]$ghidraOverride.requestedVersion -eq $installed) {
|
||||
Info " ghidra = $installed(本机覆盖 Ghidra 闸门:REA 未验证这一版;要用 pin 就 --disable 再重跑 setup)"
|
||||
$adopted++
|
||||
continue
|
||||
}
|
||||
if (-not $installed) {
|
||||
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
|
||||
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
|
||||
$unrecorded++
|
||||
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
|
||||
continue
|
||||
}
|
||||
$compared++
|
||||
$component = $null
|
||||
if ($state.components -and ($state.components.PSObject.Properties.Name -contains $name)) {
|
||||
$component = $state.components.$name
|
||||
}
|
||||
# 采纳的本机 Node(setup --reuse)不是 bundle 安装的版本,拿它跟「下载版本」
|
||||
# 精确比对必然误报;它要满足的是 engines。采纳时 setup 跑过同一份探针,这里
|
||||
# 再用同一个 Test-NodeEngines 对一次账,而不是把它当成「与 pin 一致」。
|
||||
if ($name -eq 'node' -and $component -and $component.reused -eq $true) {
|
||||
$engines = [string]$manifest.pins.nodeEngines
|
||||
$enginesOk = Test-NodeEngines $installed $engines
|
||||
if ($enginesOk -eq $true) {
|
||||
Info " node = $installed(采纳本机运行时;pin $pinned 指下载版本,满足 engines)"
|
||||
$adopted++
|
||||
} elseif ($enginesOk -eq $false) {
|
||||
Warn "node 盘上为 $installed(采纳的本机运行时),不满足 REA engines $engines —— 换一个 Node 或重跑 setup"
|
||||
$drift++
|
||||
} else {
|
||||
Warn "node 盘上为 $installed,但 engines 无法解析($engines)—— 未比对"
|
||||
$unrecorded++
|
||||
}
|
||||
continue
|
||||
}
|
||||
# 2.0.10 修掉的假绿:此前对所有组件都比「主版本前缀」,
|
||||
# "6.5.0".StartsWith("6.") 为真 → bundle 还在 6.5.0 时报「与 pin 6.7.0 一致」,
|
||||
# 而这行正是 pin 升级后唯一面向用户的「该重跑 setup」信号。apktool 同理
|
||||
# (3.0.9 对 pin 3.0.3 也假绿)。主版本前缀只对 pin 本身只有主版本号时成立(jdk 21)。
|
||||
if (Test-PinMatch -Installed $installed -Pinned $pinned) { Info " $name = $installed(pin $pinned)" }
|
||||
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
|
||||
}
|
||||
# 采纳(--reuse 的本机运行时)和本机覆盖(ghidra-gate-override)是**有记录的**
|
||||
# 偏离,不是「忘了对齐」:它们既不是 drift,也不该变成一条警告把每台正常机器
|
||||
# 都判成需要动作。所以这里用 [上游] 如实说明「并非全部精确等于 pin」。
|
||||
if ($drift -eq 0 -and $unrecorded -eq 0 -and $adopted -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
|
||||
elseif ($drift -eq 0 -and $unrecorded -eq 0) { Note "共比对 $compared 项:其中 $adopted 项为采纳的本机运行时 / 本机覆盖(见上),其余与 pin 一致" }
|
||||
elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
|
||||
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
|
||||
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
|
||||
}
|
||||
$mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
|
||||
if (Test-Path $mirrored) {
|
||||
Ok 'MCP 行指向的 shim 已就位'
|
||||
# The row launches this COPY, so a plugin upgrade that does not re-run
|
||||
# setup leaves the old launcher in place — visible only as "the new
|
||||
# version behaves like the old one". Compare it with the checkout the
|
||||
# mirror record names.
|
||||
$mirrorRecord = Join-Path $bundleRoot 'mirror.json'
|
||||
if (Test-Path $mirrorRecord) {
|
||||
try {
|
||||
$record = Get-Content $mirrorRecord -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$pluginShim = if ($record.pluginRoot) { Join-Path $record.pluginRoot 'bin\rea-shim.mjs' } else { $null }
|
||||
if ($pluginShim -and (Test-Path $pluginShim)) {
|
||||
# `Get-FileHash` is missing on hosted Windows runners and in any host
|
||||
# running PowerShell in a restricted language mode. That is a
|
||||
# "cannot verify" answer, not an "inconsistent" one: reporting it as
|
||||
# [注意] made such a machine look like it needed action, which is the
|
||||
# same false red this script was just fixed for in the other
|
||||
# direction. Ask whether the comparison is even possible first.
|
||||
if (-not (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {
|
||||
Note '本机没有 Get-FileHash,无法比对镜像 shim 是否最新 —— 跳过这一项'
|
||||
} else {
|
||||
$same = (Get-FileHash $mirrored -Algorithm SHA256).Hash -eq (Get-FileHash $pluginShim -Algorithm SHA256).Hash
|
||||
if ($same) { Ok '镜像 shim 与插件一致(行跑的就是这个版本)' }
|
||||
else { Warn "镜像 shim 落后于插件($($record.pluginRoot))—— 行仍在跑旧副本;重跑 node scripts/setup.mjs 让它换成新版本" }
|
||||
}
|
||||
} else {
|
||||
Note "mirror.json 指向的插件目录已不存在($($record.pluginRoot))—— 无法比对镜像 shim 是否最新"
|
||||
}
|
||||
} catch { Note "mirror.json 解析失败: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
|
||||
# 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
|
||||
# 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
|
||||
# 而且它已写下的 lock 会让重试被抑制 6 小时。
|
||||
$mirroredManifest = Join-Path $bundleRoot 'manifest\bundle.manifest.json'
|
||||
if (Test-Path $mirroredManifest) { Ok '镜像副本的 manifest 已就位' }
|
||||
else { Bad "镜像副本缺少 manifest: $mirroredManifest —— 请从插件目录重跑一次 node scripts/setup.mjs(旧镜像无法自愈)" }
|
||||
} else {
|
||||
Warn "尚未供给:$bundleJson 不存在 —— 运行 node scripts/setup.mjs(或直接装插件,行会自行触发)"
|
||||
}
|
||||
$pins = @()
|
||||
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
|
||||
if ($name -ne 'nodeEngines') { $pins += "$name=$($manifest.pins.$name)" }
|
||||
}
|
||||
Info ('manifest 钉子: ' + ($pins -join ', '))
|
||||
} catch { Warn "bundle 状态解析失败: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 汇总
|
||||
Head '结论'
|
||||
if ($actions.Count -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
|
||||
else { $actions | ForEach-Object { Bad $_ } }
|
||||
# 判绿条件:没有待办、没有 pin 漂移、没有未记录版本、没有「注意」、没有「动作」行。
|
||||
# 此前只看 $actions,于是「jdk 盘上为 25.0.5,manifest 已钉 21」这种漂移照样以
|
||||
# 「无需动作:整条链路与上游保持一致」收尾、退出码 0 —— 假绿正是这么产生的。
|
||||
$reasons = @()
|
||||
if ($actions.Count -gt 0) { $reasons += "待办 $($actions.Count) 项" }
|
||||
if ($drift -gt 0) { $reasons += "pin 漂移 $drift 项" }
|
||||
if ($unrecorded -gt 0) { $reasons += "未记录版本 $unrecorded 项" }
|
||||
if ($warnings -gt 0) { $reasons += "提醒 $warnings 条" }
|
||||
if ($badCount -gt 0) { $reasons += "问题 $badCount 处" }
|
||||
Write-Host ''
|
||||
Write-Host '提示:DSH 侧更新(应用升级可能改变 MCP 客户端配置结构)后,请重跑本脚本并调用一次 mcp__rea__binary_session 验证。' -ForegroundColor Gray
|
||||
if ($reasons.Count -eq 0) {
|
||||
if ($drift -eq 0 -and $unrecorded -eq 0 -and $adopted -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
|
||||
else { Ok "无需动作:能比对的项目均与 pin 一致(另有 $adopted 项采纳 / 本机覆盖,见上)" }
|
||||
exit 0
|
||||
}
|
||||
# 先列待办,最后打印结论:末行必须永远是判绿/判红的结论,而不是它上面某条动作。
|
||||
$actions | ForEach-Object { Bad $_ }
|
||||
Write-Host (' [结论] 需要动作:' + ($reasons -join ';') + '(逐条见上)') -ForegroundColor Red
|
||||
exit 1
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* dsh-plugin-rea — the one-shot "run setup" guide.
|
||||
*
|
||||
* Why this exists: a market install or update replaces the package and stops
|
||||
* there. The runtime (Node, REA, a JDK, Ghidra) lives in the profile's
|
||||
* `plugins/rea-bundle` and is only moved by `scripts/setup.mjs`, which nobody
|
||||
* runs for the user. Skipping it fails twice, silently: on a first install the
|
||||
* MCP row exits MODULE_NOT_FOUND with no tools, and after an upgrade the old
|
||||
* REA keeps answering. The README says so; nobody reads the README.
|
||||
*
|
||||
* So the guide appears once per plugin version — first install, and again after
|
||||
* every upgrade — states which side is out of date, and shows the exact command
|
||||
* for THIS install (the path depends on where the market unpacked the package).
|
||||
* "Later" and "I ran it" both record the version and stop the reminder until
|
||||
* the next upgrade. It is a notice, not a runner: this file never spawns setup.
|
||||
*
|
||||
* Facts come from GET /dsh-plugin-rea/guide-state (index.mjs → bin/rea-guide.mjs),
|
||||
* which compares the recorded bundle against this package's pin. When that route
|
||||
* is absent (a host without a webServer, an older build) the guide stays silent
|
||||
* rather than guessing.
|
||||
*/
|
||||
window.__ModuleLoader__.load({
|
||||
id: "dsh-plugin-rea",
|
||||
factory: (require) => {
|
||||
const module = { exports: {} };
|
||||
const exports = module.exports;
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||
|
||||
const react = require("react");
|
||||
const h = react.createElement;
|
||||
const { useEffect, useState } = react;
|
||||
|
||||
const name = "dsh-plugin-rea";
|
||||
const inject = ["slots"];
|
||||
const ROUTE = "/dsh-plugin-rea/guide-state";
|
||||
const ACK_KEY = "dsh-plugin-rea.guide.ack";
|
||||
|
||||
const TEXT = {
|
||||
zh: {
|
||||
badge: "需要一次性配置",
|
||||
title: "还要跑一次 setup",
|
||||
leadMissing: "插件装好了,但运行时不在这个包里。",
|
||||
leadStale: "插件更新了,机器上的运行时还是旧的。",
|
||||
leadUnknown: "运行时记录不完整,无法确认它和本插件是否一致。",
|
||||
body: "setup.mjs 负责把 Node、REA、JDK、Ghidra 下载或对齐到本机。市场里的安装和更新只替换插件包,不会替你跑它。",
|
||||
statusMissing: "未检测到运行时 —— 这是一次全新的安装。",
|
||||
statusStale: "插件 {v}:运行时里是 REA {installed},本插件钉 {pin}。",
|
||||
statusUnknown: "运行时记录里没有 REA 版本。",
|
||||
statusUnreadable: "运行时记录读不出来(bundle.json 无法解析)。",
|
||||
commandLabel: "在任意目录运行(路径已指向这次安装的插件)",
|
||||
copy: "复制命令",
|
||||
copied: "已复制",
|
||||
copyFailed: "复制失败,请手动选中",
|
||||
done: "我已运行",
|
||||
later: "稍后",
|
||||
hint: "跑完重启 dsh。这一步只对齐运行时,不会更新插件包本身;升级后同样再跑一次。",
|
||||
noteSmoke: "setup 结束时会用 JDK 自带的 java 跑一次真实分析来验证引擎 —— 那里 30–60 秒没有任何输出是正常的。",
|
||||
noteLock: "若它报 “another provisioning run holds …”,说明后台已有一个 setup 在跑,等它结束再试即可。",
|
||||
noteOverride: "若你开过 Ghidra 闸门覆盖(让本机跑 REA 未验证的 Ghidra),setup 会重新供给内置 REA,那层覆盖随之消失 —— 跑完执行 node scripts/ghidra-gate-override.mjs --enable --version 12.2 恢复。",
|
||||
},
|
||||
en: {
|
||||
badge: "ONE-TIME SETUP",
|
||||
title: "One more step: run setup",
|
||||
leadMissing: "The plugin is installed. Its runtime is not.",
|
||||
leadStale: "The plugin was updated; the runtime on this machine is still the old one.",
|
||||
leadUnknown: "The runtime record is incomplete, so it cannot be compared with this package.",
|
||||
body: "setup.mjs downloads or aligns Node, REA, a JDK and Ghidra on this machine. A market install or update only replaces the package — nothing runs it for you.",
|
||||
statusMissing: "No runtime found — this is a first install.",
|
||||
statusStale: "Plugin {v}: the runtime holds REA {installed}, this package pins {pin}.",
|
||||
statusUnknown: "The runtime record has no REA version.",
|
||||
statusUnreadable: "The runtime record cannot be read (bundle.json did not parse).",
|
||||
commandLabel: "Run it from any directory (the path points at this install)",
|
||||
copy: "Copy command",
|
||||
copied: "Copied",
|
||||
copyFailed: "Copy failed — select it manually",
|
||||
done: "I ran it",
|
||||
later: "Later",
|
||||
hint: "Restart dsh afterwards. This only aligns the runtime; it does not update the plugin package — run it again after the next upgrade.",
|
||||
noteSmoke: "It finishes by importing a small file to prove the analysis engine works — 30–60 s with no output there is normal.",
|
||||
noteLock: "If it reports “another provisioning run holds …”, a setup is already running in the background; wait for it.",
|
||||
noteOverride: "If you had enabled the Ghidra gate override (running a Ghidra REA has not verified), setup re-provisions the bundled REA and the patch goes with it — run node scripts/ghidra-gate-override.mjs --enable --version 12.2 afterwards to restore it.",
|
||||
},
|
||||
};
|
||||
|
||||
function pickLang() {
|
||||
try {
|
||||
const declared = String((document.documentElement && document.documentElement.lang) || "");
|
||||
if (/^zh/i.test(declared)) return "zh";
|
||||
if (declared) return "en";
|
||||
const nav = String((typeof navigator !== "undefined" && navigator.language) || "");
|
||||
return /^zh/i.test(nav) ? "zh" : "en";
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
function readAck() {
|
||||
try {
|
||||
return window.localStorage.getItem(ACK_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeAck(version) {
|
||||
try {
|
||||
window.localStorage.setItem(ACK_KEY, String(version));
|
||||
} catch {
|
||||
/* private mode: the guide simply reappears next time */
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the selection path */
|
||||
}
|
||||
try {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
area.setAttribute("readonly", "");
|
||||
area.style.position = "fixed";
|
||||
area.style.top = "-9999px";
|
||||
area.style.opacity = "0";
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(area);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLine(state, t) {
|
||||
if (state.state === "missing") return t.statusMissing;
|
||||
if (state.state === "stale") {
|
||||
return t.statusStale
|
||||
.replace("{v}", String(state.pluginVersion || "?"))
|
||||
.replace("{installed}", String(state.installedRea || "?"))
|
||||
.replace("{pin}", String(state.pinnedRea || "?"));
|
||||
}
|
||||
if (state.state === "unreadable") return t.statusUnreadable;
|
||||
return t.statusUnknown;
|
||||
}
|
||||
|
||||
function leadLine(state, t) {
|
||||
if (state.state === "missing") return t.leadMissing;
|
||||
if (state.state === "stale") return t.leadStale;
|
||||
return t.leadUnknown;
|
||||
}
|
||||
|
||||
const maskStyle = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 2400,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
background: "rgba(6, 8, 12, 0.55)",
|
||||
pointerEvents: "auto",
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
width: "min(620px, 100%)",
|
||||
maxHeight: "82vh",
|
||||
overflow: "auto",
|
||||
padding: "22px 24px 18px",
|
||||
borderRadius: 14,
|
||||
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.14))",
|
||||
background: "var(--dsw-alias-bg-elevated, #171a20)",
|
||||
color: "var(--dsw-alias-label-primary, #e9e9e9)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,0.45)",
|
||||
lineHeight: 1.6,
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const codeStyle = {
|
||||
margin: "10px 0 0",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 9,
|
||||
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.06))",
|
||||
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.10))",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
||||
fontSize: 12.5,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
userSelect: "all",
|
||||
};
|
||||
|
||||
const buttonBase = {
|
||||
appearance: "none",
|
||||
border: "1px solid var(--dsw-alias-border-secondary, rgba(255,255,255,0.18))",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
borderRadius: 8,
|
||||
padding: "6px 12px",
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
};
|
||||
|
||||
const primaryButton = Object.assign({}, buttonBase, {
|
||||
background: "var(--dsw-alias-label-brand, #4f8cff)",
|
||||
borderColor: "var(--dsw-alias-label-brand, #4f8cff)",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
function Guide() {
|
||||
const [state, setState] = useState(null);
|
||||
const [closed, setClosed] = useState(false);
|
||||
const [copyLabel, setCopyLabel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const ctrl = typeof AbortController === "function" ? new AbortController() : null;
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
if (ctrl) ctrl.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
fetch(ROUTE, {
|
||||
signal: ctrl ? ctrl.signal : undefined,
|
||||
credentials: "same-origin",
|
||||
headers: { accept: "application/json" },
|
||||
})
|
||||
.then((response) => (response && response.ok ? response.json() : null))
|
||||
.then((data) => {
|
||||
if (alive && data && typeof data === "object") setState(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* no route (headless host, older build): stay silent */
|
||||
})
|
||||
.finally(() => clearTimeout(timer));
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
if (ctrl) ctrl.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lang = pickLang();
|
||||
const t = TEXT[lang];
|
||||
const acked = state ? readAck() === String(state.pluginVersion) : true;
|
||||
const visible = !!state && !closed && state.needsSetup === true && !acked;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return undefined;
|
||||
const onKey = (event) => {
|
||||
if (event && (event.key === "Escape" || event.key === "Esc")) {
|
||||
writeAck(state.pluginVersion);
|
||||
setClosed(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [visible, state]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
writeAck(state.pluginVersion);
|
||||
setClosed(true);
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
copyText(String(state.setupCommand || "")).then((ok) => {
|
||||
setCopyLabel(ok ? t.copied : t.copyFailed);
|
||||
setTimeout(() => setCopyLabel(""), 2500);
|
||||
});
|
||||
};
|
||||
|
||||
const node = h(
|
||||
"div",
|
||||
{ style: maskStyle, role: "dialog", "aria-modal": "true", "aria-label": t.title },
|
||||
h(
|
||||
"div",
|
||||
{ style: cardStyle },
|
||||
h(
|
||||
"div",
|
||||
{ style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 } },
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.08em",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.08))",
|
||||
color: "var(--dsw-alias-label-secondary, #a9a9a9)",
|
||||
},
|
||||
},
|
||||
t.badge,
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{ style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #a9a9a9)" } },
|
||||
`dsh-plugin-rea ${state.pluginVersion || ""}`,
|
||||
),
|
||||
),
|
||||
h("h2", { style: { margin: "0 0 6px", fontSize: 19, fontWeight: 700 } }, t.title),
|
||||
h(
|
||||
"p",
|
||||
{ style: { margin: "0 0 4px", color: "var(--dsw-alias-label-secondary, #b6b6b6)" } },
|
||||
leadLine(state, t),
|
||||
),
|
||||
h("p", { style: { margin: "0 0 10px" } }, t.body),
|
||||
|
||||
h(
|
||||
"p",
|
||||
{
|
||||
style: {
|
||||
margin: "0 0 6px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
background: "var(--dsw-alias-bg-secondary, rgba(255,255,255,0.05))",
|
||||
color: "var(--dsw-alias-label-secondary, #c2c2c2)",
|
||||
fontSize: 13,
|
||||
},
|
||||
},
|
||||
statusLine(state, t),
|
||||
),
|
||||
|
||||
h("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #9a9a9a)" } }, t.commandLabel),
|
||||
h("pre", { style: codeStyle }, h("code", null, String(state.setupCommand || ""))),
|
||||
|
||||
h(
|
||||
"p",
|
||||
{ style: { margin: "10px 0 8px", fontSize: 12.5, color: "var(--dsw-alias-label-secondary, #9a9a9a)" } },
|
||||
t.hint,
|
||||
),
|
||||
h(
|
||||
"ul",
|
||||
{
|
||||
style: {
|
||||
margin: "0 0 16px",
|
||||
paddingLeft: 18,
|
||||
fontSize: 12.5,
|
||||
color: "var(--dsw-alias-label-secondary, #9a9a9a)",
|
||||
},
|
||||
},
|
||||
h("li", null, t.noteSmoke),
|
||||
h("li", null, t.noteLock),
|
||||
h("li", null, t.noteOverride),
|
||||
),
|
||||
|
||||
h(
|
||||
"div",
|
||||
{ style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } },
|
||||
h("button", { type: "button", style: primaryButton, onClick: onCopy }, copyLabel || t.copy),
|
||||
h("button", { type: "button", style: buttonBase, onClick: dismiss }, t.done),
|
||||
h("span", { style: { flex: 1 } }),
|
||||
h("button", { type: "button", style: buttonBase, onClick: dismiss }, t.later),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const dom = require("react-dom");
|
||||
if (dom && typeof dom.createPortal === "function" && typeof document !== "undefined" && document.body) {
|
||||
return dom.createPortal(node, document.body);
|
||||
}
|
||||
} catch {
|
||||
/* no react-dom: the overlay still renders in place */
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function apply(ctx) {
|
||||
if (!ctx || !ctx.slots || typeof ctx.slots.register !== "function") return;
|
||||
const mount = () =>
|
||||
ctx.slots.register(
|
||||
{ name: "shell.overlay", id: "rea-setup-guide", order: 70 },
|
||||
() => h(Guide),
|
||||
);
|
||||
if (typeof ctx.slots.inject === "function") ctx.slots.inject("shell.overlay", mount);
|
||||
else mount();
|
||||
}
|
||||
|
||||
exports.name = name;
|
||||
exports.inject = inject;
|
||||
exports.apply = apply;
|
||||
return module.exports;
|
||||
},
|
||||
});
|
||||
+50
-24
@@ -1,26 +1,36 @@
|
||||
# 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.
|
||||
# * The home fallback mirrors Node's own `os.homedir()`: USERPROFILE, then
|
||||
# HOMEDRIVE+HOMEPATH (the shape a service account gets), then HOME. An
|
||||
# expression that checked only USERPROFILE and HOME produced `/.dsh/...` —
|
||||
# the root of the current drive — for a service account, which is neither
|
||||
# where setup mirrors the shim nor a path anything can read.
|
||||
#
|
||||
# 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 +38,27 @@
|
||||
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.HOMEDRIVE || '') + (process.env.HOMEPATH || '')) || 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
|
||||
|
||||
# The Web GUI's one-shot setup guide. This row loads only the package entry
|
||||
# (index.mjs), which serves a read-only GET /dsh-plugin-rea/guide-state and
|
||||
# mounts nothing when the host has no webServer. It does not launch REA, does
|
||||
# not provision, and never runs setup for the user — the guide only reports
|
||||
# that the recorded bundle does not match the pin this package ships.
|
||||
- id: rea-guide
|
||||
name: 'dsh-plugin-rea'
|
||||
@@ -0,0 +1,223 @@
|
||||
# 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.8.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.8.0) and `rea doctor --json` reported 229 tools (measured again after the 6.8.0 pin; 6.7.0 reported 207). `healthy` is `false` on a bundle-only install: 6.8.0 also checks the host's own setup (Hopper, IDA registration, Kuna, `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.
|
||||
|
||||
A fourth, deliberately passive piece sits beside the three: the **guide**
|
||||
(`client.js` + `index.mjs` → `bin/rea-guide.mjs`). It exists because the setup
|
||||
step is mandatory but invisible — a market install or update replaces the
|
||||
package and stops there, and a skipped setup fails silently in both directions
|
||||
(no tools on a first install, the old REA after an upgrade). The patch mounts one
|
||||
extra row for the package entry, which serves a read-only
|
||||
`GET /dsh-plugin-rea/guide-state`; the client half renders that verdict once per
|
||||
plugin version and remembers the dismissal in `localStorage`. It is a notice:
|
||||
it never spawns setup, never downloads, and never writes `bundle.json`. The row
|
||||
is mounted only when the host has a `webServer`, so headless and ACP hosts load
|
||||
the plugin exactly as before.
|
||||
|
||||
```
|
||||
<dsh home>/plugins/rea-bundle/
|
||||
bundle.json # machine-local truth: resolved paths + versions + hashes
|
||||
mirror.json # which plugin checkout the mirrored files came from
|
||||
cache/ # downloaded archives (resumable, checksum-verified)
|
||||
vendor/ # unpacked payloads (node, jdk, ghidra, apktool, jadx, adb)
|
||||
logs/setup.log
|
||||
```
|
||||
|
||||
## 4. Why a shim instead of an `!!js` expression
|
||||
|
||||
`!!js` can inject values into the row at load time, but it cannot:
|
||||
download, verify, unpack, run a readiness probe, or report progress. It also
|
||||
couples the published patch to expressions that must parse on every platform.
|
||||
A shim is boring, testable, and can report an action's clear remediation text
|
||||
through `failOnStartupError: false` without breaking the harness.
|
||||
|
||||
## 5. Control flow
|
||||
|
||||
```
|
||||
dsh starts
|
||||
└─ MCP row: node <bundle root>/bin/rea-shim.mjs (setup mirrors it there)
|
||||
├─ mirrored copy differs from the installed plugin → re-exec that copy, once
|
||||
├─ a working `rea` on PATH is at least as new as the bundle
|
||||
│ → exec it (the 1.x contract)
|
||||
├─ bundle.json present & components verify → exec the provisioned REA
|
||||
├─ missing, auto-provision allowed → start setup detached, print
|
||||
│ remediation, exit non-zero
|
||||
└─ missing, auto-provision refused → print exact command, exit
|
||||
```
|
||||
|
||||
The shim resolves its bundle root from **its own location**, not from `DSH_HOME`:
|
||||
the harness strips every `DSH_*` name from an MCP child's environment, so with a
|
||||
custom `DSH_HOME` the shim would look in `~/.dsh`, find nothing, and start a second
|
||||
provisioning run there.
|
||||
|
||||
`setup` stages, each idempotent and individually verifiable:
|
||||
|
||||
1. `detect` — host platform/arch, DSH home, plugin dir, existing installs
|
||||
2. `resolve` — manifest entry per component; candidate URLs are ordered
|
||||
3. `fetch` — download to cache with resume + retries; skip if checksum matches
|
||||
4. `verify` — sha256 against the manifest (and vendor checksum file when present)
|
||||
5. `unpack` — platform-native extraction, atomic into `vendor/`
|
||||
6. `wire` — write `bundle.json`, export the environment variables REA reads
|
||||
7. `mirror` — copy the launcher to the path the published patch computes
|
||||
8. `probe` — `rea doctor --json`, then launch the **mirrored** shim exactly as the row would
|
||||
9. `report` — human summary + exit code; every failure names its remediation
|
||||
|
||||
Two notes on stages that exist because their absence was a bug:
|
||||
|
||||
- **7 (mirror).** The patch points at `<bundle root>/bin/rea-shim.mjs`, and the
|
||||
plugin itself is not guaranteed to live anywhere the patch can compute. Setup
|
||||
therefore copies the launcher (and the setup script it re-invokes) into the
|
||||
bundle root. Without this, provisioning could succeed and the row would still
|
||||
fail — and the "install the plugin first, it provisions itself" path could
|
||||
never start, because the shim that does the provisioning would not exist at
|
||||
the path being launched. The copy list is exactly what a mirrored run reads
|
||||
back, **including `manifest/bundle.manifest.json`**: a mirrored `setup.mjs`
|
||||
resolves its plugin root to the bundle root, so `loadManifest()` reads
|
||||
`<bundle>/manifest/bundle.manifest.json` — a file only this stage can create.
|
||||
It also writes `<bundle>/mirror.json`, naming the checkout the files came
|
||||
from: the bundle outlives any particular checkout location, and without that
|
||||
record a later mirrored run can only copy the bundle onto itself (provisioning
|
||||
the manifest it was mirrored with, forever) and its remediation text would
|
||||
point the operator at a copy that cannot start.
|
||||
- **The row's `!!js` scalar is quoted.** A bare scalar containing `? ` and `: `
|
||||
is not one scalar: YAML reads it as an explicit key/value pair, so the loader
|
||||
receives a mapping where an expression belongs — measured against the
|
||||
harness's own dialect, `args[0]` became `{"[object Object]": "..."}` and the
|
||||
row could not launch at all. Any future expression with a ternary needs the
|
||||
quotes; `audit-manifest.mjs` rejects the bare form.
|
||||
- **Launch mode (2.0.7).** 2.x exists to supply a runtime a machine lacks, so a
|
||||
machine that already has one should not be provisioned. Before touching bundle
|
||||
state the shim asks whether `rea` is on PATH and answers `--version`; if it
|
||||
does, and its version is not older than `bundle.json`'s `versions.reaAgents`,
|
||||
the row execs that install exactly as the 1.x row did. The probe is asynchronous
|
||||
and bounded (8 s: a `.cmd` shim needs ~2.4 s), runs with
|
||||
`REA_BUNDLE_DELEGATED=1` and `REA_BUNDLE_AUTO_PROVISION=0` so a candidate that
|
||||
wraps this shim cannot recurse or start an 800 MB run, and kills its whole tree
|
||||
on timeout. `REA_BUNDLE_LAUNCH=auto|system|bundle` overrides the choice; an
|
||||
unorderable pair (a prerelease on either side) keeps the bundle.
|
||||
- **Self-healing mirror (2.0.8).** The row launches a COPY, and a market upgrade
|
||||
runs no setup, so without this the copy keeps executing the code it was made
|
||||
from — invisibly, because it still works. The copy therefore compares its own
|
||||
hash with `<mirror.json.pluginRoot>/bin/rea-shim.mjs` and re-execs that file
|
||||
when they differ: one hop, guarded by `REA_BUNDLE_SHIM_REEXEC`, and only from a
|
||||
mirror (a checkout ships `cordis.patch.yml`, so it never hops into another
|
||||
copy). `mirror.json` carries `shimSha256`, `pluginVersion` and `dshHome` —
|
||||
the last one because the harness strips `DSH_*` from an MCP child, so the
|
||||
duplicate-registration check would otherwise read `~/.dsh` on a machine with a
|
||||
custom home. `--shim-status` reports `mirror.stale` instead of hopping.
|
||||
- **3 (fetch).** Route order is deliberate and environment-dependent: direct
|
||||
first, then the system proxy, and on Windows the .NET route ahead of both
|
||||
because it follows the GitHub release-asset redirect that the Node routes
|
||||
cannot. Neither "always direct" nor "always proxy" is correct — measured on
|
||||
the development machine, the local proxy answers github.com but returns 403
|
||||
for nodejs.org and 404 for dl.google.com, while the reverse also happens.
|
||||
|
||||
## 6. What is genuinely impossible on Windows
|
||||
|
||||
Declared up front so no release note over-promises. These are **upstream
|
||||
host gates**, not configuration gaps:
|
||||
|
||||
| Capability | Gate |
|
||||
| --- | --- |
|
||||
| Hopper provider | macOS only |
|
||||
| IDA provider | requires a user-owned IDA + `REA_IDA_MCP_CONFIG`; REA never launches it |
|
||||
| firmware `binwalk` / `unblob` | Linux only |
|
||||
| Wakaru JS recovery | Linux x64 only |
|
||||
| pwntools paths (ELF layout, recorded crash) | Linux x64 only |
|
||||
| pwndbg / gdb core context | Linux/macOS |
|
||||
| `mitmdump` native capture decode | Linux only (HAR needs no engine) |
|
||||
| Apple toolchain (Mach-O, plist, asset catalog, LLDB) | macOS only |
|
||||
| process capture (`capture_process_scenario`) | Linux/macOS PTY |
|
||||
|
||||
Everything else — native Ghidra analysis, static JS/Electron, .NET, archives,
|
||||
Android (JADX/apktool/adb), browser scenarios, EVM, HAR — is reachable on
|
||||
Windows and is what this bundle targets to complete.
|
||||
|
||||
## 7. Component matrix (Windows x64 first)
|
||||
|
||||
| Component | Needed for | Provisioned as |
|
||||
| --- | --- | --- |
|
||||
| Node runtime | running REA at all | reuse a compatible existing node, else portable `node.exe` |
|
||||
| `rea-agents` | the MCP server itself | vendored copy under `vendor/node_modules/rea-agents` |
|
||||
| Temurin JDK 21/25 | Ghidra headless + JADX bridge | portable JDK zip (full JDK, not a JRE) |
|
||||
| Ghidra 12.1.x | native analysis | official release zip, extracted |
|
||||
| apktool | Android resource decode | `apktool.bat` + jar in one directory |
|
||||
| jadx-headless-mcp jar | Android class/method decompile | pinned 0.7.1 jar (REA validates its classes) |
|
||||
| Android platform-tools | `adb` device tools | official zip |
|
||||
| a Chromium-family browser | browser/CDP scenarios | reuse an installed Edge/Chrome; never downloaded |
|
||||
| `ilspycmd` (optional) | .NET decompilation | optional; only wired when present |
|
||||
|
||||
Ghidra itself is **not** redistributed inside the plugin package: the release is
|
||||
~570 MB, which no npm package can carry. It is fetched from the official release
|
||||
and checksum-verified, or adopted from an existing install with `--reuse`.
|
||||
|
||||
## 8. Honest limits of this approach
|
||||
|
||||
- npm's per-package limits (unpacked/compressed size) rule out shipping the JDK
|
||||
and Ghidra inside the tarball. "One plugin install" therefore means *one
|
||||
install plus an automatic, checksum-verified fetch*, not a 600 MB npm package.
|
||||
- First run is network- and time-bound: the full payload is roughly 800 MB
|
||||
(Ghidra 570 MB, JDK 205 MB) plus the REA package. Progress is reported and the
|
||||
download is resumable; a pre-seeded cache makes it offline-capable.
|
||||
- Version pins age. `manifest/bundle.manifest.json` carries the pins, and
|
||||
`check-updates.ps1` gains a bundle section so drift is reported rather than
|
||||
discovered later.
|
||||
@@ -0,0 +1,404 @@
|
||||
# 宿主兼容性:DSH 0.2.1-rc.3 → 0.2.2-rc.1(2026-11-06)
|
||||
|
||||
`package.json` 的 `engines.dsh = ">=0.1.7-rc.2 <0.3.0-0"` 是市场判"兼容/未知"的唯一依据,
|
||||
`scripts/host-compat.mjs` 的 `HOST_RELEASES` 是"本包适配过的 dsh 清单"。本轮把清单从 10 条补到 16 条,
|
||||
并逐个核对新 release 有没有动本插件依赖的接口面。**结论:范围不用改,六个新 release 无一改动四个接口面;
|
||||
清单补齐即可;另有两处与本次 release 无关、但需要 Lead 决策的旧问题(§4、§6)。**
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论
|
||||
|
||||
| 项 | 结论 | 依据 |
|
||||
|---|---|---|
|
||||
| `engines.dsh` 要不要动 | **不动**。`0.2.2-rc.1` 仍在 `<0.3.0-0` 以内 | `node scripts/host-compat.mjs` 16 条全 `ok`,exit 0 |
|
||||
| `HOST_RELEASES` | 10 → **16**(新增 6 条) | 6 个 tag 在 GitHub releases 与 npm 双向核实存在(§1) |
|
||||
| floor 是否移动 | **不动**,仍是 `0.1.7-rc.2` | 没有更老版本被适配,也没有理由放弃它 |
|
||||
| `OUTSIDE_HOSTS` | **不动**,仍 5 条 | 本期没有任何 0.3.x 发布,边界未变 |
|
||||
| `satisfiesDeclaredRange` 窄语义 | **不动**,仍只认 `>=X <Y` | 放宽会与市场实现分叉 |
|
||||
| 六个新 release 的接口面影响 | **全部无变化** | 逐条原文 + 本机运行时代码(§2) |
|
||||
| 本机宿主版本 | **0.2.2-rc.1**(桌面版) | `app.asar` 内 `package.json`(§3) |
|
||||
| Web 卡片在新宿主能否渲染 | **能** | `dsh-client-runtime` 是死条目但被静默跳过,`slots` 服务走的是宿主自身约定(§4) |
|
||||
| 需要 Lead 改 `package.json` 的事项 | 2 条(可选清理 1 条、待决 1 条) | §6 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本核实表
|
||||
|
||||
三个独立来源交叉:GitHub releases 页(`?page=1` / `?page=2`)、GitHub releases atom feed、npm registry。
|
||||
|
||||
- npm:`https://registry.npmjs.org/@deepseek-ai/dsh`(`versions` 42 条,`time` 给出每条的发布时间;**已发布版本在 npm 上不会被静默删除**,所以"在 `versions` 里"就是存在性的硬证据)
|
||||
- GitHub 页:`https://github.com/deepseek-ai/deepseek-harness/releases?page=1` / `?page=2`(tag 形如 `dsh-v<ver>`)
|
||||
- atom:`https://github.com/deepseek-ai/deepseek-harness/releases.atom`(只带最近 10 条,正好覆盖全部 6 个新版本,且**带 release body 全文**)
|
||||
|
||||
### 1.1 本轮新增的 6 个(用户截图里看到的)
|
||||
|
||||
| GitHub tag | npm 版本 | npm 发布(UTC) | GitHub release 发布(UTC) | 存在性 | 证据 |
|
||||
|---|---|---|---|---|---|
|
||||
| `dsh-v0.2.2-rc.1` | 0.2.2-rc.1 | 2026-11-04T09:20:32Z | 2026-11-04T13:07:14Z | **存在** | [npm](https://registry.npmjs.org/@deepseek-ai/dsh)、[releases](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.1) |
|
||||
| `dsh-v0.2.2-alpha.3` | 0.2.2-alpha.3 | 2026-11-03T07:48:26Z | 2026-11-03T08:27:26Z | **存在** | 同上 |
|
||||
| `dsh-v0.2.1-rc.5` | 0.2.1-rc.5 | 2026-10-29T14:15:23Z | 2026-10-30T02:12:26Z | **存在** | 同上 |
|
||||
| `dsh-v0.2.2-alpha.2` | 0.2.2-alpha.2 | 2026-10-27T19:10:28Z | 2026-10-28T07:00:42Z | **存在** | 同上 |
|
||||
| `dsh-v0.2.1-rc.4` | 0.2.1-rc.4 | 2026-10-26T15:28:13Z | 2026-10-28T03:08:57Z | **存在** | 同上 |
|
||||
| `dsh-v0.2.1-rc.3` | 0.2.1-rc.3 | 2026-10-23T07:54:30Z | 2026-10-23T15:26:39Z | **存在** | 同上 |
|
||||
|
||||
两个时间列不同是正常的:npm `time` 是 tarball 发布时刻,GitHub `<updated>` 是 release 页面发布时刻,通常相差数小时。
|
||||
|
||||
`0.2.2-rc.1` 是**当前最新**:npm `dist-tags.latest = 0.2.2-rc.1`、`next = 0.2.2-rc.1`、`alpha = 0.2.2-alpha.3`;
|
||||
GitHub releases 第 1 页第一条就是它。截至 2026-11-06 没有更新的发布。
|
||||
|
||||
### 1.2 清单里原有的 10 个
|
||||
|
||||
| npm 版本 | npm 发布(UTC) | GitHub tag | 证据 |
|
||||
|---|---|---|---|
|
||||
| 0.2.1-rc.2 | 2026-10-17T08:36:31Z | `dsh-v0.2.1-rc.2`(page 1) | npm `versions` + releases page 1 |
|
||||
| 0.2.1-rc.1 | 2026-10-17T03:17:24Z | `dsh-v0.2.1-rc.1`(page 1) | 同上 |
|
||||
| 0.2.1-alpha.5 | 2026-10-16T12:31:49Z | `dsh-v0.2.1-alpha.5`(page 1) | 同上 |
|
||||
| 0.2.1-alpha.4 | 2026-10-15T13:08:08Z | `dsh-v0.2.1-alpha.4`(page 1) | 同上 |
|
||||
| 0.2.1-alpha.3 | 2026-10-14T12:35:33Z | `dsh-v0.2.1-alpha.3`(page 2) | npm `versions` + releases page 2 |
|
||||
| 0.2.1-alpha.2 | 2026-10-09T08:18:00Z | `dsh-v0.2.1-alpha.2`(page 2) | 同上 |
|
||||
| 0.2.1-alpha.1 | 2026-10-03T04:53:22Z | `dsh-v0.2.1-alpha.1`(page 2) | 同上 |
|
||||
| 0.2.0-rc.2 | 2026-09-29T09:56:27Z | `dsh-v0.2.0-rc.2`(page 2) | 同上 |
|
||||
| 0.2.0-rc.1 | 2026-09-28T12:34:03Z | `dsh-v0.2.0-rc.1`(page 2) | 同上 |
|
||||
| 0.1.7-rc.2 | 2026-09-24T14:18:11Z | `dsh-v0.1.7-rc.2`(page 2) | 同上 |
|
||||
|
||||
**10 条全部真实存在,没有一条被撤下**:全部出现在 npm `versions` 里(撤下的版本会从 `versions` 消失、只在 `time` 留痕),
|
||||
且全部在 GitHub releases 页以 tag 出现。
|
||||
|
||||
`OUTSIDE_HOSTS` 里的 `0.1.7-rc.1`、`0.1.7-alpha.2` 也是真实 tag(page 2 可见)——它们是**真实存在但我们刻意不覆盖**的版本,
|
||||
不是虚构的对照物。
|
||||
|
||||
### 1.3 排序说明(会误导下一个人,所以写进代码注释)
|
||||
|
||||
清单按**发布日**排序(GitHub 页面顺序),不是按 semver 优先级降序。两者在这一个位置冲突:
|
||||
|
||||
```
|
||||
0.2.1-rc.5 发布于 2026-10-29/30
|
||||
0.2.2-alpha.2 发布于 2026-10-27/28
|
||||
```
|
||||
|
||||
按日期 `rc.5` 在前,按 semver `0.2.2-alpha.2`(更高)在前。清单取**日期序**,因为那是用户实际升级经过的顺序。
|
||||
`scripts/host-compat.test.mjs` 专门 pin 了"全表 semver 降序、只有这一处例外",所以谁想把它"修正"成 semver 序都会红。
|
||||
|
||||
---
|
||||
|
||||
## 2. 六个新 release 对本插件的接口面影响
|
||||
|
||||
先把"接口面"从代码里问出来,而不是凭印象列。本插件实际依赖的只有五处:
|
||||
|
||||
| # | 接口面 | 出处 |
|
||||
|---|---|---|
|
||||
| A | `cordis.patch.yml` 的 Loader YAML 方言:`insert` 行、`id` 定向、`!!js` 表达式 | `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它) |
|
||||
| B | MCP 行配置 schema:`name: '@deepseek-ai/dsh-mcp-client'`、`config.serverName: rea`、`config.transport: stdio`、`command`/`args`/`env`/`failOnStartupError` | `cordis.patch.yml` |
|
||||
| C | 宿主插件 API:`export const name`、`export function apply(ctx)`、`ctx.inject(['webServer'], cb)`、`ctx.effect(disposer)`、`webServer.register({ kind: 'exact', path, handler })` | `index.mjs` |
|
||||
| D | 客户端 bundle 契约:`window.__ModuleLoader__.load({ id, factory })`、`factory(require)` 里 `require('react')` / `require('react-dom')`、Cordis 服务级 `inject = ["slots"]`、`ctx.slots.inject('shell.overlay', …)` | `client.js` |
|
||||
| E | 市场侧 `engines.dsh` 判定:`satisfiesRange(host, declared, { includePrerelease: true })` | 本机 `[email protected]` 的 `lib/discovery-compatibility.js:74`(见下) |
|
||||
|
||||
**E 的现场取证**(`D:\dsh-home\profiles\desktop\node_modules\dshmarket\lib\discovery-compatibility.js`):
|
||||
|
||||
```js
|
||||
function rangeResult(hostVersion, declared) {
|
||||
return satisfiesRange(hostVersion, declared, { includePrerelease: true });
|
||||
}
|
||||
```
|
||||
|
||||
与 `host-compat.mjs` 文件头写的语义完全一致,包括"市场读**两处**申报位置、顶层 `engines.dsh` 优先"
|
||||
(同文件 `manifestFacts` 里的 `engines.dsh` / `dsh.engines.dsh` 双读,注释指向 issue #577)。
|
||||
|
||||
### 2.1 `dsh-v0.2.2-rc.1`(2026-11-04)—— 唯一需要实证的一个
|
||||
|
||||
相关原文([release](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.2.2-rc.1)):
|
||||
|
||||
> * Update to Cordis Core 4.1.0, Loader 1.1.0, and Include 1.1.0, unifying configuration access, entry changes, and persistence behavior. by @shigma, @imccyu
|
||||
> * Provide unified Cordis Fiber access to raw configuration, candidate resolution, and effective configuration, used consistently by settings and hot reload. by @shigma
|
||||
> * Raise the minimum supported Node.js version to 24.9. Upgrade older Node.js runtimes before upgrading DSH. by @tianyicui
|
||||
> * Activate tools conditionally per step with consistent model declarations, PTC bindings, and execution admission. **Plugins must migrate tools.resolve/executable and prompt-assembly integration.** by @LegGasai
|
||||
> * Make Session Projection registration asynchronous, with plugins retaining a handle at startup. by @kermanx, @Magolor
|
||||
> * Rename Custom plugin markets to Custom plugin indexes. Discover reads user-added index files, and index generation excludes private versions, tags, and default-branch packages. by @turtle2099, @turtle1999
|
||||
> * Official bundle lists are now exported by `@deepseek-ai/dsh-official-bundles`; custom integrations must update imports from app-boot. by @turtle2099
|
||||
> * Show rich-text plugin details in the plugin manager and improve page layout and navigation. by @turtle1999, @turtle2099
|
||||
> * Support updating third-party plugin packages and attempt to restore the previous version if hot updates fail. by @turtle1999, @turtle2099
|
||||
|
||||
逐条判定:
|
||||
|
||||
| 条目 | 是否触及本插件 | 理由 |
|
||||
|---|---|---|
|
||||
| Cordis Core 4.0.0-rc.11 → **4.1.0**、Loader 1.0.0-rc.8 → **1.1.0**、Include → 1.1.0 | **触及(接口面 A)** | 本插件的 `cordis.patch.yml` 就是 Loader 的方言。release body 未宣告破坏性变更;**实测见下** |
|
||||
| Fiber 统一配置访问 | 否 | 影响经 Fiber 读配置的插件;`index.mjs` 不读 `ctx` 配置,两行的 `config` 由 Loader 直接交给被插入的插件 |
|
||||
| Node 下限 24.9 | 否(满足) | 本机宿主自带 node **24.21.0 ≥ 24.9** |
|
||||
| `tools.resolve/executable` 迁移 | 否 | 本插件不注册原生 agent 工具,只有一条 MCP 行 + 一个 `webServer` 只读路由 |
|
||||
| Session Projection 异步化 | 否 | 全仓 grep 无 `SessionProjection` / `sessionProjection` |
|
||||
| 市场 markets → indexes、索引生成排除 private/tags/default-branch | 否(不影响判定语义) | 影响索引**生成**流程;`engines.dsh` 判定逻辑在 1.66.39 里仍是上面那行 `includePrerelease: true` |
|
||||
| `app-boot` → `@deepseek-ai/dsh-official-bundles` | 否 | 本插件不 import `app-boot` |
|
||||
| plugin manager 富文本 / 第三方包可更新 | 否 | 纯 UI + 更新流程 |
|
||||
|
||||
**接口面 A 的实测**(本会话就跑在这台 0.2.2-rc.1 宿主的 Web GUI 上):
|
||||
|
||||
```
|
||||
GET http://127.0.0.1:31896/dsh-plugin-rea/guide-state
|
||||
→ HTTP 200
|
||||
{"pluginVersion":"2.0.13","pinnedRea":"6.8.0","installedRea":"6.8.0",
|
||||
"provisionedAt":"2026-10-26T13:15:52.343Z","state":"ok","needsSetup":false, …}
|
||||
```
|
||||
|
||||
这个 200 同时证明了三件事:`cordis.patch.yml` 的 `insert` 行被 4.1.0 的 Loader 接受并执行了(否则 `index.mjs` 不会 `apply`)、
|
||||
`ctx.inject(['webServer'])` 在 4.1.0 上仍按预期解析、`webServer.register({kind:'exact'})` 的签名没变。
|
||||
另外 `mcp__rea__*` 工具在本会话可用,说明接口面 B(`serverName: rea` + `transport: stdio`)在 0.2.2-rc.1 上正常工作。
|
||||
|
||||
> 未验证补记:Cordis 4.1.0 对 `!!js` 表达式求值作用域是否有变 —— 增量结论只覆盖**本插件实际用到的表达式形态**
|
||||
> (`process.execPath`、`process.env.*` 字符串拼接、无 `path`/`require`),没做语法面全覆盖。
|
||||
|
||||
### 2.2 `dsh-v0.2.1-rc.4`(2026-10-26)
|
||||
|
||||
> * Fix diagnostics leaking into stdout in ACP mode. by @LegGasai, @tianyicui
|
||||
|
||||
接口面:否。这是宿主自身 ACP 模式的 stdout 洁净度修复,不改 MCP 行 schema。**值得记一笔**:它说明宿主在意
|
||||
"诊断信息不能混进协议通道",与本插件 MCP 子进程只允许 stdout 输出协议帧的约定同向,不冲突。
|
||||
|
||||
### 2.3 `dsh-v0.2.2-alpha.2`(2026-10-27)
|
||||
|
||||
与 rc.1 同批 chores 的前身,条目基本一致(Node 24.9、Session Projection 异步化、Cordis Timer 同步、
|
||||
`app-boot` → `dsh-official-bundles`、市场改名 indexes、`tools` 按步条件激活、PTC/birpc、SSH 协议 2→3)。逐条判定同 §2.1 表。
|
||||
|
||||
两条本版特有、需要单独说明的:
|
||||
|
||||
> * Synchronize Cordis Timer with its upstream implementation; **custom plugins must adapt to changed return values**. by @shigma
|
||||
> * Report caller-cancelled run_code calls in PTC Mode as ABORTED rather than CODE_RUN_FAILED. SDK and Session consumers must update cancellation handling. by @kermanx
|
||||
|
||||
- Cordis Timer:本插件用的是**全局 `setTimeout`**,不是 `ctx.setTimeout` / Cordis Timer 服务 —— `client.js:223`、`client.js:283`、
|
||||
`bin/rea-shim.mjs:756` 三处全部是 Node/浏览器的 `setTimeout`。不适用。
|
||||
- PTC / `run_code`:本插件不使用 PTC。不适用。
|
||||
|
||||
### 2.4 `dsh-v0.2.1-rc.5`(2026-10-29)
|
||||
|
||||
只有一条:Improve how thinking content is displayed in conversations. by @LegGasai —— 纯展示层,接口面:否。
|
||||
|
||||
### 2.5 `dsh-v0.2.2-alpha.3`(2026-11-03)
|
||||
|
||||
新增/修复集中在统一右键与键盘菜单、文件预览目录树、变更请求与工作树、移动端布局、Trajectory 子调用折叠、
|
||||
pi-ai 图片数量上限卸载、Markdown 链接行号、历史会话加载。chores 侧两条可能沾边的:
|
||||
|
||||
> * Allow plugins to supply dynamic pi-ai headers using Session identity and request purpose, rejecting invalid headers before dispatch. by @LegGasai
|
||||
> * Allow plugins to supply navigation icons for their settings pages, restoring the default icon when unloaded. by @Magolor
|
||||
|
||||
两者都是**给插件的可选新增能力**(pi-ai 请求头、设置页导航图标),不注册就不用,不构成破坏。接口面:否。
|
||||
|
||||
### 2.6 `dsh-v0.2.1-rc.3`(2026-10-23)
|
||||
|
||||
> * Add More menus to the left sidebar, conversation views, and right sidebar for browsing and creating plugins. by @Yifffan, @imccyu
|
||||
> * Fix New Session losing an unsent draft after switching sessions. by @imccyu
|
||||
> * The DeepSeek API key adapter now updates its model list the same way as account sign-in. by @LegGasai
|
||||
|
||||
第一条沾"浏览和创建插件",但落在 UI 菜单层;不触及 `engines.dsh` 判定、不触及 `dsh.client.inject`。接口面:否。
|
||||
|
||||
### 2.7 小结
|
||||
|
||||
| release | A patch 方言 | B MCP 行 | C 宿主插件 API | D 客户端契约 | E 市场判定 |
|
||||
|---|---|---|---|---|---|
|
||||
| 0.2.2-rc.1 | 可能有变(Cordis 4.1.0)→ **实测通过** | 无变化 | 无变化(实测通过) | 无变化 | 无变化 |
|
||||
| 0.2.2-alpha.3 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
|
||||
| 0.2.1-rc.5 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
|
||||
| 0.2.2-alpha.2 | 见 rc.1 | 无变化 | 无变化 | 无变化 | 无变化 |
|
||||
| 0.2.1-rc.4 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
|
||||
| 0.2.1-rc.3 | 无变化 | 无变化 | 无变化 | 无变化 | 无变化 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 本机宿主实测
|
||||
|
||||
| 项 | 值 | 来源 |
|
||||
|---|---|---|
|
||||
| 桌面宿主版本 | **`@deepseek-ai/[email protected]`** | `D:\deepseekharnes\新建文件夹\resources\app.asar` 内 `package.json`(asar header → `files.package.json`,size 882,offset 207407341) |
|
||||
| 运行时包 | `@deepseek-ai/[email protected]` | 同 asar 的 `dsh/package.json` |
|
||||
| `hostProtocolVersion` | 4 | 同 asar 的 `dsh/desktop-runtime.json` |
|
||||
| 宿主自带 Node | **v24.21.0** | `resources\runtime\primary-runtime\dependencies\node\bin\node.exe --version`;`runtime\versions.json` 里 `"node": "24.21.0"` |
|
||||
| 宿主自带 pnpm | 11.28.5 | `runtime\versions.json` |
|
||||
| `dsh-home` 运行时副本 | v24.21.0 | `D:\dsh-home\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe --version` |
|
||||
| PATH 上的 node | **v24.22.1**(`F:\jiuguan\node.exe`) | `node --version` / `where.exe node` |
|
||||
| npm-global CLI | `@deepseek-ai/[email protected]`,`dsh --version` → `0.1.7-rc.2` | `D:\dev\npm-global\node_modules\@deepseek-ai\dsh\package.json` |
|
||||
| 已安装插件副本 | `[email protected]`,`engines.dsh` 同串 | `D:\dsh-home\profiles\desktop\node_modules\dsh-plugin-rea\package.json` |
|
||||
| 已装的 REA | 6.8.0(provisionedAt 2026-10-26T13:15:52Z) | `D:\dsh-home\plugins\rea-bundle\bundle.json` + guide-state 路由 |
|
||||
| 当前会话宿主 | 0.2.2-rc.1(本会话的 GUI 与 `mcp__rea__*` 工具都来自它) | 运行时上下文 + `GET /` 端口 31896 |
|
||||
|
||||
**注意两个容易混淆的"dsh 版本"**:`dsh --version` 报的 `0.1.7-rc.2` 是 npm 全局装的 **CLI**,
|
||||
不是跑本会话的桌面宿主(0.2.2-rc.1)。用 CLI 的版本号判断本机宿主会得出完全相反的结论。
|
||||
|
||||
### 3.1 同机历史 asar 对照(用来定位"某个包什么时候消失")
|
||||
|
||||
| asar 路径 | 版本 | `@deepseek-ai/*` 包数 | 有 `dsh-client-ui-slots` 目录 | 有 `dsh-mcp-client` | 有 `dsh-official-bundles` |
|
||||
|---|---|---|---|---|---|
|
||||
| `D:\deepseekharnes\deepseekharnes\resources\app.asar` | 0.1.7-rc.2 | 284 | 有 | 有 | 无 |
|
||||
| `D:\dsh-snapshot\20261106-2125\install\resources\app.asar` | 0.2.0-rc.2 | 289 | 有 | 有 | 无 |
|
||||
| `D:\kimicode\修复\dsh-backup-20261106\program-before-repair\resources\app.asar` | 0.2.2-rc.1 | 331 | **无** | 有 | **有** |
|
||||
| `D:\deepseekharnes\新建文件夹\resources\app.asar` | 0.2.2-rc.1 | 331 | **无** | 有 | **有** |
|
||||
|
||||
本机**没有** 0.2.1-rc.3/rc.4/rc.5、0.2.2-alpha.2/alpha.3 的桌面构建,所以"哪个版本把 `dsh-client-ui-slots` 从
|
||||
`app.asar` 的 `node_modules` 里移走"**定位不到具体版本**。但 §4 证明这不需要定位——它不是接口面缺失。
|
||||
|
||||
---
|
||||
|
||||
## 4. `dsh.client.inject` 专项核实(Lead 的核心问题)
|
||||
|
||||
`package.json` 声明:
|
||||
|
||||
```json
|
||||
"dsh": { "client": { "platform": "web", "inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots" ] } }
|
||||
```
|
||||
|
||||
### 4.1 四个包在 0.2.2-rc.1 宿主里到底在不在
|
||||
|
||||
方法:解 asar header,遍历全部 3316 个目录 / 22102 个文件;再对整份 213 MB 做字节扫描定位每个名字的所有出现。
|
||||
证据路径全部在 `D:\deepseekharnes\新建文件夹\resources\app.asar` 内。
|
||||
|
||||
| 包名 | 有 `node_modules/@deepseek-ai/<name>/` 目录? | 全 asar 出现次数 | 结论 |
|
||||
|---|---|---|---|
|
||||
| `dsh-client-connection` | **有**(`dsh/node_modules/@deepseek-ai/dsh-client-connection`,含 `lib/`、`locale/`) | 1 | **存在** |
|
||||
| `dsh-client-locale` | **有**(同上结构) | 1 | **存在** |
|
||||
| `dsh-client-ui-slots` | **无目录** | **89**(分布在 87 个文件的 `package.json` / `README` / `lib/client.js`,以及 `dsh-web-frontend/dist/assets/index-BgUyXZcc.js`) | **以"被引用"形式存在**:宿主自己的 8+ 个客户端包把它写进 `dsh.client.inject`,它被编进 Web 前端 bundle,不以独立包目录形式落盘 |
|
||||
| `dsh-client-runtime` | **无目录** | **0(整份 asar 一次都没出现)** | **不存在**。npm 上最后一次发布是 `0.1.1-rc.2`(2026-08-21),在 0.1.7-rc.2 及之后的所有桌面宿主里都找不到它 |
|
||||
|
||||
`dsh-client-runtime` 的现实身份:它是 **build-time devDependency**。本机 `[email protected]` 的 `devDependencies` 里就有
|
||||
`"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7"`,而同一个包的 `dsh.client.inject` 只列 `dsh-client-locale`、
|
||||
`dsh-client-ui-settings`、`dsh-client-ui-theme` 三个**真实宿主包**。也就是说:我们的 `inject` 把构建期依赖误写成了运行期注入项。
|
||||
|
||||
### 4.2 宿主拿 `inject` 干什么,目标不存在会怎样
|
||||
|
||||
消费方是宿主包 `@deepseek-ai/dsh-client-modules`(node 半边扫 Loader 条目里的 `dsh.client`,
|
||||
把 `window.__DSH_BOOT__` 的模块图组出来;client 半边按图装载)。三段源码:
|
||||
|
||||
**(1)校验**(`lib/index.js` 的 `parseDshClient`,`lib/client.js` 同款)——只校验类型,不校验存在:
|
||||
|
||||
```js
|
||||
const inject = optionalStringArray(pkgName, "dsh.client.inject", decl.inject);
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
|
||||
throw new Error(`client-modules: ${subject} ${field} must be a string array`);
|
||||
```
|
||||
|
||||
**(2)装载**(`lib/client.js:791-794`)——**这就是答案**:
|
||||
|
||||
```js
|
||||
for (const packageName of row.inject) {
|
||||
const dependency = this.graphRows.get(packageName);
|
||||
if (dependency !== void 0) await this.arriveDependency(row.id, dependency, [], visited);
|
||||
}
|
||||
```
|
||||
|
||||
`graphRows.get()` 取不到就**什么都不做**:不抛错、不告警、不写日志。**未知 inject 项 = 静默跳过。**
|
||||
|
||||
**(3)反证:宿主自己就在这么用。** 统计宿主 331 个 `@deepseek-ai` 包的 `dsh.client.inject`,共 412 条边,
|
||||
其中有一类目标**根本没有包目录**,却被核心面板引用:
|
||||
|
||||
```
|
||||
dsh-client-ui-primitives <- 6 个消费者:
|
||||
dsh-client-ui-jobs, dsh-client-ui-schedule, dsh-client-ui-subagent,
|
||||
dsh-experimental-client-ui-agent-team, …
|
||||
```
|
||||
|
||||
这 6 个面板在 0.2.2-rc.1 上正常工作。所以"`inject` 目标没有独立包目录"是**宿主自身也在用的形态**,不是错误配置。
|
||||
|
||||
### 4.3 本插件客户端代码真正要的是什么
|
||||
|
||||
`client.js` 的 shape:
|
||||
|
||||
```js
|
||||
window.__ModuleLoader__.load({
|
||||
id: "dsh-plugin-rea",
|
||||
factory: (require) => {
|
||||
const react = require("react");
|
||||
const reactDom = require("react-dom"); // 只在需要时
|
||||
const inject = ["slots"]; // ← Cordis 服务名,不是包名
|
||||
…
|
||||
exports.inject = inject;
|
||||
```
|
||||
|
||||
- `require(...)` 只要求 `react` / `react-dom`,**四个 inject 包一个都没 require**。所以 `dsh-client-runtime`
|
||||
即使不存在,也不会在 `factory` 里炸。
|
||||
- 真正决定"卡片能不能挂上去"的是 Cordis **服务级** `inject = ["slots"]`。这是宿主自己的约定,不是我们发明的:
|
||||
在 0.2.2-rc.1 里,`dsh-client-resources`、`dsh-client-ui-brand-official`、`dsh-client-ui-action-cards`、
|
||||
`dsh-client-ui-attachment`、`dsh-client-ui-conversation` 等 8+ 个核心客户端包写的都是 `inject = ["slots"]`(或含 `slots`)。
|
||||
- 挂载点是 `ctx.slots.inject("shell.overlay", mount)`(`client.js:391`),并带 `typeof ctx.slots.inject === "function"`
|
||||
的能力守卫——服务不在时不炸,只是不挂。
|
||||
|
||||
### 4.4 最终结论
|
||||
|
||||
> **能渲染。** 本插件的 Web 卡片(一次性 setup 引导)在 `0.2.2-rc.1` 上可以正常渲染,不需要为此改 `package.json`。
|
||||
|
||||
依据链:
|
||||
1. `dsh.client.inject` 里的未知项被宿主**静默跳过**(`dsh-client-modules/lib/client.js:791-794` 源码);
|
||||
2. 本插件的 `factory` 不 `require` 那四个包,缺任何一个都不会在 factory 内失败;
|
||||
3. 卡片真正的闸门是 Cordis 服务 `slots`,宿主 8+ 核心客户端包用的是同一条约定,0.2.2-rc.1 仍然成立;
|
||||
4. `dsh-client-ui-slots` 在宿主里"有引用、无目录"与 `dsh-client-ui-primitives` 完全同形,后者支撑着 6 个正常工作的核心面板;
|
||||
5. 宿主日志(`…\@deepseek-ai\dsh-desktop\logs\crash-2026-11-06T*`)中没有任何 `client-modules` / `inject` 相关记录;
|
||||
6. 旁证:同一宿主的 node 半边(`index.mjs`)已被证明在跑(`GET /dsh-plugin-rea/guide-state` → 200)。
|
||||
|
||||
### 4.5 交 Lead 的 `package.json` 待办(我不动 `package.json`)
|
||||
|
||||
| 优先级 | 改动 | 理由 | 不改的后果 |
|
||||
|---|---|---|---|
|
||||
| 低(清理) | `dsh.client.inject` 删掉 `"@deepseek-ai/dsh-client-runtime"` | 该包最后一次发布 0.1.1-rc.2(2026-08-21),早于本插件声明的整个 `>=0.1.7-rc.2` 区间;它是构建期 devDependency,被误写成了运行期注入项 | 无功能后果,但会误导后续维护者,并在 `dsh.client.external`/顺序敏感的场景下留一个永假的依赖边 |
|
||||
|
||||
留 `@deepseek-ai/dsh-client-connection` / `-locale` / `-ui-slots` 三个:前两个有实体目录,第三个与宿主自身核心包的用法同形。
|
||||
|
||||
---
|
||||
|
||||
## 5. 本次改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `scripts/host-compat.mjs` | `HOST_RELEASES` 10 → 16 条,新增 `0.2.2-rc.1` / `0.2.2-alpha.3` / `0.2.1-rc.5` / `0.2.2-alpha.2` / `0.2.1-rc.4` / `0.2.1-rc.3`(发布日序,最新在前);条目上方注释补"按发布日而非 semver 序"及唯一一处倒序;文件头补 "FLOOR AND CEILING" 段,记录 floor 未动、ceiling 未动。`OUTSIDE_HOSTS`、`satisfiesDeclaredRange`、`parseVersion`、`compareVersions`、`checkHostCoverage` **全部未动** |
|
||||
| `scripts/host-compat.test.mjs` | **新增**,25 条测试(正反两面 + 三个负控) |
|
||||
| `scripts/setup.test.mjs` | 仅 lead 授权的一处:`test("the release list this gate checks is the published one")` 里的 10 条字面量 → 16 条,附一句"不要改成 semver 序"的注释。其他区域未碰 |
|
||||
| `docs/host-compat-2026-11-06.md` | **新增**(本文件) |
|
||||
|
||||
验证:
|
||||
|
||||
```
|
||||
$ node scripts/host-compat.mjs
|
||||
engines.dsh = >=0.1.7-rc.2 <0.3.0-0
|
||||
ok 0.2.2-rc.1
|
||||
…(16 条全 ok)…
|
||||
ok 0.3.0 (must stay outside)
|
||||
…(5 条全 ok)…
|
||||
host range ok: 16 releases covered, 5 correctly outside
|
||||
exit 0
|
||||
|
||||
$ node --test scripts/host-compat.test.mjs → tests 25, pass 25, fail 0
|
||||
$ node --test ./scripts/setup.test.mjs → tests 88, pass 85, skipped 3, fail 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 未覆盖 / 未验证(诚实清单)
|
||||
|
||||
1. **0.2.1-rc.3 / rc.4 / rc.5、0.2.2-alpha.2 / alpha.3 的桌面构建没有在本机实测过。** 本机只有 0.2.2-rc.1 的
|
||||
`app.asar` 和两份更老的(0.1.7-rc.2、0.2.0-rc.2)。这五个版本的"接口面无变化"是**据 release body 原文 + 0.2.2-rc.1
|
||||
运行时代码推出来的**,不是逐版本跑出来的。要变成"已验证"需要逐个装一遍。
|
||||
2. **GitHub REST API 两次不可用**:第一次 `504`(网关超时),第二次 `403`(`API rate limit exceeded for 39.144.92.96`)。
|
||||
版本、日期、release body 因此取自 releases HTML 页 + atom feed + npm registry 三处交叉,没有用 API。
|
||||
3. **release body 只读了最近 10 条**(atom feed 的上限)。这正好覆盖全部 6 个新版本 + 原有 4 条,但 0.2.1-alpha.3 及更早的
|
||||
原文没有逐条读(它们不是本轮新增,且 `HOST_RELEASES` 里早已在册)。
|
||||
4. **中文段落是乱码来源**:atom feed 是 UTF-8,PowerShell `Get-Content` 按 ANSI 读,中文全部成 mojibake。
|
||||
本文件引用的原文**一律取英文段落**,没有从乱码回推中文。
|
||||
5. **Web GUI 的视觉验证没做**:`GET http://127.0.0.1:31896/` 返回 `401 dsh web authentication required`,
|
||||
拿不到 `window.__DSH_BOOT__` 的运行时模块图(需要 `dsh web` 打印的 token)。§4.4 的"能渲染"结论来自
|
||||
源码路径 + 服务名约定 + 日志无错,**不是截图**。要补的话:拿到 token 后取 `GET /` 的 boot graph,
|
||||
确认存在 `id: "dsh-plugin-rea"` 的 row 且 `inject` 里那两条缺失项被跳过。
|
||||
6. **`dsh-client-ui-slots` 到底哪个版本从 `app.asar/node_modules` 里消失,定位不到**(本机没有 0.2.1-rc.x 的桌面 asar)。
|
||||
但这不影响结论:它在 0.2.2-rc.1 上是"有引用无目录",与宿主自身的 `dsh-client-ui-primitives` 同形。
|
||||
7. **`@deepseek-ai/dsh-client-ui-primitives` 这类"无目录但被注入"的包在 Loader 侧如何解析,没有读到实现**。
|
||||
只证明了结果(引用它们的 6 个核心面板正常工作)。
|
||||
8. **`engines.node` 与宿主自带 node 的矛盾未决**:`package.json` 声明 `^22.23.4 || ^24.22.1 || >=26.12.1`,
|
||||
而宿主自带 node 是 **24.21.0**(低于 24.22.1)。实际运行没出事,因为:
|
||||
`cordis.patch.yml` 的 `command: process.execPath` 只用来起 `bin/rea-shim.mjs`(跑在宿主 24.21.0 上),
|
||||
而 shim 随后 `spawn(rea.node, …)`(`bin/rea-shim.mjs:1386`),`rea.node` 取自 `bundle.json` 的
|
||||
`paths.node = F:\jiuguan\node.exe` = **24.22.1** —— 真正跑 REA 的是满足声明的那个 node。
|
||||
**未验证**的是:市场/安装前置校验是否会拿 `engines.node` 跟宿主自带 node 比。如果会,这条比 `dsh.client.inject`
|
||||
更可能挡住安装。**需要 Lead 决策**(放宽下限到 `^24.21.0`,或维持现状并要求宿主升级),我不动 `package.json`。
|
||||
9. **`0.3.0` 尚未发布**:`OUTSIDE_HOSTS` 里三条 `0.3.0*` 是防线,不是已存在的版本,无法核实。
|
||||
10. **`deriveHostCompatibility` 的完整判定链没有逐条验证**:只核对了 `rangeResult` 那一行
|
||||
(`includePrerelease: true`)与双读申报位置的事实。它内部对 peer 依赖、隐式上限的处理(`classifyPeer`、
|
||||
`crossesImplicitCeiling`)没有展开 —— 本插件不声明 `peerDependencies`,不进入那条路径。
|
||||
@@ -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,200 @@
|
||||
# 更新审查:为什么现在不动这些钉子(2026-10-24)
|
||||
|
||||
一次针对“能不能把运行时升到最新”的核查。结论写在最前面,证据按下表逐条可复现;
|
||||
最后是重开这个决定的条件,以及本轮**没有**做的事。
|
||||
|
||||
## 0. 结论
|
||||
|
||||
| 组件 | 上游最新 | 本机/钉子 | 能否升 | 依据 |
|
||||
|---|---|---|---|---|
|
||||
| REA(`rea-agents`) | 6.7.0 | 6.7.0 | 已是最新 | npm `dist-tags.latest = 6.7.0` |
|
||||
| Node | 24.22.1 | 24.22.1 | 已是最新 | `nodejs.org/dist/index.json`:24 线最高 24.22.1(2026-10-20) |
|
||||
| apktool | 3.0.3 | 3.0.3 | 已是最新 | GitHub latest = v3.0.3(2026-07-20),jar 15,478,013 B / `dbf930b0…` 一致 |
|
||||
| platform-tools | 滚动 | 钉了当前字节 | 无需动 | 下载今日 `platform-tools-latest-windows.zip`:8,044,989 B / `45f4d631…` 与钉子逐字节一致 |
|
||||
| Ghidra | **12.2**(2026-10-22) | 12.1.4 | **不能** | REA 只接受 12.1.x,12.2 判 `unsupported` |
|
||||
| jadx 桥接 | **0.8.0**(2026-10-19) | 0.7.1 | **不能** | REA 硬校验引擎自报版本必须等于 0.7.1 |
|
||||
| JDK | 21.0.13+9 / 25.0.5+8 | 21.0.12.1(vendor)/ Corretto 25.0.5(运行) | **Windows 上不能** | 两个 release 都没有任何 Windows 资产 |
|
||||
|
||||
**净结论:本轮不移动任何 pin。** 能升的两项会当场打掉整组工具;JDK 在 win32-x64 上没有可指的构建;
|
||||
其余三项已是上游最新。现有组合实测 207 个工具全绿(见 §3)。
|
||||
|
||||
## 1. Ghidra 12.2:被 REA 的发布线挡住
|
||||
|
||||
REA 运行时的策略常量:
|
||||
|
||||
```
|
||||
dist/ghidra/GhidraReleasePolicy.js:2 export const SUPPORTED_GHIDRA_VERSION = "12.1.4";
|
||||
dist/ghidra/GhidraReleasePolicy.js:13 if (raw === SUPPORTED_GHIDRA_VERSION) → verified
|
||||
else version.major/minor 相同 → compatible
|
||||
else → unsupported
|
||||
dist/ghidra/GhidraReleasePolicy.js:54 ghidraReleaseAccepted = status === "verified" || "compatible"
|
||||
```
|
||||
|
||||
把该模块直接加载执行的结果:
|
||||
|
||||
```
|
||||
12.1.4 → verified
|
||||
12.1.5 → compatible ← 若上游发 12.1.5,会自动放行
|
||||
12.2 → unsupported ← 12.2.0 同样
|
||||
12.0.4 → unsupported
|
||||
```
|
||||
|
||||
`GhidraInstallation.js` 在 `:515` 用 `ghidraReleaseAccepted(raw)` 决定 `passed`,`:107/:135-145` 一旦
|
||||
有 failed check 就返回 `unavailable`。也就是说升 12.2 之后:provider 直接不可用,
|
||||
`analyze_function` / `procedure_pseudo_code` / `list_segments` / `search_bytes` 等整组消失——不是降级。
|
||||
|
||||
插件的 `check-updates.ps1:228` 也钉着 `^12\.1\.`,会同步报“REA 当前要求 Ghidra 12.1.x”。
|
||||
|
||||
**代价**:Ghidra 12.1.4 的四个 CVE 继续留在链上(见 §4)。
|
||||
|
||||
## 2. jadx 桥接 0.8.0:一条版本等值断言
|
||||
|
||||
REA 对 jar 的准入分三层,前两层 0.8.0 都能过,第三层过不去:
|
||||
|
||||
```
|
||||
dist/android/JadxConfiguration.js:224-241 要求 jar 内含 5 个桥接类(0.8.0 全部存在)
|
||||
dist/android/JadxSession.js:55-57 serverInfo 必须是 rea-jadx-bridge/4(由 REA 自己的桥提供,不在 jar 里)
|
||||
dist/android/JadxSession.js:59-62 const runtime = await tools.json("rea_jvm_status", {});
|
||||
if (runtime.engine_reported_version !== JADX_RELEASE.version) throw
|
||||
`Expected engine 0.7.1; JAR reported …`
|
||||
dist/android/JadxRelease.js:3 version: "0.7.1"
|
||||
dist/android/JadxRelease.js:5 sha256: 6e5eacf5…ff75(仅用于 source_revision,不参与准入)
|
||||
```
|
||||
|
||||
实测两个 jar 的 `com/atxx/jhmcp/BuildInfo.class`(`javap -v`,`VERSION` 是 `ConstantValue`,
|
||||
只有 `-c` 看不到赋值):
|
||||
|
||||
```
|
||||
0.7.1-all.jar → ConstantValue: String 0.7.1
|
||||
0.8.0-all.jar → ConstantValue: String 0.8.0
|
||||
```
|
||||
|
||||
失败点在 `load_apk`(`JadxSession.js:64`)之前,即 JVM 起 + MCP initialize + 一次工具往返之后,
|
||||
抛出的是明确错误,不是静默不可用。
|
||||
|
||||
另一条有用的事实:两个 jar 只有 `BuildInfo` 与 `JadxSession` 两个类字节不同,
|
||||
`SessionHolder`、`jadx/api/JadxDecompiler`、`gson/Gson`、MCP `Server` 逐字节相同——
|
||||
所以坏的纯粹是那一条版本断言,不是 ABI 或编译不兼容。**只要 REA 放开这个常量,0.8.0 是安全的。**
|
||||
|
||||
## 3. JDK:上游有新版,Windows 没有构建
|
||||
|
||||
`adoptium/temurin21-binaries` 的 `jdk-21.0.13+9`(2026-10-22T08:58Z)共 204 个资产,
|
||||
**没有任何 `windows` 资产**;`temurin25-binaries` 的 `jdk-25.0.5+8`(147 个资产)同样没有。
|
||||
|
||||
| manifest 钉的平台 | 21.0.13+9 是否存在 |
|
||||
|---|---|
|
||||
| win32-x64 | **缺失**(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip` 不存在) |
|
||||
| darwin-x64 | `OpenJDK21U-jdk_x64_mac_hotspot_21.0.13_9.tar.gz` 193,957,782 B |
|
||||
| darwin-arm64 | `OpenJDK21U-jdk_aarch64_mac_hotspot_21.0.13_9.tar.gz` 199,754,598 B |
|
||||
| linux-x64 | `OpenJDK21U-jdk_x64_linux_hotspot_21.0.13_9.tar.gz` 207,858,352 B |
|
||||
| linux-arm64 | `OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.13_9.tar.gz` 205,995,218 B |
|
||||
|
||||
顺带:`jdk-25.0.5+8` 连 `x64_mac` 的 JDK 资产都没有,darwin-x64 也跟不了 25.x 线。
|
||||
|
||||
所以“把 manifest 的 JDK 钉到 21.0.13”这件事在 win32-x64 上**无法执行**;只改另外 4 个平台会制造
|
||||
平台间版本分裂,而 Windows(本插件的主平台)仍是旧信任库。
|
||||
|
||||
REA 侧对 JDK 没有版本钉子:Ghidra 12.1.4 的 `application.properties` 是 `application.java.min=21`、
|
||||
`max=`(空=无上限),接受逻辑是 `major >= min && (max 为空 || major <= max)`
|
||||
(`dist/ghidra/GhidraInstallationPolicy.js:74-76`),所以 21.0.13 与今天的 25.0.5 都能过。
|
||||
`SUPPORTED_GHIDRA_JAVA_MAJOR = 21` 只是缺属性时的兜底常量,不是“必须等于 21”。
|
||||
|
||||
## 4. 风险与缓解
|
||||
|
||||
**Ghidra 12.1.4 的四个 CVE(NVD 实测编号与分数)**
|
||||
|
||||
| CVE | 分数 | 影响 |
|
||||
|---|---|---|
|
||||
| CVE-2026-100504 | 7.3 | 反编译器 `leftshift128` 负移位导致栈越界写,可致代码执行 |
|
||||
| CVE-2026-100505 | 4.8 | `StringManager::getCodepoint` 多字节解码堆越界读(9.2–12.1.4) |
|
||||
| CVE-2026-100503 | 4.8 | 反编译器 `Funcdata::opInsertAfter` UAF 拒绝服务 |
|
||||
| CVE-2026-113604 | 6.8 | ELF 加载器 `ElfHeader` 内存耗尽拒绝服务(10.2–12.1.4) |
|
||||
|
||||
缓解只能是流程上的:**不要用这套环境分析来源不可信的样本**;根治要等 REA 放开 12.1.x 这条线。
|
||||
|
||||
**CVE-2026-91403(9.1,JSSE,eMudhra 中间 CA)**
|
||||
|
||||
Oracle 的 Security Alert 把 `21.0.13` 和 `25.0.5` 都列为受影响版本。实测信任库里的 eMudhra 锚:
|
||||
|
||||
| 锚(SHA-256 指纹前缀) | Temurin 21.0.12.1 | Temurin 21.0.13 | Corretto 25.0.5(本机运行) |
|
||||
|---|---|---|---|
|
||||
| g1 `40:F6:AF:03…` | 有 | **有** | 有 |
|
||||
| c1 `12:56:09:AA…` | 有 | 无 | 有 |
|
||||
| c3 `BC:4D:80:9B…` | 有 | 无 | 有 |
|
||||
| g3 `86:A1:EC:BA…` | 有 | 无 | 有 |
|
||||
| g2 `1A:A0:C2:70…` | 无 | 无 | 有 |
|
||||
|
||||
即:21.0.12.1 → 21.0.13 会移除 c1/c3/g3,**但保留 g1**;本机运行的 Corretto 25.0.5 反而五张全在。
|
||||
**没有任何一份已发布版本是“干净”的**,所以“升到 21.0.13 就修掉这个 9.1”不成立。
|
||||
|
||||
可用的缓解有两条,都要动信任库,需人工确认:
|
||||
|
||||
1. 给 REA 单独指定 truststore(`-Djavax.net.ssl.trustStore=<剔除了 emsign 锚的副本>`),不动系统 JDK——
|
||||
`F:\game\xbox` 同时是别的程序(Minecraft)的运行时,直接删证书会波及它们。
|
||||
2. 等 Adoptium 发布带 Windows 资产、且移除了全新 eMudhra 锚的下一版,再统一升 manifest。
|
||||
|
||||
注:插件自身的下载都是 sha256 固定的,伪造证书无法改写字节,只能误导版本探测;这条风险主要落在
|
||||
“REA/Java 去访问任意 HTTPS 站点”上,而不是供给链路。
|
||||
|
||||
**信任库统计陷阱(记录一下,避免再犯)**:只 grep `emudhra` 会漏掉 Corretto 的 `emsignrootca-*` 别名,
|
||||
把“5 张锚”读成“0 张”。正确做法是 `(?i)emsign|emudhra`,或直接按 SHA-256 指纹比对。
|
||||
|
||||
## 5. 本轮实测:工具面全绿
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| Ghidra(`C:\Windows\System32\whoami.exe`) | 9 段 / 341 函数 / 308 字符串;`entry` 反编译出 `FUN_1400021b4(); FUN_140001af0();` |
|
||||
| Ghidra 冷启动 | CLI `rea analyze` 52 s;同进程内后续 `list_segments` 立即返回 |
|
||||
| apktool | `inspect_apktool_client` → 3.0.3;wrapper 直接跑 `--version` → 3.0.3(280 ms) |
|
||||
| adb | `inspect_adb_client` → 37.0.1-15733141;`adb version` → 1.0.41 |
|
||||
| jadx 桥接 | 0.7.1-all.jar 在 JDK 25.0.5 下正常启动(输出 usage) |
|
||||
| managed(mscorlib.dll) | 20,899 行元数据,MVID 2acc5ce0-… |
|
||||
| sqlite | 3.53.4,schema + 行读取正常 |
|
||||
| 仓库门禁 | `audit-manifest` 24/24 hash-pinned clean;单测 85 过 / 3 平台跳过 / 0 失败;`verify-branch.ps1` ALL CHECKS PASSED |
|
||||
|
||||
不可用的工具族都是“需要外部工具”的那批,与版本无关:`inspect_with_objdump`(本机无 GNU objdump)、
|
||||
`inspect_pcap_*`(无 Wireshark/tshark)、`extract_firmware`(无 binwalk/unblob)、`inspect_wasm_artifact`
|
||||
(无 WABT)、`recover_javascript_sources`(无 Wakaru)、`inspect_binary_layout`(无 pwntools)、
|
||||
`inspect_evm_interface`、JEB 家族、macOS 专属项。
|
||||
|
||||
## 6. 什么时候重开这个决定
|
||||
|
||||
1. **Ghidra 12.2**:`rea-agents` 新版本把 `SUPPORTED_GHIDRA_VERSION` 提到 12.2(或新增 12.2 兼容分支)
|
||||
→ 同步放宽 `check-updates.ps1:228` 的 `^12\.1\.`,改 manifest 的 `ghidra` 源,重跑
|
||||
`bundle:audit` + `bundle:test` + `setup --reuse`(含末尾 smoke)。
|
||||
2. **jadx 0.8.0**:`JadxRelease.js` 的 `version` / `sha256` 变化 → 换 jar、改 manifest 的
|
||||
`jadxHeadlessMcp` 与 `probe`、重跑一次真实 APK 分析。
|
||||
3. **JDK**:Adoptium 发布带 Windows 资产的 21.0.13+(或更新的 LTS 补丁)→ 一次改 5 个平台的
|
||||
url/size/sha256,跑 audit + test + `setup --reuse --skip-smoke`,再用 vendor JDK 跑一次 smoke。
|
||||
4. 任何一项落地时,都保持“先验证再切换”:本机当前的运行组合随时可回退。
|
||||
|
||||
## 7. 本轮的已知弱点(未修,留待决定)
|
||||
|
||||
- `manifest/bundle.manifest.json:293` 的 jadx probe 只检查 jar 路径存在(`{"file": …}` 没有 `expect`),
|
||||
`setup.mjs` 的 `runProbe` 对 file probe 只做 `existsSync`。换一个同名但版本不同的 jar,setup 会静默放行,
|
||||
直到第一次 Android 调用才报 `Expected engine 0.7.1`。
|
||||
- JDK probe 只要求 `java --list-modules` 含 `jdk.compiler`,一个 JDK 11 也能通过(`setup.mjs:1116-1118`
|
||||
的注释正描述了这类事故)。Ghidra 的 `application.java.min` 会在运行期兜住,但那时已经装完了。
|
||||
- `setup.mjs:1147-1155` 的“已供给”分支只跑组件 probe,不比对落盘文件哈希,所以装在机器上的
|
||||
`vendor/apktool/apktool.bat` 一度是旧 master 副本(2,348 B / `9440ec9d…`)而 manifest 钉的是 tag 副本
|
||||
(2,386 B / `d81a91bd…`)。本轮已把该文件对齐到钉子(备份 `apktool.bat.bak-2348`),两者行为实测一致。
|
||||
|
||||
## 8. 本轮没有做的事
|
||||
|
||||
- 没有移动任何 pin(`manifest`/`package.json` 未改)。
|
||||
- 没有提交、没有推送;工作树保持干净(本文件与 README 的说明除外)。
|
||||
- 没有重启 dsh / REA:运行中的 MCP 进程与 Ghidra 会话不受影响。
|
||||
|
||||
## 附:外部评审文档的核对结果
|
||||
|
||||
| 说法 | 判定 |
|
||||
|---|---|
|
||||
| Ghidra 12.2 被 REA 的 12.1.x 硬线挡住 | ✅ |
|
||||
| 不存在 12.1.5 | ✅(Ghidra release 列表里 12.1.4 之后直接是 12.2) |
|
||||
| jadx 0.8.0“不会被硬拒绝”“大概率能用”“失败是静默的” | ❌ 硬拒绝、明确报错,见 §2 |
|
||||
| 升 JDK 21.0.13 能修掉 CVE-2026-91403 | ❌ 只移除 4 张锚里的 3 张,g1 仍在;Oracle 把 21.0.13 列为受影响 |
|
||||
| 改 manifest 的六个平台条目即可升 JDK | ❌ 只有 5 个平台条目;win32-x64 无资产 |
|
||||
| `SUPPORTED_GHIDRA_JAVA_MAJOR = 21` 意味着“只接受主版本 21” | ❌ 实际是 `>= application.java.min`(25.0.5 今天就被接受) |
|
||||
| apktool 3.0.3 已是上游最新、哈希与官方一致 | ✅ |
|
||||
| platform-tools 无钉子、始终取最新 | ⚠️ URL 是滚动的,但 manifest 钉了当前字节的 sha256(今日仍一致) |
|
||||
| jadx 0.7.1 受 CVE-2022-39259 / CVE-2024-32653 影响 | ❌ 类目错误:0.7.1 是 jadx-headless-mcp 的版本号,不是 jadx-core 的 |
|
||||
@@ -0,0 +1,301 @@
|
||||
# 更新落地:REA 6.7.0 → 6.8.0(2026-10-26)
|
||||
|
||||
上游在 2026-10-25 放出 `[email protected]`(npm `latest`)。本轮按以往的做法核对全部钉子、只升级能升的那一个,
|
||||
并在真机上把整条链路重跑一遍。**结论:只动 REA 一个 pin,但它连带改了 Node 的 engines 下限,所以三处措辞一起动;
|
||||
Ghidra / jadx / JDK / apktool / platform-tools / Node 全部原地不动。**
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 0. 结论
|
||||
|
||||
| 组件 | 上游最新 | 本轮钉子 | 动作 | 依据 |
|
||||
|---|---|---|---|---|
|
||||
| REA(`rea-agents`) | **6.8.0**(2026-10-25) | **6.8.0** | **升** | npm `dist-tags.latest = 6.8.0`,`time.modified 2026-10-25T09:07:31Z` |
|
||||
| Node | LTS 24.22.1(2026-10-20);current 26.12.1(非 LTS) | 24.22.1 | 不动 | 24.22.1 仍是 24 线最新 LTS,且**正好等于 6.8.0 的 24 线下限** |
|
||||
| Ghidra | **12.2**(2026-10-22) | 12.1.4 | **不动**(本机可覆盖,见 §9) | 6.8.0 的 `SUPPORTED_GHIDRA_VERSION` 仍是 `12.1.4`;12.2 判 `unsupported`,Ghidra 系工具整组消失 |
|
||||
| jadx 桥接 | **0.8.0**(2026-10-19) | 0.7.1 | **不动** | 6.8.0 的 `JADX_RELEASE` 仍是 0.7.1,`sha256` 与盘上 jar 逐字节相同 |
|
||||
| JDK | 21.0.13+9(无 Windows 资产)/ 25.0.4.1+1(Windows 线) | 21.0.12.1 | 不动 | Adoptium `/v3/assets/latest/21/...os=windows` 仍返回 21.0.12.1+1(`updated 2026-08-21`) |
|
||||
| apktool | 3.0.3(2026-07-20) | 3.0.3 | 不动 | GitHub latest 未变;jar 15,478,013 B / `dbf930b0…` 现场复核 |
|
||||
| platform-tools | 滚动 | 37.0.1 | 不动 | 现场 `adb version` = 37.0.1 (1.0.41),`source.properties` `Pkg.Revision=37.0.1` |
|
||||
|
||||
本轮唯一变化的 pin:`reaAgents 6.7.0 → 6.8.0`。唯一连带变化:`nodeEngines` 与 node probe。
|
||||
|
||||
**例外:Ghidra 12.2 在本机是「有记录的本机覆盖」,不是 pin 变更**(§9)。四项 CVE 的修复版就是 12.2,实测桥接可用、
|
||||
分析结果与 12.1.4 逐字节一致,所以给了 `scripts/ghidra-gate-override.mjs` 这条显式通道;`setup.mjs` 仍按 12.1.4 供给。
|
||||
|
||||
---
|
||||
|
||||
## 1. 6.8.0 是什么
|
||||
|
||||
不是修 bug 的补丁版,**是工具扩容**:
|
||||
|
||||
| 项 | 6.7.0 | 6.8.0 |
|
||||
|---|---|---|
|
||||
| tarball 条目 | 1775 | **2398** |
|
||||
| `node_modules` 条目 | 0 | 0(仍由 `npm install --ignore-scripts` 解闭包) |
|
||||
| 解包体积 | — | 49,251,336 B(压缩包 9,944,902 B) |
|
||||
| MCP 工具 | 207 | **229**(+22) |
|
||||
| CLI 命令 / prompt | — | 175 / 6 |
|
||||
| catalog 记录 | 无 | `dist/mcp-catalog.json`(含 `identity.counts` 与三个 digest) |
|
||||
| engines.node | `^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0` | **`^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`** |
|
||||
| 依赖闭包 | 42 条 | 44 条(+`@bufbuild/protobuf 2.16.0`、+`@msgpack/msgpack 3.1.3`) |
|
||||
|
||||
文件级差异(按路径比对):**+843 / −225**。新增族从目录名就能看出来:`dist/apkSignature/*`(APK 签名与证书)、
|
||||
`dist/application/binaryDiagnostics/*`(PE 证书/导入/加载配置、ELF notes、内核模块、devicetree、Apple IPS、
|
||||
字节熵)、`inspect_model_artifact`(GGUF v2/v3、safetensors 声明)、`inspect_gpu_code_objects`(CUDA fatbin /
|
||||
CUBIN / PTX / HIP bundle 的静态解析)。后两者**不需要外部引擎**(描述里明写 "without loading models or
|
||||
executing code" / "No GPU tools"),所以 `notProvisionable` 不用新增条目。
|
||||
|
||||
catalog 自带的三个 digest(可用来判定「装的是不是同一份工具表」):
|
||||
|
||||
```
|
||||
tools_sha256 a98940ba69acd9c1781d180cee362d4ddab18f77eefdd3e3216f10e1bf98e5e9
|
||||
prompts_sha256 9609379442110570cd52fdea61a777242350873bc28958f54827ca3590e680b7
|
||||
combined_sha256 fe938a7c6eba3763b22ae2022de76dc4a7812d35d86f1d4efd36b9962b04da0b
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 为什么只有 REA 动了:两道闸门实测
|
||||
|
||||
把 6.8.0 的策略模块直接加载执行(不是读代码推断):
|
||||
|
||||
```
|
||||
dist/ghidra/GhidraReleasePolicy.js
|
||||
12.1.4 → verified
|
||||
12.1.5 → compatible (这个 release 并不存在,12.1.x 最后一版就是 12.1.4)
|
||||
12.2 → unsupported
|
||||
12.2.0 → unsupported
|
||||
|
||||
dist/android/JadxRelease.js
|
||||
JADX_RELEASE = { version: "0.7.1",
|
||||
revision: "5844800a486d2248fa949b74c9896285f5b54de2",
|
||||
sha256: "6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75" }
|
||||
↑ 与盘上 vendor/jadx/jadx-headless-mcp-0.7.1-all.jar 的实测哈希一致
|
||||
```
|
||||
|
||||
也就是说:**6.8.0 没有放宽任何一条版本线**。Ghidra 12.2 会照旧让整个 Ghidra 族消失,jadx 0.8.0 会照旧在
|
||||
`load_apk` 之前抛 `Expected engine 0.7.1`。这两项维持 10-24 那轮的决定。
|
||||
|
||||
---
|
||||
|
||||
## 3. engines 抬升:一处声明,三处执行
|
||||
|
||||
REA 抬了下限,本包有三处必须同时跟上,否则会出现「文档说一套、reuse 路径执行另一套」:
|
||||
|
||||
| 位置 | 值 | 谁在校验 |
|
||||
|---|---|---|
|
||||
| `manifest.pins.nodeEngines` | `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1` | `audit-manifest.mjs` §12 与 `package.json engines.node` 逐字比对 |
|
||||
| `package.json engines.node` | 同上 | npm / DSH 市场 |
|
||||
| `components.node.probe.expect.regex` | 见下 | setup 的 reuse 路径(把一个 24.11 的 Node 判成可用就是静默降级) |
|
||||
|
||||
probe 只有一条正则、没有版本比较器,所以写成一条交替:
|
||||
|
||||
```
|
||||
^v(22\.23\.(?:[4-9]|[1-9][0-9]+) # 22.23.4 … 22.23.x
|
||||
|22\.(?:2[4-9]|[3-9][0-9])\.\d+ # 22.24+ … 22.9x
|
||||
|24\.22\.(?:[1-9]|[1-9][0-9]+) # 24.22.1 … 24.22.x
|
||||
|24\.(?:2[3-9]|[3-9][0-9])\.\d+ # 24.23+ … 24.9x
|
||||
|26\.12\.(?:[1-9]|[1-9][0-9]+) # 26.12.1 … 26.12.x
|
||||
|26\.(?:1[3-9]|[2-9][0-9])\.\d+ # 26.13+ … 26.9x
|
||||
|2[7-9]\.\d+\.\d+|[3-9][0-9]\.\d+\.\d+)
|
||||
```
|
||||
|
||||
用 24 组版本正反验证(正例 12 / 反例 12,0 处不符):
|
||||
|
||||
```
|
||||
接受 v22.23.4 v22.23.10 v22.24.0 v22.30.1 v24.22.1 v24.22.9 v24.22.12
|
||||
v24.23.0 v26.12.1 v26.12.15 v26.13.0 v26.20.0 v27.0.0 v30.1.2
|
||||
拒绝 v22.19.0 v22.23.3 v24.11.0 v24.21.0 v24.22.0 v25.0.0 v26.0.0
|
||||
v26.12.0 v20.19.0 v18.20.0
|
||||
```
|
||||
|
||||
**用户可见的后果**:6.7.0 时代合法的 Node 22.19–22.23.3 与 24.11–24.22.0,现在会被 reuse 路径拒绝。
|
||||
钉住的 24.22.1 现在是 24 线**既最新、又最低**可接受的那一个 —— 这种「钉值同时是下限」的情况本包第一次遇到。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实测记录(Windows 11 x64)
|
||||
|
||||
`node scripts/setup.mjs --reuse`:
|
||||
|
||||
```
|
||||
[skip] node — reusing existing node from PATH: F:\jiuguan\node.exe
|
||||
[skip] jdk — reusing existing jdk from JAVA_HOME: F:\game\xbox\
|
||||
[skip] ghidra — reusing existing ghidra from GHIDRA_INSTALL_DIR: D:\ghidra\ghidra_12.1.4_PUBLIC
|
||||
[skip] apktool — already provisioned (3.0.3)
|
||||
[skip] jadxHeadlessMcp — already provisioned (0.7.1)
|
||||
[skip] androidPlatformTools — already provisioned (latest (37.0.1))
|
||||
[ok ] reaAgents — …\vendor\node_modules\rea-agents
|
||||
[ok ] probe — cli=6.8.0 healthy=false tools=229 unmet=10
|
||||
[ok ] smoke — imported java.exe in 26s via ghidra
|
||||
[ok ] shim — resolved to REA 6.8.0
|
||||
setup complete.
|
||||
```
|
||||
|
||||
`healthy=false` 与 `unmet=10` 是**预期**:Hopper / IDA / Kuna / 五个客户端的 registration / skill 都属「调用方自备」,
|
||||
供给刻意不碰(与 6.7.0 时同样的 10 条)。
|
||||
|
||||
独立 stdio MCP 握手(不经 PATH 的 `rea`):
|
||||
|
||||
```
|
||||
initialize → serverInfo {name: rea, version: 6.8.0}, protocol 2024-11-05
|
||||
tools/list → 229, nextCursor null
|
||||
prompts/list → 6
|
||||
```
|
||||
|
||||
`rea doctor --json` 的 identity 段与 tarball 里的 catalog 一致:
|
||||
|
||||
```
|
||||
cli_package_version 6.8.0 · expected_skill_version 49 · sdk 2.3.1
|
||||
catalog.counts {cli_commands: 175, mcp_tools: 229, mcp_prompts: 6}
|
||||
```
|
||||
|
||||
各引擎现场复核:`java --version` = openjdk 25.0.5(宿主 Corretto);`java -jar vendor/apktool/apktool.jar --version`
|
||||
= 3.0.3;`vendor/platform-tools/adb.exe version` = 1.0.41 / 37.0.1;`vendor/jadx/jadx-headless-mcp-0.7.1-all.jar`
|
||||
= 42,619,858 B / `6e5eacf5…`。
|
||||
|
||||
门禁:单测 **88(85 过 / 3 平台跳过 / 0 失败)**、`audit-manifest` **24/24 hash-pinned clean**、
|
||||
`verify-branch.ps1` **ALL CHECKS PASSED**、`host-compat` 10 covered / 5 outside。
|
||||
|
||||
---
|
||||
|
||||
## 5. 供给过程里的一个坑(值得写进排障)
|
||||
|
||||
第一次 `setup --reuse` 在 reaAgents 这一步失败:
|
||||
|
||||
```
|
||||
[FAIL] reaAgents — npm install failed (4294963219): npm error network … ECONNRESET
|
||||
```
|
||||
|
||||
npm 日志显示 `rea-agents-6.8.0.tgz` 取包时 `read ECONNRESET`(`errno -4077`),reify 回滚后 probe 仍读 6.7.0 /
|
||||
207 工具 —— 读起来像「升级根本没生效」。原因不是哈希、不是权限:**setup 的 npm 子进程不继承本脚本自己的代理
|
||||
路由**,而直连 registry 的 tarball 在这条网络上是断的(同一个 URL 用 `Invoke-WebRequest` 却能下完,所以只看
|
||||
浏览器/`curl` 会误判)。
|
||||
|
||||
解法:给 setup 的进程环境加代理后重跑即可。
|
||||
|
||||
```powershell
|
||||
$env:HTTPS_PROXY="http://127.0.0.1:7890"; $env:HTTP_PROXY="http://127.0.0.1:7890"
|
||||
node scripts/setup.mjs --reuse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 仍不能升的四项(与 10-24 结论一致)
|
||||
|
||||
1. **Ghidra 12.2**:`SUPPORTED_GHIDRA_VERSION = "12.1.4"` 未变;跨 minor 即 `unsupported`,Ghidra 族工具整组不可用。
|
||||
—— 但这条闸门**不等于 12.2 坏了**:实际测下来它可用,见 §9。
|
||||
2. **jadx 0.8.0**:`JADX_RELEASE.version` 仍是 0.7.1,`JadxSession` 在其自报版本不等时立刻抛错。
|
||||
3. **JDK 21.0.13**:Adoptium 的 Windows 资产仍停在 21.0.12.1+1;且 21.0.13 只移除 4 个 eMudhra 锚里的 3 个。
|
||||
4. **Node 26**:`26.12.1` 是本轮唯一的 26 线新版本,但它是 current 而非 LTS;本包钉 LTS,且 24.22.1 已满足
|
||||
6.8.0 的 `^24.22.1`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 重开条件
|
||||
|
||||
| 触发 | 动作 |
|
||||
|---|---|
|
||||
| REA 把 `SUPPORTED_GHIDRA_VERSION` 提到 `12.2.x` | 换 Ghidra 源(一个 zip 通吃三平台),重跑冒烟(首次导入 26–64 s) |
|
||||
| REA 把 `JADX_RELEASE` 提到 0.8.0 | 换 jar 的 url/size/sha256(vendor 侧 sha256 可自算),并确认桥接类仍能加载 |
|
||||
| Adoptium 为 21 线发布 Windows 资产 | 换 5 条 jdk 源(win32-x64 / darwin-x64 / darwin-arm64 / linux-x64 / linux-arm64) |
|
||||
| Node 26 转为 LTS | 重新评估是否把 `pins.node` 抬到 26(engines 已允许 `>=26.12.1`) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 本轮没做的事
|
||||
|
||||
- **没有 npm publish、没有 GitHub Release**:`package.json` 到 2.0.13,仓库与 tag 先行,npm `latest` 仍是 2.0.12。
|
||||
- **没有动 Ghidra / jadx / JDK / apktool / platform-tools 的任何一个字节**。
|
||||
- **没有清理 `C:\Users\lenovo\.dsh`** 下的旧 bundle(本机 `DSH_HOME=D:\dsh-home`,那份是历史遗留,与当前宿主无关)。
|
||||
- **没有跑 macOS / Linux 的首次供给分支**(本机只有 Windows)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 能不能升到 Ghidra 12.2(这四个 CVE 的修复版):能,但要覆盖上游闸门
|
||||
|
||||
用户问的是「这些不是漏洞吗,能不能升到最新版、同时保证 REA 能用」。答案分两半:**「升」可以,「默认升」不行**,
|
||||
因为挡住 12.2 的不是兼容性,是 REA 自己的一条版本等值判定。
|
||||
|
||||
### 9.1 试了什么
|
||||
|
||||
```
|
||||
下载 ghidra_12.2_PUBLIC_20261016.zip 569,712,708 B
|
||||
sha256 2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67 ← 与 release body 一致
|
||||
解包 application.version=12.2 · application.java.min=25 · analyzeHeadless.bat / win_x86_64 decompile.exe 均在
|
||||
```
|
||||
|
||||
只改了 **vendored 副本**里那三处常量(`dist/ghidra/GhidraReleasePolicy.js`、`dist/cli-runtime/KunaCommand-*.js`、
|
||||
`dist/mcp/binary-*.js`,都是 `SUPPORTED_GHIDRA_VERSION = "12.1.4"`),把值换成 `12.2`。
|
||||
|
||||
### 9.2 结果
|
||||
|
||||
| 检查 | 12.1.4(pin) | 12.2(覆盖后) |
|
||||
|---|---|---|
|
||||
| `rea doctor` ghidra-version | `12.1.4` ok | `12.2; compatible 12.2.x release` ok |
|
||||
| `rea doctor` ghidra-java | `25.0.5 … accepted a 64-bit full JDK 21 or newer` | `25.0.5 … accepted a 64-bit full JDK 25 or newer` |
|
||||
| `rea analyze whoami.exe` | 52 s · 9 段 / 341 函数 / 308 字符串 | 52 s · 9 段 / 341 函数 / 308 字符串 |
|
||||
| `list_segments`(MCP,经 shim) | 基准 | **逐字节相同** |
|
||||
| `list_procedures`(MCP) | 基准 | **逐字节相同**(74,483 字符) |
|
||||
| `procedure_pseudo_code("entry")` | `FUN_1400021b4(); FUN_140001af0();` | **逐字节相同**(101 字符) |
|
||||
| `provider.version` | `12.1.4` | `12.2` |
|
||||
|
||||
即:**段表、函数表、入口伪代码三样在两个版本上完全一致**,桥接也没有出现 API 漂移的编译/运行错误。
|
||||
|
||||
### 9.3 所以怎么用
|
||||
|
||||
```powershell
|
||||
node scripts/ghidra-gate-override.mjs --status # 现在是什么状态
|
||||
node scripts/ghidra-gate-override.mjs --enable --version 12.2 # 打补丁 + 记录
|
||||
node scripts/ghidra-gate-override.mjs --disable # 还原成上游闸门
|
||||
```
|
||||
|
||||
- 状态写进 `<bundle>/ghidra-gate-override.json`(原值、目标值、改了哪几个文件、何时启用)。
|
||||
- **重跑 `setup.mjs` 会重新供给 rea-agents,补丁随之消失** —— 这是刻意的:选错方向的默认值应该是「回到钉住的
|
||||
已验证组合」,而不是「悄悄继续跑未验证的版本」。
|
||||
- `check-updates.ps1` 认得这份记录:盘上是 12.2 时它打印「本机覆盖 Ghidra 闸门」,不再劝你「重跑 setup 对齐」。
|
||||
- 12.2 要求 **JDK ≥ 25**(`application.java.min=25`)。本机 `JAVA_HOME=F:\game\xbox` 是 Corretto **25.0.5**,
|
||||
正好满足;而本包钉的 vendor JDK 是 **21**,所以「干净机器 + 12.2」还得把 JDK 钉到 25
|
||||
(Adoptium 25 线 Windows 资产是 25.0.4.1+1)。
|
||||
|
||||
### 9.4 没被验证的部分(说清楚)
|
||||
|
||||
只测了 PE 的 `open_binary` / `list_segments` / `list_procedures` / `procedure_pseudo_code` / `analyze`。
|
||||
**没测**:ELF / macOS / COM / DOL / raw 这些走各自 `ReaGhidraPrepare*` 的路径、PSP 扩展、
|
||||
值流(`trace_native_values`)、decompiler hook(`ReaGhidraNoReturnFix`、`ReaGhidraMipsSoftFloat`)的边角。
|
||||
上游没有验证 12.2,这些边角现在记在使用者名下 —— 这正是上游闸门存在的理由,覆盖它就得承担它。
|
||||
|
||||
---
|
||||
|
||||
## 10. CVE-2026-91403(JSSE / eMudhra)为什么不靠「升级」解决
|
||||
|
||||
- **没有已发布的 JDK 移除全部 5 张 eMudhra 锚**:Temurin 21.0.13 只掉了 c1/c3/g3(保留 g1),Corretto 25.0.5 五张全在。
|
||||
所以「升到某个版本就干净」在任何厂商都不成立。
|
||||
- **这条链上没有 Java 侧出站 TLS**:REA 本体是 Node(用 Node 自带的 CA 集,不用 Java truststore);Ghidra 是本地
|
||||
headless,jadx 桥接也是本地进程。Java truststore 里那几张锚在这条链上**没有被使用的调用点**。
|
||||
- 真要清理,只能给 REA 单独做一份剔除了 emsign 锚的 truststore 并通过 JVM 参数指过去;那属于硬化动作,
|
||||
不改变本链路的实际暴露面。本轮没有做。
|
||||
|
||||
---
|
||||
|
||||
## 11. jadx 0.8.0 为什么不在这次
|
||||
|
||||
它没有对应的 CVE 修复诉求(用户的问题是关于漏洞的),而 REA 对它的要求是**精确等于 0.7.1 + 该 jar 的 sha256**;
|
||||
0.8.0 会在 `load_apk` 之前抛 `Expected engine 0.7.1`。要升它同样得覆盖 `JADX_RELEASE`,风险与本轮无关,暂不动。
|
||||
|
||||
---
|
||||
|
||||
## 附:WSL 侧核对
|
||||
|
||||
用户的会话标题带「WSL 环境更新」,所以顺带查了一遍:
|
||||
|
||||
| 项 | 实测 | 判断 |
|
||||
|---|---|---|
|
||||
| WSL 发行版 | Ubuntu 22.04.5 LTS(WSL2,内核 6.18.54.1) | 与本次更新无关 |
|
||||
| WSL 原生 Node | `/usr/bin/node` = **v22.23.4** | 正好等于 6.8.0 的 22 线下限(旧 engines 的 `^22.19.0` 也吃它)→ 无可升级项 |
|
||||
| WSL 侧 `~/.dsh` | 不存在 | 没有第二份 bundle 要供 |
|
||||
| WSL 里的 `dsh` | `/mnt/d/dev/npm-global/dsh`(Windows 侧实体) | 用的仍是 `D:\dsh-home` 那一份 → 更新一处即覆盖两边 |
|
||||
|
||||
结论:**WSL 侧不需要单独更新**,REA 6.8.0 通过共享的 `D:\dsh-home\plugins\rea-bundle` 同时生效。
|
||||
@@ -0,0 +1,225 @@
|
||||
# 2.0.14 — 外部审计的逐条修复与 DSH 宿主适配(2026-11-06)
|
||||
|
||||
输入是一份针对 `dsh-plugin-rea 2.0.13`(提交 `4d8a082`)的外部审计(18 项),外加一次宿主版本适配请求。
|
||||
本轮**没有升级任何上游组件**:REA 6.8.0 / Ghidra 12.1.4 / JDK 21 / apktool 3.0.3 / jadx 0.7.1 / Node 24.22.1 全部原地。
|
||||
|
||||
结论先说:审计的主线判断是对的,而且比它自己写的更一致 —— **这七项动态缺陷的共同点是「验证的对象不是要交付的那个对象」**。
|
||||
一次部分失败被记录成成功、一个坏路径被另一份好安装背书、一条超时的锁被从活进程手里删掉、一个探针只验证「同名组件在某处存在」。
|
||||
所以本轮的修法也统一:让探针去看真正要交付的那份东西,并把「无法证明」和「证明失败」分开。
|
||||
|
||||
---
|
||||
|
||||
## 一、18 项审计条目:逐条状态
|
||||
|
||||
| # | 审计条目 | 严重性 | 状态 | 落点 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 多文件组件部分失败会记录成新版本并假绿 | 高 | **已修** | `scripts/setup.mjs` multi-file 段 |
|
||||
| 2 | 已记录组件的探针没绑定 `recorded.path`(且不校验 pin) | 高 | **已修** | `scripts/setup.mjs` `recordedMatchesPin()` |
|
||||
| 3 | 损坏 bundle 阻止 auto 回退,状态命令仍退出 0 | 中 | **已修** | `bin/rea-shim.mjs` `bundleUsable` |
|
||||
| 4 | 六小时过期会删掉活进程的锁 | 中 | **已修** | `bin/rea-shim.mjs` + `setup.mjs` 心跳/token |
|
||||
| 5 | JDK 探针不保证 21+ | 中 | **已修** | `manifest/bundle.manifest.json` |
|
||||
| 6 | 当前策略使 Frida 原生绑定缺失 | 中 | **已修** | `scripts/setup.mjs` `ensureFridaBindings()` |
|
||||
| 7 | 损坏的 `setup.lock` 让手动 setup 抛 `SyntaxError` | 低 | **已修** | `scripts/setup.mjs` `acquireRunLock()` |
|
||||
| 8 | `check-updates.ps1` 假绿(drift/warn 不进结论) | 中 | **已修** | `check-updates.ps1` 判绿条件 |
|
||||
| 9 | `bin/rea-guide.mjs` 忽略 `REA_BUNDLE_ROOT` | 低 | **已修** | root 解析链统一 |
|
||||
| 10 | `ghidra-gate-override.mjs` 忽略自定义 root | 低 | **已修** | 同上 |
|
||||
| 11 | `--dry-run` 仍创建 root/cache/log | 低 | **已修** | 一个字节都不写 |
|
||||
| 12 | 归档替换先删后 rename,失败丢两份 | 中 | **已修** | `.backup` + 回滚 |
|
||||
| 13 | `bundle.json`/`mirror.json` 非原子写 | 中 | **已修** | `writeJsonAtomic()` |
|
||||
| 14 | override 先 patch 后写恢复记录 | 低 | **已修** | 先落盘再改 + 回滚 |
|
||||
| 15 | override `--disable` 缺 vendor containment | 低 | **已修** | `containedPath()` + `lstat` |
|
||||
| 16 | `tarfile.extractall()` 未用安全 filter | 纵深 | **已修** | `filter='data'` |
|
||||
| 17 | Zod 4.4.3 落在 advisory 内(依赖闭包未锁) | 高 | **已修** | `scripts/vendor-lock/` + `npm ci` |
|
||||
| 18 | `engines.node` 与宿主自带 Node 不一致 | 未列入 | **未解决,已记录** | 见 §五-1 |
|
||||
|
||||
---
|
||||
|
||||
## 二、关键缺陷的修法与实测证据
|
||||
|
||||
### 1. 多文件组件全有或全无(#1、#12)
|
||||
|
||||
复现形状:apktool 的 jar 下载失败、wrapper 成功,目标目录里残留 2.0.0 的 jar 通过了 `java -jar --version`,
|
||||
记录写成 3.0.3 且哈希取自 wrapper,下一次运行 `already provisioned (3.0.3)` 不再下载。
|
||||
|
||||
修法四层:
|
||||
1. **全部 entry 先取全**,缺一个即整体失败并打印 `N of M entries were available; nothing was installed`;
|
||||
2. 所有文件先进 `<target>.staging`,**全就位后才原子换入**;
|
||||
3. 旧目录改名 `.backup`,rename 失败或**探针失败**都回滚并明说 `the previous install was put back`;
|
||||
4. 记录改成**每 entry 独立**的 `{id, version, sha256, tofu}`,`jarName`/`sha256` 按类型(`.jar`)而非列表顺序选主。
|
||||
|
||||
真机实测(本机 `D:\dsh-home\plugins\rea-bundle`,先手工备份 `vendor/apktool`):摘掉 `bundle.json` 的 apktool 记录后
|
||||
`--only apktool` 走完整路径 —— jar 用缓存、wrapper 重新下载(2386 B,pin 版本),目录里**恰好两个文件**
|
||||
(历史遗留的 `apktool.bat.bak-2348` 随旧目录被丢弃,这是「目录内容严格等于 manifest」的预期结果),
|
||||
无残留 `.staging`/`.backup`,`java -jar apktool.jar --version` 答 `3.0.3`,退出 0。
|
||||
|
||||
### 2. 探针绑定记录路径 + pin 校验(#2)
|
||||
|
||||
`recorded` 分支现在用**独立 context** 把该组件解析到 `recorded.path` 再探针(与 reuse 分支同一手法),
|
||||
并新增 `recordedMatchesPin()`:记录版本必须等于 manifest 的 pin;JDK 按**主版本下限**匹配。
|
||||
|
||||
一个必须处理的例外:**已启用的 Ghidra 闸门覆盖**。本机 manifest 钉 12.1.4、盘上是 12.2,
|
||||
若 pin 校验不认 `ghidra-gate-override.json`,一次普通 setup 会把操作员显式打开的覆盖**悄悄重钉回 12.1.4** ——
|
||||
这是「修一个新缺陷时制造另一个」。实测 `--dry-run`:
|
||||
|
||||
```
|
||||
[skip] node — already provisioned (24.22.1) — pinned 24.22.1
|
||||
[skip] jdk — already provisioned (25.0.5) — major 25 satisfies the pin's major 21
|
||||
[skip] ghidra — already provisioned (12.2) — recorded 12.2 matches the enabled gate override
|
||||
[skip] apktool — already provisioned (3.0.3) — pinned 3.0.3
|
||||
[skip] jadxHeadlessMcp — already provisioned (0.7.1) — pinned 0.7.1
|
||||
[skip] androidPlatformTools — already provisioned (latest (37.0.1)) — pinned latest (37.0.1)
|
||||
[skip] reaAgents — already provisioned (6.8.0) — pinned 6.8.0
|
||||
```
|
||||
|
||||
### 3. 统一「bundle 可用」判定(#3)
|
||||
|
||||
`loaded.ok`(JSON 解析成功)曾被当成「已供给」,一口气决定三件事:启动选择、状态退出码、是否进入修复。
|
||||
现在 `bundleUsable = loaded.ok && resolveRea(...).ok`,三处共用:
|
||||
|
||||
- 启动选择:不可用的 bundle 不再凭「记录版本更新」赢过可用的系统 REA;
|
||||
- `--shim-status`:不可用且无系统 REA → 退出 **78**(原来 0),报告新增 `bundleUsable` 字段;
|
||||
- 修复路径:状态可解析但运行时缺失,现在也会进入 auto-provision(原来直接 `fail()`)。
|
||||
|
||||
### 4. 锁:存活 pid 是硬证据,过期只是软证据(#4)
|
||||
|
||||
原规则只信任 6 小时内的锁,超时后**不问持有者是否还活着**就删。慢下载确实会跑过六小时,删锁后第二个
|
||||
provisioning 会写同一份 cache、同一批 `.staging` 和同一份 `bundle.json`。
|
||||
|
||||
现在:
|
||||
- pid 活着 → **永不删**;超过窗口报 `in-flight-stale`(附 pid 与「多久没汇报」),由操作员决定;
|
||||
- pid 证明已消失 → 回收;
|
||||
- **无 pid 的旧式数字锁**(没有人可查)→ 仍按时间处理,否则「无法判断」会永远读成「活着」;
|
||||
- 配套:`setup` 的 `log()` 兼作**心跳**(30 s 节流刷新 `at`)—— `spawnSync` 下载会饿死定时器,所以心跳挂在进度输出上;
|
||||
- shim→setup 的交接从 **PID 交接改为 token 交接**(`REA_BUNDLE_LOCK_TOKEN`):原窗口里 setup 可能在 shim 改写
|
||||
`child.pid` 之前就读到一个不属于自己的活 pid,于是倒在自己的锁上、退出 75(审计把它列为静态风险,100 次自然启动未命中)。
|
||||
|
||||
`failed` 记录**仍然优先于 pid 检查**:失败进程的 pid 必然已消失,顺序颠倒会把「阻止无限重试的刹车」当成陈旧锁删掉。
|
||||
|
||||
### 5. JDK 探针(#5)
|
||||
|
||||
`jdk.compiler` 只区分完整 JDK 与裁剪运行时 —— 真实 Java 17.0.20 同样打印 `[email protected]`。
|
||||
`expect` 现在同时要求两件事(`runProbe` 对 `contains` 和 `regex` 是 AND):
|
||||
`contains: jdk.compiler` **且** `regex` 匹配版本段 ≥ 21(21–29 / 30–99 / 100+)。8 组版本串正反验证通过,
|
||||
含本机真实输出 `[email protected]`。
|
||||
|
||||
### 6. Frida 原生绑定(#6)
|
||||
|
||||
实测复现:`import('frida')` → `Could not locate the bindings file. Tried: … frida\build\frida_binding.node`。
|
||||
原因是 frida 的 `install` 脚本(`prebuild-install`)是放置这个 .node 的**唯一**途径,而 `--ignore-scripts` 跳过了它。
|
||||
|
||||
修法不是删 `--ignore-scripts`(那是正确的加固),而是**点名这一个包**:`npm rebuild frida` → 用
|
||||
`import('frida')` + `enumerateDevices()` 验证。修好后 135.7 MB 的 `frida_binding.node` 落地,
|
||||
实测 `IMPORT OK` + 3 个设备(`local/local`、`socket/remote`、`barebone/remote`),即 `FRIDA_OK 3`。
|
||||
|
||||
**检查放在 `main` 层而不是 provisioning 路径里** —— 否则「已记录 6.8.0 的存量 bundle」(也就是每一次升级)
|
||||
永远不会进入那段代码,绑定会一直缺着而每行都在说 `already provisioned`。结果写进
|
||||
`components.reaAgents.frida = {available, binding, sha256, devices}`;不可用时按 `browser` 的先例报 `skip`,
|
||||
不把一次可用供给判成失败(`step(..., "FAIL")` 会进 `failed` 数组并让 setup 退出 1)。
|
||||
|
||||
### 7. 依赖闭包(#17)
|
||||
|
||||
`[email protected]` 对 zod 是**精确锁 `"4.4.3"`**,所以「装一个 4.6.7 覆盖它」会得到**两份 zod**,REA 仍吃坏的那份
|
||||
(实测否决)。`overrides` 是唯一不改发布 tarball 的杠杆,`npm ci` + lockfile 再补上「每次同一份字节」。
|
||||
|
||||
放大实测(同一个 `.max(2)` schema 喂 25 个元素):zod 4.4.3 → **26 条 issue**,4.6.7 → **1 条**;
|
||||
REA 自己的 `inspectAnalysisSettingsInputSchema` 同样 26 → 1。
|
||||
|
||||
落地:`scripts/vendor-lock/`(lockfileVersion 3,183 条目)→ `zod 4.6.7` / `minimatch 11.0.5` / `glob 14.0.4`,
|
||||
各单副本。兼容性实测:`@electron/asar` 在 glob 14 / minimatch 11 下 create/list/extract/stat **往返通过**;
|
||||
REA 真实 MCP stdio 握手 `tools/list` 仍是 **229/229 且每个都有 inputSchema**(与 4.4.3 基线一致)。
|
||||
|
||||
---
|
||||
|
||||
## 三、修复过程中发现的、审计没提的问题
|
||||
|
||||
三处都是「修完之后自查」抓到的,不是审计输入的:
|
||||
|
||||
1. **`"21"` 这种只有主版本的 pin 被当成「家族前缀」**(`scripts/pin-match.ps1`、`bin/rea-guide.mjs`)。
|
||||
`25.0.5` 对 pin `21` 被判成漂移 → `check-updates.ps1` 说「重跑 setup 对齐」,而 setup 说
|
||||
「already provisioned (25.0.5) — major 25 satisfies the pin's major 21」。Ghidra 要求的是「JDK 21 **或更新**」,
|
||||
所以**只有主版本的 pin 是下限,不是前缀**。三个文件现在的判定一致。
|
||||
2. **`check-updates.ps1` 的假红**:`rea doctor` 退出码 1 涵盖「任何可选能力未配置」(Hopper / IDA / Kuna /
|
||||
skill 副本 / 五个客户端登记),于是一台供给完好的机器恒报「需要动作」。退出码降为 `[上游]`,
|
||||
实际状态由输出解析出的 provider 检查决定 —— 假绿修完之后不能顺手留下假红。
|
||||
3. **依赖锁的新读取绕过了 BOM 处理**:`.mjs` 里新增的两处 `JSON.parse(readFileSync(...))` 会被
|
||||
`verify-branch.ps1` 的「no BOM-blind JSON read」判定拦下(确有其事:带 BOM 的 lock 会被读成「锁里什么都没钉」)。
|
||||
改为走 `readJsonSafe()`。附带发现 `verify-branch.ps1` 的这条检查会把**注释里**的同名字面量也算进去 ——
|
||||
所以注释措辞一并改掉了。
|
||||
|
||||
---
|
||||
|
||||
## 四、DSH 宿主适配(0.2.1-rc.3 → 0.2.2-rc.1)
|
||||
|
||||
细节见 [`host-compat-2026-11-06.md`](host-compat-2026-11-06.md)。要点:
|
||||
|
||||
- `HOST_RELEASES` 10 → **16**,`engines.dsh` 声明与 `OUTSIDE_HOSTS` 都没动(6 个新版本全在范围内)。
|
||||
- 6 个 tag 三源交叉核实(releases 页 / atom / npm registry)**全部真实存在**,原有 10 条一条未被撤下;
|
||||
`0.2.2-rc.1` 是当前 `latest`。排序按**发布日**而非 semver(`0.2.1-rc.5` 晚于 `0.2.2-alpha.2`),这处倒序钉死在测试里。
|
||||
- 接口面逐条核对:6 个版本都没有触及本插件依赖的五处表面。唯一沾边的是 `0.2.2-rc.1` 的
|
||||
Cordis Core 4.1.0 / Loader 1.1.0,**已在本机 0.2.2-rc.1 上实测**:`GET /dsh-plugin-rea/guide-state` → 200。
|
||||
- 现场取证市场语义:`dshmarket/lib/verify.js` 与 `discovery-compatibility.js` 都**只读 `engines.dsh`**,
|
||||
不读 `engines.node`。
|
||||
|
||||
---
|
||||
|
||||
## 五、没有解决的,以及为什么
|
||||
|
||||
1. **`engines.node` 仍是 `^22.23.4 || ^24.22.1 || >=26.12.1`**,而 `0.2.2-rc.1` 桌面宿主自带 Node **24.21.0**。
|
||||
实测市场不读这个字段,npm 默认也只警告 —— **但 `engine-strict=true` 的用户会装不上**。
|
||||
没放宽的原因不是怕,而是它现在被 `audit-manifest.mjs` 强制与 `manifest.pins.nodeEngines`(REA 的真实要求)
|
||||
**逐字相等**;拆开「插件的 Node 要求」与「REA 的 Node 要求」是一次独立改动,塞进本轮会让那条守护变模糊。
|
||||
运行期不受影响:REA 用 `bundle.json` 的 `paths.node`(24.22.1)启动。
|
||||
2. **`dsh.client.inject` 仍列出 `@deepseek-ai/dsh-client-runtime`**,该包在 `0.2.2-rc.1` 宿主里**一次都不出现**
|
||||
(331 个 `@deepseek-ai` 包目录、整份 213 MB asar 字节扫描,0 命中;它是构建期 `devDependency`)。
|
||||
宿主对未知 inject 项**静默跳过**(`dsh-client-modules/lib/client.js` 的 `graphRows.get()` 取不到就什么也不做),
|
||||
且本插件 `client.js` 不 require 它,真实闸门是服务 `["slots"]`。所以卡片照常渲染;保留它是为了不损害旧宿主适配。
|
||||
属清理项,不是缺陷。
|
||||
3. **Ghidra 默认 pin 仍是 12.1.4**(带那四个 CVE)。审计明确说这不是本轮新发现,也明确说「不能只把 manifest 改成
|
||||
12.2」—— 12.2 需要 JDK 25+ 且 REA 未验证,只能通过 `ghidra-gate-override.mjs` 显式开启。
|
||||
**在升级这个 pin 之前,不要用内置 Ghidra 分析来源不可信的样本。**
|
||||
4. **硬链接逃逸没做 containment**:同卷硬链接与 bundle 内文件共享 inode,纯路径检查区分不了。
|
||||
本轮拒绝的是 `../`、绝对路径、符号链接/junction(均已实测)。
|
||||
5. **minimatch / glob 在 REA 默认 MCP 路径上的可达性仍未证实**:本轮把它们抬到修复线上,没有证明可利用性。
|
||||
`glob 13 → 14` 是跨大版本(asar 声明 `^13.0.2`),已用 asar 往返与 229 工具 schema 验证,但**没有**把真实
|
||||
`.asar` 喂进 REA 的 asar 工具端到端跑一遍(没有现成样本)。
|
||||
6. **lock 放在 `scripts/vendor-lock/` 而不是仓库根**:根级新目录不在 `package.json` 的 `files` 白名单里,
|
||||
发出去会静默走降级路径。setup 同时认 `HERE/vendor-lock` 与 `PLUGIN_ROOT/vendor-lock`,
|
||||
所以将来搬到根级只需加一条 `files`,代码不用动。已实测 `npm pack` 确实带上了这两个文件。
|
||||
7. **CI 时序**:`bundle:deps` 已作为独立 step 接进 `validate.yml` 的两个 job,但它是在本轮**第二个**提交里补上的
|
||||
—— 第一次推送用的凭据缺 GitHub `workflow` scope,服务端拒绝改 workflow 文件
|
||||
(`refusing to allow an OAuth App to create or update workflow … without 'workflow' scope`),
|
||||
所以那一次提交刻意排除了 workflow 改动。第二次换用带 `workflow` scope 的凭据后才补上,
|
||||
期间 CI 的 Windows job 先红了一次(原因见 §三 的测试夹具 bug),已修复。
|
||||
|
||||
---
|
||||
|
||||
## 六、验证清单
|
||||
|
||||
```
|
||||
单测 154 项:151 过 / 0 失败 / 3 跳过
|
||||
setup.test 98 · deps.test 17 · host-compat.test 25 · hardening.test 14
|
||||
manifest audit 24 条下载 entry 全部 SHA-256 钉死,0 TOFU,clean
|
||||
依赖闸门 zod 4.6.7 · minimatch 11.0.5 · glob 14.0.4,各单副本
|
||||
宿主兼容 16 releases covered,5 correctly outside
|
||||
verify-branch ALL CHECKS PASSED
|
||||
```
|
||||
|
||||
新增回归测试(**在 2.0.13 的代码上会红**):部分失败不成假绿且第二次仍重试、记录路径不被 vendor 背书、
|
||||
pin 漂移不被信任、JDK 17/11/9 被拒、`--dry-run` 不写盘、坏锁不抛异常、`tarfile` 带 filter、
|
||||
坏 bundle 回退系统 REA、`--shim-status` 对不可用 bundle 返回 78、活 pid 的过期锁不被删。
|
||||
|
||||
真机链路:`--dry-run` 7 组件正确 skip → 强制重装 apktool 走 staging(无残留)→ `--reuse` 全量 8 组件 +
|
||||
`fridaBindings FRIDA_OK 3` → `probe 229 tools` / `smoke 28 s` / `shim 6.8.0`,退出 0;
|
||||
`check-updates.ps1` 由退出 1(假绿/假红混合)转为退出 0。
|
||||
|
||||
## 七、复现命令
|
||||
|
||||
```powershell
|
||||
cd D:\dsh-plugin-rea
|
||||
node --test ./scripts/setup.test.mjs ./scripts/deps.test.mjs ./scripts/host-compat.test.mjs ./scripts/hardening.test.mjs
|
||||
node scripts/audit-manifest.mjs
|
||||
node scripts/check-dependency-advisories.mjs
|
||||
node scripts/host-compat.mjs
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-branch.ps1
|
||||
node scripts/setup.mjs --dry-run --root "$env:DSH_HOME\plugins\rea-bundle"
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Package entry — a loadable package, plus the read-only route behind the
|
||||
* one-shot setup guide.
|
||||
*
|
||||
* Two jobs, deliberately kept apart:
|
||||
*
|
||||
* 1. Make this package loadable. The row that does the real work lives in
|
||||
* `cordis.patch.yml`: it inserts one row of `@deepseek-ai/dsh-mcp-client`
|
||||
* that launches `bin/rea-shim.mjs`. A package that declares no
|
||||
* `main`/`exports` is judged by its consumers as "has no entry file",
|
||||
* whether or not the loader needs one: dshmarket's `hasLoadableEntry`
|
||||
* asks that on every update and rolls a working build back when the answer
|
||||
* is false (measured 2026-10-18, 2.0.1 → 2.0.4), and npm metadata resolves
|
||||
* `main` too. So this is a real module with a real Cordis shape.
|
||||
*
|
||||
* 2. Answer GET /dsh-plugin-rea/guide-state for `client.js`. The guide has to
|
||||
* tell a first install apart from an upgrade — not by guessing, but by
|
||||
* comparing the REA version recorded in `plugins/rea-bundle/bundle.json`
|
||||
* with the version this package pins. `bin/rea-guide.mjs` computes it as a
|
||||
* pure function; this file only serves the result. Nothing here provisions,
|
||||
* spawns or mutates: the route is read-only, and the guide never runs
|
||||
* setup for the user.
|
||||
*
|
||||
* The route is mounted only when the host has a webServer (the Web GUI), through
|
||||
* `ctx.inject`, so a headless/ACP host loads this plugin exactly like before.
|
||||
*/
|
||||
|
||||
import { GUIDE_ROUTE, computeGuideState, localPackageFacts } from './bin/rea-guide.mjs'
|
||||
|
||||
/** Cordis plugin name. Also the package name, which is what the loader expects. */
|
||||
export const name = 'dsh-plugin-rea'
|
||||
|
||||
/** Cached package facts; the files change only when the package is replaced. */
|
||||
let cachedFacts = null
|
||||
|
||||
function packageFacts() {
|
||||
if (!cachedFacts) cachedFacts = localPackageFacts()
|
||||
return cachedFacts
|
||||
}
|
||||
|
||||
function sendJson(res, status, payload, headOnly = false) {
|
||||
const body = JSON.stringify(payload)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
})
|
||||
res.end(headOnly ? undefined : body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the read-only guide route on a host that has a webServer.
|
||||
*
|
||||
* `webServer.register` takes `{ kind: 'exact', path, handler }` and returns a
|
||||
* disposer; `ctx.effect` ties that disposer to this plugin's lifetime, so a
|
||||
* hot disable removes the route instead of leaving a handler over unloaded code.
|
||||
*/
|
||||
function mountGuideRoute(host) {
|
||||
const server = host && host.webServer
|
||||
if (!server || typeof server.register !== 'function') return
|
||||
if (typeof host.effect !== 'function') return
|
||||
|
||||
host.effect(() =>
|
||||
server.register({
|
||||
kind: 'exact',
|
||||
path: GUIDE_ROUTE,
|
||||
handler: (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
sendJson(res, 405, { error: 'GET only' })
|
||||
return
|
||||
}
|
||||
const facts = packageFacts()
|
||||
const state = computeGuideState({
|
||||
pluginVersion: facts.pluginVersion,
|
||||
pinnedRea: facts.pinnedRea,
|
||||
})
|
||||
sendJson(res, 200, state, req.method === 'HEAD')
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: error && error.message ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the guide route when the host can serve it.
|
||||
*
|
||||
* `ctx.inject` is the normal path: it runs the callback once `webServer` exists,
|
||||
* and never on a host that has none. The direct fallback keeps older Cordis
|
||||
* shapes working. Neither path touches the MCP row — that row is the bundle
|
||||
* patch's, and registering a second `serverName: rea` here would be the exact
|
||||
* duplicate the shim warns about.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
if (!ctx) return
|
||||
if (typeof ctx.inject === 'function') {
|
||||
try {
|
||||
ctx.inject(['webServer'], (host) => mountGuideRoute(host))
|
||||
return
|
||||
} catch {
|
||||
/* fall through to the direct check */
|
||||
}
|
||||
}
|
||||
if (ctx.webServer) mountGuideRoute(ctx)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
{
|
||||
"$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.8.0",
|
||||
"ghidra": "12.1.4",
|
||||
"jdk": "21",
|
||||
"apktool": "3.0.3",
|
||||
"jadxHeadlessMcp": "0.7.1",
|
||||
"node": "24.22.1",
|
||||
"nodeEngines": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
||||
},
|
||||
"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\\.23\\.(?:[4-9]|[1-9][0-9]+)|22\\.(?:2[4-9]|[3-9][0-9])\\.\\d+|24\\.22\\.(?:[1-9]|[1-9][0-9]+)|24\\.(?:2[3-9]|[3-9][0-9])\\.\\d+|26\\.12\\.(?:[1-9]|[1-9][0-9]+)|26\\.(?:1[3-9]|[2-9][0-9])\\.\\d+|2[7-9]\\.\\d+\\.\\d+|[3-9][0-9]\\.\\d+\\.\\d+)" },
|
||||
"why": "REA 6.8.0 raised its engines to ^22.23.4 || ^24.22.1 || >=26.12.1, so the pinned Node 24.22.1 is now exactly the floor of the 24 line. A loose '^v\\d+' accepted Node 20, which then fails at REA runtime; the reuse path must reject it here. Enforced as one alternation because a probe has no version comparator: 22.23.4-22.23.x, 22.24+, 24.22.1-24.22.x, 24.23+, 26.12.1-26.12.x, 26.13+, 27+. 22.23.3, 24.22.0 and 26.12.0 are below the declared floor and must not pass."
|
||||
},
|
||||
"sources": {
|
||||
"win32-x64": {
|
||||
"version": "24.22.1",
|
||||
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip",
|
||||
"format": "zip",
|
||||
"size": 37748064,
|
||||
"sha256": "d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-win-x64.zip)",
|
||||
"sha256VerifiedOn": "2026-10-23",
|
||||
"note": "Checksum and unpack verified on this machine: the extracted node.exe runs standalone (v24.22.1), no installer and no registry writes. 24.22.1 is Node's 2026-10-21 security release (Brotli decoder out-of-bounds write RCE, two TLS hostname bypasses, eMudhra intermediate removal); 24.21.0 must not be redistributed. REA 6.8.0 declares ^24.22.1 as the floor of the 24 line, so this version is now both the newest 24.x and the oldest acceptable one — a 24.21.x runtime must be rejected by the reuse path even though it was fine under 6.7.0."
|
||||
},
|
||||
"win32-arm64": {
|
||||
"version": "24.22.1",
|
||||
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-arm64.zip",
|
||||
"format": "zip",
|
||||
"size": 33786263,
|
||||
"sha256": "a0c807319fede24c4a0d8f84564c82a619bd313b16b10c007c383c2609e99171",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-win-arm64.zip), size confirmed by HEAD",
|
||||
"sha256VerifiedOn": "2026-10-23"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"version": "24.22.1",
|
||||
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-x64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 54382566,
|
||||
"sha256": "d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-darwin-x64.tar.gz)",
|
||||
"sha256VerifiedOn": "2026-10-23"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"version": "24.22.1",
|
||||
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-darwin-arm64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"size": 53062778,
|
||||
"sha256": "89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-darwin-arm64.tar.gz)",
|
||||
"sha256VerifiedOn": "2026-10-23"
|
||||
},
|
||||
"linux-x64": {
|
||||
"version": "24.22.1",
|
||||
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz",
|
||||
"format": "tar.xz",
|
||||
"size": 32026916,
|
||||
"sha256": "6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-linux-x64.tar.xz)",
|
||||
"sha256VerifiedOn": "2026-10-23",
|
||||
"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.22.1",
|
||||
"url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-arm64.tar.xz",
|
||||
"format": "tar.xz",
|
||||
"size": 30978868,
|
||||
"sha256": "dbabf7a1cbf947e6059ffad2c27b80041db97dea1e553b62afdee7f13fca336e",
|
||||
"sha256Source": "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt (line: node-v24.22.1-linux-arm64.tar.xz)",
|
||||
"sha256VerifiedOn": "2026-10-23"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"regex": "jdk\\.compiler@(?:2[1-9]|[3-9]\\d|\\d{3,})\\b"
|
||||
},
|
||||
"why": "Two independent facts, and BOTH are required (runProbe applies contains and regex as an AND). `contains: jdk.compiler` separates a full JDK from a trimmed runtime, but it says nothing about the VERSION: a real Java 17.0.20 also prints `[email protected]`, and setup accepted a JAVA_HOME pointing at it, recorded `{version: 17.0.20, reused: true}`, and only then did Ghidra 12.1 (JDK 21+) fail later in a different process. The regex matches the version segment of that same line and demands major >= 21 (21-29 / 30-99 / 100+); 1x, single digits and two-digit values below 21 do not match. "
|
||||
},
|
||||
"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-23",
|
||||
"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-23",
|
||||
"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-23",
|
||||
"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-23",
|
||||
"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-23",
|
||||
"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-23",
|
||||
"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-23",
|
||||
"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-23",
|
||||
"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-23 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-23",
|
||||
"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-23 by downloading the archive and hashing it locally (16,110,554 bytes)",
|
||||
"sha256VerifiedOn": "2026-10-23",
|
||||
"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-23); Google publishes one darwin zip for both architectures.",
|
||||
"sha256VerifiedOn": "2026-10-23",
|
||||
"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-23 by downloading the archive and hashing it locally (9,054,187 bytes)",
|
||||
"sha256VerifiedOn": "2026-10-23",
|
||||
"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-23), recorded so the mismatch is explicit instead of implied.",
|
||||
"sha256VerifiedOn": "2026-10-23",
|
||||
"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\\.8\\.0(\\s|$)" }
|
||||
},
|
||||
"sources": {
|
||||
"npm": {
|
||||
"version": "6.8.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: 2398 entries in 6.8.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.8.0 (published 2026-10-25, npm latest; 6.6.0 was never on npm) does raise the runtime floor: engines.node went from ^22.19.0 || ^24.11.0 || >=26.0.0 to ^22.23.4 || ^24.22.1 || >=26.12.1, which makes the pinned Node 24.22.1 exactly the floor of its line — hence pins.nodeEngines and the node probe had to move with it. bin is still scripts/rea.mjs, and the dependency closure gained exactly two packages (@bufbuild/protobuf 2.16.0, @msgpack/msgpack 3.1.3), so the same npm install path resolves it. This one IS a tool expansion: the shipped dist/mcp-catalog.json records identity.counts = 229 MCP tools / 175 CLI commands / 6 prompts (6.7.0 shipped no catalog and exposed 207), with new families for APK signatures, binary diagnostics, model artifacts (GGUF/safetensors) and CUDA/HIP code objects. The two version gates this bundle cares about did NOT move: SUPPORTED_GHIDRA_VERSION is still 12.1.4 (so Ghidra 12.2 is still unsupported) and JADX_RELEASE is still 0.7.1 with the same 6e5eacf5… sha256 (so jadx 0.8.0 is still rejected) — those pins stay put."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
+46
-4
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"name": "dsh-plugin-rea",
|
||||
"version": "1.0.1",
|
||||
"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.16",
|
||||
"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",
|
||||
"exports": {
|
||||
".": "./index.mjs",
|
||||
"./client": "./client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "czcdr",
|
||||
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
|
||||
@@ -27,19 +33,55 @@
|
||||
"ghidra",
|
||||
"binary-analysis"
|
||||
],
|
||||
"bin": {
|
||||
"rea-bundle-setup": "./scripts/setup.mjs",
|
||||
"rea-bundle-status": "./bin/rea-shim.mjs"
|
||||
},
|
||||
"files": [
|
||||
"cordis.patch.yml",
|
||||
"index.mjs",
|
||||
"client.js",
|
||||
"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": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"check-updates": "powershell -NoProfile -File ./check-updates.ps1"
|
||||
"check-updates": "node ./scripts/check-updates.mjs",
|
||||
"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:deps": "node ./scripts/check-dependency-advisories.mjs",
|
||||
"bundle:test": "node --test ./scripts/setup.test.mjs ./scripts/deps.test.mjs ./scripts/host-compat.test.mjs ./scripts/hardening.test.mjs",
|
||||
"bundle:host-compat": "node ./scripts/host-compat.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.23.4 || ^24.22.1 || >=26.12.1",
|
||||
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"assets/screenshot-1.png",
|
||||
"assets/screenshot-2.png"
|
||||
]
|
||||
@@ -0,0 +1,547 @@
|
||||
#!/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`],
|
||||
]) {
|
||||
// `next` is a string and `Number.isFinite` does not coerce, so testing
|
||||
// the bare split element was always false and this branch never ran —
|
||||
// item 13 could not fail no matter what the probe accepted. Convert
|
||||
// first; the guard exists only to skip a NaN major/minor/patch.
|
||||
if (Number.isFinite(Number(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,267 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Offline advisory gate for the vendored REA dependency closure.
|
||||
*
|
||||
* `scripts/setup.mjs` installs `rea-agents` into `<root>/vendor` from
|
||||
* `vendor-lock/package-lock.json`. That lock is the only thing that decides which
|
||||
* zod / minimatch / glob the vendored REA actually runs on, and it is regenerated
|
||||
* by hand, so it can silently fall behind the advisories it was created for. This
|
||||
* gate is the part that fails loudly:
|
||||
*
|
||||
* - every copy of each advisory-affected package in the lock must be at or above
|
||||
* the first fixed version;
|
||||
* - the lock must still pin the REA version the manifest asks for (a stale lock
|
||||
* would install the wrong server, and setup.mjs would skip the lock only after
|
||||
* burning a run);
|
||||
* - the `overrides` that setup.mjs falls back to and the `overrides` recorded in
|
||||
* vendor-lock/package.json must agree with each other and with the lock, so the
|
||||
* degraded install path cannot resolve something the audited path would not.
|
||||
*
|
||||
* It reads files only: no registry, no network, no npm. Run it in CI next to the
|
||||
* test suite (`node ./scripts/check-dependency-advisories.mjs`), or by hand after
|
||||
* changing a pin.
|
||||
*
|
||||
* Exit codes: 0 = every floor met, 1 = a floor is violated / the lock is stale or
|
||||
* inconsistent, 2 = the lockfile could not be read at all.
|
||||
*
|
||||
* Regenerating the lock after an upstream pin bump:
|
||||
*
|
||||
* cd scripts/vendor-lock
|
||||
* npm install --package-lock-only --ignore-scripts
|
||||
*
|
||||
* and commit the resulting package-lock.json together with the manifest change.
|
||||
*/
|
||||
import { existsSync, 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 PLUGIN_ROOT = path.resolve(HERE, "..");
|
||||
|
||||
/**
|
||||
* First fixed version per advisory, with the advisory that set it. The floors are
|
||||
* the lower bound of "not affected"; the pins in vendor-lock are the versions the
|
||||
* closure actually resolves and are expected to sit above them.
|
||||
*/
|
||||
export const ADVISORY_FLOORS = [
|
||||
{
|
||||
name: "zod",
|
||||
floor: "4.6.6",
|
||||
advisory: "GHSA-mrhx-c7xc-m3w5",
|
||||
impact: "memory exhaustion while validating oversized constrained collections",
|
||||
},
|
||||
{
|
||||
name: "minimatch",
|
||||
floor: "11.0.1",
|
||||
advisory: "GHSA-g3hv-75wg-m249",
|
||||
impact: "globparts amplification on an attacker-chosen pattern",
|
||||
},
|
||||
{
|
||||
name: "glob",
|
||||
floor: "14.0.2",
|
||||
advisory: "GHSA-3g3m-2fjr-6pw4",
|
||||
impact: "AbortSignal cancellation bypass keeps traversing after a timeout",
|
||||
},
|
||||
];
|
||||
|
||||
/* Same comparison setup.mjs uses: dotted numerics, prerelease suffix ignored. */
|
||||
export function compareVersions(left, right) {
|
||||
const parts = (value) => String(value).split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
const a = parts(left);
|
||||
const b = parts(right);
|
||||
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
||||
const one = a[index] ?? 0;
|
||||
const other = b[index] ?? 0;
|
||||
if (one !== other) return one < other ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Every installed copy of `name` in a lockfile, including nested duplicates. */
|
||||
export function lockCopies(lock, name) {
|
||||
const suffix = `node_modules/${name}`;
|
||||
const found = [];
|
||||
for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
|
||||
if (key !== suffix && !key.endsWith(`/${suffix}`)) continue;
|
||||
found.push({ location: key, version: entry?.version ?? null });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge one parsed lockfile.
|
||||
*
|
||||
* `setupSource` and `lockPackageSource` are raw texts (or null); the overrides they
|
||||
* declare are compared against each other and against the resolved versions.
|
||||
*/
|
||||
export function evaluateAdvisories(lock, { manifestPin = null, setupSource = null, lockPackageSource = null } = {}) {
|
||||
const rows = [];
|
||||
const problems = [];
|
||||
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
const copies = lockCopies(lock, advisory.name);
|
||||
if (copies.length === 0) {
|
||||
rows.push({ ...advisory, status: "absent", copies, detail: "not in the closure; no advisory applies" });
|
||||
continue;
|
||||
}
|
||||
const below = copies.filter((copy) => !copy.version || compareVersions(copy.version, advisory.floor) < 0);
|
||||
if (below.length > 0) {
|
||||
rows.push({
|
||||
...advisory,
|
||||
status: "unsafe",
|
||||
copies,
|
||||
detail: `${below.map((copy) => `${copy.version ?? "unknown"} at ${copy.location}`).join(", ")} is below ${advisory.floor}`,
|
||||
});
|
||||
problems.push(`${advisory.name}: ${below.map((copy) => copy.version ?? "unknown").join(", ")} < ${advisory.floor} (${advisory.advisory})`);
|
||||
continue;
|
||||
}
|
||||
const distinct = [...new Set(copies.map((copy) => copy.version))].join(", ");
|
||||
rows.push({
|
||||
...advisory,
|
||||
status: "ok",
|
||||
copies,
|
||||
detail: `${distinct} across ${copies.length} cop${copies.length === 1 ? "y" : "ies"}`,
|
||||
});
|
||||
}
|
||||
|
||||
/* The lock has to match the manifest, or provisioning installs a different REA. */
|
||||
const lockedRea = lock?.packages?.[""]?.dependencies?.["rea-agents"] ?? null;
|
||||
if (manifestPin && lockedRea !== manifestPin) {
|
||||
problems.push(
|
||||
`the lock pins rea-agents@${lockedRea ?? "nothing"} but the manifest pins ${manifestPin}; regenerate scripts/vendor-lock/package-lock.json`,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* setup.mjs falls back to `npm install` with its own overrides when the lock
|
||||
* cannot be replayed. That path must not be able to resolve anything the audited
|
||||
* one would refuse, so the same floors are applied to it, and the two override
|
||||
* sets must name the same versions.
|
||||
*/
|
||||
const declared = [];
|
||||
const fromSetup = setupSource ? parseOverrides(setupSource, "const CLOSURE_OVERRIDES") : null;
|
||||
const fromLockPackage = lockPackageSource ? parseOverrides(lockPackageSource, '"overrides"') : null;
|
||||
if (fromSetup) declared.push({ origin: "scripts/setup.mjs", overrides: fromSetup });
|
||||
if (fromLockPackage) declared.push({ origin: "vendor-lock/package.json", overrides: fromLockPackage });
|
||||
for (const { origin, overrides } of declared) {
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
const pinned = overrides[advisory.name];
|
||||
if (!pinned) continue;
|
||||
if (compareVersions(pinned, advisory.floor) < 0) {
|
||||
problems.push(`${origin} overrides ${advisory.name} to ${pinned}, below the ${advisory.floor} floor (${advisory.advisory})`);
|
||||
}
|
||||
const resolved = lockCopies(lock, advisory.name).map((copy) => copy.version);
|
||||
if (resolved.length > 0 && !resolved.every((version) => version === pinned)) {
|
||||
problems.push(
|
||||
`${origin} overrides ${advisory.name} to ${pinned} but the lock resolves ${[...new Set(resolved)].join(", ")}; the degraded install path would not match the audited one`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (declared.length === 2) {
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
const [first, second] = declared.map((entry) => entry.overrides[advisory.name] ?? null);
|
||||
if (first !== second) {
|
||||
problems.push(`overrides disagree for ${advisory.name}: setup.mjs ${first ?? "unset"} vs vendor-lock/package.json ${second ?? "unset"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: problems.length === 0, rows, problems, lockedRea };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `{ name: "version", ... }` object out of JavaScript source or JSON text.
|
||||
*
|
||||
* setup.mjs is a script, not a module, so its override table is read as text; this
|
||||
* is deliberately tolerant (it only fails when a key of interest is unreadable).
|
||||
*/
|
||||
export function parseOverrides(source, anchor) {
|
||||
const objectStart = source.indexOf(anchor);
|
||||
if (objectStart < 0) return null;
|
||||
const braceAt = source.indexOf("{", objectStart + anchor.length);
|
||||
const braceEnd = source.indexOf("}", braceAt);
|
||||
if (braceAt < 0 || braceEnd < 0) return null;
|
||||
const body = source.slice(braceAt + 1, braceEnd);
|
||||
const overrides = {};
|
||||
// JSON form: "zod": "4.6.7" | JS form: zod: "4.6.7" (also '"key": "value"')
|
||||
for (const match of body.matchAll(/"?(?<name>[A-Za-z0-9@/._-]+)"?\s*:\s*"(?<version>[^"]+)"/g)) {
|
||||
overrides[match.groups.name] = match.groups.version;
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/** The shipped lock, or a caller-supplied one. */
|
||||
export function resolveLockPath(candidates) {
|
||||
const searched = candidates ?? [
|
||||
path.join(HERE, "vendor-lock", "package-lock.json"),
|
||||
path.join(PLUGIN_ROOT, "vendor-lock", "package-lock.json"),
|
||||
];
|
||||
return searched.find((file) => existsSync(file)) ?? null;
|
||||
}
|
||||
|
||||
export function readJson(file) {
|
||||
return JSON.parse(readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
if (argv.includes("--help") || argv.includes("-h")) {
|
||||
process.stdout.write("usage: node ./scripts/check-dependency-advisories.mjs [--lock <package-lock.json>] [--json]\n");
|
||||
return 0;
|
||||
}
|
||||
const lockFlag = argv.indexOf("--lock");
|
||||
const lockPath = lockFlag >= 0 ? path.resolve(argv[lockFlag + 1] ?? "") : resolveLockPath();
|
||||
if (!lockPath || !existsSync(lockPath)) {
|
||||
process.stderr.write(
|
||||
`dependency advisories: no lockfile at ${lockPath ?? "scripts/vendor-lock/package-lock.json"}\n` +
|
||||
" the vendored closure is unverifiable without it; regenerate it with\n" +
|
||||
" cd scripts/vendor-lock && npm install --package-lock-only --ignore-scripts\n",
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = readJson(lockPath);
|
||||
} catch (error) {
|
||||
process.stderr.write(`dependency advisories: ${lockPath} is not readable JSON: ${String(error?.message ?? error)}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const manifestPath = path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json");
|
||||
const manifestPin = existsSync(manifestPath) ? (readJson(manifestPath)?.pins?.reaAgents ?? null) : null;
|
||||
const setupPath = path.join(PLUGIN_ROOT, "scripts", "setup.mjs");
|
||||
const lockPackagePath = path.join(path.dirname(lockPath), "package.json");
|
||||
const verdict = evaluateAdvisories(lock, {
|
||||
manifestPin,
|
||||
setupSource: existsSync(setupPath) ? readFileSync(setupPath, "utf8") : null,
|
||||
lockPackageSource: existsSync(lockPackagePath) ? readFileSync(lockPackagePath, "utf8") : null,
|
||||
});
|
||||
|
||||
if (argv.includes("--json")) {
|
||||
process.stdout.write(`${JSON.stringify({ lock: lockPath, manifestPin, ...verdict }, null, 2)}\n`);
|
||||
return verdict.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
const relative = path.relative(PLUGIN_ROOT, lockPath) || lockPath;
|
||||
process.stdout.write("dependency advisories — vendored REA closure\n");
|
||||
process.stdout.write(` lock ${relative} (lockfileVersion ${lock?.lockfileVersion ?? "?"}, ${Object.keys(lock?.packages ?? {}).length} entries)\n`);
|
||||
process.stdout.write(` manifest rea-agents@${manifestPin ?? "unpinned"}, lock pins ${verdict.lockedRea ?? "nothing"}\n`);
|
||||
for (const row of verdict.rows) {
|
||||
const label = row.status === "ok" ? "ok " : row.status === "absent" ? "n/a " : "FAIL";
|
||||
process.stdout.write(` ${label} ${row.name.padEnd(10)} ${row.detail} [floor ${row.floor}, ${row.advisory}]\n`);
|
||||
}
|
||||
if (!verdict.ok) {
|
||||
for (const problem of verdict.problems) process.stderr.write(`FAIL ${problem}\n`);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write("ok: every advisory-affected package is at or above its fixed version\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
typeof process.argv[1] === "string" && path.resolve(process.argv[1]).toLowerCase() === fileURLToPath(import.meta.url).toLowerCase();
|
||||
if (invokedDirectly) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `npm run check-updates` — a portable entry for a Windows-only script.
|
||||
*
|
||||
* `check-updates.ps1` is a Windows PowerShell tool on purpose: it reads
|
||||
* `%APPDATA%\npm`, prefers a `rea.cmd` shim over `rea.ps1`, and walks the
|
||||
* profile the way the desktop app lays it out. On macOS/Linux even a working
|
||||
* `pwsh` would run those branches against paths that do not exist there, so
|
||||
* "make the .ps1 cross-platform" is not the fix — the honest answer is to say
|
||||
* so, and to name the two commands that DO work everywhere.
|
||||
*
|
||||
* This wrapper therefore:
|
||||
* - on Windows: runs `powershell` (the 5.1 the script is written for), and
|
||||
* falls back to `pwsh` when 5.1 is absent;
|
||||
* - elsewhere: prints why, plus the portable equivalents, and exits non-zero
|
||||
* so a CI step cannot mistake "not applicable" for "all green".
|
||||
*
|
||||
* Its pure parts are exported so a unit test can pin both branches without
|
||||
* spawning anything.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** Repository root: this file lives in <root>/scripts/. */
|
||||
export const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/**
|
||||
* PowerShell executables to try, in order, for a platform.
|
||||
*
|
||||
* Empty on POSIX: the script's own probes are Windows-specific, so running it
|
||||
* there would produce a confident wrong answer instead of a clear "not here".
|
||||
*/
|
||||
export function powerShellCandidates(platform = process.platform) {
|
||||
return platform === 'win32' ? ['powershell', 'pwsh'] : []
|
||||
}
|
||||
|
||||
/** The message used when this platform (or this machine) cannot run the script. */
|
||||
export function unsupportedMessage(platform = process.platform) {
|
||||
const where = platform === 'win32'
|
||||
? 'No PowerShell (powershell or pwsh) was found on this machine.'
|
||||
: `check-updates.ps1 is a Windows PowerShell tool; this host is ${platform}.`
|
||||
return [
|
||||
where,
|
||||
'',
|
||||
'The same questions, answered with commands that work on every platform:',
|
||||
' node bin/rea-shim.mjs --shim-status # provisioned bundle, launch choice, versions',
|
||||
' node scripts/setup.mjs --dry-run # what a setup run would still fetch',
|
||||
'',
|
||||
'On Windows, `npm run check-updates` also compares the installed versions',
|
||||
'against the upstream registries and the profile registration.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const script = join(ROOT, 'check-updates.ps1')
|
||||
if (!existsSync(script)) {
|
||||
process.stderr.write(`check-updates: ${script} is missing from this package.\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const candidates = powerShellCandidates()
|
||||
if (candidates.length === 0) {
|
||||
process.stderr.write(`${unsupportedMessage()}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const env = process.env
|
||||
const attempts = []
|
||||
for (const command of candidates) {
|
||||
const result = spawnSync(command, ['-NoProfile', '-File', script, ...argv], {
|
||||
stdio: 'inherit',
|
||||
cwd: ROOT,
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.error && result.error.code === 'ENOENT') {
|
||||
attempts.push(command)
|
||||
continue
|
||||
}
|
||||
if (result.error) {
|
||||
process.stderr.write(`check-updates: ${command} failed to start: ${result.error.message}\n`)
|
||||
return 1
|
||||
}
|
||||
return typeof result.status === 'number' ? result.status : 1
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`check-updates: tried ${attempts.join(', ')} — none is on PATH.\n\n${unsupportedMessage()}\n`,
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
||||
process.exitCode = main(process.argv.slice(2))
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tests for the vendored dependency closure: the audited lockfile, the advisory
|
||||
* gate that guards it, and the provisioning path that replays it.
|
||||
*
|
||||
* The defect these pin, measured on the closure `[email protected]` resolves from
|
||||
* the registry alone: it pins zod exactly (`"zod": "4.4.3"`), 4.4.3 is inside
|
||||
* GHSA-mrhx-c7xc-m3w5 (fixed in 4.6.6), and its `.max()` array validators turn a
|
||||
* malformed tool call into an issue-object amplifier — 25 elements into a
|
||||
* `.max(2)` schema produced 26 issues on 4.4.3 and 1 on 4.6.7. The same closure
|
||||
* carried [email protected] (GHSA-g3hv-75wg-m249) and [email protected]
|
||||
* (GHSA-3g3m-2fjr-6pw4) through @electron/asar and frida, and nothing recorded
|
||||
* which versions an install produced, so two machines a month apart disagreed.
|
||||
*
|
||||
* No network and no npm: every check here is a file read or a spawned script.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
ADVISORY_FLOORS,
|
||||
compareVersions,
|
||||
evaluateAdvisories,
|
||||
lockCopies,
|
||||
parseOverrides,
|
||||
} from "./check-dependency-advisories.mjs";
|
||||
|
||||
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const LOCK_DIR = path.join(REPO_ROOT, "scripts", "vendor-lock");
|
||||
const LOCK_PATH = path.join(LOCK_DIR, "package-lock.json");
|
||||
const SETUP_SOURCE = path.join(REPO_ROOT, "scripts", "setup.mjs");
|
||||
const GATE = path.join(REPO_ROOT, "scripts", "check-dependency-advisories.mjs");
|
||||
const MANIFEST = path.join(REPO_ROOT, "manifest", "bundle.manifest.json");
|
||||
|
||||
const REA_PIN = JSON.parse(readFileSync(MANIFEST, "utf8")).pins.reaAgents;
|
||||
const readLock = () => JSON.parse(readFileSync(LOCK_PATH, "utf8"));
|
||||
const cloneLock = () => JSON.parse(JSON.stringify(readLock()));
|
||||
|
||||
/** Runs the gate as CI would, against a caller-selected lockfile. */
|
||||
function runGate(lockFile, extra = []) {
|
||||
return spawnSync(process.execPath, [GATE, "--lock", lockFile, ...extra], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes `lock` into a throwaway directory and returns the file path. */
|
||||
function fixtureLock(lock) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "rea-deps-lock-"));
|
||||
const file = path.join(dir, "package-lock.json");
|
||||
writeFileSync(file, `${JSON.stringify(lock, null, 2)}\n`);
|
||||
return { dir, file };
|
||||
}
|
||||
|
||||
/* ------------------------------------------- the shipped closure is not advisory-affected */
|
||||
|
||||
test("the shipped lock resolves zod, minimatch and glob at or above every floor", () => {
|
||||
const lock = readLock();
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
const copies = lockCopies(lock, advisory.name);
|
||||
assert.ok(copies.length > 0, `${advisory.name} must stay in the closure; the escape hatch below would hide its removal`);
|
||||
for (const copy of copies) {
|
||||
assert.ok(
|
||||
compareVersions(copy.version, advisory.floor) >= 0,
|
||||
`${advisory.name}@${copy.version} at ${copy.location} is below the ${advisory.floor} floor of ${advisory.advisory}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the lock pins the REA the manifest names", () => {
|
||||
// A lock that pins a different REA is not a fix, it is a different server: the
|
||||
// closure is resolved for one exact tarball's dependency list.
|
||||
assert.equal(readLock().packages[""].dependencies["rea-agents"], REA_PIN);
|
||||
});
|
||||
|
||||
test("every advisory-affected package resolves to exactly one version", () => {
|
||||
// Two copies would mean one of them is the unpatched one; the gate checks all
|
||||
// copies, and this asserts the closure does not rely on that leniency.
|
||||
const lock = readLock();
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
const versions = [...new Set(lockCopies(lock, advisory.name).map((copy) => copy.version))];
|
||||
assert.deepEqual(versions.length, 1, `${advisory.name} resolves to ${versions.join(", ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------- the gate itself */
|
||||
|
||||
test("the gate passes the shipped lock and prints the three rows", () => {
|
||||
const run = spawnSync(process.execPath, [GATE], { cwd: REPO_ROOT, encoding: "utf8", timeout: 120000 });
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stdout, /dependency advisories/);
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
assert.match(run.stdout, new RegExp(`${advisory.name}\\s+\\S+`), `the report must name ${advisory.name}`);
|
||||
assert.match(run.stdout, new RegExp(advisory.advisory), `the report must cite ${advisory.advisory}`);
|
||||
}
|
||||
assert.doesNotMatch(run.stderr, /FAIL/);
|
||||
});
|
||||
|
||||
test("the gate fails a copy below the zod floor", () => {
|
||||
const lock = cloneLock();
|
||||
lock.packages["node_modules/zod"].version = "4.4.3";
|
||||
const fixture = fixtureLock(lock);
|
||||
try {
|
||||
const run = runGate(fixture.file);
|
||||
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stderr, /zod: 4\.4\.3 < 4\.6\.6 \(GHSA-mrhx-c7xc-m3w5\)/);
|
||||
} finally {
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the gate fails a NESTED copy below the minimatch floor even when the top copy is fine", () => {
|
||||
// The amplification advisory is about whichever copy is imported; a patched
|
||||
// top-level package with an unpatched duplicate underneath is the realistic
|
||||
// regression when an upstream dependency gains its own minimatch.
|
||||
const lock = cloneLock();
|
||||
lock.packages["node_modules/incur/node_modules/minimatch"] = { version: "10.2.6", resolved: "fixture", integrity: "" };
|
||||
const fixture = fixtureLock(lock);
|
||||
try {
|
||||
const run = runGate(fixture.file);
|
||||
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stderr, /minimatch: 10\.2\.6 < 11\.0\.1 \(GHSA-g3hv-75wg-m249\)/);
|
||||
} finally {
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the gate fails a glob below the AbortSignal floor", () => {
|
||||
const lock = cloneLock();
|
||||
lock.packages["node_modules/glob"].version = "14.0.1";
|
||||
const fixture = fixtureLock(lock);
|
||||
try {
|
||||
const run = runGate(fixture.file);
|
||||
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stderr, /glob: 14\.0\.1 < 14\.0\.2 \(GHSA-3g3m-2fjr-6pw4\)/);
|
||||
} finally {
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing lockfile is unverifiable, not safe", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "rea-deps-missing-"));
|
||||
try {
|
||||
const run = runGate(path.join(dir, "package-lock.json"));
|
||||
assert.equal(run.status, 2, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stderr, /no lockfile/);
|
||||
assert.match(run.stderr, /npm install --package-lock-only/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a package that left the closure is reported, and is not a violation", () => {
|
||||
// An upstream REA that stops depending on minimatch must not leave a gate that
|
||||
// fails forever for a package nobody imports.
|
||||
const lock = cloneLock();
|
||||
delete lock.packages["node_modules/minimatch"];
|
||||
const fixture = fixtureLock(lock);
|
||||
try {
|
||||
const run = runGate(fixture.file);
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stdout, /n\/a\s+minimatch\s+not in the closure/);
|
||||
} finally {
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a lock that lags the manifest is refused as stale", () => {
|
||||
const verdict = evaluateAdvisories(readLock(), { manifestPin: "6.9.0" });
|
||||
assert.equal(verdict.ok, false);
|
||||
assert.ok(
|
||||
verdict.problems.some((problem) => /pins rea-agents@6\.8\.0 but the manifest pins 6\.9\.0/.test(problem)),
|
||||
verdict.problems.join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
/* --------------------------------- the degraded install path cannot undo the fix */
|
||||
|
||||
test("setup.mjs's fallback overrides sit at or above the floors", () => {
|
||||
const overrides = parseOverrides(readFileSync(SETUP_SOURCE, "utf8"), "const CLOSURE_OVERRIDES");
|
||||
assert.ok(overrides, "provisionReaAgents must keep a readable CLOSURE_OVERRIDES table for the no-lock fallback");
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
assert.equal(typeof overrides[advisory.name], "string", `${advisory.name} must be overridden explicitly`);
|
||||
assert.ok(
|
||||
compareVersions(overrides[advisory.name], advisory.floor) >= 0,
|
||||
`setup.mjs overrides ${advisory.name} to ${overrides[advisory.name]}, below ${advisory.floor}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("setup.mjs and vendor-lock/package.json declare the same overrides", () => {
|
||||
const fromSetup = parseOverrides(readFileSync(SETUP_SOURCE, "utf8"), "const CLOSURE_OVERRIDES");
|
||||
const fromLockPackage = parseOverrides(readFileSync(path.join(LOCK_DIR, "package.json"), "utf8"), '"overrides"');
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
assert.equal(
|
||||
fromSetup[advisory.name],
|
||||
fromLockPackage[advisory.name],
|
||||
`the lock and the fallback disagree on ${advisory.name}; one of the two install paths would resolve differently`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("an override below a floor is caught even when the lock is clean", () => {
|
||||
// The lock is what runs when `ci` works; the override table is what runs when it
|
||||
// does not, and only the second one is edited by hand.
|
||||
const sabotaged = `const CLOSURE_OVERRIDES = { zod: "4.4.3", minimatch: "10.2.6", glob: "13.0.7" };\n`;
|
||||
const verdict = evaluateAdvisories(readLock(), {
|
||||
manifestPin: REA_PIN,
|
||||
setupSource: sabotaged,
|
||||
lockPackageSource: readFileSync(path.join(LOCK_DIR, "package.json"), "utf8"),
|
||||
});
|
||||
assert.equal(verdict.ok, false);
|
||||
for (const advisory of ADVISORY_FLOORS) {
|
||||
assert.ok(
|
||||
verdict.problems.some((problem) => problem.includes(advisory.name) && problem.includes(advisory.floor)),
|
||||
`expected a floor complaint for ${advisory.name}; got:\n${verdict.problems.join("\n")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("setup.mjs's own zod floor is the advisory's fixed version", () => {
|
||||
// ZOD_FLOOR decides whether a provisioned closure is accepted, so it has to be
|
||||
// the advisory's number, not a value that drifted with a pin bump.
|
||||
const source = readFileSync(SETUP_SOURCE, "utf8");
|
||||
const match = source.match(/const ZOD_FLOOR = "([^"]+)";/);
|
||||
assert.ok(match, "provisionReaAgents must verify the resolved zod against an explicit floor");
|
||||
const zodFloor = ADVISORY_FLOORS.find((advisory) => advisory.name === "zod").floor;
|
||||
assert.equal(compareVersions(match[1], zodFloor), 0, `setup.mjs floor ${match[1]} != advisory floor ${zodFloor}`);
|
||||
});
|
||||
|
||||
/* ---------------------------------------------- provisioning replays the lock, in staging */
|
||||
|
||||
test("provisionReaAgents installs from the shipped lock, after staging, and never blind", () => {
|
||||
const source = readFileSync(SETUP_SOURCE, "utf8");
|
||||
const start = source.indexOf("async function provisionReaAgents(component, context) {");
|
||||
assert.ok(start > 0, "provisionReaAgents must still exist");
|
||||
const end = source.slice(start).search(/\r?\n\}\r?\n/);
|
||||
assert.ok(end > 0, "provisionReaAgents must have a closing brace");
|
||||
const body = source.slice(start, start + end);
|
||||
|
||||
// The old shape: one `npm install rea-agents@x --no-save` straight into the live
|
||||
// vendor tree, which is neither reproducible nor safe to interrupt.
|
||||
assert.doesNotMatch(body, /--no-save/, "a replayed lock replaces the blind `--no-save` install");
|
||||
assert.match(body, /vendor-lock/, "the function must look for the audited lock");
|
||||
assert.match(body, /npmRun\("ci"\)/, "the lock must be replayed with `npm ci`");
|
||||
assert.match(body, /--ignore-scripts/, "lifecycle scripts stay off on both routes");
|
||||
|
||||
// Staging before the swap: `npm ci` deletes node_modules first, so running it in
|
||||
// place would destroy a working install on any network failure.
|
||||
const stagingAt = body.indexOf('.npm-staging');
|
||||
const swapAt = body.indexOf("renameSync(modules, live)");
|
||||
assert.ok(stagingAt > 0 && swapAt > stagingAt, "the closure must be installed into staging and renamed in afterwards");
|
||||
|
||||
// The fallback route must keep the patched pins, and the result is verified
|
||||
// whatever route produced it.
|
||||
assert.match(body, /npmRun\("install"\)/);
|
||||
assert.match(body, /atLeast\(zodVersion, ZOD_FLOOR\)/);
|
||||
});
|
||||
|
||||
test("compareVersions orders dotted versions numerically, not as strings", () => {
|
||||
assert.equal(compareVersions("4.6.7", "4.6.6"), 1);
|
||||
assert.equal(compareVersions("4.4.3", "4.6.6"), -1);
|
||||
assert.equal(compareVersions("4.6.6", "4.6.6"), 0);
|
||||
assert.equal(compareVersions("11.0.5", "11.0.1"), 1);
|
||||
assert.equal(compareVersions("14.0.4", "14.0.2"), 1);
|
||||
// The string-comparison trap: "10" < "9" lexically, but 10 is newer than 9.
|
||||
assert.equal(compareVersions("10.2.6", "9.9.9"), 1);
|
||||
});
|
||||
|
||||
test("the shipped lock is what the gate reads by default", () => {
|
||||
assert.ok(existsSync(LOCK_PATH), "scripts/vendor-lock/package-lock.json must be committed");
|
||||
assert.ok(existsSync(path.join(LOCK_DIR, "package.json")), "the lock's package.json must be committed next to it");
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ghidra-gate-override — run a Ghidra release REA has not verified, on purpose.
|
||||
*
|
||||
* Why this exists
|
||||
* REA ships `SUPPORTED_GHIDRA_VERSION` (dist/ghidra/GhidraReleasePolicy.js, inlined
|
||||
* into the CLI and MCP bundles). A build outside that major.minor is reported as
|
||||
* `unsupported` and the whole Ghidra family is withdrawn, even when the bridge
|
||||
* compiles and analyses fine against the newer jars. On 2026-10-26 Ghidra 12.2 was
|
||||
* measured to produce byte-identical segments, procedure inventory and `entry`
|
||||
* pseudocode on a PE sample, so the block is upstream's *unverified*, not broken.
|
||||
*
|
||||
* The release that fixes Ghidra 12.1.4's four CVEs is 12.2, so staying on 12.1.4 is
|
||||
* a choice to keep an unpatched decompiler/loader in the chain. This script makes
|
||||
* the opposite choice explicit, recorded and reversible — it never happens silently.
|
||||
*
|
||||
* What it changes
|
||||
* Only the version constant inside the *vendored* rea-agents copy, and only for the
|
||||
* files that declare it. Every original value — plus the SHA-256 of each original
|
||||
* file — is written to <root>/ghidra-gate-override.json BEFORE the first byte is
|
||||
* patched, so an interrupted run is recoverable and --disable can prove it restored
|
||||
* exactly what was there. A patch that fails partway through rolls back the files it
|
||||
* already wrote and says so.
|
||||
*
|
||||
* What it does NOT do
|
||||
* It does not verify the new release for you. Everything REA's own gate would have
|
||||
* protected against — bridge API drift, decompiler-hook drift, non-PE loaders — is
|
||||
* now your risk. Re-running `setup.mjs` re-provisions rea-agents and removes the
|
||||
* patch, which puts the bundle back on the pinned, verified release.
|
||||
*
|
||||
* Usage
|
||||
* node scripts/ghidra-gate-override.mjs --status [--root <dir>]
|
||||
* node scripts/ghidra-gate-override.mjs --enable --version 12.2 [--root <dir>]
|
||||
* node scripts/ghidra-gate-override.mjs --disable [--root <dir>]
|
||||
*
|
||||
* The root is `--root`, else REA_BUNDLE_ROOT, else <DSH_HOME>/plugins/rea-bundle,
|
||||
* else <home>/.dsh/plugins/rea-bundle — the same chain setup.mjs, the shim and
|
||||
* scripts/rea-bundle-bootstrap.ps1 use.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, lstatSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const HERE = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
|
||||
const CONSTANT = "SUPPORTED_GHIDRA_VERSION";
|
||||
const RECORD = "ghidra-gate-override.json";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { mode: "status", version: null, root: null };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--enable") out.mode = "enable";
|
||||
else if (arg === "--disable") out.mode = "disable";
|
||||
else if (arg === "--status") out.mode = "status";
|
||||
else if (arg === "--version") out.version = argv[++i] ?? null;
|
||||
else if (arg === "--root") out.root = argv[++i] ?? null;
|
||||
else if (arg === "--help" || arg === "-h") out.mode = "help";
|
||||
else throw new Error(`unknown argument ${arg}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same root rule as scripts/setup.mjs, bin/rea-shim.mjs and
|
||||
* scripts/rea-bundle-bootstrap.ps1: `--root`, then REA_BUNDLE_ROOT, then
|
||||
* <DSH_HOME>/plugins/rea-bundle, then <home>/.dsh/plugins/rea-bundle.
|
||||
*
|
||||
* REA_BUNDLE_ROOT used to be ignored here, so on a machine that provisions into
|
||||
* a custom root `--status` (and every patch) silently addressed the default
|
||||
* root: either "no vendored REA" or — worse — a different installation.
|
||||
*/
|
||||
function defaultRoot() {
|
||||
const override = process.env.REA_BUNDLE_ROOT;
|
||||
if (override && override.trim() !== "") return path.resolve(override);
|
||||
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== "" ? path.resolve(process.env.DSH_HOME) : null;
|
||||
if (home) return path.join(home, "plugins", "rea-bundle");
|
||||
const profile = process.env.USERPROFILE || (process.env.HOMEDRIVE ?? "") + (process.env.HOMEPATH ?? "") || process.env.HOME;
|
||||
return path.join(profile || homedir(), ".dsh", "plugins", "rea-bundle");
|
||||
}
|
||||
|
||||
/** Case-insensitive on Windows, where two spellings can name one directory. */
|
||||
function within(base, target) {
|
||||
const norm = (value) => (process.platform === "win32" ? value.toLowerCase() : value);
|
||||
const from = norm(base);
|
||||
const to = norm(target);
|
||||
return to === from || to.startsWith(from + path.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one record-supplied path inside the bundle, refusing escapes.
|
||||
*
|
||||
* The record is written by this script, but it lives in a directory the user can
|
||||
* edit and a half-finished restore can mangle, and `--disable` joined
|
||||
* `entry.file` straight onto the vendor root: a `"file": "../../../elsewhere"`
|
||||
* entry would have made this script rewrite a file outside the bundle. Local
|
||||
* write access is still required, so this is containment, not a remote escape.
|
||||
*/
|
||||
function containedPath(baseDir, relative, label) {
|
||||
if (typeof relative !== "string" || relative.trim() === "") {
|
||||
throw new Error(`${label} names no file`);
|
||||
}
|
||||
const base = path.resolve(baseDir);
|
||||
const target = path.resolve(base, relative);
|
||||
if (!within(base, target)) {
|
||||
throw new Error(`${label} escapes ${base} (${relative}); refusing to touch it`);
|
||||
}
|
||||
// Resolve every segment by hand so a symlinked directory cannot carry the
|
||||
// target outside the bundle after the string check above passed.
|
||||
let current = base;
|
||||
for (const segment of path.relative(base, target).split(path.sep)) {
|
||||
if (segment === "") continue;
|
||||
current = path.join(current, segment);
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(current);
|
||||
} catch {
|
||||
break; // absent: the caller's existsSync decides what that means
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} goes through the symbolic link ${current}; refusing to follow it`);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = path.join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) walk(full, out);
|
||||
else if (entry.endsWith(".js")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
const text = readFileSync(file, "utf8").replace(/^\uFEFF/, "");
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
/** Every file in the vendored REA that declares the constant, with its value. */
|
||||
function findDeclarations(vendorRoot) {
|
||||
const found = [];
|
||||
for (const file of walk(vendorRoot)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const match = new RegExp(`${CONSTANT}\\s*=\\s*"([^"]+)"`).exec(text);
|
||||
if (match) found.push({ file, value: match[1] });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function replaceValue(file, from, to) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const pattern = new RegExp(`(${CONSTANT}\\s*=\\s*)"${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`);
|
||||
const next = text.replace(pattern, `$1"${to}"`);
|
||||
if (next === text) throw new Error(`${path.basename(file)} did not contain ${CONSTANT} = "${from}"`);
|
||||
writeFileSync(file, next, "utf8");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.mode === "help") {
|
||||
console.log("node scripts/ghidra-gate-override.mjs --status | --enable --version <x.y[.z]> | --disable [--root <dir>]");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = args.root ? path.resolve(args.root) : defaultRoot();
|
||||
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
|
||||
const record = path.join(root, RECORD);
|
||||
if (!existsSync(vendor)) throw new Error(`no vendored REA at ${vendor} — run scripts/setup.mjs first`);
|
||||
|
||||
const declarations = findDeclarations(vendor);
|
||||
if (declarations.length === 0) throw new Error(`none of the ${CONSTANT} declarations were found under ${vendor}`);
|
||||
const values = [...new Set(declarations.map((entry) => entry.value))];
|
||||
const active = existsSync(record) ? readJson(record) : null;
|
||||
|
||||
if (args.mode === "status") {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
root,
|
||||
vendoredRea: vendor,
|
||||
declarations: declarations.map((entry) => ({
|
||||
file: path.relative(vendor, entry.file),
|
||||
value: entry.value,
|
||||
})),
|
||||
override: active,
|
||||
state: active ? `overridden → ${values.join(", ")}` : `upstream gate (${values.join(", ")})`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.mode === "disable") {
|
||||
if (!active) {
|
||||
console.log("no override is recorded; nothing to restore");
|
||||
return;
|
||||
}
|
||||
let restored = 0;
|
||||
let stale = 0;
|
||||
for (const entry of active.files) {
|
||||
// `entry.file` comes out of a JSON file, not out of the vendor tree: keep
|
||||
// every restore inside the bundle (no `..`, no symlinks) before touching it.
|
||||
const file = containedPath(vendor, entry.file, `record entry ${JSON.stringify(entry.file)}`);
|
||||
if (!existsSync(file)) {
|
||||
stale += 1;
|
||||
continue;
|
||||
}
|
||||
const text = readFileSync(file, "utf8");
|
||||
const patchedPattern = new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`);
|
||||
if (patchedPattern.test(text)) {
|
||||
replaceValue(file, entry.patched, entry.original);
|
||||
if (typeof entry.originalSha256 === "string") {
|
||||
const digest = createHash("sha256").update(readFileSync(file)).digest("hex");
|
||||
if (digest !== entry.originalSha256) {
|
||||
throw new Error(
|
||||
`${entry.file} was restored but does not match the recorded original hash (${digest}); ` +
|
||||
"the file changed outside this script — inspect it before restoring the rest",
|
||||
);
|
||||
}
|
||||
}
|
||||
restored += 1;
|
||||
} else if (new RegExp(`${CONSTANT}\\s*=\\s*"${entry.original.replace(/\./g, "\\.")}"`).test(text)) {
|
||||
stale += 1;
|
||||
} else {
|
||||
throw new Error(`${entry.file} declares neither ${entry.patched} nor ${entry.original}; inspect it before restoring`);
|
||||
}
|
||||
}
|
||||
unlinkSync(record);
|
||||
console.log(
|
||||
`restored ${CONSTANT} = "${active.originalVersion}" in ${restored} file(s)` +
|
||||
(stale > 0 ? `; ${stale} file(s) had already been replaced by a re-provision` : "") +
|
||||
"; override record removed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// enable
|
||||
const version = args.version;
|
||||
if (!version || !/^\d+\.\d+(\.\d+)?$/.test(version)) {
|
||||
throw new Error("--version must look like 12.2 or 12.2.1");
|
||||
}
|
||||
if (values.includes(version)) {
|
||||
console.log(`already ${version} — nothing to do`);
|
||||
return;
|
||||
}
|
||||
if (active) {
|
||||
// A record that says its own rollback failed describes files that are still
|
||||
// patched. It must not be mistaken for the "stale record" case below, which
|
||||
// unlinks it: that would strand those files with no record of their originals.
|
||||
if (active.status === "rollback-failed") {
|
||||
throw new Error(
|
||||
`${record} records a patch that could not be rolled back; run --disable to finish restoring before enabling again`,
|
||||
);
|
||||
}
|
||||
// Re-provisioning rea-agents replaces the tree, so a record can outlive the patch
|
||||
// it describes. That stale record must not block the next --enable, and it must
|
||||
// not be trusted by --disable either: judge it by the files on disk.
|
||||
const stillApplied = active.files.every((entry) => {
|
||||
const file = containedPath(vendor, entry.file, `record entry ${JSON.stringify(entry.file)}`);
|
||||
if (!existsSync(file)) return false;
|
||||
const text = readFileSync(file, "utf8");
|
||||
return new RegExp(`${CONSTANT}\\s*=\\s*"${entry.patched.replace(/\./g, "\\.")}"`).test(text);
|
||||
});
|
||||
if (stillApplied) {
|
||||
throw new Error(`an override is already recorded (→ ${active.requestedVersion}); run --disable first`);
|
||||
}
|
||||
console.log(`previous record is stale (rea-agents was re-provisioned); replacing it`);
|
||||
unlinkSync(record);
|
||||
}
|
||||
|
||||
// Record every original BEFORE the first write to a vendored file: a failure
|
||||
// halfway through used to leave patched files that no record described, and a
|
||||
// record written afterwards cannot describe a process that died mid-loop.
|
||||
const originals = declarations.map((entry) => {
|
||||
const bytes = readFileSync(entry.file);
|
||||
return {
|
||||
file: path.relative(vendor, entry.file),
|
||||
absolute: entry.file,
|
||||
original: entry.value,
|
||||
patched: version,
|
||||
originalSha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes,
|
||||
};
|
||||
});
|
||||
const bundle = existsSync(path.join(root, "bundle.json")) ? readJson(path.join(root, "bundle.json")) : null;
|
||||
const payload = {
|
||||
schema: 2,
|
||||
requestedVersion: version,
|
||||
originalVersion: values.length === 1 ? values[0] : values.join(","),
|
||||
files: originals.map(({ file, original, patched: patchedValue, originalSha256 }) => ({
|
||||
file,
|
||||
original,
|
||||
patched: patchedValue,
|
||||
originalSha256,
|
||||
})),
|
||||
ghidraPath: bundle?.paths?.ghidra ?? null,
|
||||
enabledAt: new Date().toISOString(),
|
||||
status: "patching",
|
||||
warning:
|
||||
"REA did not verify this Ghidra release. The gate existed because the bridge is only tested against the pinned one; re-running setup.mjs re-provisions rea-agents and removes this patch.",
|
||||
};
|
||||
// A crash between here and the final write still leaves a complete, verifiable
|
||||
// description of what must be put back.
|
||||
writeFileSync(record, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
|
||||
const patched = [];
|
||||
try {
|
||||
for (const entry of originals) {
|
||||
replaceValue(entry.absolute, entry.original, version);
|
||||
patched.push(entry);
|
||||
}
|
||||
// Read back: the whole point is that the gate now reads a value REA accepts.
|
||||
for (const entry of originals) {
|
||||
const text = readFileSync(entry.absolute, "utf8");
|
||||
if (!new RegExp(`${CONSTANT}\\s*=\\s*"${version.replace(/\./g, "\\.")}"`).test(text)) {
|
||||
throw new Error(`readback failed for ${entry.file}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = [];
|
||||
for (const entry of patched) {
|
||||
try {
|
||||
writeFileSync(entry.absolute, entry.bytes);
|
||||
} catch (rollbackError) {
|
||||
failed.push(`${entry.file}: ${rollbackError && rollbackError.message ? rollbackError.message : String(rollbackError)}`);
|
||||
}
|
||||
}
|
||||
const reason = error && error.message ? error.message : String(error);
|
||||
if (failed.length > 0) {
|
||||
// Keep the record: it still names every original value and hash, so a
|
||||
// `--disable` can finish the restore this run could not.
|
||||
writeFileSync(
|
||||
record,
|
||||
`${JSON.stringify({ ...payload, status: "rollback-failed", rollbackErrors: failed }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
throw new Error(
|
||||
`patch failed (${reason}); ${patched.length - failed.length} file(s) rolled back, ` +
|
||||
`${failed.length} could not be restored (see ${record}) — run --disable`,
|
||||
);
|
||||
}
|
||||
unlinkSync(record);
|
||||
throw new Error(`patch failed (${reason}); rolled back ${patched.length} file(s), nothing left changed`);
|
||||
}
|
||||
writeFileSync(record, `${JSON.stringify({ ...payload, status: "applied" }, null, 2)}\n`, "utf8");
|
||||
console.log(`patched ${originals.length} file(s) → ${CONSTANT} = "${version}"`);
|
||||
console.log(`recorded ${record}`);
|
||||
console.log("restart dsh (or reload the profile) so the MCP row reconnects");
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`ghidra-gate-override: ${error && error.message ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regression tests for the "script consistency / state integrity" fixes.
|
||||
*
|
||||
* Three defects, one theme: a tool that answers for a *different* machine state
|
||||
* than the one in front of it.
|
||||
*
|
||||
* 1. `check-updates.ps1` printed "无需动作" (and exited 0) while the same run
|
||||
* had already reported a pin drift. Green now requires no actions, no
|
||||
* drift, no unrecorded versions and no warning — and the exit code follows.
|
||||
* 2. `bin/rea-guide.mjs` ignored REA_BUNDLE_ROOT, so a custom-root machine was
|
||||
* reported `missing` by the one screen that exists to decide "run setup?".
|
||||
* 3. `scripts/ghidra-gate-override.mjs` ignored REA_BUNDLE_ROOT too, patched
|
||||
* files before it wrote the recovery record, and trusted `entry.file` from
|
||||
* that record without keeping the target inside the bundle.
|
||||
*
|
||||
* The PowerShell tests build a throwaway bundle in a temp directory and point
|
||||
* REA_BUNDLE_ROOT at it — the live install under DSH_HOME is never touched. The
|
||||
* registry is a local stub, so these tests do not need the network and do not
|
||||
* turn "upstream published a newer version today" into a red build.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PLUGIN_ROOT = path.resolve(HERE, "..");
|
||||
const GUIDE = path.join(PLUGIN_ROOT, "bin", "rea-guide.mjs");
|
||||
const GHIDRA_OVERRIDE = path.join(PLUGIN_ROOT, "scripts", "ghidra-gate-override.mjs");
|
||||
const CHECK_UPDATES = path.join(PLUGIN_ROOT, "check-updates.ps1");
|
||||
|
||||
const MANIFEST = JSON.parse(readFileSync(path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json"), "utf8"));
|
||||
const PINS = MANIFEST.pins;
|
||||
|
||||
/** A temp directory that removes itself when the test ends. */
|
||||
function tempDir(t, label) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), `rea-hardening-${label}-`));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Run one Node script and return its UTF-8 output. */
|
||||
function runNode(script, args, env) {
|
||||
const result = spawnSync(process.execPath, [script, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
return { status: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- 2. bin/rea-guide.mjs */
|
||||
|
||||
test("rea-guide: REA_BUNDLE_ROOT is the root the guide answers for", (t) => {
|
||||
const base = tempDir(t, "guide-root");
|
||||
const home = path.join(base, "home");
|
||||
const root = path.join(base, "custom-root");
|
||||
mkdirSync(home, { recursive: true });
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ versions: { reaAgents: PINS.reaAgents } }));
|
||||
|
||||
const env = { DSH_HOME: home, REA_BUNDLE_ROOT: root };
|
||||
const run = runNode(GUIDE, ["--json"], env);
|
||||
assert.equal(run.status, 0, run.output);
|
||||
const state = JSON.parse(run.output);
|
||||
|
||||
assert.equal(state.state, "ok", `a provisioned custom root must not read as missing: ${run.output}`);
|
||||
assert.equal(path.resolve(state.bundlePath), path.resolve(root, "bundle.json"));
|
||||
assert.equal(path.resolve(state.bundleRoot), path.resolve(root));
|
||||
// The remedy has to name the root it is talking about, or the copy-pasted
|
||||
// command provisions the default root and leaves this one untouched.
|
||||
assert.match(state.setupCommand, /--root/);
|
||||
assert.ok(state.setupCommand.includes(root), state.setupCommand);
|
||||
});
|
||||
|
||||
test("rea-guide: a missing custom root is reported as missing, still with --root", (t) => {
|
||||
const base = tempDir(t, "guide-missing");
|
||||
const home = path.join(base, "home");
|
||||
const root = path.join(base, "never-provisioned");
|
||||
mkdirSync(home, { recursive: true });
|
||||
|
||||
const run = runNode(GUIDE, ["--json"], { DSH_HOME: home, REA_BUNDLE_ROOT: root });
|
||||
assert.equal(run.status, 0, run.output);
|
||||
const state = JSON.parse(run.output);
|
||||
assert.equal(state.state, "missing");
|
||||
assert.match(state.setupCommand, /--root/);
|
||||
});
|
||||
|
||||
test("rea-guide: the default root keeps producing the plain --reuse command", (t) => {
|
||||
const home = tempDir(t, "guide-default");
|
||||
const run = runNode(GUIDE, ["--json"], { DSH_HOME: home, REA_BUNDLE_ROOT: "" });
|
||||
assert.equal(run.status, 0, run.output);
|
||||
const state = JSON.parse(run.output);
|
||||
assert.equal(state.state, "missing");
|
||||
assert.equal(path.resolve(state.bundlePath), path.resolve(home, "plugins", "rea-bundle", "bundle.json"));
|
||||
assert.doesNotMatch(state.setupCommand, /--root/);
|
||||
});
|
||||
|
||||
test("rea-guide: --root overrides both the environment and the default", (t) => {
|
||||
const base = tempDir(t, "guide-flag");
|
||||
const root = path.join(base, "from-flag");
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ versions: { reaAgents: PINS.reaAgents } }));
|
||||
const run = runNode(GUIDE, ["--json", "--root", root], { DSH_HOME: path.join(base, "home") });
|
||||
assert.equal(run.status, 0, run.output);
|
||||
const state = JSON.parse(run.output);
|
||||
assert.equal(state.state, "ok", run.output);
|
||||
assert.equal(path.resolve(state.bundlePath), path.resolve(root, "bundle.json"));
|
||||
});
|
||||
|
||||
/* --------------------------------------------- 3/4/5. ghidra-gate-override.mjs */
|
||||
|
||||
const FILES = ["dist/a-policy.js", "dist/b-cli.js", "dist/c-mcp.js"];
|
||||
|
||||
/** A throwaway vendored rea-agents tree with the gate constant in three files. */
|
||||
function makeVendor(t, label) {
|
||||
const base = tempDir(t, label);
|
||||
const root = path.join(base, "root");
|
||||
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
|
||||
mkdirSync(vendor, { recursive: true });
|
||||
for (const relative of FILES) {
|
||||
mkdirSync(path.join(vendor, path.dirname(relative)), { recursive: true });
|
||||
writeFileSync(path.join(vendor, relative), `const SUPPORTED_GHIDRA_VERSION = "12.1.4";\n`);
|
||||
}
|
||||
writeFileSync(path.join(root, "bundle.json"), JSON.stringify({ paths: { ghidra: path.join(base, "ghidra") } }));
|
||||
return { base, root, vendor, env: { DSH_HOME: path.join(base, "home"), REA_BUNDLE_ROOT: root } };
|
||||
}
|
||||
|
||||
function declared(vendor) {
|
||||
return FILES.map((relative) => {
|
||||
const match = /SUPPORTED_GHIDRA_VERSION = "([^"]+)"/.exec(readFileSync(path.join(vendor, relative), "utf8"));
|
||||
return match ? match[1] : null;
|
||||
});
|
||||
}
|
||||
|
||||
test("ghidra-gate-override: REA_BUNDLE_ROOT and --root select the root", (t) => {
|
||||
const fixture = makeVendor(t, "gate-status");
|
||||
const fromEnv = runNode(GHIDRA_OVERRIDE, ["--status"], fixture.env);
|
||||
assert.equal(fromEnv.status, 0, fromEnv.output);
|
||||
assert.equal(path.resolve(JSON.parse(fromEnv.output).root), path.resolve(fixture.root));
|
||||
|
||||
const fromFlag = runNode(GHIDRA_OVERRIDE, ["--status", "--root", fixture.root], {
|
||||
DSH_HOME: path.join(fixture.base, "elsewhere"),
|
||||
REA_BUNDLE_ROOT: "",
|
||||
});
|
||||
assert.equal(fromFlag.status, 0, fromFlag.output);
|
||||
assert.equal(path.resolve(JSON.parse(fromFlag.output).root), path.resolve(fixture.root));
|
||||
});
|
||||
|
||||
test("ghidra-gate-override: --enable then --disable round-trips on a custom root", (t) => {
|
||||
const fixture = makeVendor(t, "gate-roundtrip");
|
||||
|
||||
const enable = runNode(GHIDRA_OVERRIDE, ["--enable", "--version", "12.2"], fixture.env);
|
||||
assert.equal(enable.status, 0, enable.output);
|
||||
assert.deepEqual(declared(fixture.vendor), ["12.2", "12.2", "12.2"]);
|
||||
|
||||
const recordPath = path.join(fixture.root, "ghidra-gate-override.json");
|
||||
const record = JSON.parse(readFileSync(recordPath, "utf8"));
|
||||
assert.equal(record.status, "applied");
|
||||
assert.equal(record.files.length, FILES.length);
|
||||
for (const entry of record.files) {
|
||||
// The point of recording first: --disable can prove it restored the bytes.
|
||||
assert.equal(typeof entry.originalSha256, "string");
|
||||
assert.equal(entry.originalSha256.length, 64);
|
||||
}
|
||||
|
||||
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
|
||||
assert.equal(disable.status, 0, disable.output);
|
||||
assert.deepEqual(declared(fixture.vendor), ["12.1.4", "12.1.4", "12.1.4"]);
|
||||
assert.equal(existsSync(recordPath), false);
|
||||
});
|
||||
|
||||
test("ghidra-gate-override: originals are recorded before the first patch", (t) => {
|
||||
const fixture = makeVendor(t, "gate-record-first");
|
||||
// A read-only last file fails the third write. The first two must be rolled
|
||||
// back, and the run must say so — a half-applied patch with no record is what
|
||||
// the old order produced when it died in the middle.
|
||||
const readOnly = path.join(fixture.vendor, FILES[FILES.length - 1]);
|
||||
chmodSync(readOnly, 0o444);
|
||||
let enable;
|
||||
try {
|
||||
enable = runNode(GHIDRA_OVERRIDE, ["--enable", "--version", "12.2"], fixture.env);
|
||||
} finally {
|
||||
chmodSync(readOnly, 0o666);
|
||||
}
|
||||
assert.equal(enable.status, 1, enable.output);
|
||||
assert.match(enable.output, /rolled back 2 file\(s\), nothing left changed/);
|
||||
assert.deepEqual(declared(fixture.vendor), ["12.1.4", "12.1.4", "12.1.4"]);
|
||||
assert.equal(existsSync(path.join(fixture.root, "ghidra-gate-override.json")), false);
|
||||
});
|
||||
|
||||
test("ghidra-gate-override: --disable refuses a record that escapes the vendor tree", (t) => {
|
||||
const fixture = makeVendor(t, "gate-escape");
|
||||
const outside = path.join(fixture.base, "outside.js");
|
||||
writeFileSync(outside, `const SUPPORTED_GHIDRA_VERSION = "12.2";\n`);
|
||||
const escape = path.relative(fixture.vendor, outside); // ../../../../outside.js
|
||||
writeFileSync(
|
||||
path.join(fixture.root, "ghidra-gate-override.json"),
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
requestedVersion: "12.2",
|
||||
originalVersion: "12.1.4",
|
||||
files: [{ file: escape, original: "12.1.4", patched: "12.2" }],
|
||||
}),
|
||||
);
|
||||
|
||||
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
|
||||
assert.equal(disable.status, 1, disable.output);
|
||||
assert.match(disable.output, /escapes/);
|
||||
assert.match(readFileSync(outside, "utf8"), /"12\.2"/);
|
||||
});
|
||||
|
||||
test("ghidra-gate-override: --disable refuses a record that goes through a symlink", (t) => {
|
||||
const fixture = makeVendor(t, "gate-symlink");
|
||||
const outside = path.join(fixture.base, "outside-dir");
|
||||
mkdirSync(outside, { recursive: true });
|
||||
writeFileSync(path.join(outside, "x.js"), `const SUPPORTED_GHIDRA_VERSION = "12.2";\n`);
|
||||
const link = path.join(fixture.vendor, "linked");
|
||||
try {
|
||||
symlinkSync(outside, link, "junction");
|
||||
} catch (error) {
|
||||
t.skip(`this host cannot create a link: ${error.code || error.message}`);
|
||||
return;
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(fixture.root, "ghidra-gate-override.json"),
|
||||
JSON.stringify({
|
||||
schema: 1,
|
||||
requestedVersion: "12.2",
|
||||
originalVersion: "12.1.4",
|
||||
files: [{ file: "linked/x.js", original: "12.1.4", patched: "12.2" }],
|
||||
}),
|
||||
);
|
||||
|
||||
const disable = runNode(GHIDRA_OVERRIDE, ["--disable"], fixture.env);
|
||||
assert.equal(disable.status, 1, disable.output);
|
||||
assert.match(disable.output, /symbolic link/);
|
||||
assert.match(readFileSync(path.join(outside, "x.js"), "utf8"), /"12\.2"/);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------ 1. check-updates.ps1 (drift) */
|
||||
|
||||
function powerShell() {
|
||||
const candidates = process.platform === "win32" ? ["powershell", "pwsh"] : [];
|
||||
for (const command of candidates) {
|
||||
const probe = spawnSync(command, ["-NoProfile", "-Command", "exit 0"], { windowsHide: true });
|
||||
if (!probe.error && probe.status === 0) return command;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows PowerShell 5.1 writes host output in the console code page, so the
|
||||
* bytes are not always UTF-8. Decode as UTF-8 first and fall back to GBK when
|
||||
* the result contains replacement characters.
|
||||
*/
|
||||
function decodeOutput(buffer) {
|
||||
if (!buffer || buffer.length === 0) return "";
|
||||
const utf8 = buffer.toString("utf8");
|
||||
if (!utf8.includes("\uFFFD")) return utf8;
|
||||
try {
|
||||
return new TextDecoder("gbk").decode(buffer);
|
||||
} catch {
|
||||
return utf8;
|
||||
}
|
||||
}
|
||||
|
||||
function runPowerShell(command, args, env) {
|
||||
// Force UTF-8 out of the host process.
|
||||
//
|
||||
// A Windows CI runner answers in CP437, where every CJK character collapses to
|
||||
// "?" — irreversibly, so `decodeOutput`'s GBK fallback cannot rescue it and
|
||||
// every assertion below fails on a host where the script is perfectly fine.
|
||||
// (Measured: the conclusion line arrived as
|
||||
// `[??] ????:pin ?? 1 ?;?? 2 ?(????)`.) Setting this in the CHILD keeps the
|
||||
// shipped script's behaviour untouched — a test harness must not change what
|
||||
// the product prints just to make its own assertions match.
|
||||
// Parameter NAMES must stay unquoted: `& 'script.ps1' '-Registry' 'url'` hands
|
||||
// PowerShell a positional string "-Registry" instead of binding the parameter,
|
||||
// which then became $Registry's VALUE and turned the lookup into a request for
|
||||
// "-Registry/rea-agents/latest" (measured: a 502 and a red suite). `-File` did
|
||||
// the parsing for us; `-Command` does not.
|
||||
const token = (value) =>
|
||||
String(value).startsWith("-") ? String(value) : `"${String(value).replace(/"/g, '""')}"`;
|
||||
const invocation = [
|
||||
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;",
|
||||
"$OutputEncoding=[System.Text.Encoding]::UTF8;",
|
||||
`& "${CHECK_UPDATES}"`,
|
||||
...args.map(token),
|
||||
].join(" ");
|
||||
const result = spawnSync(command, ["-NoProfile", "-Command", invocation], {
|
||||
env: { ...process.env, ...env },
|
||||
windowsHide: true,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
return { status: result.status, output: decodeOutput(result.stdout) + decodeOutput(result.stderr) };
|
||||
}
|
||||
|
||||
function lastMeaningfulLine(output) {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== "")
|
||||
.pop();
|
||||
}
|
||||
|
||||
/** A bundle whose recorded versions all equal the manifest pins. */
|
||||
function makeBundleFixture(t, label) {
|
||||
const base = tempDir(t, label);
|
||||
const root = path.join(base, "bundle");
|
||||
const vendor = path.join(root, "vendor", "node_modules", "rea-agents");
|
||||
const ghidra = path.join(root, "ghidra");
|
||||
mkdirSync(path.join(vendor, "scripts"), { recursive: true });
|
||||
mkdirSync(path.join(root, "bin"), { recursive: true });
|
||||
mkdirSync(path.join(root, "manifest"), { recursive: true });
|
||||
mkdirSync(path.join(ghidra, "Ghidra"), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
path.join(vendor, "package.json"),
|
||||
JSON.stringify({ name: "rea-agents", version: PINS.reaAgents, engines: { node: PINS.nodeEngines } }),
|
||||
);
|
||||
// An empty entry is enough: the script only needs the path to exist, and an
|
||||
// empty CLI exits 0, so the doctor section degrades to "cannot parse".
|
||||
writeFileSync(path.join(vendor, "scripts", "rea.mjs"), "");
|
||||
writeFileSync(path.join(ghidra, "Ghidra", "application.properties"), "application.version=12.1.4\n");
|
||||
copyFileSync(path.join(PLUGIN_ROOT, "bin", "rea-shim.mjs"), path.join(root, "bin", "rea-shim.mjs"));
|
||||
copyFileSync(
|
||||
path.join(PLUGIN_ROOT, "manifest", "bundle.manifest.json"),
|
||||
path.join(root, "manifest", "bundle.manifest.json"),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(root, "mirror.json"),
|
||||
JSON.stringify({ schema: 1, pluginRoot: PLUGIN_ROOT, mirroredAt: "2026-10-26T13:15:52.351Z", dshHome: base }),
|
||||
);
|
||||
|
||||
const bundle = {
|
||||
schema: 1,
|
||||
provisionedAt: "2026-10-26T13:15:52.343Z",
|
||||
paths: { reaEntry: path.join(vendor, "scripts", "rea.mjs"), ghidra },
|
||||
versions: {
|
||||
reaAgents: PINS.reaAgents,
|
||||
ghidra: "12.1.4",
|
||||
jdk: "21.0.12",
|
||||
apktool: PINS.apktool,
|
||||
jadxHeadlessMcp: PINS.jadxHeadlessMcp,
|
||||
node: PINS.node,
|
||||
},
|
||||
components: {
|
||||
node: { path: "node", how: "PATH", version: PINS.node, reused: true },
|
||||
apktool: { path: path.join(root, "vendor", "apktool"), jarName: "apktool.jar", version: PINS.apktool, tofu: false },
|
||||
jadxHeadlessMcp: { path: path.join(root, "vendor", "jadx"), jarName: "jadx.jar", version: PINS.jadxHeadlessMcp, tofu: false },
|
||||
},
|
||||
unverifiedChecksums: [],
|
||||
};
|
||||
writeFileSync(path.join(root, "bundle.json"), JSON.stringify(bundle, null, 2));
|
||||
|
||||
const home = path.join(base, "home");
|
||||
const profile = path.join(home, "profiles", "desktop");
|
||||
mkdirSync(profile, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(profile, "package.json"),
|
||||
JSON.stringify({
|
||||
dependencies: { "dsh-plugin-rea": `file:${PLUGIN_ROOT.replace(/\\/g, "/")}` },
|
||||
dsh: { profile: { bundles: ["dsh-plugin-rea"] } },
|
||||
}),
|
||||
);
|
||||
writeFileSync(path.join(profile, "cordis.patch.yml"), "plugins:\n - id: mcp-rea\n");
|
||||
|
||||
const env = { DSH_HOME: path.join(home), REA_BUNDLE_ROOT: root, DSH_PROFILE_DIR: profile };
|
||||
return { base, root, vendor, ghidra, bundle, env };
|
||||
}
|
||||
|
||||
/** A registry stub that answers the pin, so these tests never need the network. */
|
||||
async function stubRegistry(t) {
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/rea-agents/latest") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ version: PINS.reaAgents }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
|
||||
test("check-updates: a clean bundle is the only green run", async (t) => {
|
||||
const command = powerShell();
|
||||
if (!command) {
|
||||
t.skip("PowerShell is not available on this host");
|
||||
return;
|
||||
}
|
||||
const registry = await stubRegistry(t);
|
||||
const fixture = makeBundleFixture(t, "check-clean");
|
||||
|
||||
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
|
||||
assert.equal(run.status, 0, `clean bundle must exit 0:\n${run.output}`);
|
||||
assert.match(lastMeaningfulLine(run.output), /无需动作/, run.output);
|
||||
});
|
||||
|
||||
test("check-updates: a pin drift is reported as needing action, not as green", async (t) => {
|
||||
const command = powerShell();
|
||||
if (!command) {
|
||||
t.skip("PowerShell is not available on this host");
|
||||
return;
|
||||
}
|
||||
const registry = await stubRegistry(t);
|
||||
const fixture = makeBundleFixture(t, "check-drift");
|
||||
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
|
||||
bundle.versions.apktool = "3.0.2"; // pin says 3.0.3
|
||||
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
|
||||
|
||||
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
|
||||
assert.equal(run.status, 1, `drift must not exit 0:\n${run.output}`);
|
||||
assert.doesNotMatch(run.output, /无需动作/, run.output);
|
||||
assert.match(run.output, /apktool 盘上为 3\.0\.2/);
|
||||
const last = lastMeaningfulLine(run.output);
|
||||
assert.match(last, /需要动作/, last);
|
||||
assert.match(last, /pin 漂移 1 项/, last);
|
||||
});
|
||||
|
||||
function hasFileHash(command) {
|
||||
// The stale-mirror warning below is produced by comparing two file hashes, so a
|
||||
// host without `Get-FileHash` (hosted Windows runners, restricted language mode)
|
||||
// cannot construct that scenario at all. Skip honestly instead of asserting on a
|
||||
// warning the platform can never emit — the script itself already reports the
|
||||
// missing capability as [上游] rather than as "needs action".
|
||||
const probe = spawnSync(command, ["-NoProfile", "-Command", "if (Get-Command Get-FileHash -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"], {
|
||||
windowsHide: true,
|
||||
});
|
||||
return probe.status === 0;
|
||||
}
|
||||
|
||||
test("check-updates: a warning alone is enough to stop the green verdict", async (t) => {
|
||||
const command = powerShell();
|
||||
if (!command) {
|
||||
t.skip("PowerShell is not available on this host");
|
||||
return;
|
||||
}
|
||||
if (!hasFileHash(command)) {
|
||||
t.skip("this host has no Get-FileHash, so the stale-mirror warning cannot be produced");
|
||||
return;
|
||||
}
|
||||
const registry = await stubRegistry(t);
|
||||
const fixture = makeBundleFixture(t, "check-warn");
|
||||
// Every pin matches; only the mirrored shim is stale (plugin upgraded, setup
|
||||
// not re-run). That used to be a warning next to a green verdict.
|
||||
writeFileSync(path.join(fixture.root, "bin", "rea-shim.mjs"), "// stale mirror\n");
|
||||
|
||||
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
|
||||
assert.equal(run.status, 1, `a warning must not exit 0:\n${run.output}`);
|
||||
assert.doesNotMatch(run.output, /无需动作/, run.output);
|
||||
assert.match(run.output, /镜像 shim 落后于插件/);
|
||||
const last = lastMeaningfulLine(run.output);
|
||||
assert.match(last, /需要动作/, last);
|
||||
assert.match(last, /提醒/, last);
|
||||
});
|
||||
|
||||
test("check-updates: an action item also produces a non-zero exit", async (t) => {
|
||||
const command = powerShell();
|
||||
if (!command) {
|
||||
t.skip("PowerShell is not available on this host");
|
||||
return;
|
||||
}
|
||||
const registry = await stubRegistry(t);
|
||||
const fixture = makeBundleFixture(t, "check-action");
|
||||
// components.node.reused stays true, but the recorded runtime no longer
|
||||
// satisfies REA's engines — the case that used to print an action and exit 0.
|
||||
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
|
||||
bundle.versions.node = "16.20.2";
|
||||
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
|
||||
|
||||
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
|
||||
assert.equal(run.status, 1, `an unhealthy node must not exit 0:\n${run.output}`);
|
||||
const last = lastMeaningfulLine(run.output);
|
||||
assert.match(last, /需要动作/, last);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------- safety net */
|
||||
|
||||
test("check-updates: a recorded gate override is not drift", async (t) => {
|
||||
const command = powerShell();
|
||||
if (!command) {
|
||||
t.skip("PowerShell is not available on this host");
|
||||
return;
|
||||
}
|
||||
const registry = await stubRegistry(t);
|
||||
const fixture = makeBundleFixture(t, "check-override");
|
||||
// Ghidra pinned at 12.1.4 would match anyway; record 12.2 plus its override to
|
||||
// prove the pair is reported as a deliberate local divergence, never as drift.
|
||||
const bundle = JSON.parse(readFileSync(path.join(fixture.root, "bundle.json"), "utf8"));
|
||||
bundle.versions.ghidra = "12.2";
|
||||
writeFileSync(path.join(fixture.root, "bundle.json"), JSON.stringify(bundle, null, 2));
|
||||
writeFileSync(path.join(fixture.ghidra, "Ghidra", "application.properties"), "application.version=12.2\n");
|
||||
writeFileSync(
|
||||
path.join(fixture.root, "ghidra-gate-override.json"),
|
||||
JSON.stringify({ schema: 1, requestedVersion: "12.2", originalVersion: "12.1.4", files: [] }),
|
||||
);
|
||||
|
||||
const run = runPowerShell(command, ["-Registry", registry], fixture.env);
|
||||
assert.doesNotMatch(run.output, /ghidra 盘上为 12\.2/, run.output);
|
||||
// The version gate still knows REA pins 12.1.x; it must attribute the 12.2 to
|
||||
// the recorded override instead of printing an action item.
|
||||
assert.match(run.output, /本机 --enable 的覆盖/);
|
||||
assert.equal(run.status, 0, `a recorded override is not a defect:\n${run.output}`);
|
||||
const last = lastMeaningfulLine(run.output);
|
||||
assert.doesNotMatch(last, /pin 漂移/, last);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/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.
|
||||
*
|
||||
* FLOOR AND CEILING: the floor stays at 0.1.7-rc.2 (nothing older was ever
|
||||
* adapted), and the newest entry must still sit below the declared ceiling.
|
||||
* The 2026-11-06 adaptation added the 0.2.2 line and 0.2.1-rc.3/4/5, all of
|
||||
* which remain below `<0.3.0-0` — the ceiling was NOT raised to reach them, so
|
||||
* a release past 0.3.0 arrives here as an `uncovered` failure and as a decision
|
||||
* for a human, never as a silent widening of what the market advertises.
|
||||
*
|
||||
* SEMANTICS: this file evaluates the declaration the way `includePrerelease:
|
||||
* true` does, and that choice is not stylistic. Both consumers of `engines.dsh`
|
||||
* in this ecosystem pass that option — dshmarket's discovery
|
||||
* (`satisfiesRange(host, declared, { includePrerelease: true })` in
|
||||
* `deriveHostCompatibility`) and the host's own peer gate — so a plain semver
|
||||
* reading, where `0.2.0-rc.2` does NOT satisfy `>=0.1.7-rc.2 <0.3.0-0` (a
|
||||
* prerelease must be named by a comparator on its own tuple), would disagree
|
||||
* with the verdict users actually see. The tests pin the option's effect.
|
||||
*
|
||||
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
|
||||
* covered by the declared range; exit 1 = at least one is not).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const REPO_ROOT = path.join(HERE, '..')
|
||||
|
||||
/**
|
||||
* Every dsh release this bundle is checked against, newest first.
|
||||
*
|
||||
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
|
||||
* floor is the oldest release the plugin has been adapted to; everything above
|
||||
* it up to the declared ceiling must keep working, so a release that is missing
|
||||
* here is a release nobody checked.
|
||||
*
|
||||
* "Newest first" means BY PUBLISH DATE — the order the release page shows — and
|
||||
* not semver precedence. The two disagree exactly once here: 0.2.1-rc.5
|
||||
* (published 2026-10-30) came out after 0.2.2-alpha.2 (2026-10-27), so a reader
|
||||
* who assumes descending semver will take 0.2.2-alpha.2 to be the newer of the
|
||||
* two. Publication order is the sequence hosts actually upgrade through, so
|
||||
* that is the order recorded; per-release dates are in
|
||||
* docs/host-compat-2026-11-06.md rather than beside the literals, because a
|
||||
* date on every line goes stale the first time someone appends without one.
|
||||
*/
|
||||
export const HOST_RELEASES = [
|
||||
'0.2.2-rc.1', // 2026-11-04 — the host the 2026-11-06 adaptation was measured on
|
||||
'0.2.2-alpha.3', // 2026-11-03
|
||||
'0.2.1-rc.5', // 2026-10-30
|
||||
'0.2.2-alpha.2', // 2026-10-27
|
||||
'0.2.1-rc.4', // 2026-10-26
|
||||
'0.2.1-rc.3', // 2026-10-23
|
||||
'0.2.1-rc.2',
|
||||
'0.2.1-rc.1',
|
||||
'0.2.1-alpha.5',
|
||||
'0.2.1-alpha.4',
|
||||
'0.2.1-alpha.3',
|
||||
'0.2.1-alpha.2',
|
||||
'0.2.1-alpha.1',
|
||||
'0.2.0-rc.2',
|
||||
'0.2.0-rc.1',
|
||||
'0.1.7-rc.2',
|
||||
]
|
||||
|
||||
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
|
||||
export const OUTSIDE_HOSTS = [
|
||||
'0.3.0',
|
||||
// Prereleases of the NEXT line are the case a plain `<0.3.0` ceiling lets
|
||||
// through: with `includePrerelease` they sort below `0.3.0` itself. The
|
||||
// declared ceiling is `<0.3.0-0`, semver's idiom for "nothing from 0.3.0",
|
||||
// and these entries are what proves it does what it says.
|
||||
'0.3.0-rc.1',
|
||||
'0.3.0-alpha.1',
|
||||
'0.1.7-rc.1',
|
||||
'0.1.7-alpha.2',
|
||||
]
|
||||
|
||||
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
||||
|
||||
/**
|
||||
* Split a version into comparable parts.
|
||||
*
|
||||
* Build metadata (`+sha`) is dropped, as semver says it must be for precedence
|
||||
* and as node-semver does for comparison: a host that reports
|
||||
* `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. Everything else is deliberately
|
||||
* strict — a leading `v` or a zero-padded identifier is rejected rather than
|
||||
* guessed at, because this gate exists to catch a declaration nobody checked and
|
||||
* a guess would put the guess in charge.
|
||||
* @param {string} version - a plain semver string, no range operators.
|
||||
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
|
||||
*/
|
||||
export function parseVersion(version) {
|
||||
const cleaned = String(version ?? '').trim().replace(/\+.*$/, '')
|
||||
const match = SEMVER.exec(cleaned)
|
||||
if (match === null) return null
|
||||
const raw = match[4] === undefined ? [] : match[4].split('.')
|
||||
// semver forbids a zero-padded numeric prerelease identifier (`0.2.1-01`);
|
||||
// accepting one would make this gate read a typo as a version.
|
||||
if (raw.some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) return null
|
||||
const pre = raw.map(part => (/^\d+$/.test(part) ? Number(part) : part))
|
||||
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions the way semver does, prerelease included.
|
||||
*
|
||||
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
|
||||
* prereleases an identifier that is numeric sorts below one that is not, which
|
||||
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
|
||||
* on a numeric comparison.
|
||||
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
|
||||
*/
|
||||
export function compareVersions(a, b) {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
if (left === null) throw new Error(`not a version: ${a}`)
|
||||
if (right === null) throw new Error(`not a version: ${b}`)
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
|
||||
}
|
||||
if (left.pre.length === 0 && right.pre.length === 0) return 0
|
||||
if (left.pre.length === 0) return 1
|
||||
if (right.pre.length === 0) return -1
|
||||
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
||||
const l = left.pre[index]
|
||||
const r = right.pre[index]
|
||||
if (l === undefined) return -1
|
||||
if (r === undefined) return 1
|
||||
if (l === r) continue
|
||||
const lNum = typeof l === 'number'
|
||||
const rNum = typeof r === 'number'
|
||||
if (lNum && rNum) return l < r ? -1 : 1
|
||||
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
||||
if (lNum !== rNum) return lNum ? -1 : 1
|
||||
return String(l) < String(r) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
|
||||
* ships, and the shape the market's `engines.dsh` is compared with).
|
||||
*
|
||||
* Deliberately narrow: a wider parser here could disagree with the market's
|
||||
* semver implementation on a range the manifest would then be trusted for. If a
|
||||
* future declaration needs `||` or `^`, this function has to grow with it.
|
||||
* @param {string} version - host version to test.
|
||||
* @param {string} range - the declared range.
|
||||
* @returns {boolean} whether the host is inside the declaration.
|
||||
*/
|
||||
export function satisfiesDeclaredRange(version, range) {
|
||||
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
|
||||
if (match === null) throw new Error(`unsupported host range: ${range}`)
|
||||
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The host range this repository declares, `engines.dsh`.
|
||||
* @param {string} [root] - repository root, for tests.
|
||||
* @returns {string | null} the declared range, or null when absent.
|
||||
*/
|
||||
export function declaredHostRange(root = REPO_ROOT) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
|
||||
const value = pkg?.engines?.dsh
|
||||
return typeof value === 'string' ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every release covered by the declaration, and every version that must not be.
|
||||
* @param {string} range - declared host range.
|
||||
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
|
||||
*/
|
||||
export function checkHostCoverage(range) {
|
||||
const covered = []
|
||||
const uncovered = []
|
||||
for (const version of HOST_RELEASES) {
|
||||
if (satisfiesDeclaredRange(version, range)) covered.push(version)
|
||||
else uncovered.push(version)
|
||||
}
|
||||
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
|
||||
return { covered, uncovered, leaked }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const range = declaredHostRange()
|
||||
if (range === null) {
|
||||
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
|
||||
process.exit(1)
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = checkHostCoverage(range)
|
||||
} catch (error) {
|
||||
console.error(`FAIL: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`engines.dsh = ${range}`)
|
||||
for (const version of HOST_RELEASES) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
|
||||
}
|
||||
for (const version of OUTSIDE_HOSTS) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
|
||||
}
|
||||
if (result.uncovered.length > 0) {
|
||||
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (result.leaked.length > 0) {
|
||||
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tests for scripts/host-compat.mjs — the gate that compares the declared
|
||||
* `engines.dsh` string against the list of DSH releases this bundle was adapted
|
||||
* to.
|
||||
*
|
||||
* Every test here exists because one of these failures is silent. The gate has
|
||||
* no runtime feedback: it prints a line per release and exits, and the wrong
|
||||
* verdict looks exactly like the right one in a CI log nobody reads closely. So
|
||||
* each way it can lie gets a test that names the lie:
|
||||
*
|
||||
* - a range that is unparseable reads as `unknown` (an undeclared plugin,
|
||||
* not a mis-declared one) — so the parser refuses shapes it cannot judge
|
||||
* - a floor that drifted drops hosts the README still promises — so the
|
||||
* drifted-floor control asserts the exact set it loses
|
||||
* - a ceiling of `<0.3.0` admits `0.3.0-rc.1`, because a prerelease sorts
|
||||
* BELOW its release once prereleases are included — the single most
|
||||
* expensive mistake this package ever shipped
|
||||
* - a zero-padded prerelease or a `v` prefix is a typo, and a gate that
|
||||
* guesses at one puts the guess in charge of what the market advertises
|
||||
* - an appended release lands in the wrong place in the list, so the next
|
||||
* person reads the coverage claim backwards
|
||||
*
|
||||
* Runs with `node --test`. No network, no filesystem writes.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
HOST_RELEASES,
|
||||
OUTSIDE_HOSTS,
|
||||
checkHostCoverage,
|
||||
compareVersions,
|
||||
declaredHostRange,
|
||||
parseVersion,
|
||||
satisfiesDeclaredRange,
|
||||
} from "./host-compat.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.join(HERE, "..");
|
||||
|
||||
/** The declaration this package actually ships; pinned by its own test too. */
|
||||
const RANGE = ">=0.1.7-rc.2 <0.3.0-0";
|
||||
|
||||
/* --------------------------------------------------------- parseVersion */
|
||||
|
||||
test("parseVersion splits the tuple and keeps a prerelease", () => {
|
||||
assert.deepEqual(parseVersion("0.2.1-rc.2"), { parts: [0, 2, 1], pre: ["rc", 2] });
|
||||
assert.deepEqual(parseVersion("0.1.7-rc.2"), { parts: [0, 1, 7], pre: ["rc", 2] });
|
||||
// A numeric prerelease identifier becomes a number, not the string "2": the
|
||||
// comparison in compareVersions branches on that type.
|
||||
assert.deepEqual(parseVersion("0.2.1-alpha.10"), { parts: [0, 2, 1], pre: ["alpha", 10] });
|
||||
});
|
||||
|
||||
test("parseVersion drops build metadata, because precedence has none", () => {
|
||||
// A host reporting `0.2.0-rc.2+build.7` is running 0.2.0-rc.2. node-semver and
|
||||
// this file both ignore the suffix; a gate that kept it would compare the
|
||||
// string "7" against an alpha identifier and answer with nonsense.
|
||||
assert.deepEqual(parseVersion("0.2.0-rc.2+build.7"), { parts: [0, 2, 0], pre: ["rc", 2] });
|
||||
assert.deepEqual(parseVersion("0.2.0+build.7"), { parts: [0, 2, 0], pre: [] });
|
||||
assert.equal(compareVersions("0.2.0-rc.2+build.7", "0.2.0-rc.2"), 0);
|
||||
});
|
||||
|
||||
test("parseVersion refuses the shapes a typo produces instead of guessing", () => {
|
||||
// Zero-padded numeric identifier: semver forbids it. Accepting one would let
|
||||
// `0.2.1-01` compare equal to `0.2.1-1` and hide a typo in the release list.
|
||||
assert.equal(parseVersion("0.2.1-01"), null);
|
||||
assert.equal(parseVersion("0.2.1-rc.01"), null);
|
||||
// A leading `v` is a tag, not a version.
|
||||
assert.equal(parseVersion("v0.2.0-rc.2"), null);
|
||||
// Too few / too many components, and outright garbage.
|
||||
assert.equal(parseVersion("0.2"), null);
|
||||
assert.equal(parseVersion("0.2.0.1"), null);
|
||||
assert.equal(parseVersion(""), null);
|
||||
assert.equal(parseVersion(undefined), null);
|
||||
assert.equal(parseVersion(null), null);
|
||||
assert.equal(parseVersion("latest"), null);
|
||||
});
|
||||
|
||||
test("parseVersion accepts a single zero and a prerelease of one identifier", () => {
|
||||
// `0` is legal and is load bearing: `<0.3.0-0` ends at the bare zero
|
||||
// identifier, which is what makes every 0.3.0 prerelease sort above it.
|
||||
assert.deepEqual(parseVersion("0.3.0-0"), { parts: [0, 3, 0], pre: [0] });
|
||||
assert.deepEqual(parseVersion("0.2.1-rc"), { parts: [0, 2, 1], pre: ["rc"] });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------- compareVersions */
|
||||
|
||||
test("a prerelease sorts below its release", () => {
|
||||
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1"), -1);
|
||||
assert.equal(compareVersions("0.2.1", "0.2.1-rc.2"), 1);
|
||||
assert.equal(compareVersions("0.2.2-rc.1", "0.2.2"), -1);
|
||||
assert.equal(compareVersions("0.2.1", "0.2.1"), 0);
|
||||
});
|
||||
|
||||
test("prerelease identifiers order the way semver says, not the way strings do", () => {
|
||||
// Numeric identifiers sort below alphanumeric ones.
|
||||
assert.equal(compareVersions("0.3.0-0", "0.3.0-alpha.1"), -1);
|
||||
assert.equal(compareVersions("0.3.0-rc.1", "0.3.0-0"), 1);
|
||||
// alpha < rc. String order agrees with this one, so it alone proves nothing.
|
||||
assert.equal(compareVersions("0.2.1-alpha.5", "0.2.1-rc.1"), -1);
|
||||
// But string order DISAGREES here: "alpha.10" < "alpha.5" as text, and semver
|
||||
// compares 10 > 5. This is the assertion that catches a localeCompare slip.
|
||||
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
|
||||
// A shorter identifier list sorts below a longer one that shares its prefix.
|
||||
assert.equal(compareVersions("0.2.1-rc", "0.2.1-rc.1"), -1);
|
||||
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1-rc.1"), 1);
|
||||
});
|
||||
|
||||
test("compareVersions compares the tuple before the prerelease", () => {
|
||||
// 0.2.2-alpha.3 is NEWER than 0.2.1-rc.5 even though "alpha" < "rc": the
|
||||
// tuple decides first. The release list's one ordering exception depends on
|
||||
// the opposite direction of this rule, so get it pinned.
|
||||
assert.equal(compareVersions("0.2.2-alpha.3", "0.2.1-rc.5"), 1);
|
||||
assert.equal(compareVersions("0.2.1-rc.5", "0.2.2-alpha.2"), -1);
|
||||
});
|
||||
|
||||
test("compareVersions refuses unparseable input rather than returning a verdict", () => {
|
||||
assert.throws(() => compareVersions("v0.2.0", "0.2.0"), /not a version/);
|
||||
assert.throws(() => compareVersions("0.2.0", "0.2.1-01"), /not a version/);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------ satisfiesDeclaredRange */
|
||||
|
||||
test("the declared range admits every release from the floor up", () => {
|
||||
assert.equal(satisfiesDeclaredRange("0.1.7-rc.2", RANGE), true, "the floor itself is inside");
|
||||
assert.equal(satisfiesDeclaredRange("0.2.0-rc.1", RANGE), true);
|
||||
assert.equal(satisfiesDeclaredRange("0.2.1-rc.5", RANGE), true);
|
||||
assert.equal(satisfiesDeclaredRange("0.2.2-rc.1", RANGE), true, "the host this list was last measured on");
|
||||
});
|
||||
|
||||
test("the next minor line falls outside, prereleases included", () => {
|
||||
// This is the assertion the `<0.3.0-0` ceiling exists for. With
|
||||
// `includePrerelease: true`, `0.3.0-rc.1 < 0.3.0` is true, so a plain
|
||||
// `<0.3.0` ceiling would have admitted a host nobody audited.
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0", RANGE), false);
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1", RANGE), false);
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0-alpha.1", RANGE), false);
|
||||
// And the boundary node itself is excluded: `< 0.3.0-0` is strict.
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0-0", RANGE), false);
|
||||
assert.equal(satisfiesDeclaredRange("0.4.0", RANGE), false);
|
||||
});
|
||||
|
||||
test("versions below the floor fall outside", () => {
|
||||
assert.equal(satisfiesDeclaredRange("0.1.7-rc.1", RANGE), false);
|
||||
assert.equal(satisfiesDeclaredRange("0.1.7-alpha.2", RANGE), false);
|
||||
assert.equal(satisfiesDeclaredRange("0.1.6-alpha.2", RANGE), false);
|
||||
assert.equal(satisfiesDeclaredRange("0.1.7", RANGE), true, "the release of the floor line is above its rc");
|
||||
});
|
||||
|
||||
test("build metadata does not change the verdict", () => {
|
||||
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2+build.7", RANGE), true);
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0-rc.1+build.7", RANGE), false);
|
||||
});
|
||||
|
||||
test("only the `>=X <Y` shape is evaluated; anything else is refused", () => {
|
||||
// Deliberately narrow. A wider parser here could disagree with the market's
|
||||
// semver implementation on a range the manifest would then be trusted for,
|
||||
// and the disagreement would surface as a wrong "compatible" badge.
|
||||
for (const range of ["^0.2.0", "~0.2.0", "0.2.0", ">=0.1.7-rc.2", "<0.3.0-0", ">=0.1.7-rc.2 <0.3.0-0 || >=0.4.0-0", ">= 0.1.7-rc.2 <0.3.0-0 <0.4.0-0"]) {
|
||||
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", range), /unsupported host range/, `must refuse ${range}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("extra whitespace around the two comparators is tolerated", () => {
|
||||
// A pasted range often carries it, and node-semver tolerates it too.
|
||||
assert.equal(satisfiesDeclaredRange("0.2.0-rc.2", " >=0.1.7-rc.2 <0.3.0-0 "), true);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------ declaredHostRange */
|
||||
|
||||
test("declaredHostRange reads the real manifest and returns null when absent", () => {
|
||||
assert.equal(typeof declaredHostRange(REPO_ROOT), "string");
|
||||
assert.equal(declaredHostRange(path.join(HERE, "no-such-root-for-host-compat")), null);
|
||||
});
|
||||
|
||||
/* ----------------------------------------------------- checkHostCoverage */
|
||||
|
||||
test("every listed release is covered and no outsider leaks in", () => {
|
||||
const { covered, uncovered, leaked } = checkHostCoverage(RANGE);
|
||||
assert.deepEqual(uncovered, [], `these releases fall outside ${RANGE}`);
|
||||
assert.deepEqual(leaked, [], `the declaration also admits ${leaked.join(", ")}, which nobody tested`);
|
||||
assert.deepEqual(covered, HOST_RELEASES, "covered must report the list in list order");
|
||||
});
|
||||
|
||||
test("the drifted-floor control loses exactly the hosts a 0.2.0 floor would drop", () => {
|
||||
// A negative control: a gate that always passes proves nothing. `>=0.2.0` is
|
||||
// the shape someone writes after testing only on their own machine — and
|
||||
// because a prerelease sorts BELOW its release, it drops the whole 0.2.0-rc
|
||||
// line as well as 0.1.7, while looking like it names the running host.
|
||||
const { uncovered } = checkHostCoverage(">=0.2.0 <0.3.0-0");
|
||||
assert.deepEqual(uncovered, ["0.2.0-rc.2", "0.2.0-rc.1", "0.1.7-rc.2"]);
|
||||
});
|
||||
|
||||
test("the leaked-host control notices a low floor and a raised ceiling", () => {
|
||||
assert.deepEqual(checkHostCoverage(">=0.1.0 <0.3.0-0").leaked, ["0.1.7-rc.1", "0.1.7-alpha.2"]);
|
||||
// The other half: a ceiling that reaches into 0.3.0 must be reported, not
|
||||
// silently accepted. Every 0.3.0 entry has to show up.
|
||||
assert.deepEqual(checkHostCoverage(">=0.1.7-rc.2 <0.4.0-0").leaked, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1"]);
|
||||
// A plain `<0.3.0` ceiling is the expensive one: it admits the 0.3.0
|
||||
// prereleases while still excluding 0.3.0 itself, so only the two prerelease
|
||||
// entries surface. That asymmetry is exactly why this list is pinned.
|
||||
assert.deepEqual(checkHostCoverage(">=0.1.7-rc.2 <0.3.0").leaked, ["0.3.0-rc.1", "0.3.0-alpha.1"]);
|
||||
});
|
||||
|
||||
test("checkHostCoverage propagates the refusal for an unparseable range", () => {
|
||||
assert.throws(() => checkHostCoverage("^0.2.0"), /unsupported host range/);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------- the release list */
|
||||
|
||||
test("the 2026-11-06 adaptation is present, and the newest six are in front", () => {
|
||||
const added = ["0.2.2-rc.1", "0.2.2-alpha.3", "0.2.1-rc.5", "0.2.2-alpha.2", "0.2.1-rc.4", "0.2.1-rc.3"];
|
||||
assert.deepEqual(HOST_RELEASES.slice(0, added.length), added, "newest-first by publish date");
|
||||
for (const version of added) {
|
||||
assert.ok(HOST_RELEASES.includes(version), `${version} must be listed; an unlisted release is an unchecked one`);
|
||||
}
|
||||
// 0.2.2-rc.1 is the host this adaptation was measured on: if it is not the
|
||||
// first entry, someone prepended without a reason.
|
||||
assert.equal(HOST_RELEASES[0], "0.2.2-rc.1");
|
||||
});
|
||||
|
||||
test("the list stays newest-first, with exactly one documented publication-order exception", () => {
|
||||
// "Newest first" means by publish date. That is semver-descending everywhere
|
||||
// EXCEPT 0.2.1-rc.5 (published after 0.2.2-alpha.2), which is the one
|
||||
// inversion the list carries on purpose. Pinning the exception list rather
|
||||
// than the whole array keeps this test from being a third copy of the data
|
||||
// while still catching an append in the wrong place.
|
||||
const inversions = [];
|
||||
for (let index = 1; index < HOST_RELEASES.length; index += 1) {
|
||||
const previous = HOST_RELEASES[index - 1];
|
||||
const current = HOST_RELEASES[index];
|
||||
if (compareVersions(previous, current) <= 0) inversions.push(`${previous} < ${current}`);
|
||||
}
|
||||
assert.deepEqual(inversions, ["0.2.1-rc.5 < 0.2.2-alpha.2"]);
|
||||
});
|
||||
|
||||
test("the floor is still 0.1.7-rc.2, the oldest release ever adapted to", () => {
|
||||
// Raising the floor silently drops hosts the README promises, which is the
|
||||
// failure the file header calls out. If a future adaptation really does drop
|
||||
// 0.1.7, this test is where the decision gets recorded.
|
||||
assert.equal(HOST_RELEASES[HOST_RELEASES.length - 1], "0.1.7-rc.2");
|
||||
// And the list never runs below the floor it claims.
|
||||
for (const version of HOST_RELEASES) {
|
||||
assert.ok(compareVersions(version, "0.1.7-rc.2") >= 0, `${version} sorts below the floor`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the list carries no duplicate and every entry parses", () => {
|
||||
assert.equal(new Set(HOST_RELEASES).size, HOST_RELEASES.length, "a duplicate would double-count coverage");
|
||||
for (const version of HOST_RELEASES) {
|
||||
assert.notEqual(parseVersion(version), null, `${version} is not a version this gate can evaluate`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the outsider list is the audited one, and none of it is a shipped release", () => {
|
||||
// Pinned so that "make the gate pass" can never be done by deleting an
|
||||
// outsider. These five are the ones a wrong ceiling or a wrong floor admits.
|
||||
assert.deepEqual(OUTSIDE_HOSTS, ["0.3.0", "0.3.0-rc.1", "0.3.0-alpha.1", "0.1.7-rc.1", "0.1.7-alpha.2"]);
|
||||
for (const version of OUTSIDE_HOSTS) {
|
||||
assert.ok(!HOST_RELEASES.includes(version), `${version} is both covered and required to be outside`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the newest release stays inside the declared ceiling, without the ceiling moving", () => {
|
||||
// The declared range is the thing the market reads. An adaptation that needed
|
||||
// a wider ceiling would have to change `engines.dsh` — a decision for a human,
|
||||
// and this assertion is what forces it to be one.
|
||||
assert.equal(declaredHostRange(REPO_ROOT), RANGE);
|
||||
assert.equal(satisfiesDeclaredRange(HOST_RELEASES[0], RANGE), true);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
# Pin comparison for check-updates.ps1, kept in a dot-sourceable file so it can
|
||||
# be tested without running the whole check (which talks to a registry).
|
||||
#
|
||||
# Why this is a function and not an inline condition: until 2.0.10 the comparison
|
||||
# was "equal, OR shares the first dotted component with the pin", applied to every
|
||||
# component. "6.5.0".StartsWith("6.") is true, so a bundle still on 6.5.0 reported
|
||||
# "consistent with pin 6.7.0", and that line is the only signal a user has that a
|
||||
# pin bump requires re-running setup. apktool had the same shape (3.0.9 vs 3.0.3,
|
||||
# where the major-prefix test called them equal).
|
||||
#
|
||||
# The one pin that is genuinely major-only is the JDK ("21"): the download records
|
||||
# 21.0.12.1+1 and both name the same family. A dotted pin names exactly one
|
||||
# version. The reverse rule, prefix matching on a dotted pin, is the bug.
|
||||
# Kept ASCII-only on purpose: Windows PowerShell 5.1 reads a BOM-less script as
|
||||
# ANSI, and this file ships inside the package.
|
||||
function Test-PinMatch {
|
||||
param(
|
||||
[string]$Installed,
|
||||
[string]$Pinned
|
||||
)
|
||||
$installed = ([string]$Installed).Trim()
|
||||
$pinned = ([string]$Pinned).Trim()
|
||||
if ($installed -eq '' -or $pinned -eq '') { return $false }
|
||||
$same = [string]::Equals($installed, $pinned, [System.StringComparison]::Ordinal)
|
||||
if ($pinned.Contains('.')) { return $same }
|
||||
# A major-only pin ("21") names a FLOOR, not a family prefix.
|
||||
#
|
||||
# Ghidra 12.1 requires "JDK 21 or newer", so a machine whose JAVA_HOME holds
|
||||
# 25.0.5 satisfies a pin that names 21. This function used to accept 21.0.12.1+1
|
||||
# and reject 25.0.5, which told the operator to "re-run setup to align" while
|
||||
# setup itself answered "already provisioned (25.0.5) - major 25 satisfies the
|
||||
# pin's major 21". One of the two had to be wrong, and it was this one: the
|
||||
# prefix rule cannot express "or newer", and a bare-major pin can mean nothing
|
||||
# else.
|
||||
$installedMajor = 0
|
||||
$pinnedMajor = 0
|
||||
$installedHead = ($installed -split '\.')[0]
|
||||
if ([int]::TryParse($installedHead, [ref]$installedMajor) -and [int]::TryParse($pinned, [ref]$pinnedMajor)) {
|
||||
return ($installedMajor -ge $pinnedMajor)
|
||||
}
|
||||
# Not two numbers (a "21-rc.1"-shaped pin, say): fall back to the literal match.
|
||||
return $same
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
|
||||
#
|
||||
# Why this exists: setup.mjs is written in Node, so a machine with no Node
|
||||
# cannot run it. This script uses only PowerShell + Expand-Archive (no
|
||||
# downloads other than the official Node.js zip and then the components), gets a
|
||||
# portable Node, and hands the rest to scripts/setup.mjs.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1
|
||||
#
|
||||
# Options:
|
||||
# -Root <dir> bundle root (default: $env:DSH_HOME\plugins\rea-bundle,
|
||||
# falling back to ~\.dsh\plugins\rea-bundle)
|
||||
# -Reuse adopt an existing Ghidra/JDK instead of downloading them
|
||||
# -DryRun print the plan and stop
|
||||
#
|
||||
# Nothing is installed system-wide and no registry key is touched: the runtime
|
||||
# is unpacked next to the bundle and used from there.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Root,
|
||||
[switch]$Reuse,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step([string]$Message) { Write-Host "[bootstrap] $Message" }
|
||||
|
||||
# --- where things go -------------------------------------------------------
|
||||
|
||||
# Same precedence as setup.mjs, the shim and check-updates.ps1: explicit override,
|
||||
# then DSH_HOME, then ~/.dsh. An override this script alone ignored would put
|
||||
# ~800 MB into <DSH_HOME>/plugins/rea-bundle while every reader looked in
|
||||
# REA_BUNDLE_ROOT, saw an empty bundle, and started a second run.
|
||||
if (-not $Root -or $Root.Trim() -eq '') { $Root = $env:REA_BUNDLE_ROOT }
|
||||
if (-not $Root -or $Root.Trim() -eq '') {
|
||||
$home2 = $env:DSH_HOME
|
||||
if (-not $home2 -or $home2.Trim() -eq '') { $home2 = Join-Path $HOME '.dsh' }
|
||||
$Root = Join-Path $home2 'plugins\rea-bundle'
|
||||
}
|
||||
$Root = [System.IO.Path]::GetFullPath($Root)
|
||||
$stack = Join-Path $Root 'stack'
|
||||
$cache = Join-Path $Root 'cache'
|
||||
$logs = Join-Path $Root 'logs'
|
||||
# -DryRun promises that nothing was changed, so it must not create these either:
|
||||
# it only reads them, and Test-Path / Get-ChildItem handle a missing path.
|
||||
if (-not $DryRun) {
|
||||
foreach ($dir in @($Root, $stack, $cache, $logs)) {
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
$pluginRoot = Split-Path -Parent $PSScriptRoot
|
||||
$setupScript = Join-Path $pluginRoot 'scripts\setup.mjs'
|
||||
if (-not (Test-Path $setupScript)) { throw "setup.mjs not found next to this script: $setupScript" }
|
||||
|
||||
Write-Step "bundle root : $Root"
|
||||
Write-Step "plugin root : $pluginRoot"
|
||||
Write-Step "platform : $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)"
|
||||
Write-Step "architecture: $env:PROCESSOR_ARCHITECTURE"
|
||||
|
||||
if ($env:PROCESSOR_ARCHITECTURE -notmatch '64') {
|
||||
throw "This bootstrap supports 64-bit Windows (PROCESSOR_ARCHITECTURE=$env:PROCESSOR_ARCHITECTURE)."
|
||||
}
|
||||
|
||||
# --- Node: reuse if compatible, else fetch the portable zip -----------------
|
||||
#
|
||||
# The version and checksum come from the manifest, which is the single source of
|
||||
# truth the rest of provisioning already reads. Hard-coding a second copy here
|
||||
# meant two numbers that could drift apart silently.
|
||||
|
||||
$manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
|
||||
if (-not (Test-Path $manifestPath)) { throw "manifest not found: $manifestPath" }
|
||||
$manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
$hostKey = 'win32-' + $(if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' })
|
||||
$nodeSource = $manifest.components.node.sources.$hostKey
|
||||
if (-not $nodeSource) { throw "the manifest has no Node source for $hostKey" }
|
||||
$nodeVersion = $nodeSource.version
|
||||
$nodeSha256 = $nodeSource.sha256
|
||||
$nodeUrl = $nodeSource.url
|
||||
$nodeZipName = [System.IO.Path]::GetFileName(([uri]$nodeUrl).AbsolutePath)
|
||||
# The official archive unpacks to exactly one top-level directory named after the
|
||||
# zip: node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64. The extension is
|
||||
# therefore stripped EXACTLY ONCE. Stripping twice yielded 'node-v24.21', so the
|
||||
# checks below looked for <stack>\node-v24.21\node.exe and the run threw
|
||||
# "the Node zip did not contain node-v24.21" *after* a successful download,
|
||||
# checksum and extraction — on every machine that had no Node, which is the only
|
||||
# audience this script has. Invisible wherever a Node is already on PATH.
|
||||
$nodeFolder = [System.IO.Path]::GetFileNameWithoutExtension($nodeZipName)
|
||||
if ($nodeSha256 -eq 'unverified') { throw "the manifest pins no checksum for $hostKey; refusing to download unverified bytes" }
|
||||
Write-Step "Node source: $hostKey v$nodeVersion"
|
||||
|
||||
$script:NodeEngines = [string]$manifest.pins.nodeEngines
|
||||
if (-not $script:NodeEngines) { throw "the manifest pins no nodeEngines, so Node compatibility cannot be judged" }
|
||||
|
||||
# REA's engines come from the manifest pin, which the rest of provisioning already
|
||||
# treats as the single source of truth. This used to be a second copy of the same
|
||||
# numbers in a comment, and two copies drift: the audit now asserts that the pin
|
||||
# keeps the shape this parser understands.
|
||||
function Compare-SchemaVersion([int[]]$Left, [int[]]$Right) {
|
||||
# Negative when $Left is older, 0 when equal, positive when newer. All three
|
||||
# components are compared: `^24.11.1` must reject 24.11.0, and comparing only
|
||||
# major/minor accepted it — the same engines range then meant two different
|
||||
# things here and in setup.mjs's regex probe.
|
||||
for ($i = 0; $i -lt 3; $i++) {
|
||||
if ($Left[$i] -ne $Right[$i]) { return $(if ($Left[$i] -gt $Right[$i]) { 1 } else { -1 }) }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
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 }
|
||||
# A two-component version (`v24.11`) still has a patch of 0 for comparison.
|
||||
while ($versions.Count -lt 3) { $versions += 0 }
|
||||
foreach ($clause in ($Range -split '\|\|')) {
|
||||
$c = $clause.Trim()
|
||||
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
|
||||
$want = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
|
||||
if ($versions[0] -eq $want[0] -and (Compare-SchemaVersion $versions $want) -ge 0) { return $true }
|
||||
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
|
||||
$want = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
|
||||
if ((Compare-SchemaVersion $versions $want) -ge 0) { 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
|
||||
+2998
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Generated
+2194
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "rea-bundle-vendor",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Install root for the vendored REA MCP server closure. scripts/setup.mjs copies this pair into <root>/vendor/.npm-staging and runs `npm ci --ignore-scripts`, so provisioning resolves exactly the versions in package-lock.json. The `overrides` block is the audit fix: rea-agents pins zod exactly (\"4.4.3\"), which is inside GHSA-mrhx-c7xc-m3w5 (<4.6.6), and only an override can move an exact pin without editing the published tarball.",
|
||||
"dependencies": {
|
||||
"rea-agents": "6.8.0"
|
||||
},
|
||||
"overrides": {
|
||||
"zod": "4.6.7",
|
||||
"minimatch": "11.0.5",
|
||||
"glob": "14.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
# 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','bin\rea-guide.mjs','client.js','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs','scripts\check-updates.mjs')) {
|
||||
$out = & node --check $f 2>&1
|
||||
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
|
||||
}
|
||||
|
||||
Write-Host "`n=== 1b. both JSON readers tolerate a BOM ===" -ForegroundColor Cyan
|
||||
# bin/rea-shim.mjs and scripts/setup.mjs read the same mirror.json and bundle.json.
|
||||
# A BOM strip on one side only makes one file mean "there is a checkout" to the
|
||||
# shim and "there is none" to setup, and the mirrored setup then re-mirrors the
|
||||
# previous manifest. A bare JSON.parse(readFileSync(...)) is that divergence.
|
||||
$jsBom = Join-Path $env:TEMP 'rea-verify-bom.cjs'
|
||||
@'
|
||||
const fs = require("node:fs");
|
||||
const blind = [];
|
||||
for (const file of ["bin/rea-shim.mjs", "scripts/setup.mjs"]) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
for (const m of text.matchAll(/JSON\.parse\(\s*readFileSync\(/g)) {
|
||||
// readJson() itself is the strip; anything else must not parse a file raw.
|
||||
if (!/replace\(/.test(text.slice(m.index, m.index + 200))) {
|
||||
blind.push(file + "@" + text.slice(0, m.index).split("\n").length);
|
||||
}
|
||||
}
|
||||
if (!text.includes("replace(/^\\uFEFF/")) blind.push(file + ": no U+FEFF strip");
|
||||
}
|
||||
console.log("bom-blind reads: " + JSON.stringify(blind));
|
||||
if (blind.length) process.exit(1);
|
||||
'@ | Set-Content $jsBom -Encoding utf8
|
||||
& node $jsBom 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
Check 'no BOM-blind JSON read' ($LASTEXITCODE -eq 0) 'see above'
|
||||
|
||||
Write-Host "`n=== 2. PowerShell parse + BOM ===" -ForegroundColor Cyan
|
||||
# This script checks itself too: it is edited as often as the others, and an editor
|
||||
# that drops the BOM would otherwise go unnoticed until someone adds CJK text here.
|
||||
foreach ($f in @('check-updates.ps1','assets\make-screenshots.ps1','scripts\rea-bundle-bootstrap.ps1','scripts\verify-branch.ps1')) {
|
||||
$p = Join-Path $root $f
|
||||
$bytes = [System.IO.File]::ReadAllBytes($p)
|
||||
$bom = ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($p, [ref]$null, [ref]$errs) | Out-Null
|
||||
$count = @($errs).Count
|
||||
Check "$f parses" ($count -eq 0) "$count error(s): $(($errs | Select-Object -First 1).Message)"
|
||||
if ($f -ne 'scripts\rea-bundle-bootstrap.ps1') {
|
||||
# These two carry CJK text, which PowerShell 5.1 misreads without a BOM.
|
||||
Check "$f has UTF-8 BOM" $bom "first bytes: $(($bytes[0..2] | ForEach-Object { $_.ToString('x2') }) -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n=== 3. manifest audit ===" -ForegroundColor Cyan
|
||||
$audit = & node scripts\audit-manifest.mjs 2>&1
|
||||
$audit | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
Check 'audit clean' ($LASTEXITCODE -eq 0) 'see findings above'
|
||||
|
||||
Write-Host "`n=== 4. unit tests ===" -ForegroundColor Cyan
|
||||
$tests = & node --test scripts\setup.test.mjs 2>&1
|
||||
$testExit = $LASTEXITCODE
|
||||
$failing = @($tests | Select-String -Pattern 'not ok' ).Count
|
||||
Check 'node --test' ($testExit -eq 0) "$failing failing"
|
||||
|
||||
Write-Host "`n=== 5. the published patch is machine-path-free and escape-safe ===" -ForegroundColor Cyan
|
||||
$patch = Get-Content (Join-Path $root 'cordis.patch.yml') -Raw
|
||||
Check 'no drive letter in patch' (-not ($patch -match '[A-Za-z]:[\\/]')) 'a machine path leaked into the published patch'
|
||||
Check 'no backslash escape in JS literals' (-not ($patch -replace '(?m)^\s*#.*$','' -match '\\[prbnt]')) 'use forward slashes'
|
||||
Check 'references rea-shim.mjs' ($patch -match 'rea-shim\.mjs') ''
|
||||
Check 'has a DSH_HOME fallback' (($patch -match 'DSH_HOME') -and ($patch -match 'USERPROFILE|HOME')) ''
|
||||
$jsProbe = Join-Path $env:TEMP 'rea-verify-literals.cjs'
|
||||
@'
|
||||
const fs = require("node:fs");
|
||||
const text = fs.readFileSync("cordis.patch.yml", "utf8").replace(/^\s*#.*$/gm, "");
|
||||
const control = /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/;
|
||||
const literals = [...text.matchAll(/'((?:[^'\\]|\\.)*)'/g)].map((m) => {
|
||||
try { return new Function("return '" + m[1] + "'")(); } catch { return m[1]; }
|
||||
});
|
||||
const bad = literals.filter((v) => control.test(v));
|
||||
console.log("literals: " + JSON.stringify(literals));
|
||||
if (bad.length) {
|
||||
console.error("control characters in: " + bad.map((v) => JSON.stringify(v)).join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
'@ | Set-Content $jsProbe -Encoding utf8
|
||||
& node $jsProbe 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
|
||||
Check 'evaluated literals contain no control characters' ($LASTEXITCODE -eq 0) 'see above'
|
||||
|
||||
Write-Host "`n=== 5b. the pin comparison is not a major-prefix match ===" -ForegroundColor Cyan
|
||||
# 2.0.10 shipped a false green here: "6.5.0".StartsWith("6.") is true, so a bundle
|
||||
# still on 6.5.0 reported "consistent with pin 6.7.0" — and that line is the only
|
||||
# signal a user has that a pin bump requires re-running setup. The rule lives in
|
||||
# scripts/pin-match.ps1 so it can be checked without the registry/profile sections.
|
||||
$pinMatchFile = Join-Path $root 'scripts\pin-match.ps1'
|
||||
Check 'scripts/pin-match.ps1 exists' (Test-Path $pinMatchFile) ''
|
||||
Check 'check-updates.ps1 uses Test-PinMatch' ((Get-Content (Join-Path $root 'check-updates.ps1') -Raw -Encoding UTF8) -match 'Test-PinMatch') ''
|
||||
if (Test-Path $pinMatchFile) {
|
||||
. $pinMatchFile
|
||||
$pinCases = @(
|
||||
@('6.5.0', '6.7.0', $false, 'older REA under a newer pin must not be a match'),
|
||||
@('6.7.0', '6.7.0', $true, 'exact match'),
|
||||
@('3.0.9', '3.0.3', $false, 'apktool above the pin must not be a match'),
|
||||
@('21.0.12.1+1', '21', $true, 'major-only jdk pin names the family'),
|
||||
@('21', '21', $true, 'exact major-only pin'),
|
||||
@('24.11.1', '24.21.0', $false, 'a dotted node pin is one version'),
|
||||
@('', '6.7.0', $false, 'no recorded version is never a match')
|
||||
)
|
||||
foreach ($c in $pinCases) {
|
||||
$got = Test-PinMatch -Installed $c[0] -Pinned $c[1]
|
||||
Check ("pin '{0}' vs '{1}' -> {2}" -f $c[0], $c[1], $c[2]) ($got -eq $c[2]) ("got ${got}: {0}" -f $c[3])
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n=== 6. package payload lists what the bundle needs ===" -ForegroundColor Cyan
|
||||
# -Encoding UTF8 is required: the package description contains CJK text, and
|
||||
# PowerShell 5.1 reads a BOM-less file as ANSI, which breaks ConvertFrom-Json.
|
||||
$pkg = Get-Content (Join-Path $root 'package.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
||||
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
|
||||
}
|
||||
# The market's update path asks whether package.json's main/exports points at a
|
||||
# file that exists, and rolls the update back when it does not (dshmarket's
|
||||
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
|
||||
# a healthy profile failed with "updated build has no loadable entry". Asserting
|
||||
# the pointer resolves keeps that from returning silently.
|
||||
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
|
||||
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
|
||||
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
|
||||
# plugin from hosts whose version does not match. An absent or unparseable
|
||||
# declaration therefore reads as "nothing to say" rather than as a defect, which
|
||||
# is why the coverage check runs here as well as in CI.
|
||||
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
|
||||
$compat = & node scripts\host-compat.mjs 2>&1
|
||||
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
|
||||
# Both READMEs quote the range verbatim, and a stale quote is invisible: JSON,
|
||||
# host-compat and the audit all agree while the sentence a human reads names a
|
||||
# different ceiling. That is exactly how `<0.3.0` survived in the docs after the
|
||||
# declaration moved to `<0.3.0-0`, so the comparison lives here now.
|
||||
foreach ($doc in @('README.md', 'README.zh-CN.md')) {
|
||||
$text = Get-Content (Join-Path $root $doc) -Raw -Encoding UTF8
|
||||
Check "$doc quotes the declared engines.dsh" $text.Contains([string]$pkg.engines.dsh) "expected $($pkg.engines.dsh)"
|
||||
# The pin example is copy-pasteable, so it must name a ref that will exist: the
|
||||
# version this tree publishes. It has drifted twice (v2.0.0 long after 2.0.5,
|
||||
# then v2.0.8 while the tree was 2.0.9), and both times every other signal was
|
||||
# green because nothing compared the two.
|
||||
Check "$doc pins the current version" $text.Contains("dsh-plugin-rea#v$($pkg.version)") "expected dsh-plugin-rea#v$($pkg.version)"
|
||||
}
|
||||
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
|
||||
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
|
||||
}
|
||||
|
||||
Write-Host "`n=== 7. dry-run still plans the whole chain ===" -ForegroundColor Cyan
|
||||
$env:REA_BUNDLE_ROOT = Join-Path $env:TEMP 'rea-verify-dryrun'
|
||||
# Deliberately WITHOUT --reuse. Adoption of whatever this machine happens to have
|
||||
# installed used to hide a defect that only a bare machine sees: every planned
|
||||
# component returned `{ planned: true }` with no path, so the dependents of each
|
||||
# plan entry were reported as "required component is unavailable" and the command
|
||||
# exited 1 — for the exact reader the README sends to `--dry-run`.
|
||||
$dry = & node scripts\setup.mjs --root $env:REA_BUNDLE_ROOT --dry-run 2>&1
|
||||
$planned = @($dry | Select-String -Pattern '^\[plan\]').Count
|
||||
$unmet = @($dry | Select-String -Pattern 'is unavailable').Count
|
||||
Check 'dry-run exits 0 without --reuse' ($LASTEXITCODE -eq 0) (($dry | Select-Object -Last 3) -join ' ')
|
||||
Check 'dry-run plans every component' ($planned -ge 4) "planned=$planned"
|
||||
Check 'dry-run reports no unavailable dependency' ($unmet -eq 0) "unavailable=$unmet"
|
||||
Remove-Item Env:\REA_BUNDLE_ROOT -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "`n=== 8. bootstrap recognises an unpacked portable Node (no download) ===" -ForegroundColor Cyan
|
||||
# A machine with no Node is this script's entire audience, so the portable path is
|
||||
# only ever exercised where it is least observable: it must therefore be tested
|
||||
# here, with an empty PATH and a stack laid out exactly as the official zip lays it
|
||||
# out. The folder name is derived from the manifest by a SINGLE extension strip
|
||||
# (node-v24.21.0-win-x64.zip -> node-v24.21.0-win-x64); a double strip used to make
|
||||
# the bootstrap throw "the Node zip did not contain node-v24.21" after a successful
|
||||
# download, checksum and extraction. -DryRun keeps this check download-free.
|
||||
$bootRoot = Join-Path $env:TEMP 'rea-verify-bootstrap'
|
||||
# -Encoding UTF8: the manifest carries non-ASCII text and PowerShell 5.1 would
|
||||
# otherwise read a BOM-less file as ANSI and fail ConvertFrom-Json.
|
||||
$manifest = Get-Content (Join-Path $root 'manifest\bundle.manifest.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$bootLayoutName = [System.IO.Path]::GetFileNameWithoutExtension(
|
||||
[System.IO.Path]::GetFileName(([uri]$manifest.components.node.sources.'win32-x64'.url).AbsolutePath))
|
||||
$bootLayout = Join-Path (Join-Path $bootRoot 'stack') $bootLayoutName
|
||||
New-Item -ItemType Directory -Path $bootLayout -Force | Out-Null
|
||||
$hostNode = (Get-Command node -ErrorAction SilentlyContinue).Source
|
||||
if ($hostNode) {
|
||||
$target = Join-Path $bootLayout 'node.exe'
|
||||
if (Test-Path $target) { Remove-Item $target -Force }
|
||||
try { New-Item -ItemType HardLink -Path $target -Target $hostNode -ErrorAction Stop | Out-Null }
|
||||
catch { Copy-Item $hostNode $target -Force }
|
||||
}
|
||||
$bootOut = & powershell -NoProfile -Command "`$env:PATH='C:\Windows\System32;C:\Windows'; & '$root\scripts\rea-bundle-bootstrap.ps1' -Root '$bootRoot' -Reuse -DryRun" 2>&1
|
||||
Check 'bootstrap finds the portable Node in the official layout' (@($bootOut | Select-String 'portable Node present').Count -ge 1) (($bootOut | Select-Object -Last 2) -join ' ')
|
||||
Check 'bootstrap plans no download when one is present' (@($bootOut | Select-String 'plan: download').Count -eq 0) (@($bootOut | Select-String 'plan:').Line -join ' | ')
|
||||
for ($i = 0; $i -lt 5; $i++) {
|
||||
try { Remove-Item -Recurse -Force $bootRoot -ErrorAction Stop; break } catch { Start-Sleep -Milliseconds 300 }
|
||||
}
|
||||
|
||||
Write-Host "`n=== 9. the one-shot setup guide is wired end to end ===" -ForegroundColor Cyan
|
||||
# The guide spans four files with no type system between them: the route in
|
||||
# bin/rea-guide.mjs, the mount in index.mjs, the fetch in client.js, and the row
|
||||
# in cordis.patch.yml. Two of those used to be free-form strings, so a rename in
|
||||
# one place produced a guide that silently never appeared (the client treats a
|
||||
# 404 as "this host has no route"). Compare them here instead.
|
||||
$clientText = if (Test-Path (Join-Path $root 'client.js')) { Get-Content (Join-Path $root 'client.js') -Raw -Encoding UTF8 } else { '' }
|
||||
Check 'client.js ships with the package' ($clientText -ne '') ''
|
||||
$routeMatch = Select-String -Path (Join-Path $root 'bin\rea-guide.mjs') -Pattern "export const GUIDE_ROUTE = '([^']+)'" | Select-Object -First 1
|
||||
$guideRoute = if ($routeMatch) { $routeMatch.Matches[0].Groups[1].Value } else { '' }
|
||||
Check 'bin/rea-guide.mjs declares the guide route' ($guideRoute -eq '/dsh-plugin-rea/guide-state') "got '$guideRoute'"
|
||||
Check 'client.js loads through the client module loader' ($clientText.Contains('window.__ModuleLoader__.load')) ''
|
||||
Check 'client.js registers the guide on shell.overlay' ($clientText.Contains('rea-setup-guide') -and $clientText.Contains('shell.overlay')) ''
|
||||
Check 'client.js reads the same route the host serves' ($clientText.Contains($guideRoute)) "expected '$guideRoute'"
|
||||
$indexText = Get-Content (Join-Path $root 'index.mjs') -Raw -Encoding UTF8
|
||||
Check 'index.mjs serves the guide route' ($indexText.Contains('GUIDE_ROUTE') -and $indexText.Contains('mountGuideRoute')) ''
|
||||
Check 'index.mjs mounts it only when a webServer exists' ($indexText.Contains("ctx.inject(['webServer']")) ''
|
||||
Check 'package.json declares dsh.client for the web surface' ($pkg.dsh.client.platform -eq 'web') ''
|
||||
Check 'package.json ships client.js' (@($pkg.files) -contains 'client.js') ''
|
||||
$patchText = Get-Content (Join-Path $root 'cordis.patch.yml') -Raw -Encoding UTF8
|
||||
Check 'cordis.patch.yml mounts the guide row' ($patchText.Contains('rea-guide') -and $patchText.Contains("name: 'dsh-plugin-rea'")) ''
|
||||
$guideRaw = & node bin\rea-guide.mjs --json 2>&1
|
||||
$guideParsed = $null
|
||||
try { $guideParsed = ($guideRaw -join "`n") | ConvertFrom-Json } catch { $guideParsed = $null }
|
||||
Check 'bin/rea-guide.mjs answers with JSON' ($null -ne $guideParsed -and $null -ne $guideParsed.needsSetup) (($guideRaw | Select-Object -Last 2) -join ' ')
|
||||
Check 'the guide command points at this package scripts' ($null -ne $guideParsed -and [string]$guideParsed.setupCommand -match 'scripts[\\/]setup\.mjs') ''
|
||||
|
||||
Write-Host "`n=== 10. npm run check-updates is portable ===" -ForegroundColor Cyan
|
||||
# check-updates.ps1 is Windows-only on purpose (it reads %APPDATA% and prefers a
|
||||
# rea.cmd shim over the rea.ps1 one). The npm script used to BE the bare
|
||||
# `powershell -File` command, so on macOS/Linux `npm run check-updates` was an
|
||||
# exec error, and a pwsh there would have answered from paths that do not exist.
|
||||
# The wrapper is the contract: Windows forwards, everything else explains and
|
||||
# exits non-zero.
|
||||
$wrapper = Join-Path $root 'scripts\check-updates.mjs'
|
||||
Check 'scripts/check-updates.mjs exists' (Test-Path $wrapper) ''
|
||||
$checkScript = [string]$pkg.scripts.'check-updates'
|
||||
Check 'scripts.check-updates goes through the wrapper' ($checkScript -match 'check-updates\.mjs') $checkScript
|
||||
$wrapperText = if (Test-Path $wrapper) { Get-Content $wrapper -Raw -Encoding UTF8 } else { '' }
|
||||
Check 'the wrapper runs PowerShell on Windows only' ($wrapperText.Contains("platform === 'win32' ? ['powershell', 'pwsh'] : []")) ''
|
||||
Check 'the wrapper names both portable fallbacks' ($wrapperText.Contains('--shim-status') -and $wrapperText.Contains('--dry-run')) ''
|
||||
|
||||
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