dsh-plugin-rea 1.0.6 → 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,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);
package/check-updates.ps1 CHANGED
@@ -46,6 +46,10 @@ function Test-NodeEngines([string]$node, [string]$engines) {
46
46
  # 之后 $node.Major 为 $null,所有 ^ / ~ 分支都会判假(曾因此把 Node 24 判成不满足)。
47
47
  $v = Get-NumericVersion $node
48
48
  if (-not $v) { return $null }
49
+ # 只要有一个 term 的形态不认识,整个判断就只能给"无法解析":返回 $false 会把
50
+ # `22.x` / `>=22` 这类合法但未实现的 pattern 误报成"不满足",让用户去升级一个
51
+ # 本来就满足的 Node。
52
+ $recognized = $true
49
53
  foreach ($term in ($engines -split '\|\|')) {
50
54
  $t = $term.Trim()
51
55
  if ($t -match '^\^(\d+\.\d+\.\d+)$') {
@@ -56,8 +60,11 @@ function Test-NodeEngines([string]$node, [string]$engines) {
56
60
  } elseif ($t -match '^~(\d+\.\d+\.\d+)$') {
57
61
  $base = [version]$Matches[1]
58
62
  if ($v.Major -eq $base.Major -and $v.Minor -eq $base.Minor -and $v -ge $base) { return $true }
63
+ } else {
64
+ $recognized = $false
59
65
  }
60
66
  }
67
+ if (-not $recognized) { return $null }
61
68
  return $false
62
69
  }
63
70
 
@@ -216,6 +223,67 @@ if (Test-Path $profPatch) {
216
223
  }
217
224
  }
218
225
 
226
+ # ------------------------------------------------- 一体包(bundle 分支)
227
+ # 设计文档承诺了这个分区:一体包把组件版本钉在 manifest 里,供应结果记在
228
+ # bundle.json 里。两者不一致 = 有一次 pin 更新还没重新供给,或盘上的组件被换过。
229
+ Head '一体包供给状态(bundle 分支)'
230
+ $pluginRoot = $PSScriptRoot
231
+ $manifestPath = Join-Path $pluginRoot 'manifest\bundle.manifest.json'
232
+ $bundleRoot = $env:REA_BUNDLE_ROOT
233
+ if (-not $bundleRoot) {
234
+ $homeDir = $env:DSH_HOME
235
+ if (-not $homeDir) { $homeDir = Join-Path $HOME '.dsh' }
236
+ $bundleRoot = Join-Path $homeDir 'plugins\rea-bundle'
237
+ }
238
+ $bundleJson = Join-Path $bundleRoot 'bundle.json'
239
+
240
+ if (-not (Test-Path $manifestPath)) {
241
+ Info '未找到 manifest/bundle.manifest.json —— 这不是一体包分支,跳过'
242
+ } else {
243
+ Info "bundle 根目录: $bundleRoot"
244
+ try {
245
+ $manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
246
+ if (Test-Path $bundleJson) {
247
+ $state = Get-Content $bundleJson -Raw -Encoding UTF8 | ConvertFrom-Json
248
+ Ok "已供给:$($state.provisionedAt)"
249
+ $drift = 0
250
+ foreach ($name in $manifest.pins.PSObject.Properties.Name) {
251
+ if ($name -eq 'nodeEngines') { continue }
252
+ $pinned = [string]$manifest.pins.$name
253
+ $installed = ''
254
+ if ($state.versions -and ($state.versions.PSObject.Properties.Name -contains $name)) {
255
+ $installed = [string]$state.versions.$name
256
+ }
257
+ if (-not $installed) { continue }
258
+ # Node 的 pin 是引擎范围,不是版本号;其它组件比前缀即可
259
+ $same = $installed.Equals($pinned) -or $installed.StartsWith(($pinned -split '\.')[0] + '.')
260
+ if ($same) { Info " $name = $installed(pin $pinned)" }
261
+ else { Warn "$name 盘上为 $installed,manifest 已钉 $pinned —— 重跑 setup 对齐"; $drift++ }
262
+ }
263
+ if ($drift -eq 0) { Ok '所有组件与 manifest 的 pin 一致' }
264
+ if ($state.unverifiedChecksums -and @($state.unverifiedChecksums).Count -gt 0) {
265
+ Warn ("TOFU(无厂商校验和,取首次实测值): " + (@($state.unverifiedChecksums) -join ', '))
266
+ }
267
+ $mirrored = Join-Path $bundleRoot 'bin\rea-shim.mjs'
268
+ if (Test-Path $mirrored) { Ok 'MCP 行指向的 shim 已就位' }
269
+ else { Bad "缺少 MCP 行指向的 shim: $mirrored —— 重跑 setup --reuse" }
270
+ # 镜像副本的 setup 用它自己所在的位置推 plugin root,所以 manifest 必须
271
+ # 一起镜像。旧版镜像缺这个文件:auto-provision 会以 ENOENT 崩掉且不可见,
272
+ # 而且它已写下的 lock 会让重试被抑制 6 小时。
273
+ $mirroredManifest = Join-Path $bundleRoot 'manifest\bundle.manifest.json'
274
+ if (Test-Path $mirroredManifest) { Ok '镜像副本的 manifest 已就位' }
275
+ else { Bad "镜像副本缺少 manifest: $mirroredManifest —— 请从插件目录重跑一次 node scripts/setup.mjs(旧镜像无法自愈)" }
276
+ } else {
277
+ Warn "尚未供给:$bundleJson 不存在 —— 运行 node scripts/setup.mjs(或直接装插件,行会自行触发)"
278
+ }
279
+ $pins = @()
280
+ foreach ($name in $manifest.pins.PSObject.Properties.Name) {
281
+ if ($name -ne 'nodeEngines') { $pins += "$name=$($manifest.pins.$name)" }
282
+ }
283
+ Info ('manifest 钉子: ' + ($pins -join ', '))
284
+ } catch { Warn "bundle 状态解析失败: $($_.Exception.Message)" }
285
+ }
286
+
219
287
  # ------------------------------------------------------------------ 汇总
