@mmmbuto/nexuscrew 0.8.12 → 0.8.14

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.
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const net = require('node:net');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+
9
+ const MAX_PAYLOAD = 512 * 1024;
10
+ const REQUEST_LIMIT = 256;
11
+
12
+ function runtimeDir(cfg = {}) {
13
+ const home = cfg.home || os.homedir();
14
+ return cfg.launchRuntimeDir || path.join(home, '.nexuscrew', 'run');
15
+ }
16
+
17
+ function ensureRuntimeDir(dir) {
18
+ const parent = path.dirname(dir);
19
+ try {
20
+ const parentSt = fs.lstatSync(parent);
21
+ const owned = typeof process.getuid !== 'function' || parentSt.uid === process.getuid();
22
+ if (!parentSt.isSymbolicLink() && parentSt.isDirectory() && owned && (parentSt.mode & 0o077)) {
23
+ fs.chmodSync(parent, 0o700);
24
+ }
25
+ const checked = fs.lstatSync(parent);
26
+ if (checked.isSymbolicLink() || !checked.isDirectory()
27
+ || (typeof process.getuid === 'function' && checked.uid !== process.getuid()) || (checked.mode & 0o077)) {
28
+ throw new Error('unsafe launch broker parent directory');
29
+ }
30
+ } catch (error) {
31
+ if (error.code !== 'ENOENT') throw error;
32
+ const grand = fs.lstatSync(path.dirname(parent));
33
+ if (grand.isSymbolicLink() || !grand.isDirectory()
34
+ || (typeof process.getuid === 'function' && grand.uid !== process.getuid()) || (grand.mode & 0o022)) {
35
+ throw new Error('unsafe launch broker parent root');
36
+ }
37
+ fs.mkdirSync(parent, { mode: 0o700 });
38
+ }
39
+ try {
40
+ const st = fs.lstatSync(dir);
41
+ if (st.isSymbolicLink() || !st.isDirectory()
42
+ || (typeof process.getuid === 'function' && st.uid !== process.getuid()) || (st.mode & 0o077)) {
43
+ throw new Error('unsafe launch broker directory');
44
+ }
45
+ } catch (error) {
46
+ if (error.code !== 'ENOENT') throw error;
47
+ fs.mkdirSync(dir, { mode: 0o700 });
48
+ }
49
+ }
50
+
51
+ function encodePayload(payload) {
52
+ const body = Buffer.from(JSON.stringify(payload), 'utf8');
53
+ if (!body.length || body.length > MAX_PAYLOAD) throw new Error('launch payload too large');
54
+ const head = Buffer.allocUnsafe(4); head.writeUInt32BE(body.length, 0);
55
+ return Buffer.concat([head, body]);
56
+ }
57
+
58
+ function createLaunchBroker(cfg = {}) {
59
+ const dir = runtimeDir(cfg);
60
+ let server = null;
61
+ let socketPath = '';
62
+ let starting = null;
63
+ let closed = false;
64
+ const pending = new Map();
65
+ const ttlMs = Math.max(1000, Number(cfg.launchTokenTtlMs) || 15000);
66
+
67
+ function expire(nonce) {
68
+ const entry = pending.get(nonce);
69
+ if (!entry) return;
70
+ pending.delete(nonce);
71
+ clearTimeout(entry.timer);
72
+ }
73
+
74
+ async function start() {
75
+ if (closed) throw new Error('launch broker closed');
76
+ if (server) return socketPath;
77
+ if (starting) return starting;
78
+ starting = new Promise((resolve, reject) => {
79
+ try {
80
+ ensureRuntimeDir(dir);
81
+ socketPath = path.join(dir, `launch-${process.pid}-${crypto.randomBytes(5).toString('hex')}.sock`);
82
+ } catch (error) { reject(error); return; }
83
+ server = net.createServer((socket) => {
84
+ let raw = '';
85
+ socket.setEncoding('utf8');
86
+ socket.setTimeout(3000, () => socket.destroy());
87
+ socket.on('data', (chunk) => {
88
+ raw += chunk;
89
+ if (raw.length > REQUEST_LIMIT) return socket.destroy();
90
+ const nl = raw.indexOf('\n');
91
+ if (nl === -1) return;
92
+ let nonce = '';
93
+ try { nonce = JSON.parse(raw.slice(0, nl)).nonce; } catch (_) {}
94
+ if (typeof nonce !== 'string' || !/^[a-f0-9]{64}$/.test(nonce)) return socket.destroy();
95
+ const entry = pending.get(nonce);
96
+ if (!entry || entry.expires < Date.now()) { expire(nonce); return socket.destroy(); }
97
+ // Single-use before any bytes leave this process. A second client can
98
+ // never claim the same payload, even while the first socket drains.
99
+ pending.delete(nonce); clearTimeout(entry.timer);
100
+ try { socket.end(entry.encoded); } catch (_) { socket.destroy(); }
101
+ });
102
+ });
103
+ server.once('error', (error) => {
104
+ if (!server?.listening) { server = null; starting = null; reject(error); }
105
+ });
106
+ server.listen(socketPath, () => {
107
+ try { fs.chmodSync(socketPath, 0o600); } catch (_) {}
108
+ server.unref(); starting = null; resolve(socketPath);
109
+ });
110
+ });
111
+ return starting;
112
+ }
113
+
114
+ async function issue(payload) {
115
+ const target = await start();
116
+ const nonce = crypto.randomBytes(32).toString('hex');
117
+ const entry = { encoded: encodePayload(payload), expires: Date.now() + ttlMs, timer: null };
118
+ entry.timer = setTimeout(() => expire(nonce), ttlMs);
119
+ entry.timer.unref?.();
120
+ pending.set(nonce, entry);
121
+ return { socketPath: target, nonce };
122
+ }
123
+
124
+ async function close() {
125
+ if (closed) return;
126
+ closed = true;
127
+ for (const [nonce] of pending) expire(nonce);
128
+ const active = server; server = null;
129
+ if (active) await new Promise((resolve) => active.close(() => resolve()));
130
+ try { if (socketPath) fs.unlinkSync(socketPath); } catch (_) {}
131
+ }
132
+
133
+ return { issue, close, pendingCount: () => pending.size };
134
+ }
135
+
136
+ module.exports = { createLaunchBroker, runtimeDir, ensureRuntimeDir, encodePayload, MAX_PAYLOAD };
@@ -6,6 +6,8 @@ const fs = require('node:fs');
6
6
  const path = require('node:path');
