@mmmbuto/nexuscrew 0.8.12 → 0.8.13

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/lib/mcp/server.js CHANGED
@@ -3,14 +3,16 @@
3
3
  //
4
4
  // Porta NexusCrew DENTRO le sessioni AI (Claude Code / codex-vl) come server
5
5
  // MCP: notifiche umane, richieste di attenzione (ask), consegna file, stato
6
- // read-only. Il bus cella↔cella resta a crewd/crew: questo bridge parla SOLO
7
- // con l'HTTP API locale di NexusCrew (loopback + Bearer).
6
+ // read-only e directory/invio autenticato tra celle Fleet attive. Il bridge
7
+ // parla SOLO con l'HTTP API locale di NexusCrew (loopback + Bearer); le route
8
+ // federate applicano ACL e identita' owner-qualified lato server.
8
9
  //
9
10
  // Protocollo: JSON-RPC 2.0, UN messaggio JSON per riga (stdio framing MCP).
10
11
  // Hand-rolled minimale, zero dipendenze SDK (stile del repo). Fail-closed:
11
12
  // garbage in input non crasha MAI il processo — risponde un errore JSON-RPC.
12
13
  // Niente log su stdout (corromperebbe il canale): diagnostica su stderr.
13
14
  const readline = require('node:readline');
15
+ const crypto = require('node:crypto');
14
16
  const os = require('node:os');
15
17
  const path = require('node:path');
16
18
  const { execFile } = require('node:child_process');
@@ -95,6 +97,7 @@ function orderedDeckMembers(deck) {
95
97
 
96
98
  const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
97
99
  const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
100
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
98
101
  function fleetStatusPath(node) {
99
102
  if (!node) return '/api/fleet/status';
100
103
  const parts = String(node).split('/');
@@ -144,6 +147,69 @@ function memberOwnerId(member, deckOwner, ownerTopology) {
144
147
  return found ? found.instanceId : null;
145
148
  }
146
149
 
150
+ function parseCellTarget(value) {
151
+ if (typeof value !== 'string') return null;
152
+ const split = value.indexOf(':');
153
+ if (split < 16) return null;
154
+ const instanceId = value.slice(0, split);
155
+ const cell = value.slice(split + 1);
156
+ return NODE_ID_RE.test(instanceId) && CELL_ID_RE.test(cell) ? { instanceId, cell } : null;
157
+ }
158
+
159
+ function normalizeCellPayload(payload, owner, callerSession = null) {
160
+ if (!payload || payload.instanceId !== owner.instanceId || !Array.isArray(payload.cells)) return [];
161
+ const route = owner.route.length ? owner.route.join('/') : 'local';
162
+ const seen = new Set();
163
+ const out = [];
164
+ for (const raw of payload.cells) {
165
+ if (!raw || raw.instanceId !== owner.instanceId || !CELL_ID_RE.test(String(raw.cell || ''))
166
+ || typeof raw.tmuxSession !== 'string' || !isValidSession(raw.tmuxSession)
167
+ || seen.has(raw.cell)) continue;
168
+ seen.add(raw.cell);
169
+ out.push({
170
+ id: `${owner.instanceId}:${raw.cell}`,
171
+ instanceId: owner.instanceId,
172
+ owner: owner.label,
173
+ route,
174
+ cell: raw.cell,
175
+ tmuxSession: raw.tmuxSession,
176
+ engine: typeof raw.engine === 'string' ? raw.engine : '',
177
+ model: typeof raw.model === 'string' ? raw.model : '',
178
+ active: raw.active === true,
179
+ canReceive: raw.canReceive === true,
180
+ lastSeen: Number.isFinite(raw.lastSeen) ? raw.lastSeen : null,
181
+ self: owner.route.length === 0 && callerSession === raw.tmuxSession,
182
+ });
183
+ }
184
+ return out;
185
+ }
186
+
187
+ async function readCellDirectory(ctx, callerSession = null) {
188
+ const [config, topology] = await Promise.all([
189
+ ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
190
+ ]);
191
+ const localId = String(config && config.instanceId || '');
192
+ if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
193
+ const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
194
+ ...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
195
+ const cells = [];
196
+ const unavailable = [];
197
+ await Promise.all(owners.map(async (owner) => {
198
+ const apiPath = owner.route.length ? routePath(owner.route, 'cells') : '/api/cells';
199
+ if (!apiPath) return;
200
+ try {
201
+ cells.push(...normalizeCellPayload(await ctx.api('GET', apiPath), owner, callerSession));
202
+ } catch (_) {
203
+ unavailable.push({ instanceId: owner.instanceId, owner: owner.label,
204
+ route: owner.route.length ? owner.route.join('/') : 'local' });
205
+ }
206
+ }));
207
+ cells.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1
208
+ : a.route.localeCompare(b.route)) || a.cell.localeCompare(b.cell));
209
+ unavailable.sort((a, b) => a.route.localeCompare(b.route));
210
+ return { nodeId: localId, cells, unavailable };
211
+ }
212
+
147
213
  // --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
