0xray 4.0.27 → 4.0.29

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.
@@ -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: "node",
149
- args: [repertoireMcp],
150
- env: { XRAY_ROOT: targetDir },
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: ["node", repertoireMcp],
158
+ command: launch.commandList,
155
159
  enabled: true,
160
+ environment: launch.env,
156
161
  };
157
162
  extras.openclaw.repertoire = {
158
- command: "node",
159
- args: [repertoireMcp],
160
- env: { XRAY_ROOT: targetDir },
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 buildStdioMcpServer(mcpCmd, env, targetDir) {
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", "0xray", "mcp", mcpCmd],
172
- env: { ...env, XRAY_ROOT: targetDir },
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 environment = { ...s.env };
475
+ const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
198
476
  entries[s.name] = {
199
477
  type: "local",
200
- command: ["npx", "-y", "0xray", "mcp", s.mcpCmd],
478
+ command: launch.commandList,
201
479
  enabled: true,
202
- ...(Object.keys(environment).length > 0 ? { environment } : {}),
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: "npx",
214
- args: ["-y", "0xray", "mcp", s.mcpCmd],
215
- env: { ...s.env, XRAY_ROOT: targetDir },
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
- /** Portable .mcp.json for Grok/Cursor — no absolute XRAY_ROOT */
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: "npx",
227
- args: ["-y", "0xray", "mcp", s.mcpCmd],
228
- ...(Object.keys(s.env).length > 0 ? { env: { ...s.env } } : {}),
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
- config.mcp = { ...(config.mcp || {}), ...entries };
316
- fs.writeFileSync(opencodePath, `${JSON.stringify(config, null, 2)}\n`);
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,15 +614,14 @@ 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 false;
340
623
  fs.writeFileSync(destPath, `${JSON.stringify(merged, null, 2)}\n`);
624
+ return true;
341
625
  }
342
626
 
343
627
  function enableHermesPluginBestEffort() {
@@ -573,6 +857,14 @@ module.exports = {
573
857
  buildStdioMcpServer,
574
858
  buildPluginMcpJson,
575
859
  buildPortableProjectMcpJson,
860
+ pinnedMcpLaunch,
861
+ scopedMcpLaunch,
862
+ baseScopedEnv,
863
+ wrapScopedLaunch,
864
+ mergeMcpMap,
865
+ mergeNamedRecords,
866
+ mergeUserWinsServer,
867
+ jsonDeepEqual,
576
868
  copyHermesFindProjectRootHelper,
577
869
  writeHermesPluginArtifacts,
578
870
  syncHermesMcpRegistry,
@@ -9,6 +9,7 @@ const MARKER_END = "# --- end 0xray suit ---";
9
9
 
10
10
  /** Suit artifacts dropped by postinstall / install-bridges — keep out of product repos */
11
11
  const SUIT_IGNORE_LINES = [
12
+ ".xray/state/cursor-hook-wear/",
12
13
  ".grok/",
13
14
  ".mcp.json",
14
15
  "opencode.json",
@@ -21,16 +21,6 @@ const require = createRequire(import.meta.url);
21
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
  const rootDir = path.resolve(__dirname, "../..");
23
23
 
24
- const XRAY_MCP_NAMES = [
25
- "xray-governance",
26
- "xray-skills",
27
- "xray-orchestrator",
28
- "xray-enforcer",
29
- "xray-researcher",
30
- "xray-code-review",
31
- "xray-architect-tools",
32
- ];
33
-
34
24
  function run(cmd, cwd, { inherit = false } = {}) {
35
25
  const result = execSync(cmd, {
36
26
  cwd,
@@ -311,19 +301,12 @@ function main() {
311
301
  }
312
302
 
313
303
  const millSkillHanger = path.join(tmpRoot, ".opencode", "skills", "mill", "SKILL.md");
314
- if (!fs.existsSync(millSkillHanger)) {
315
- throw new Error("mill plant missing mill skill");
304
+ if (fs.existsSync(millSkillHanger)) {
305
+ throw new Error("postinstall must not fasten mill skills into the project");
316
306
  }
317
- const wornSkills = path.join(tmpRoot, ".opencode", "skills");
318
- const wornNames = fs.existsSync(wornSkills)
319
- ? fs.readdirSync(wornSkills).filter((n) =>
320
- fs.existsSync(path.join(wornSkills, n, "SKILL.md")),
321
- )
322
- : [];
323
- if (wornNames.length > 3) {
324
- throw new Error(`costume dump: ${wornNames.join(",")}`);
307
+ if (fs.existsSync(path.join(tmpRoot, ".mcp.json"))) {
308
+ throw new Error("postinstall must not write .mcp.json");
325
309
  }
326
-
327
310
  const installedPkg = JSON.parse(fs.readFileSync(path.join(nmRoot, "package.json"), "utf-8"));
328
311
  if (installedPkg.version !== version) {
329
312
  throw new Error(`Installed version ${installedPkg.version} != packed ${version}`);
@@ -342,23 +325,11 @@ function main() {
342
325
  }
343
326
  console.log(" ✅ consumer target resolution");
344
327
 
345
- const mcpPath = path.join(tmpRoot, ".mcp.json");
346
- if (!fs.existsSync(mcpPath)) {
347
- throw new Error(".mcp.json not created by postinstall");
348
- }
349
- const mcp = JSON.parse(fs.readFileSync(mcpPath, "utf-8"));
350
- const servers = Object.keys(mcp.mcpServers || {});
351
- for (const name of XRAY_MCP_NAMES) {
352
- if (!servers.includes(name)) {
353
- throw new Error(`Missing MCP server in .mcp.json: ${name}`);
354
- }
355
- const entry = mcp.mcpServers[name];
356
- if (entry.command !== "npx") {
357
- throw new Error(`${name} should use npx, got ${entry.command}`);
358
- }
359
- }
360
- console.log(` ✅ .mcp.json has ${XRAY_MCP_NAMES.length} npx MCP servers`);
361
-
328
+ fs.mkdirSync(path.join(tmpRoot, ".xray"), { recursive: true });
329
+ fs.copyFileSync(
330
+ path.join(nmRoot, "xray", "features.json"),
331
+ path.join(tmpRoot, ".xray", "features.json"),
332
+ );
362
333
  assertFreshInstallDefaults(tmpRoot, version);
363
334
  assertOrganRequire(tmpRoot);
364
335