dsh-plugin-rea 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ # dsh-plugin-rea
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dsh-plugin-rea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/dsh-plugin-rea)
4
+
5
+ [English](README.md) · **简体中文**
6
+
7
+ 把上游 **[REA — Reverse Engineer Anything](https://github.com/morluto/rea)** 的 MCP 服务接入
8
+ **DeepSeek Harness**(dsh),让它的工具在会话里以 `mcp__rea__*` 出现。
9
+
10
+ ![dsh-plugin-rea:会话经官方 MCP 客户端行启动本包供给的 shim,再由 shim 拉起 bundle 内置的 REA,最后把 Evidence 带回会话](assets/screenshot-1.png)
11
+
12
+ > **这是 2.x 线,而 `main` 就是它。** 一体包版本:它会自己准备运行时(Node、REA CLI、JDK、Ghidra),
13
+ > 再把那一行接好。原本的纯配置适配器(1.x —— 假设 Node、`rea-agents`、Ghidra 都是你自己装好的)保留在
14
+ > [`v1` 分支](https://github.com/czcdr/dsh-plugin-rea/tree/v1),npm 上仍是 `dsh-plugin-rea@1.0.6`。
15
+ > 两条线的对比见[两条线:1.x 与 2.x](#两条线1x-与-2x)。
16
+
17
+ ## 这是什么
18
+
19
+ 一个**带供给层的 Cordis bundle**。`package.json` 声明 `dsh.bundle.patch`,该 patch 插入一行官方 MCP 客户端
20
+ 插件(`@deepseek-ai/dsh-mcp-client`)。这一行用当前运行的 Node 可执行文件启动 `bin/rea-shim.mjs`,由 shim
21
+ 读取 `scripts/setup.mjs` 写下的本机状态,再拉起它供给的 REA CLI。这里没有任何分析代码,且**发布出去的
22
+ patch 里不含任何机器路径**。
23
+
24
+ > **第三方适配。** 本仓库不属于 REA,不携带 REA 的任何代码,也不由 REA 作者维护;它只组装一行配置,
25
+ > 并从上游官方源拉取上游自己的发布物。
26
+ > 上游:[github.com/morluto/rea](https://github.com/morluto/rea)(MIT)·
27
+ > [中文文档](https://github.com/morluto/rea/blob/main/README_zh.md#可以分析什么) ·
28
+ > [rea.tools](https://rea.tools/) · npm 包 `rea-agents`。
29
+ > REA 自身的问题请提给上游;**本插件**的问题提到
30
+ > [本仓库 issues](https://github.com/czcdr/dsh-plugin-rea/issues)。
31
+
32
+ **它只做三件事**:注册 `mcp__rea__*` 这批工具名;把每台机器各自的引擎环境
33
+ (`GHIDRA_INSTALL_DIR`、`JAVA_HOME`、`REA_ADB_PATH`、`REA_APKTOOL_COMMAND`、`REA_JADX_MCP_JAR`、
34
+ `REA_BROWSER_EXECUTABLE` 等)限定在那一个 MCP 子进程上、而不是整个 harness;以及把这些组件装到盘上。
35
+
36
+ ## 能做什么、不能做什么
37
+
38
+ 分析能力属上游,本节只是复述上游自己的契约,避免预期错位。
39
+
40
+ - **它无法还原完整源码。** 原生二进制上游给的是*伪代码与汇编*,APK 是*反编译方法*,JEB 单元是*伪代码*,
41
+ JavaScript/Electron 是*模块及其关系*。上游的工具契约明确写着伪代码"是反编译器输出,不是原始源码,
42
+ 也不与另一 provider 的输出文本等价"。
43
+ - 实际上拿不回来的:原始标识符名、类型、注释、宏与模板展开、构建脚本与资源。优化(内联、重排)、
44
+ 符号剥离、混淆、加壳、VM 保护只会让输出更差。
45
+ - 每个结果都带**置信度与限制**(`observed` / `derived` / `inferred`,外加一份明确的 limitations 列表)。
46
+ 请当证据读,别当还原出来的源码。
47
+ - 适合:理解行为、定位实现、互操作与兼容重写、安全审阅、迁移参考。要拿真正的源码,走正规渠道 ——
48
+ 上游仓库、厂商授权,或厂商自己的 SDK。
49
+
50
+ **供给解决不了的部分。** 这些是上游的宿主门槛,不是本插件漏配了什么:
51
+
52
+ | 能力 | 门槛 |
53
+ |---|---|
54
+ | Hopper provider | 仅 macOS |
55
+ | IDA provider | 需要你自己的 IDA 与 `REA_IDA_MCP_CONFIG`;REA 从不启动 IDA |
56
+ | 固件 `binwalk` / `unblob` | 仅 Linux |
57
+ | Wakaru JS 还原、pwntools 的 ELF/宕机转储路径 | 仅 Linux x64 |
58
+ | pwndbg / gdb 核心上下文 | 仅 Linux、macOS |
59
+ | 原生 mitmproxy 抓包 | 仅 Linux(`REA_MITMDUMP_COMMAND`);HAR 不需要引擎 |
60
+ | 苹果工具链(Mach-O、plist、资源目录、LLDB) | 仅 macOS |
61
+ | `capture_process_scenario` | 仅 Linux/macOS 的 PTY |
62
+ | apktool(Android 资源解码) | 仅当 `REA_APKTOOL_COMMAND` 指向 Node 能**直接执行**的文件时才可用:上游用 `execFile` 不加 shell 直接起这个命令,而 Windows 上 Node 无法直接执行 `.bat`/`.cmd`(EINVAL)。本包按上游文档接的是官方 `apktool.bat`,所以在 Windows 上它拿不到版本、该能力不可用;JADX 走 jar,不受影响 |
63
+
64
+ ## 两条线:1.x 与 2.x
65
+
66
+ | | **2.x** —— 当前这条,`main` | **1.x** —— [`v1`](https://github.com/czcdr/dsh-plugin-rea/tree/v1) |
67
+ |---|---|---|
68
+ | 是什么 | 适配器**加供给**:自己准备 Node、REA CLI、JDK、Ghidra,再把那一行接好 | 纯配置适配器:一行 MCP 行,指向你自己装好的 `rea` |
69
+ | 前置 | 运行时什么都不用装 —— 缺什么它去取(`--reuse` 则复用机器上已有的 Ghidra/JDK) | Node.js(满足 REA engines)、`npm i -g rea-agents`,原生分析还要 Ghidra 12.1.x |
70
+ | 首次运行 | 先跑一次 `scripts/setup.mjs`(或引导脚本),再重启 dsh | 不用跑任何东西 |
71
+ | 那一行启动什么 | 当前 Node 可执行文件 + `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`(由 shim 读本机状态) | Windows 上 `cmd.exe /c rea mcp`,macOS/Linux 上 `rea mcp` |
72
+ | 机器状态 | `<DSH_HOME>/plugins/rea-bundle/` —— `bundle.json`、`vendor/`、`cache/` | 你自己的 REA 与 Ghidra 安装 |
73
+ | 安装 | `dsh plugin --profile <profile> add dsh-plugin-rea`(npm `latest` ≥ 2.0.0) | `… add dsh-plugin-rea@1.0.6`,或 `github:czcdr/dsh-plugin-rea#v1.0.6` |
74
+
75
+ 两条线发布的是**同一种 patch 形态**(一条 `insert` 行,`@deepseek-ai/dsh-mcp-client`),对外也提供同一套
76
+ `mcp__rea__*` 工具面;区别只在"谁准备运行时"。从 1.x 到 2.x 只要两条命令,见
77
+ [从 1.x 升级](#从-1x-升级)。
78
+
79
+ ## 安装
80
+
81
+ **前置什么都不用装。** 在干净的 Windows x64 机器上:
82
+
83
+ ```
84
+ powershell -ExecutionPolicy Bypass -File scripts\rea-bundle-bootstrap.ps1 -Reuse
85
+ ```
86
+
87
+ `-Reuse` 会复用你已有的 Ghidra/JDK;去掉它则从官方发布下载两者(约 775 MB,逐个校验和验证)。然后为你的
88
+ profile 安装插件:
89
+
90
+ ```
91
+ dsh plugin --profile <profile> add dsh-plugin-rea
92
+ # 或钉住这个版本,而不是跟着 npm latest 走:
93
+ dsh plugin --profile <profile> add github:czcdr/dsh-plugin-rea#v2.0.0
94
+ ```
95
+
96
+ > **Electron 宿主(DeepSeek Harness 桌面版)的 profile 由应用独占。** 那种机器上 `dsh plugin --profile desktop …`
97
+ > 会被 CLI 直接拒绝(`profile "desktop" is managed exclusively by the Electron application`),请改用**应用内的
98
+ > 插件管理器**安装/更新;上面这条 CLI 只适用于你自己用 CLI 建的非 `desktop` profile。
99
+
100
+ 如果机器上已经有 Node,可以跳过引导脚本直接供给:
101
+
102
+ ```
103
+ node scripts/setup.mjs --reuse # 或先 --dry-run 看计划
104
+ ```
105
+
106
+ 反过来**先装插件**也可以,但要分两步:发布的行指向 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs`,
107
+ 而那个文件**只有 setup 会创建**——所以在第一次供给之前,该行会以 `MODULE_NOT_FOUND` 退出,也就没有
108
+ 任何东西能去启动后台供给(harness 本身不受影响:`failOnStartupError: false` 是刻意的)。先手动跑一次
109
+ `node scripts/setup.mjs`;此后 shim 就位,之后每一次「状态缺失」都由它自己发现并在后台补供给。
110
+
111
+ ## 配置 —— 不需要
112
+
113
+ 发布的那一行**不含任何机器路径**:
114
+
115
+ ```yaml
116
+ - insert:
117
+ - id: mcp-rea
118
+ name: '@deepseek-ai/dsh-mcp-client'
119
+ config:
120
+ serverName: rea
121
+ transport: stdio
122
+ command: !!js process.execPath
123
+ args:
124
+ - !!js "(process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : (process.env.USERPROFILE || process.env.HOME || '') + '/.dsh') + '/plugins/rea-bundle/bin/rea-shim.mjs'"
125
+ - mcp
126
+ env:
127
+ ELECTRON_RUN_AS_NODE: '1'
128
+ failOnStartupError: false
129
+ ```
130
+
131
+ 这一行里有四个"承重"细节,而且**都先以 bug 的形式出现过**:
132
+
133
+ - **`!!js` 标量必须加引号。** 裸标量里同时出现 `? ` 和 `: ` 时,它就不是一个标量了:YAML 会把它读成显式键值对,于是 loader 拿到的是一个 **mapping** 而不是表达式节点。用 harness 自己的方言实测(`JSON_SCHEMA` + `app.asar` 里的 `!!js` 类型),不加引号时 `args[0]` 变成 `{"[object Object]": "..."}` —— 一个行根本无法用来启动的对象。加引号消除歧义,内部的 JS 字符串改用单引号。
134
+ - **必须用正斜杠,不能用反斜杠。** YAML 单引号标量会保留 `\` 字面量,接着 JavaScript 会把它吃掉:`'\plugins'` 不是路径而是 BEL 控制字符,`'\rea-bundle'` 变成回车、`'\bin'` 变成退格 —— 路径被静默摧毁。正斜杠在 Windows 与 POSIX 上都成立。
135
+ - **`DSH_HOME` 的兜底是 `<home>/.dsh`,不是 `<home>`。** shim、`setup.mjs`、`bootstrap.ps1` 三者都把未设置的 `DSH_HOME` 解析到 `<home>/.dsh/...`;兜底少这一层,行就会指向供给永远不会写的位置 —— 而在 `DSH_HOME` 恰好被设置的机器上,这个错误完全不可见。
136
+ - **`!!js` 表达式里没有 `path`/`require`**:`ctx` 是 loader 作用域,所以 DSH_HOME 的兜底写成纯字符串拼接。
137
+
138
+ `npm run bundle:audit` 会把前三条钉住:它用「`DSH_HOME` 未设 / 为空 / 已设」三种情形求值这个表达式,并拒绝会被 YAML 读成 mapping 的裸标量。
139
+
140
+ `ELECTRON_RUN_AS_NODE` 只在 harness 本身跑在 Electron 上时才有意义;对纯 Node 宿主无害,而显式写出来胜过依赖它恰好能在子进程环境清洗中存活。
141
+
142
+ `command` 是当前运行的 Node 可执行文件,`args[0]` 由 `DSH_HOME` 在启动时算出,所以同一份 patch 在
143
+ Windows、macOS、Linux 上都成立,且不依赖 PATH 上的 `rea`。所有机器相关的真相都放在
144
+ `<DSH_HOME>/plugins/rea-bundle/bundle.json`,由 setup 写入,可随时查看:
145
+
146
+ ```
147
+ node bin/rea-shim.mjs --shim-status # 解析到的路径、版本、校验状态
148
+ node bin/rea-shim.mjs --shim-print-env # MCP 子进程将看到的环境变量
149
+ ```
150
+
151
+ 这两个命令在**插件目录**(checkout 副本)或 `<DSH_HOME>/plugins/rea-bundle`(镜像副本)里跑都给出同一
152
+ 答案:checkout 副本按与 setup 相同的规则解析供给位置,镜像副本用自身位置(因为 MCP 子进程的环境里
153
+ `DSH_*` 会被剥掉,它不能依赖 `DSH_HOME`)。
154
+
155
+ 设 `REA_BUNDLE_AUTO_PROVISION=0` 可关掉首次运行的后台自动供给。
156
+
157
+ 想在本机把这一行改成别的启动命令(例如用你自己全局装的 `rea`),从 profile 自己的
158
+ `cordis.patch.yml` 按 id 覆盖即可 —— 那个文件是机器本地的,永不发布。一个 profile 里只允许一行使用
159
+ `serverName: rea`。
160
+
161
+ ## 验证
162
+
163
+ 随便发一个 `mcp__rea__*` 调用 —— `binary_session` 会报引擎各项检查
164
+ (`configuration` / `installation` / `headless` / `native_decompiler` / `java`),应当全为 `ok: true`。
165
+ 然后 `open_binary` 一个 PE、`binary_overview`、`procedure_pseudo_code`。
166
+
167
+ 供给本身结束时也会跑两道探针:`rea doctor --json`(如实报告,并点名未满足项),以及**与 MCP 行完全相同的
168
+ shim 启动**。
169
+
170
+ ## 实测环境
171
+
172
+ 作者机器,2026-10-13 / 14:
173
+
174
+ | 组件 | 版本 |
175
+ |---|---|
176
+ | REA(`rea-agents`) | **6.4.0**(170 个工具) |
177
+ | DeepSeek Harness | desktop runtime **0.2.0-rc.2**,内含 dsh CLI 0.1.7-rc.2 |
178
+ | `@deepseek-ai/dsh-mcp-client`(行 provider) | 随 harness 打包 |
179
+ | Ghidra | 12.1.4 |
180
+ | JDK | Temurin 21(供给)/ Microsoft OpenJDK 25.0.4(复用) |
181
+ | Node.js | 24.21.0(REA engines:`^22.19.0 \|\| ^24.11.0 \|\| >=26.0.0`) |
182
+ | 宿主 | Windows 11 x64(build 26200) |
183
+
184
+ 实测跑过:一次干净的 `setup.mjs --reuse` 走到 `setup complete`;一次**不经 PATH 上的 `rea`、完全经由
185
+ shim** 的 MCP `initialize` 握手返回 `serverInfo {name: rea, version: 6.4.0}`;以及**真实桌面宿主下的完整链路** ——
186
+ 宿主自己的 Electron 二进制启动 `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs mcp`,shim 拉起 **bundle 内置**
187
+ 的 REA,随后一次真实工具调用报出 `path_source: environment`,而那个路径只有该机的 `bundle.json` 知道。
188
+
189
+ 本机**没有**端到端跑过、因此不做承诺的:约 775 MB 的 JDK + Ghidra 下载分支(`--dry-run` 有计划,本机用
190
+ `--reuse` 复用了已有安装),以及 macOS/Linux 的首次运行。
191
+
192
+ ## 边界与注意事项
193
+
194
+ - REA 在 6.4.0 有 170 个工具,每个会话的工具列表都会变长。
195
+ - 校验和对得起良心:厂商发布了的就固定并验证;没发布的(jadx-headless-mcp、apktool 包装脚本、
196
+ Android platform-tools、滚动的 `latest` 地址)则**实测一次并记录**,之后若漂移是**报告**而不是默默接受。
197
+ `bundle.json` 的 `unverifiedChecksums` 会列出处于该状态的组件。
198
+ - 某些网络下很慢(这里实测 GitHub release CDN 只有 0.35–0.54 MiB/s,光 Ghidra 就要 25–45 分钟)。
199
+ 下载是**可续传**的:缓存保留并重新校验,被中断的一轮会继续而不是从头再来。
200
+ - **代理**:Node 的全局 `fetch` 不读系统代理,而 .NET/PowerShell 会读。在开着 WinINET 代理的机器上,
201
+ 同一个地址 `Invoke-WebRequest` 通、Node 却 `ECONNRESET`/超时。setup 自己解析代理
202
+ (环境变量优先,Windows 上回退读注册表)并用 CONNECT 隧道,`NO_PROXY` 也照常生效。
203
+ - REA 的部分工具会启动进程、写文件、访问网络 —— DSH 的权限策略照常适用。只分析你有权检视的样本。
204
+ - `scripts/setup.mjs` 为 REA CLI 跑的是带 `--ignore-scripts` 的 `npm install`:官方 tarball 不带
205
+ `node_modules`,必须解析依赖闭包;而供给过程中**刻意跳过包的 lifecycle 脚本**。
206
+
207
+ ## 更新 —— 三件互相独立的事
208
+
209
+ ### 从 1.x 升级
210
+
211
+ 装上 2.x 后,**先跑一次供给**,再重启 dsh:
212
+
213
+ ```
214
+ node scripts/setup.mjs # 或:powershell -File scripts\rea-bundle-bootstrap.ps1 -Reuse
215
+ ```
216
+
217
+ 为什么必须先跑:1.x 的行从 PATH 上启动 `rea mcp`,而 2.x 的行启动
218
+ `<DSH_HOME>/plugins/rea-bundle/bin/rea-shim.mjs` —— 那个文件**只有 `setup.mjs` 会创建**。升级后直接重启,
219
+ 该行会以 `MODULE_NOT_FOUND` 退出:harness 本身照常(`failOnStartupError: false` 是刻意的),但
220
+ `mcp__rea__*` 工具会消失,而且**不会有任何修复提示** —— 会打提示的 shim 本身还不存在。跑完 setup 再启动,
221
+ 工具就回来了。
222
+
223
+ - **本插件**从不自更新。从市场装的就在市场更新;从 `file:` 检出的就覆盖目录重装。
224
+ - **从 `2.0.0-bundle.1` 升级**:那一版镜像了 shim 与 `setup.mjs`,却漏了 manifest,也没有留下插件 checkout
225
+ 的记录,所以它供给出来的 bundle **无法自愈** —— 后台供给会因缺 manifest 而崩溃且完全不可见,而它自己写下的
226
+ lock 又会让重试被抑制六小时。请**从插件目录**重跑一次 `node scripts/setup.mjs`:它会重新镜像三个文件,
227
+ 并写下让后续运行找到真源的记录。`check-updates.ps1` 会报告这个状态。
228
+ - **供给的组件**:重跑 `node scripts/setup.mjs`,它会重新校验盘上的东西,只补缺失或不对的。
229
+ 版本钉在 `manifest/bundle.manifest.json`。
230
+ - **上游 REA / Ghidra**:改清单里的钉子再重跑 setup。REA 对 Ghidra 有版本闸门(目前 12.1.x),
231
+ 所以先更新 REA。
232
+
233
+ `check-updates.ps1`(Windows PowerShell)会一次性报告以上各项的状态。
234
+
235
+ ## 许可证
236
+
237
+ MIT(本适配器)。REA 是 MIT、归其作者;Ghidra 是 Apache-2.0、归 NSA;Temurin 是 GPLv2+CE;Node.js 是
238
+ MIT;apktool、jadx-headless-mcp、Android platform-tools 各自保留自己的许可。
239
+ **本包不再分发任何上游代码或二进制** —— 供给过程是从各组件的官方源逐个拉取。
@@ -11,7 +11,13 @@
11
11
  (用 pwsh 7 编辑则无此限制,但保持 BOM 对两者都安全)。
12
12
  #>
13
13
  [CmdletBinding()]
14
- param([string]$OutDir = (Join-Path $PSScriptRoot '.'))
14
+ param([string]$OutDir)
15
+
16
+ # The documented invocation (`powershell -File .\make-screenshots.ps1`) died right here:
17
+ # "Cannot bind argument to parameter 'Path' because it is an empty string" — $PSScriptRoot
18
+ # is not reliably populated while parameter defaults are bound, so this script had never
19
+ # actually regenerated a screenshot. Resolve the default in the body instead.
20
+ if (-not $OutDir -or $OutDir.Trim() -eq '') { $OutDir = $PSScriptRoot }
15
21
 
16
22
  Add-Type -AssemblyName System.Drawing
17
23
 
@@ -73,7 +79,7 @@ function Save-Png($bmp, $g, $path) { $g.Dispose(); $bmp.Save($path, [System.Draw
73
79
  # ---------------------------------------------------------------- 图 1:插件做了什么
74
80
  $r = New-Canvas; $bmp = $r[0]; $g = $r[1]
75
81
  Draw-Text $g 'dsh-plugin-rea:把 REA 接入 DeepSeek Harness' $fTitle $ink 60 46
76
- Draw-Text $g '第三方适配 · 自身不做逆向分析 · 只插入一条 MCP 客户端记录,并把引擎环境变量限制在该子进程内' $fSub $muted 62 98
82
+ Draw-Text $g '第三方适配 · 自身不做逆向分析 · 自供给 Node/REA/JDK/Ghidra 后自动接好那一行' $fSub $muted 62 98
77
83
 
78
84
  $rowY = 180; $boxH = 158; $boxW = 250
79
85
  Draw-Box $g 60 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
@@ -82,16 +88,16 @@ Draw-Text $g "mcp__rea__* 工具`n(6.4.0 暴露 170+)`n工具随会话注入
82
88
 
83
89
  Draw-Box $g 360 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,239,246,255)) $accent
84
90
  Draw-Text $g '官方 MCP 客户端行' $fBox $accent 380 ($rowY + 18)
85
- Draw-Text $g "@deepseek-ai/dsh-mcp-client`nserverName = rea`ntransport = stdio`ncommand = node" $fBody $muted 380 ($rowY + 50)
91
+ Draw-Text $g "@deepseek-ai/dsh-mcp-client`nserverName = rea / transport = stdio`ncommand = 当前 Node 可执行文件`nargs[0] = <DSH_HOME>/plugins/rea-bundle/`n bin/rea-shim.mjs" $fBody $muted 380 ($rowY + 50)
86
92
  Draw-Text $g '↑ 本插件插入的就是这一行' $fSmall $accent 360 ($rowY + $boxH + 8)
87
93
 
88
94
  Draw-Box $g 660 $rowY $boxW $boxH $([System.Drawing.Color]::White) $line
89
- Draw-Text $g 'rea mcp(本机进程)' $fBox $ink 680 ($rowY + 18)
90
- Draw-Text $g "node …`n \rea-agents\scripts`n \rea.mjs mcp`nenv: GHIDRA_INSTALL_DIR`n JAVA_HOME(仅此处)" $fBody $muted 680 ($rowY + 50)
95
+ Draw-Text $g 'rea-shim(供给后镜像到此)' $fBox $ink 680 ($rowY + 18)
96
+ Draw-Text $g "读 bundle.json 里的本机真相`n导出 GHIDRA_INSTALL_DIR /`n JAVA_HOME / REA_*`n再拉起 bundle 内置的 REA" $fBody $muted 680 ($rowY + 50)
91
97
 
92
98
  Draw-Box $g 960 $rowY $boxW $boxH $([System.Drawing.Color]::FromArgb(255,240,253,244)) $accent2
93
- Draw-Text $g '分析引擎' $fBox $accent2 980 ($rowY + 18)
94
- Draw-Text $g "Ghidra 12.1.4(本机已装)`nHopper / IDA(可选,`n各自需授权)`n静态 JS / .NET 无需引擎" $fBody $muted 980 ($rowY + 50)
99
+ Draw-Text $g '分析引擎(供给或复用)' $fBox $accent2 980 ($rowY + 18)
100
+ Draw-Text $g "Ghidra 12.1.x / JDK 21:`n缺则下载并校验,`n已有则 --reuse 复用`n静态 JS / .NET 无需引擎" $fBody $muted 980 ($rowY + 50)
95
101
 
96
102
  Draw-Arrow $g 310 ($rowY + 40) 358 ($rowY + 40) $muted
97
103
  Draw-Arrow $g 610 ($rowY + 40) 658 ($rowY + 40) $muted
@@ -106,7 +112,7 @@ Draw-Line $g 358 446 150 446 $muted
106
112
  Draw-Arrow $g 150 446 150 342 $muted
107
113
 
108
114
  Draw-Text $g '上游 REA(MIT):github.com/morluto/rea 本插件:github.com/czcdr/dsh-plugin-rea' $fSmall $muted 60 620
109
- Draw-Text $g '实测 REA 6.4.0 + DSH 桌面运行时 0.2.0-rc.2 + Ghidra 12.1.4 + Node 24.21.0(Windows 11 x64,2026-10-12)' $fSmall $muted 60 646
115
+ 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
110
116
  Draw-Text $g 'Ghidra 为 Apache-2.0(NSA);REA 为 MIT。本仓库不分发上游代码。' $fSmall $muted 60 672
111
117
  Save-Png $bmp $g (Join-Path $OutDir 'screenshot-1.png')
112
118
 
Binary file
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rea-shim — the process the DSH MCP row launches.
4
+ *
5
+ * Why this exists: the published `cordis.patch.yml` must carry no machine path,
6
+ * yet REA needs absolute paths for every dependency. The shim is the seam: it
7
+ * reads one machine-local JSON file, exports exactly the variables REA reads,
8
+ * and execs the vendored REA CLI with the arguments the row was given.
9
+ *
10
+ * It downloads nothing, writes nothing, and never touches PATH.
11
+ *
12
+ * Exit codes: 0 child exited normally * child's own status
13
+ * 78 EX_CONFIG bundle not provisioned (or unusable) — the row has
14
+ * `failOnStartupError: false`, so the harness stays up
15
+ * and the message is the operator's remediation.
16
+ */
17
+ import { spawn } from "node:child_process";
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import path from "node:path";
21
+ import process from "node:process";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const EX_CONFIG = 78;
25
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
26
+ const PLUGIN_ROOT = path.resolve(HERE, "..");
27
+
28
+ /** DSH home, by the same rule setup.mjs and the published patch use. */
29
+ function dshHome() {
30
+ const fromEnv = process.env.DSH_HOME;
31
+ if (fromEnv && fromEnv.trim() !== "") return path.resolve(fromEnv);
32
+ return path.join(homedir(), ".dsh");
33
+ }
34
+
35
+ /**
36
+ * Where setup writes the machine-local truth.
37
+ *
38
+ * Two copies of this file exist and they need different answers, so the copy has
39
+ * to be identified before the root can be:
40
+ *
41
+ * * The **mirrored** copy at `<bundle>/bin/rea-shim.mjs` is what the published
42
+ * row launches, and its own location is the bundle root. It must not consult
43
+ * DSH_HOME: the harness strips every `DSH_*` name from an MCP child's
44
+ * environment (case-insensitively, because Windows is), so a custom DSH_HOME
45
+ * would send it to `~/.dsh`, where it would report "not provisioned" for a
46
+ * bundle sitting right next to it — and start a second ~800 MB run there.
47
+ * * The **checkout** copy (`<plugin>/bin/rea-shim.mjs`) is what a human runs, e.g.
48
+ * `npm run bundle:status`, from a shell where DSH_HOME *is* visible. Its own
49
+ * directory is the plugin, not the bundle, so resolving to itself would report
50
+ * "not provisioned" on a fully provisioned machine — and a bare
51
+ * `node bin/rea-shim.mjs mcp` would provision ~800 MB into the checkout.
52
+ *
53
+ * Only the checkout ships `cordis.patch.yml`, so that file is the discriminator.
54
+ * REA_BUNDLE_ROOT stays as an explicit override for tests and unusual layouts.
55
+ */
56
+ function bundleRoot() {
57
+ const override = process.env.REA_BUNDLE_ROOT;
58
+ if (override && override.trim() !== "") return path.resolve(override);
59
+ if (!existsSync(path.join(PLUGIN_ROOT, "cordis.patch.yml"))) return PLUGIN_ROOT;
60
+ return path.join(dshHome(), "plugins", "rea-bundle");
61
+ }
62
+
63
+ /**
64
+ * A directory can provision only when it holds both files a run reads: the script
65
+ * and the manifest it loads at startup. A bundle provisioned before the manifest
66
+ * was mirrored has the script but not the manifest, so recommending that copy
67
+ * would send the operator straight into an ENOENT.
68
+ */
69
+ function usableSetup(dir) {
70
+ if (typeof dir !== "string" || dir.trim() === "") return null;
71
+ const base = path.resolve(dir);
72
+ const script = path.join(base, "scripts", "setup.mjs");
73
+ const manifest = path.join(base, "manifest", "bundle.manifest.json");
74
+ return existsSync(script) && existsSync(manifest) ? script : null;
75
+ }
76
+
77
+ /**
78
+ * Which setup.mjs to run.
79
+ *
80
+ * The mirrored copy is a snapshot: after a plugin upgrade it still holds the
81
+ * previous manifest, so it would provision the previous versions forever. setup
82
+ * writes <bundle>/mirror.json naming the real checkout; prefer that while it is
83
+ * usable, and fall back to the local copy — which is also all a first run has.
84
+ */
85
+ function provisioningScript(root) {
86
+ try {
87
+ const record = JSON.parse(readFileSync(path.join(root, "mirror.json"), "utf8"));
88
+ const recorded = usableSetup(record && record.pluginRoot);
89
+ if (recorded) return recorded;
90
+ } catch {
91
+ /* no record, or not readable: the local copy is the only option */
92
+ }
93
+ return usableSetup(PLUGIN_ROOT);
94
+ }
95
+
96
+ /**
97
+ * On first run there is nothing to launch. Provisioning is a long download, so
98
+ * it is started detached: the harness may kill this row (or the whole session)
99
+ * long before 800 MB arrive, and the downloader is resumable and idempotent, so
100
+ * a killed attempt is simply continued on the next start.
101
+ *
102
+ * Set REA_BUNDLE_AUTO_PROVISION=0 to require an explicit `setup.mjs` run.
103
+ * Returns the log path when a run was started, otherwise null.
104
+ */
105
+ function maybeAutoProvision(root) {
106
+ if (process.env.REA_BUNDLE_AUTO_PROVISION === "0") return null;
107
+ const setupScript = provisioningScript(root);
108
+ if (!setupScript) return null;
109
+ const lock = path.join(root, "setup.lock");
110
+ const logPath = path.join(root, "logs", "setup.log");
111
+ if (existsSync(lock)) {
112
+ // A crashed run must not wedge provisioning forever: honour the lock only
113
+ // while it is fresh; setup removes it itself when it finishes cleanly.
114
+ let age = Infinity;
115
+ try {
116
+ age = Date.now() - Number(readFileSync(lock, "utf8").trim() || 0);
117
+ } catch {
118
+ age = Infinity;
119
+ }
120
+ if (Number.isFinite(age) && age < 6 * 60 * 60 * 1000) return logPath;
121
+ }
122
+ try {
123
+ mkdirSync(path.join(root, "logs"), { recursive: true });
124
+ // Exclusive create: check-then-write is not atomic, so two rows starting at
125
+ // once could both decide to provision. EEXIST means another run owns it.
126
+ writeFileSync(lock, String(Date.now()), { flag: "wx" });
127
+ } catch (error) {
128
+ if (error?.code === "EEXIST") return logPath;
129
+ /* a read-only bundle root must never break the harness */
130
+ return null;
131
+ }
132
+ try {
133
+ const child = spawn(process.execPath, [setupScript, "--root", root], {
134
+ detached: true,
135
+ stdio: "ignore",
136
+ windowsHide: true,
137
+ });
138
+ child.unref();
139
+ return logPath;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function fail(message, detail) {
146
+ const lines = [
147
+ "",
148
+ " dsh-plugin-rea (bundle): REA is not provisioned on this machine.",
149
+ "",
150
+ " " + message,
151
+ ];
152
+ if (detail) lines.push("", " detail: " + detail);
153
+ // Point at the real checkout when the record allows it: running the mirrored
154
+ // copy is fine, but it can only ever provision the manifest it was mirrored
155
+ // with — and a bundle mirrored before the manifest travelled with it has no
156
+ // usable copy at all, so the honest answer is then "run the plugin's own script".
157
+ const setupScript = provisioningScript(bundleRoot());
158
+ lines.push(
159
+ "",
160
+ " Finish setup with:",
161
+ setupScript
162
+ ? " node \"" + setupScript + "\""
163
+ : " node \"<installed plugin directory>/scripts/setup.mjs\"",
164
+ );
165
+ if (!setupScript) {
166
+ lines.push("", " (Run it from the installed plugin directory: the copy in this bundle has no manifest.)");
167
+ }
168
+ lines.push(
169
+ "",
170
+ " Then restart DSH (or reload the profile) so the MCP row reconnects.",
171
+ "",
172
+ " The harness keeps running: this row declares failOnStartupError: false.",
173
+ "",
174
+ );
175
+ process.stderr.write(lines.join("\n"));
176
+ return EX_CONFIG;
177
+ }
178
+
179
+ function readBundleConfig(root) {
180
+ const file = path.join(root, "bundle.json");
181
+ if (!existsSync(file)) return { ok: false, code: "missing", file };
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(readFileSync(file, "utf8"));
185
+ } catch (error) {
186
+ return { ok: false, code: "malformed", file, detail: String(error && error.message) };
187
+ }
188
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
189
+ return { ok: false, code: "malformed", file, detail: "bundle.json is not an object" };
190
+ }
191
+ return { ok: true, file, config: parsed };
192
+ }
193
+
194
+ /** Resolve the vendored REA entry, or report precisely what is missing. */
195
+ function resolveRea(config, root) {
196
+ const paths = config.paths ?? {};
197
+ const node = paths.node;
198
+ const reaEntry = paths.reaEntry
199
+ ?? path.join(root, "vendor", "rea-agents", "scripts", "rea.mjs");
200
+
201
+ if (typeof node !== "string" || node.trim() === "") {
202
+ return { ok: false, reason: "bundle.json has no paths.node" };
203
+ }
204
+ if (!existsSync(node)) {
205
+ return { ok: false, reason: `the recorded Node runtime is gone: ${node}` };
206
+ }
207
+ if (!existsSync(reaEntry)) {
208
+ return { ok: false, reason: `the vendored REA CLI is gone: ${reaEntry}` };
209
+ }
210
+ return { ok: true, node, reaEntry };
211
+ }
212
+
213
+ /**
214
+ * Export only what REA actually reads, and only when the bundle recorded it.
215
+ * An absent variable is better than a wrong one: REA then reports its own
216
+ * availability with its own remediation text.
217
+ */
218
+ function childEnvironment(config) {
219
+ const env = { ...process.env };
220
+ const paths = config.paths ?? {};
221
+
222
+ const set = (name, value) => {
223
+ if (typeof value === "string" && value.trim() !== "") env[name] = value;
224
+ };
225
+
226
+ // Analysis engines.
227
+ set("GHIDRA_INSTALL_DIR", paths.ghidra);
228
+ set("JAVA_HOME", paths.jdk);
229
+ // Optional providers/tools; wired only when the bundle provisioned them.
230
+ set("REA_ADB_PATH", paths.adb);
231
+ set("REA_APKTOOL_COMMAND", paths.apktoolCommand);
232
+ set("REA_JADX_MCP_JAR", paths.jadxJar);
233
+ set("REA_BROWSER_EXECUTABLE", paths.browser);
234
+ set("REA_ILSPY_CMD_PATH", paths.ilspycmd);
235
+
236
+ // Keep the vendored Node first for any grandchild the bridge may spawn.
237
+ if (typeof paths.node === "string" && paths.node.trim() !== "") {
238
+ const nodeDir = path.dirname(paths.node);
239
+ const current = env.PATH ?? "";
240
+ const parts = current.split(path.delimiter).filter((part) => part !== "");
241
+ if (!parts.some((part) => path.resolve(part) === path.resolve(nodeDir))) {
242
+ env.PATH = [nodeDir, ...parts].join(path.delimiter);
243
+ }
244
+ }
245
+ return env;
246
+ }
247
+
248
+ /**
249
+ * Usage text, also what a bare invocation prints.
250
+ *
251
+ * `package.json` exposes this file as the `rea-bundle-status` bin, and npm's bin
252
+ * shim can only forward the arguments a user typed — it cannot append a flag. So a
253
+ * bare `rea-bundle-status`, which is exactly what that name invites, used to fall
254
+ * through to the launch path: it started REA, and on an unprovisioned machine it
255
+ * began an ~800 MB download. Reporting usage instead makes the command's behaviour
256
+ * match its name, and the row is unaffected because it always passes `mcp`.
257
+ */
258
+ function usage() {
259
+ return [
260
+ "rea-shim: launches the vendored REA MCP server for a DSH MCP row.",
261
+ "",
262
+ " node bin/rea-shim.mjs [rea arguments...] (normal use: 'mcp')",
263
+ " node bin/rea-shim.mjs --shim-status provisioning status, no launch",
264
+ " node bin/rea-shim.mjs --shim-print-env the environment it would export",
265
+ "",
266
+ ` bundle root: ${bundleRoot()}`,
267
+ ` plugin root: ${PLUGIN_ROOT}`,
268
+ "",
269
+ ].join("\n");
270
+ }
271
+
272
+ function main() {
273
+ const argv = process.argv.slice(2);
274
+
275
+ if (argv.includes("--shim-help") || argv.length === 0) {
276
+ process.stdout.write(usage());
277
+ return 0;
278
+ }
279
+
280
+ const root = bundleRoot();
281
+ const loaded = readBundleConfig(root);
282
+
283
+ if (argv.includes("--shim-status")) {
284
+ const report = {
285
+ bundleRoot: root,
286
+ bundleFile: loaded.file,
287
+ provisioned: loaded.ok,
288
+ reason: loaded.ok ? undefined : loaded.code,
289
+ autoProvision: process.env.REA_BUNDLE_AUTO_PROVISION ?? "(default)",
290
+ };
291
+ if (loaded.ok) {
292
+ report.paths = loaded.config.paths ?? {};
293
+ report.versions = loaded.config.versions ?? {};
294
+ report.rea = resolveRea(loaded.config, root);
295
+ }
296
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
297
+ return loaded.ok ? 0 : EX_CONFIG;
298
+ }
299
+
300
+ if (!loaded.ok) {
301
+ const message =
302
+ loaded.code === "missing"
303
+ ? `No bundle state at ${loaded.file}.`
304
+ : `Bundle state at ${loaded.file} could not be parsed.`;
305
+ const logPath = loaded.code === "missing" ? maybeAutoProvision(root) : null;
306
+ if (logPath) {
307
+ process.stderr.write(
308
+ `\n dsh-plugin-rea (bundle): first run — provisioning started in the background.\n progress: ${logPath}\n`,
309
+ );
310
+ }
311
+ return fail(message, loaded.detail);
312
+ }
313
+
314
+ const rea = resolveRea(loaded.config, root);
315
+ if (!rea.ok) return fail(rea.reason);
316
+
317
+ const env = childEnvironment(loaded.config);
318
+ if (argv.includes("--shim-print-env")) {
319
+ const interesting = Object.fromEntries(
320
+ Object.entries(env).filter(([key]) =>
321
+ /^(GHIDRA_INSTALL_DIR|JAVA_HOME|REA_[A-Z0-9_]+|PATH)$/.test(key),
322
+ ),
323
+ );
324
+ process.stdout.write(JSON.stringify(interesting, null, 2) + "\n");
325
+ return 0;
326
+ }
327
+
328
+ // Pass through whatever the row supplied; 'mcp' in the normal case.
329
+ const childArgs = [rea.reaEntry, ...(argv.length > 0 ? argv : ["mcp"])];
330
+ const child = spawn(rea.node, childArgs, {
331
+ env,
332
+ stdio: "inherit",
333
+ windowsHide: true,
334
+ });
335
+
336
+ child.on("error", (error) => {
337
+ process.stderr.write(
338
+ `rea-shim: could not start ${rea.node}\n ${String(error && error.message)}\n`,
339
+ );
340
+ process.exit(EX_CONFIG);
341
+ });
342
+ child.on("exit", (code, signal) => {
343
+ if (signal) {
344
+ // Re-raising the signal is not portable: process.kill(pid, "SIGTERM")
345
+ // throws ERR_UNKNOWN_SIGNAL on Windows. Report the conventional 128+n
346
+ // status instead, which the harness reads the same way.
347
+ const signals = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
348
+ process.exit(128 + (signals[signal] ?? 0));
349
+ }
350
+ process.exit(code ?? 0);
351
+ });
352
+ return null;
353
+ }
354
+
355
+ const status = main();
356
+ if (typeof status === "number") process.exit(status);