@mmmbuto/nexuscrew 0.7.7 → 0.8.0

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.
Files changed (47) hide show
  1. package/README.md +156 -20
  2. package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
  3. package/frontend/dist/assets/index-DQrDBogT.js +83 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +29 -0
  6. package/frontend/dist/version.json +1 -0
  7. package/lib/auth/middleware.js +7 -1
  8. package/lib/auth/token.js +31 -1
  9. package/lib/cli/commands.js +415 -31
  10. package/lib/cli/doctor.js +160 -0
  11. package/lib/cli/fleet-service.js +16 -3
  12. package/lib/cli/init.js +39 -6
  13. package/lib/cli/path.js +24 -0
  14. package/lib/cli/service.js +14 -3
  15. package/lib/cli/url.js +63 -0
  16. package/lib/config.js +5 -0
  17. package/lib/decks/routes.js +81 -0
  18. package/lib/decks/store.js +117 -0
  19. package/lib/files/routes.js +55 -1
  20. package/lib/files/store.js +16 -1
  21. package/lib/fleet/builtin.js +141 -24
  22. package/lib/fleet/definitions.js +26 -0
  23. package/lib/fleet/managed.js +211 -0
  24. package/lib/fleet/routes.js +5 -4
  25. package/lib/mcp/server.js +362 -0
  26. package/lib/nodes/commands.js +396 -0
  27. package/lib/nodes/store.js +358 -0
  28. package/lib/nodes/tunnel-supervisor.js +102 -0
  29. package/lib/nodes/tunnel.js +300 -0
  30. package/lib/notify/asks.js +150 -0
  31. package/lib/notify/events.js +59 -0
  32. package/lib/notify/notifier.js +37 -0
  33. package/lib/notify/persist.js +73 -0
  34. package/lib/notify/push.js +289 -0
  35. package/lib/notify/routes.js +260 -0
  36. package/lib/proxy/node-proxy.js +292 -0
  37. package/lib/pty/attach.js +34 -9
  38. package/lib/pty/provider.js +21 -6
  39. package/lib/server.js +206 -12
  40. package/lib/settings/routes.js +473 -0
  41. package/lib/ws/bridge.js +10 -1
  42. package/package.json +7 -1
  43. package/skills/nexuscrew-agent/SKILL.md +83 -0
  44. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  45. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  46. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  47. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const express = require('express');
2
6
  const { Router } = require('express');
3
7
  const multer = require('multer');
4
8
  const store = require('./store.js');
5
9
 
6
10
  // Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
