mirror of
https://github.com/czcdr/dsh-plugin-rea.git
synced 2026-11-07 10:25:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6ee476bfd | ||
|
|
d0e2ffd7a6 | ||
|
|
27d3f1106c | ||
|
|
f6afdea83d | ||
|
|
975ac5aac8 |
No files matched your search
@@ -78,5 +78,24 @@ jobs:
|
||||
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
|
||||
@@ -25,13 +25,27 @@ jobs:
|
||||
const fail = (m) => { console.error('FAIL: ' + m); process.exit(1) };
|
||||
if (!p.dsh || !p.dsh.bundle || !p.dsh.bundle.patch) fail('package.json is missing dsh.bundle.patch');
|
||||
if (p.private) fail('package.json must not be private to be publishable');
|
||||
for (const f of ['cordis.patch.yml', 'README.md', 'LICENSE', 'check-updates.ps1']) {
|
||||
for (const f of ['cordis.patch.yml', 'index.mjs', 'README.md', 'LICENSE', 'check-updates.ps1']) {
|
||||
if (!p.files.includes(f)) fail('files[] must ship ' + f);
|
||||
}
|
||||
if (!require('fs').existsSync(p.dsh.bundle.patch)) fail('dsh.bundle.patch target does not exist');
|
||||
// The market's update path rolls a build back when package.json points
|
||||
// at no existing entry (dshmarket's hasLoadableEntry); this package
|
||||
// shipped no main at all until 2.0.5, so every update of a healthy
|
||||
// profile failed with "updated build has no loadable entry".
|
||||
if (typeof p.main !== 'string') fail('package.json must declare main');
|
||||
if (!require('fs').existsSync(p.main)) fail('main points at no shipped file: ' + p.main);
|
||||
// engines.dsh is what the market reads to say "compatible" instead of
|
||||
// "unknown"; without it the plugin is reported as undeclared.
|
||||
if (!p.engines || typeof p.engines.dsh !== 'string') fail('package.json must declare engines.dsh');
|
||||
console.log('manifest ok');
|
||||
"
|
||||
|
||||
- name: Declared DSH range covers every released host
|
||||
# A wrong range is silent on both sides: unparseable reads as "undeclared"
|
||||
# and a drifted floor drops hosts the README still lists.
|
||||
run: node scripts/host-compat.mjs
|
||||
|
||||
- name: JSON files parse
|
||||
run: |
|
||||
node -e "
|
||||
@@ -44,16 +58,15 @@ jobs:
|
||||
- name: The npm payload ships every market-facing file
|
||||
run: |
|
||||
# Regression guard for files[]: the market reads screenshots.json and the
|
||||
# images next to package.json, and a user copies examples/ by hand — none of
|
||||
# that is worth anything if npm pack drops it.
|
||||
# images next to package.json — none of that is worth anything if npm pack
|
||||
# drops it.
|
||||
node -e "
|
||||
const { execSync } = require('child_process');
|
||||
const meta = JSON.parse(execSync('npm pack --dry-run --json', { encoding: 'utf8' }))[0];
|
||||
const files = meta.files.map((f) => f.path);
|
||||
const need = [
|
||||
'cordis.patch.yml', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
|
||||
'cordis.patch.yml', 'index.mjs', 'README.md', 'README.zh-CN.md', 'LICENSE', 'CHANGELOG.md', 'check-updates.ps1',
|
||||
'screenshots.json', 'assets/screenshot-1.png', 'assets/screenshot-2.png',
|
||||
'examples/cordis.patch.posix.yml', 'examples/cordis.patch.with-env.yml',
|
||||
];
|
||||
const missing = need.filter((f) => !files.includes(f));
|
||||
if (missing.length) {
|
||||
@@ -77,7 +90,7 @@ jobs:
|
||||
'tag:yaml.org,2002:js',
|
||||
lambda loader, node: loader.construct_scalar(node),
|
||||
)
|
||||
files = ['cordis.patch.yml', 'docs/marketplace-entry.yml'] + sorted(glob.glob('examples/*.yml')) + sorted(glob.glob('.github/workflows/*.yml'))
|
||||
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)
|
||||
@@ -87,10 +100,9 @@ jobs:
|
||||
- name: The shipped patches stay machine-independent
|
||||
run: |
|
||||
set -e
|
||||
# The row DSH loads, plus every example a reader is told to copy: none of
|
||||
# them may carry a real machine path. `<placeholders in angle brackets>`
|
||||
# are the intended form.
|
||||
targets=$(printf '%s\n' cordis.patch.yml examples/*.yml)
|
||||
# The row DSH loads may carry no real machine path; `<placeholders in angle
|
||||
# brackets>` are the intended form.
|
||||
targets=$(printf '%s\n' cordis.patch.yml)
|
||||
if grep -nE '[A-Za-z]:\\' $targets; then
|
||||
echo "FAIL: a shipped patch contains an absolute Windows path"
|
||||
exit 1
|
||||
@@ -163,3 +175,23 @@ jobs:
|
||||
# Checksum coverage, unresolved tokens, duplicate JSON keys, and the
|
||||
# patch-wiring agreement between the row, the shim and setup.mjs.
|
||||
run: npm run bundle:audit
|
||||
|
||||
windows:
|
||||
# The PowerShell download route, the tar/Expand-Archive extraction path and
|
||||
# scripts/rea-bundle-bootstrap.ps1 are Windows-only code, and an ubuntu-only
|
||||
# matrix never executed any of them — they were verified by hand, if at all.
|
||||
# The tests are platform-sensitive on purpose: each platform exercises the
|
||||
# download route it actually chooses.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Unit tests for the pure logic (Windows)
|
||||
run: npm run bundle:test
|
||||
|
||||
- name: Bundle manifest audit (Windows)
|
||||
run: npm run bundle:audit
|
||||
+188
@@ -2,6 +2,194 @@
|
||||
|
||||
本插件遵循语义化版本。版本号变化说明「配置组合」这层的变化,与 REA 自身的版本无关。
|
||||
|
||||
## 2.0.6 — 2026-10-18
|
||||
|
||||
**声明宿主版本:`engines.dsh` 覆盖 `0.1.7-rc.2` … `0.2.1-rc.2` 全部发布版本,市场卡片因此显示「兼容」而不是「未声明」。**
|
||||
|
||||
### 兼容声明
|
||||
|
||||
- **`engines.dsh: ">=0.1.7-rc.2 <0.3.0"`**:市场侧的 `deriveHostCompatibility` 只认两类声明 —— `engines.dsh`
|
||||
与指向 `@deepseek-ai/dsh*` 的 `peerDependencies`;两者都没有就是 `unknown`(卡片「兼容性未知」)。
|
||||
这里只声明前者,刻意不声明 peer:`@deepseek-ai/dsh-mcp-client` 在 npm 上只有 `0.0.1-rc.1`,
|
||||
而宿主随包内置的是 0.2.x,声明 peer 会让 pnpm 解析出一个与宿主不一致的版本,反而制造假冲突。
|
||||
- **范围来自核对,不是估计**:`scripts/host-compat.mjs` 内置 dsh 发布列表(0.1.7-rc.2 至 0.2.1-rc.2 共 10 个),
|
||||
逐个验证是否被声明覆盖,并反向确认 `0.3.0` 与 `0.1.7-rc.1`、`0.1.7-alpha.2` 落在范围之外。
|
||||
- **跨版本比对过的东西**:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
||||
配置面与发布的行一致(`serverName`/`transport`/`command`/`args`/`env`/`failOnStartupError` 均存在且同名),
|
||||
且两个 tag 都有 `dsh.bundle.patch`。范围声明的是「核对过的宿主」,不是「应该能用」。
|
||||
|
||||
### 上游(只更新仓库,暂不带入)
|
||||
|
||||
- 上游已发布 **rea-agents 6.6.0**(GitHub release `rea-agents-6.6.0`,2026-10-18);npm `latest` 仍是 **6.5.0**,
|
||||
所以本包的钉(`manifest/bundle.manifest.json` 的 `pins.reaAgents` 与 `components.reaAgents.sources.npm.version`)
|
||||
**保持 6.5.0 不动**。已核 6.6.0 的 `engines.node`(`^22.19.0 || ^24.11.0 || >=26.0.0`)与 `bin`
|
||||
(`scripts/rea.mjs`)均未变化,`optionalDependencies.frida` 为 17.23.2。
|
||||
- 计划:**2026-10-19** 随下一版把钉升到 6.6.0 并复跑真机;届时工具数、探针正则与文档一并更新。
|
||||
|
||||
### 修复 / 工程
|
||||
|
||||
- 新增 `scripts/host-compat.mjs`(纯函数 + CLI):semver 预发布排序、`>=floor <ceiling` 判定、发布列表覆盖检查。
|
||||
- `bundle:host-compat` npm script;`verify-branch.ps1` 新增 `engines.dsh` 存在性与覆盖率两项;
|
||||
`validate.yml` 新增「Declared DSH range covers every released host」步骤,清单检查也要求 `engines.dsh`。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 **46 条**(新增 4 条:声明可解析、10 个发布版本全覆盖且 3 个未核对版本被排除、反向对照能抓出
|
||||
`>=0.2.0` 这种看似合理却漏掉整个 0.2.0-rc 线的写法、预发布排序符合 semver 而非字符串序):Windows 43 过 + 3 平台跳过。
|
||||
- `node scripts/host-compat.mjs`:10 个发布版本 covered、3 个 correctly outside。
|
||||
- `verify-branch.ps1` 全绿;`audit-manifest.mjs` clean(两份 README 结构仍平行)。
|
||||
|
||||
## 2.0.5 — 2026-10-18
|
||||
|
||||
**市场更新失败已定位并修复:清单里没有 `main`,市场因此在每次更新时判「新构建没有可加载入口」并回滚。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **应用内市场更新到 2.0.4 失败**:`dshmarket` 的更新路径用 `hasLoadableEntry` 判断新构建能不能加载
|
||||
(实现是 `entryArtifactExists`:读 `main`/`exports`,两者都没有就回落 `index.js`)。本包是纯 bundle
|
||||
(只有 `dsh.bundle.patch`),从未声明 `main`;而它挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
||||
构建、不在 `node_modules` 里,于是该检查要看的 profile / 包内 / workspace 三个位置全空。结果:2.0.1 → 2.0.4
|
||||
的更新连续三次报「更新后缺少入口文件……且未能验证恢复原版本文件」,而回滚其实成功了 —— 旧版同样没有入口,
|
||||
所以回滚后的复检依然为 false。现已声明 `main: ./index.mjs`,并让该文件进入 `files[]`。
|
||||
- **`index.mjs` 是真空插件,不是占位**:导出 `name` 与空 `apply()`,不注册行、不起进程、不写状态;真正起作用的
|
||||
行仍由 `cordis.patch.yml` 插入。若 harness 某天真把它当插件加载,行为仍是「什么都不做」,因此不会造出第二条
|
||||
`serverName: rea`。
|
||||
|
||||
### 文档
|
||||
|
||||
- 两份 README 新增「在市场里更新 / Updating from the market」小节:说明该报错来自市场侧的入口判定、与 tarball
|
||||
完整性无关、profile 未受损,以及 2.0.5 起的做法。
|
||||
|
||||
### 验证
|
||||
|
||||
- 用 `dshmarket` 自身的 `hasLoadableEntry` 实测:把本包按 profile 布局模拟安装,修复前 `false`、修复后 `true`;
|
||||
对仓库目录 `entryArtifactExists` 修复后为 `true`。
|
||||
- 单测 42 条(新增 2 条:清单声明的入口必须存在且被 `files[]` 收录;入口可 `import`、导出的 `name` 与包名一致、
|
||||
`apply()` 返回 `undefined`):Windows 39 过 + 3 平台跳过。
|
||||
- `verify-branch.ps1` 新增 `main` 存在性与 `files[]` 收录检查;`validate.yml` 的清单检查与 npm 载荷检查加了同一条守卫。
|
||||
- `npm pack --dry-run`:20 个文件,含 `index.mjs`。
|
||||
|
||||
## 2.0.4 — 2026-10-17
|
||||
|
||||
**两处已核实的缺陷:只读 bundle 目录下 shim 启动即崩;与上游 `rea setup --client dsh` 的同名注册冲突现在会被明确提示。**
|
||||
|
||||
### 修复
|
||||
|
||||
- **只读 bundle 根目录 + 陈旧 lock → shim 未捕获 EACCES 崩溃**:2.0.3 把删锁移出了 `if (fresh)`,却没包
|
||||
try/catch。目录只读(`chmod 555`、系统级安装目录)且锁已陈旧时,`rmSync(lock)` 抛 `EACCES`,shim 以
|
||||
未捕获异常退出(exit 1 + 堆栈)—— 比原来的静默更吵,但同样起不来。沙箱已复现(WSL `chmod 555`)。
|
||||
现在删锁失败返回 `unwritable`,走既有的「无法写入,请手动跑 setup」路径(exit 78,行不崩)。
|
||||
- **与上游 `rea setup --client dsh` 的同名注册冲突**:该命令往 `<DSH_HOME>/cordis.patch.yml` 写入
|
||||
`id: rea-mcp`、`serverName: rea`,而本包是 `id: mcp-rea`、同一个 `serverName`;DSH 对同名条目只保留
|
||||
一个(后加载者报 duplicate server name)。已按上游 6.5.0 实测生成该文件确认。shim 现在启动时检测同
|
||||
文件的 `serverName: rea`(id 不是 `mcp-rea`)并向 stderr 提示「二选一」,`--shim-status` 也会在
|
||||
`duplicateRegistration` 下报告。
|
||||
|
||||
### 文档
|
||||
|
||||
- 两份 README 新增「只保留一条 REA 注册」小节:与 `rea setup --client dsh` 二选一,并说明两条路线的
|
||||
取舍(零依赖供给 vs 上游官方纯配置注册)。
|
||||
- v1 分支 README 顶部加横幅:指向 2.x(`main`,默认推荐)与上游官方注册。
|
||||
|
||||
### 验证
|
||||
|
||||
- 单测 40 条:Windows 37 过 + 3 平台跳过;WSL 39 过 + 1 跳过(本轮新增 3 条,其中只读目录一条仅 POSIX)。
|
||||
- 反向验证:回退删锁的 try/catch → 只读目录测试在 WSL 必红(EACCES/exit 1);回退冲突检测 → 重复注册
|
||||
测试必红。
|
||||
- `verify-branch.ps1` 全绿。
|
||||
|
||||
## 2.0.3 — 2026-10-16
|
||||
|
||||
**修复 11 处已核实的缺陷(每条都先确认存在、并配了"回退即变红"的回归测试);上游 REA 钉子从 6.4.0 升到 6.5.0。**
|
||||
|
||||
### 上游
|
||||
|
||||
- `pins.reaAgents` 与 `components.reaAgents.sources.npm.version` → **6.5.0**:engines 与 bin 入口未变
|
||||
(`^22.19.0 || ^24.11.0 || >=26.0.0`、`scripts/rea.mjs`),工具数 170 → **207**(运行时 `rea doctor --json` 实测;上游源码静态清点为 206,多出的 1 个注册在 contracts 之外。新增:Frida 9、
|
||||
GDB 4、Rizin/Cutter 6、pcap/packet 4、flutter 2、sqlite/rust/go/wasm/electron/harmony/hopper 各 1 等)。
|
||||
新增的 `optionalDependencies.frida` 在 `--ignore-scripts` 下不会执行安装脚本。REA 探针正则收紧为
|
||||
`^6\.5\.0(\s|$)`,审计新增"下一个 patch/minor 不得通过"的断言。
|
||||
- Ghidra 12.1.4 / apktool 3.0.3 / jadx-headless-mcp 0.7.1 **经核实仍是当前最新**;Node 24.22.0 虽已发布,
|
||||
但晚于本插件的实测矩阵,故保持 24.21.0(engines 完全满足)。
|
||||
|
||||
### 修复
|
||||
|
||||
- **`--only` 抹掉其它组件的记录**:bundle.json 由本轮结果整份重写,未点名组件的 `paths.jdk/ghidra` 变
|
||||
`null`,shim 不再导出 `JAVA_HOME`/`GHIDRA_INSTALL_DIR`——原生分析静默失效,下次完整跑还得重新解压
|
||||
750 MB。未点名的组件现在按旧记录结转(且只在记录的路径仍然存在时),缺依赖时优先用旧记录/机器/运行
|
||||
setup 的解释器来满足,而不是整段跳过检查。
|
||||
- **完整的 `.part` 不被当作已完成**:只打一行日志,然后发 `Range: bytes=<全长>-`(416;四次重试后把
|
||||
已经下好的文件报成失败)。现在校验通过即改名收下。
|
||||
- **Windows 下载主路线没有续传**:PowerShell 路线每轮先删 `.part`;失败后 Node 兜底仍用进本轮前的
|
||||
`have`,以 append 把 200 全量接在半成品后面(长度必然超标,只能重下)。两条路线现在都按盘上实际
|
||||
长度发 Range 续传。
|
||||
- **POSIX 的 apktool wrapper 没有执行位**(copyFileSync 落 0644,而 REA 不加 shell 直接 spawn → EACCES):
|
||||
manifest 里标了 `executable` 的条目落盘时 chmod 0755。
|
||||
- **apktool wrapper 钉在 `master` 分支 + 硬校验 sha256**:上游对 wrapper 的任何一次提交都会让全网新装
|
||||
hash mismatch。改钉不可变的 `v3.0.3` tag,哈希按该 tag 重新实测。
|
||||
- **`--reuse` 不校验版本**:JDK 只看 `javac` 存在、Ghidra 只看 `analyzeHeadless` 存在(JDK 8/11、Ghidra 11
|
||||
照收)。现在候选必须通过该组件自己的 probe;同时修好"文件型 probe 从不检查 `expect`"(Ghidra 的
|
||||
`application.version=` 断言此前是死配置)。
|
||||
- **`bundle.json` 损坏即永久失败**:setup 读不了它、也没人启动 setup,用户只能手删文件。现在 setup 容忍
|
||||
它(并按需重写),shim 对 `malformed` 与 `missing` 一样触发供给。
|
||||
- **失败后 shim 谎报 6 小时**:lock 现在记录状态与 pid;失败态如实报告"上一次失败了",持有者已消失的
|
||||
lock 不再阻塞重试。
|
||||
- **成功的 `--json` 轮次从不删 lock**:删除语句原先在非 `--json` 分支里,--json 成功也留下 lock。
|
||||
- **shim 兜底 REA 路径少了 `node_modules`**:旧 bundle.json 缺 `paths.reaEntry` 时会把可用安装判成"CLI 丢了"。
|
||||
- **`scripts/rea-bundle-bootstrap.ps1`**:`node -v` 输出非数字时 `[int]` 强转在 `$ErrorActionPreference='Stop'`
|
||||
下会炸掉整个 bootstrap(而它存在的意义就是这条路径);`&` 调用失败时 `$LASTEXITCODE` 可能为 `$null` →
|
||||
`exit $null` = 假成功;engines 改从 manifest 的 `pins.nodeEngines` 读(不再抄第二份);`-DryRun` 不再创建
|
||||
目录(它承诺"什么都没改")。
|
||||
- **`check-updates.ps1` 的 1.x 残留**:REA 节默认找 npm 全局包(2.x 是 vendored)、Ghidra 节读用户级
|
||||
`GHIDRA_INSTALL_DIR`、`rea doctor` 拿不到 vendored CLI(整段自检被静默跳过)、一体包对账对"未记录版本"
|
||||
的组件静默 `continue` 却打印"全部一致"。现在一律先读 `bundle.json`,并如实报告无法对账的项。
|
||||
|
||||
### 审计与 CI
|
||||
|
||||
- `audit-manifest.mjs` 新增 4 项:manifest 组件 ⇄ `INSTALL_ORDER` 对账、`pins.nodeEngines` ⇄ `package.json
|
||||
engines`、REA pin 探针不得放行下一个 patch/minor、POSIX wrapper 必须标 `executable`。
|
||||
- `validate.yml` 新增 `windows-latest` job:PowerShell 下载路线、zip 解压与 bootstrap 此前是零 CI 覆盖的
|
||||
Windows 专属代码。
|
||||
- 单测 33 条(32 过 + 1 条按平台跳过);`--reuse adopts a compatible node` 在 engines 之外的宿主上改为显式
|
||||
skip(此前在 Node 20 上必红,而 CI 钉 22 看不出来)。
|
||||
|
||||
### 第二轮修复(同一份**更新后**的清单,2026-10-16 晚)
|
||||
|
||||
- 🔴 **过期 lock 永久卡死自动供给(2.0.0–2.0.2 三版全带)**:`maybeAutoProvision()` 只在 lock 新鲜时
|
||||
处理它,过期的 lock 既不清除也不报告 → 落到独占创建 → `EEXIST` → 返回 `in-flight`,而 `main()` 对
|
||||
`in-flight` **一个字都不打印**。于是「下载被 kill / 断电」这一常态,在 6 小时后再启动就变成**永久不再
|
||||
自动供给且完全静默**。已按清单复现(7 小时死 lock:不启动、lock 原样、无任何提示;5 分钟 lock 对照组
|
||||
正常删锁重启),修法:**不新鲜或持有者已死就一律清锁**(这正是注释里"只在新鲜时尊重它"的本意),
|
||||
并给 `in-flight` / `spawn-failed` / `unwritable` 补上如实提示。
|
||||
- 🔴 **`examples/` 两个文件是 1.x 内容,却随 2.x 进了 npm 包**:`cordis.patch.posix.yml` 教人把整行换成
|
||||
`command: rea`、`cordis.patch.with-env.yml` 给的是 `cmd.exe /c rea` 的 1.x 行——对 2.x 全错(2.x 的行是
|
||||
跨平台的 `process.execPath` + bundle shim,照抄会整行绕过 bundle)。两份都已是孤儿(README 不再引用),
|
||||
故从 `main` **删除**,并同步 `package.json files[]` 与 `validate.yml` 的载荷/YAML/机器路径三项检查
|
||||
(v1 分支保留它们,在 1.x 语境里是对的)。npm 载荷由此 21 → 19 个文件。
|
||||
- 🟡 **`--offline` 管不住 `reaAgents`**:`provisionReaAgents()` 里 0 处 offline 判断,`npm install` 永远
|
||||
联网——与「`--offline` is a promise, not a hint」直接矛盾。现在 `--offline` 且内置副本不可用时**直接
|
||||
FAIL** 并说明原因(而可用的内置副本仍照常跳过)。
|
||||
- 🟡 **npm 那一步没有代理/镜像兜底**:压缩包有 direct→proxy→PowerShell 三路,npm 只读环境变量。失败时
|
||||
现在会打印这句提示,两份 README 的能力/注意里也写明 `HTTPS_PROXY` / `NPM_CONFIG_REGISTRY`。
|
||||
- 🟡 **POSIX 缺 `unzip`/`xz` 必现失败**:Ghidra 全平台只有 zip,而 POSIX 解压原先只有 `unzip` 一条路。
|
||||
现在 zip 依次尝试 `unzip` → `python3 -m zipfile` → **已供给 JDK 自带的 `jar`**(JDK 在 Ghidra 之前供给,
|
||||
等于保底),tar 依次 `tar` → `python3`;三者皆无时打印明确的「no zip extractor available」而不是干巴巴的
|
||||
「extraction failed」。
|
||||
- 🟡 **manifest 的 arm64 注释是错的**:Google 的 linux/darwin platform-tools 都是 x86_64 二进制,
|
||||
「The same archive serves x86-64 and arm64」不成立。三个条目改成如实描述(macOS 需 Rosetta 2、
|
||||
linux-arm64 上 probe 预期失败并被报为可选组件不可用)。
|
||||
- 🟡 **`docs/bundle-design.md` 与实现漂移**:§3 的「row points at `<plugin>/bin/rea-shim.mjs`」改成
|
||||
「按 `DSH_HOME` 算出 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(镜像副本)」;§7 的
|
||||
`vendor/rea-agents` 改成 `vendor/node_modules/rea-agents`。
|
||||
- 🟢 **`release.yml` 的 `workflow_dispatch` 可绕过 Release 直接发 npm**:publish job 现在先断言
|
||||
`v<package.json version>` 的 tag 在 origin 上存在、且该 Release 挂着版本无关的 `dsh-plugin-rea.tgz`
|
||||
资产,否则拒绝发布(否则 npm 与市场兜底链接会岔开)。
|
||||
- 🟢 **数值环境变量会变成 NaN**:`REA_BUNDLE_FETCH_RETRIES=abc` → `Math.max(1, NaN)` → 报
|
||||
「gave up after NaN attempt(s)」;NaN 的 socket 超时等价于 0(立即超时)。现在非正有限值一律回落到默认,
|
||||
且启动日志新增一行 `limits connect=… idle=… retries=…`,让覆盖生效可见。
|
||||
- 🟢 注释与整洁:`downloadViaPowerShell` 的文档说自己是 fallback,实际在 Windows 上是主路线(已改口径);
|
||||
去掉 setup.mjs 里 3 处对已静态导入成员的重复 `await import("node:fs")`(`readdirSync` 补进静态导入)。
|
||||
|
||||
## 2.0.2 — 2026-10-15
|
||||
|
||||
**仅文档,无行为变更。**
|
||||
|
||||
@@ -82,6 +82,10 @@ Both lines ship the **same patch shape** — one `insert` row of `@deepseek-ai/d
|
||||
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:
|
||||
@@ -110,6 +114,10 @@ If Node is already installed, you can skip the bootstrap and run provisioning di
|
||||
node scripts/setup.mjs --reuse # or --dry-run to see the plan first
|
||||
```
|
||||
|
||||
Node, when you provide it instead of letting setup fetch one, must satisfy REA's engines —
|
||||
`^22.19.0 || ^24.11.0 || >=26.0.0` (check with `node -v`). A lower Node does not fail at install
|
||||
time; the MCP row fails later, which reads as silence.
|
||||
|
||||
Installing the plugin **first** also works, but it takes two steps. The row resolves to
|
||||
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`, and that file is created by setup — so until the first
|
||||
provisioning run the row exits with `MODULE_NOT_FOUND`, and nothing is there to start a background
|
||||
@@ -117,6 +125,23 @@ provisioning (the harness itself is unaffected: `failOnStartupError: false` is d
|
||||
`node scripts/setup.mjs` once; from then on the shim is in place, and every later missing state is picked up
|
||||
by the shim itself, which provisions in the background.
|
||||
|
||||
### Which DSH versions
|
||||
|
||||
`package.json` declares `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`, and that declaration is the answer the plugin market
|
||||
reads: with it the card says **compatible**; without it the market can only say *unknown*. The range is not a guess —
|
||||
it is the dsh release list this plugin is checked against, and `node scripts/host-compat.mjs` fails the build when the
|
||||
two disagree:
|
||||
|
||||
| Host | Covered |
|
||||
|---|---|
|
||||
| `0.1.7-rc.2` … `0.2.1-rc.2` — every release in between, alpha and rc | yes |
|
||||
| `0.3.0` and later | no — a new minor line is a host nobody updated this row for yet |
|
||||
|
||||
What was actually compared across that range is the row provider: `packages/mcp/mcp-client` at `dsh-v0.1.7-rc.2` and at
|
||||
`dsh-v0.2.1-rc.2` exposes the same config surface the shipped row uses (`serverName`, `transport`, `command`, `args`,
|
||||
`env`, `failOnStartupError`), and `dsh.bundle.patch` exists at both tags. A host outside the range can still work; it is
|
||||
simply not claimed.
|
||||
|
||||
## Configure — nothing
|
||||
|
||||
The shipped row carries **no machine path**:
|
||||
@@ -168,6 +193,27 @@ To pin a different launch command on this machine (for example your own globally
|
||||
the row from the profile's own `cordis.patch.yml` — that file is machine-local and never published. Only one
|
||||
row may use `serverName: rea` in a profile.
|
||||
|
||||
### One REA registration only
|
||||
|
||||
Upstream REA 6.5.0 can register itself with DSH:
|
||||
|
||||
```
|
||||
rea setup --client dsh
|
||||
```
|
||||
|
||||
That writes a row with `id: rea-mcp` and `serverName: rea` into `<DSH_HOME>/cordis.patch.yml`. This
|
||||
bundle's row is `id: mcp-rea` with the same `serverName`, and DSH rejects the later of two entries
|
||||
that share a server name — so which REA row survives depends on load order. **Use one, not both.**
|
||||
|
||||
Which one? Upstream's is the configuration-only route: it expects Node on REA's engines,
|
||||
`rea-agents` reachable, and a Ghidra/JDK you provisioned yourself. This bundle is the
|
||||
zero-dependency route: it fetches Node, REA, JDK and Ghidra on first run. The patch shape and the
|
||||
`mcp__rea__*` surface are the same either way.
|
||||
|
||||
The shim reports the conflict on startup when it can see that file — a custom `DSH_HOME` is hidden
|
||||
from an MCP child, so it falls back to `~/.dsh` there — and `node bin/rea-shim.mjs --shim-status`
|
||||
lists it under `duplicateRegistration`.
|
||||
|
||||
## Verify
|
||||
|
||||
Ask for a `mcp__rea__*` call — `binary_session` reports the engine checks
|
||||
@@ -179,11 +225,11 @@ exact shim launch the MCP row will use.
|
||||
|
||||
## Verified against
|
||||
|
||||
Author's machine, 2026-10-13 / 14:
|
||||
Author's machine, 2026-10-13 / 14 / 16:
|
||||
|
||||
| Component | Version |
|
||||
|---|---|
|
||||
| REA (`rea-agents`) | **6.4.0** (170 tools) |
|
||||
| REA (`rea-agents`) | **6.5.0** (207 tools) |
|
||||
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**, with its bundled dsh CLI 0.1.7-rc.2 |
|
||||
| `@deepseek-ai/dsh-mcp-client` (row provider) | bundled by the harness |
|
||||
| Ghidra | 12.1.4 |
|
||||
@@ -192,7 +238,7 @@ Author's machine, 2026-10-13 / 14:
|
||||
| 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.4.0}`; and the whole
|
||||
**through the shim with no `rea` on PATH** answered `serverInfo {name: rea, version: 6.5.0}`; and the whole
|
||||
chain ran under the real desktop host — its own Electron binary launched
|
||||
`<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`, the shim started the **vendored** REA, and a live tool
|
||||
call reported `path_source: environment` for a path only that machine's `bundle.json` knows.
|
||||
@@ -203,22 +249,57 @@ macOS/Linux.
|
||||
|
||||
## Scope and caveats
|
||||
|
||||
- REA exposes 170 tools at 6.4.0, so every session's tool list grows.
|
||||
- REA exposes 207 tools at 6.5.0, so every session's tool list grows.
|
||||
- Provisioning is honest about checksums: where a vendor publishes one it is pinned and verified; where none
|
||||
exists (jadx-headless-mcp, the apktool wrapper, Android platform-tools, the rolling "latest" URLs) the
|
||||
hash is **measured once and recorded**, and later drift is reported rather than silently accepted.
|
||||
`bundle.json` lists which components are in that state under `unverifiedChecksums`.
|
||||
- Downloads are slow on some networks (measured 0.35–0.54 MiB/s from the GitHub release CDN here, so Ghidra
|
||||
alone takes ~25–45 min). Fetching is resumable: the cache is kept and re-verified, and a killed run
|
||||
continues instead of restarting.
|
||||
alone takes ~25–45 min). Fetching is resumable on both routes: a partial `.part` is continued with a Range
|
||||
request, and a `.part` that already holds the whole file is verified and kept instead of re-fetched.
|
||||
- Some REA tools launch processes, write files and access the network — DSH's permission policy applies.
|
||||
Analyse only artifacts you are authorized to inspect.
|
||||
- The MCP child and the engines it launches are ordinary processes running as **you**. DSH's tool
|
||||
approvals govern what the model asks for, not what that child may read, write or send on its own;
|
||||
treat the plugin like any third-party code you install.
|
||||
- On Linux, upstream's process-ownership checks need a **procps-compatible `ps`** on PATH; the
|
||||
Ghidra/JADX and firmware paths use it. A minimal image without it fails those tools, not the install.
|
||||
- `scripts/setup.mjs` runs `npm install` for the REA CLI with `--ignore-scripts`: the published tarball
|
||||
carries no `node_modules`, so a dependency closure must be resolved, and package lifecycle scripts are
|
||||
skipped on purpose during provisioning.
|
||||
skipped on purpose during provisioning. That step is also the **only** one that cannot use this bundle's
|
||||
own route/proxy resolution — npm reads `HTTPS_PROXY`/`HTTP_PROXY` and `NPM_CONFIG_REGISTRY` from the
|
||||
environment, so on a network that needs a mirror, set one of those before provisioning
|
||||
(`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`). With `--offline` the step is refused outright
|
||||
instead of quietly going online.
|
||||
- On POSIX, extraction falls back when the usual tools are missing: a zip is opened with `unzip`, then
|
||||
`python3 -m zipfile`, then the provisioned JDK's `jar`; a tarball with `tar`, then `python3`. A minimal
|
||||
image that has none of them fails with an explicit "no zip extractor available" line rather than a bare
|
||||
"extraction failed". This matters because Ghidra is a zip on every platform and `unzip` is not installed
|
||||
everywhere by default.
|
||||
|
||||
## Updating — three independent things
|
||||
|
||||
### 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:
|
||||
|
||||
+79
-6
@@ -77,6 +77,9 @@ patch 里不含任何机器路径**。
|
||||
`mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
|
||||
[从 1.x 升级](#从-1x-升级)。
|
||||
|
||||
上游现在也能自己注册进 dsh(`rea setup --client dsh`):那基本就是上游维护的 1.x 模式 —— 只写配置,
|
||||
Node/REA/Ghidra 由你准备。本包是零依赖路线。三条路选一条,不要同时留两行 `serverName: rea`。
|
||||
|
||||
## 安装
|
||||
|
||||
**前置什么都不用装。** 在干净的 Windows x64 机器上:
|
||||
@@ -104,11 +107,30 @@ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
|
||||
node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
|
||||
```
|
||||
|
||||
如果 Node 由你自备(而不是让 setup 去取),它必须满足 REA 的 engines:
|
||||
`^22.19.0 || ^24.11.0 || >=26.0.0`(用 `node -v` 核对)。版本偏低不会在安装时失败,而是到 MCP 行
|
||||
启动时才失败 —— 表现就是"没有反应"。
|
||||
|
||||
反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
|
||||
而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
|
||||
任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
|
||||
`node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
|
||||
|
||||
### 支持哪些 DSH 版本
|
||||
|
||||
`package.json` 里声明了 `engines.dsh: ">=0.1.7-rc.2 <0.3.0"`,这就是插件市场读的那句话:有它,卡片显示
|
||||
**兼容**;没有它,市场只能说*未声明*。这个范围不是猜的——它对应本插件实际核对过的 dsh 发布列表,
|
||||
`node scripts/host-compat.mjs` 会在两者不一致时让构建失败:
|
||||
|
||||
| 宿主版本 | 是否覆盖 |
|
||||
|---|---|
|
||||
| `0.1.7-rc.2` … `0.2.1-rc.2`(中间的 alpha 与 rc 全部在内) | 是 |
|
||||
| `0.3.0` 及以后 | 否 —— 新的小版本线是还没为它核对过这一行的宿主 |
|
||||
|
||||
真正被比对过的是「行提供方」:`packages/mcp/mcp-client` 在 `dsh-v0.1.7-rc.2` 与 `dsh-v0.2.1-rc.2` 两个 tag 上,
|
||||
暴露的配置面与发布的行完全一致(`serverName`、`transport`、`command`、`args`、`env`、`failOnStartupError`),
|
||||
并且两个 tag 都有 `dsh.bundle.patch`。范围之外的宿主可能照样能用,只是没有替它声明。
|
||||
|
||||
## 配置 —— 不需要
|
||||
|
||||
发布的那一行**不含任何机器路径**:
|
||||
@@ -159,6 +181,25 @@ node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
|
||||
`cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
|
||||
`serverName: rea`。
|
||||
|
||||
### 只保留一条 REA 注册
|
||||
|
||||
上游 REA 6.5.0 起可以自己注册进 DSH:
|
||||
|
||||
```
|
||||
rea setup --client dsh
|
||||
```
|
||||
|
||||
它会把一行 `id: rea-mcp`、`serverName: rea` 写进 `<DSH_HOME>/cordis.patch.yml`。本包的行是
|
||||
`id: mcp-rea`、同样 `serverName: rea`,而 DSH 对同名条目只保留一个:后加载的那行会以
|
||||
duplicate server name 报错并加载失败,最终留下哪一行取决于加载顺序。**二选一,不要同时用。**
|
||||
|
||||
选哪个?上游那条是纯配置路线:需要 Node 满足 REA engines、`rea-agents` 可达,Ghidra/JDK 由你
|
||||
自己准备。本包是零依赖路线:首次运行自动取 Node、REA、JDK、Ghidra。两者的 patch 形态与
|
||||
`mcp__rea__*` 工具面相同。
|
||||
|
||||
shim 在能看到该文件时会在启动时提示冲突 —— MCP 子进程里拿不到自定义 `DSH_HOME`,那种情况下它会
|
||||
退回 `~/.dsh`;`node bin/rea-shim.mjs --shim-status` 会把冲突列在 `duplicateRegistration` 下。
|
||||
|
||||
## 验证
|
||||
|
||||
随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
|
||||
@@ -170,11 +211,11 @@ shim 启动**。
|
||||
|
||||
## 实测环境
|
||||
|
||||
作者机器,2026-10-13 / 14:
|
||||
作者机器,2026-10-13 / 14 / 16:
|
||||
|
||||
| 组件 | 版本 |
|
||||
|---|---|
|
||||
| REA(`rea-agents`) | **6.4.0**(170 个工具) |
|
||||
| REA(`rea-agents`) | **6.5.0**(207 个工具) |
|
||||
| DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
|
||||
| `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
|
||||
| Ghidra | 12.1.4 |
|
||||
@@ -183,7 +224,7 @@ shim 启动**。
|
||||
| 宿主 | Windows 11 x64(build 26200) |
|
||||
|
||||
实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
|
||||
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}`;以及**真实桌面宿主下的完整链路** ——
|
||||
shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.5.0}`;以及**真实桌面宿主下的完整链路** ——
|
||||
宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
|
||||
的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
|
||||
|
||||
@@ -192,21 +233,53 @@ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}
|
||||
|
||||
## 边界与注意事项
|
||||
|
||||
- REA 在 6.4.0 有 170 个工具,每个会话的工具列表都会变长。
|
||||
- REA 在 6.5.0 有 207 个工具,每个会话的工具列表都会变长。
|
||||
- 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
|
||||
Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
|
||||
`bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
|
||||
- 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
|
||||
下载是**可续传**的:缓存保留并重新校验,被中断的一轮会继续而不是从头再来。
|
||||
下载是**可续传**的:两条路线都按盘上已有的字节发 Range 继续;若 `.part` 恰好已经完整,会校验后直接收下
|
||||
而不是重下。
|
||||
- **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
|
||||
同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
|
||||
(环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
|
||||
- REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
|
||||
- MCP 子进程与它拉起的引擎都是以**你的身份**运行的普通进程。DSH 的工具审批管的是模型发起的调用,
|
||||
管不到这个子进程自己去读、写或发送什么;请把插件当作你安装的第三方代码来对待。
|
||||
- Linux 上,上游的进程归属检查依赖 PATH 上**兼容 procps 的 `ps`**;Ghidra/JADX 与固件工具会用到它。
|
||||
极简镜像里缺它只会让这些工具失败,不影响整体安装。
|
||||
- `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
|
||||
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。
|
||||
`node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。这一步也是**唯一**用不上
|
||||
本插件自己的路由/代理回退的环节 —— npm 只从环境读 `HTTPS_PROXY`/`HTTP_PROXY` 与
|
||||
`NPM_CONFIG_REGISTRY`,所以网络需要镜像时请先设其中一个(例如
|
||||
`NPM_CONFIG_REGISTRY=https://registry.npmmirror.com`)。带 `--offline` 时这一步会被**直接拒绝**,
|
||||
而不是悄悄联网。
|
||||
- POSIX 上解压有兜底:zip 依次尝试 `unzip` → `python3 -m zipfile` → 已供给 JDK 自带的 `jar`;tar 包依次
|
||||
尝试 `tar` → `python3`。三者都没有的最小镜像会得到一句明确的「no zip extractor available」,而不是
|
||||
干巴巴的「extraction failed」。Ghidra 在所有平台都只有 zip,而 `unzip` 并非每个发行版默认安装,
|
||||
所以这条不是理论问题。
|
||||
|
||||
## 更新 —— 三件互相独立的事
|
||||
|
||||
### 在市场里更新
|
||||
|
||||
在哪儿装的就在哪儿更新。更新到 **2.0.5 及以上**是常规操作;而把已装的 2.0.1–2.0.4 通过应用内市场**更新到
|
||||
2.0.4**,在健康的 profile 上连续失败了三次:
|
||||
|
||||
> dsh-plugin-rea 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),且未能验证恢复原版本文件
|
||||
|
||||
这条消息与 tarball 是否完整无关。市场在每次更新时都会问一句:新构建的 `package.json` 是否指向一个真实存在的
|
||||
入口文件(`dshmarket` 里的 `hasLoadableEntry`);答案是「否」,就把这次更新判为坏构建并回滚。bundle 本来允许
|
||||
自己没有入口 —— 前提是它**挂载**的某个包有入口;而本包挂载的 `@deepseek-ai/dsh-mcp-client` 属于 harness 自身
|
||||
构建,不在 `node_modules` 里,于是这项检查要看的每个位置都是空的。1.x 与 2.0.x 的清单又根本没有声明 `main`,
|
||||
所以答案必然是「否」,哪怕 profile 一直是好的;紧接着的回滚也因此被报成「无法验证」。实际上 profile 没有被
|
||||
破坏,只是这次更新没有生效。
|
||||
|
||||
2.0.5 声明了 `main: ./index.mjs`。那是一个真实存在、刻意留空的 Cordis 插件:不注册任何行、不启动任何进程、
|
||||
不碰任何状态(真正起作用的行仍由 `cordis.patch.yml` 插入),它的作用只是让「这个包能不能加载」有一个诚实的
|
||||
答案,并让 `import 'dsh-plugin-rea'` 能解析。还停在旧版本时,更新到 `>=2.0.5` 即可;若市场在 2.0.5 之前就拒绝,
|
||||
就直接安装 `[email protected]`(桌面端:在插件管理器里装),不要原地更新。
|
||||
|
||||
### 从 1.x 升级
|
||||
|
||||
装上 2.x 后,**先跑一次供给**,再重启 dsh:
|
||||
|
||||
@@ -84,7 +84,7 @@ Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/
|
||||
$rowY = 180; $boxH = 158; $boxW = 250
|
||||
Draw-Box $g 60 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
|
||||
Draw-Text $g 'DeepSeek Harness 会话' $fBox $ink 80 ($rowY + 18)
|
||||
Draw-Text $g "mcp__rea__* 工具`n(6.4.0 暴露 170+)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
|
||||
Draw-Text $g "mcp__rea__* 工具`n(6.5.0 暴露 207)`n工具随会话注入`n按 DSH 权限策略执行" $fBody $muted 80 ($rowY + 50)
|
||||
|
||||
Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
|
||||
Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
|
||||
@@ -112,7 +112,7 @@ Draw-Line $g 358 446 150 446 $muted
|
||||
Draw-Arrow $g 150 446 150 342 $muted
|
||||
|
||||
Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 620
|
||||
Draw-Text $g '实测 REA 6.4.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-14)' $fSmall $muted 60 646
|
||||
Draw-Text $g '实测 REA 6.5.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-16)' $fSmall $muted 60 646
|
||||
Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
|
||||
Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
+169
-17
@@ -15,7 +15,7 @@
|
||||
* and the message is the operator's remediation.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
@@ -93,6 +93,77 @@ function provisioningScript(root) {
|
||||
return usableSetup(PLUGIN_ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream REA 6.5.0 can add a second registration for the same MCP server:
|
||||
* `rea setup --client dsh` writes a row with id "rea-mcp" and serverName "rea"
|
||||
* into `<DSH_HOME>/cordis.patch.yml`, while this bundle ships id "mcp-rea" with
|
||||
* the same serverName. DSH does not dedupe those rows: it rejects the later entry
|
||||
* with a duplicate-server-name error, so which REA row survives depends on load
|
||||
* order. The shim cannot prevent that, but it can name the conflict instead of
|
||||
* leaving a missing row to be diagnosed from "the tools are gone".
|
||||
*
|
||||
* The parse is deliberately small: the two files it cares about are block
|
||||
* mappings with one id/serverName per row, and anything it cannot attribute is
|
||||
* ignored — a missed hint is acceptable, a false alarm is not. The mirrored shim
|
||||
* cannot see a custom DSH_HOME (the harness strips DSH_* from an MCP child), so
|
||||
* this falls back to `~/.dsh` like the rest of the bundle; the row has the same
|
||||
* limitation.
|
||||
*/
|
||||
function foreignReaRegistration() {
|
||||
const patch = path.join(dshHome(), "cordis.patch.yml");
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(patch, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const unquote = (value) => value.replace(/^(['"])(.*)\1$/, "$2").trim();
|
||||
// A row starts at `- id:`; the block runs to the next row, so a serverName can
|
||||
// never be attributed to a row that does not contain it.
|
||||
for (const block of text.split(/\r?\n(?=\s*-\s+id:\s*)/)) {
|
||||
const id = /^\s*-\s+id:\s*(.+?)\s*$/m.exec(block);
|
||||
const server = /^\s+serverName:\s*(.+?)\s*$/m.exec(block);
|
||||
if (!id || !server || unquote(server[1]) !== "rea") continue;
|
||||
const rowId = unquote(id[1]);
|
||||
if (rowId !== "mcp-rea") return { patch, id: rowId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The provisioning lock is a small JSON record: { state, pid, at, exit? }.
|
||||
* `state: "running"` is written when a run is started (setup's own pid once it
|
||||
* exists), and the failing run rewrites it as `state: "failed"` — it is kept on
|
||||
* purpose so a broken setup cannot be re-downloaded in a loop. A legacy
|
||||
* plain-number lock is still honoured as "running".
|
||||
*/
|
||||
const STALE_LOCK_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function readLock(lock) {
|
||||
try {
|
||||
const raw = readFileSync(lock, "utf8").trim();
|
||||
if (raw.startsWith("{")) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
}
|
||||
const at = Number(raw);
|
||||
return Number.isFinite(at) ? { state: "running", at } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the process recorded in the lock still alive? null means "cannot tell". */
|
||||
function pidAlive(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error?.code === "ESRCH" ? false : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On first run there is nothing to launch. Provisioning is a long download, so
|
||||
* it is started detached: the harness may kill this row (or the whole session)
|
||||
@@ -100,34 +171,67 @@ function provisioningScript(root) {
|
||||
* a killed attempt is simply continued on the next start.
|
||||
*
|
||||
* Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
|
||||
* Returns the log path when a run was started, otherwise null.
|
||||
* Returns { started, reason, logPath } — the caller words its message from
|
||||
* `reason`, because "provisioning started in the background" was also printed
|
||||
* for a lock left behind by a run that had already failed, and for one whose
|
||||
* process was long gone.
|
||||
*/
|
||||
function maybeAutoProvision(root) {
|
||||
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return null;
|
||||
if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return { started: false, reason: "disabled" };
|
||||
const setupScript = provisioningScript(root);
|
||||
if (!setupScript) return null;
|
||||
if (!setupScript) return { started: false, reason: "no-setup" };
|
||||
const lock = path.join(root, "setup.lock");
|
||||
const logPath = path.join(root, "logs", "setup.log");
|
||||
if (existsSync(lock)) {
|
||||
// A crashed run must not wedge provisioning forever: honour the lock only
|
||||
// while it is fresh; setup removes it itself when it finishes cleanly.
|
||||
let age = Infinity;
|
||||
const record = readLock(lock);
|
||||
const age = record && Number.isFinite(record.at) ? Date.now() - record.at : Infinity;
|
||||
const fresh = Number.isFinite(age) && age < STALE_LOCK_MS;
|
||||
if (fresh && record?.state === "failed") {
|
||||
// The run gave up and left the lock behind so it would not be retried in a
|
||||
// loop. Claiming it is still provisioning was a six-hour lie: nothing runs,
|
||||
// and every restart repeated the sentence.
|
||||
return {
|
||||
started: false,
|
||||
reason: "last-run-failed",
|
||||
exit: record.exit,
|
||||
failedAt: record.at,
|
||||
ageMs: age,
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
if (fresh) {
|
||||
const alive = pidAlive(record?.pid);
|
||||
if (alive !== false) return { started: false, reason: "in-flight", logPath, ageMs: age };
|
||||
}
|
||||
// Reached when the lock is older than the freshness window, or its holder is
|
||||
// gone and never wrote an outcome (harness restart, power loss, a kill): what
|
||||
// it records is not happening any more. This removal is the whole point of the
|
||||
// freshness rule — without it the exclusive create below failed with EEXIST
|
||||
// *forever*, auto-provisioning never ran again, and main() printed nothing at
|
||||
// all for that outcome. The lock's own "honour it only while it is fresh"
|
||||
// comment described behaviour the code did not have.
|
||||
try {
|
||||
age = Date.now() - Number(readFileSync(lock, "utf8").trim() || 0);
|
||||
rmSync(lock, { force: true });
|
||||
} catch {
|
||||
age = Infinity;
|
||||
// A read-only bundle root (system-wide install, chmod 555) cannot drop the
|
||||
// stale lock either. Returning the same "cannot write here" outcome as the
|
||||
// exclusive create below keeps the row alive: an uncaught EACCES killed the
|
||||
// shim with a stack trace, which is louder than the old silence but just as
|
||||
// unable to start REA.
|
||||
return { started: false, reason: "unwritable", logPath };
|
||||
}
|
||||
if (Number.isFinite(age) && age < 6 * 60 * 60 * 1000) return logPath;
|
||||
}
|
||||
try {
|
||||
mkdirSync(path.join(root, "logs"), { recursive: true });
|
||||
// Exclusive create: check-then-write is not atomic, so two rows starting at
|
||||
// once could both decide to provision. EEXIST means another run owns it.
|
||||
writeFileSync(lock, String(Date.now()), { flag: "wx" });
|
||||
writeFileSync(lock, JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }), { flag: "wx" });
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") return logPath;
|
||||
if (error?.code === "EEXIST") return { started: false, reason: "in-flight", logPath };
|
||||
/* a read-only bundle root must never break the harness */
|
||||
return null;
|
||||
return { started: false, reason: "unwritable" };
|
||||
}
|
||||
try {
|
||||
const child = spawn(process.execPath, [setupScript, "--root", root], {
|
||||
@@ -136,9 +240,18 @@ function maybeAutoProvision(root) {
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
return logPath;
|
||||
// Hand the lock to the detached downloader, so a later start that finds this
|
||||
// row dead still sees the run that is actually doing the work as alive.
|
||||
if (Number.isInteger(child.pid)) {
|
||||
try {
|
||||
writeFileSync(lock, JSON.stringify({ state: "running", pid: child.pid, at: Date.now(), supervisor: process.pid }));
|
||||
} catch {
|
||||
/* the lock is advisory: the row keeps working without it */
|
||||
}
|
||||
}
|
||||
return { started: true, reason: "started", logPath };
|
||||
} catch {
|
||||
return null;
|
||||
return { started: false, reason: "spawn-failed", logPath };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +308,13 @@ function readBundleConfig(root) {
|
||||
function resolveRea(config, root) {
|
||||
const paths = config.paths ?? {};
|
||||
const node = paths.node;
|
||||
// The fallback has to match where setup actually installs the package:
|
||||
// `npm install --prefix <root>/vendor` resolves into vendor/node_modules, so a
|
||||
// fallback without that segment named a directory that never exists. It only
|
||||
// fires when an older bundle.json has no paths.reaEntry — which is exactly when
|
||||
// a wrong fallback turns a usable install into "the vendored REA CLI is gone".
|
||||
const reaEntry = paths.reaEntry
|
||||
?? path.join(root, "vendor", "rea-agents", "scripts", "rea.mjs");
|
||||
?? path.join(root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs");
|
||||
|
||||
if (typeof node !== "string" || node.trim() === "") {
|
||||
return { ok: false, reason: "bundle.json has no paths.node" };
|
||||
@@ -279,6 +397,18 @@ function main() {
|
||||
|
||||
const root = bundleRoot();
|
||||
const loaded = readBundleConfig(root);
|
||||
// Upstream can add a second row for the same serverName; the shim cannot stop
|
||||
// DSH from loading both rows, but saying so is better than doubled tools with
|
||||
// no stated cause. The status report carries the same fact for diagnostics.
|
||||
const duplicate = foreignReaRegistration();
|
||||
if (duplicate && !argv.includes("--shim-status")) {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): duplicate REA registration.\n` +
|
||||
` ${duplicate.patch} also registers serverName "rea" (id "${duplicate.id}").\n` +
|
||||
` This bundle's row is id "mcp-rea" with the same serverName; DSH rejects the later\n` +
|
||||
` entry as a duplicate server name, so only one survives. Keep exactly one of them.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (argv.includes("--shim-status")) {
|
||||
const report = {
|
||||
@@ -288,6 +418,7 @@ function main() {
|
||||
reason: loaded.ok ? undefined : loaded.code,
|
||||
autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
|
||||
};
|
||||
if (duplicate) report.duplicateRegistration = duplicate;
|
||||
if (loaded.ok) {
|
||||
report.paths = loaded.config.paths ?? {};
|
||||
report.versions = loaded.config.versions ?? {};
|
||||
@@ -302,10 +433,31 @@ function main() {
|
||||
loaded.code === "missing"
|
||||
? `No bundle state at ${loaded.file}.`
|
||||
: `Bundle state at ${loaded.file} could not be parsed.`;
|
||||
const logPath = loaded.code === "missing" ? maybeAutoProvision(root) : null;
|
||||
if (logPath) {
|
||||
// Both "missing" and "malformed" start a run. A malformed file used to fail
|
||||
// permanently: setup overwrites a bundle.json it cannot parse, but nothing
|
||||
// ever started setup, so every restart reported the same EX_CONFIG until the
|
||||
// operator deleted the file by hand. The lock stops a provisioning loop when
|
||||
// writing keeps failing.
|
||||
const provisioning = maybeAutoProvision(root);
|
||||
if (provisioning.started) {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${logPath}\n`,
|
||||
`\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${provisioning.logPath}\n`,
|
||||
);
|
||||
} else if (provisioning.reason === "last-run-failed") {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): the last provisioning attempt failed (exit ${provisioning.exit ?? "?"}).\n` +
|
||||
` Nothing is running right now — read the log, then re-run setup.\n log: ${provisioning.logPath}\n`,
|
||||
);
|
||||
} else if (provisioning.reason === "in-flight") {
|
||||
// A live run owns the lock. Saying nothing here is what made a wedged
|
||||
// provisioning attempt look like a machine that simply has no bundle.
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): provisioning is already running in the background.\n progress: ${provisioning.logPath}\n`,
|
||||
);
|
||||
} else if (provisioning.reason === "spawn-failed" || provisioning.reason === "unwritable") {
|
||||
process.stderr.write(
|
||||
`\n dsh-plugin-rea (bundle): provisioning could not be started (${provisioning.reason}).\n` +
|
||||
` Run it by hand:\n node "${path.join(root, "scripts", "setup.mjs")}"\n`,
|
||||
);
|
||||
}
|
||||
return fail(message, loaded.detail);
|
||||
|
||||
+92
-17
@@ -69,13 +69,40 @@ function Test-NodeEngines([string]$node, [string]$engines) {
|
||||
}
|
||||
|
||||
# ------------------------------------------------------- 定位 REA 与 profile
|
||||
if (-not $ReaPackageDir) {
|
||||
$npmRoot = $null
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
|
||||
}
|
||||
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
|
||||
# 2.x 的 REA 在 <bundleRoot>\vendor\node_modules\rea-agents,不在 npm 全局。先读
|
||||
# bundle.json:把全局路径当默认是 1.x 的答案,在 2.x 机器上会让这一节指向错误的
|
||||
# 对象("全局包丢了"),并给出错误的升级动作(npm i -g)。
|
||||
$bundleRootEarly = $env:REA_BUNDLE_ROOT
|
||||
if (-not $bundleRootEarly) {
|
||||
$homeEarly = $env:DSH_HOME
|
||||
if (-not $homeEarly) { $homeEarly = Join-Path $HOME '.dsh' }
|
||||
$bundleRootEarly = Join-Path $homeEarly 'plugins\rea-bundle'
|
||||
}
|
||||
$bundleState = $null
|
||||
$bundleJsonEarly = Join-Path $bundleRootEarly 'bundle.json'
|
||||
if (Test-Path $bundleJsonEarly) {
|
||||
try { $bundleState = Get-Content $bundleJsonEarly -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $bundleState = $null }
|
||||
}
|
||||
$bundleReaPackageDir = $null
|
||||
$bundleReaEntry = $null
|
||||
if ($bundleState -and $bundleState.paths.reaEntry) {
|
||||
$bundleReaEntry = [string]$bundleState.paths.reaEntry
|
||||
# <vendor>\node_modules\rea-agents\scripts\rea.mjs -> <vendor>\node_modules\rea-agents
|
||||
$bundleReaPackageDir = Split-Path -Parent (Split-Path -Parent $bundleReaEntry)
|
||||
}
|
||||
|
||||
if (-not $ReaPackageDir) {
|
||||
if ($bundleReaPackageDir -and (Test-Path (Join-Path $bundleReaPackageDir 'package.json'))) {
|
||||
$ReaPackageDir = $bundleReaPackageDir
|
||||
} else {
|
||||
$npmRoot = $null
|
||||
if (Get-Command npm -ErrorAction SilentlyContinue) {
|
||||
try { $npmRoot = (& npm root -g 2>$null | Select-Object -First 1).Trim() } catch { }
|
||||
}
|
||||
$ReaPackageDir = if ($npmRoot) { Join-Path $npmRoot 'rea-agents' } else { Join-Path $env:APPDATA 'npm\node_modules\rea-agents' }
|
||||
}
|
||||
}
|
||||
$reaFromBundle = [bool]($bundleReaPackageDir -and $ReaPackageDir -eq $bundleReaPackageDir)
|
||||
if (-not $ProfileDir) {
|
||||
if ($env:DSH_PROFILE_DIR) { $ProfileDir = $env:DSH_PROFILE_DIR }
|
||||
else {
|
||||
@@ -94,11 +121,21 @@ if (Test-Path $pkgFile) {
|
||||
$installed = $pkg.version; $engines = $pkg.engines.node
|
||||
$binPath = ($pkg.bin.PSObject.Properties | Select-Object -First 1).Value
|
||||
if ($binPath) { $reaEntry = Join-Path $ReaPackageDir ($binPath -replace '/', '\') }
|
||||
Ok "本地已安装: $installed"
|
||||
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
|
||||
if ($reaFromBundle) {
|
||||
Ok "一体包内置(vendored): $installed"
|
||||
Info "位置: $ReaPackageDir"
|
||||
Info "入口(bin): $binPath"
|
||||
} else {
|
||||
Ok "本地已安装: $installed"
|
||||
Info "入口(bin): $binPath ← 插件通过 PATH 上的 rea 调用它,跨版本稳定"
|
||||
}
|
||||
if ($engines) { Info "Node 要求: $engines" }
|
||||
} catch { Bad "package.json 解析失败: $($_.Exception.Message)" }
|
||||
} else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
|
||||
} else {
|
||||
if ($reaFromBundle) { Bad "找不到 $pkgFile —— 内置副本不完整?重跑 setup.mjs" }
|
||||
elseif ($bundleReaPackageDir) { Bad "找不到 $pkgFile —— 一体包内置副本也缺($bundleReaPackageDir);记录里没有 reaEntry" }
|
||||
else { Bad "找不到 $pkgFile —— REA 全局包丢了?重装: npm i -g rea-agents" }
|
||||
}
|
||||
|
||||
$latest = $null
|
||||
foreach ($r in @($Registry, 'https://registry.npmjs.org')) {
|
||||
@@ -110,7 +147,11 @@ if ($installed -and $latest) {
|
||||
$vi = Get-NumericVersion $installed; $vl = Get-NumericVersion $latest
|
||||
if ($vi -and $vl -and $vl -gt $vi) {
|
||||
Warn "有新版本:$installed → $latest"
|
||||
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
|
||||
if ($reaFromBundle) {
|
||||
$actions.Add("升级一体包内置 REA:把 manifest 的 pins.reaAgents / components.reaAgents.sources.npm.version 改成 $latest,然后重跑 node scripts\setup.mjs(内置副本不会自己更新)")
|
||||
} else {
|
||||
$actions.Add("升级上游 REA: npm i -g rea-agents@$latest (只更新 REA;本插件不会自动更新——从市场装的在市场里更新,file: 装的覆盖目录后重装)")
|
||||
}
|
||||
} elseif ($vi -and $vl -and $vl -eq $vi -and $installed -ne $latest) {
|
||||
Warn "同版本号但通道不同:$installed → $latest(预发布/正式)"
|
||||
} else { Ok '已是最新版本' }
|
||||
@@ -139,7 +180,14 @@ if (Get-Command node -ErrorAction SilentlyContinue) {
|
||||
|
||||
# ------------------------------------------------------------ Ghidra 引擎
|
||||
Head 'Ghidra 引擎'
|
||||
$gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User')
|
||||
# bundle.json 是 2.x 的真源:shim 只在 MCP 子进程里设 GHIDRA_INSTALL_DIR,用户级
|
||||
# 变量是 1.x 的写法,没手工设过的机器会被误报"未设置"。
|
||||
$gd = $null
|
||||
if ($bundleState -and $bundleState.paths.ghidra) {
|
||||
$bundleGhidra = [string]$bundleState.paths.ghidra
|
||||
if (Test-Path $bundleGhidra) { $gd = $bundleGhidra }
|
||||
}
|
||||
if (-not $gd) { $gd = [Environment]::GetEnvironmentVariable('GHIDRA_INSTALL_DIR', 'User') }
|
||||
if (-not $gd) { $gd = $env:GHIDRA_INSTALL_DIR }
|
||||
if ($gd -and (Test-Path $gd)) {
|
||||
Ok "GHIDRA_INSTALL_DIR = $gd"
|
||||
@@ -162,11 +210,28 @@ try {
|
||||
# --------------------------------------------------------- REA 引擎自检
|
||||
Head 'REA 引擎自检(rea doctor)'
|
||||
$env:GHIDRA_INSTALL_DIR = $gd
|
||||
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
|
||||
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
|
||||
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
|
||||
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
|
||||
# 2.x:优先用 bundle 内置的 CLI 和它自己的 Node。PATH 上的 rea 可能是另一个,
|
||||
# 甚至完全不在 PATH —— 那会让整段自检被静默跳过。这正是 1.x 口径的残留。
|
||||
$reaCmd = $null
|
||||
$reaArgs = @()
|
||||
if ($bundleReaEntry -and (Test-Path $bundleReaEntry)) {
|
||||
$bundleNode = $null
|
||||
if ($bundleState -and $bundleState.paths.node) {
|
||||
$candidateNode = [string]$bundleState.paths.node
|
||||
if (Test-Path $candidateNode) { $bundleNode = $candidateNode }
|
||||
}
|
||||
if (-not $bundleNode) { $bundleNode = (Get-Command node -ErrorAction SilentlyContinue).Source }
|
||||
if ($bundleNode) {
|
||||
$reaCmd = $bundleNode; $reaArgs = @($bundleReaEntry)
|
||||
Info '使用一体包内置 REA(node <bundle 入口>),不依赖 PATH 上的 rea'
|
||||
}
|
||||
}
|
||||
if (-not $reaCmd) {
|
||||
# 优先取 .cmd:发布出去的那一行是 `cmd.exe /c rea mcp`(走 cmd 的 PATHEXT,命中 rea.cmd),
|
||||
# 而 PowerShell 的 Get-Command rea 会先命中同目录的 rea.ps1 垫片,两者行为(尤其退出码)并不相同。
|
||||
$reaCmd = (Get-Command 'rea.cmd' -ErrorAction SilentlyContinue).Source
|
||||
if (-not $reaCmd) { $reaCmd = (Get-Command rea -ErrorAction SilentlyContinue).Source }
|
||||
}
|
||||
if (-not $reaCmd) {
|
||||
if ($reaEntry -and (Test-Path $reaEntry) -and (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
$reaCmd = 'node'; $reaArgs = @($reaEntry)
|
||||
@@ -247,6 +312,8 @@ if (-not (Test-Path $manifestPath)) {
|
||||
$state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
Ok "已供给:$($state.provisionedAt)"
|
||||
$drift = 0
|
||||
$compared = 0
|
||||
$unrecorded = 0
|
||||
foreach ($name in $manifest.pins.PSObject.Properties.Name) {
|
||||
if ($name -eq 'nodeEngines') { continue }
|
||||
$pinned = [string]$manifest.pins.$name
|
||||
@@ -254,13 +321,21 @@ if (-not (Test-Path $manifestPath)) {
|
||||
if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
|
||||
$installed = [string]$state.versions.$name
|
||||
}
|
||||
if (-not $installed) { continue }
|
||||
if (-not $installed) {
|
||||
# 被 --reuse 采纳的组件没有 version 字段(bundle.json 里是 null)。静默
|
||||
# continue 会让这一项从未被比对,却在下面打印"全部一致"——那是假绿。
|
||||
$unrecorded++
|
||||
Warn " $name 未记录版本(reuse 采纳的组件?)—— 无法与 pin $pinned 对账"
|
||||
continue
|
||||
}
|
||||
$compared++
|
||||
# Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
|
||||
$same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
|
||||
if ($same) { Info " $name = $installed(pin $pinned)" }
|
||||
else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
|
||||
}
|
||||
if ($drift -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
|
||||
if ($drift -eq 0 -and $unrecorded -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
|
||||
elseif ($drift -eq 0) { Warn "已比对的 $compared 项与 pin 一致,但 $unrecorded 项没有记录版本(见上)—— 不是全绿" }
|
||||
if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
|
||||
Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ Two upstream rules shape the runtime wiring:
|
||||
|
||||
## 2. Verified facts this design rests on
|
||||
|
||||
Measured on Windows 11 x64, REA 6.4.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
|
||||
Measured on Windows 11 x64, REA 6.5.0, Ghidra 12.1.4, Temurin JDK 25.0.4:
|
||||
|
||||
| Fact | Evidence |
|
||||
| --- | --- |
|
||||
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.4.0) and `rea doctor --json` reported 170 tools, `healthy: true` |
|
||||
| A row can launch REA with **no PATH `rea`** | `node <pkg>/scripts/rea.mjs mcp` answered the MCP `initialize` handshake (`serverInfo.name = rea`, version 6.5.0) and `rea doctor --json` reported 207 tools. `healthy` is `false` on a bundle-only install: 6.5.0 also checks the host's own setup (Hopper, IDA registration, `rea setup` skill/registrations), which provisioning deliberately never touches — the row does not require `healthy`, only a CLI that runs and a catalog it can read |
|
||||
| `!!js` expressions are supported in this profile layer | the existing profile patch already used one; the loader evaluates them |
|
||||
| A row-level `env:` block reaches the MCP child | `binary_session` reported `registrationEnvironment` = `GHIDRA_INSTALL_DIR`, `JAVA_HOME` |
|
||||
| No fixed home/layout is promised | the disk layout differs per install; nothing may assume one |
|
||||
@@ -54,8 +54,9 @@ Three responsibilities, kept strictly apart:
|
||||
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 points at `<plugin>/bin/rea-shim.mjs` and the
|
||||
shim is responsible for the rest.
|
||||
**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.
|
||||
@@ -161,7 +162,7 @@ Windows and is what this bundle targets to complete.
|
||||
| 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/rea-agents` |
|
||||
| `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 |
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# macOS / Linux variant of the row.
|
||||
#
|
||||
# `npm i -g rea-agents` installs a `rea` executable on PATH, so no shell wrapper
|
||||
# is needed here (on Windows the shim is a .cmd/.ps1 file, which is why the
|
||||
# default cordis.patch.yml goes through cmd.exe).
|
||||
#
|
||||
# Copy the block below into cordis.patch.yml, replacing the Windows row.
|
||||
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: 'rea'
|
||||
args:
|
||||
- 'mcp'
|
||||
failOnStartupError: false
|
||||
@@ -1,42 +0,0 @@
|
||||
# Windows variant that also pins the analysis-engine environment for the MCP child.
|
||||
#
|
||||
# Use this when GHIDRA_INSTALL_DIR is NOT set as an OS-level environment variable
|
||||
# the harness can inherit — for example after setting it up just now, without
|
||||
# restarting the harness.
|
||||
#
|
||||
# The value below is a placeholder in angle brackets, not a real directory:
|
||||
# replace it with your own, or use the `!!js` form to read the variable the
|
||||
# harness already has. REA reads only GHIDRA_INSTALL_DIR; `java` comes from PATH
|
||||
# or JAVA_HOME.
|
||||
#
|
||||
# Copy the block into cordis.patch.yml, or — better — keep it out of the plugin
|
||||
# entirely and override the row from your profile's own cordis.patch.yml:
|
||||
#
|
||||
# - id: mcp-rea
|
||||
# config:
|
||||
# env:
|
||||
# GHIDRA_INSTALL_DIR: '<your Ghidra directory>'
|
||||
#
|
||||
# A profile-level override is machine-local and is never published.
|
||||
|
||||
- insert:
|
||||
- id: mcp-rea
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: 'cmd.exe'
|
||||
args:
|
||||
- '/c'
|
||||
- 'rea'
|
||||
- 'mcp'
|
||||
env:
|
||||
# Replace with your own Ghidra 12.1.x extraction directory.
|
||||
# Leaving the placeholder in place is harmless: REA then reports the
|
||||
# native engine as unavailable, exactly as when the variable is unset.
|
||||
GHIDRA_INSTALL_DIR: '<your Ghidra 12.1.x extraction directory>'
|
||||
# Or read it from the environment the harness already has — the
|
||||
# entry-list dialect evaluates `!!js` expressions at activation:
|
||||
# GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR
|
||||
# JAVA_HOME: '<your JDK 21 or newer home>' # only when java is not on PATH
|
||||
failOnStartupError: false
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Package entry — the smallest thing that makes this bundle a *loadable* package.
|
||||
*
|
||||
* Everything this plugin DOES lives in `cordis.patch.yml`: it inserts one row of
|
||||
* `@deepseek-ai/dsh-mcp-client` that launches `bin/rea-shim.mjs`, and that row is
|
||||
* what the harness loads. This file registers nothing, starts nothing and
|
||||
* provisions nothing.
|
||||
*
|
||||
* It exists because a package that declares no `main`/`exports` is judged by its
|
||||
* consumers as "has no entry file", whether or not the loader ever needs one:
|
||||
*
|
||||
* - The market's update path (`hasLoadableEntry` in dshmarket) asks that
|
||||
* question on every update and, when the answer is false, treats the new
|
||||
* build as broken and rolls back. A bundle with no entry of its own is
|
||||
* *supposed* to survive the test through the packages it mounts — but the
|
||||
* package this one mounts (`@deepseek-ai/dsh-mcp-client`) is part of the
|
||||
* harness build, not of `node_modules`, so every location that test probes
|
||||
* comes back empty. Measured on 2026-10-18: updating an installed, working
|
||||
* 2.0.1 to 2.0.4 failed three times with "updated build has no loadable
|
||||
* entry", and the rollback was then reported as unverifiable for the same
|
||||
* reason.
|
||||
* - `npm pack` metadata and `import 'dsh-plugin-rea'` both resolve `main`;
|
||||
* absent, both fall back to `index.js`, which this repository never shipped.
|
||||
*
|
||||
* So this is a real module with a real Cordis plugin shape rather than a stub
|
||||
* for a checker: if the harness ever does load this package as a plugin, it
|
||||
* loads cleanly and does nothing.
|
||||
*/
|
||||
|
||||
/** Cordis plugin name. Also the package name, which is what the loader expects. */
|
||||
export const name = 'dsh-plugin-rea'
|
||||
|
||||
/**
|
||||
* Intentionally empty.
|
||||
*
|
||||
* The row that does the work is inserted by the bundle patch, not by this
|
||||
* function: registering the MCP client from here would create a second
|
||||
* `serverName: rea` entry, which the harness refuses as a duplicate (the same
|
||||
* collision `bin/rea-shim.mjs` warns about for upstream's `rea setup --client
|
||||
* dsh`). Doing nothing is the correct behaviour, and the function present keeps
|
||||
* this a valid Cordis plugin instead of an arbitrary module.
|
||||
*/
|
||||
export function apply() {}
|
||||
@@ -12,12 +12,14 @@
|
||||
" 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.4.0",
|
||||
"reaAgents": "6.5.0",
|
||||
"ghidra": "12.1.4",
|
||||
"jdk": "21",
|
||||
"apktool": "3.0.3",
|
||||
@@ -203,16 +205,16 @@
|
||||
"win32-x64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "master",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/windows/apktool.bat",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/windows/apktool.bat",
|
||||
"format": "file",
|
||||
"size": 2348,
|
||||
"sha256": "9440ec9d98efdef14136f4cefd79ac200d72baa016b5991d128234a3a3ef3b43",
|
||||
"sha256Source": "measured on 2026-10-13 (raw.githubusercontent.com publishes no checksum for the wrapper); a single 2,348-byte file, so the archive-free case needs no TOFU",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"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.",
|
||||
"Its final line is `for %%i in (%cmdcmdline%) do if /i \"%%~i\"==\"/c\" pause & exit /b`: measured here it returns exit 0 in 0.2 s, but it appends 'Press any key to continue . . .' to stdout. That trailing text is upstream's, not a provisioning defect; tools that parse apktool's stdout should tolerate it.",
|
||||
"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",
|
||||
@@ -222,13 +224,14 @@
|
||||
"darwin-x64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "master",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
|
||||
"format": "file",
|
||||
"size": 2672,
|
||||
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
|
||||
"sha256Source": "measured on 2026-10-13 from the osx wrapper (the correct one for macOS; the linux script is a different file)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"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"
|
||||
}
|
||||
@@ -236,13 +239,14 @@
|
||||
"darwin-arm64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "master",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/osx/apktool",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/osx/apktool",
|
||||
"format": "file",
|
||||
"size": 2672,
|
||||
"sha256": "d0c86f0475ed73bfffa6ac39faa69de7aa2dd40ff5e7e6540f82002be3a38f0b",
|
||||
"sha256Source": "measured on 2026-10-13 from the osx wrapper",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"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"
|
||||
}
|
||||
@@ -250,13 +254,14 @@
|
||||
"linux-x64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "master",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
|
||||
"format": "file",
|
||||
"size": 2648,
|
||||
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
|
||||
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"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"
|
||||
}
|
||||
@@ -264,13 +269,14 @@
|
||||
"linux-arm64": [
|
||||
{
|
||||
"id": "wrapper",
|
||||
"version": "master",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool",
|
||||
"version": "3.0.3",
|
||||
"url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/v3.0.3/scripts/linux/apktool",
|
||||
"format": "file",
|
||||
"size": 2648,
|
||||
"sha256": "a851371023c43ca9b9a30218f87f9fd536304283de7b85447183ae811dfd033c",
|
||||
"sha256Source": "measured on 2026-10-13 from the linux wrapper",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"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"
|
||||
}
|
||||
@@ -337,9 +343,9 @@
|
||||
"format": "zip",
|
||||
"size": 16110554,
|
||||
"sha256": "ee39ad5967e95c2a07f04dbcbde96b1a0c916ba376096db5d2f498b7727a5d1d",
|
||||
"sha256Source": "Same darwin archive as x64 (measured 2026-10-13); it serves Intel and Apple silicon.",
|
||||
"sha256Source": "Same darwin archive as x64 (measured 2026-10-13); Google publishes one darwin zip for both architectures.",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "The same darwin archive serves Intel and Apple silicon."
|
||||
"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",
|
||||
@@ -349,7 +355,7 @@
|
||||
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
|
||||
"sha256Source": "Google publishes no checksum for this rolling asset; measured on 2026-10-13 by downloading the archive and hashing it locally (9,054,187 bytes)",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "The same linux archive serves x86-64 and arm64."
|
||||
"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",
|
||||
@@ -357,8 +363,9 @@
|
||||
"format": "zip",
|
||||
"size": 9054187,
|
||||
"sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1",
|
||||
"sha256Source": "Same linux archive as x64 (measured 2026-10-13).",
|
||||
"sha256VerifiedOn": "2026-10-13"
|
||||
"sha256Source": "Google publishes no arm64 platform-tools; this is the same x86_64 linux archive as x64 (measured 2026-10-13), recorded so the mismatch is explicit instead of implied.",
|
||||
"sha256VerifiedOn": "2026-10-13",
|
||||
"note": "Kept so an arm64 host reports a precise failure instead of \"no source\". adb here is x86_64: it runs under qemu/binfmt where the host provides it, and otherwise the probe fails and the report names it."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -370,14 +377,14 @@
|
||||
"unpack": { "moveTo": "rea-agents" },
|
||||
"probe": {
|
||||
"argv": ["{node}", "{vendor}/node_modules/rea-agents/scripts/rea.mjs", "--version"],
|
||||
"expect": { "regex": "^6\\.4\\.0" }
|
||||
"expect": { "regex": "^6\\.5\\.0(\\s|$)" }
|
||||
},
|
||||
"sources": {
|
||||
"npm": {
|
||||
"version": "6.4.0",
|
||||
"version": "6.5.0",
|
||||
"registry": "https://registry.npmjs.org/rea-agents",
|
||||
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1161 entries, 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."
|
||||
"integrity": "npm's own dist.integrity is the authoritative check for this tarball: the registry tarball ships NO node_modules (verified: 1650 entries in 6.5.0, 0 under node_modules), so setup resolves the dependency closure with `npm install --ignore-scripts` instead of unpacking a tarball.",
|
||||
"note": "`just unpacking the published tarball cannot work` is a verified fact, not a caution: the CLI would fail with ERR_MODULE_NOT_FOUND on zod. 6.5.0 is the same engines/bin contract as 6.4.0 (^22.19.0 || ^24.11.0 || >=26.0.0, scripts/rea.mjs) and adds 37 MCP tools (170 -> 207 by rea doctor --json on 2026-10-16; the upstream contract files count 206) plus an optional frida dependency; --ignore-scripts means its install script never runs."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "dsh-plugin-rea",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.6",
|
||||
"description": "One-plugin bundle: provisions Node/REA/JDK/Ghidra for you, then registers the upstream REA MCP server as mcp__rea__* tools. · 一体包:自动准备 Node/REA/JDK/Ghidra,并把上游 REA 的 MCP 服务接入 DeepSeek Harness。",
|
||||
"type": "module",
|
||||
"main": "./index.mjs",
|
||||
"license": "MIT",
|
||||
"author": "czcdr",
|
||||
"homepage": "https://github.com/czcdr/dsh-plugin-rea",
|
||||
@@ -33,6 +34,7 @@
|
||||
},
|
||||
"files": [
|
||||
"cordis.patch.yml",
|
||||
"index.mjs",
|
||||
"bin/",
|
||||
"scripts/",
|
||||
"manifest/",
|
||||
@@ -43,8 +45,7 @@
|
||||
"CHANGELOG.md",
|
||||
"check-updates.ps1",
|
||||
"screenshots.json",
|
||||
"assets/",
|
||||
"examples/"
|
||||
"assets/"
|
||||
],
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
@@ -57,10 +58,12 @@
|
||||
"bundle:status": "node ./bin/rea-shim.mjs --shim-status",
|
||||
"bundle:dry-run": "node ./scripts/setup.mjs --reuse --dry-run",
|
||||
"bundle:audit": "node ./scripts/audit-manifest.mjs",
|
||||
"bundle:test": "node --test ./scripts/setup.test.mjs"
|
||||
"bundle:test": "node --test ./scripts/setup.test.mjs",
|
||||
"bundle:host-compat": "node ./scripts/host-compat.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
|
||||
"node": "^22.19.0 || ^24.11.0 || >=26.0.0",
|
||||
"dsh": ">=0.1.7-rc.2 <0.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
@@ -430,6 +430,104 @@ if (manifest) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- 11. setup and the manifest describe the same set */
|
||||
|
||||
// A component added to the manifest but not to setup.mjs's INSTALL_ORDER was
|
||||
// silently never provisioned, and nothing here said so. The reverse — a name in
|
||||
// INSTALL_ORDER with no manifest entry — is skipped by the loop's
|
||||
// `if (!component) continue`, so it is equally silent.
|
||||
const installOrderMatch = setupText.match(/const INSTALL_ORDER = \[([^\]]*)\]/);
|
||||
if (!installOrderMatch) {
|
||||
add("error", "scripts/setup.mjs", "INSTALL_ORDER was not found, so manifest/setup agreement cannot be checked");
|
||||
} else {
|
||||
const order = [...installOrderMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
||||
const manifestNames = Object.keys(manifest.components ?? {});
|
||||
for (const name of manifestNames) {
|
||||
if (!order.includes(name)) {
|
||||
add("error", "manifest.components", `"${name}" is not in scripts/setup.mjs INSTALL_ORDER, so setup would silently never provision it`);
|
||||
}
|
||||
}
|
||||
for (const name of order) {
|
||||
if (!manifestNames.includes(name)) {
|
||||
add("error", "scripts/setup.mjs", `INSTALL_ORDER names "${name}", which the manifest does not describe`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------- 12. the Node engines rule has one wording too */
|
||||
|
||||
// REA's engines are restated in the manifest pin, package.json and the node
|
||||
// probe regex. Only the pin and package.json can be compared as text, but a
|
||||
// drift between those two would advertise one range and enforce another.
|
||||
const declaredEngines = manifest.pins?.nodeEngines;
|
||||
let packageEngines = null;
|
||||
try {
|
||||
packageEngines = JSON.parse(readFileSync(path.join(HERE, "..", "package.json"), "utf8")).engines?.node ?? null;
|
||||
} catch (error) {
|
||||
add("error", "package.json", `could not be read: ${String(error && error.message)}`);
|
||||
}
|
||||
if (!declaredEngines) {
|
||||
add("error", "manifest.pins.nodeEngines", "is missing, so nothing states REA's engines for the reuse path");
|
||||
} else if (declaredEngines !== packageEngines) {
|
||||
add(
|
||||
"error",
|
||||
"manifest.pins.nodeEngines",
|
||||
`is ${JSON.stringify(declaredEngines)} but package.json engines.node is ${JSON.stringify(packageEngines)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------- 13. a pinned version's probe must not accept a prefix */
|
||||
|
||||
// The concern is a probe that accepts a *newer* release as the pin, so test the
|
||||
// expression against the versions that would mean that. The audit is not a
|
||||
// regex linter: `^6\.4\.0` legitimately rejects 6.4.1 and 6.5.0 — the failure
|
||||
// mode is only real when the next patch or minor still matches.
|
||||
const reaAgents = manifest.components?.reaAgents;
|
||||
const pinnedRea = reaAgents?.sources?.npm?.version;
|
||||
const reaProbe = reaAgents?.probe?.expect?.regex;
|
||||
if (pinnedRea && reaProbe) {
|
||||
let probe = null;
|
||||
try {
|
||||
probe = new RegExp(reaProbe, "m");
|
||||
} catch (error) {
|
||||
add("error", "reaAgents.probe.expect.regex", `is not a valid regular expression: ${String(error && error.message)}`);
|
||||
}
|
||||
if (probe) {
|
||||
if (!probe.test(pinnedRea)) {
|
||||
add("error", "reaAgents.probe.expect.regex", `does not match the pinned version ${pinnedRea}`);
|
||||
}
|
||||
const [major, minor, patch] = pinnedRea.split(".").map((part) => Number(part));
|
||||
for (const [label, next] of [
|
||||
["the next patch", `${major}.${minor}.${patch + 1}`],
|
||||
["the next minor", `${major}.${minor + 1}.0`],
|
||||
]) {
|
||||
if (Number.isFinite(next.split(".")[2]) && probe.test(next)) {
|
||||
add("error", "reaAgents.probe.expect.regex", `accepts ${next} (${label}), so a newer release passes as the pin`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------- 14. a POSIX wrapper must be marked executable */
|
||||
|
||||
// copyFileSync carries the archive's 0644 and nothing else changes a mode, while
|
||||
// REA spawns REA_APKTOOL_COMMAND without a shell: on Linux/macOS an unmarked
|
||||
// wrapper fails with EACCES even though the component probe (`java -jar`) passes.
|
||||
// Only the manifest can say which files need the bit, so require it here.
|
||||
const apktoolWrappers = manifest.components?.apktool?.entries?.byPlatform ?? {};
|
||||
for (const [platform, list] of Object.entries(apktoolWrappers)) {
|
||||
if (platform.startsWith("win32")) continue;
|
||||
for (const entry of list) {
|
||||
if (entry.executable !== true) {
|
||||
add(
|
||||
"error",
|
||||
`apktool.entries.byPlatform.${platform}`,
|
||||
`the ${platform} wrapper is not marked "executable": true, so it lands 0644 and cannot be spawned without a shell`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- summary */
|
||||
|
||||
const pinned = fetchEntries.filter((entry) => entry.entry.sha256 && entry.entry.sha256 !== "unverified").length;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* host-compat — does the range this package declares cover the DSH releases it
|
||||
* claims to support?
|
||||
*
|
||||
* `package.json` carries `engines.dsh`, and that single string is what the
|
||||
* plugin market reads to say "compatible" instead of "unknown" (the market's
|
||||
* `deriveHostCompatibility` treats `engines.dsh` as a host declaration and
|
||||
* checks it against the running host version). A declaration is therefore load
|
||||
* bearing twice over: it decides whether the market HIDES this plugin from users
|
||||
* on a matching host, and it decides whether users on a host we never tested are
|
||||
* told it works.
|
||||
*
|
||||
* The two ways it goes wrong are silent. A typo makes the range unparseable, and
|
||||
* the market then reports `unknown` — a plugin that looks undeclared rather than
|
||||
* mis-declared. A floor that drifted (say `>=0.2.0`) silently drops hosts the
|
||||
* README still promises. Neither shows up as a test failure unless something
|
||||
* compares the STRING to the release list, which is what this file does.
|
||||
*
|
||||
* `HOST_RELEASES` is the dsh release line this package is built against, newest
|
||||
* first, as published on github.com/deepseek-ai/deepseek-harness. It is a
|
||||
* literal list rather than a GitHub query on purpose: a check that needs the
|
||||
* network fails on a plane and passes on a total outage, which is the wrong
|
||||
* shape for a gate. When a release is added upstream, add it here too — that
|
||||
* edit is the reminder to re-check the row against the new host.
|
||||
*
|
||||
* Usage: `node scripts/host-compat.mjs` (exit 0 = every listed release is
|
||||
* covered by the declared range; exit 1 = at least one is not).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const REPO_ROOT = path.join(HERE, '..')
|
||||
|
||||
/**
|
||||
* Every dsh release this bundle is checked against, newest first.
|
||||
*
|
||||
* Taken from the release list of github.com/deepseek-ai/deepseek-harness. The
|
||||
* floor is the oldest release the plugin has been adapted to; everything above
|
||||
* it up to the declared ceiling must keep working, so a release that is missing
|
||||
* here is a release nobody checked.
|
||||
*/
|
||||
export const HOST_RELEASES = [
|
||||
'0.2.1-rc.2',
|
||||
'0.2.1-rc.1',
|
||||
'0.2.1-alpha.5',
|
||||
'0.2.1-alpha.4',
|
||||
'0.2.1-alpha.3',
|
||||
'0.2.1-alpha.2',
|
||||
'0.2.1-alpha.1',
|
||||
'0.2.0-rc.2',
|
||||
'0.2.0-rc.1',
|
||||
'0.1.7-rc.2',
|
||||
]
|
||||
|
||||
/** Hosts that must fall OUTSIDE the declaration, for the other direction. */
|
||||
export const OUTSIDE_HOSTS = [
|
||||
'0.3.0',
|
||||
'0.1.7-rc.1',
|
||||
'0.1.7-alpha.2',
|
||||
]
|
||||
|
||||
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
||||
|
||||
/**
|
||||
* Split a version into comparable parts.
|
||||
* @param {string} version - a plain semver string, no range operators.
|
||||
* @returns {{ parts: number[], pre: (string|number)[] } | null} null when unparseable.
|
||||
*/
|
||||
export function parseVersion(version) {
|
||||
const match = SEMVER.exec(version.trim())
|
||||
if (match === null) return null
|
||||
const pre = match[4] === undefined
|
||||
? []
|
||||
: match[4].split('.').map(part => (/^\d+$/.test(part) ? Number(part) : part))
|
||||
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], pre }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions the way semver does, prerelease included.
|
||||
*
|
||||
* A prerelease sorts BELOW its release (`0.2.0-rc.2 < 0.2.0`), and among
|
||||
* prereleases an identifier that is numeric sorts below one that is not, which
|
||||
* is why `0.2.1-alpha.5 < 0.2.1-rc.1` — the whole reason this file cannot lean
|
||||
* on a numeric comparison.
|
||||
* @returns -1, 0 or 1; NaN-free because unparseable input throws.
|
||||
*/
|
||||
export function compareVersions(a, b) {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
if (left === null) throw new Error(`not a version: ${a}`)
|
||||
if (right === null) throw new Error(`not a version: ${b}`)
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (left.parts[index] !== right.parts[index]) return left.parts[index] < right.parts[index] ? -1 : 1
|
||||
}
|
||||
if (left.pre.length === 0 && right.pre.length === 0) return 0
|
||||
if (left.pre.length === 0) return 1
|
||||
if (right.pre.length === 0) return -1
|
||||
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index += 1) {
|
||||
const l = left.pre[index]
|
||||
const r = right.pre[index]
|
||||
if (l === undefined) return -1
|
||||
if (r === undefined) return 1
|
||||
if (l === r) continue
|
||||
const lNum = typeof l === 'number'
|
||||
const rNum = typeof r === 'number'
|
||||
if (lNum && rNum) return l < r ? -1 : 1
|
||||
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
||||
if (lNum !== rNum) return lNum ? -1 : 1
|
||||
return String(l) < String(r) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one `>=floor <ceiling` declaration (the only shape this package
|
||||
* ships, and the shape the market's `engines.dsh` is compared with).
|
||||
*
|
||||
* Deliberately narrow: a wider parser here could disagree with the market's
|
||||
* semver implementation on a range the manifest would then be trusted for. If a
|
||||
* future declaration needs `||` or `^`, this function has to grow with it.
|
||||
* @param {string} version - host version to test.
|
||||
* @param {string} range - the declared range.
|
||||
* @returns {boolean} whether the host is inside the declaration.
|
||||
*/
|
||||
export function satisfiesDeclaredRange(version, range) {
|
||||
const match = /^>=\s*(\S+)\s+<\s*(\S+)$/.exec(range.trim())
|
||||
if (match === null) throw new Error(`unsupported host range: ${range}`)
|
||||
return compareVersions(version, match[1]) >= 0 && compareVersions(version, match[2]) < 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The host range this repository declares, `engines.dsh`.
|
||||
* @param {string} [root] - repository root, for tests.
|
||||
* @returns {string | null} the declared range, or null when absent.
|
||||
*/
|
||||
export function declaredHostRange(root = REPO_ROOT) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'))
|
||||
const value = pkg?.engines?.dsh
|
||||
return typeof value === 'string' ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every release covered by the declaration, and every version that must not be.
|
||||
* @param {string} range - declared host range.
|
||||
* @returns {{ covered: string[], uncovered: string[], leaked: string[] }} findings.
|
||||
*/
|
||||
export function checkHostCoverage(range) {
|
||||
const covered = []
|
||||
const uncovered = []
|
||||
for (const version of HOST_RELEASES) {
|
||||
if (satisfiesDeclaredRange(version, range)) covered.push(version)
|
||||
else uncovered.push(version)
|
||||
}
|
||||
const leaked = OUTSIDE_HOSTS.filter(version => satisfiesDeclaredRange(version, range))
|
||||
return { covered, uncovered, leaked }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const range = declaredHostRange()
|
||||
if (range === null) {
|
||||
console.error('FAIL: package.json declares no engines.dsh — the market would report this plugin as "unknown"')
|
||||
process.exit(1)
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = checkHostCoverage(range)
|
||||
} catch (error) {
|
||||
console.error(`FAIL: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`engines.dsh = ${range}`)
|
||||
for (const version of HOST_RELEASES) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'ok ' : 'MISS'} ${version}`)
|
||||
}
|
||||
for (const version of OUTSIDE_HOSTS) {
|
||||
const inside = satisfiesDeclaredRange(version, range)
|
||||
console.log(` ${inside ? 'LEAK' : 'ok '} ${version} (must stay outside)`)
|
||||
}
|
||||
if (result.uncovered.length > 0) {
|
||||
console.error(`FAIL: the declaration does not cover ${result.uncovered.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (result.leaked.length > 0) {
|
||||
console.error(`FAIL: the declaration also admits ${result.leaked.join(', ')}, which is not a tested host`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`host range ok: ${HOST_RELEASES.length} releases covered, ${OUTSIDE_HOSTS.length} correctly outside`)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
|
||||
@@ -1,4 +1,4 @@
|
||||
# rea-bundle-bootstrap.ps1 — get from "no Node at all" to a provisioned bundle.
|
||||
# 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
|
||||
@@ -39,8 +39,12 @@ $Root = [System.IO.Path]::GetFullPath($Root)
|
||||
$stack = Join-Path $Root 'stack'
|
||||
$cache = Join-Path $Root 'cache'
|
||||
$logs = Join-Path $Root 'logs'
|
||||
foreach ($dir in @($Root, $stack, $cache, $logs)) {
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
# -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
|
||||
@@ -84,6 +88,32 @@ $nodeFolder = [System.IO.Path]::GetFileNameWithoutExtension($nodeZipName)
|
||||
if ($nodeSha256 -eq 'unverified') { throw "the manifest pins no checksum for $hostKey; refusing to download unverified bytes" }
|
||||
Write-Step "Node source: $hostKey v$nodeVersion"
|
||||
|
||||
$script:NodeEngines = [string]$manifest.pins.nodeEngines
|
||||
if (-not $script:NodeEngines) { throw "the manifest pins no nodeEngines, so Node compatibility cannot be judged" }
|
||||
|
||||
# REA's engines come from the manifest pin, which the rest of provisioning already
|
||||
# treats as the single source of truth. This used to be a second copy of the same
|
||||
# numbers in a comment, and two copies drift: the audit now asserts that the pin
|
||||
# keeps the shape this parser understands.
|
||||
function Test-EnginesMatch([string]$Clean, [string]$Range) {
|
||||
$versions = @()
|
||||
foreach ($part in $Clean.Split('.')) {
|
||||
$n = 0
|
||||
if (-not [int]::TryParse($part, [ref]$n)) { return $false }
|
||||
$versions += $n
|
||||
}
|
||||
if ($versions.Count -lt 2) { return $false }
|
||||
foreach ($clause in ($Range -split '\|\|')) {
|
||||
$c = $clause.Trim()
|
||||
if ($c -match '^\^(\d+)\.(\d+)\.(\d+)$') {
|
||||
if ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2]) { return $true }
|
||||
} elseif ($c -match '^>=(\d+)\.(\d+)\.(\d+)$') {
|
||||
if ($versions[0] -gt [int]$Matches[1] -or ($versions[0] -eq [int]$Matches[1] -and $versions[1] -ge [int]$Matches[2])) { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-NodeUsable([string]$Exe) {
|
||||
if (-not $Exe -or -not (Test-Path $Exe)) { return $null }
|
||||
try {
|
||||
@@ -91,13 +121,10 @@ function Test-NodeUsable([string]$Exe) {
|
||||
} catch { return $null }
|
||||
if (-not $version) { return $null }
|
||||
$clean = $version.TrimStart('v').Trim()
|
||||
$parts = $clean.Split('.')
|
||||
if ($parts.Count -lt 2) { return $null }
|
||||
$major = [int]$parts[0]
|
||||
$minor = [int]$parts[1]
|
||||
# REA's engines: ^22.19.0 || ^24.11.0 || >=26.0.0
|
||||
$ok = ($major -eq 22 -and $minor -ge 19) -or ($major -eq 24 -and $minor -ge 11) -or ($major -ge 26)
|
||||
if (-not $ok) { return $null }
|
||||
# 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 }
|
||||
}
|
||||
|
||||
|
||||
+352
-87
@@ -26,7 +26,7 @@
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFileSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { appendFileSync, chmodSync, closeSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { homedir } from "node:os";
|
||||
@@ -69,6 +69,22 @@ function defaultBundleRoot() {
|
||||
return path.join(dshHome(), "plugins", "rea-bundle");
|
||||
}
|
||||
|
||||
/**
|
||||
* A numeric environment override, or the default.
|
||||
*
|
||||
* `Number("abc")` is NaN, and NaN does not stay local: `REA_BUNDLE_FETCH_RETRIES=abc`
|
||||
* produced "gave up after NaN attempt(s)", and a NaN socket timeout behaves as 0
|
||||
* (immediate expiry) rather than as "no override". Anything that is not a positive
|
||||
* finite number is therefore ignored, and the effective values are logged below so
|
||||
* an override is never invisible.
|
||||
*/
|
||||
function envNumber(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || String(raw).trim() === "") return fallback;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
root: defaultBundleRoot(),
|
||||
@@ -82,9 +98,9 @@ function parseArgs(argv) {
|
||||
// be declared dead quickly when the socket never opens, but a slow-but-alive
|
||||
// transfer must not be killed for taking long overall (measured here:
|
||||
// 0.35–0.54 MiB/s, where 570 MB legitimately takes ~30 minutes).
|
||||
connectTimeoutMs: Number(process.env.REA_BUNDLE_CONNECT_TIMEOUT_MS ?? 15000),
|
||||
idleTimeoutMs: Number(process.env.REA_BUNDLE_IDLE_TIMEOUT_MS ?? 120000),
|
||||
retries: Number(process.env.REA_BUNDLE_FETCH_RETRIES ?? 4),
|
||||
connectTimeoutMs: envNumber("REA_BUNDLE_CONNECT_TIMEOUT_MS", 15000),
|
||||
idleTimeoutMs: envNumber("REA_BUNDLE_IDLE_TIMEOUT_MS", 120000),
|
||||
retries: envNumber("REA_BUNDLE_FETCH_RETRIES", 4),
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
@@ -307,11 +323,20 @@ function openGet(url, { proxy, headers, connectTimeoutMs }) {
|
||||
},
|
||||
(res) => done(res, "direct"),
|
||||
);
|
||||
let connected = false;
|
||||
req.on("socket", (socket) => {
|
||||
socket.setTimeout(connectTimeout);
|
||||
socket.once("connect", () => socket.setTimeout(idleTimeout));
|
||||
socket.once("connect", () => {
|
||||
connected = true;
|
||||
socket.setTimeout(idleTimeout);
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error(`no response within ${idleTimeout}ms`)));
|
||||
// The socket carries the connect deadline until it connects and the idle
|
||||
// deadline afterwards. Reporting the idle number for a connection that never
|
||||
// happened sent the reader after the wrong setting.
|
||||
req.on("timeout", () =>
|
||||
req.destroy(new Error(connected ? `no data within ${idleTimeout}ms` : `no connection within ${connectTimeout}ms`)),
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
return;
|
||||
@@ -387,11 +412,27 @@ async function download(url, destination, expected, label) {
|
||||
rmSync(temporary, { force: true });
|
||||
have = 0;
|
||||
}
|
||||
if (have > 0 && have === expected.size) {
|
||||
log(` ${label}: partial file already complete`);
|
||||
} else {
|
||||
log(` downloading ${label}${have > 0 ? ` (resuming at ${(have / 1048576).toFixed(1)} MiB)` : ""}`);
|
||||
// A `.part` already holding every expected byte is finished work, not a resume
|
||||
// point: a crash between the last write and the rename leaves exactly this
|
||||
// shape. Resuming it sends `Range: bytes=<size>-`, which servers answer with
|
||||
// 416, so four attempts later a complete download was reported failed (and on
|
||||
// Windows the "resume" simply deleted it and fetched 570 MB again). Verify it
|
||||
// and finish the rename instead.
|
||||
if (have > 0 && expected.size && have === expected.size) {
|
||||
const digest = sha256File(temporary);
|
||||
const matches = !expected.sha256 || expected.sha256 === "unverified" || digest === expected.sha256;
|
||||
if (matches) {
|
||||
log(` ${label}: partial file was already complete; verified and kept`);
|
||||
rmSync(destination, { force: true });
|
||||
renameSync(temporary, destination);
|
||||
return { sha256: digest, size: have, tofu: !expected.sha256 || expected.sha256 === "unverified" };
|
||||
}
|
||||
log(` ${label}: partial file is complete but the checksum does not match; starting over`);
|
||||
rmSync(temporary, { force: true });
|
||||
have = 0;
|
||||
}
|
||||
if (have > 0) log(` downloading ${label} (resuming at ${(have / 1048576).toFixed(1)} MiB)`);
|
||||
else log(` downloading ${label}`);
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
@@ -412,29 +453,29 @@ async function download(url, destination, expected, label) {
|
||||
let via = null;
|
||||
let nodeFailure = null;
|
||||
const tryPowerShellFirst = IS_WINDOWS;
|
||||
const first = tryPowerShellFirst
|
||||
? async () => {
|
||||
rmSync(temporary, { force: true });
|
||||
const written = downloadViaPowerShell(url, temporary);
|
||||
log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
|
||||
return "powershell";
|
||||
}
|
||||
: async () => {
|
||||
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
|
||||
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
|
||||
return response.via;
|
||||
};
|
||||
const second = tryPowerShellFirst
|
||||
? async () => {
|
||||
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
|
||||
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
|
||||
return response.via;
|
||||
}
|
||||
: async () => {
|
||||
rmSync(temporary, { force: true });
|
||||
const written = downloadViaPowerShell(url, temporary);
|
||||
return `powershell (${(written / 1048576).toFixed(1)} MiB)`;
|
||||
};
|
||||
const viaPowerShell = async () => {
|
||||
// Resume through the .NET route as well: it is the Windows default, it
|
||||
// honours the system proxy, and the 570 MB Ghidra archive is the transfer
|
||||
// that matters. Deleting the partial file first — what this used to do —
|
||||
// made every retry a full re-download, the opposite of the resumable
|
||||
// behaviour this module promises. downloadViaPowerShell appends only when
|
||||
// the server answers 206.
|
||||
const written = downloadViaPowerShell(url, temporary, have);
|
||||
log(` ${label}: via powershell (${(written / 1048576).toFixed(1)} MiB)`);
|
||||
return "powershell";
|
||||
};
|
||||
const viaNode = async () => {
|
||||
// Both routes write into the same .part. Trusting the `have` captured
|
||||
// before this attempt appended a full 200 response onto the bytes the
|
||||
// failed route had already written, producing a file that could only fail
|
||||
// the size gate on the way out. Read the file's real length instead.
|
||||
have = existsSync(temporary) ? statSync(temporary).size : 0;
|
||||
const response = await openViaBestRoute(url, { connectTimeoutMs, have });
|
||||
await writeBody(response.stream, response.headers, temporary, have, response.status, label);
|
||||
return response.via;
|
||||
};
|
||||
const first = tryPowerShellFirst ? viaPowerShell : viaNode;
|
||||
const second = tryPowerShellFirst ? viaNode : viaPowerShell;
|
||||
|
||||
try {
|
||||
via = await first();
|
||||
@@ -469,7 +510,6 @@ async function download(url, destination, expected, label) {
|
||||
throw new Error(`sha256 mismatch: got ${digest}, expected ${expected.sha256}`);
|
||||
}
|
||||
rmSync(destination, { force: true });
|
||||
const { renameSync } = await import("node:fs");
|
||||
renameSync(temporary, destination);
|
||||
return { sha256: digest, size: produced, tofu: !expected.sha256 || expected.sha256 === "unverified" };
|
||||
} catch (error) {
|
||||
@@ -487,30 +527,46 @@ async function download(url, destination, expected, label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows fallback route: let PowerShell/.NET perform the transfer.
|
||||
* The PowerShell/.NET route: on Windows this is the **primary** route (see
|
||||
* `tryPowerShellFirst` in download()), and everywhere else it exists only as the
|
||||
* last-resort fallback.
|
||||
*
|
||||
* Measured here, this is not redundant with the Node routes. The local proxy
|
||||
* Measured here, it is not redundant with the Node routes. The local proxy
|
||||
* rewrites GitHub's release-asset redirect back to itself (an infinite 301
|
||||
* loop), while a direct Node connection to github.com times out or is reset.
|
||||
* The .NET stack honours the system proxy, follows the redirect chain to
|
||||
* release-assets.githubusercontent.com correctly, and reports the full asset
|
||||
* length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s), so it is a fallback, and
|
||||
* length. It is slower (~0.13 MiB/s vs ~0.5 MiB/s), which is why the routes are
|
||||
* ordered per platform rather than universally, and
|
||||
* the result is verified by the same size + checksum gate as every other route.
|
||||
*/
|
||||
function downloadViaPowerShell(url, destination) {
|
||||
function downloadViaPowerShell(url, destination, have = 0) {
|
||||
const resume = Number.isFinite(Number(have)) && Number(have) > 0;
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
"$ProgressPreference = 'SilentlyContinue'",
|
||||
`$url = '${url.replace(/'/g, "''")}'`,
|
||||
`$out = '${destination.replace(/'/g, "''")}'`,
|
||||
`$have = ${resume ? Math.trunc(Number(have)) : 0}`,
|
||||
"$req = [System.Net.HttpWebRequest]::Create($url)",
|
||||
"$req.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) dsh-plugin-rea-bundle'",
|
||||
"$req.AllowAutoRedirect = $true",
|
||||
// The response stage. The body-copy stage needs its own deadline: with only
|
||||
// Timeout set, a stalled socket inside CopyTo hung forever (spawnSync runs
|
||||
// with no total timeout by design, because a slow-but-alive 570 MB transfer
|
||||
// is legitimate).
|
||||
"$req.Timeout = 60000",
|
||||
"$req.ReadWriteTimeout = 120000",
|
||||
// Resume instead of starting over. A server that ignores the range answers
|
||||
// 200, and the file is then rewritten from the start rather than appended to.
|
||||
"if ($have -gt 0) { $req.AddRange($have) }",
|
||||
"$resp = $req.GetResponse()",
|
||||
"$code = [int]$resp.StatusCode",
|
||||
"$stream = $resp.GetResponseStream()",
|
||||
"$file = [System.IO.File]::Create($out)",
|
||||
"if ($have -gt 0 -and $code -eq 206) { $mode = [System.IO.FileMode]::Append } else { $mode = [System.IO.FileMode]::Create }",
|
||||
"$file = New-Object System.IO.FileStream($out, $mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)",
|
||||
"$stream.CopyTo($file)",
|
||||
"$file.Flush()",
|
||||
"$file.Close()",
|
||||
"$stream.Close()",
|
||||
"$resp.Close()",
|
||||
@@ -609,24 +665,72 @@ async function writeBody(stream, headers, temporary, have, status, label) {
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Extract with the platform's own tools. Returns true on success. */
|
||||
function extract(archive, destination, format) {
|
||||
/** The provisioned JDK's own `jar`, when one is resolved — a guaranteed unzipper. */
|
||||
function jdkJarCommand(context) {
|
||||
const jdk = context?.resolved?.jdk?.path;
|
||||
if (typeof jdk !== "string" || jdk === "") return null;
|
||||
const home = /[\\/]bin[\\/][^\\/]+$/.test(jdk) ? path.dirname(path.dirname(jdk)) : jdk;
|
||||
const candidate = path.join(home, "bin", `jar${EXE}`);
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract with whatever the host actually has.
|
||||
*
|
||||
* Ghidra ships only as a zip on every platform, so "no unzip" was a certain
|
||||
* failure on a minimal Linux image rather than an occasional one. The fallbacks
|
||||
* are `python3 -m zipfile` and — since a JDK is provisioned before Ghidra — that
|
||||
* JDK's own `jar`. Tarballs fall back to python3, whose tarfile reads xz without
|
||||
* the xz binary. A host with none of them is told which tools were tried instead
|
||||
* of getting a bare "extraction failed".
|
||||
*/
|
||||
function extract(archive, destination, format, context) {
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const stdio = options.json ? "ignore" : "inherit";
|
||||
const attempts = [];
|
||||
if (format === "zip") {
|
||||
if (IS_WINDOWS) {
|
||||
const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command",
|
||||
`Expand-Archive -LiteralPath '${archive.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`],
|
||||
{ stdio: options.json ? "ignore" : "inherit" });
|
||||
return result.status === 0;
|
||||
// bsdtar ships with Windows 10+ and unpacks a zip far faster than
|
||||
// Expand-Archive's single-threaded .NET implementation — the 543 MB Ghidra
|
||||
// archive is the case that matters. A failing tar still falls through to
|
||||
// Expand-Archive.
|
||||
attempts.push({ command: "tar", args: ["-xf", archive, "-C", destination] });
|
||||
attempts.push({
|
||||
command: "powershell",
|
||||
args: [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`Expand-Archive -LiteralPath '${archive.replace(/'/g, "''")}' -DestinationPath '${destination.replace(/'/g, "''")}' -Force`,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
attempts.push({ command: "unzip", args: ["-q", "-o", archive, "-d", destination] });
|
||||
attempts.push({ command: "python3", args: ["-m", "zipfile", "-e", archive, destination] });
|
||||
const jar = jdkJarCommand(context);
|
||||
// `jar xf` unpacks into the working directory, hence cwd.
|
||||
if (jar) attempts.push({ command: jar, args: ["xf", archive], cwd: destination });
|
||||
}
|
||||
const result = spawnSync("unzip", ["-q", "-o", archive, "-d", destination], { stdio: options.json ? "ignore" : "inherit" });
|
||||
return result.status === 0;
|
||||
} else if (format === "tar.gz" || format === "tar.xz" || format === "tar") {
|
||||
attempts.push({ command: "tar", args: ["-xf", archive, "-C", destination] });
|
||||
attempts.push({
|
||||
command: "python3",
|
||||
args: ["-c", "import sys,tarfile;tarfile.open(sys.argv[1]).extractall(sys.argv[2])", archive, destination],
|
||||
});
|
||||
} else {
|
||||
throw new Error(`unsupported archive format: ${format}`);
|
||||
}
|
||||
if (format === "tar.gz" || format === "tar.xz") {
|
||||
const result = spawnSync("tar", ["-xf", archive, "-C", destination], { stdio: options.json ? "ignore" : "inherit" });
|
||||
return result.status === 0;
|
||||
|
||||
let anyToolRan = false;
|
||||
for (const attempt of attempts) {
|
||||
const result = spawnSync(attempt.command, attempt.args, { stdio, cwd: attempt.cwd });
|
||||
if (result.status === 0) return true;
|
||||
if (!(result.error && result.error.code === "ENOENT")) anyToolRan = true;
|
||||
}
|
||||
throw new Error(`unsupported archive format: ${format}`);
|
||||
if (!anyToolRan) {
|
||||
log(` no ${format === "zip" ? "zip" : "tar"} extractor available (tried ${attempts.map((attempt) => path.basename(attempt.command)).join(", ")}); install unzip or python3`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- host probing */
|
||||
@@ -704,7 +808,28 @@ function substituteProbe(value, context) {
|
||||
function runProbe(probe, context) {
|
||||
if (probe.file) {
|
||||
const file = substituteProbe(probe.file, context);
|
||||
return { ok: existsSync(file), detail: file };
|
||||
if (!existsSync(file)) return { ok: false, detail: `${file} is missing` };
|
||||
// `expect` used to be ignored for file probes, which made the Ghidra probe's
|
||||
// `expect.contains: "application.version="` dead configuration: any file at
|
||||
// that path passed, including a directory or a stray placeholder. Read the
|
||||
// file — these are small metadata files — and honour the contract. The bound
|
||||
// exists so a probe can never be pointed at a 543 MB tree by accident.
|
||||
const expect = probe.expect ?? {};
|
||||
if (expect.contains || expect.regex) {
|
||||
let text = "";
|
||||
try {
|
||||
text = readFileSync(file, "utf8").slice(0, 65536);
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `${file} could not be read: ${String(error && error.message)}` };
|
||||
}
|
||||
if (expect.contains && !text.includes(expect.contains)) {
|
||||
return { ok: false, detail: `${file} does not contain "${expect.contains}"` };
|
||||
}
|
||||
if (expect.regex && !new RegExp(expect.regex, "m").test(text)) {
|
||||
return { ok: false, detail: `${file} does not match /${expect.regex}/` };
|
||||
}
|
||||
}
|
||||
return { ok: true, detail: file };
|
||||
}
|
||||
if (!probe.argv) return { ok: true, detail: "no probe" };
|
||||
const argv = probe.argv.map((value) => substituteProbe(value, context));
|
||||
@@ -866,6 +991,25 @@ function loadManifest() {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bundle.json, tolerating one that is not valid JSON.
|
||||
*
|
||||
* A half-written file (disk full, a kill mid-write) used to throw straight out of
|
||||
* provisioning — the run died before it could replace the file it could not
|
||||
* parse, and the shim then failed on it forever. Treating it as absent lets the
|
||||
* normal path read what is usable and rewrite the record at the end.
|
||||
*/
|
||||
function readBundleJson(file) {
|
||||
if (!existsSync(file)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
log(` ${path.basename(file)} is not readable JSON (${String(error && error.message)}); ignoring it for this run`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const INSTALL_ORDER = ["node", "jdk", "ghidra", "apktool", "jadxHeadlessMcp", "androidPlatformTools", "reaAgents"];
|
||||
|
||||
/* -------------------------------------------------------------------- stages */
|
||||
@@ -878,8 +1022,26 @@ async function provisionComponent(name, component, context) {
|
||||
// makes the recorded-state check below redundant (and its message wrong).
|
||||
const reused = reuseExisting(name, root);
|
||||
if (reused) {
|
||||
step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
|
||||
return { path: reused.path, reused: true };
|
||||
// A candidate is a claim until the component's own probe agrees. Adoption
|
||||
// tested only that javac / analyzeHeadless / node exists, so a JDK 11 (Ghidra
|
||||
// 12.1 needs 21+) or a Ghidra 11 install was recorded in bundle.json and the
|
||||
// failure surfaced much later, inside REA. Run the same probe a provisioned
|
||||
// copy has to pass, with the candidate planted so the probe's own tokens
|
||||
// ({jvm}, {ghidra}) resolve to it instead of to a vendor path it never had.
|
||||
const planted = context.resolved[name];
|
||||
context.resolved[name] = { path: reused.path };
|
||||
let probe;
|
||||
try {
|
||||
probe = runProbe(component.probe ?? {}, context);
|
||||
} finally {
|
||||
if (planted === undefined) delete context.resolved[name];
|
||||
else context.resolved[name] = planted;
|
||||
}
|
||||
if (probe.ok) {
|
||||
step(name, "skip", `reusing existing ${name} from ${reused.how}: ${reused.path}`);
|
||||
return { path: reused.path, reused: true };
|
||||
}
|
||||
step(name, "plan", `not reusing ${reused.how} ${name} at ${reused.path}: ${probe.detail}`);
|
||||
}
|
||||
|
||||
// 2) already present in the bundle and still verifiable?
|
||||
@@ -889,8 +1051,8 @@ async function provisionComponent(name, component, context) {
|
||||
// deleted afterwards. Only a recorded path that exists AND passes the probe is
|
||||
// trusted; anything else falls through to provisioning again.
|
||||
if (existsSync(path.join(root, "bundle.json"))) {
|
||||
const current = JSON.parse(readFileSync(path.join(root, "bundle.json"), "utf8"));
|
||||
const recorded = current.components?.[name];
|
||||
const current = readBundleJson(path.join(root, "bundle.json"));
|
||||
const recorded = current?.components?.[name];
|
||||
if (recorded && typeof recorded.path === "string" && existsSync(recorded.path)) {
|
||||
const probe = runProbe(component.probe ?? {}, context);
|
||||
if (probe.ok) {
|
||||
@@ -955,8 +1117,19 @@ async function provisionComponent(name, component, context) {
|
||||
}
|
||||
mkdirSync(targetDirectory, { recursive: true });
|
||||
const target = path.join(targetDirectory, entry.saveAs ?? archiveName);
|
||||
const { copyFileSync } = await import("node:fs");
|
||||
copyFileSync(archivePath, target);
|
||||
// A wrapper script has to be executable. copyFileSync lands the 0644 that
|
||||
// the archive carries and nothing else in this file changes a mode, while
|
||||
// REA starts REA_APKTOOL_COMMAND through execFile/no shell — so on POSIX a
|
||||
// 0644 wrapper fails with EACCES. The component's own probe misses it
|
||||
// because it runs `java -jar` and never executes the wrapper.
|
||||
if (entry.executable === true) {
|
||||
try {
|
||||
chmodSync(target, 0o755);
|
||||
} catch {
|
||||
/* a filesystem that cannot carry the bit must not fail the component */
|
||||
}
|
||||
}
|
||||
placed.push({ target, sha256: fetched.sha256, tofu: fetched.tofu });
|
||||
}
|
||||
if (options.dryRun) return { planned: true };
|
||||
@@ -1015,7 +1188,6 @@ async function provisionComponent(name, component, context) {
|
||||
const targetDirectory = path.join(vendor, component.unpack?.moveTo ?? name);
|
||||
mkdirSync(targetDirectory, { recursive: true });
|
||||
const target = path.join(targetDirectory, source.saveAs ?? archiveName);
|
||||
const { copyFileSync } = await import("node:fs");
|
||||
copyFileSync(archivePath, target);
|
||||
const probe = runProbe(component.probe ?? {}, context);
|
||||
step(name, probe.ok ? "ok" : "FAIL", `${target}${probe.ok ? "" : " — " + probe.detail}`);
|
||||
@@ -1026,7 +1198,7 @@ async function provisionComponent(name, component, context) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
const staging = target + ".staging";
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
const unpacked = extract(archivePath, staging, source.format);
|
||||
const unpacked = extract(archivePath, staging, source.format, context);
|
||||
if (!unpacked) {
|
||||
step(name, "FAIL", `extraction failed for ${archiveName}`);
|
||||
return null;
|
||||
@@ -1036,12 +1208,10 @@ async function provisionComponent(name, component, context) {
|
||||
for (let level = 0; level < strip; level += 1) {
|
||||
// Descend into the archive's single root directory. If the level is not a
|
||||
// single directory (some archives are already flat), stop rather than guess.
|
||||
const { readdirSync } = await import("node:fs");
|
||||
const entries = readdirSync(effective).filter((entry) => !entry.startsWith("."));
|
||||
if (entries.length !== 1 || !statSync(path.join(effective, entries[0])).isDirectory()) break;
|
||||
effective = path.join(effective, entries[0]);
|
||||
}
|
||||
const { renameSync } = await import("node:fs");
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
renameSync(effective, target);
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
@@ -1094,6 +1264,15 @@ async function provisionReaAgents(component, context) {
|
||||
return { planned: true, version };
|
||||
}
|
||||
|
||||
// `--offline is a promise, not a hint` — and this is the one step that cannot be
|
||||
// served from the cache: it is an npm install, not a download. It used to run
|
||||
// anyway, so a machine with every archive pre-cached still went to the registry
|
||||
// (or failed there) after the flag had promised it would not.
|
||||
if (options.offline) {
|
||||
step("reaAgents", "FAIL", `--offline and the vendored REA at ${vendor} is not usable; npm install needs the registry (drop --offline or pre-install it)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* A plain pack+unpack cannot work here: the published tarball carries no
|
||||
* node_modules, so the CLI would fail with ERR_MODULE_NOT_FOUND on its own
|
||||
@@ -1149,6 +1328,10 @@ async function provisionReaAgents(component, context) {
|
||||
});
|
||||
if (install.status !== 0) {
|
||||
step("reaAgents", "FAIL", `npm install failed (${install.status}): ${`${install.stderr ?? ""}${install.stdout ?? ""}`.trim().split("\n").slice(-3).join(" | ").slice(0, 300)}`);
|
||||
// Every other component can fall back to the proxy route this script resolves
|
||||
// itself; npm cannot, and this step is the one that fails most often on a
|
||||
// network where the registry needs a mirror.
|
||||
log(" npm reads HTTPS_PROXY/HTTP_PROXY and NPM_CONFIG_REGISTRY from the environment (this script's own proxy routing does not apply here)");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1174,6 +1357,9 @@ log(` root ${options.root}`);
|
||||
log(` cache ${options.cache}`);
|
||||
log(` host ${hostKey()} node ${process.versions.node}`);
|
||||
log(` mode ${options.dryRun ? "dry-run " : ""}${options.reuse ? "reuse " : ""}${options.offline ? "offline" : "online"}`.trimEnd());
|
||||
// Printed so an environment override is visible: a typo used to reach the socket
|
||||
// layer as NaN (and as "gave up after NaN attempt(s)") with nothing to see.
|
||||
log(` limits connect=${options.connectTimeoutMs}ms idle=${options.idleTimeoutMs}ms retries=${options.retries}`);
|
||||
log("");
|
||||
|
||||
const manifest = loadManifest();
|
||||
@@ -1185,17 +1371,72 @@ const context = {
|
||||
};
|
||||
const results = {};
|
||||
|
||||
/**
|
||||
* What a previous run recorded.
|
||||
*
|
||||
* `--only` names a subset of components, so this run's `results` describes only
|
||||
* that subset — and writing bundle.json straight from `results` erased every
|
||||
* other component: the JDK and Ghidra disappeared from `paths`, the shim stopped
|
||||
* exporting JAVA_HOME/GHIDRA_INSTALL_DIR, so `setup.mjs --only androidPlatformTools`
|
||||
* silently disabled native analysis on a fully provisioned machine, and the next
|
||||
* full run had to unpack 750 MB again. Components this run does not touch are
|
||||
* carried over instead.
|
||||
*/
|
||||
const previous = readBundleJson(path.join(options.root, "bundle.json"));
|
||||
|
||||
/** One untouched component's record, kept only while its recorded path exists. */
|
||||
function carriedOver(name) {
|
||||
const record = previous?.components?.[name];
|
||||
if (!record || typeof record !== "object") return null;
|
||||
if (typeof record.path === "string" && record.path !== "") {
|
||||
if (existsSync(record.path)) return record;
|
||||
return { ...record, path: null, unavailable: true, reason: "the recorded path is gone" };
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
for (const name of INSTALL_ORDER) {
|
||||
const component = manifest.components[name];
|
||||
if (!component) continue;
|
||||
if (options.only && !options.only.includes(name)) continue;
|
||||
const missingRequirement = (component.requires ?? []).find((requirement) => !context.resolved[requirement]);
|
||||
if (missingRequirement && !options.only) {
|
||||
step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
|
||||
results[name] = null;
|
||||
continue;
|
||||
if (missingRequirement) {
|
||||
// Under --only the requirement is not being provisioned this run, but the
|
||||
// previous run's record still answers it — and that is the same answer a full
|
||||
// run would use. Skipping the check entirely made `--only apktool` resolve
|
||||
// {jvm} to the vendor/jdk that only a full run creates and report FAIL, on a
|
||||
// machine whose JDK was recorded right next to it.
|
||||
let carried = options.only ? carriedOver(missingRequirement) : null;
|
||||
if (!carried && options.only) {
|
||||
// A requirement can also be answered by the machine instead of the record.
|
||||
// `node` always can: a Node is already running this script, and
|
||||
// provisionReaAgents falls back to exactly that interpreter — without this,
|
||||
// `--only reaAgents` on a fresh root failed on a "missing" node the run is
|
||||
// itself made of. The others are answered when the caller passed --reuse.
|
||||
if (missingRequirement === "node") {
|
||||
carried = { path: process.execPath, reused: true, how: "the interpreter running setup" };
|
||||
} else {
|
||||
const reused = reuseExisting(missingRequirement, options.root);
|
||||
if (reused) carried = { path: reused.path, reused: true, how: reused.how };
|
||||
}
|
||||
}
|
||||
if (!carried || typeof carried.path !== "string" || !existsSync(carried.path)) {
|
||||
step(name, "FAIL", `skipped: required component "${missingRequirement}" is unavailable`);
|
||||
results[name] = null;
|
||||
continue;
|
||||
}
|
||||
context.resolved[missingRequirement] = carried;
|
||||
}
|
||||
let resolvedValue = null;
|
||||
try {
|
||||
resolvedValue = await provisionComponent(name, component, context);
|
||||
} catch (error) {
|
||||
// An unexpected throw used to abort the run before it could write a summary
|
||||
// or record why its lock is still there. Report it as this component's
|
||||
// failure instead, so the exit code and the log stay truthful.
|
||||
step(name, "FAIL", `unexpected error: ${String(error?.message ?? error)}`);
|
||||
resolvedValue = null;
|
||||
}
|
||||
const resolvedValue = await provisionComponent(name, component, context);
|
||||
results[name] = resolvedValue;
|
||||
// A component satisfies another's `requires` when it has a real path — or when
|
||||
// this run only *planned* it. In dry-run every component returns
|
||||
@@ -1208,27 +1449,36 @@ for (const name of INSTALL_ORDER) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Components this run does not touch keep the record the previous run wrote. */
|
||||
const merged = { ...results };
|
||||
for (const name of INSTALL_ORDER) {
|
||||
if (Object.prototype.hasOwnProperty.call(merged, name)) continue;
|
||||
if (!manifest.components[name]) continue;
|
||||
const carried = carriedOver(name);
|
||||
if (carried) merged[name] = carried;
|
||||
}
|
||||
|
||||
/* browser is adopted, never downloaded */
|
||||
const browser = reuseExisting("browser", options.root);
|
||||
if (browser) step("browser", "skip", `adopting ${browser.path}`);
|
||||
|
||||
/* --------------------------------------------------------------- wire + probe */
|
||||
|
||||
const nodePath = typeof results.node?.path === "string" && existsSync(results.node.path)
|
||||
? results.node.path
|
||||
const nodePath = typeof merged.node?.path === "string" && existsSync(merged.node.path)
|
||||
? merged.node.path
|
||||
: process.execPath;
|
||||
|
||||
const paths = {
|
||||
node: nodePath,
|
||||
reaEntry: path.join(options.root, "vendor", "node_modules", "rea-agents", "scripts", "rea.mjs"),
|
||||
ghidra: results.ghidra?.path ?? null,
|
||||
jdk: results.jdk?.path ?? null,
|
||||
adb: results.androidPlatformTools?.path ? path.join(results.androidPlatformTools.path, `adb${EXE}`) : null,
|
||||
apktoolCommand: results.apktool?.path
|
||||
? path.join(results.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
|
||||
ghidra: merged.ghidra?.path ?? null,
|
||||
jdk: merged.jdk?.path ?? null,
|
||||
adb: merged.androidPlatformTools?.path ? path.join(merged.androidPlatformTools.path, `adb${EXE}`) : null,
|
||||
apktoolCommand: merged.apktool?.path
|
||||
? path.join(merged.apktool.path, IS_WINDOWS ? "apktool.bat" : "apktool")
|
||||
: null,
|
||||
jadxJar: results.jadxHeadlessMcp?.path
|
||||
? path.join(results.jadxHeadlessMcp.path, results.jadxHeadlessMcp.jarName ?? "")
|
||||
jadxJar: merged.jadxHeadlessMcp?.path
|
||||
? path.join(merged.jadxHeadlessMcp.path, merged.jadxHeadlessMcp.jarName ?? "")
|
||||
: null,
|
||||
browser: browser?.path ?? null,
|
||||
ilspycmd: process.env.REA_ILSPY_CMD_PATH ?? null,
|
||||
@@ -1242,17 +1492,17 @@ if (!options.dryRun) {
|
||||
plugin: { name: "dsh-plugin-rea", root: sourceRoot() },
|
||||
paths,
|
||||
versions: {
|
||||
reaAgents: results.reaAgents?.version ?? null,
|
||||
ghidra: results.ghidra?.version ?? null,
|
||||
jdk: results.jdk?.version ?? null,
|
||||
apktool: results.apktool?.version ?? null,
|
||||
jadxHeadlessMcp: results.jadxHeadlessMcp?.version ?? null,
|
||||
node: results.node?.version ?? process.versions.node,
|
||||
reaAgents: merged.reaAgents?.version ?? null,
|
||||
ghidra: merged.ghidra?.version ?? null,
|
||||
jdk: merged.jdk?.version ?? null,
|
||||
apktool: merged.apktool?.version ?? null,
|
||||
jadxHeadlessMcp: merged.jadxHeadlessMcp?.version ?? null,
|
||||
node: merged.node?.version ?? process.versions.node,
|
||||
},
|
||||
components: Object.fromEntries(
|
||||
Object.entries(results).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
|
||||
Object.entries(merged).map(([name, value]) => [name, value ?? { path: null, unavailable: true }]),
|
||||
),
|
||||
unverifiedChecksums: Object.entries(results)
|
||||
unverifiedChecksums: Object.entries(merged)
|
||||
.filter(([, value]) => value?.tofu)
|
||||
.map(([name]) => name),
|
||||
};
|
||||
@@ -1417,6 +1667,25 @@ const failures = state.steps.filter((entry) => entry.status === "FAIL");
|
||||
const failed = failures.filter((entry) => !optionalFailure(entry));
|
||||
const optionalUnavailable = failures.filter(optionalFailure);
|
||||
|
||||
/*
|
||||
* Lock bookkeeping is not presentation, and it used to sit inside the
|
||||
* non-`--json` branch: a successful `--json` run therefore left the lock behind,
|
||||
* and the shim then reported "provisioning started in the background" for six
|
||||
* hours on a fully provisioned machine. A failing run keeps the lock on purpose —
|
||||
* so a restart loop cannot re-download forever — but marks it failed, which lets
|
||||
* the shim say what actually happened instead of claiming a run is in flight.
|
||||
*/
|
||||
const lockFile = path.join(options.root, "setup.lock");
|
||||
if (failed.length === 0) {
|
||||
rmSync(lockFile, { force: true });
|
||||
} else {
|
||||
try {
|
||||
writeFileSync(lockFile, JSON.stringify({ state: "failed", pid: process.pid, at: Date.now(), exit: 1, log: logFilePath() }) + "\n");
|
||||
} catch {
|
||||
/* the lock is advisory: a read-only root must not break the report */
|
||||
}
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
@@ -1436,10 +1705,6 @@ if (options.json) {
|
||||
}
|
||||
log("");
|
||||
if (failed.length === 0) {
|
||||
// Release the provisioning lock the shim set, so a later first run on a new
|
||||
// machine (or after a manual wipe) can start again. A failed run keeps the
|
||||
// lock, but the shim treats a lock older than six hours as stale.
|
||||
rmSync(path.join(options.root, "setup.lock"), { force: true });
|
||||
log("restart DSH (or reload the profile) so the MCP row reconnects.");
|
||||
// Point at the mirrored copy, not at this checkout's: the mirror resolves its
|
||||
// bundle from its own location, so this command is right for the root this run
|
||||
|
||||
+833
-5
@@ -11,12 +11,15 @@
|
||||
* Runs with `node --test`. No network, no filesystem writes outside a temp dir.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, 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";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { HOST_RELEASES, OUTSIDE_HOSTS, checkHostCoverage, declaredHostRange, compareVersions, satisfiesDeclaredRange } from "./host-compat.mjs";
|
||||
|
||||
/* ------------------------------------------------- probe token substitution */
|
||||
|
||||
@@ -319,7 +322,17 @@ function runSetupNodeOnly(setupPath, root, env) {
|
||||
});
|
||||
}
|
||||
|
||||
test("--reuse still adopts a compatible node", () => {
|
||||
test("--reuse still adopts a compatible node", (t) => {
|
||||
// The candidate is the host's own interpreter, so this test only means anything
|
||||
// where the host satisfies REA's engines: on a Node 20 runner it fails for a
|
||||
// reason that has nothing to do with the reuse check (the check is doing its
|
||||
// job), while CI's pinned Node 22 never notices.
|
||||
const [major, minor] = process.versions.node.split(".").map(Number);
|
||||
const compatible = (major === 22 && minor >= 19) || (major === 24 && minor >= 11) || major >= 26;
|
||||
if (!compatible) {
|
||||
t.skip(`host Node ${process.versions.node} is outside REA's engines; the candidate would be rejected on purpose`);
|
||||
return;
|
||||
}
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-node-ok-"));
|
||||
try {
|
||||
const run = runSetupNodeOnly(SETUP_SOURCE, root, { REA_BUNDLE_NODE: process.execPath });
|
||||
@@ -395,7 +408,7 @@ test("an unavailable optional component is reported, not fatal", () => {
|
||||
mkdirSync(stub, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(stub, "rea.mjs"),
|
||||
'if (process.argv.includes("--version")) { console.log("6.4.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
|
||||
'if (process.argv.includes("--version")) { console.log("6.5.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
|
||||
);
|
||||
|
||||
const run = spawnSync(process.execPath, [SETUP_SOURCE, "--offline", "--only", "androidPlatformTools", "--root", root], {
|
||||
@@ -416,3 +429,818 @@ test("an unavailable optional component is reported, not fatal", () => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* =========================== this round's regressions =========================
|
||||
*
|
||||
* Each test below pins a defect that was verified in the code before it was
|
||||
* changed. They run setup.mjs itself (never a mirror of it) against a throwaway
|
||||
* plugin tree, so the whole wiring — manifest → download → place → wire — is the
|
||||
* real one. Nothing here reaches the network: a component is either satisfied
|
||||
* from a primed cache, or its download is *supposed* to return before opening a
|
||||
* socket.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const REPO_SHIM = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
|
||||
|
||||
/** A throwaway plugin tree: setup.mjs, the real manifest, and the shim. */
|
||||
function makePluginTree(root, mutateManifest) {
|
||||
const plugin = path.join(root, "plugin");
|
||||
mkdirSync(path.join(plugin, "scripts"), { recursive: true });
|
||||
mkdirSync(path.join(plugin, "manifest"), { recursive: true });
|
||||
mkdirSync(path.join(plugin, "bin"), { recursive: true });
|
||||
copyFileSync(SETUP_SOURCE, path.join(plugin, "scripts", "setup.mjs"));
|
||||
copyFileSync(REPO_SHIM, path.join(plugin, "bin", "rea-shim.mjs"));
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
|
||||
if (mutateManifest) mutateManifest(manifest);
|
||||
writeFileSync(path.join(plugin, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
||||
return path.join(plugin, "scripts", "setup.mjs");
|
||||
}
|
||||
|
||||
/** A `file`-kind component, used to replace a real one in a test manifest. */
|
||||
function testComponent(url, payload, extra = {}) {
|
||||
return {
|
||||
description: "test vehicle",
|
||||
kind: "file",
|
||||
license: "test",
|
||||
requires: extra.requires ?? [],
|
||||
optional: extra.optional ?? false,
|
||||
probe: extra.probe ?? {},
|
||||
entries: {
|
||||
all: [
|
||||
{
|
||||
id: "payload",
|
||||
version: "1",
|
||||
url,
|
||||
format: "file",
|
||||
size: payload.length,
|
||||
sha256: createHash("sha256").update(payload).digest("hex"),
|
||||
moveTo: extra.moveTo ?? "payload",
|
||||
saveAs: extra.saveAs ?? "payload.bin",
|
||||
...(extra.executable ? { executable: true } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A stub vendored CLI, so the probe/shim stages have something to run. */
|
||||
function addReaStub(bundleRoot, version = "6.5.0") {
|
||||
const dir = path.join(bundleRoot, "vendor", "node_modules", "rea-agents", "scripts");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const identity = JSON.stringify({ cli_package_version: version, catalog: { counts: { mcp_tools: 206 } } });
|
||||
writeFileSync(
|
||||
path.join(dir, "rea.mjs"),
|
||||
'if (process.argv.includes("--version")) { console.log(' + JSON.stringify(version) + '); }\n' +
|
||||
"else { console.log(JSON.stringify({ healthy: true, checks: [], identity: " + identity + " })); }\n",
|
||||
);
|
||||
}
|
||||
|
||||
function runToCompletion(command, args, options) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, options);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
child.on("error", (error) => resolve({ status: -1, stdout, stderr: String(error) }));
|
||||
child.on("close", (code) => resolve({ status: code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(predicate, timeoutMs = 8000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
if (predicate()) return true;
|
||||
if (Date.now() > deadline) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- --only keeps what it did not touch -------- */
|
||||
|
||||
test("--only keeps the records of the components it did not touch", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-only-keep-"));
|
||||
try {
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(bundle, { recursive: true });
|
||||
const jdkHome = path.join(root, "jdk-home");
|
||||
mkdirSync(jdkHome, { recursive: true });
|
||||
// A provisioned machine: the JDK and Node are recorded, and only adb is being
|
||||
// topped up. Writing bundle.json from this run's results alone erased the
|
||||
// other two, so the shim stopped exporting JAVA_HOME/GHIDRA_INSTALL_DIR.
|
||||
writeFileSync(
|
||||
path.join(bundle, "bundle.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
schema: 1,
|
||||
paths: { node: process.execPath, jdk: jdkHome, ghidra: null, adb: null },
|
||||
versions: { reaAgents: "6.5.0", node: process.versions.node, jdk: "21" },
|
||||
components: {
|
||||
node: { path: process.execPath, version: process.versions.node },
|
||||
jdk: { path: jdkHome, version: "21" },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
addReaStub(bundle);
|
||||
const setup = makePluginTree(root, (manifest) => {
|
||||
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional: true });
|
||||
});
|
||||
const run = spawnSync(process.execPath, [setup, "--offline", "--only", "androidPlatformTools", "--root", bundle], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
});
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
const written = JSON.parse(readFileSync(path.join(bundle, "bundle.json"), "utf8"));
|
||||
assert.equal(written.components.jdk.path, jdkHome, "the jdk record must survive an --only run");
|
||||
assert.equal(written.paths.jdk, jdkHome, "and so must the path the shim exports as JAVA_HOME");
|
||||
assert.equal(written.components.node.path, process.execPath);
|
||||
assert.equal(written.paths.node, process.execPath);
|
||||
// The component that did run failed (offline), and stays unavailable: the
|
||||
// merge must not invent a path for it.
|
||||
assert.equal(written.paths.adb, null);
|
||||
assert.equal(written.components.androidPlatformTools.unavailable, true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("--only satisfies a requirement from the record instead of skipping the check", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-only-requires-"));
|
||||
try {
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(bundle, { recursive: true });
|
||||
const jdkHome = path.join(root, "jdk-home");
|
||||
mkdirSync(jdkHome, { recursive: true });
|
||||
const setup = makePluginTree(root, () => {});
|
||||
const args = (extra) => [setup, "--reuse", "--dry-run", "--only", "apktool", "--root", bundle, ...extra];
|
||||
// JAVA_HOME is also a way "the machine" answers this requirement, so it is
|
||||
// cleared: the first case needs the requirement to be genuinely unavailable.
|
||||
const env = { ...process.env, JAVA_HOME: "" };
|
||||
|
||||
// No record at all: the requirement is genuinely unavailable, and that must be
|
||||
// reported exactly as a full run reports it.
|
||||
const missing = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
|
||||
assert.match(missing.stdout, /required component "jdk" is unavailable/);
|
||||
|
||||
// With a recorded JDK the requirement is answered by the record, so apktool
|
||||
// plans instead of failing on a vendor/jdk that --only never provisions.
|
||||
writeFileSync(
|
||||
path.join(bundle, "bundle.json"),
|
||||
JSON.stringify({ schema: 1, paths: { jdk: jdkHome }, versions: { jdk: "21" }, components: { jdk: { path: jdkHome, version: "21" } } }, null, 2) + "\n",
|
||||
);
|
||||
const recorded = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
|
||||
assert.equal(recorded.status, 0, `${recorded.stdout}\n${recorded.stderr}`);
|
||||
assert.doesNotMatch(recorded.stdout, /required component "jdk" is unavailable/);
|
||||
assert.match(recorded.stdout, /\[plan\] apktool/);
|
||||
// And `--only reaAgents` on a fresh root must still work: the node it needs is
|
||||
// the interpreter running setup, which is what provisionReaAgents already uses.
|
||||
const fresh = path.join(root, "fresh");
|
||||
const reaOnly = spawnSync(process.execPath, [setup, "--dry-run", "--only", "reaAgents", "--root", fresh], { encoding: "utf8", timeout: 120000, env });
|
||||
assert.equal(reaOnly.status, 0, `${reaOnly.stdout}\n${reaOnly.stderr}`);
|
||||
assert.doesNotMatch(reaOnly.stdout, /required component "node" is unavailable/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------- a complete .part is finished work */
|
||||
|
||||
test("a complete .part is verified and kept instead of re-requested", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-part-complete-"));
|
||||
try {
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
||||
const payload = Buffer.from("the whole file, already on disk\n");
|
||||
// The URL points at a closed port on purpose: the fixed code must complete
|
||||
// without opening a socket. Before the fix it sent `Range: bytes=<size>-`,
|
||||
// which a real server answers with 416, and four attempts later it reported a
|
||||
// download that was already finished as failed.
|
||||
const setup = makePluginTree(root, (manifest) => {
|
||||
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", payload);
|
||||
});
|
||||
addReaStub(bundle);
|
||||
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload);
|
||||
|
||||
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stdout, /was already complete; verified and kept/);
|
||||
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------- the PowerShell route resumes */
|
||||
|
||||
test("the PowerShell route resumes from the bytes already on disk", async (t) => {
|
||||
if (process.platform !== "win32") {
|
||||
// The Node route pins TLS (`httpsRequest`) whatever the URL scheme says, so a
|
||||
// plain-HTTP fixture can only exercise the PowerShell route — which is also the
|
||||
// Windows default and the one the resume defect was in.
|
||||
t.skip("the PowerShell download route only exists on Windows");
|
||||
return;
|
||||
}
|
||||
const payload = Buffer.alloc(1024 * 1024, 11);
|
||||
const alreadyHave = 256 * 1024;
|
||||
const seen = [];
|
||||
const server = createServer((req, res) => {
|
||||
req.on("error", () => {});
|
||||
res.on("error", () => {});
|
||||
const range = req.headers.range ?? null;
|
||||
seen.push(range);
|
||||
const start = range ? Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0) : 0;
|
||||
if (range && start >= payload.length) {
|
||||
res.writeHead(416);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const slice = payload.subarray(start);
|
||||
const headers = { "content-type": "application/octet-stream", "content-length": String(slice.length) };
|
||||
if (start > 0) headers["content-range"] = `bytes ${start}-${payload.length - 1}/${payload.length}`;
|
||||
res.writeHead(start > 0 ? 206 : 200, headers);
|
||||
res.end(slice);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-ps-resume-"));
|
||||
try {
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
||||
const setup = makePluginTree(root, (manifest) => {
|
||||
manifest.components.androidPlatformTools = testComponent(`http://127.0.0.1:${port}/payload.bin`, payload);
|
||||
});
|
||||
addReaStub(bundle);
|
||||
writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload.subarray(0, alreadyHave));
|
||||
|
||||
const run = await runToCompletion(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
});
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
assert.ok(
|
||||
seen.some((header) => header && Number(/bytes=(\d+)-/.exec(header)?.[1]) === alreadyHave),
|
||||
`expected a resume request starting at ${alreadyHave}; saw ${JSON.stringify(seen)}`,
|
||||
);
|
||||
assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------- a wrapper must be executable */
|
||||
|
||||
test("a wrapper marked executable lands with the exec bit on POSIX", (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("POSIX exec bits do not exist on Windows; the flag is honoured but meaningless there");
|
||||
return;
|
||||
}
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-exec-bit-"));
|
||||
try {
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
||||
const marked = Buffer.from("#!/bin/sh\necho wrapper\n");
|
||||
const plain = Buffer.from("not a script\n");
|
||||
const setup = makePluginTree(root, (manifest) => {
|
||||
const component = testComponent("http://127.0.0.1:9/wrapper.sh", marked, { executable: true, saveAs: "wrapper.sh", moveTo: "tool" });
|
||||
component.entries.all.push({
|
||||
id: "plain",
|
||||
version: "1",
|
||||
url: "http://127.0.0.1:9/plain.sh",
|
||||
format: "file",
|
||||
size: plain.length,
|
||||
sha256: createHash("sha256").update(plain).digest("hex"),
|
||||
moveTo: "tool",
|
||||
saveAs: "plain.sh",
|
||||
});
|
||||
manifest.components.androidPlatformTools = component;
|
||||
});
|
||||
addReaStub(bundle);
|
||||
// Prime the cache so no socket is opened and the mode is the only variable.
|
||||
writeFileSync(path.join(bundle, "cache", "wrapper.sh"), marked);
|
||||
writeFileSync(path.join(bundle, "cache", "plain.sh"), plain);
|
||||
|
||||
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
|
||||
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
|
||||
const wrapperMode = statSync(path.join(bundle, "vendor", "tool", "wrapper.sh")).mode;
|
||||
const plainMode = statSync(path.join(bundle, "vendor", "tool", "plain.sh")).mode;
|
||||
assert.notEqual(wrapperMode & 0o111, 0, `the marked wrapper must be executable, mode ${wrapperMode.toString(8)}`);
|
||||
assert.equal(plainMode & 0o111, 0, "an unmarked file must keep the mode the archive gave it");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------- a file probe's expect is honoured */
|
||||
|
||||
test("a file probe honours expect.contains", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-file-probe-"));
|
||||
try {
|
||||
const make = (content) => {
|
||||
const bundle = path.join(root, `bundle-${content.length}`);
|
||||
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
||||
const payload = Buffer.from(content);
|
||||
const component = testComponent("http://127.0.0.1:9/marker.txt", payload, { saveAs: "marker.txt", moveTo: "probe" });
|
||||
component.probe = { file: "{vendor}/probe/marker.txt", expect: { contains: "application.version=" } };
|
||||
const setup = makePluginTree(path.join(root, `tree-${content.length}`), (manifest) => {
|
||||
manifest.components.androidPlatformTools = component;
|
||||
});
|
||||
addReaStub(bundle);
|
||||
writeFileSync(path.join(bundle, "cache", "marker.txt"), payload);
|
||||
return spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
|
||||
};
|
||||
const ok = make("application.version=12.1.4\n");
|
||||
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
|
||||
// The file exists either way; only its content decides. Before the fix the
|
||||
// `expect` on a file probe was dead configuration and this case passed.
|
||||
const wrong = make("this is not a Ghidra properties file\n");
|
||||
assert.equal(wrong.status, 1, `${wrong.stdout}\n${wrong.stderr}`);
|
||||
assert.match(wrong.stdout, /does not contain "application\.version="/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------- a reused engine must pass its own probe */
|
||||
|
||||
test("a reused component must pass the probe a provisioned copy would face", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-reuse-probe-"));
|
||||
try {
|
||||
const install = path.join(root, "ghidra");
|
||||
mkdirSync(path.join(install, "support"), { recursive: true });
|
||||
mkdirSync(path.join(install, "Ghidra"), { recursive: true });
|
||||
writeFileSync(path.join(install, "support", process.platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless"), "");
|
||||
const properties = path.join(install, "Ghidra", "application.properties");
|
||||
const setup = makePluginTree(root, (manifest) => {
|
||||
// The real manifest needs a JDK for ghidra; this test is about the reuse
|
||||
// probe, so the requirement is removed rather than faked.
|
||||
manifest.components.ghidra.requires = [];
|
||||
});
|
||||
const bundle = path.join(root, "bundle");
|
||||
const args = ["--reuse", "--dry-run", "--only", "ghidra", "--root", bundle];
|
||||
const env = { ...process.env, GHIDRA_INSTALL_DIR: install };
|
||||
|
||||
writeFileSync(properties, "application.version=12.1.4\n");
|
||||
const good = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
|
||||
assert.equal(good.status, 0, `${good.stdout}\n${good.stderr}`);
|
||||
assert.match(good.stdout, /reusing existing ghidra from GHIDRA_INSTALL_DIR/);
|
||||
|
||||
// Adoption used to test only that analyzeHeadless exists, so any directory
|
||||
// shaped like a Ghidra install was recorded and the failure surfaced inside REA.
|
||||
writeFileSync(properties, "not a Ghidra properties file\n");
|
||||
const bad = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
|
||||
assert.doesNotMatch(bad.stdout, /reusing existing ghidra/);
|
||||
assert.match(bad.stdout, /not reusing GHIDRA_INSTALL_DIR ghidra/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------- the shim's lock */
|
||||
|
||||
function makeBundleTree(root, { marker, lock }) {
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(path.join(bundle, "bin"), { recursive: true });
|
||||
mkdirSync(path.join(bundle, "scripts"), { recursive: true });
|
||||
mkdirSync(path.join(bundle, "manifest"), { recursive: true });
|
||||
copyFileSync(REPO_SHIM, path.join(bundle, "bin", "rea-shim.mjs"));
|
||||
writeFileSync(path.join(bundle, "scripts", "setup.mjs"), 'import { writeFileSync } from "node:fs";\nwriteFileSync(' + JSON.stringify(marker) + ', "ran");\n');
|
||||
writeFileSync(path.join(bundle, "manifest", "bundle.manifest.json"), "{}\n");
|
||||
if (lock !== undefined) writeFileSync(path.join(bundle, "setup.lock"), lock);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function runShim(bundle, args, extraEnv = {}) {
|
||||
// A fresh home keeps the duplicate-registration check from reading this
|
||||
// machine's real ~/.dsh/cordis.patch.yml and making the suite depend on it.
|
||||
const env = {
|
||||
...process.env,
|
||||
DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
|
||||
REA_BUNDLE_ROOT: bundle,
|
||||
...extraEnv,
|
||||
};
|
||||
delete env.REA_BUNDLE_AUTO_PROVISION;
|
||||
mkdirSync(env.DSH_HOME, { recursive: true });
|
||||
return spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), ...args], { encoding: "utf8", timeout: 60000, env });
|
||||
}
|
||||
|
||||
test("a malformed bundle.json starts a provisioning run instead of failing forever", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-malformed-"));
|
||||
try {
|
||||
const marker = path.join(root, "setup-ran");
|
||||
const bundle = makeBundleTree(root, { marker, lock: undefined });
|
||||
writeFileSync(path.join(bundle, "bundle.json"), "{ this is not json");
|
||||
const run = runShim(bundle, ["mcp"]);
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
assert.match(run.stderr, /provisioning started in the background/);
|
||||
assert.ok(await waitFor(() => existsSync(marker)), "setup was never started for a malformed bundle.json");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed lock is reported as failed, not as provisioning in progress", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-failed-lock-"));
|
||||
try {
|
||||
const marker = path.join(root, "setup-ran");
|
||||
const lock = JSON.stringify({ state: "failed", pid: 1, at: Date.now(), exit: 1 });
|
||||
const bundle = makeBundleTree(root, { marker, lock });
|
||||
// No bundle.json: the "missing" path, which used to print the "provisioning
|
||||
// started" sentence for six hours while nothing was running at all.
|
||||
const run = runShim(bundle, ["mcp"]);
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
assert.match(run.stderr, /the last provisioning attempt failed/);
|
||||
assert.doesNotMatch(run.stderr, /provisioning started in the background/);
|
||||
assert.equal(existsSync(marker), false, "a fresh failed lock must not trigger another run");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a lock whose holder is gone does not block provisioning", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-dead-lock-"));
|
||||
try {
|
||||
const marker = path.join(root, "setup-ran");
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
assert.ok(Number.isInteger(dead.pid), "could not obtain a pid to outlive");
|
||||
const lock = JSON.stringify({ state: "running", pid: dead.pid, at: Date.now() });
|
||||
const bundle = makeBundleTree(root, { marker, lock });
|
||||
const run = runShim(bundle, ["mcp"]);
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
assert.match(run.stderr, /provisioning started in the background/);
|
||||
assert.ok(await waitFor(() => existsSync(marker)), "a dead lock holder must not suppress the run");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an expired lock is cleared instead of wedging provisioning forever", async () => {
|
||||
const stale = Date.now() - 7 * 60 * 60 * 1000;
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||
const cases = {
|
||||
"running lock with a dead holder": JSON.stringify({ state: "running", pid: dead, at: stale }),
|
||||
"failed lock past the freshness window": JSON.stringify({ state: "failed", pid: dead, at: stale, exit: 1 }),
|
||||
"legacy numeric lock": String(stale),
|
||||
};
|
||||
for (const [label, lock] of Object.entries(cases)) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-stale-"));
|
||||
try {
|
||||
const marker = path.join(root, "setup-ran");
|
||||
const bundle = makeBundleTree(root, { marker, lock });
|
||||
const run = runShim(bundle, ["mcp"]);
|
||||
assert.equal(run.status, 78, `${label}: ${run.stderr}`);
|
||||
// Reproduced before the fix: all three cases were silent AND blocked, because
|
||||
// the stale lock was never removed and the exclusive create then failed with
|
||||
// EEXIST forever. A lock older than the freshness window is not evidence of a
|
||||
// run, so it must be dropped and the attempt started again.
|
||||
assert.match(run.stderr, /provisioning started in the background/, label);
|
||||
assert.ok(await waitFor(() => existsSync(marker)), `${label}: setup was never started`);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a read-only bundle root reports unwritable instead of crashing on the stale lock", { skip: process.platform === "win32" ? "POSIX directory permissions only" : false }, () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-readonly-"));
|
||||
try {
|
||||
const stale = Date.now() - 7 * 60 * 60 * 1000;
|
||||
const lock = JSON.stringify({ state: "running", pid: 999999, at: stale });
|
||||
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock });
|
||||
chmodSync(bundle, 0o555);
|
||||
try {
|
||||
const run = runShim(bundle, ["mcp"], { DSH_HOME: path.join(root, "dsh-home") });
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
// Before the fix this was an uncaught EACCES stack trace and exit 1: the
|
||||
// stale lock could not be removed, and nothing caught the throw.
|
||||
assert.match(run.stderr, /provisioning could not be started \(unwritable\)/);
|
||||
assert.doesNotMatch(run.stderr, /EACCES/);
|
||||
} finally {
|
||||
chmodSync(bundle, 0o755);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a second REA registration is reported instead of silently duplicated", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-duplicate-"));
|
||||
try {
|
||||
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
|
||||
const home = path.join(root, "dsh-home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(home, "cordis.patch.yml"),
|
||||
["- insert:", " - id: rea-mcp", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", " transport: stdio", ""].join("\n"),
|
||||
);
|
||||
const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
assert.match(run.stderr, /duplicate REA registration/);
|
||||
assert.match(run.stderr, /id "rea-mcp"/);
|
||||
|
||||
const status = spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), "--shim-status"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle },
|
||||
});
|
||||
assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("this bundle's own row is not reported as a duplicate", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-own-row-"));
|
||||
try {
|
||||
const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
|
||||
const home = path.join(root, "dsh-home");
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(home, "cordis.patch.yml"),
|
||||
["- insert:", " - id: mcp-rea", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", ""].join("\n"),
|
||||
);
|
||||
const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
|
||||
assert.equal(run.status, 78, run.stderr);
|
||||
assert.doesNotMatch(run.stderr, /duplicate REA registration/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------- --offline is a promise, and npm is the one step
|
||||
* that cannot keep it: it is an install, not a download. -------------------- */
|
||||
|
||||
test("--offline refuses the npm step instead of going online anyway", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-offline-rea-"));
|
||||
try {
|
||||
const setup = makePluginTree(root, () => {});
|
||||
const bundle = path.join(root, "bundle");
|
||||
const run = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", bundle], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
});
|
||||
assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
|
||||
assert.match(run.stdout, /--offline and the vendored REA/);
|
||||
|
||||
// The other direction: a usable vendored copy is still accepted offline, so the
|
||||
// flag does not turn into "reaAgents can never be provisioned".
|
||||
const second = path.join(root, "bundle2");
|
||||
mkdirSync(second, { recursive: true });
|
||||
addReaStub(second);
|
||||
const ok = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", second], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
});
|
||||
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
|
||||
assert.match(ok.stdout, /already vendored/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------- numeric overrides must not become NaN ------ */
|
||||
|
||||
test("a non-numeric numeric override falls back to the default", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-env-"));
|
||||
try {
|
||||
const setup = makePluginTree(root, () => {});
|
||||
const run = (env) =>
|
||||
spawnSync(process.execPath, [setup, "--dry-run", "--only", "node", "--root", path.join(root, "bundle")], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
// `Number("abc")` is NaN, and NaN reached the socket layer as 0-ish behaviour;
|
||||
// `REA_BUNDLE_FETCH_RETRIES=abc` once produced "gave up after NaN attempt(s)".
|
||||
const bogus = run({ REA_BUNDLE_FETCH_RETRIES: "abc", REA_BUNDLE_IDLE_TIMEOUT_MS: "abc", REA_BUNDLE_CONNECT_TIMEOUT_MS: "abc" });
|
||||
assert.equal(bogus.status, 0, `${bogus.stdout}\n${bogus.stderr}`);
|
||||
assert.match(bogus.stdout, /retries=4\b/);
|
||||
assert.match(bogus.stdout, /idle=120000ms/);
|
||||
assert.doesNotMatch(bogus.stdout, /NaN/);
|
||||
const explicit = run({ REA_BUNDLE_FETCH_RETRIES: "7" });
|
||||
assert.match(explicit.stdout, /retries=7\b/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------- POSIX extraction fallbacks ----------------- */
|
||||
|
||||
/** An `archive`-kind component, for the extraction paths. */
|
||||
function testArchive(url, payload, extra = {}) {
|
||||
return {
|
||||
description: "test archive",
|
||||
kind: "archive",
|
||||
license: "test",
|
||||
requires: [],
|
||||
optional: false,
|
||||
unpack: { stripRootComponents: extra.strip ?? 0, moveTo: extra.moveTo ?? "arch" },
|
||||
probe: extra.probe ?? {},
|
||||
sources: {
|
||||
all: {
|
||||
version: "1",
|
||||
url,
|
||||
format: extra.format ?? "zip",
|
||||
size: payload.length,
|
||||
sha256: createHash("sha256").update(payload).digest("hex"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a zip still extracts when unzip is absent (POSIX fallbacks)", (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Windows extracts through tar/Expand-Archive; the POSIX fallbacks do not exist there");
|
||||
return;
|
||||
}
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-extract-"));
|
||||
try {
|
||||
// A real zip, built with the very tool the fallback will use.
|
||||
const zipPath = path.join(root, "fixture.zip");
|
||||
const built = spawnSync("python3", [
|
||||
"-c",
|
||||
"import sys,zipfile;z=zipfile.ZipFile(sys.argv[1],'w');z.writestr('inside.txt','hello from inside\\n');z.close()",
|
||||
zipPath,
|
||||
]);
|
||||
if (built.status !== 0) {
|
||||
t.skip("python3 is unavailable, so neither the fixture nor the fallback can run");
|
||||
return;
|
||||
}
|
||||
const payload = readFileSync(zipPath);
|
||||
const python = spawnSync("sh", ["-c", "command -v python3"], { encoding: "utf8" }).stdout.trim();
|
||||
assert.ok(python, "python3 was just used to build the fixture");
|
||||
|
||||
// A PATH that has python3 and nothing else: unzip cannot be found, which is
|
||||
// exactly the bare-server case. Ghidra ships only as a zip, so this used to be
|
||||
// a certain failure rather than an occasional one.
|
||||
const binDir = path.join(root, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
const onlyPython = path.join(binDir, "python3");
|
||||
symlinkSync(python, onlyPython);
|
||||
const setup = makePluginTree(root, (manifest) => {
|
||||
manifest.components.androidPlatformTools = testArchive("http://127.0.0.1:9/fixture.zip", payload);
|
||||
});
|
||||
const cases = [
|
||||
{ label: "python3 fallback", path: binDir, expected: 0 },
|
||||
{ label: "no extractor at all", path: path.join(root, "empty-bin"), expected: 1 },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
mkdirSync(testCase.path, { recursive: true });
|
||||
const bundle = path.join(root, `bundle-${testCase.expected}`);
|
||||
mkdirSync(path.join(bundle, "cache"), { recursive: true });
|
||||
writeFileSync(path.join(bundle, "cache", "fixture.zip"), payload);
|
||||
addReaStub(bundle);
|
||||
const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
env: { ...process.env, PATH: testCase.path },
|
||||
});
|
||||
assert.equal(run.status, testCase.expected, `${testCase.label}: ${run.stdout}\n${run.stderr}`);
|
||||
if (testCase.expected === 0) {
|
||||
assert.equal(readFileSync(path.join(bundle, "vendor", "arch", "inside.txt"), "utf8"), "hello from inside\n");
|
||||
} else {
|
||||
assert.match(run.stdout, /no zip extractor available/);
|
||||
assert.match(run.stdout, /install unzip or python3/);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the fallback vendored-REA path matches where setup installs it", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-shim-fallback-"));
|
||||
try {
|
||||
const bundle = path.join(root, "bundle");
|
||||
// An older bundle.json has paths.node but no paths.reaEntry. The fallback used
|
||||
// to omit `node_modules`, so a working install was reported as gone.
|
||||
mkdirSync(bundle, { recursive: true });
|
||||
addReaStub(bundle);
|
||||
writeFileSync(path.join(bundle, "bundle.json"), JSON.stringify({ schema: 1, paths: { node: process.execPath } }, null, 2) + "\n");
|
||||
const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
|
||||
});
|
||||
const report = JSON.parse(run.stdout);
|
||||
assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
|
||||
assert.match(report.rea.reaEntry, /node_modules/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("--json releases the lock on success and marks it failed on failure", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "rea-lock-json-"));
|
||||
try {
|
||||
const bundle = path.join(root, "bundle");
|
||||
mkdirSync(bundle, { recursive: true });
|
||||
addReaStub(bundle);
|
||||
const component = (optional) =>
|
||||
makePluginTree(path.join(root, optional ? "ok" : "bad"), (manifest) => {
|
||||
manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional });
|
||||
});
|
||||
|
||||
// The lock is what tells the shim a run is in flight. A successful --json run
|
||||
// used to leave it behind (the removal sat in the non---json branch), so a
|
||||
// fully provisioned machine advertised "provisioning started" for six hours.
|
||||
writeFileSync(path.join(bundle, "setup.lock"), JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }));
|
||||
const ok = spawnSync(process.execPath, [component(true), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
});
|
||||
assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
|
||||
assert.equal(existsSync(path.join(bundle, "setup.lock")), false, "a successful --json run must not leave the lock behind");
|
||||
|
||||
// A failing run keeps the lock on purpose, but records why, so the shim can
|
||||
// report a failure instead of pretending a run is still going.
|
||||
const bad = spawnSync(process.execPath, [component(false), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
|
||||
encoding: "utf8",
|
||||
timeout: 120000,
|
||||
});
|
||||
assert.equal(bad.status, 1, `${bad.stdout}\n${bad.stderr}`);
|
||||
const lock = JSON.parse(readFileSync(path.join(bundle, "setup.lock"), "utf8"));
|
||||
assert.equal(lock.state, "failed");
|
||||
assert.equal(lock.exit, 1);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------- the package entry */
|
||||
|
||||
// The market judges an update by asking whether the new build's package.json
|
||||
// points at an entry file that exists (`hasLoadableEntry` -> `entryArtifactExists`
|
||||
// in dshmarket). This package declared neither main nor exports, and the package
|
||||
// it mounts (`@deepseek-ai/dsh-mcp-client`) ships inside the harness build rather
|
||||
// than in node_modules, so every location that test probes came back empty:
|
||||
// updating a healthy 2.0.1 profile to 2.0.4 failed with "updated build has no
|
||||
// loadable entry", and the rollback was then reported unverifiable for the same
|
||||
// reason (measured 2026-10-18). These two tests are that regression.
|
||||
test("package.json declares an entry file that is actually shipped", () => {
|
||||
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
|
||||
assert.equal(typeof pkg.main, "string", "package.json must declare main; without it consumers probe index.js, which does not exist");
|
||||
assert.equal(existsSync(path.join(REPO_ROOT, pkg.main)), true, `${pkg.main} is declared but missing`);
|
||||
// `main` alone is not enough: npm pack ships only what files[] lists, and a
|
||||
// tarball without the entry reproduces the very failure this fixes.
|
||||
const shipped = String(pkg.main).replace(/^\.\//, "");
|
||||
assert.equal(pkg.files.includes(shipped), true, `files[] must ship ${shipped}`);
|
||||
});
|
||||
|
||||
test("the entry loads as a cordis plugin and does nothing", async () => {
|
||||
const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8"));
|
||||
const mod = await import(pathToFileURL(path.join(REPO_ROOT, pkg.main)).href);
|
||||
assert.equal(mod.name, pkg.name, "the loader resolves the plugin by this name");
|
||||
assert.equal(typeof mod.apply, "function", "a cordis plugin needs apply()");
|
||||
// Loading and applying must not register a row, spawn anything or touch state:
|
||||
// registering the MCP client here would add a second `serverName: rea` entry,
|
||||
// which the harness refuses. Returning undefined is the whole contract.
|
||||
assert.equal(mod.apply(), undefined);
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------- declared DSH hosts */
|
||||
|
||||
// The market reads `engines.dsh` to answer "is this plugin compatible with the
|
||||
// host I am running" — and hides the plugin from hosts that mismatch. Two silent
|
||||
// failures follow from a declaration nobody checks: a typo leaves the range
|
||||
// unparseable and the market falls back to "undeclared", while a drifted floor
|
||||
// drops hosts the README still promises. Both look exactly like a plugin that
|
||||
// simply has nothing to say, so the string is pinned against the release list.
|
||||
test("the manifest declares a DSH host range the market can read", () => {
|
||||
const range = declaredHostRange(REPO_ROOT);
|
||||
assert.equal(typeof range, "string", "engines.dsh must be declared; without it the market reports the plugin as unknown");
|
||||
assert.throws(() => satisfiesDeclaredRange("0.2.0-rc.2", "not a range"), "the gate must reject an unparseable range");
|
||||
assert.doesNotThrow(() => satisfiesDeclaredRange("0.2.0-rc.2", range));
|
||||
});
|
||||
|
||||
test("every released DSH host is inside the declared range, and nothing untested is", () => {
|
||||
const range = declaredHostRange(REPO_ROOT);
|
||||
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.equal(covered.length, HOST_RELEASES.length, "every release in the list must be covered");
|
||||
});
|
||||
|
||||
test("the host-range gate rejects a plausible but wrong declaration", () => {
|
||||
// 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. That is the trap this test exists for: the typo looks like it names
|
||||
// the running host.
|
||||
const drifted = checkHostCoverage(">=0.2.0 <0.3.0");
|
||||
assert.deepEqual(drifted.uncovered, ["0.2.0-rc.2", "0.2.0-rc.1", "0.1.7-rc.2"], "a 0.2.0 floor must be seen dropping the 0.2.0 prereleases");
|
||||
// And the ceiling is load bearing: the next minor line is a host nobody audited.
|
||||
assert.equal(satisfiesDeclaredRange("0.3.0", ">=0.1.7-rc.2 <0.3.0"), false);
|
||||
});
|
||||
|
||||
test("prerelease ordering matches semver, not string order", () => {
|
||||
assert.equal(compareVersions("0.2.1-alpha.5", "0.2.1-rc.1"), -1);
|
||||
assert.equal(compareVersions("0.2.1-rc.2", "0.2.1"), -1);
|
||||
assert.equal(compareVersions("0.2.0-rc.2", "0.2.0-rc.1"), 1);
|
||||
// String comparison would sort "alpha.10" before "alpha.5"; semver does not.
|
||||
assert.equal(compareVersions("0.2.1-alpha.10", "0.2.1-alpha.5"), 1);
|
||||
assert.equal(OUTSIDE_HOSTS.length > 0, true);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ function Check($name, [bool]$ok, $detail) {
|
||||
}
|
||||
|
||||
Write-Host "`n=== 1. JS syntax ===" -ForegroundColor Cyan
|
||||
foreach ($f in @('bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs')) {
|
||||
foreach ($f in @('index.mjs','bin\rea-shim.mjs','scripts\setup.mjs','scripts\audit-manifest.mjs','scripts\setup.test.mjs','scripts\host-compat.mjs')) {
|
||||
$out = & node --check $f 2>&1
|
||||
Check $f ($LASTEXITCODE -eq 0) ($out -join ' ')
|
||||
}
|
||||
@@ -73,10 +73,24 @@ Write-Host "`n=== 6. package payload lists what the bundle needs ===" -Foregroun
|
||||
# -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 @('bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
||||
foreach ($entry in @('index.mjs', 'bin/', 'scripts/', 'manifest/', 'cordis.patch.yml')) {
|
||||
Check "files[] includes $entry" (@($pkg.files) -contains $entry) ''
|
||||
}
|
||||
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test')) {
|
||||
# The market's update path asks whether package.json's main/exports points at a
|
||||
# file that exists, and rolls the update back when it does not (dshmarket's
|
||||
# hasLoadableEntry). This package declared neither until 2.0.5, so every update of
|
||||
# a healthy profile failed with "updated build has no loadable entry". Asserting
|
||||
# the pointer resolves keeps that from returning silently.
|
||||
Check 'package.json declares main' (-not [string]::IsNullOrWhiteSpace($pkg.main)) ''
|
||||
Check 'main resolves to a shipped file' ($pkg.main -and (Test-Path (Join-Path $root $pkg.main))) "main=$($pkg.main)"
|
||||
# The market decides "compatible" vs "unknown" from engines.dsh, and hides the
|
||||
# plugin from hosts whose version does not match. An absent or unparseable
|
||||
# declaration therefore reads as "nothing to say" rather than as a defect, which
|
||||
# is why the coverage check runs here as well as in CI.
|
||||
Check 'package.json declares engines.dsh' (-not [string]::IsNullOrWhiteSpace($pkg.engines.dsh)) ''
|
||||
$compat = & node scripts\host-compat.mjs 2>&1
|
||||
Check 'engines.dsh covers every released DSH host' ($LASTEXITCODE -eq 0) (($compat | Select-Object -Last 2) -join ' ')
|
||||
foreach ($script in @('bundle:setup', 'bundle:status', 'bundle:audit', 'bundle:test', 'bundle:host-compat')) {
|
||||
Check "scripts.$script exists" ($pkg.scripts.PSObject.Properties.Name -contains $script) ''
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user