@mmmbuto/nexuscrew 0.8.28 → 0.8.30

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DiKMLCvW.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-BAzlX2nP.css">
14
+ <script type="module" crossorigin src="/assets/index-CPNWWnqN.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CJtVoawR.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.28"}
1
+ {"version":"0.8.30"}
@@ -346,9 +346,64 @@ function servicePinsLegacyPort(platform, home, installPath) {
346
346
  try { return /NEXUSCREW_PORT/.test(fs.readFileSync(target, 'utf8')); } catch (_) { return false; }
347
347
  }
348
348
 
349
- function serviceDefinitionNeedsRefresh(platform, home, installPath) {
349
+ function fleetServiceDefinitionNeedsRefresh(platform, home, installPath) {
350
+ let target;
351
+ try { target = installPath || fleetInstallPath(platform, home); } catch (_) { return false; }
352
+ try { fs.lstatSync(target); }
353
+ catch (error) { return error.code !== 'ENOENT'; }
354
+ return !checkServiceWorkingDirectory(platform, home, target).ok;
355
+ }
356
+
357
+ function serviceDefinitionNeedsRefresh(platform, home, installPath, fleetInstallPathOverride) {
350
358
  if (servicePinsLegacyPort(platform, home, installPath)) return true;
351
- return !checkServiceWorkingDirectory(platform, home, installPath).ok;
359
+ if (!checkServiceWorkingDirectory(platform, home, installPath).ok) return true;
360
+ return fleetServiceDefinitionNeedsRefresh(platform, home, fleetInstallPathOverride);
361
+ }
362
+
363
+ function serviceMigrationMarkerPath(home, override) {
364
+ return override || path.join(home, '.nexuscrew', 'service-cwd-migration.pending');
365
+ }
366
+
367
+ function serviceMigrationPending(home, override) {
368
+ const target = serviceMigrationMarkerPath(home, override);
369
+ try {
370
+ const st = fs.lstatSync(target);
371
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error(`unsafe service migration marker: ${target}`);
372
+ return true;
373
+ } catch (error) {
374
+ if (error.code === 'ENOENT') return false;
375
+ throw error;
376
+ }
377
+ }
378
+
379
+ function ensureServiceMigrationMarker(home, override) {
380
+ const target = serviceMigrationMarkerPath(home, override);
381
+ if (serviceMigrationPending(home, override)) return target;
382
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
383
+ let fd;
384
+ try {
385
+ fd = fs.openSync(target, 'wx', 0o600);
386
+ fs.writeFileSync(fd, 'pending\n');
387
+ fs.fsyncSync(fd);
388
+ fs.fchmodSync(fd, 0o600);
389
+ } catch (error) {
390
+ if (error.code === 'EEXIST' && serviceMigrationPending(home, override)) return target;
391
+ throw error;
392
+ } finally {
393
+ if (fd !== undefined) fs.closeSync(fd);
394
+ }
395
+ return target;
396
+ }
397
+
398
+ function clearServiceMigrationMarker(home, override) {
399
+ const target = serviceMigrationMarkerPath(home, override);
400
+ try {
401
+ const st = fs.lstatSync(target);
402
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error(`unsafe service migration marker: ${target}`);
403
+ fs.unlinkSync(target);
404
+ } catch (error) {
405
+ if (error.code !== 'ENOENT') throw error;
406
+ }
352
407
  }
353
408
 
354
409
  function portAvailable(port, host = '127.0.0.1') {
@@ -471,18 +526,37 @@ async function smartUp(opts = {}) {
471
526
  const home = opts.home || require('node:os').homedir();
472
527
  const persistent = bootState({ ...opts, platform }).enabled;
473
528
  let serviceRefreshed = false;
474
- if (persistent && serviceDefinitionNeedsRefresh(platform, home, opts.installPath)) {
475
- runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
476
- serviceRefreshed = checkServiceWorkingDirectory(platform, home, opts.installPath).ok;
477
- // Termux:Boot has no service manager, so replacing the script alone cannot
478
- // change the cwd of the already-running pidfile owner. Restart exactly once
479
- // after a verified migration; Linux/macOS are restarted by installService.
480
- if (serviceRefreshed && platform === 'termux') {
481
- const migrated = restartImpl({ ...opts, platform, log: quiet });
482
- if (!migrated || migrated.restarted !== true) {
483
- throw new Error(`service cwd migration restart failed: ${(migrated && migrated.reason) || 'unknown'}`);
484
- }
529
+ const migrationPending = persistent
530
+ ? serviceMigrationPending(home, opts.serviceMigrationMarkerPath) : false;
531
+ if (persistent && (migrationPending || serviceDefinitionNeedsRefresh(
532
+ platform, home, opts.installPath, opts.fleetInstallPath,
533
+ ))) {
534
+ ensureServiceMigrationMarker(home, opts.serviceMigrationMarkerPath);
535
+ const initResult = runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
536
+ serviceRefreshed = !serviceDefinitionNeedsRefresh(
537
+ platform, home, opts.installPath, opts.fleetInstallPath,
538
+ );
539
+ if (!serviceRefreshed) {
540
+ throw new Error('service cwd migration failed verification; refusing to continue with a replaceable working directory');
541
+ }
542
+ const installFailures = Array.isArray(initResult?.installFailures) ? initResult.installFailures : [];
543
+ if (installFailures.length) {
544
+ const components = [...new Set(installFailures.map((failure) => failure?.component)
545
+ .filter((component) => component === 'service' || component === 'fleet-companion'))];
546
+ throw new Error(`service cwd migration activation failed verification${components.length ? ` (${components.join(', ')})` : ''}; refusing to continue with an unverified service runtime`);
547
+ }
548
+ // Writing and activating a definition does not prove that an already-active
549
+ // Linux unit adopted it (`systemctl enable --now` is a no-op for that
550
+ // process). Restart every persistent runtime exactly once after the files
551
+ // and activation outcomes are verified. The lifecycle helper preserves the
552
+ // shared tmux server on Linux and uses PID-pinned portable restart on Termux.
553
+ const migrated = restartImpl({ ...opts, platform, log: quiet });
554
+ if (!migrated || migrated.restarted !== true) {
555
+ throw new Error(`service cwd migration restart failed verification (${platform}); refusing to continue with an unverified service runtime`);
485
556
  }
557
+ // Only a verified file migration plus successful service activation (and,
558
+ // on Termux, a verified runtime restart) clears the durable retry marker.
559
+ clearServiceMigrationMarker(home, opts.serviceMigrationMarkerPath);
486
560
  }
487
561
 
488
562
  if (!port) port = urlmod.loadPort(opts);
@@ -989,6 +1063,8 @@ module.exports = {
989
1063
  dispatch, dispatchNodes, serve, start, stop, status, parseFlags, HELP, NODES_HELP,
990
1064
  smartUp, url, tokenRotate, logs, doctor, update, restart,
991
1065
  serviceDefinitionNeedsRefresh,
1066
+ fleetServiceDefinitionNeedsRefresh,
1067
+ serviceMigrationPending,
992
1068
  portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
993
1069
  servicePinsLegacyPort,
994
1070
  isServiceRunning, readRoles,
package/lib/cli/doctor.js CHANGED
@@ -11,6 +11,7 @@ const path = require('node:path');
11
11
  const { execFileSync } = require('node:child_process');
12
12
  const { detectPlatform, uid } = require('./platform.js');
13
13
  const { installPath } = require('./service.js');
14
+ const { fleetInstallPath } = require('./fleet-service.js');
14
15
  const { resolvePaths } = require('./url.js');
15
16
  const { commandExists } = require('./path.js');
16
17
  const { loadDefinitions } = require('../fleet/definitions.js');
@@ -126,6 +127,26 @@ function checkServiceWorkingDirectory(platform, home, installPathOverride) {
126
127
  };
127
128
  }
128
129
 
130
+ // The Fleet boot companion can win the boot race and create the shared tmux
131
+ // server before the HTTP service. Apply the same stable-HOME invariant to it.
132
+ // A missing companion is non-fatal because Fleet boot is optional; an installed
133
+ // but stale/unsafe definition is a real failure.
134
+ function checkFleetServiceWorkingDirectory(platform, home, installPathOverride) {
135
+ if (!['linux', 'mac', 'termux'].includes(platform)) {
136
+ return { name: 'fleet service cwd stabile', ok: true, detail: `${platform}: non applicabile` };
137
+ }
138
+ const target = installPathOverride || fleetInstallPath(platform, home);
139
+ try {
140
+ fs.lstatSync(target);
141
+ } catch (error) {
142
+ if (error.code === 'ENOENT') {
143
+ return { name: 'fleet service cwd stabile', ok: true, warn: true, detail: `companion non installato (${target})` };
144
+ }
145
+ }
146
+ const result = checkServiceWorkingDirectory(platform, home, target);
147
+ return { ...result, name: 'fleet service cwd stabile' };
148
+ }
149
+
129
150
  function checkBoot(platform, home, execImpl) {
130
151
  if (platform === 'termux') {
131
152
  const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
@@ -425,6 +446,9 @@ function doctor(opts = {}) {
425
446
  checkPty(ptyLoad),
426
447
  checkService(platform, home, execImpl, uidVal, opts.installPath),
427
448
  checkServiceWorkingDirectory(platform, home, opts.installPath),
449
+ fleetEnabled
450
+ ? checkFleetServiceWorkingDirectory(platform, home, opts.fleetInstallPath)
451
+ : { name: 'fleet service cwd stabile', ok: true, warn: true, detail: 'Fleet disabilitata' },
428
452
  checkBoot(platform, home, execImpl),
429
453
  checkUserLinger(platform, execImpl, uidVal),
430
454
  checkTmuxSurvival(platform, execImpl),
@@ -454,6 +478,7 @@ module.exports = {
454
478
  checkMacServiceWorkingDirectory,
455
479
  checkFleetDefinitions,
456
480
  checkServiceWorkingDirectory,
481
+ checkFleetServiceWorkingDirectory,
457
482
  checkTmuxSurvival, checkUserLinger,
458
483
  checkSshClient, checkAutossh, checkSshPermitlisten,
459
484
  checkMcpIdentity, checkTermuxExec,
@@ -41,13 +41,16 @@ function generateFleetService(opts) {
41
41
  }
42
42
 
43
43
  function generateFleetLinux(opts) {
44
- const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
44
+ const home = opts.home || os.homedir();
45
45
  const { nodeBin, entryPath } = opts;
46
46
  // Parita' di hardening con service.js (M3): reject char non gestibili in systemd.
47
- assertSystemdSafe('repoRoot', repoRoot);
47
+ assertSystemdSafe('home', home);
48
48
  assertSystemdSafe('nodeBin', nodeBin);
49
49
  assertSystemdSafe('entryPath', entryPath);
50
- const repo = escapeSystemdPath(repoRoot);
50
+ // fleet-boot can create the shared tmux server before the HTTP service. Its
51
+ // cwd must therefore be the stable HOME, never the replaceable npm package
52
+ // directory containing entryPath.
53
+ const stableCwd = escapeSystemdPath(home);
51
54
  const node = escapeSystemdExec(nodeBin);
52
55
  const entry = escapeSystemdExec(entryPath);
53
56
  const nodeDir = escapeSystemdPath(path.dirname(nodeBin));
@@ -61,7 +64,7 @@ Type=oneshot
61
64
  # fleet-boot may be the process that creates the shared tmux server. Keep
62
65
  # systemd from reaping that server and its cells when this oneshot exits.
63
66
  KillMode=process
64
- WorkingDirectory=${repo}
67
+ WorkingDirectory=${stableCwd}
65
68
  Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
66
69
  ExecStart=${node} ${entry} fleet-boot
67
70
 
@@ -71,11 +74,9 @@ WantedBy=default.target
71
74
  }
72
75
 
73
76
  function generateFleetMac(opts) {
74
- const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
75
77
  const home = opts.home || os.homedir();
76
78
  const nodeXml = escapeXml(opts.nodeBin);
77
79
  const entryXml = escapeXml(opts.entryPath);
78
- const repoXml = escapeXml(repoRoot);
79
80
  const homeXml = escapeXml(home);
80
81
  const launchPath = [...new Set([
81
82
  path.dirname(opts.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
@@ -94,7 +95,7 @@ function generateFleetMac(opts) {
94
95
  <string>fleet-boot</string>
95
96
  </array>
96
97
  <key>WorkingDirectory</key>
97
- <string>${repoXml}</string>
98
+ <string>${homeXml}</string>
98
99
  <key>EnvironmentVariables</key>
99
100
  <dict>
100
101
  <key>PATH</key>
@@ -112,10 +113,8 @@ function generateFleetMac(opts) {
112
113
  }
113
114
 
114
115
  function generateFleetTermux(opts) {
115
- const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
116
116
  const nodeQ = shellQuote(opts.nodeBin);
117
117
  const entryQ = shellQuote(opts.entryPath);
118
- const repoQ = shellQuote(repoRoot);
119
118
  return `#!/data/data/com.termux/files/usr/bin/sh
120
119
  # NexusCrew fleet boot companion (Termux) - avvia le celle boot:true
121
120
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
@@ -124,7 +123,7 @@ export PREFIX=/data/data/com.termux/files/usr
124
123
  export TMPDIR=$PREFIX/tmp
125
124
  export TMUX_TMPDIR=$PREFIX/var/run
126
125
  mkdir -p "$TMPDIR" "$TMUX_TMPDIR"
127
- cd -- ${repoQ}
126
+ cd -- "$HOME"
128
127
  exec ${nodeQ} ${entryQ} fleet-boot >> "$HOME/.nexuscrew/fleet-boot.log" 2>&1
129
128
  `;
130
129
  }
package/lib/cli/init.js CHANGED
@@ -109,6 +109,9 @@ function runInit(opts = {}) {
109
109
  }
110
110
 
111
111
  const actions = [];
112
+ // Structured, value-free install outcomes for callers that must fail closed
113
+ // (notably smart-up migrations). Human-readable actions remain compatible.
114
+ const installFailures = [];
112
115
 
113
116
  // porta: opts.port > migration rule > config esistente > default 41820
114
117
  let port = opts.port;
@@ -214,12 +217,14 @@ function runInit(opts = {}) {
214
217
  const r = installService(platform, content, svcCtx, { execImpl: opts.execImpl });
215
218
  if (r.failures && r.failures.length) {
216
219
  // file installato MA activation (systemctl/launchctl) fallita [M1]
220
+ installFailures.push({ component: 'service', phase: 'activation' });
217
221
  actions.push(`WARN: service file installato ${r.target} MA activation fallita: ${r.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
218
222
  } else {
219
223
  actions.push(`service installed ${r.target} (mode 0${fileMode(platform).toString(8)})`);
220
224
  }
221
225
  } catch (e) {
222
226
  // failure: preserve file + diagnosi (no rollback) [M8]
227
+ installFailures.push({ component: 'service', phase: 'install' });
223
228
  actions.push(`WARN: service install fallito: ${e.message} (file generati preservati)`);
224
229
  }
225
230
  }
@@ -273,6 +278,7 @@ function runInit(opts = {}) {
273
278
  );
274
279
  if (fr.failures && fr.failures.length) {
275
280
  // file installato MA activation (systemctl/launchctl) fallita [M1]
281
+ installFailures.push({ component: 'fleet-companion', phase: 'activation' });
276
282
  actions.push(`WARN: fleet companion file installato ${fr.target} MA activation fallita: ${fr.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
277
283
  } else {
278
284
  actions.push(`fleet companion installed ${fr.target} (mode 0${fleetFileMode(platform).toString(8)})`);
@@ -282,6 +288,7 @@ function runInit(opts = {}) {
282
288
  }
283
289
  } catch (e) {
284
290
  // qualunque errore del companion: WARN + init principale prosegue intatto.
291
+ installFailures.push({ component: 'fleet-companion', phase: 'install' });
285
292
  actions.push(`WARN: fleet companion fallito: ${e.message} (init principale prosegue)`);
286
293
  }
287
294
 
@@ -303,7 +310,7 @@ function runInit(opts = {}) {
303
310
 
304
311
  for (const a of actions) log(a);
305
312
 
306
- return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
313
+ return { platform, port, token, url: urlWithToken, actions, installFailures, tmuxOk, dryRun };
307
314
  }
308
315
 
309
316
  module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic, ensureFleetDefaults };
@@ -18,7 +18,7 @@ function redactText(value, max = 160) {
18
18
  .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
19
19
  .replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)_[A-Za-z0-9_-]{8,}\b/g, '[redacted]')
20
20
  .replace(/\b(?:token|password|secret|api[_-]?key)\s*[=:]\s*\S+/gi, '[redacted]')
21
- .replace(/(?:\/home\/[^/\s]+|\/data\/(?:data|user\/\d+)\/[^/\s]+)(?:\/[^\s]*)?/g, '[path]')
21
+ .replace(/(?:\/home\/[^/\s]+|\/Users\/[^/\s]+|\/data\/(?:data|user\/\d+)\/[^/\s]+)(?:\/[^\s]*)?/g, '[path]')
22
22
  .replace(/\b[A-Z][A-Z0-9_]{2,}\s*=\s*\S+/g, '[env]');
23
23
  return text.slice(0, max);
24
24
  }
@@ -620,8 +620,12 @@ async function createBuiltinFleet(cfg = {}) {
620
620
  if (defs.cells.some((c) => c.id === id)) throw httpError(409, `id cella già usato: ${id}`);
621
621
  const cwd = typeof b.cwd === 'string' && b.cwd.trim() ? b.cwd.trim() : home;
622
622
  const cellDef = { id, cwd, engine: engineId, boot: b.boot === true, tmuxSession };
623
+ // Import is a write boundary just like define/edit/restore. Resolve and
624
+ // confine the cwd before mutate so a foreign or missing path is never
625
+ // persisted as a cell that can only fail later at up().
626
+ const [resolvedCell] = resolveCellsOrFail([cellDef], home);
623
627
  try {
624
- await mutate(defs, (d) => { d.cells.push(cellDef); });
628
+ await mutate(defs, (d) => { d.cells.push(resolvedCell); });
625
629
  } catch (e) { throw httpError(400, `import non valido: ${e.message}`); }
626
630
  return { ok: true, id, tmuxSession, imported: true };
627
631
  }
@@ -47,6 +47,7 @@ const CODES = [
47
47
  'SESSION_DUPLICATE',
48
48
  // readiness (client started but exited immediately)
49
49
  'CLIENT_EARLY_EXIT',
50
+ 'SHELL_COMMAND_FAILED',
50
51
  // spawn-client (cell client spawn error: ENOENT/EACCES/...)
51
52
  'SPAWN_CLIENT_FAILED',
52
53
  // bounded fallback for any untagged / legacy / unexpected error
@@ -63,6 +63,7 @@ const REDACTED = '‹redacted›';
63
63
  // - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
64
64
  // - testo del prompt della cella (cell.prompt)
65
65
  // - testo del prompt dell'engine (engine.prompt) se presente
66
+ // - comando Shell attivo per cella (cell.commands[cell.engine])
66
67
  // Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
67
68
  // tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
68
69
  // lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
@@ -76,6 +77,11 @@ function redactSecrets(text, engine, cell) {
76
77
  }
77
78
  if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
78
79
  if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
80
+ if (cell && typeof cell.engine === 'string'
81
+ && cell.commands && typeof cell.commands === 'object' && !Array.isArray(cell.commands)) {
82
+ const activeCommand = cell.commands[cell.engine];
83
+ if (typeof activeCommand === 'string' && activeCommand) secrets.push(activeCommand);
84
+ }
79
85
  // Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
80
86
  // che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
81
87
  secrets.sort((a, b) => b.length - a.length);
@@ -312,6 +312,17 @@ function shellLoginArgs(command) {
312
312
  return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || ''))) ? ['-l'] : [];
313
313
  }
314
314
 
315
+ // Un command configurato deve vedere lo stesso ambiente della shell interattiva
316
+ // che l'utente ottiene lasciando vuoto il campo. Con `-lc`, zsh non carica
317
+ // `.zshrc`: alias e PATH user-locali (per esempio ~/.local/bin) spariscono e il
318
+ // command esce 127 pur essendo disponibile nel terminale normale. I quattro
319
+ // shell POSIX-like supportati vengono quindi avviati login+interactive+command;
320
+ // shell custom conservano il contratto storico -lc.
321
+ function shellConfiguredCommandArgs(command, raw) {
322
+ return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || '')))
323
+ ? ['-lic', raw] : ['-lc', raw];
324
+ }
325
+
315
326
  // Termux reports process.platform === 'android' and deliberately has no
316
327
  // /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
317
328
  // `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
@@ -641,7 +652,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
641
652
  const raw = cell?.commands && typeof cell.commands[engineId] === 'string'
642
653
  ? cell.commands[engineId] : '';
643
654
  shellOneShot = raw.trim().length > 0;
644
- if (shellOneShot) args.push('-lc', raw);
655
+ if (shellOneShot) args.push(...shellConfiguredCommandArgs(info.binary, raw));
645
656
  else args.push(...shellLoginArgs(info.binary));
646
657
  } else if (spec.client === 'claude') {
647
658
  if (spec.provider === 'native') {
@@ -792,5 +803,6 @@ module.exports = {
792
803
  defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
793
804
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
794
805
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
795
- ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell, shellLoginArgs, ENV_KEY_RE,
806
+ ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
807
+ shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,
796
808
  };
@@ -219,11 +219,10 @@ function createBuiltinRuntime(ctx) {
219
219
  const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
220
220
  argv.splice(2, 0, '-P', '-F', '#{pane_id}');
221
221
  // Mantieni il pane morto solo durante la finestra di readiness: permette di
222
- // catturare un errore reale del client senza lasciare una sessione fantasma.
223
- // Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
224
- if (!launchEngine.shellOneShot) {
225
- argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
226
- }
222
+ // catturare sia un errore reale del client sia l'exit immediato di un command
223
+ // Shell. Il separatore e' interpretato da tmux (execFile argv diretto), non
224
+ // dalla shell che esegue il command configurato.
225
+ argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
227
226
  const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
228
227
  if (launch.err) {
229
228
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
@@ -235,15 +234,6 @@ function createBuiltinRuntime(ctx) {
235
234
  { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
236
235
  }
237
236
 
238
- // Un comando Shell valorizzato e' intenzionalmente one-shot: l'uscita del
239
- // processo, anche immediata o non-zero, e' il risultato del comando e non
240
- // un crash del client Fleet. Non si mantiene il pane e non si abilita il
241
- // supervisore; la cella torna inattiva quando la shell termina.
242
- if (launchEngine.shellOneShot) {
243
- cache = { ...cache, at: 0 };
244
- return { ok: true, cell: cellId, session: cell.tmuxSession, prompt: null, oneShot: true };
245
- }
246
-
247
237
  // `tmux new-session -d` can return 0 even when the launched CLI exits a
248
238
  // moment later (missing login, bad model, incompatible provider). Without
249
239
  // this readiness gate the PWA reported success and then showed nothing.
@@ -263,8 +253,23 @@ function createBuiltinRuntime(ctx) {
263
253
  // nella Fleet o nella lista tmux dopo aver raccolto l'errore.
264
254
  await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
265
255
  cache = { ...cache, at: 0 };
256
+ // Un command Shell completato rapidamente con exit 0 e' un one-shot
257
+ // riuscito. Qualunque altro exit immediato e' invece osservabile come
258
+ // errore strutturato: prima il runtime restituiva un falso successo e
259
+ // scartava proprio l'exit 127/diagnostica che servivano all'operatore.
260
+ if (launchEngine.shellOneShot && readiness.status === 0) {
261
+ return {
262
+ ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
263
+ oneShot: true, active: false, completed: true, exitCode: 0,
264
+ };
265
+ }
266
266
  const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
267
267
  const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
268
+ if (launchEngine.shellOneShot) {
269
+ throw httpError(500,
270
+ `comando Shell terminato subito${status}: ${diagnostic || 'verifica command, PATH e configurazione della shell'}`,
271
+ null, { phase: 'readiness', code: 'SHELL_COMMAND_FAILED' });
272
+ }
268
273
  // Cause-preserving (T4): distinguish a cell-client spawn failure (the
269
274
  // captured pane carries the stable 'cell spawn failed:' marker produced by
270
275
  // cell-exec.js) from a generic early exit. Both stay on the readiness
@@ -278,6 +283,17 @@ function createBuiltinRuntime(ctx) {
278
283
  ['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
279
284
  }
280
285
 
286
+ // Il command Shell e' partito ed e' ancora vivo dopo la finestra di
287
+ // readiness: la cella deve risultare attiva (per CLI interattive come agy),
288
+ // poi tornera' inattiva quando il processo terminera' naturalmente.
289
+ if (launchEngine.shellOneShot) {
290
+ cache = { ...cache, at: 0 };
291
+ return {
292
+ ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
293
+ oneShot: true, active: true, completed: false,
294
+ };
295
+ }
296
+
281
297
  // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
282
298
  // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
283
299
  let prompt = null;
package/lib/mcp/tools.js CHANGED
@@ -9,9 +9,11 @@
9
9
  // argomenti e orchestrazione delle chiamate via ctx.api. Ordine, nomi, schemi,
10
10
  // handler, identity gate e semantica read/write sono preservati EXACT.
11
11
  const path = require('node:path');
12
+ const { codeOf, phaseOf } = require('../fleet/causes.js');
12
13
  const {
13
14
  NODE_ID_RE, orderedDeckMembers, fleetStatusPath, fleetCellsBySession,
14
- routePath, topologyOwners, memberOwnerId, parseCellTarget, readCellDirectory,
15
+ routePath, topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload,
16
+ readCellDirectory,
15
17
  } = require('./cells.js');
16
18
 
17
19
  // --- helper input tool (fail-closed) ------------------------------------------
@@ -57,6 +59,93 @@ function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
57
59
  );
58
60
  }
59
61
 
62
+ // Il command Shell e' utile per diagnosticare una cella locale, ma puo'
63
+ // contenere credenziali scritte per errore. Manteniamo la struttura operativa
64
+ // e redigiamo flag/assegnazioni credential-shaped prima che il valore lasci il
65
+ // bridge MCP. Nessun command entra mai nella directory federata nc_cells.
66
+ function commandForDiagnostics(raw) {
67
+ if (typeof raw !== 'string' || !raw.trim()) {
68
+ return { configured: false, value: null, redacted: false, truncated: false };
69
+ }
70
+ let value = raw.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
71
+ const before = value;
72
+ value = value
73
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
74
+ .replace(/((?:--)?(?:api[-_]?key|token|secret|password|credential))(\s*(?:=|:)\s*|\s+)(?:"[^"]*"|'[^']*'|\S+)/gi,
75
+ '$1$2[redacted]')
76
+ .replace(/\b([A-Za-z][A-Za-z0-9_]*(?:_API_KEY|_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIAL|_AUTH)[A-Za-z0-9_]*)\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/gi,
77
+ '$1=[redacted]')
78
+ // env maiuscole generiche senza separatore prima del suffisso credenziale
79
+ // (ZAIKEY=, PASSWD=, MYPASS=, DATABASE_URL=...): coerente con
80
+ // lib/diagnostics/store.js, ma consuma anche valori quotati per intero.
81
+ .replace(/\b([A-Z][A-Z0-9_]{2,})\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '$1=[redacted]')
82
+ .replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)[_-][A-Za-z0-9_-]{8,}\b/g, '[redacted]');
83
+ const truncated = value.length > 4096;
84
+ if (truncated) value = value.slice(0, 4096);
85
+ return { configured: true, value, redacted: value !== before || truncated, truncated };
86
+ }
87
+
88
+ // Accetta soltanto i due eventi failure prodotti dal router Fleet e ricostruisce
89
+ // una causa chiusa. Free text e meta ignoti vengono sempre scartati, anche se
90
+ // arrivano da un runtime precedente o compromesso.
91
+ function failureForDiagnostics(record, cell) {
92
+ if (!record || record.component !== 'fleet' || !record.meta || record.meta.cell !== cell) return null;
93
+ const at = typeof record.ts === 'string' && record.ts.length <= 64 ? record.ts : null;
94
+ const status = Number.isSafeInteger(record.meta.status) && record.meta.status >= 100 && record.meta.status <= 599
95
+ ? record.meta.status : null;
96
+ if (record.code === 'CELL_SPAWN_FAILED') {
97
+ return {
98
+ event: 'CELL_SPAWN_FAILED', at, status,
99
+ code: 'SPAWN_CLIENT_FAILED', phase: 'spawn-client',
100
+ errno: /^[A-Z][A-Z0-9_]{0,31}$/.test(String(record.meta.errno || '')) ? record.meta.errno : null,
101
+ client: /^[A-Za-z0-9._+-]{1,128}$/.test(String(record.meta.client || '')) ? record.meta.client : null,
102
+ };
103
+ }
104
+ if (record.code !== 'FLEET_ACTION_FAILED') return null;
105
+ return {
106
+ event: 'FLEET_ACTION_FAILED', at, status,
107
+ code: codeOf(record.meta.code), phase: phaseOf(record.meta.phase),
108
+ };
109
+ }
110
+
111
+ function lastFailureForCell(records, cell) {
112
+ if (!Array.isArray(records)) return null;
113
+ for (let i = records.length - 1; i >= 0; i -= 1) {
114
+ const failure = failureForDiagnostics(records[i], cell);
115
+ if (failure) return failure;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ async function recentDiagnostics(ctx) {
121
+ const out = [];
122
+ let after = 0;
123
+ // Store bounded a 500 record: tre pagine da 200 coprono l'intero buffer senza
124
+ // una query illimitata e senza dipendere dalla modalita' verbose.
125
+ for (let page = 0; page < 3; page += 1) {
126
+ const payload = await ctx.api('GET', `/api/diagnostics/logs?after=${after}&limit=200`);
127
+ const records = Array.isArray(payload && payload.records) ? payload.records : [];
128
+ out.push(...records);
129
+ const cursor = Number.isSafeInteger(payload && payload.cursor) ? payload.cursor : after;
130
+ if (records.length < 200 || cursor <= after) break;
131
+ after = cursor;
132
+ }
133
+ return out.slice(-500);
134
+ }
135
+
136
+ // La diagnostica command/cause e' intenzionalmente locale: non deve neppure
137
+ // interrogare la topologia o i proxy remoti. Questo helper costruisce la stessa
138
+ // forma normalizzata di nc_cells usando soltanto config + /api/cells locali.
139
+ async function readLocalCellDirectory(ctx, callerSession) {
140
+ const [config, payload] = await Promise.all([
141
+ ctx.api('GET', '/api/config'), ctx.api('GET', '/api/cells'),
142
+ ]);
143
+ const nodeId = String(config && config.instanceId || '');
144
+ if (!NODE_ID_RE.test(nodeId)) throw new Error('instanceId locale non disponibile');
145
+ const owner = { instanceId: nodeId, route: [], label: 'Local', stale: false };
146
+ return { nodeId, cells: normalizeCellPayload(payload, owner, callerSession) };
147
+ }
148
+
60
149
  // --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
61
150
  // annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
62
151
  const TOOLS = [
@@ -263,6 +352,53 @@ const TOOLS = [
263
352
  return readCellDirectory(ctx, callerSession);
264
353
  },
265
354
  },
355
+ {
356
+ name: 'nc_cell_diagnostics',
357
+ description: 'Diagnostica read-only target-specifica di una cella Fleet locale: command Shell redatto e ultima causa spawn/start bounded. Richiede un caller Fleet locale attivo; non attraversa la federazione.',
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ target: { type: 'string', description: 'id owner-qualified locale restituito da nc_cells: <instanceId>:<cell>' },
362
+ },
363
+ required: ['target'],
364
+ },
365
+ annotations: { readOnlyHint: true },
366
+ async handler(args, ctx) {
367
+ const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
368
+ if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
369
+ const identity = await ctx.identity();
370
+ const callerSession = requireSession(identity.session, 'nc_cell_diagnostics', identity.code);
371
+ const directory = await readLocalCellDirectory(ctx, callerSession);
372
+ const sender = directory.cells.find((cell) => cell.self && cell.active);
373
+ if (!sender) throw new Error('nc_cell_diagnostics: la sessione chiamante non e\' una cella Fleet attiva locale');
374
+ if (targetRef.instanceId !== directory.nodeId) {
375
+ throw new Error('nc_cell_diagnostics: target remoto rifiutato; i command non attraversano la federazione');
376
+ }
377
+ const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
378
+ && cell.cell === targetRef.cell);
379
+ if (!target) throw new Error('nc_cell_diagnostics: cella locale non trovata');
380
+
381
+ const [definitions, diagnostics] = await Promise.all([
382
+ ctx.api('GET', '/api/fleet/definitions'), recentDiagnostics(ctx),
383
+ ]);
384
+ const definition = Array.isArray(definitions && definitions.cells)
385
+ ? definitions.cells.find((cell) => cell && cell.id === target.cell) : null;
386
+ if (!definition || definition.tmuxSession !== target.tmuxSession || definition.engine !== target.engine) {
387
+ throw new Error('nc_cell_diagnostics: definizione locale incoerente o non disponibile');
388
+ }
389
+ const rawCommand = definition.commands && typeof definition.commands === 'object'
390
+ ? definition.commands[definition.engine] : '';
391
+ return {
392
+ target: target.id,
393
+ cell: target.cell,
394
+ tmuxSession: target.tmuxSession,
395
+ engine: target.engine,
396
+ active: target.active,
397
+ command: commandForDiagnostics(rawCommand),
398
+ lastFailure: lastFailureForCell(diagnostics, target.cell),
399
+ };
400
+ },
401
+ },
266
402
  {
267
403
  name: 'nc_send_cell',
268
404
  description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
@@ -346,4 +482,7 @@ const TOOLS = [
346
482
  },
347
483
  ];
348
484
 
349
- module.exports = { TOOLS, argString, requireSession, IDENTITY_CODE, IDENTITY_REMEDIATION };
485
+ module.exports = {
486
+ TOOLS, argString, requireSession, commandForDiagnostics, failureForDiagnostics,
487
+ lastFailureForCell, readLocalCellDirectory, IDENTITY_CODE, IDENTITY_REMEDIATION,
488
+ };