7
- function filesRoutes({ cfg, sessionExists, paste }) {
11
+ // notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
12
+ function filesRoutes({ cfg, sessionExists, paste, notifier }) {
8
13
  const router = Router();
9
14
  const upload = multer({
10
15
  storage: multer.memoryStorage(),
@@ -31,6 +36,55 @@ function filesRoutes({ cfg, sessionExists, paste }) {
31
36
  });
32
37
  });
33
38
 
39
+ // Consegna deliverable dal MCP bridge (nc_send_file): copia un file locale
40
+ // nell'outbox della sessione mittente. Path sorgente fail-closed: stringa
41
+ // assoluta, realpath ESISTENTE sotto HOME (i symlink si risolvono PRIMA del
42
+ // check: un link che esce da HOME viene rifiutato), file regolare.
43
+ // F3 (audit): gated READONLY — la copia e' una scrittura su disco; il gate
44
+ // sta PRIMA di ogni altro check (nessun probe di sessione/path in READONLY).
45
+ const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
46
+ router.post('/outbox', express.json({ limit: '8kb' }), (req, res) => {
47
+ try {
48
+ if (bridgeReadonly()) {
49
+ return res.status(403).json({ error: 'READONLY: consegna file bloccata' });
50
+ }
51
+ const b = req.body || {};
52
+ const session = String(b.session || '');
53
+ if (!store.isValidSession(session) || !sessionExists(session)) {
54
+ return res.status(404).json({ error: 'sessione tmux inesistente' });
55
+ }
56
+ if (typeof b.path !== 'string' || !path.isAbsolute(b.path)) {
57
+ return res.status(400).json({ error: 'path deve essere assoluto' });
58
+ }
59
+ if (b.caption !== undefined && (typeof b.caption !== 'string' || b.caption.length > 500)) {
60
+ return res.status(400).json({ error: 'caption deve essere una stringa (max 500)' });
61
+ }
62
+ const home = cfg.home || os.homedir();
63
+ let src;
64
+ try { src = fs.realpathSync(b.path); } catch (_) {
65
+ return res.status(404).json({ error: 'file sorgente inesistente' });
66
+ }
67
+ const homeReal = fs.realpathSync(home);
68
+ if (src !== homeReal && !src.startsWith(homeReal + path.sep)) {
69
+ return res.status(400).json({ error: 'path fuori da HOME' });
70
+ }
71
+ if (!fs.statSync(src).isFile()) {
72
+ return res.status(400).json({ error: 'path non e\' un file regolare' });
73
+ }
74
+ const saved = store.saveDeliverable(cfg.filesRoot, session, src);
75
+ if (!saved) return res.status(400).json({ error: 'sessione invalida' });
76
+ // Notify best-effort (il badge outbox lo genera comunque il watcher).
77
+ if (notifier) {
78
+ notifier.emit({
79
+ title: `file da ${session}`,
80
+ body: b.caption ? `${saved.name} — ${b.caption}` : saved.name,
81
+ session,
82
+ }).catch(() => {});
83
+ }
84
+ res.json({ name: saved.name, box: 'outbox', size: saved.size });
85
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
86
+ });
87
+
34
88
  router.get('/', (req, res) => {
35
89
  const session = String(req.query.session || '');
36
90
  if (!store.isValidSession(session)) return res.status(400).json({ error: 'sessione invalida' });
@@ -77,6 +77,21 @@ function saveUpload(root, session, buffer, origName, now = new Date()) {
77
77
  return { name, path: full, size: buffer.length };
78
78
  }
79
79
 
80
+ // Consegna MCP bridge: copia un file gia' su disco nell'outbox della sessione
81
+ // (badge/watcher scattano da soli). Naming identico a saveUpload (stamp +
82
+ // sanitize + dedup) e COPYFILE_EXCL: mai overwrite. La validazione del path
83
+ // sorgente (assoluto, sotto HOME, realpath) sta nella route, non qui.
84
+ function saveDeliverable(root, session, srcPath, now = new Date()) {
85
+ const dir = ensureBox(root, session, 'outbox');
86
+ if (!dir) return null;
87
+ const base = `${stamp(now)}_${sanitizeName(path.basename(srcPath))}`;
88
+ let name = base;
89
+ for (let i = 1; fs.existsSync(path.join(dir, name)); i += 1) name = `${i}-${base}`;
90
+ const full = path.join(dir, name);
91
+ fs.copyFileSync(srcPath, full, fs.constants.COPYFILE_EXCL);
92
+ return { name, path: full, size: fs.statSync(full).size };
93
+ }
94
+
80
95
  function listBox(root, session, box) {
81
96
  const dir = boxDir(root, session, box);
82
97
  if (!dir) return null;
@@ -123,5 +138,5 @@ function removeFile(root, session, box, name) {
123
138
 
124
139
  module.exports = {
125
140
  isValidSession, sanitizeName, stamp, boxDir, ensureBox,
126
- saveUpload, listBox, resolveExisting, removeFile, BOXES,
141
+ saveUpload, saveDeliverable, listBox, resolveExisting, removeFile, BOXES,
127
142
  };
@@ -28,6 +28,9 @@ const {
28
28
  parseDefinitions, validateCommandTrust, resolveCwd,
29
29
  loadDefinitions, atomicWrite, CAPS,
30
30
  } = require('./definitions.js');
31
+ const {
32
+ CATALOG: MANAGED_CATALOG, describeManaged, resolveManagedEngine, discoverOllamaModels,
33
+ } = require('./managed.js');
31
34
 
32
35
  const STATUS_TTL_MS = 2000;
33
36
 
@@ -183,11 +186,34 @@ async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, targ
183
186
  function draftFrom(defs) {
184
187
  return {
185
188
  schemaVersion: defs.schemaVersion,
186
- engines: defs.engines.map((e) => ({ ...e })),
187
- cells: defs.cells.map((c) => ({ ...c })),
189
+ engines: defs.engines.map((e) => ({
190
+ ...e,
191
+ ...(e.managed ? { managed: { ...e.managed } } : {}),
192
+ ...(e.args ? { args: [...e.args] } : {}),
193
+ ...(e.env ? { env: { ...e.env } } : {}),
194
+ ...(e.model ? { model: { ...e.model } } : {}),
195
+ })),
196
+ cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
188
197
  };
189
198
  }
190
199
 
200
+ // Applica engine + modello come un'unica transizione. Ogni engine ricorda il
201
+ // proprio ultimo modello; passando a un altro engine il modello vecchio non
202
+ // attraversa mai il confine.
203
+ function applyCellEngineModel(target, engineId, model, hasModel) {
204
+ const remembered = { ...(target.models || {}) };
205
+ target.engine = engineId;
206
+ if (hasModel) {
207
+ if (typeof model === 'string' && model) remembered[engineId] = model;
208
+ else delete remembered[engineId];
209
+ }
210
+ const selected = hasModel
211
+ ? (typeof model === 'string' ? model : '')
212
+ : (remembered[engineId] || '');
213
+ if (Object.keys(remembered).length) target.models = remembered; else delete target.models;
214
+ if (selected) target.model = selected; else delete target.model;
215
+ }
216
+
191
217
  // ---------------------------------------------------------------------------
192
218
  // createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
193
219
  // cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
@@ -240,11 +266,25 @@ async function createBuiltinFleet(cfg = {}) {
240
266
  const alive = sessions.has(c.tmuxSession);
241
267
  return {
242
268
  cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
269
+ model: c.model || '', models: { ...(c.models || {}) },
243
270
  active: alive, boot: c.boot, tmux: alive,
244
271
  rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
245
272
  };
246
273
  });
247
- const engines = cache.defs.engines.map((e) => ({ id: e.id, label: e.label, rc: !!e.rc }));
274
+ const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
275
+ const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
276
+ const engines = cache.defs.engines.map((e) => {
277
+ const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
278
+ return {
279
+ id: e.id, label: e.label, rc: !!e.rc,
280
+ ...(managed ? {
281
+ kind: 'managed', client: managed.client, provider: managed.provider,
282
+ model: e.managed.model || managed.defaultModel || '',
283
+ models: managed.provider === 'ollama-cloud' ? ollamaModels : managed.models,
284
+ configured: managed.configured, reason: managed.reason,
285
+ } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
286
+ };
287
+ });
248
288
  return {
249
289
  available: true,
250
290
  provider: 'builtin',
@@ -269,10 +309,16 @@ async function createBuiltinFleet(cfg = {}) {
269
309
  if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
270
310
  const engine = findEngine(defs, cell.engine);
271
311
  if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
312
+ let launchEngine = engine;
313
+ if (engine.managed) {
314
+ const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
315
+ if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
316
+ launchEngine = resolved.engine;
317
+ }
272
318
 
273
319
  // (2) trust del command PRIMA di lanciare
274
- const trust = validateCommandTrust(engine.command);
275
- if (!trust.ok) throw httpError(400, `command non trusted (${engine.command}): ${trust.reason}`);
320
+ const trust = validateCommandTrust(launchEngine.command);
321
+ if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
276
322
  // (3) cwd reale sotto la home
277
323
  const realCwd = resolveCwd(cell.cwd, home);
278
324
  if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
@@ -281,28 +327,28 @@ async function createBuiltinFleet(cfg = {}) {
281
327
  // '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
282
328
  // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
283
329
  // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
284
- const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine, cell });
330
+ const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
285
331
  argv.splice(2, 0, '-P', '-F', '#{pane_id}');
286
332
  const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
287
333
  if (launch.err) {
288
334
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
289
335
  const why = /duplicate session/i.test(launch.stderr)
290
336
  ? 'sessione già in esecuzione'
291
- : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, engine, cell)}`;
337
+ : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
292
338
  throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
293
339
  }
294
340
 
295
341
  // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
296
342
  // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
297
343
  let prompt = null;
298
- if (engine.promptMode === 'send-keys' && cell.prompt) {
344
+ if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
299
345
  const paneId = launch.stdout.trim().split('\n')[0] || '';
300
346
  const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
301
347
  prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
302
348
  env: minimalEnv(),
303
349
  readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : 400,
304
350
  target,
305
- engine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
351
+ engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
306
352
  });
307
353
  }
308
354
  cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
@@ -332,13 +378,14 @@ async function createBuiltinFleet(cfg = {}) {
332
378
  return up(cellId);
333
379
  }
334
380
 
335
- async function setEngine(cellId, engId) {
381
+ async function setEngine(cellId, engId, opts = {}) {
336
382
  if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
337
383
  const defs = reloadDefs();
338
384
  if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
339
385
  if (!findEngine(defs, engId)) throw httpError(400, `engine non valido: ${engId}`);
340
- await mutate(defs, (d) => { findCell(d, cellId).engine = engId; });
341
- return { ok: true };
386
+ await mutate(defs, (d) => applyCellEngineModel(findCell(d, cellId), engId, opts.model, typeof opts.model === 'string' && opts.model !== ''));
387
+ const saved = reloadDefs(); const target = findCell(saved, cellId);
388
+ return { ok: true, engine: engId, model: target.model || '' };
342
389
  }
343
390
 
344
391
  async function setBoot(cellId, enabled) {
@@ -359,13 +406,31 @@ async function createBuiltinFleet(cfg = {}) {
359
406
  await mutate(reloadDefs(), (d) => { d.engines.push(def); });
360
407
  return { ok: true, id: def.id };
361
408
  }
362
- async function editEngine(id, patch) {
409
+ async function editEngine(id, patch, envChanges) {
363
410
  if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
364
411
  if (!id) throw httpError(400, 'id engine mancante');
365
412
  const defs = reloadDefs();
366
413
  if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
367
- await mutate(defs, (d) => { Object.assign(findEngine(d, id), patch || {}); });
368
- return { ok: true };
414
+ if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'env'))) {
415
+ throw httpError(400, 'id ed env non sono modificabili tramite patch generica');
416
+ }
417
+ await mutate(defs, (d) => {
418
+ const target = findEngine(d, id);
419
+ for (const [key, value] of Object.entries(patch || {})) {
420
+ if (value === null) delete target[key]; else target[key] = value;
421
+ }
422
+ if (envChanges !== undefined) {
423
+ if (!envChanges || typeof envChanges !== 'object' || Array.isArray(envChanges)) throw httpError(400, 'envChanges non valido');
424
+ const next = { ...(target.env || {}) };
425
+ for (const key of Array.isArray(envChanges.remove) ? envChanges.remove : []) delete next[key];
426
+ if (envChanges.set && typeof envChanges.set === 'object' && !Array.isArray(envChanges.set)) {
427
+ for (const [key, value] of Object.entries(envChanges.set)) next[key] = value;
428
+ }
429
+ target.env = next;
430
+ }
431
+ });
432
+ const sessions = await refreshSessions();
433
+ return { ok: true, activeCells: defs.cells.filter((c) => c.engine === id && sessions.has(c.tmuxSession)).map((c) => c.id) };
369
434
  }
370
435
  async function removeEngine(id) {
371
436
  if (readonly()) throw httpError(403, 'READONLY: remove-engine bloccato');
@@ -373,7 +438,14 @@ async function createBuiltinFleet(cfg = {}) {
373
438
  if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
374
439
  const used = defs.cells.filter((c) => c.engine === id);
375
440
  if (used.length) throw httpError(400, `engine in uso da ${used.length} cella/e: rimuovi prima la cella`);
376
- await mutate(defs, (d) => { d.engines = d.engines.filter((e) => e.id !== id); });
441
+ await mutate(defs, (d) => {
442
+ d.engines = d.engines.filter((e) => e.id !== id);
443
+ for (const cell of d.cells) {
444
+ if (!cell.models || !Object.prototype.hasOwnProperty.call(cell.models, id)) continue;
445
+ delete cell.models[id];
446
+ if (!Object.keys(cell.models).length) delete cell.models;
447
+ }
448
+ });
377
449
  return { ok: true };
378
450
  }
379
451
 
@@ -389,17 +461,54 @@ async function createBuiltinFleet(cfg = {}) {
389
461
  if (!id) throw httpError(400, 'id cell mancante');
390
462
  const defs = reloadDefs();
391
463
  if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
392
- await mutate(defs, (d) => { Object.assign(findCell(d, id), patch || {}); });
393
- return { ok: true };
464
+ if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'tmuxSession'))) {
465
+ throw httpError(400, 'id e tmuxSession sono immutabili');
466
+ }
467
+ await mutate(defs, (d) => {
468
+ const target = findCell(d, id);
469
+ const hasEngine = typeof patch?.engine === 'string';
470
+ const hasModel = Object.prototype.hasOwnProperty.call(patch || {}, 'model');
471
+ for (const [key, value] of Object.entries(patch || {})) {
472
+ if (key === 'engine' || key === 'model') continue;
473
+ if (value === null) delete target[key]; else target[key] = value;
474
+ }
475
+ if (hasEngine || hasModel) applyCellEngineModel(target, hasEngine ? patch.engine : target.engine, patch?.model, hasModel);
476
+ });
477
+ const sessions = await refreshSessions();
478
+ return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
394
479
  }
395
- async function removeCell(id) {
480
+ async function removeCell(id, opts = {}) {
396
481
  if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
397
- const defs = reloadDefs();
398
- if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
482
+ let defs = reloadDefs();
483
+ const cell = findCell(defs, id);
484
+ if (!cell) throw httpError(400, `cell inesistente: ${id}`);
485
+ const sessions = await refreshSessions();
486
+ if (sessions.has(cell.tmuxSession)) {
487
+ if (opts.stop !== true) throw httpError(409, 'cell attiva: conferma stop e rimozione');
488
+ await down(id);
489
+ defs = reloadDefs();
490
+ }
399
491
  await mutate(defs, (d) => { d.cells = d.cells.filter((c) => c.id !== id); });
400
492
  return { ok: true };
401
493
  }
402
494
 
495
+ // Vista editabile ma secret-safe: gli env values restano write-only.
496
+ function definitions() {
497
+ const defs = reloadDefs();
498
+ return {
499
+ schemaVersion: defs.schemaVersion,
500
+ engines: defs.engines.map((e) => {
501
+ const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
502
+ delete out.env;
503
+ if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
504
+ return out;
505
+ }),
506
+ cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
507
+ managedCatalog: MANAGED_CATALOG.map((p) => ({ ...p })),
508
+ managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
509
+ };
510
+ }
511
+
403
512
  // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
404
513
  // invalido: backup predecessore + throw -> httpError(400) (mai garbage).
405
514
  async function mutate(defs, mutator) {
@@ -421,10 +530,17 @@ async function createBuiltinFleet(cfg = {}) {
421
530
  schemaVersion: 1,
422
531
  caps: CAPS,
423
532
  engine: {
533
+ kind: { type: 'enum', values: ['managed', 'custom'], default: 'managed' },
424
534
  id: { type: 'string', required: true, pattern: '^[a-z0-9._-]{1,32}$', max: 32 },
425
535
  label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN, default: '<id>' },
426
536
  rc: { type: 'boolean', required: false, default: false },
427
- command: { type: 'string', required: true, max: CAPS.MAX_COMMAND_LEN, absolute: true },
537
+ managed: {
538
+ type: 'object', requiredFor: 'managed',
539
+ client: { type: 'enum', values: ['claude', 'codex-vl'] },
540
+ provider: { type: 'enum', values: ['native', 'ollama-cloud', 'zai-a', 'zai-p'] },
541
+ model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
542
+ },
543
+ command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
428
544
  args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
429
545
  env: {
430
546
  type: 'object', required: false, maxKeys: CAPS.MAX_ENV_KEYS,
@@ -448,13 +564,14 @@ async function createBuiltinFleet(cfg = {}) {
448
564
  engine: { type: 'string', required: true, ref: 'engine.id' },
449
565
  boot: { type: 'boolean', required: false, default: false },
450
566
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
567
+ models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
451
568
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
452
569
  },
453
570
  };
454
571
  }
455
572
 
456
573
  function capabilities() {
457
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema'];
574
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema', 'definitions'];
458
575
  }
459
576
 
460
577
  return {
@@ -463,7 +580,7 @@ async function createBuiltinFleet(cfg = {}) {
463
580
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
464
581
  defineEngine, editEngine, removeEngine,
465
582
  defineCell, editCell, removeCell,
466
- schema, capabilities,
583
+ schema, definitions, capabilities,
467
584
  };
468
585
  }
469
586
 
@@ -11,6 +11,7 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
  const crypto = require('node:crypto');
14
+ const { normalizeManagedSpec } = require('./managed.js');
14
15
 
15
16
  // --- Cap + identita' (dichiarati; ragionevoli per un file di flotta locale) ---
16
17
  const SCHEMA_VERSION = 1;
@@ -145,6 +146,17 @@ function parseEngine(e) {
145
146
  rc = e.rc;
146
147
  }
147
148
 
149
+ // Managed: NexusCrew conosce client/provider e compone internamente il
150
+ // processo. Nessun command/env/argv o segreto vive nella definizione.
151
+ if (e.managed !== undefined) {
152
+ const managed = normalizeManagedSpec(e.managed);
153
+ if (!managed) return null;
154
+ for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
155
+ if (e[key] !== undefined) return null;
156
+ }
157
+ return { id: e.id, label, rc, managed };
158
+ }
159
+
148
160
  // command (obbligatorio, stringa non vuota; il trust si verifica a parte)
149
161
  if (typeof e.command !== 'string' || !e.command || e.command.length > MAX_COMMAND_LEN) return null;
150
162
 
@@ -229,6 +241,19 @@ function parseCell(c, engineIds) {
229
241
  model = c.model;
230
242
  }
231
243
 
244
+ // Ultimo modello per engine, persistito per cella. Consente di tornare a un
245
+ // provider e ritrovare la scelta precedente senza trascinarla su altri engine.
246
+ let models = {};
247
+ if (c.models !== undefined) {
248
+ if (!c.models || typeof c.models !== 'object' || Array.isArray(c.models)) return null;
249
+ const entries = Object.entries(c.models);
250
+ if (entries.length > MAX_ENGINES) return null;
251
+ for (const [engineId, value] of entries) {
252
+ if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
253
+ models[engineId] = value;
254
+ }
255
+ }
256
+
232
257
  // prompt (opzionale, cap)
233
258
  let prompt;
234
259
  if (c.prompt !== undefined) {
@@ -254,6 +279,7 @@ function parseCell(c, engineIds) {
254
279
 
255
280
  const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
256
281
  if (model !== undefined) out.model = model;
282
+ if (Object.keys(models).length) out.models = models;
257
283
  if (prompt !== undefined) out.prompt = prompt;
258
284
  return out;
259
285
  }
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+ // Profili engine gestiti da NexusCrew. La configurazione descrive client e
3
+ // provider; command/argv/env effettivi vengono composti qui, senza shell e senza
4
+ // salvare segreti in fleet.json. I profili custom v1 restano supportati.
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const OLLAMA_CLOUD_MODELS = Object.freeze([
9
+ 'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
10
+ 'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
11
+ ]);
12
+ const OLLAMA_CONTEXT = Object.freeze({
13
+ 'glm-5.2': 1000000,
14
+ 'kimi-k2.7-code': 262144,
15
+ 'deepseek-v4-pro': 524288,
16
+ 'minimax-m3': 524288,
17
+ 'qwen3.5:397b': 262144,
18
+ 'deepseek-v4-flash': 1048576,
19
+ 'mistral-large-3:675b': 262144,
20
+ 'gemma4:31b': 262144,
21
+ });
22
+
23
+ const CATALOG = Object.freeze([
24
+ { id: 'claude.native', client: 'claude', provider: 'native', label: 'Claude · Native', auth: 'login', endpoint: 'Anthropic account', rc: true, default: true },
25
+ { id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'Codex-VL · Native', auth: 'login', endpoint: 'OpenAI account', rc: false, default: true },
26
+ { id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
27
+ { id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Codex-VL · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
28
+ { id: 'claude.zai-a', client: 'claude', provider: 'zai-a', label: 'Claude · Z.AI A', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
29
+ { id: 'claude.zai-p', client: 'claude', provider: 'zai-p', label: 'Claude · Z.AI P', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
30
+ ]);
31
+
32
+ const byPair = (client, provider) => CATALOG.find((p) => p.client === client && p.provider === provider) || null;
33
+
34
+ function normalizeManagedSpec(value) {
35
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
36
+ const keys = Object.keys(value);
37
+ if (keys.some((k) => !['client', 'provider', 'model'].includes(k))) return null;
38
+ const profile = byPair(value.client, value.provider);
39
+ if (!profile) return null;
40
+ const model = value.model === undefined ? (profile.model || '') : value.model;
41
+ if (typeof model !== 'string' || model.length > 128) return null;
42
+ return { client: profile.client, provider: profile.provider, model };
43
+ }
44
+
45
+ function defaultDefinitions() {
46
+ return {
47
+ schemaVersion: 1,
48
+ engines: CATALOG.filter((p) => p.default).map((p) => ({
49
+ id: p.id, label: p.label, rc: p.rc,
50
+ managed: { client: p.client, provider: p.provider, model: p.model || '' },
51
+ })),
52
+ cells: [],
53
+ };
54
+ }
55
+
56
+ function parseEnvFile(file) {
57
+ const out = {};
58
+ let raw;
59
+ try {
60
+ const st = fs.lstatSync(file);
61
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077)) return out;
62
+ raw = fs.readFileSync(file, 'utf8');
63
+ } catch (_) { return out; }
64
+ for (const line of raw.split(/\r?\n/)) {
65
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
66
+ if (!m) continue;
67
+ let value = m[2];
68
+ if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) value = value.slice(1, -1);
69
+ out[m[1]] = value;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function binaryCandidates(client, home) {
75
+ const prefix = process.env.PREFIX || '';
76
+ if (client === 'claude') return [path.join(home, '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', prefix && path.join(prefix, 'bin', 'claude')].filter(Boolean);
77
+ return [path.join(home, '.local', 'bin', 'codex-vl'), '/usr/local/bin/codex-vl', '/opt/homebrew/bin/codex-vl', prefix && path.join(prefix, 'bin', 'codex-vl')].filter(Boolean);
78
+ }
79
+
80
+ function findBinary(client, home) {
81
+ for (const candidate of binaryCandidates(client, home)) {
82
+ try {
83
+ const real = fs.realpathSync(candidate);
84
+ const st = fs.lstatSync(real);
85
+ if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
86
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
87
+ return real;
88
+ } catch (_) { /* next */ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function secretsPath(cfg, home) {
94
+ return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS
95
+ || path.join(home, '.nexuscrew', 'providers.env');
96
+ }
97
+
98
+ let ollamaCache = { at: 0, models: [] };
99
+ async function discoverOllamaModels(opts = {}) {
100
+ const now = Date.now();
101
+ const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
102
+ if (!opts.noCache && ollamaCache.models.length && now - ollamaCache.at < ttl) return [...ollamaCache.models];
103
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
104
+ if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
105
+ try {
106
+ const home = opts.home || require('node:os').homedir();
107
+ const apiKey = opts.apiKey || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
108
+ if (!apiKey) throw new Error('OLLAMA_API_KEY mancante');
109
+ const response = await fetchImpl('https://ollama.com/api/tags', {
110
+ headers: { authorization: `Bearer ${apiKey}` },
111
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined,
112
+ });
113
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
114
+ const body = await response.json();
115
+ const available = new Set();
116
+ for (const item of Array.isArray(body.models) ? body.models : []) {
117
+ const name = typeof item?.name === 'string' ? item.name : '';
118
+ if (!/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) || name.length > 128) continue;
119
+ available.add(name);
120
+ }
121
+ // La API diretta espone molti modelli, inclusi modelli in deprecazione. La UI
122
+ // mantiene la shortlist TOP curata, ma solo se ancora realmente disponibile.
123
+ const models = OLLAMA_CLOUD_MODELS.filter((name) => available.has(name));
124
+ if (!models.length) throw new Error('nessun modello cloud');
125
+ ollamaCache = { at: now, models };
126
+ return [...models];
127
+ } catch (_) {
128
+ ollamaCache = { at: now, models: [...OLLAMA_CLOUD_MODELS] };
129
+ return [...OLLAMA_CLOUD_MODELS];
130
+ }
131
+ }
132
+
133
+ function describeManaged(spec, cfg = {}) {
134
+ const normalized = normalizeManagedSpec(spec);
135
+ if (!normalized) return { configured: false, reason: 'profilo managed non valido' };
136
+ const home = cfg.home || require('node:os').homedir();
137
+ const profile = byPair(normalized.client, normalized.provider);
138
+ const binary = findBinary(normalized.client, home);
139
+ const secretFile = secretsPath(cfg, home);
140
+ const secrets = profile.auth === 'login' || profile.auth === 'none' ? {} : parseEnvFile(secretFile);
141
+ const authConfigured = profile.auth === 'login' || profile.auth === 'none' || !!secrets[profile.auth];
142
+ const configured = !!binary && authConfigured;
143
+ return {
144
+ client: profile.client, provider: profile.provider, model: normalized.model,
145
+ endpoint: profile.endpoint, auth: profile.auth, authConfigured, configured,
146
+ models: [...(profile.models || [])], defaultModel: profile.model || '',
147
+ binary: binary || '',
148
+ reason: !binary ? `client ${profile.client} non trovato` : (!authConfigured ? `credenziale ${profile.auth} mancante` : 'ready'),
149
+ };
150
+ }
151
+
152
+ function resolveManagedEngine(engine, cell, cfg = {}) {
153
+ const info = describeManaged(engine.managed, cfg);
154
+ if (!info.configured) return { ok: false, reason: info.reason, info };
155
+ const home = cfg.home || require('node:os').homedir();
156
+ const env = {};
157
+ const args = [];
158
+ const model = (cell && cell.model) || info.model;
159
+ if (info.client === 'claude') {
160
+ args.push('--dangerously-skip-permissions');
161
+ if (info.provider === 'native') {
162
+ if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
163
+ } else if (info.provider === 'ollama-cloud') {
164
+ const secrets = parseEnvFile(secretsPath(cfg, home));
165
+ Object.assign(env, {
166
+ ANTHROPIC_BASE_URL: 'https://ollama.com', ANTHROPIC_AUTH_TOKEN: secrets.OLLAMA_API_KEY,
167
+ ANTHROPIC_API_KEY: '',
168
+ ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model, API_TIMEOUT_MS: '3000000',
169
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || 200000),
170
+ });
171
+ } else {
172
+ const profile = byPair(info.client, info.provider);
173
+ const secrets = parseEnvFile(secretsPath(cfg, home));
174
+ Object.assign(env, {
175
+ ANTHROPIC_BASE_URL: profile.endpoint, ANTHROPIC_AUTH_TOKEN: secrets[profile.auth],
176
+ ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model,
177
+ API_TIMEOUT_MS: '3000000', CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000',
178
+ });
179
+ }
180
+ if (model) args.push('--model', model);
181
+ } else {
182
+ args.push('--dangerously-bypass-approvals-and-sandbox');
183
+ if (info.provider === 'ollama-cloud') {
184
+ const secrets = parseEnvFile(secretsPath(cfg, home));
185
+ env.OPENAI_API_KEY = secrets.OLLAMA_API_KEY;
186
+ if (model) args.push('-m', model);
187
+ const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
188
+ args.push(
189
+ '-c', 'model_provider="ollama_cloud"',
190
+ '-c', 'model_providers.ollama_cloud.name="Ollama Cloud"',
191
+ '-c', 'model_providers.ollama_cloud.base_url="https://ollama.com/v1"',
192
+ '-c', 'model_providers.ollama_cloud.wire_api="responses"',
193
+ '-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000',
194
+ '-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`,
195
+ );
196
+ // Codex carica il catalogo solo all'avvio. Il file e' locale e opzionale:
197
+ // sui device che non lo hanno resta valido il fallback context-window sopra.
198
+ if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
199
+ }
200
+ }
201
+ // Entrambi i client accettano il bootstrap come argomento posizionale. Questo
202
+ // evita di digitare nella TUI prima che sia pronta; argv e' diretto, mai shell.
203
+ if (cell && cell.prompt) args.push(cell.prompt);
204
+ return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
205
+ }
206
+
207
+ module.exports = {
208
+ CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, normalizeManagedSpec, defaultDefinitions, describeManaged,
209
+ discoverOllamaModels,
210
+ resolveManagedEngine, parseEnvFile, findBinary,
211
+ };