220
288
  Head '结论'
221
289
  if ($actions.Count -eq 0) { Ok '无需动作:整条链路与上游保持一致' }
package/cordis.patch.yml CHANGED
@@ -1,33 +1,31 @@
1
- # dsh-plugin-rea — REA (Reverse Engineer Anything) as native DSH tools
1
+ # dsh-plugin-rea — one-plugin bundle
2
2
  #
3
- # Configuration-only bundle: no Host/Client entry files. It inserts one row of
4
- # the official MCP client plugin and points it at the installed `rea mcp`
5
- # stdio server; the model then sees mcp__rea__<tool>.
3
+ # Registers the REA MCP server as mcp__rea__* and launches it through the
4
+ # bundle's own shim, which reads the machine-local state written by
5
+ # `scripts/setup.mjs` and exports exactly the variables REA reads.
6
6
  #
7
- # ---------------------------------------------------------------------------
8
- # PORTABLE BY DEFAULT — this file carries no machine-specific path.
7
+ # PORTABLE BY CONSTRUCTION — no machine path appears in this file.
8
+ # * `command` is the running Node executable (`process.execPath`), so the
9
+ # launcher needs no PATH entry and no shim resolution by the OS.
10
+ # * `args[0]` is computed at boot from DSH_HOME, where setup mirrors the shim.
11
+ # * FORWARD SLASHES ONLY in the JS string literals below. A single-quoted YAML
12
+ # scalar keeps backslashes literal, and JavaScript then eats them: '\plugins'
13
+ # is not "\plugins" but the BEL control character, '\rea-bundle' becomes a
14
+ # carriage return and '\bin' a backspace, so the path is silently destroyed.
15
+ # Forward slashes are accepted on Windows and are correct on POSIX, so one
16
+ # expression serves every platform.
17
+ # * `path`/`require` are not in scope for a `!!js` expression (`ctx` is the
18
+ # loader scope), hence plain string concatenation.
19
+ # * No DSH_HOME (undefined/absent/empty) falls back to the SAME rule the shim
20
+ # and setup.mjs use — `<home>/.dsh` — never to the bare home directory.
21
+ # Getting this wrong is silent: setup would mirror the shim into
22
+ # `<home>/.dsh/plugins/rea-bundle/bin/` while the row looked for it one level
23
+ # up, so the row could never start on a machine that merely happened to have
24
+ # DSH_HOME unset.
9
25
  #
10
- # Prerequisite: `rea` must be reachable from the PATH the harness sees.
11
- # (`npm i -g rea-agents` puts it there.)
12
- #
13
- # Windows : command: cmd.exe args: ['/c', 'rea', 'mcp'] (as below)
14
- # macOS/Linux: command: 'rea' args: ['mcp']
15
- # → see examples/cordis.patch.posix.yml
16
- #
17
- # Analysis engines: REA reads GHIDRA_INSTALL_DIR from its environment.
18
- # - Option 1 (recommended): set it as an OS-level environment variable,
19
- # then start/restart the harness so the MCP child inherits it.
20
- # - Option 2: put an `env:` block in the row —
21
- # see examples/cordis.patch.with-env.yml.
22
- # Without it, native analysis reports the engine as unavailable while every
23
- # other REA capability keeps working. Static JavaScript/Electron and .NET
24
- # analysis need no engine at all.
25
- #
26
- # Only ONE row may use serverName: rea in a profile. To adapt this bundle for
27
- # your machine without touching the upstream file, override `mcp-rea` from
28
- # your profile's own cordis.patch.yml (id-targeted config override) — that
29
- # file is never published.
30
- # ---------------------------------------------------------------------------
26
+ # One row may use serverName: rea in a profile. To pin a different launch
27
+ # command on this machine, override `mcp-rea` from the profile's own
28
+ # cordis.patch.yml (id-targeted config override) — that file is never published.
31
29
 
32
30
  - insert:
33
31
  - id: mcp-rea
@@ -35,9 +33,19 @@
35
33
  config:
36
34
  serverName: rea
37
35
  transport: stdio
38
- command: 'cmd.exe'
36
+ command: !!js process.execPath
39
37
  args:
40
- - '/c'
41
- - 'rea'
42
- - 'mcp'
38
+ # DOUBLE-QUOTED on purpose. A bare scalar containing "? " and ": " is not
39
+ # one scalar at all: YAML reads `? a : b` as an explicit key/value pair,
40
+ # so js-yaml hands the loader a mapping instead of an expression node and
41
+ # args[0] becomes an object the launcher cannot use. Quoting makes the
42
+ # scalar unambiguous; the JS string literals inside use single quotes so
43
+ # they cannot collide with the YAML ones.
44
+ - !!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'"
45
+ - mcp
46
+ env:
47
+ # Only meaningful when the harness itself runs on Electron; harmless
48
+ # for a plain Node host. Explicit is better than relying on this
49
+ # variable happening to survive the child-env scrub.
50
+ ELECTRON_RUN_AS_NODE: '1'
43
51
  failOnStartupError: false