@mmmbuto/nexuscrew 0.8.11 → 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.
@@ -140,24 +140,38 @@ function defaultDefinitions() {
140
140
  };
141
141
  }
142
142
 
143
- function parseEnvFile(file) {
143
+ function parseAssignments(raw) {
144
144
  const out = {};
145
- let raw;
146
- try {
147
- const st = fs.lstatSync(file);
148
- if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077)) return out;
149
- raw = fs.readFileSync(file, 'utf8');
150
- } catch (_) { return out; }
151
145
  for (const line of raw.split(/\r?\n/)) {
152
146
  const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
153
147
  if (!m) continue;
154
148
  let v = m[2];
155
149
  if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
150
+ // This is a data parser, not a shell. Reject syntax that would have a
151
+ // different meaning if sourced instead of treating it as a credential.
152
+ if (/\$\(|`|\x00|\r|\n/.test(v)) continue;
156
153
  out[m[1]] = v;
157
154
  }
158
155
  return out;
159
156
  }
160
157
 
158
+ function parseEnvFile(file) {
159
+ try {
160
+ const st = fs.lstatSync(file);
161
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
162
+ return parseAssignments(fs.readFileSync(file, 'utf8'));
163
+ } catch (_) { return {}; }
164
+ }
165
+
166
+ function parseProviderShellFile(file) {
167
+ try {
168
+ const st = fs.lstatSync(file);
169
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
170
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
171
+ return parseAssignments(fs.readFileSync(file, 'utf8'));
172
+ } catch (_) { return {}; }
173
+ }
174
+
161
175
  function binaryCandidates(client, home) {
162
176
  const prefix = process.env.PREFIX || '';
163
177
  const bin = client;
@@ -180,17 +194,51 @@ function findBinary(client, home) {
180
194
  return null;
181
195
  }
182
196
 
197
+ // Termux reports process.platform === 'android' and deliberately has no
198
+ // /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
199
+ // `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
200
+ // client starts. Detect only that explicit Node shebang and invoke it through
201
+ // the already-running trusted Node executable. Native and shell binaries keep
202
+ // their original direct-exec path.
203
+ function needsExplicitNode(binary, platform = process.platform) {
204
+ if (platform !== 'android') return false;
205
+ try {
206
+ const fd = fs.openSync(binary, 'r');
207
+ try {
208
+ const buffer = Buffer.alloc(160);
209
+ const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
210
+ const first = buffer.subarray(0, length).toString('utf8').split(/\r?\n/, 1)[0];
211
+ return /^#!\s*\/usr\/bin\/env(?:\s+-S)?\s+node(?:\s|$)/.test(first);
212
+ } finally { fs.closeSync(fd); }
213
+ } catch (_) { return false; }
214
+ }
215
+
183
216
  function secretsPath(cfg, home) {
184
217
  return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS || path.join(home, '.nexuscrew', 'providers.env');
185
218
  }
186
219
 
220
+ function shellProvidersPath(cfg, home) {
221
+ return cfg.providerShellPath || process.env.NEXUSCREW_PROVIDER_SHELL
222
+ || path.join(home, '.config', 'ai-shell', 'providers.zsh');
223
+ }
224
+
225
+ function credentialSources(cfg, home) {
226
+ return {
227
+ runtime: cfg.env || process.env,
228
+ shell: parseProviderShellFile(shellProvidersPath(cfg, home)),
229
+ legacy: parseEnvFile(secretsPath(cfg, home)),
230
+ };
231
+ }
232
+
187
233
  function credential(profile, spec, cfg, home) {
188
234
  if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '' };
189
235
  const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
190
- const runtimeEnv = cfg.env || process.env;
191
- let value = runtimeEnv[envKey] || '';
192
- // Preserve 0.8.0 Z.AI/Ollama Cloud behavior, but new Custom never reads disk.
193
- if (!value && profile.legacySecrets) value = parseEnvFile(secretsPath(cfg, home))[envKey] || '';
236
+ const sources = credentialSources(cfg, home);
237
+ // The fixed shell file is already the user's environment source. Values are
238
+ // consumed only in memory and passed to the selected child; never persisted
239
+ // in fleet.json, service files, API responses or logs.
240
+ let value = sources.runtime[envKey] || sources.shell[envKey] || '';
241
+ if (!value && profile.legacySecrets) value = sources.legacy[envKey] || '';
194
242
  return { envKey, value };
195
243
  }
196
244
 
@@ -202,7 +250,9 @@ async function discoverOllamaModels(opts = {}) {
202
250
  if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
203
251
  try {
204
252
  const home = opts.home || require('node:os').homedir();
205
- const apiKey = opts.apiKey || (opts.env || process.env).OLLAMA_API_KEY || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
253
+ const sources = credentialSources(opts, home);
254
+ const apiKey = opts.apiKey || sources.runtime.OLLAMA_API_KEY
255
+ || sources.shell.OLLAMA_API_KEY || sources.legacy.OLLAMA_API_KEY;
206
256
  if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
207
257
  const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
208
258
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -393,7 +443,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
393
443
  if (model) args.push('--model', model);
394
444
  }
395
445
  if (cell?.prompt) args.push(cell.prompt);
396
- return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
446
+ let command = info.binary;
447
+ if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
448
+ command = cfg.nodeExecPath || process.execPath;
449
+ args.unshift(info.binary);
450
+ }
451
+ return { ok: true, info, engine: {
452
+ ...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
453
+ } };
397
454
  }
398
455
 
399
456
  function publicCatalog() {
@@ -410,6 +467,6 @@ function publicCatalog() {
410
467
 
411
468
  module.exports = {
412
469
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
413
- defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
414
- discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
470
+ defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
471
+ discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
415
472
  };
@@ -27,7 +27,7 @@ function fleetRoutes(fleetP, cfg = {}) {
27
27
  const r = express.Router();
28
28
  const smallJson = express.json({ limit: '4kb' });
29
29
  const restoreJson = express.json({ limit: '256kb' });
30
- r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
30
+ r.use((req, res, next) => (req.path === '/restore-cells' || req.path === '/restore-engines' ? restoreJson : smallJson)(req, res, next));
31
31
 
32
32
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
33
33
 
@@ -113,6 +113,11 @@ function fleetRoutes(fleetP, cfg = {}) {
113
113
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
114
114
  r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
115
115
  r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
116
+ r.post('/restore-engines', guard((f, b) => {
117
+ requireCap(f, 'restore');
118
+ if (typeof f.restoreEngines !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
119
+ return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
120
+ }, { mutate: true }));
116
121
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
117
122
  // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
118
123
  r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
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
+ };
@@ -14,6 +14,9 @@ const pidPath = process.env.NEXUSCREW_TUNNEL_PIDFILE;
14
14
  const runId = process.env.NEXUSCREW_TUNNEL_RUN_ID;
15
15
  const stableMsRaw = Number(process.env.NEXUSCREW_TUNNEL_STABLE_MS || 3000);
16
16
  const stableMs = Number.isFinite(stableMsRaw) && stableMsRaw >= 100 ? Math.min(stableMsRaw, 30000) : 3000;
17
+ const ownershipGraceRaw = Number(process.env.NEXUSCREW_TUNNEL_OWNERSHIP_GRACE_MS || 2000);
18
+ const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw >= 100
19
+ ? Math.min(ownershipGraceRaw, 10000) : 2000;
17
20
  if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
18
21
 
19
22
  let child = null;
@@ -21,6 +24,8 @@ let stopping = false;
21
24
  let attempt = 0;
22
25
  let retryTimer = null;
23
26
  let upTimer = null;
27
+ let ownershipWaitTimer = null;
28
+ let ownershipTimer = null;
24
29
 
25
30
  function ownsGeneration() {
26
31
  try {
@@ -48,14 +53,14 @@ function writeState(status, extra = {}) {
48
53
  function scheduleRetry(detail) {
49
54
  if (stopping) return finish();
50
55
  const delayMs = backoffDelay(attempt, { baseMs: 1000, capMs: 60000 });
51
- writeState('retrying', { delayMs, detail });
56
+ if (!writeState('retrying', { delayMs, detail })) return stop();
52
57
  attempt += 1;
53
58
  retryTimer = setTimeout(run, delayMs);
54
59
  }
55
60
 
56
61
  function run() {
57
62
  if (stopping) return finish();
58
- writeState('starting');
63
+ if (!writeState('starting')) return stop();
59
64
  try {
60
65
  child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
61
66
  } catch (e) {
@@ -78,7 +83,7 @@ function run() {
78
83
  upTimer = setTimeout(() => {
79
84
  if (!stopping && child && child.exitCode == null) {
80
85
  attempt = 0;
81
- writeState('transport-ready', { sshPid: child.pid, stableMs });
86
+ if (!writeState('transport-ready', { sshPid: child.pid, stableMs })) stop();
82
87
  }
83
88
  }, stableMs);
84
89
  });
@@ -94,6 +99,8 @@ function run() {
94
99
  function finish() {
95
100
  clearTimeout(retryTimer);
96
101
  clearTimeout(upTimer);
102
+ clearTimeout(ownershipWaitTimer);
103
+ clearInterval(ownershipTimer);
97
104
  if (ownsGeneration()) {
98
105
  writeState('down');
99
106
  try {
@@ -119,4 +126,19 @@ function stop() {
119
126
 
120
127
  process.on('SIGTERM', stop);
121
128
  process.on('SIGINT', stop);
122
- run();
129
+
130
+ // The parent can only write our PID after spawn returns. Give that narrow race
131
+ // a bounded grace window, then enforce generation ownership continuously. A
132
+ // replaced/removed pidfile must stop both supervisor and ssh instead of leaving
133
+ // an invisible retrying orphan behind.
134
+ const ownershipDeadline = Date.now() + ownershipGraceMs;
135
+ function acquireGeneration() {
136
+ if (stopping) return finish();
137
+ if (ownsGeneration()) {
138
+ ownershipTimer = setInterval(() => { if (!ownsGeneration()) stop(); }, 500);
139
+ return run();
140
+ }
141
+ if (Date.now() >= ownershipDeadline) return finish();
142
+ ownershipWaitTimer = setTimeout(acquireGeneration, 20);
143
+ }
144
+ acquireGeneration();
@@ -37,6 +37,11 @@ function assertForwardSpec(node) {
37
37
  if (node.sshPort !== undefined && !store.isPort(node.sshPort)) throw new Error('tunnel: sshPort non valida');
38
38
  if (node.identityFile !== undefined && !store.isAbsPath(node.identityFile)) throw new Error('tunnel: identityFile non valido');
39
39
  if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
40
+ if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
41
+ if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
42
+ if (node.shared === true && node.reversePort !== undefined && !store.isPort(node.localAppPort)) {
43
+ throw new Error('tunnel: Share richiede localAppPort esplicita');
44
+ }
40
45
  if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
41
46
  }
42
47
 
@@ -52,7 +57,7 @@ function buildForwardArgs(node) {
52
57
  // The reverse channel publishes this device back through the hub, so it is
53
58
  // opt-in only. A negotiated reversePort alone must never imply consent.
54
59
  const reverse = node.shared === true && node.reversePort !== undefined ? [
55
- '-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort || node.remotePort}`,
60
+ '-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort}`,
56
61
  ] : [];
57
62
  return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
58
63
  '-L', `127.0.0.1:${node.localPort}:127.0.0.1:${node.remotePort}`,
@@ -246,13 +251,23 @@ function removeStateIfOwned(home, name, meta) {
246
251
  return false;
247
252
  }
248
253
 
249
- function supervisorExited(pid, timeoutMs = 2500) {
254
+ function supervisorExited(pid, timeoutMs = 2500, impl = {}) {
250
255
  const deadline = Date.now() + timeoutMs;
251
256
  const sleeper = new Int32Array(new SharedArrayBuffer(4));
257
+ const pidExistsImpl = impl.pidExistsImpl || pidf.pidExists;
258
+ const procReadImpl = impl.procReadImpl || fs.readFileSync;
259
+ const spawnSyncImpl = impl.spawnSyncImpl || spawnSync;
252
260
  const exited = () => {
253
- if (!pidf.pidExists(pid)) return true;
254
- try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
255
- catch (_) { return false; }
261
+ if (!pidExistsImpl(pid)) return true;
262
+ try { return procReadImpl(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
263
+ catch (_) {
264
+ // macOS has no /proc. `ps` is argv-only and its STAT starts with Z for a
265
+ // zombie, which is no longer a usable supervisor even before wait/reap.
266
+ try {
267
+ const result = spawnSyncImpl('ps', ['-p', String(pid), '-o', 'stat='], { encoding: 'utf8' });
268
+ return !result?.error && /^Z/.test(String(result?.stdout || '').trim());
269
+ } catch (_error) { return false; }
270
+ }
256
271
  };
257
272
  while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
258
273
  return exited();
@@ -418,7 +433,8 @@ function restartTunnel(opts) {
418
433
  // Helper di alto livello: costruisce args dal nodo e avvia il forward.
419
434
  function startForward(opts) {
420
435
  const node = opts.node;
421
- const args = buildForwardArgs({ ...node, localAppPort: opts.localAppPort });
436
+ const localAppPort = opts.localAppPort === undefined ? node.localAppPort : opts.localAppPort;
437
+ const args = buildForwardArgs({ ...node, localAppPort });
422
438
  // NexusCrew already owns retry/backoff in tunnel-supervisor.js. Nesting
423
439
  // autossh inside that supervisor made liveness dishonest: autossh could stay
424
440
  // alive while every SSH child exited 255, so the UI reported "tunnel up".
@@ -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 };