@mmmbuto/nexuscrew 0.7.7 → 0.8.2

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 (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const crypto = require('node:crypto');
6
+
7
+ const SCHEMA_VERSION = 1;
8
+ const MAX_DECKS = 24;
9
+ const MAX_TILES = 9;
10
+ const NAME_RE = /^[a-z0-9-]{1,32}$/;
11
+ const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
12
+
13
+ function validNodeRoute(node) {
14
+ if (!NODE_RE.test(node)) return false;
15
+ const parts = node.split('/');
16
+ return new Set(parts).size === parts.length;
17
+ }
18
+
19
+ function defaultDecksPath(home) {
20
+ return path.join(home || os.homedir(), '.nexuscrew', 'decks.json');
21
+ }
22
+
23
+ function emptyLayout() { return { columns: [] }; }
24
+ function emptyStore() {
25
+ return { schemaVersion: SCHEMA_VERSION, decks: [{ name: 'main', revision: 0, layout: emptyLayout() }] };
26
+ }
27
+
28
+ function validText(s, max) {
29
+ if (typeof s !== 'string' || !s || s.length > max) return false;
30
+ for (let i = 0; i < s.length; i += 1) {
31
+ const c = s.charCodeAt(i);
32
+ if (c <= 0x1f || c === 0x7f) return false;
33
+ }
34
+ return true;
35
+ }
36
+
37
+ function parseLayout(raw) {
38
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(raw.columns)) return null;
39
+ if (raw.columns.length > MAX_TILES) return null;
40
+ const seen = new Set();
41
+ const columns = [];
42
+ let count = 0;
43
+ for (const c of raw.columns) {
44
+ if (!c || typeof c !== 'object' || Array.isArray(c) || !Array.isArray(c.tiles) || !c.tiles.length) return null;
45
+ const width = Number(c.width);
46
+ if (!Number.isFinite(width) || width < 0.2 || width > 100) return null;
47
+ const tiles = [];
48
+ for (const t of c.tiles) {
49
+ if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
50
+ if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
51
+ const height = Number(t.height);
52
+ const fontSize = Number(t.fontSize);
53
+ if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
54
+ if (!Number.isFinite(fontSize) || fontSize < 9 || fontSize > 24) return null;
55
+ const key = t.node ? `${t.node}:${t.session}` : t.session;
56
+ if (seen.has(key) || ++count > MAX_TILES) return null;
57
+ seen.add(key);
58
+ const tile = { session: t.session, height, fontSize };
59
+ if (t.node) tile.node = t.node;
60
+ tiles.push(tile);
61
+ }
62
+ columns.push({ width, tiles });
63
+ }
64
+ return { columns };
65
+ }
66
+
67
+ function parseStore(raw) {
68
+ try {
69
+ const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
70
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)
71
+ || obj.schemaVersion !== SCHEMA_VERSION || !Array.isArray(obj.decks)
72
+ || obj.decks.length < 1 || obj.decks.length > MAX_DECKS) return null;
73
+ const names = new Set();
74
+ const decks = [];
75
+ for (const d of obj.decks) {
76
+ if (!d || typeof d !== 'object' || !NAME_RE.test(d.name) || names.has(d.name)) return null;
77
+ if (!Number.isSafeInteger(d.revision) || d.revision < 0) return null;
78
+ const layout = parseLayout(d.layout);
79
+ if (!layout) return null;
80
+ names.add(d.name);
81
+ decks.push({ name: d.name, revision: d.revision, layout });
82
+ }
83
+ if (!names.has('main')) return null;
84
+ decks.sort((a, b) => (a.name === 'main' ? -1 : b.name === 'main' ? 1 : a.name.localeCompare(b.name)));
85
+ return { schemaVersion: SCHEMA_VERSION, decks };
86
+ } catch (_) { return null; }
87
+ }
88
+
89
+ function loadStore(p) {
90
+ try {
91
+ const st = fs.lstatSync(p);
92
+ if (!st.isFile() || st.isSymbolicLink()) return null;
93
+ return parseStore(fs.readFileSync(p, 'utf8'));
94
+ } catch (_) { return null; }
95
+ }
96
+
97
+ function atomicWrite(p, data) {
98
+ try {
99
+ if (fs.lstatSync(p).isSymbolicLink()) throw new Error('refuse symlink decks.json');
100
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
101
+ const parsed = parseStore(data);
102
+ if (!parsed) throw new Error('decks.json non valido');
103
+ fs.mkdirSync(path.dirname(p), { recursive: true });
104
+ const tmp = path.join(path.dirname(p), `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
105
+ try {
106
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
107
+ fs.chmodSync(tmp, 0o600);
108
+ fs.renameSync(tmp, p);
109
+ } catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
110
+ return parsed;
111
+ }
112
+
113
+ function loadOrCreate(p) {
114
+ const found = loadStore(p);
115
+ if (found) return found;
116
+ try { if (fs.existsSync(p)) throw new Error('decks.json presente ma invalido'); } catch (e) { throw e; }
117
+ return atomicWrite(p, emptyStore());
118
+ }
119
+
120
+ module.exports = {
121
+ SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE,
122
+ defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
123
+ };
@@ -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, readonly = () => false }) {
8
13
  const router = Router();
9
14
  const upload = multer({
10
15
  storage: multer.memoryStorage(),
@@ -12,6 +17,7 @@ function filesRoutes({ cfg, sessionExists, paste }) {
12
17
  });
13
18
 
14
19
  router.post('/upload', (req, res) => {
20
+ if (readonly()) return res.status(403).json({ error: 'READONLY: upload bloccato' });
15
21
  upload.single('file')(req, res, async (err) => {
16
22
  if (err) {
17
23
  const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
@@ -31,6 +37,55 @@ function filesRoutes({ cfg, sessionExists, paste }) {
31
37
  });
32
38
  });
33
39
 
40
+ // Consegna deliverable dal MCP bridge (nc_send_file): copia un file locale
41
+ // nell'outbox della sessione mittente. Path sorgente fail-closed: stringa
42
+ // assoluta, realpath ESISTENTE sotto HOME (i symlink si risolvono PRIMA del
43
+ // check: un link che esce da HOME viene rifiutato), file regolare.
44
+ // F3 (audit): gated READONLY — la copia e' una scrittura su disco; il gate
45
+ // sta PRIMA di ogni altro check (nessun probe di sessione/path in READONLY).
46
+ const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
47
+ router.post('/outbox', express.json({ limit: '8kb' }), (req, res) => {
48
+ try {
49
+ if (bridgeReadonly()) {
50
+ return res.status(403).json({ error: 'READONLY: consegna file bloccata' });
51
+ }
52
+ const b = req.body || {};
53
+ const session = String(b.session || '');
54
+ if (!store.isValidSession(session) || !sessionExists(session)) {
55
+ return res.status(404).json({ error: 'sessione tmux inesistente' });
56
+ }
57
+ if (typeof b.path !== 'string' || !path.isAbsolute(b.path)) {
58
+ return res.status(400).json({ error: 'path deve essere assoluto' });
59
+ }
60
+ if (b.caption !== undefined && (typeof b.caption !== 'string' || b.caption.length > 500)) {
61
+ return res.status(400).json({ error: 'caption deve essere una stringa (max 500)' });
62
+ }
63
+ const home = cfg.home || os.homedir();
64
+ let src;
65
+ try { src = fs.realpathSync(b.path); } catch (_) {
66
+ return res.status(404).json({ error: 'file sorgente inesistente' });
67
+ }
68
+ const homeReal = fs.realpathSync(home);
69
+ if (src !== homeReal && !src.startsWith(homeReal + path.sep)) {
70
+ return res.status(400).json({ error: 'path fuori da HOME' });
71
+ }
72
+ if (!fs.statSync(src).isFile()) {
73
+ return res.status(400).json({ error: 'path non e\' un file regolare' });
74
+ }
75
+ const saved = store.saveDeliverable(cfg.filesRoot, session, src);
76
+ if (!saved) return res.status(400).json({ error: 'sessione invalida' });
77
+ // Notify best-effort (il badge outbox lo genera comunque il watcher).
78
+ if (notifier) {
79
+ notifier.emit({
80
+ title: `file da ${session}`,
81
+ body: b.caption ? `${saved.name} — ${b.caption}` : saved.name,
82
+ session,
83
+ }).catch(() => {});
84
+ }
85
+ res.json({ name: saved.name, box: 'outbox', size: saved.size });
86
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
87
+ });
88
+
34
89
  router.get('/', (req, res) => {
35
90
  const session = String(req.query.session || '');
36
91
  if (!store.isValidSession(session)) return res.status(400).json({ error: 'sessione invalida' });
@@ -50,6 +105,7 @@ function filesRoutes({ cfg, sessionExists, paste }) {
50
105
  });
51
106
 
52
107
  router.delete('/', (req, res) => {
108
+ if (readonly()) return res.status(403).json({ error: 'READONLY: eliminazione file bloccata' });
53
109
  const ok = store.removeFile(
54
110
  cfg.filesRoot, String(req.query.session || ''), String(req.query.box || ''), String(req.query.name || ''),
55
111
  );
@@ -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
+ publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
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,28 @@ 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 needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
277
+ const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
278
+ const engines = cache.defs.engines.map((e) => {
279
+ const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
280
+ return {
281
+ id: e.id, label: e.label, rc: !!e.rc,
282
+ ...(managed ? {
283
+ kind: 'managed', client: managed.client, provider: managed.provider,
284
+ model: e.managed.model || managed.defaultModel || '',
285
+ models: managed.provider === 'ollama-cloud' ? ollamaModels
286
+ : (managed.client === 'pi' ? (e.managed.provider === 'custom' ? [e.managed.model] : (piModels[e.managed.provider] || [])) : managed.models),
287
+ configured: managed.configured, reason: managed.reason,
288
+ } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
289
+ };
290
+ });
248
291
  return {
249
292
  available: true,
250
293
  provider: 'builtin',
@@ -269,10 +312,16 @@ async function createBuiltinFleet(cfg = {}) {
269
312
  if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
270
313
  const engine = findEngine(defs, cell.engine);
271
314
  if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
315
+ let launchEngine = engine;
316
+ if (engine.managed) {
317
+ const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
318
+ if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
319
+ launchEngine = resolved.engine;
320
+ }
272
321
 
273
322
  // (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}`);
323
+ const trust = validateCommandTrust(launchEngine.command);
324
+ if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
276
325
  // (3) cwd reale sotto la home
277
326
  const realCwd = resolveCwd(cell.cwd, home);
278
327
  if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
@@ -281,28 +330,28 @@ async function createBuiltinFleet(cfg = {}) {
281
330
  // '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
282
331
  // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
283
332
  // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
284
- const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine, cell });
333
+ const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
285
334
  argv.splice(2, 0, '-P', '-F', '#{pane_id}');
286
335
  const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
287
336
  if (launch.err) {
288
337
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
289
338
  const why = /duplicate session/i.test(launch.stderr)
290
339
  ? 'sessione già in esecuzione'
291
- : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, engine, cell)}`;
340
+ : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
292
341
  throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
293
342
  }
294
343
 
295
344
  // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
296
345
  // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
297
346
  let prompt = null;
298
- if (engine.promptMode === 'send-keys' && cell.prompt) {
347
+ if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
299
348
  const paneId = launch.stdout.trim().split('\n')[0] || '';
300
349
  const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
301
350
  prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
302
351
  env: minimalEnv(),
303
352
  readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : 400,
304
353
  target,
305
- engine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
354
+ engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
306
355
  });
307
356
  }
308
357
  cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
@@ -332,13 +381,14 @@ async function createBuiltinFleet(cfg = {}) {
332
381
  return up(cellId);
333
382
  }
334
383
 
335
- async function setEngine(cellId, engId) {
384
+ async function setEngine(cellId, engId, opts = {}) {
336
385
  if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
337
386
  const defs = reloadDefs();
338
387
  if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
339
388
  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 };
389
+ await mutate(defs, (d) => applyCellEngineModel(findCell(d, cellId), engId, opts.model, typeof opts.model === 'string' && opts.model !== ''));
390
+ const saved = reloadDefs(); const target = findCell(saved, cellId);
391
+ return { ok: true, engine: engId, model: target.model || '' };
342
392
  }
343
393
 
344
394
  async function setBoot(cellId, enabled) {
@@ -359,13 +409,31 @@ async function createBuiltinFleet(cfg = {}) {
359
409
  await mutate(reloadDefs(), (d) => { d.engines.push(def); });
360
410
  return { ok: true, id: def.id };
361
411
  }
362
- async function editEngine(id, patch) {
412
+ async function editEngine(id, patch, envChanges) {
363
413
  if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
364
414
  if (!id) throw httpError(400, 'id engine mancante');
365
415
  const defs = reloadDefs();
366
416
  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 };
417
+ if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'env'))) {
418
+ throw httpError(400, 'id ed env non sono modificabili tramite patch generica');
419
+ }
420
+ await mutate(defs, (d) => {
421
+ const target = findEngine(d, id);
422
+ for (const [key, value] of Object.entries(patch || {})) {
423
+ if (value === null) delete target[key]; else target[key] = value;
424
+ }
425
+ if (envChanges !== undefined) {
426
+ if (!envChanges || typeof envChanges !== 'object' || Array.isArray(envChanges)) throw httpError(400, 'envChanges non valido');
427
+ const next = { ...(target.env || {}) };
428
+ for (const key of Array.isArray(envChanges.remove) ? envChanges.remove : []) delete next[key];
429
+ if (envChanges.set && typeof envChanges.set === 'object' && !Array.isArray(envChanges.set)) {
430
+ for (const [key, value] of Object.entries(envChanges.set)) next[key] = value;
431
+ }
432
+ target.env = next;
433
+ }
434
+ });
435
+ const sessions = await refreshSessions();
436
+ return { ok: true, activeCells: defs.cells.filter((c) => c.engine === id && sessions.has(c.tmuxSession)).map((c) => c.id) };
369
437
  }
370
438
  async function removeEngine(id) {
371
439
  if (readonly()) throw httpError(403, 'READONLY: remove-engine bloccato');
@@ -373,7 +441,14 @@ async function createBuiltinFleet(cfg = {}) {
373
441
  if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
374
442
  const used = defs.cells.filter((c) => c.engine === id);
375
443
  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); });
444
+ await mutate(defs, (d) => {
445
+ d.engines = d.engines.filter((e) => e.id !== id);
446
+ for (const cell of d.cells) {
447
+ if (!cell.models || !Object.prototype.hasOwnProperty.call(cell.models, id)) continue;
448
+ delete cell.models[id];
449
+ if (!Object.keys(cell.models).length) delete cell.models;
450
+ }
451
+ });
377
452
  return { ok: true };
378
453
  }
379
454
 
@@ -389,17 +464,54 @@ async function createBuiltinFleet(cfg = {}) {
389
464
  if (!id) throw httpError(400, 'id cell mancante');
390
465
  const defs = reloadDefs();
391
466
  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 };
467
+ if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'tmuxSession'))) {
468
+ throw httpError(400, 'id e tmuxSession sono immutabili');
469
+ }
470
+ await mutate(defs, (d) => {
471
+ const target = findCell(d, id);
472
+ const hasEngine = typeof patch?.engine === 'string';
473
+ const hasModel = Object.prototype.hasOwnProperty.call(patch || {}, 'model');
474
+ for (const [key, value] of Object.entries(patch || {})) {
475
+ if (key === 'engine' || key === 'model') continue;
476
+ if (value === null) delete target[key]; else target[key] = value;
477
+ }
478
+ if (hasEngine || hasModel) applyCellEngineModel(target, hasEngine ? patch.engine : target.engine, patch?.model, hasModel);
479
+ });
480
+ const sessions = await refreshSessions();
481
+ return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
394
482
  }
395
- async function removeCell(id) {
483
+ async function removeCell(id, opts = {}) {
396
484
  if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
397
- const defs = reloadDefs();
398
- if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
485
+ let defs = reloadDefs();
486
+ const cell = findCell(defs, id);
487
+ if (!cell) throw httpError(400, `cell inesistente: ${id}`);
488
+ const sessions = await refreshSessions();
489
+ if (sessions.has(cell.tmuxSession)) {
490
+ if (opts.stop !== true) throw httpError(409, 'cell attiva: conferma stop e rimozione');
491
+ await down(id);
492
+ defs = reloadDefs();
493
+ }
399
494
  await mutate(defs, (d) => { d.cells = d.cells.filter((c) => c.id !== id); });
400
495
  return { ok: true };
401
496
  }
402
497
 
498
+ // Vista editabile ma secret-safe: gli env values restano write-only.
499
+ function definitions() {
500
+ const defs = reloadDefs();
501
+ return {
502
+ schemaVersion: defs.schemaVersion,
503
+ engines: defs.engines.map((e) => {
504
+ const out = { ...e, envKeys: Object.keys(e.env || {}).sort() };
505
+ delete out.env;
506
+ if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
507
+ return out;
508
+ }),
509
+ cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
510
+ managedCatalog: publicCatalog(),
511
+ managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
512
+ };
513
+ }
514
+
403
515
  // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
404
516
  // invalido: backup predecessore + throw -> httpError(400) (mai garbage).
405
517
  async function mutate(defs, mutator) {
@@ -421,10 +533,24 @@ async function createBuiltinFleet(cfg = {}) {
421
533
  schemaVersion: 1,
422
534
  caps: CAPS,
423
535
  engine: {
536
+ kind: { type: 'enum', values: ['managed', 'custom'], default: 'managed' },
424
537
  id: { type: 'string', required: true, pattern: '^[a-z0-9._-]{1,32}$', max: 32 },
425
538
  label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN, default: '<id>' },
426
539
  rc: { type: 'boolean', required: false, default: false },
427
- command: { type: 'string', required: true, max: CAPS.MAX_COMMAND_LEN, absolute: true },
540
+ managed: {
541
+ type: 'object', requiredFor: 'managed',
542
+ client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi'] },
543
+ provider: { type: 'catalog', source: 'managedCatalog' },
544
+ credentialProfile: { type: 'string', required: false, max: 32 },
545
+ model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
546
+ permissionPolicy: { type: 'enum', values: ['standard', 'unsafe'], default: 'standard' },
547
+ displayName: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_LABEL_LEN },
548
+ protocol: { type: 'catalog', source: 'managedCatalog.protocol' },
549
+ baseUrl: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_COMMAND_LEN },
550
+ envKey: { type: 'string', requiredFor: 'provider=custom', pattern: '^[A-Za-z_][A-Za-z0-9_]{0,63}$' },
551
+ providerId: { type: 'string', requiredFor: 'provider=custom', pattern: '^[a-z][a-z0-9_-]{0,31}$' },
552
+ },
553
+ command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
428
554
  args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
429
555
  env: {
430
556
  type: 'object', required: false, maxKeys: CAPS.MAX_ENV_KEYS,
@@ -448,13 +574,14 @@ async function createBuiltinFleet(cfg = {}) {
448
574
  engine: { type: 'string', required: true, ref: 'engine.id' },
449
575
  boot: { type: 'boolean', required: false, default: false },
450
576
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
577
+ models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
451
578
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
452
579
  },
453
580
  };
454
581
  }
455
582
 
456
583
  function capabilities() {
457
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema'];
584
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema', 'definitions'];
458
585
  }
459
586
 
460
587
  return {
@@ -463,7 +590,7 @@ async function createBuiltinFleet(cfg = {}) {
463
590
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
464
591
  defineEngine, editEngine, removeEngine,
465
592
  defineCell, editCell, removeCell,
466
- schema, capabilities,
593
+ schema, definitions, capabilities,
467
594
  };
468
595
  }
469
596
 
@@ -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
  }