148
214
  // annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
149
215
  const TOOLS = [
@@ -337,6 +403,63 @@ const TOOLS = [
337
403
  };
338
404
  },
339
405
  },
406
+ {
407
+ name: 'nc_cells',
408
+ description: 'Directory read-only di tutte le celle Fleet autorizzate nella rete NexusCrew. Usa l\'id owner-qualified restituito per evitare nomi ambigui.',
409
+ inputSchema: { type: 'object', properties: {} },
410
+ annotations: { readOnlyHint: true },
411
+ async handler(_args, ctx) {
412
+ const callerSession = await ctx.session();
413
+ return readCellDirectory(ctx, callerSession);
414
+ },
415
+ },
416
+ {
417
+ name: 'nc_send_cell',
418
+ 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.',
419
+ inputSchema: {
420
+ type: 'object',
421
+ properties: {
422
+ target: { type: 'string', description: 'id owner-qualified restituito da nc_cells: <instanceId>:<cell>' },
423
+ message: { type: 'string', description: 'messaggio o task da sottoporre (max 8000 caratteri)' },
424
+ },
425
+ required: ['target', 'message'],
426
+ },
427
+ async handler(args, ctx) {
428
+ const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
429
+ if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
430
+ const message = argString(args, 'message', { required: true, max: 8000 });
431
+ for (let i = 0; i < message.length; i += 1) {
432
+ const code = message.charCodeAt(i);
433
+ if (code === 9 || code === 10 || code === 13) continue;
434
+ if (code < 32 || code === 127) throw new Error('message contiene caratteri di controllo non ammessi');
435
+ }
436
+ const callerSession = requireSession(await ctx.session(), 'nc_send_cell');
437
+ const directory = await readCellDirectory(ctx, callerSession);
438
+ const sender = directory.cells.find((cell) => cell.self && cell.active);
439
+ if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
440
+ const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
441
+ && cell.cell === targetRef.cell);
442
+ if (!target) throw new Error('cella destinataria non trovata nella rete autorizzata');
443
+ if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
444
+ const apiPath = target.route === 'local'
445
+ ? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
446
+ if (!apiPath) throw new Error('route destinataria non valida');
447
+ const id = ctx.messageId();
448
+ const receipt = await ctx.api('POST', apiPath, {
449
+ id,
450
+ from: { instanceId: sender.instanceId, cell: sender.cell, tmuxSession: sender.tmuxSession },
451
+ to: { instanceId: target.instanceId, cell: target.cell, tmuxSession: target.tmuxSession },
452
+ message,
453
+ });
454
+ return {
455
+ id: receipt.id,
456
+ status: receipt.status,
457
+ at: receipt.at,
458
+ to: receipt.to,
459
+ note: receipt.note || 'submitted conferma il trasporto, non il completamento del task',
460
+ };
461
+ },
462
+ },
340
463
  {
341
464
  name: 'nc_inbox',
342
465
  description: 'Elenca i file ricevuti nell\'inbox NexusCrew di questa sessione (read-only).',
@@ -357,6 +480,7 @@ function createMcpServer(opts = {}) {
357
480
  const env = opts.env || process.env;
358
481
  const execFileImpl = opts.execFileImpl || execFile;
359
482
  const fetchImpl = opts.fetchImpl || fetch;
483
+ const idFactory = opts.idFactory || (() => crypto.randomUUID());
360
484
  const errlog = opts.errlog || ((s) => { try { process.stderr.write(`${s}\n`); } catch (_) {} });
361
485
  // Config UNICA fonte per porta/token path: stessa risoluzione del server
362
486
  // (config.json + env NEXUSCREW_CONFIG_FILE/PORT/TOKEN_FILE). opts.config per test.
@@ -405,6 +529,7 @@ function createMcpServer(opts = {}) {
405
529
  api,
406
530
  home: () => env.HOME || os.homedir(),
407
531
  fileExists: (p) => { try { return require('node:fs').statSync(p).isFile(); } catch (_) { return false; } },
532
+ messageId: () => String(idFactory()).toLowerCase(),
408
533
  };
409
534
 
410
535
  function write(msg) {
@@ -539,4 +664,7 @@ function startMcp(opts = {}) {
539
664
  return srv;
540
665
  }
541
666
 
542
- module.exports = { createMcpServer, startMcp, resolveSession, TOOLS };
667
+ module.exports = {
668
+ createMcpServer, startMcp, resolveSession, TOOLS,
669
+ parseCellTarget, normalizeCellPayload, readCellDirectory,
670
+ };
@@ -57,6 +57,8 @@ function knownResource(resource) {
57
57
  || resource === '/files'
58
58
  || resource === '/files/download'
59
59
  || resource === '/files/upload'
60
+ || resource === '/cells'
61
+ || resource === '/cells/send'
60
62
  || resource === '/decks'
61
63
  || /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
62
64
  || resource === '/topology'
@@ -65,7 +67,7 @@ function knownResource(resource) {
65
67
  // Hydra: the rest of /settings stays unreachable.
66
68
  || resource === '/settings/peering/invite'
67
69
  || resource === '/ws'
68
- || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
70
+ || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
69
71
  }
70
72
 
71
73
  function allowedResource(resource, method = 'GET') {
@@ -76,6 +78,8 @@ function allowedResource(resource, method = 'GET') {
76
78
  if (resource === '/files') return method === 'GET' || method === 'DELETE';
77
79
  if (resource === '/files/download') return method === 'GET';
78
80
  if (resource === '/files/upload') return method === 'POST';
81
+ if (resource === '/cells') return method === 'GET';
82
+ if (resource === '/cells/send') return method === 'POST';
79
83
  if (resource === '/decks') return method === 'GET' || method === 'POST';
80
84
  if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
81
85
  return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
@@ -84,7 +88,7 @@ function allowedResource(resource, method = 'GET') {
84
88
  if (resource === '/settings/peering/invite') return method === 'POST';
85
89
  if (resource === '/ws') return method === 'GET';
86
90
  if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
87
- if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
91
+ if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
88
92
  return false;
89
93
  }
90
94
 
@@ -116,7 +120,12 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
116
120
  const visited = controlledVisited(req, ingress, st.nodeId);
117
121
  if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
118
122
  if (parsed.route.length === 0) {
119
- return proxyHttp(req, res, { port: typeof localPort === 'function' ? localPort() : localPort, path: `/api${parsed.resource}${queryOf(req.url)}`, credential: localCredential() });
123
+ return proxyHttp(req, res, {
124
+ port: typeof localPort === 'function' ? localPort() : localPort,
125
+ path: `/api${parsed.resource}${queryOf(req.url)}`,
126
+ credential: localCredential(),
127
+ visited,
128
+ });
120
129
  }
121
130
  const next = st && store.getNode(st, parsed.route[0]);
122
131
  const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
@@ -196,9 +205,37 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
196
205
  return out;
197
206
  }
198
207
 
208
+ // A freshly restarted SSH supervisor returns before both forwards are
209
+ // necessarily accepting traffic. Share must therefore wait for the actual
210
+ // authenticated federation channel instead of racing a single immediate
211
+ // fetch. Auth/identity failures are terminal; transport startup is retried
212
+ // for a short bounded window.
213
+ async function waitForHealthyPeer(opts = {}) {
214
+ const attempts = Number.isInteger(opts.attempts) && opts.attempts > 0 ? Math.min(opts.attempts, 12) : 6;
215
+ const delayMs = Number.isInteger(opts.delayMs) && opts.delayMs >= 0 ? Math.min(opts.delayMs, 2000) : 200;
216
+ const delay = typeof opts.delay === 'function'
217
+ ? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
218
+ const probeOpts = { ...opts };
219
+ delete probeOpts.attempts; delete probeOpts.delayMs; delete probeOpts.delay;
220
+ let last = null;
221
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
222
+ last = await probeHealth(probeOpts);
223
+ if (last.status === 'healthy') return last;
224
+ if (last.auth === 'failed' || /instanceId inatteso/.test(last.detail || '')) break;
225
+ if (attempt + 1 < attempts) await delay(delayMs);
226
+ }
227
+ return last || { status: 'down', detail: 'peer non raggiungibile' };
228
+ }
229
+
199
230
  function controlledVisited(req, ingress, instanceId) {
200
231
  const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
201
232
  const seen = raw ? raw.split(',').filter(Boolean) : [];
233
+ // On a peer-facing route the last server-controlled hop must be the peer
234
+ // authenticated by its scoped federation token. Without this binding a
235
+ // token holder could forge the first visited ID and impersonate another
236
+ // cell-network sender at the destination.
237
+ if (ingress && (!store.NODE_ID_RE.test(String(ingress.nodeId || ''))
238
+ || !seen.length || seen.at(-1) !== ingress.nodeId)) return null;
202
239
  if (seen.some((id) => !store.NODE_ID_RE.test(id)) || seen.includes(instanceId) || seen.length > MAX_HOPS) return null;
203
240
  return [...seen, instanceId];
204
241
  }
@@ -320,11 +357,13 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
320
357
  }
321
358
  try {
322
359
  if (body.shared) {
323
- const health = await probeHealth({
360
+ const health = await waitForHealthyPeer({
324
361
  port: req.peer.localPort,
325
362
  token: req.peer.token,
326
363
  expectedInstanceId: req.peer.nodeId || null,
327
364
  fetchImpl: fetchImpl || fetch,
365
+ attempts: 6,
366
+ delayMs: 200,
328
367
  });
329
368
  if (health.status !== 'healthy') {
330
369
  return res.status(409).json({
@@ -396,5 +435,5 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
396
435
  module.exports = {
397
436
  MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
398
437
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
399
- probeHealth,
438
+ probeHealth, waitForHealthyPeer,
400
439
  };
package/lib/pty/attach.js CHANGED
@@ -2,6 +2,7 @@
2
2
  const { execFile } = require('node:child_process');
3
3
  const os = require('node:os');
4
4
  const { loadPty } = require('./provider.js');
5
+ const { withUtf8Locale } = require('../runtime/env.js');
5
6
 
6
7
  // Opens `tmux attach` inside a real PTY, non-destructive for other clients.
7
8
  //
@@ -31,7 +32,7 @@ function openAttach(session, opts = {}) {
31
32
  name: 'xterm-256color',
32
33
  cols, rows,
33
34
  cwd: process.env.HOME || os.homedir(),
34
- env: process.env,
35
+ env: withUtf8Locale(process.env),
35
36
  });
36
37
  // tty del client tmux (es. /dev/pts/N): identifica QUESTO client per la
37
38
  // promozione/demozione runtime del size-owner via `refresh-client -t <tty>`.
@@ -52,4 +53,4 @@ function openAttach(session, opts = {}) {
52
53
  demote: () => setFlags('ignore-size'),
53
54
  };
54
55
  }
55
- module.exports = { openAttach };
56
+ module.exports = { openAttach, attachEnv: (env, platform) => withUtf8Locale(env, { platform }) };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+
5
+ const MINIMAL_ENV_KEYS = Object.freeze([
6
+ 'PATH', 'HOME', 'SHELL', 'TERM', 'COLORTERM', 'LANG', 'LANGUAGE',
7
+ 'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME', 'TMUX', 'TMUX_TMPDIR',
8
+ 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME',
9
+ 'DBUS_SESSION_BUS_ADDRESS',
10
+ // Termux/Android native clients need these to resolve their runtime, tmp and
11
+ // platform paths. They are location metadata, never credentials.
12
+ 'PREFIX', 'TMPDIR', 'TERMUX_VERSION', 'ANDROID_DATA', 'ANDROID_ROOT',
13
+ ]);
14
+
15
+ const UTF8_RE = /utf-?8/i;
16
+
17
+ function localeDefaults(platform = process.platform, env = process.env) {
18
+ const termux = platform === 'android' || String(env.PREFIX || '').includes('com.termux');
19
+ if (platform === 'darwin') return { lang: 'en_US.UTF-8', ctype: 'UTF-8' };
20
+ if (termux) return { lang: 'en_US.UTF-8', ctype: 'en_US.UTF-8' };
21
+ return { lang: 'C.UTF-8', ctype: 'C.UTF-8' };
22
+ }
23
+
24
+ function withUtf8Locale(source = process.env, { platform = process.platform } = {}) {
25
+ const env = { ...source };
26
+ const defaults = localeDefaults(platform, env);
27
+ const effective = env.LC_ALL || env.LC_CTYPE || env.LANG || '';
28
+ if (!UTF8_RE.test(effective)) {
29
+ if (env.LC_ALL) env.LC_ALL = defaults.lang;
30
+ env.LANG = defaults.lang;
31
+ env.LC_CTYPE = defaults.ctype;
32
+ } else {
33
+ if (!env.LANG) env.LANG = defaults.lang;
34
+ if (!env.LC_CTYPE) env.LC_CTYPE = env.LC_ALL || env.LANG || defaults.ctype;
35
+ }
36
+ if (!env.TERM) env.TERM = 'xterm-256color';
37
+ return env;
38
+ }
39
+
40
+ function minimalRuntimeEnv(source = process.env, opts = {}) {
41
+ const selected = {};
42
+ for (const key of MINIMAL_ENV_KEYS) {
43
+ if (source[key] !== undefined && source[key] !== '') selected[key] = String(source[key]);
44
+ }
45
+ if (!selected.PATH) selected.PATH = '/usr/local/bin:/usr/bin:/bin';
46
+ if (!selected.HOME) selected.HOME = opts.home || os.homedir();
47
+ return withUtf8Locale(selected, opts);
48
+ }
49
+
50
+ module.exports = { MINIMAL_ENV_KEYS, UTF8_RE, localeDefaults, withUtf8Locale, minimalRuntimeEnv };
package/lib/server.js CHANGED
@@ -9,7 +9,7 @@ const { WebSocketServer } = require('ws');
9
9
  const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
10
10
  const { writeConfigAtomic } = require('./cli/init.js');
11
11
  const { listSessions, attachedClients } = require('./tmux/list.js');
12
- const { runAction, pasteToSession } = require('./tmux/actions.js');
12
+ const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
13
13
  const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
14
14
  const { createPreviewSampler } = require('./tmux/preview.js');
15
15
  const { openAttach } = require('./pty/attach.js');
@@ -22,6 +22,7 @@ const VERSION = require('../package.json').version;
22
22
  const { transcribe } = require('./voice/transcribe.js');
23
23
  const { selectProvider } = require('./fleet/provider.js');
24
24
  const { fleetRoutes } = require('./fleet/routes.js');
25
+ const { cellsRoutes } = require('./cells/routes.js');
25
26
  const { fsRoutes } = require('./fs/routes.js');
26
27
  const nodesStore = require('./nodes/store.js');
27
28
  const nodesTunnel = require('./nodes/tunnel.js');
@@ -220,6 +221,14 @@ function createServer(opts = {}) {
220
221
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
221
222
  }));
222
223
  api.use('/fleet', fleetRoutes(fleetP, cfg));
224
+ api.use('/cells', cellsRoutes({
225
+ fleetP,
226
+ instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
227
+ submit: opts.cellSubmit || ((session, text, meta) => submitToSession(cfg.tmuxBin, session, text, {
228
+ engine: meta && meta.engine,
229
+ })),
230
+ readonly: proxyReadonly,
231
+ }));
223
232
  api.use('/decks', decksRoutes({ cfg, decksPath }));
224
233
  api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
225
234
  // /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
47
47
  const nodesCmds = require('../nodes/commands.js');
48
48
  const nodesTunnel = require('../nodes/tunnel.js');
49
49
  const peering = require('../nodes/peering.js');
50
- const { probeHealth } = require('../proxy/federation.js');
50
+ const { probeHealth, waitForHealthyPeer } = require('../proxy/federation.js');
51
51
  const { rotateToken } = require('../auth/token.js');
52
52
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
53
53
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -822,9 +822,10 @@ function settingsRoutes(deps = {}) {
822
822
  if (!started.started && started.reason !== 'already running') {
823
823
  throw new Error(started.reason || 'avvio SSH fallito');
824
824
  }
825
- const ready = await probeHealth({
825
+ const ready = await waitForHealthyPeer({
826
826
  port: node.localPort, token: node.token, expectedInstanceId: node.nodeId,
827
- fetchImpl,
827
+ fetchImpl, attempts: 8, delayMs: 200,
828
+ ...(typeof seams.pairDelay === 'function' ? { delay: seams.pairDelay } : {}),
828
829
  });
829
830
  if (!ready || ready.status !== 'healthy') {
830
831
  const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, name, node.remotePort);
@@ -1,5 +1,10 @@
1
1
  'use strict';
2
2
  const { execFile } = require('node:child_process');
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { isValidSession } = require('../files/store.js');
3
8
 
4
9
  // Window/pane navigation must NOT be emulated with client-side prefix keys
5
10
  // (it depends on each host's bindings, which may be remapped or broken).
@@ -53,6 +58,7 @@ function runAction(tmuxBin, session, name) {
53
58
  }
54
59
 
55
60
  const MAX_PASTE = 4096;
61
+ const MAX_SUBMIT = 8192;
56
62
 
57
63
  // true se text contiene control char (codici 0x00-0x1f e 0x7f). Espresso via
58
64
  // charCode invece di regex perche' il write-layer corrompe gli escape backslash
@@ -86,4 +92,93 @@ function pasteToSession(tmuxBin, session, text) {
86
92
  });
87
93
  }
88
94
 
89
- module.exports = { actionArgs, runAction, ACTIONS, pasteArgs, pasteToSession, scrollArgs };
95
+ function submitTextOk(text) {
96
+ if (typeof text !== 'string' || !text || text.length > MAX_SUBMIT) return false;
97
+ for (let i = 0; i < text.length; i += 1) {
98
+ const c = text.charCodeAt(i);
99
+ if (c === 9 || c === 10 || c === 13) continue;
100
+ if (c < 32 || c === 127) return false;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ function execTmux(execFileImpl, tmuxBin, args, timeout = 5000) {
106
+ return new Promise((resolve) => {
107
+ try {
108
+ execFileImpl(tmuxBin, args, { timeout }, (err, stdout) => {
109
+ resolve({ ok: !err, stdout: String(stdout || '') });
110
+ });
111
+ } catch (_) { resolve({ ok: false, stdout: '' }); }
112
+ });
113
+ }
114
+
115
+ // Consegna un messaggio a un pane esatto e lo sottopone al TUI. Il testo non
116
+ // passa mai in argv: file temporaneo 0600 -> tmux load-buffer -> paste-buffer -p
117
+ // (bracketed paste), poi Enter con una chiamata separata. Il pane id viene
118
+ // risolto prima del paste e verificato di nuovo prima dell'Enter, evitando
119
+ // prefix match e il riuso del solo nome sessione.
120
+ async function submitToSession(tmuxBin, session, text, opts = {}) {
121
+ if (!isValidSession(session) || !submitTextOk(text)) {
122
+ return { submitted: false, reason: 'input non valido' };
123
+ }
124
+ const execFileImpl = opts.execFileImpl || execFile;
125
+ const fsImpl = opts.fsImpl || fs;
126
+ const tmpRoot = opts.tmpdir || os.tmpdir();
127
+ const delay = typeof opts.delay === 'function'
128
+ ? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
129
+ const codexComposer = /^(?:codex|codex-vl)(?:\.|$)/.test(String(opts.engine || ''));
130
+ const nonce = (opts.nonce || crypto.randomBytes(8).toString('hex')).replace(/[^a-f0-9]/g, '').slice(0, 32);
131
+ if (!nonce) return { submitted: false, reason: 'invio non disponibile' };
132
+ const buffer = `ncmsg-${nonce}`;
133
+ const tmp = path.join(tmpRoot, `.nexuscrew-message-${nonce}.txt`);
134
+ let loaded = false;
135
+ try {
136
+ fsImpl.writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
137
+ fsImpl.chmodSync(tmp, 0o600);
138
+ const pane = await execTmux(execFileImpl, tmuxBin,
139
+ ['display-message', '-p', '-t', `=${session}:`, '#{pane_id}']);
140
+ const paneId = pane.stdout.trim();
141
+ if (!pane.ok || !/^%[0-9]+$/.test(paneId)) {
142
+ return { submitted: false, reason: 'sessione non raggiungibile' };
143
+ }
144
+ const load = await execTmux(execFileImpl, tmuxBin, ['load-buffer', '-b', buffer, tmp]);
145
+ if (!load.ok) return { submitted: false, reason: 'buffer non disponibile' };
146
+ loaded = true;
147
+ const paste = await execTmux(execFileImpl, tmuxBin,
148
+ ['paste-buffer', '-p', '-t', paneId, '-b', buffer]);
149
+ if (!paste.ok) return { submitted: false, reason: 'consegna non riuscita' };
150
+ const paneAlive = async () => {
151
+ const verify = await execTmux(execFileImpl, tmuxBin,
152
+ ['display-message', '-p', '-t', paneId, '#{session_name}\t#{pane_dead}\t#{pane_id}']);
153
+ const [verifiedSession, dead, verifiedPane] = verify.stdout.trim().split('\t');
154
+ return verify.ok && verifiedSession === session && dead === '0' && verifiedPane === paneId;
155
+ };
156
+ // Codex/Codex-VL have a paste-burst window: a too-early Enter can remain
157
+ // inside the composer. C-e is sent as its own tmux command after the paste,
158
+ // then the pane is revalidated before the separate Enter. Other clients
159
+ // still receive a short separation between paste and submit.
160
+ await delay(codexComposer ? 400 : 150);
161
+ if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
162
+ if (codexComposer) {
163
+ const flush = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'C-e']);
164
+ if (!flush.ok) return { submitted: false, reason: 'testo consegnato ma flush composer non riuscito' };
165
+ await delay(300);
166
+ if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
167
+ }
168
+ const enter = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'Enter']);
169
+ if (!enter.ok) return { submitted: false, reason: 'testo consegnato ma invio non riuscito' };
170
+ return { submitted: true, reason: codexComposer
171
+ ? 'bracketed paste + burst flush + Enter separati'
172
+ : 'bracketed paste + Enter separato' };
173
+ } catch (_) {
174
+ return { submitted: false, reason: 'consegna non riuscita' };
175
+ } finally {
176
+ if (loaded) await execTmux(execFileImpl, tmuxBin, ['delete-buffer', '-b', buffer]);
177
+ try { fsImpl.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
178
+ }
179
+ }
180
+
181
+ module.exports = {
182
+ actionArgs, runAction, ACTIONS, pasteArgs, pasteToSession, scrollArgs,
183
+ submitTextOk, submitToSession, MAX_SUBMIT,
184
+ };
package/lib/tmux/list.js CHANGED
@@ -17,7 +17,8 @@ function parseSessions(raw) {
17
17
  activity: Number(activity) || 0,
18
18
  cmd: cmd || '',
19
19
  };
20
- });
20
+ })
21
+ .filter((session) => session.windows > 0);
21
22
  }
22
23
 
23
24
  function listSessions(tmuxBin = 'tmux') {
@@ -48,15 +48,42 @@ function compareVersions(a, b) {
48
48
  }
49
49
 
50
50
  function registryVersion(stdout) {
51
- const raw = String(stdout || '').trim();
52
- let value = raw;
51
+ const raw = String(stdout || '')
52
+ .replace(/^\uFEFF/, '')
53
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
54
+ .trim();
55
+ const candidates = [];
56
+ const add = (value) => {
57
+ if (typeof value !== 'string') return;
58
+ const parsed = parseVersion(value);
59
+ if (parsed) candidates.push(parsed.raw);
60
+ };
53
61
  try {
54
62
  const decoded = JSON.parse(raw);
55
- if (typeof decoded === 'string') value = decoded;
56
- } catch (_) { /* npm senza --json o output gia' plain */ }
57
- const parsed = parseVersion(value);
58
- if (!parsed) throw new Error('npm ha restituito una versione non valida');
59
- return parsed.raw;
63
+ if (typeof decoded === 'string') add(decoded);
64
+ else if (Array.isArray(decoded)) decoded.forEach(add);
65
+ else if (decoded && typeof decoded === 'object') add(decoded.version);
66
+ } catch (_) {
67
+ // npm versions differ in whether --json returns a JSON scalar or a plain
68
+ // line. Notices may also precede the value, so inspect complete lines but
69
+ // never extract a semver from the middle of arbitrary text.
70
+ for (const line of raw.split(/\r?\n/)) add(line.trim().replace(/^['"]|['"]$/g, ''));
71
+ }
72
+ const unique = [...new Set(candidates)];
73
+ if (unique.length !== 1) throw new Error('npm ha restituito una versione non valida');
74
+ return unique[0];
75
+ }
76
+
77
+ function stableRuntimeDir(home) {
78
+ const dir = path.join(path.resolve(String(home || '')), '.nexuscrew');
79
+ try {
80
+ const st = fs.lstatSync(dir);
81
+ if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('directory runtime NexusCrew non sicura');
82
+ } catch (error) {
83
+ if (error.code !== 'ENOENT') throw error;
84
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
85
+ }
86
+ return dir;
60
87
  }
61
88
 
62
89
  function scrubError(error) {
@@ -153,5 +180,5 @@ function writeState(file, value) {
153
180
  module.exports = {
154
181
  PACKAGE_NAME, VERSION_RE, parseVersion, compareVersions, registryVersion,
155
182
  scrubError, pidAlive, readLock, acquireUpdateLock, adoptUpdateLock, releaseUpdateLock,
156
- readState, writeState,
183
+ readState, writeState, stableRuntimeDir,
157
184
  };
@@ -6,7 +6,7 @@ const path = require('node:path');
6
6
  const { execFile, spawn } = require('node:child_process');
7
7
  const {
8
8
  PACKAGE_NAME, compareVersions, parseVersion, registryVersion, scrubError, pidAlive, readLock,
9
- acquireUpdateLock, releaseUpdateLock, readState, writeState,
9
+ acquireUpdateLock, releaseUpdateLock, readState, writeState, stableRuntimeDir,
10
10
  } = require('./core.js');
11
11
 
12
12
  const DEFAULT_INITIAL_DELAY_MS = 60 * 1000;
@@ -24,10 +24,11 @@ function isNewer(candidate, current) {
24
24
  try { return compareVersions(candidate, current) > 0; } catch (_) { return false; }
25
25
  }
26
26
 
27
- function lookupLatestNpm({ execFileImpl = execFile, timeoutMs = 20_000 } = {}) {
27
+ function lookupLatestNpm({ execFileImpl = execFile, timeoutMs = 20_000, home = os.homedir(), cwd } = {}) {
28
28
  return new Promise((resolve, reject) => {
29
29
  execFileImpl('npm', ['view', `${PACKAGE_NAME}@latest`, 'version', '--json'], {
30
30
  encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024,
31
+ cwd: cwd || stableRuntimeDir(home),
31
32
  }, (error, stdout) => {
32
33
  if (error) return reject(error);
33
34
  try { resolve(registryVersion(stdout)); } catch (e) { reject(e); }
@@ -42,6 +43,7 @@ function createNpmUpdater(opts = {}) {
42
43
  const logPath = opts.logPath || path.join(home, '.nexuscrew', 'npm-update.log');
43
44
  const lockPath = opts.lockPath || path.join(home, '.nexuscrew', 'npm-update.lock');
44
45
  const runnerPath = opts.runnerPath || path.join(__dirname, 'runner.js');
46
+ const workDir = opts.cwd || stableRuntimeDir(home);
45
47
  const supported = opts.supported === undefined ? isGlobalInstall(opts.packageRoot) : !!opts.supported;
46
48
  const readonly = opts.readonly === true;
47
49
  const lookupLatest = opts.lookupLatest || (() => lookupLatestNpm(opts));
@@ -135,7 +137,8 @@ function createNpmUpdater(opts = {}) {
135
137
  process.execPath, ...runnerArgs]
136
138
  : runnerArgs;
137
139
  child = spawnImpl(bin, argv, {
138
- detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, NEXUSCREW_UPDATE_RUNNER: '1' },
140
+ detached: true, stdio: ['ignore', fd, fd], cwd: workDir,
141
+ env: { ...process.env, NEXUSCREW_UPDATE_RUNNER: '1' },
139
142
  });
140
143
  } catch (e) {
141
144
  releaseUpdateLock(lockPath, reservation.token);