7
7
  const crypto = require('node:crypto');
8
8
  const { execFile } = require('node:child_process');
9
+ const { ENV_KEY_RE } = require('./env-key.js');
10
+ const { readCredentialStore } = require('./credentials.js');
9
11
 
10
12
  const OLLAMA_CLOUD_MODELS = Object.freeze([
11
13
  'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
@@ -20,7 +22,6 @@ const OLLAMA_CONTEXT = Object.freeze({
20
22
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
21
23
  const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
22
24
  const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi' });
23
- const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
24
25
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
25
26
 
26
27
  function validBaseUrl(value) {
@@ -140,24 +141,65 @@ function defaultDefinitions() {
140
141
  };
141
142
  }
142
143
 
143
- function parseEnvFile(file) {
144
+ function parseAssignments(raw) {
144
145
  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
146
  for (const line of raw.split(/\r?\n/)) {
152
147
  const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
153
148
  if (!m) continue;
154
149
  let v = m[2];
155
150
  if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
151
+ // This is a data parser, not a shell. Reject syntax that would have a
152
+ // different meaning if sourced instead of treating it as a credential.
153
+ if (/\$\(|`|\x00|\r|\n|\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)/.test(v)) continue;
156
154
  out[m[1]] = v;
157
155
  }
158
156
  return out;
159
157
  }
160
158
 
159
+ function insideRoot(root, target) {
160
+ const relative = path.relative(root, target);
161
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
162
+ }
163
+
164
+ function safeAllowedRoots(roots = []) {
165
+ const out = [];
166
+ for (const root of roots) {
167
+ try {
168
+ const st = fs.lstatSync(root);
169
+ if (st.isSymbolicLink() || !st.isDirectory() || (st.mode & 0o022)) continue;
170
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) continue;
171
+ out.push(fs.realpathSync(root));
172
+ } catch (_) {}
173
+ }
174
+ return out;
175
+ }
176
+
177
+ function parseEnvFile(file, opts = {}) {
178
+ try {
179
+ const lst = fs.lstatSync(file);
180
+ let target = file;
181
+ if (lst.isSymbolicLink()) {
182
+ const roots = safeAllowedRoots(opts.allowSymlinkRoots);
183
+ if (!roots.length) return {};
184
+ target = fs.realpathSync(file);
185
+ if (!roots.some((root) => insideRoot(root, target))) return {};
186
+ }
187
+ const st = fs.lstatSync(target);
188
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
189
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
190
+ return parseAssignments(fs.readFileSync(target, 'utf8'));
191
+ } catch (_) { return {}; }
192
+ }
193
+
194
+ function parseProviderShellFile(file) {
195
+ try {
196
+ const st = fs.lstatSync(file);
197
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
198
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
199
+ return parseAssignments(fs.readFileSync(file, 'utf8'));
200
+ } catch (_) { return {}; }
201
+ }
202
+
161
203
  function binaryCandidates(client, home) {
162
204
  const prefix = process.env.PREFIX || '';
163
205
  const bin = client;
@@ -180,18 +222,82 @@ function findBinary(client, home) {
180
222
  return null;
181
223
  }
182
224
 
225
+ // Termux reports process.platform === 'android' and deliberately has no
226
+ // /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
227
+ // `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
228
+ // client starts. Detect only that explicit Node shebang and invoke it through
229
+ // the already-running trusted Node executable. Native and shell binaries keep
230
+ // their original direct-exec path.
231
+ function needsExplicitNode(binary, platform = process.platform) {
232
+ if (platform !== 'android') return false;
233
+ try {
234
+ const fd = fs.openSync(binary, 'r');
235
+ try {
236
+ const buffer = Buffer.alloc(160);
237
+ const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
238
+ const first = buffer.subarray(0, length).toString('utf8').split(/\r?\n/, 1)[0];
239
+ return /^#!\s*\/usr\/bin\/env(?:\s+-S)?\s+node(?:\s|$)/.test(first);
240
+ } finally { fs.closeSync(fd); }
241
+ } catch (_) { return false; }
242
+ }
243
+
183
244
  function secretsPath(cfg, home) {
184
245
  return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS || path.join(home, '.nexuscrew', 'providers.env');
185
246
  }
186
247
 
248
+ function shellProvidersPath(cfg, home) {
249
+ return cfg.providerShellPath || process.env.NEXUSCREW_PROVIDER_SHELL
250
+ || path.join(home, '.config', 'ai-shell', 'providers.zsh');
251
+ }
252
+
253
+ function providerKeyPaths(cfg, home) {
254
+ const paths = [
255
+ cfg.providerKeysPath || process.env.NEXUSCREW_PROVIDER_KEYS
256
+ || path.join(home, '.config', 'keys', 'ai.env'),
257
+ cfg.providerSecurePath || process.env.NEXUSCREW_PROVIDER_SECURE
258
+ || path.join(home, '.config', 'secure', '.env'),
259
+ ];
260
+ return [...new Set(paths.filter((file) => typeof file === 'string' && file))];
261
+ }
262
+
263
+ function parseProviderKeyFiles(cfg, home) {
264
+ const values = {};
265
+ // Match providers.zsh ordering: a later secure file may intentionally
266
+ // override the canonical ai.env value. Files remain data-only and must be
267
+ // private regular files owned by the NexusCrew user.
268
+ const files = providerKeyPaths(cfg, home);
269
+ const roots = [...new Set(files.map((file) => path.dirname(path.resolve(file))))];
270
+ for (const file of files) Object.assign(values, parseEnvFile(file, { allowSymlinkRoots: roots }));
271
+ return values;
272
+ }
273
+
274
+ function credentialSources(cfg, home) {
275
+ let local = {};
276
+ try { local = readCredentialStore(cfg, home); } catch (_) { /* unsafe/corrupt store is ignored, never trusted */ }
277
+ return {
278
+ runtime: cfg.env || process.env,
279
+ local,
280
+ shell: parseProviderShellFile(shellProvidersPath(cfg, home)),
281
+ keys: parseProviderKeyFiles(cfg, home),
282
+ legacy: parseEnvFile(secretsPath(cfg, home)),
283
+ };
284
+ }
285
+
187
286
  function credential(profile, spec, cfg, home) {
188
- if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '' };
287
+ if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '', source: profile.auth };
189
288
  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] || '';
194
- return { envKey, value };
289
+ const sources = credentialSources(cfg, home);
290
+ // The fixed shell file is already the user's environment source. Values are
291
+ // consumed only in memory and passed to the selected child; never persisted
292
+ // in fleet.json, service files, API responses or logs.
293
+ if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
294
+ if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'local' };
295
+ if (sources.shell[envKey]) return { envKey, value: sources.shell[envKey], source: 'compatibility' };
296
+ if (sources.keys[envKey]) return { envKey, value: sources.keys[envKey], source: 'compatibility' };
297
+ if (profile.legacySecrets && sources.legacy[envKey]) {
298
+ return { envKey, value: sources.legacy[envKey], source: 'compatibility' };
299
+ }
300
+ return { envKey, value: '', source: 'missing' };
195
301
  }
196
302
 
197
303
  let ollamaCache = { at: 0, models: [] };
@@ -202,7 +308,10 @@ async function discoverOllamaModels(opts = {}) {
202
308
  if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
203
309
  try {
204
310
  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;
311
+ const sources = credentialSources(opts, home);
312
+ const apiKey = opts.apiKey || sources.runtime.OLLAMA_API_KEY
313
+ || sources.local.OLLAMA_API_KEY || sources.shell.OLLAMA_API_KEY || sources.keys.OLLAMA_API_KEY
314
+ || sources.legacy.OLLAMA_API_KEY;
206
315
  if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
207
316
  const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
208
317
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -270,9 +379,12 @@ function describeManaged(spec, cfg = {}) {
270
379
  credentialProfile: normalized.credentialProfile || '', model: normalized.model,
271
380
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
272
381
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
382
+ credentialSource: authConfigured ? cred.source : 'missing',
273
383
  configured: !!binary && authConfigured, models: [...(profile.models || [])], defaultModel: profile.model || '',
274
384
  binary: binary || '', displayName: normalized.displayName || profile.label,
275
- reason: !binary ? `client ${profile.client} not found` : (!authConfigured ? `environment variable ${cred.envKey} missing` : 'ready'),
385
+ reason: !binary ? `client ${profile.client} not found` : (!authConfigured
386
+ ? `credential ${cred.envKey} missing — set it on this device`
387
+ : 'ready'),
276
388
  };
277
389
  }
278
390
 
@@ -393,7 +505,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
393
505
  if (model) args.push('--model', model);
394
506
  }
395
507
  if (cell?.prompt) args.push(cell.prompt);
396
- return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
508
+ let command = info.binary;
509
+ if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
510
+ command = cfg.nodeExecPath || process.execPath;
511
+ args.unshift(info.binary);
512
+ }
513
+ return { ok: true, info, engine: {
514
+ ...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
515
+ } };
397
516
  }
398
517
 
399
518
  function publicCatalog() {
@@ -410,6 +529,7 @@ function publicCatalog() {
410
529
 
411
530
  module.exports = {
412
531
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
413
- defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
414
- discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
532
+ defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
533
+ discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
534
+ providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, ENV_KEY_RE,
415
535
  };
@@ -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
 
@@ -106,6 +106,16 @@ function fleetRoutes(fleetP, cfg = {}) {
106
106
  // Ogni route negozia la capability del provider: mancante → 501 (§9c).
107
107
  r.get('/schema', guard((f) => { requireCap(f, 'schema'); return f.schema(); }));
108
108
  r.get('/definitions', guard((f) => { requireCap(f, 'definitions'); return f.definitions(); }));
109
+ r.get('/credentials/status', guard((f) => {
110
+ requireCap(f, 'credentials'); return f.credentialStatus();
111
+ }));
112
+ r.post('/credentials/set', guard((f, b) => {
113
+ requireCap(f, 'credentials');
114
+ return f.setLocalCredential(String(b.envKey || ''), typeof b.value === 'string' ? b.value : '');
115
+ }, { mutate: true }));
116
+ r.post('/credentials/remove', guard((f, b) => {
117
+ requireCap(f, 'credentials'); return f.removeLocalCredential(String(b.envKey || ''));
118
+ }, { mutate: true }));
109
119
  r.post('/define-engine', guard((f, b) => { requireCap(f, 'define'); return f.defineEngine(b.def); }, { mutate: true }));
110
120
  r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch, b.envChanges); }, { mutate: true }));
111
121
  r.post('/remove-engine', guard((f, b) => { requireCap(f, 'remove'); return f.removeEngine(b.id); }, { mutate: true }));
@@ -113,6 +123,11 @@ function fleetRoutes(fleetP, cfg = {}) {
113
123
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
114
124
  r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
115
125
  r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
126
+ r.post('/restore-engines', guard((f, b) => {
127
+ requireCap(f, 'restore');
128
+ if (typeof f.restoreEngines !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
129
+ return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
130
+ }, { mutate: true }));
116
131
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
117
132
  // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
118
133
  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
+ };