0xray 4.0.26 → 4.0.28
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.
- package/.grok-plugin/plugin.json +1 -1
- package/.opencode/skills/orchestrator/SKILL.md +5 -5
- package/.opencode/skills/storyteller/SKILL.md +1 -1
- package/.xray/features.json +1 -1
- package/AGENTS.md +1 -1
- package/README.md +2 -0
- package/SKILLS.md +1 -1
- package/dist/AGENTS.md +1 -1
- package/dist/CHANGELOG.md +34 -0
- package/dist/README.md +2 -0
- package/dist/SKILLS.md +1 -1
- package/dist/integrations/cursor/README.md +3 -1
- package/dist/integrations/cursor/hooks/cursor-hook-utils.js +77 -7
- package/dist/integrations/cursor/hooks/pre-compact.js +22 -16
- package/dist/integrations/cursor/hooks/xray-cloud-hook.sh +1 -1
- package/dist/integrations/hooks/plates.cjs +7 -0
- package/dist/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
- package/dist/memory-routing/plates.d.ts +1 -1
- package/dist/memory-routing/plates.js +1 -0
- package/dist/skills/orchestrator/SKILL.md +5 -5
- package/dist/skills/storyteller/SKILL.md +1 -1
- package/docs-site/docs/plates/house.md +50 -0
- package/docs-site/docs/plates/index.md +1 -1
- package/llms.txt +6 -1
- package/package.json +3 -2
- package/scripts/foundry/inspect.mjs +47 -2
- package/scripts/node/bridge-mcp-wiring.cjs +320 -29
- package/scripts/node/consumer-install-smoke.mjs +37 -3
- package/scripts/node/install-bridges.cjs +34 -19
- package/scripts/node/mcp-launch.cjs +70 -0
- package/scripts/node/postinstall.cjs +42 -13
- package/src/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
- package/src/skills/orchestrator/SKILL.md +5 -5
- package/src/skills/storyteller/SKILL.md +1 -1
- package/xray/features.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Mill inspect organ. Runs the
|
|
3
|
+
* Mill inspect organ. Runs the plant checks, a machinePlugin check, and observational harness.
|
|
4
4
|
* Not an 8th MCP. Not PPE. Does not fasten hooks or Repertoire.
|
|
5
5
|
*
|
|
6
6
|
* npx @0xray/foundry inspect [--skip-live] [--go] [--go-out PATH]
|
|
@@ -515,7 +515,7 @@ export function packagesToProbe(root) {
|
|
|
515
515
|
});
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
-
function checkIsolatedHome(root, env = process.env, machine = mint.machineHome()) {
|
|
518
|
+
export function checkIsolatedHome(root, env = process.env, machine = mint.machineHome()) {
|
|
519
519
|
const isolated = isIsolatedHome(env, machine);
|
|
520
520
|
const machinePlugin = machineGrokPluginDir(machine);
|
|
521
521
|
const dests = mint.resolveGrokPluginDests(root, env, machine);
|
|
@@ -542,6 +542,50 @@ function checkIsolatedHome(root, env = process.env, machine = mint.machineHome()
|
|
|
542
542
|
};
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
function isXrayPluginName(name) {
|
|
546
|
+
return name === "0xray" || name.startsWith("0xray");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 0xray plugin dirs under the machine HOME's grok plugin folder. */
|
|
550
|
+
export function machineXrayPluginPaths(machine = mint.machineHome()) {
|
|
551
|
+
const pluginsDir = path.join(machine, ".grok", "plugins");
|
|
552
|
+
if (!fs.existsSync(pluginsDir)) return [];
|
|
553
|
+
let names;
|
|
554
|
+
try {
|
|
555
|
+
names = fs.readdirSync(pluginsDir);
|
|
556
|
+
} catch {
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
return names
|
|
560
|
+
.filter((name) => isXrayPluginName(name))
|
|
561
|
+
.sort()
|
|
562
|
+
.map((name) => path.join(pluginsDir, name));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* `isolated` stays HOME != machine home. This check is whether a machine-level
|
|
567
|
+
* 0xray Grok plugin exists. Overall inspect ok is false when this check fails.
|
|
568
|
+
*/
|
|
569
|
+
export function checkMachinePlugin(machine = mint.machineHome()) {
|
|
570
|
+
const machinePlugin = machineGrokPluginDir(machine);
|
|
571
|
+
const hits = machineXrayPluginPaths(machine);
|
|
572
|
+
if (hits.length > 0) {
|
|
573
|
+
return {
|
|
574
|
+
id: "machinePlugin",
|
|
575
|
+
ok: false,
|
|
576
|
+
machinePlugin,
|
|
577
|
+
hits,
|
|
578
|
+
detail: `machine-level ${hits.join(", ")} exists — delete it`,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
id: "machinePlugin",
|
|
583
|
+
ok: true,
|
|
584
|
+
machinePlugin,
|
|
585
|
+
hits: [],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
545
589
|
export async function inspectSuit(root, opts = {}) {
|
|
546
590
|
const millRoot = opts.millRoot || millPackageDir();
|
|
547
591
|
const fetchFn = opts.fetch || fetch;
|
|
@@ -584,6 +628,7 @@ export async function inspectSuit(root, opts = {}) {
|
|
|
584
628
|
}
|
|
585
629
|
|
|
586
630
|
checks.push(checkIsolatedHome(root, env, machineHome));
|
|
631
|
+
checks.push(checkMachinePlugin(machineHome));
|
|
587
632
|
checks.push(checkHarness(root, requireHarness));
|
|
588
633
|
|
|
589
634
|
const failed = checks.filter((c) => c.ok === false);
|
|
@@ -144,20 +144,25 @@ function detectConsumerExtraMcpServers(targetDir) {
|
|
|
144
144
|
try {
|
|
145
145
|
const repertoireMcp = resolveRepertoireMcp(targetDir);
|
|
146
146
|
if (!repertoireMcp) return extras;
|
|
147
|
+
const launch = wrapScopedLaunch(
|
|
148
|
+
{ command: "node", args: [repertoireMcp] },
|
|
149
|
+
baseScopedEnv(targetDir),
|
|
150
|
+
);
|
|
147
151
|
extras.hermes.repertoire = {
|
|
148
|
-
command:
|
|
149
|
-
args:
|
|
150
|
-
env:
|
|
152
|
+
command: launch.command,
|
|
153
|
+
args: launch.args,
|
|
154
|
+
env: launch.env,
|
|
151
155
|
};
|
|
152
156
|
extras.opencode.repertoire = {
|
|
153
157
|
type: "local",
|
|
154
|
-
command:
|
|
158
|
+
command: launch.commandList,
|
|
155
159
|
enabled: true,
|
|
160
|
+
environment: launch.env,
|
|
156
161
|
};
|
|
157
162
|
extras.openclaw.repertoire = {
|
|
158
|
-
command:
|
|
159
|
-
args:
|
|
160
|
-
env:
|
|
163
|
+
command: launch.command,
|
|
164
|
+
args: launch.args,
|
|
165
|
+
env: launch.env,
|
|
161
166
|
};
|
|
162
167
|
} catch {
|
|
163
168
|
// best-effort
|
|
@@ -165,11 +170,284 @@ function detectConsumerExtraMcpServers(targetDir) {
|
|
|
165
170
|
return extras;
|
|
166
171
|
}
|
|
167
172
|
|
|
168
|
-
function
|
|
173
|
+
function readInstalledXrayVersion() {
|
|
174
|
+
const pkgPath = path.join(__dirname, "..", "..", "package.json");
|
|
175
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
176
|
+
if (typeof pkg.version !== "string" || !pkg.version) {
|
|
177
|
+
throw new Error("0xray package.json version missing; refusing unpinned MCP launch");
|
|
178
|
+
}
|
|
179
|
+
return pkg.version;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function localXrayCli(targetDir) {
|
|
183
|
+
if (!targetDir) return null;
|
|
184
|
+
const cli = path.join(targetDir, "node_modules", "0xray", "dist", "cli", "index.js");
|
|
185
|
+
return fs.existsSync(cli) ? cli : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Prefer the installed CLI. Else pin npx to this package version. Never bare `0xray`. */
|
|
189
|
+
function pinnedMcpLaunch(targetDir, mcpCmd) {
|
|
190
|
+
const cli = localXrayCli(targetDir);
|
|
191
|
+
if (cli) {
|
|
192
|
+
return {
|
|
193
|
+
command: "node",
|
|
194
|
+
args: [cli, "mcp", mcpCmd],
|
|
195
|
+
commandList: ["node", cli, "mcp", mcpCmd],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const spec = `0xray@${readInstalledXrayVersion()}`;
|
|
169
199
|
return {
|
|
170
200
|
command: "npx",
|
|
171
|
-
args: ["-y",
|
|
172
|
-
|
|
201
|
+
args: ["-y", spec, "mcp", mcpCmd],
|
|
202
|
+
commandList: ["npx", "-y", spec, "mcp", mcpCmd],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Stdio MCP hosts merge an `env` object onto the parent process. They cannot
|
|
208
|
+
* drop inherited secrets. argv is world-readable, so values never go on the
|
|
209
|
+
* command line. Every launch is:
|
|
210
|
+
* node <mcp-launch.cjs> --keep PATH,HOME,XRAY_ROOT[,flag][,user names] -- <pinned command>
|
|
211
|
+
* `scripts/node/mcp-launch.cjs` copies those names from its own environ (the
|
|
212
|
+
* live host, after the host merged this entry's `env` block) and spawns the
|
|
213
|
+
* server. PATH and HOME are names only; their values are whatever the host
|
|
214
|
+
* has at launch. XRAY_ROOT and non-secret server flags stay in the `env`
|
|
215
|
+
* block. User keys stay in that block too, and their names are appended to
|
|
216
|
+
* --keep. NODE_OPTIONS is omitted: none of the seven stdio servers read it.
|
|
217
|
+
*/
|
|
218
|
+
const LIVE_ENV_NAMES = ["PATH", "HOME"];
|
|
219
|
+
|
|
220
|
+
function mcpLauncherPath() {
|
|
221
|
+
return path.join(__dirname, "mcp-launch.cjs");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function baseScopedEnv(targetDir) {
|
|
225
|
+
const env = {};
|
|
226
|
+
if (targetDir) env.XRAY_ROOT = targetDir;
|
|
227
|
+
return env;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function orderedEnv(envMap) {
|
|
231
|
+
const preferred = ["PATH", "HOME", "XRAY_ROOT"];
|
|
232
|
+
const ordered = {};
|
|
233
|
+
for (const key of preferred) {
|
|
234
|
+
if (Object.prototype.hasOwnProperty.call(envMap, key)) ordered[key] = String(envMap[key]);
|
|
235
|
+
}
|
|
236
|
+
for (const key of Object.keys(envMap)) {
|
|
237
|
+
if (!Object.prototype.hasOwnProperty.call(ordered, key)) ordered[key] = String(envMap[key]);
|
|
238
|
+
}
|
|
239
|
+
return ordered;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function mergeScopedEnv(layers) {
|
|
243
|
+
const merged = {};
|
|
244
|
+
for (const layer of layers) {
|
|
245
|
+
if (!layer || typeof layer !== "object" || Array.isArray(layer)) continue;
|
|
246
|
+
for (const key of Object.keys(layer)) {
|
|
247
|
+
if (layer[key] == null) continue;
|
|
248
|
+
merged[key] = String(layer[key]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return orderedEnv(merged);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function configEnvFromMap(envMap) {
|
|
255
|
+
const ordered = orderedEnv(envMap || {});
|
|
256
|
+
const config = {};
|
|
257
|
+
for (const key of Object.keys(ordered)) {
|
|
258
|
+
if (LIVE_ENV_NAMES.includes(key)) continue;
|
|
259
|
+
config[key] = ordered[key];
|
|
260
|
+
}
|
|
261
|
+
return config;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function keepList(config) {
|
|
265
|
+
return [...LIVE_ENV_NAMES, ...Object.keys(config)];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function wrapScopedLaunch(inner, envMap) {
|
|
269
|
+
const env = configEnvFromMap(envMap);
|
|
270
|
+
const tail = [mcpLauncherPath(), "--keep", keepList(env).join(","), "--", inner.command, ...inner.args];
|
|
271
|
+
return {
|
|
272
|
+
command: "node",
|
|
273
|
+
args: tail,
|
|
274
|
+
commandList: ["node", ...tail],
|
|
275
|
+
env,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scopedMcpLaunch(targetDir, mcpCmd, serverEnv) {
|
|
280
|
+
return wrapScopedLaunch(pinnedMcpLaunch(targetDir, mcpCmd), mergeScopedEnv([baseScopedEnv(targetDir), serverEnv]));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function commandTokens(server) {
|
|
284
|
+
if (!server || typeof server !== "object") return [];
|
|
285
|
+
if (Array.isArray(server.command)) return server.command.map(String);
|
|
286
|
+
const tokens = [];
|
|
287
|
+
if (typeof server.command === "string") tokens.push(server.command);
|
|
288
|
+
if (Array.isArray(server.args)) tokens.push(...server.args.map(String));
|
|
289
|
+
return tokens;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isMcpLauncherToken(token) {
|
|
293
|
+
return token === "mcp-launch.cjs" || token.endsWith(`${path.sep}mcp-launch.cjs`) || token.endsWith("/mcp-launch.cjs");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function peelScopedLaunch(tokens) {
|
|
297
|
+
if (tokens[0] === "env") {
|
|
298
|
+
let i = 1;
|
|
299
|
+
if (tokens[i] === "-i" || tokens[i] === "--ignore-environment") i += 1;
|
|
300
|
+
else return tokens.slice();
|
|
301
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i += 1;
|
|
302
|
+
return tokens.slice(i);
|
|
303
|
+
}
|
|
304
|
+
if (tokens[0] === "node" && isMcpLauncherToken(tokens[1] || "")) {
|
|
305
|
+
const dash = tokens.indexOf("--");
|
|
306
|
+
if (dash >= 0) return tokens.slice(dash + 1);
|
|
307
|
+
}
|
|
308
|
+
return tokens.slice();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function launchTokens(server) {
|
|
312
|
+
return peelScopedLaunch(commandTokens(server));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function innerFromTokens(tokens) {
|
|
316
|
+
if (!tokens.length) return null;
|
|
317
|
+
return { command: tokens[0], args: tokens.slice(1) };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function usesCommandArray(server) {
|
|
321
|
+
return Array.isArray(server && server.command);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function hasVersionPin(server) {
|
|
325
|
+
return launchTokens(server).some((token) => /^0xray@.+/.test(token));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isUnpinnedXrayLaunch(server) {
|
|
329
|
+
return launchTokens(server).some((token) => token === "0xray");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function jsonDeepEqual(a, b) {
|
|
333
|
+
if (a === b) return true;
|
|
334
|
+
if (typeof a !== typeof b) return false;
|
|
335
|
+
if (a === null || b === null || typeof a !== "object") return a === b;
|
|
336
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
337
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
338
|
+
return a.every((item, i) => jsonDeepEqual(item, b[i]));
|
|
339
|
+
}
|
|
340
|
+
const aKeys = Object.keys(a);
|
|
341
|
+
const bKeys = Object.keys(b);
|
|
342
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
343
|
+
return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && jsonDeepEqual(a[key], b[key]));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function mcpCmdFromInner(inner) {
|
|
347
|
+
const idx = inner.args.indexOf("mcp");
|
|
348
|
+
if (idx >= 0 && inner.args[idx + 1]) return inner.args[idx + 1];
|
|
349
|
+
return inner.args.length > 0 ? inner.args[inner.args.length - 1] : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Framework fills gaps. User env, enabled/disabled, and version pins win. Unpinned `0xray` is repinned. Launch is `node mcp-launch.cjs --keep names -- cmd`. */
|
|
353
|
+
function mergeUserWinsServer(framework, user, targetDir) {
|
|
354
|
+
if (!user || typeof user !== "object") return framework;
|
|
355
|
+
if (!framework || typeof framework !== "object") return user;
|
|
356
|
+
const next = { ...user };
|
|
357
|
+
const userInner = innerFromTokens(launchTokens(user));
|
|
358
|
+
const frameworkInner = innerFromTokens(launchTokens(framework));
|
|
359
|
+
let inner = userInner || frameworkInner;
|
|
360
|
+
if (userInner && !hasVersionPin(user) && isUnpinnedXrayLaunch(user) && frameworkInner) {
|
|
361
|
+
inner = frameworkInner;
|
|
362
|
+
}
|
|
363
|
+
if (inner && isUnpinnedXrayLaunch({ command: inner.command, args: inner.args })) {
|
|
364
|
+
const mcpCmd = mcpCmdFromInner(inner);
|
|
365
|
+
if (mcpCmd) inner = pinnedMcpLaunch(targetDir, mcpCmd);
|
|
366
|
+
}
|
|
367
|
+
if (!inner) return framework;
|
|
368
|
+
const mergedEnv = mergeScopedEnv([
|
|
369
|
+
baseScopedEnv(targetDir),
|
|
370
|
+
framework.env,
|
|
371
|
+
framework.environment,
|
|
372
|
+
user.env,
|
|
373
|
+
user.environment,
|
|
374
|
+
]);
|
|
375
|
+
const wrapped = wrapScopedLaunch(inner, mergedEnv);
|
|
376
|
+
if (usesCommandArray(user) || usesCommandArray(framework)) {
|
|
377
|
+
next.command = wrapped.commandList;
|
|
378
|
+
delete next.args;
|
|
379
|
+
} else {
|
|
380
|
+
next.command = wrapped.command;
|
|
381
|
+
next.args = wrapped.args;
|
|
382
|
+
}
|
|
383
|
+
const wantEnvironment =
|
|
384
|
+
Object.prototype.hasOwnProperty.call(user, "environment") ||
|
|
385
|
+
Object.prototype.hasOwnProperty.call(framework, "environment");
|
|
386
|
+
const wantEnv =
|
|
387
|
+
Object.prototype.hasOwnProperty.call(user, "env") || Object.prototype.hasOwnProperty.call(framework, "env");
|
|
388
|
+
if (wantEnvironment) next.environment = mergedEnv;
|
|
389
|
+
if (wantEnv) next.env = mergedEnv;
|
|
390
|
+
else if (!wantEnvironment) next.env = mergedEnv;
|
|
391
|
+
if (!Object.prototype.hasOwnProperty.call(user, "enabled") && Object.prototype.hasOwnProperty.call(framework, "enabled")) {
|
|
392
|
+
next.enabled = framework.enabled;
|
|
393
|
+
}
|
|
394
|
+
if (!Object.prototype.hasOwnProperty.call(user, "disabled") && Object.prototype.hasOwnProperty.call(framework, "disabled")) {
|
|
395
|
+
next.disabled = framework.disabled;
|
|
396
|
+
}
|
|
397
|
+
return next;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function mergeMcpMap(frameworkServers, userServers, targetDir) {
|
|
401
|
+
const framework = frameworkServers && typeof frameworkServers === "object" ? frameworkServers : {};
|
|
402
|
+
const user = userServers && typeof userServers === "object" ? userServers : {};
|
|
403
|
+
const merged = {};
|
|
404
|
+
const seen = new Set();
|
|
405
|
+
for (const name of Object.keys(user)) {
|
|
406
|
+
seen.add(name);
|
|
407
|
+
merged[name] = framework[name] ? mergeUserWinsServer(framework[name], user[name], targetDir) : user[name];
|
|
408
|
+
}
|
|
409
|
+
for (const name of Object.keys(framework)) {
|
|
410
|
+
if (seen.has(name)) continue;
|
|
411
|
+
merged[name] = framework[name];
|
|
412
|
+
}
|
|
413
|
+
return merged;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function mergeNamedRecords(src, dest) {
|
|
417
|
+
const source = src && typeof src === "object" ? src : {};
|
|
418
|
+
const user = dest && typeof dest === "object" ? dest : {};
|
|
419
|
+
const merged = {};
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
for (const name of Object.keys(user)) {
|
|
422
|
+
seen.add(name);
|
|
423
|
+
const fromSrc = source[name];
|
|
424
|
+
const fromUser = user[name];
|
|
425
|
+
if (
|
|
426
|
+
fromSrc &&
|
|
427
|
+
fromUser &&
|
|
428
|
+
typeof fromSrc === "object" &&
|
|
429
|
+
typeof fromUser === "object" &&
|
|
430
|
+
!Array.isArray(fromSrc) &&
|
|
431
|
+
!Array.isArray(fromUser)
|
|
432
|
+
) {
|
|
433
|
+
merged[name] = { ...fromSrc, ...fromUser };
|
|
434
|
+
} else {
|
|
435
|
+
merged[name] = fromUser;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const name of Object.keys(source)) {
|
|
439
|
+
if (seen.has(name)) continue;
|
|
440
|
+
merged[name] = source[name];
|
|
441
|
+
}
|
|
442
|
+
return merged;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function buildStdioMcpServer(mcpCmd, env, targetDir) {
|
|
446
|
+
const launch = scopedMcpLaunch(targetDir, mcpCmd, env);
|
|
447
|
+
return {
|
|
448
|
+
command: launch.command,
|
|
449
|
+
args: launch.args,
|
|
450
|
+
env: launch.env,
|
|
173
451
|
};
|
|
174
452
|
}
|
|
175
453
|
|
|
@@ -194,12 +472,12 @@ function buildOpenClawMcpServers(targetDir) {
|
|
|
194
472
|
function buildOpencodeMcpEntries(targetDir) {
|
|
195
473
|
const entries = {};
|
|
196
474
|
for (const s of XRAY_MCP_SERVERS) {
|
|
197
|
-
const
|
|
475
|
+
const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
|
|
198
476
|
entries[s.name] = {
|
|
199
477
|
type: "local",
|
|
200
|
-
command:
|
|
478
|
+
command: launch.commandList,
|
|
201
479
|
enabled: true,
|
|
202
|
-
|
|
480
|
+
environment: launch.env,
|
|
203
481
|
};
|
|
204
482
|
}
|
|
205
483
|
const extras = detectConsumerExtraMcpServers(targetDir);
|
|
@@ -209,23 +487,25 @@ function buildOpencodeMcpEntries(targetDir) {
|
|
|
209
487
|
function buildPluginMcpJson(targetDir) {
|
|
210
488
|
const mcpServers = {};
|
|
211
489
|
for (const s of XRAY_MCP_SERVERS) {
|
|
490
|
+
const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
|
|
212
491
|
mcpServers[s.name] = {
|
|
213
|
-
command:
|
|
214
|
-
args:
|
|
215
|
-
env:
|
|
492
|
+
command: launch.command,
|
|
493
|
+
args: launch.args,
|
|
494
|
+
env: launch.env,
|
|
216
495
|
};
|
|
217
496
|
}
|
|
218
497
|
return { mcpServers };
|
|
219
498
|
}
|
|
220
499
|
|
|
221
|
-
/**
|
|
222
|
-
function buildPortableProjectMcpJson() {
|
|
500
|
+
/** Project .mcp.json for Grok/Cursor. Launcher allowlist drops inherited secrets; XRAY_ROOT is set when targetDir is known. */
|
|
501
|
+
function buildPortableProjectMcpJson(targetDir) {
|
|
223
502
|
const mcpServers = {};
|
|
224
503
|
for (const s of XRAY_MCP_SERVERS) {
|
|
504
|
+
const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
|
|
225
505
|
mcpServers[s.name] = {
|
|
226
|
-
command:
|
|
227
|
-
args:
|
|
228
|
-
|
|
506
|
+
command: launch.command,
|
|
507
|
+
args: launch.args,
|
|
508
|
+
env: launch.env,
|
|
229
509
|
};
|
|
230
510
|
}
|
|
231
511
|
return { mcpServers };
|
|
@@ -312,16 +592,21 @@ function mergeOpencodeMcpRegistry(targetDir) {
|
|
|
312
592
|
config = { $schema: "https://opencode.ai/config.json", mcp: {} };
|
|
313
593
|
}
|
|
314
594
|
}
|
|
315
|
-
|
|
316
|
-
|
|
595
|
+
const existed = fs.existsSync(opencodePath);
|
|
596
|
+
const nextMcp = mergeMcpMap(entries, config.mcp || {}, targetDir);
|
|
597
|
+
const next = { ...config, mcp: nextMcp };
|
|
598
|
+
if (existed && jsonDeepEqual(next, config)) return Object.keys(entries).length;
|
|
599
|
+
fs.writeFileSync(opencodePath, `${JSON.stringify(next, null, 2)}\n`);
|
|
317
600
|
return Object.keys(entries).length;
|
|
318
601
|
}
|
|
319
602
|
|
|
320
603
|
function deployPortableProjectMcpJson(targetDir) {
|
|
321
604
|
const destPath = path.join(targetDir, ".mcp.json");
|
|
322
|
-
const portable = buildPortableProjectMcpJson();
|
|
605
|
+
const portable = buildPortableProjectMcpJson(targetDir);
|
|
323
606
|
let existing = { mcpServers: {} };
|
|
607
|
+
let hadFile = false;
|
|
324
608
|
if (fs.existsSync(destPath)) {
|
|
609
|
+
hadFile = true;
|
|
325
610
|
try {
|
|
326
611
|
existing = JSON.parse(fs.readFileSync(destPath, "utf8"));
|
|
327
612
|
} catch {
|
|
@@ -329,14 +614,12 @@ function deployPortableProjectMcpJson(targetDir) {
|
|
|
329
614
|
}
|
|
330
615
|
}
|
|
331
616
|
const extras = detectConsumerExtraMcpServers(targetDir);
|
|
617
|
+
const framework = { ...portable.mcpServers, ...(extras.openclaw || {}) };
|
|
332
618
|
const merged = {
|
|
333
619
|
...existing,
|
|
334
|
-
mcpServers: {
|
|
335
|
-
...(existing.mcpServers || {}),
|
|
336
|
-
...portable.mcpServers,
|
|
337
|
-
...(extras.openclaw || {}),
|
|
338
|
-
},
|
|
620
|
+
mcpServers: mergeMcpMap(framework, existing.mcpServers || {}, targetDir),
|
|
339
621
|
};
|
|
622
|
+
if (hadFile && jsonDeepEqual(merged, existing)) return;
|
|
340
623
|
fs.writeFileSync(destPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
341
624
|
}
|
|
342
625
|
|
|
@@ -573,6 +856,14 @@ module.exports = {
|
|
|
573
856
|
buildStdioMcpServer,
|
|
574
857
|
buildPluginMcpJson,
|
|
575
858
|
buildPortableProjectMcpJson,
|
|
859
|
+
pinnedMcpLaunch,
|
|
860
|
+
scopedMcpLaunch,
|
|
861
|
+
baseScopedEnv,
|
|
862
|
+
wrapScopedLaunch,
|
|
863
|
+
mergeMcpMap,
|
|
864
|
+
mergeNamedRecords,
|
|
865
|
+
mergeUserWinsServer,
|
|
866
|
+
jsonDeepEqual,
|
|
576
867
|
copyHermesFindProjectRootHelper,
|
|
577
868
|
writeHermesPluginArtifacts,
|
|
578
869
|
syncHermesMcpRegistry,
|
|
@@ -353,11 +353,45 @@ function main() {
|
|
|
353
353
|
throw new Error(`Missing MCP server in .mcp.json: ${name}`);
|
|
354
354
|
}
|
|
355
355
|
const entry = mcp.mcpServers[name];
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
const cli = path.join(tmpRoot, "node_modules", "0xray", "dist", "cli", "index.js");
|
|
357
|
+
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
358
|
+
const launcher = path.join(nmRoot, "scripts", "node", "mcp-launch.cjs");
|
|
359
|
+
if (entry.command !== "node" || args[0] !== launcher || args[1] !== "--keep") {
|
|
360
|
+
throw new Error(`${name} must launch via node mcp-launch.cjs --keep, got ${entry.command} ${args.join(" ")}`);
|
|
361
|
+
}
|
|
362
|
+
const dash = args.indexOf("--");
|
|
363
|
+
if (dash < 0) throw new Error(`${name} launcher args missing --`);
|
|
364
|
+
const kept = String(args[2] || "").split(",").filter(Boolean);
|
|
365
|
+
const inner = args.slice(dash + 1);
|
|
366
|
+
const pinnedNode = inner[0] === "node" && inner[1] === cli && inner[2] === "mcp";
|
|
367
|
+
const pinnedNpx =
|
|
368
|
+
inner[0] === "npx" && inner[1] === "-y" && /^0xray@\d/.test(inner[2] || "") && inner[3] === "mcp";
|
|
369
|
+
if (!pinnedNode && !pinnedNpx) {
|
|
370
|
+
throw new Error(`${name} must pin node CLI or npx 0xray@version after --, got ${inner.join(" ")}`);
|
|
371
|
+
}
|
|
372
|
+
if (inner.includes("0xray")) {
|
|
373
|
+
throw new Error(`${name} launched unpinned 0xray`);
|
|
374
|
+
}
|
|
375
|
+
for (const required of ["PATH", "HOME", "XRAY_ROOT"]) {
|
|
376
|
+
if (!kept.includes(required)) {
|
|
377
|
+
throw new Error(`${name} keep list missing ${required}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (entry.env && (entry.env.PATH || entry.env.HOME)) {
|
|
381
|
+
throw new Error(`${name} baked PATH or HOME into the env block`);
|
|
382
|
+
}
|
|
383
|
+
for (const arg of args) {
|
|
384
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
|
385
|
+
throw new Error(`${name} put an env assignment in argv: ${arg.split("=")[0]}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const banned of ["NPM_TOKEN", "CURSOR_AUTH_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "RAILWAY_TOKEN"]) {
|
|
389
|
+
if (kept.includes(banned) || (entry.env && Object.prototype.hasOwnProperty.call(entry.env, banned))) {
|
|
390
|
+
throw new Error(`${name} inherited ${banned}`);
|
|
391
|
+
}
|
|
358
392
|
}
|
|
359
393
|
}
|
|
360
|
-
console.log(` ✅ .mcp.json has ${XRAY_MCP_NAMES.length}
|
|
394
|
+
console.log(` ✅ .mcp.json has ${XRAY_MCP_NAMES.length} version-pinned MCP servers`);
|
|
361
395
|
|
|
362
396
|
assertFreshInstallDefaults(tmpRoot, version);
|
|
363
397
|
assertOrganRequire(tmpRoot);
|
|
@@ -22,6 +22,10 @@ const {
|
|
|
22
22
|
wireOpencodeBridge,
|
|
23
23
|
wireOpenClawBridge,
|
|
24
24
|
deployPortableProjectMcpJson,
|
|
25
|
+
scopedMcpLaunch,
|
|
26
|
+
mergeMcpMap,
|
|
27
|
+
mergeNamedRecords,
|
|
28
|
+
jsonDeepEqual,
|
|
25
29
|
copyHermesFindProjectRootHelper,
|
|
26
30
|
copyHermesHookRuntimes,
|
|
27
31
|
installOpenClawHostWear,
|
|
@@ -199,10 +203,21 @@ function mergeOpencodeJson(targetDir, packageRoot, log) {
|
|
|
199
203
|
if (fs.existsSync(userOpencode)) {
|
|
200
204
|
const destData = JSON.parse(fs.readFileSync(userOpencode, "utf8"));
|
|
201
205
|
const merged = { ...destData };
|
|
202
|
-
if (srcData.agent
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
if (srcData.agent || destData.agent) {
|
|
207
|
+
merged.agent = mergeNamedRecords(srcData.agent, destData.agent);
|
|
208
|
+
}
|
|
209
|
+
if (srcData.mcp || destData.mcp) {
|
|
210
|
+
merged.mcp = mergeMcpMap(srcData.mcp, destData.mcp, targetDir);
|
|
211
|
+
}
|
|
212
|
+
if (srcData.compaction || destData.compaction) {
|
|
213
|
+
merged.compaction =
|
|
214
|
+
srcData.compaction && destData.compaction
|
|
215
|
+
? { ...srcData.compaction, ...destData.compaction }
|
|
216
|
+
: destData.compaction || srcData.compaction;
|
|
217
|
+
}
|
|
218
|
+
if (!jsonDeepEqual(merged, destData)) {
|
|
219
|
+
fs.writeFileSync(userOpencode, JSON.stringify(merged, null, 2) + "\n");
|
|
220
|
+
}
|
|
206
221
|
} else {
|
|
207
222
|
fs.copyFileSync(rootOpencode, userOpencode);
|
|
208
223
|
}
|
|
@@ -308,13 +323,13 @@ function copyOpencodePlugin(packageRoot, opencodeDest, log) {
|
|
|
308
323
|
if (!fs.existsSync(pluginSource)) return;
|
|
309
324
|
const pluginDestDir = path.dirname(pluginDest);
|
|
310
325
|
if (!fs.existsSync(pluginDestDir)) fs.mkdirSync(pluginDestDir, { recursive: true });
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
fs.
|
|
314
|
-
if (shouldCopy) {
|
|
315
|
-
fs.copyFileSync(pluginSource, pluginDest);
|
|
316
|
-
log("opencode-bridge", "plugin updated", "info");
|
|
326
|
+
const scopePkg = path.join(opencodeDest, "package.json");
|
|
327
|
+
if (!fs.existsSync(scopePkg)) {
|
|
328
|
+
fs.writeFileSync(scopePkg, `${JSON.stringify({ type: "module" })}\n`);
|
|
317
329
|
}
|
|
330
|
+
const shim = `export { default } from ${JSON.stringify(pluginSource)};\n`;
|
|
331
|
+
fs.writeFileSync(pluginDest, shim);
|
|
332
|
+
log("opencode-bridge", "plugin shim written", "info");
|
|
318
333
|
}
|
|
319
334
|
|
|
320
335
|
function installOpencodeBridge(targetDir, packageRoot, log) {
|
|
@@ -368,15 +383,13 @@ function registerGrokMcpServers(targetDir, log, pluginDirs) {
|
|
|
368
383
|
|
|
369
384
|
for (const s of XRAY_MCP_SERVERS) {
|
|
370
385
|
try {
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
);
|
|
379
|
-
log("grok-bridge", `registered ${s.name} (npx)`, "info");
|
|
386
|
+
const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
|
|
387
|
+
const argv = ["mcp", "add", s.name, "--command", launch.command, "--args", launch.args[0], ...launch.args.slice(1)];
|
|
388
|
+
for (const [key, value] of Object.entries(launch.env)) {
|
|
389
|
+
argv.push("--env", `${key}=${value}`);
|
|
390
|
+
}
|
|
391
|
+
execFileSync("grok", argv, { stdio: "pipe" });
|
|
392
|
+
log("grok-bridge", `registered ${s.name} (mcp-launch)`, "info");
|
|
380
393
|
} catch {
|
|
381
394
|
// already registered or grok config conflict — non-blocking
|
|
382
395
|
}
|
|
@@ -1068,6 +1081,8 @@ module.exports = {
|
|
|
1068
1081
|
installFrameworkDogfoodWear,
|
|
1069
1082
|
wearVendoredRepertoire,
|
|
1070
1083
|
installCursorBridge,
|
|
1084
|
+
mergeOpencodeJson,
|
|
1085
|
+
copyOpencodePlugin,
|
|
1071
1086
|
parseExecDaemonCmdline,
|
|
1072
1087
|
reloadCursorHostHooks,
|
|
1073
1088
|
resolveCursorHooksTemplate,
|