@mmmbuto/nexuscrew 0.8.5 → 0.8.6

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.
@@ -1,4 +1,4 @@
1
- .nc-icon{display:block;flex:0 0 auto}.nc-sheet-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:50;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}.nc-sheet{width:100%;max-width:380px;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:14px;padding:16px;display:flex;flex-direction:column;gap:12px;box-shadow:0 12px 40px #00000080}.nc-power-sheet{max-width:min(620px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow-y:auto;box-sizing:border-box;min-width:0}.nc-sheet-head{display:flex;align-items:center;justify-content:space-between}.nc-sheet-head b{font-size:16px;color:#d8ffe8}.nc-sheet-state{font-size:12px;opacity:.6}.nc-sheet-label{font-size:12px;opacity:.7;text-transform:uppercase;letter-spacing:.5px}.nc-power-config{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 10px;padding:10px;border:1px solid #243;border-radius:8px;background:#0a0e0a;min-width:0;overflow-wrap:anywhere}.nc-power-config span{opacity:.7}.nc-power-config small{grid-column:1 / -1;opacity:.65}.nc-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.nc-check input{accent-color:#2e7d32}.nc-note{font-size:11px;color:#e0b020;opacity:.9}.nc-field{display:flex;flex-direction:column;gap:4px;font-size:12px;opacity:.8}.nc-field input,.nc-field select{padding:9px 10px;font:14px/1.2 inherit;background:#0a0e0a;color:#cfe;border:1px solid #243;border-radius:8px;width:100%;min-width:0;max-width:100%;box-sizing:border-box}.nc-field input:focus,.nc-field select:focus{outline:none;border-color:#2e7d32}.nc-err{color:#f88;font-size:12px}.nc-sheet-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;min-width:0}.nc-btn{border:none;border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer}.nc-btn.ghost{background:none;border:1px solid #243;color:#9fd}.nc-btn.primary{background:#2e7d32;color:#fff}.nc-btn.primary:active{background:#255028}.nc-btn:disabled{opacity:.5;cursor:default}@media(max-width:600px){.nc-power-sheet{max-width:380px}}.nc-home{display:flex;flex-direction:column;gap:12px;padding:18px 14px 10px;color:#cfe;max-width:560px;margin:0 auto;min-height:100dvh;box-sizing:border-box}.nc-home-head{position:relative}.nc-wordmark{font-size:26px;font-weight:700;letter-spacing:.5px;color:#d8ffe8}.nc-cursor{display:inline-block;width:12px;height:22px;margin-left:6px;background:#0c6;vertical-align:-2px;animation:nc-blink 1.1s steps(1) infinite}@keyframes nc-blink{50%{opacity:0}}@media(prefers-reduced-motion:reduce){.nc-cursor{animation:none}}.nc-home-sub{margin-top:4px;font-size:13px;opacity:.65}.nc-head-actions{position:absolute;top:0;right:0;display:inline-flex;gap:6px}.nc-refresh{display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid #243;color:#9fd;border-radius:8px;min-width:40px;min-height:40px}.nc-filter{padding:10px 12px;font:16px/1.2 inherit;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:8px}.nc-filter:focus{outline:none;border-color:#2e7d32}.nc-session-list{display:flex;flex-direction:column;gap:8px}.nc-session{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #1c241c;background:#0e140e;color:#cfe;border-radius:10px;text-align:left;min-height:56px}.nc-session:active{background:#14200f;border-color:#2e7d32}.nc-session .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-session .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-session-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.nc-session-main b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}.nc-session-main small{opacity:.55;font-size:12px}.nc-badge{flex:0 0 auto;background:#2e7d32;color:#fff;border-radius:10px;padding:1px 8px;font-size:12px;line-height:18px}.nc-err{color:#f88;font-size:13px;padding:4px 2px}.nc-empty{opacity:.5;padding:18px 4px;font-size:13px}.nc-home-foot{margin-top:auto;padding-top:14px;display:flex;justify-content:space-between;gap:8px;font-size:11px;opacity:.4}.nc-group{display:flex;flex-direction:column;gap:8px}.nc-group-title{font-size:11px;opacity:.55;text-transform:uppercase;letter-spacing:.5px;padding:6px 2px 2px}.nc-mcard{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #1c241c;background:#0e140e;color:#cfe;border-radius:12px;min-height:56px}.nc-mcard-main{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:12px;background:none;border:none;color:inherit;cursor:pointer;padding:0;text-align:left}.nc-mcard-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.nc-mcard-text b{font-size:14px;color:#d8ffe8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nc-mcard-text small{font-size:12px;opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dot.warn{background:#e0b020;box-shadow:0 0 6px #e0b02088}.nc-group-title.nc-node-title{display:flex;align-items:center;gap:6px}.nc-group-title.nc-node-title .dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:#355}.nc-group-title.nc-node-title .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-mcard .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-mcard .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-rel{flex:0 0 auto;font-size:11px;opacity:.45}.nc-power,.nc-menu{flex:0 0 auto;background:none;border:1px solid #243;color:#9fd;border-radius:8px;min-width:38px;min-height:38px;font-size:17px;cursor:pointer}.nc-power:active,.nc-menu:active{border-color:#2e7d32}.nc-fab{position:fixed;right:18px;bottom:18px;z-index:20;width:54px;height:54px;border-radius:50%;background:#2e7d32;color:#fff;border:none;font-size:28px;line-height:1;cursor:pointer;box-shadow:0 6px 18px #00000073}.nc-fab:active{background:#255028}.nc-lang{display:inline-flex;align-items:center;gap:2px}.nc-act{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:#ffffff0d;border:1px solid rgba(255,255,255,.22);color:#9aa89a;cursor:pointer;flex:0 0 auto;font-size:15px;line-height:1}.nc-act.power.on{color:#0c6;border-color:#00cc668c;background:#00cc661a}.nc-act.power.warn{color:#e8c547;border-color:#e8c5478c;background:#e8c5471a}.nc-act.pin.on{color:#e8c547;border-color:#e8c5478c;background:#e8c54714}.nc-mcard{gap:8px}/**
1
+ .nc-icon{display:block;flex:0 0 auto}.nc-sheet-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:50;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}.nc-launch-overlay{z-index:70}.nc-sheet{width:100%;max-width:380px;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:14px;padding:16px;display:flex;flex-direction:column;gap:12px;box-shadow:0 12px 40px #00000080}.nc-power-sheet{max-width:min(620px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow-y:auto;box-sizing:border-box;min-width:0}.nc-launch-sheet{max-width:min(520px,calc(100vw - 32px));width:100%;min-width:0}.nc-launch-fields{display:flex;flex-direction:column;gap:10px;min-width:0}.nc-launch-fields .nc-field,.nc-launch-fields .nc-field input,.nc-launch-fields .nc-field select{width:100%;min-width:0;max-width:100%;box-sizing:border-box}.nc-sheet-head{display:flex;align-items:center;justify-content:space-between}.nc-sheet-head b{font-size:16px;color:#d8ffe8}.nc-sheet-state{font-size:12px;opacity:.6}.nc-sheet-label{font-size:12px;opacity:.7;text-transform:uppercase;letter-spacing:.5px}.nc-power-config{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 10px;padding:10px;border:1px solid #243;border-radius:8px;background:#0a0e0a;min-width:0;overflow-wrap:anywhere}.nc-power-config span{opacity:.7}.nc-power-config small{grid-column:1 / -1;opacity:.65}.nc-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.nc-check input{accent-color:#2e7d32}.nc-note{font-size:11px;color:#e0b020;opacity:.9}.nc-field{display:flex;flex-direction:column;gap:4px;font-size:12px;opacity:.8}.nc-field input,.nc-field select{padding:9px 10px;font:14px/1.2 inherit;background:#0a0e0a;color:#cfe;border:1px solid #243;border-radius:8px;width:100%;min-width:0;max-width:100%;box-sizing:border-box}.nc-field input:focus,.nc-field select:focus{outline:none;border-color:#2e7d32}.nc-err{color:#f88;font-size:12px}.nc-sheet-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;min-width:0}.nc-btn{border:none;border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer}.nc-btn.ghost{background:none;border:1px solid #243;color:#9fd}.nc-btn.primary{background:#2e7d32;color:#fff}.nc-btn.primary:active{background:#255028}.nc-btn:disabled{opacity:.5;cursor:default}@media(max-width:600px){.nc-power-sheet{max-width:380px}}.nc-home{display:flex;flex-direction:column;gap:12px;padding:18px 14px 10px;color:#cfe;max-width:560px;margin:0 auto;min-height:100dvh;box-sizing:border-box}.nc-home-head{position:relative}.nc-wordmark{font-size:26px;font-weight:700;letter-spacing:.5px;color:#d8ffe8}.nc-cursor{display:inline-block;width:12px;height:22px;margin-left:6px;background:#0c6;vertical-align:-2px;animation:nc-blink 1.1s steps(1) infinite}@keyframes nc-blink{50%{opacity:0}}@media(prefers-reduced-motion:reduce){.nc-cursor{animation:none}}.nc-home-sub{margin-top:4px;font-size:13px;opacity:.65}.nc-head-actions{position:absolute;top:0;right:0;display:inline-flex;gap:6px}.nc-refresh{display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid #243;color:#9fd;border-radius:8px;min-width:40px;min-height:40px}.nc-filter{padding:10px 12px;font:16px/1.2 inherit;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:8px}.nc-filter:focus{outline:none;border-color:#2e7d32}.nc-session-list{display:flex;flex-direction:column;gap:8px}.nc-session{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #1c241c;background:#0e140e;color:#cfe;border-radius:10px;text-align:left;min-height:56px}.nc-session:active{background:#14200f;border-color:#2e7d32}.nc-session .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-session .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-session-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.nc-session-main b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}.nc-session-main small{opacity:.55;font-size:12px}.nc-badge{flex:0 0 auto;background:#2e7d32;color:#fff;border-radius:10px;padding:1px 8px;font-size:12px;line-height:18px}.nc-err{color:#f88;font-size:13px;padding:4px 2px}.nc-empty{opacity:.5;padding:18px 4px;font-size:13px}.nc-home-foot{margin-top:auto;padding-top:14px;display:flex;justify-content:space-between;gap:8px;font-size:11px;opacity:.4}.nc-group{display:flex;flex-direction:column;gap:8px}.nc-group-title{font-size:11px;opacity:.55;text-transform:uppercase;letter-spacing:.5px;padding:6px 2px 2px}.nc-mcard{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #1c241c;background:#0e140e;color:#cfe;border-radius:12px;min-height:56px}.nc-mcard-main{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:12px;background:none;border:none;color:inherit;cursor:pointer;padding:0;text-align:left}.nc-mcard-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.nc-mcard-text b{font-size:14px;color:#d8ffe8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nc-mcard-text small{font-size:12px;opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dot.warn{background:#e0b020;box-shadow:0 0 6px #e0b02088}.nc-group-title.nc-node-title{display:flex;align-items:center;gap:6px}.nc-group-title.nc-node-title .dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:#355}.nc-group-title.nc-node-title .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-mcard .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-mcard .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-rel{flex:0 0 auto;font-size:11px;opacity:.45}.nc-power,.nc-menu{flex:0 0 auto;background:none;border:1px solid #243;color:#9fd;border-radius:8px;min-width:38px;min-height:38px;font-size:17px;cursor:pointer}.nc-power:active,.nc-menu:active{border-color:#2e7d32}.nc-fab{position:fixed;right:18px;bottom:18px;z-index:20;width:54px;height:54px;border-radius:50%;background:#2e7d32;color:#fff;border:none;font-size:28px;line-height:1;cursor:pointer;box-shadow:0 6px 18px #00000073}.nc-fab:active{background:#255028}.nc-lang{display:inline-flex;align-items:center;gap:2px}.nc-act{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:#ffffff0d;border:1px solid rgba(255,255,255,.22);color:#9aa89a;cursor:pointer;flex:0 0 auto;font-size:15px;line-height:1}.nc-act.power.on{color:#0c6;border-color:#00cc668c;background:#00cc661a}.nc-act.power.warn{color:#e8c547;border-color:#e8c5478c;background:#e8c5471a}.nc-act.pin.on{color:#e8c547;border-color:#e8c5478c;background:#e8c54714}.nc-mcard{gap:8px}/**
2
2
  * Copyright (c) 2014 The xterm.js authors. All rights reserved.
3
3
  * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
4
4
  * https://github.com/chjj/term.js
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-UAVxf6SE.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-U_e8auCM.css">
14
+ <script type="module" crossorigin src="/assets/index-CEFh0hI4.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-kX5IUxt4.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.5"}
1
+ {"version":"0.8.6"}
@@ -185,8 +185,13 @@ function selectProviderModeSync(cfg = {}) {
185
185
 
186
186
  // external: binario fidato (regular file, exec, non world-writable, no symlink).
187
187
  // lazy-require: niente costo/accoppiamento all'import del modulo (usato solo dal companion).
188
- const { binTrusted } = require('../fleet/index.js'); // sync (audit F3)
189
- const extTrusted = !!(cfg.fleetBin && binTrusted(cfg.fleetBin));
188
+ const { binTrusted, externalFleetCandidates } = require('../fleet/index.js'); // sync (audit F3)
189
+ // Deve usare lo stesso insieme di path del resolver runtime asincrono. In
190
+ // particolare Termux installa normalmente `fleet` in $PREFIX/bin: se init
191
+ // guardasse soltanto cfg.fleetBin potrebbe installare il companion builtin e
192
+ // il runtime scegliere poi l'external, creando due proprietari del boot.
193
+ const trustedExternal = externalFleetCandidates(cfg).find((candidate) => binTrusted(candidate)) || null;
194
+ const extTrusted = !!trustedExternal;
190
195
 
191
196
  // builtin: fleet.json valido (loadDefinitions e' sync; garbage -> null = fail-closed).
192
197
  const { loadDefinitions } = require('../fleet/definitions.js');
@@ -209,7 +214,7 @@ function selectProviderModeSync(cfg = {}) {
209
214
  if (forced) return { mode: 'disabled', reason: `fail-closed: provider forzato "${forced}" non riconosciuto` };
210
215
 
211
216
  // auto: external fidato vince (no-doppio-boot), poi builtin, poi disabled.
212
- if (extTrusted) return { mode: 'external', reason: 'auto: external fidato configurato (skip companion)' };
217
+ if (extTrusted) return { mode: 'external', reason: `auto: external fidato configurato (${trustedExternal}; skip companion)` };
213
218
  if (builtinAvail) return { mode: 'builtin', reason: 'auto: builtin (fleet.json valido)' };
214
219
  return { mode: 'disabled', reason: 'auto: nessun provider disponibile (no external fidato, no fleet.json)' };
215
220
  }
@@ -26,7 +26,7 @@ const path = require('node:path');
26
26
  const { execFile } = require('node:child_process');
27
27
  const {
28
28
  parseDefinitions, validateCommandTrust, resolveCwd,
29
- loadDefinitions, atomicWrite, CAPS,
29
+ loadDefinitions, atomicWrite, CAPS, validTmuxName,
30
30
  } = require('./definitions.js');
31
31
  const {
32
32
  publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
@@ -193,24 +193,36 @@ function draftFrom(defs) {
193
193
  ...(e.env ? { env: { ...e.env } } : {}),
194
194
  ...(e.model ? { model: { ...e.model } } : {}),
195
195
  })),
196
- cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
196
+ cells: defs.cells.map((c) => ({
197
+ ...c,
198
+ ...(c.models ? { models: { ...c.models } } : {}),
199
+ ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
200
+ })),
197
201
  };
198
202
  }
199
203
 
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 || {}) };
204
+ // Applica engine + modello + policy come un'unica transizione. Ogni engine ricorda
205
+ // il proprio ultimo modello E l'ultima policy; passando a un altro engine
206
+ // l'uno l'altra attraversano il confine. La policy e' PER-CELL PER-ENGINE:
207
+ // mai si tocca engine.managed.permissionPolicy (globale su tutte le celle che
208
+ // usano quell'engine). Valori ammessi solo 'standard' | 'unsafe'.
209
+ function applyCellTransition(target, engineId, { model, hasModel, policy, hasPolicy } = {}) {
210
+ const rememberedModels = { ...(target.models || {}) };
211
+ const rememberedPolicies = { ...(target.permissionPolicies || {}) };
205
212
  target.engine = engineId;
206
213
  if (hasModel) {
207
- if (typeof model === 'string' && model) remembered[engineId] = model;
208
- else delete remembered[engineId];
214
+ if (typeof model === 'string' && model) rememberedModels[engineId] = model;
215
+ else delete rememberedModels[engineId];
216
+ }
217
+ if (hasPolicy) {
218
+ if (policy === 'standard' || policy === 'unsafe') rememberedPolicies[engineId] = policy;
219
+ else delete rememberedPolicies[engineId];
209
220
  }
221
+ if (Object.keys(rememberedModels).length) target.models = rememberedModels; else delete target.models;
222
+ if (Object.keys(rememberedPolicies).length) target.permissionPolicies = rememberedPolicies; else delete target.permissionPolicies;
210
223
  const selected = hasModel
211
224
  ? (typeof model === 'string' ? model : '')
212
- : (remembered[engineId] || '');
213
- if (Object.keys(remembered).length) target.models = remembered; else delete target.models;
225
+ : (rememberedModels[engineId] || '');
214
226
  if (selected) target.model = selected; else delete target.model;
215
227
  }
216
228
 
@@ -264,9 +276,21 @@ async function createBuiltinFleet(cfg = {}) {
264
276
  const sessions = cache.sessions;
265
277
  const cells = cache.defs.cells.map((c) => {
266
278
  const alive = sessions.has(c.tmuxSession);
279
+ // Effective policy per cella: override remembered per-engine, altrimenti il
280
+ // default dell'engine gestito. Esposta SOLO come policy effettiva (mai segreti).
281
+ const engineDef = findEngine(cache.defs, c.engine);
282
+ const engineDefault = engineDef && engineDef.managed ? engineDef.managed.permissionPolicy : '';
283
+ const remembered = c.permissionPolicies
284
+ && (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
285
+ ? c.permissionPolicies[c.engine] : null;
286
+ const effectivePolicy = engineDef?.managed?.client === 'pi'
287
+ ? 'standard'
288
+ : (remembered || engineDefault || '');
267
289
  return {
268
290
  cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
269
291
  model: c.model || '', models: { ...(c.models || {}) },
292
+ permissionPolicy: effectivePolicy,
293
+ permissionPolicies: { ...(c.permissionPolicies || {}) },
270
294
  active: alive, boot: c.boot, tmux: alive,
271
295
  rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
272
296
  };
@@ -391,8 +415,20 @@ async function createBuiltinFleet(cfg = {}) {
391
415
  if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
392
416
  const defs = reloadDefs();
393
417
  if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
394
- if (!findEngine(defs, engId)) throw httpError(400, `engine non valido: ${engId}`);
395
- await mutate(defs, (d) => applyCellEngineModel(findCell(d, cellId), engId, opts.model, typeof opts.model === 'string' && opts.model !== ''));
418
+ const selectedEngine = findEngine(defs, engId);
419
+ if (!selectedEngine) throw httpError(400, `engine non valido: ${engId}`);
420
+ const hasModel = typeof opts.model === 'string';
421
+ const hasPolicy = Object.prototype.hasOwnProperty.call(opts, 'permissionPolicy') && typeof opts.permissionPolicy === 'string';
422
+ if (hasPolicy && opts.permissionPolicy !== 'standard' && opts.permissionPolicy !== 'unsafe') {
423
+ throw httpError(400, 'permissionPolicy non valida (standard|unsafe)');
424
+ }
425
+ if (hasPolicy && selectedEngine.managed?.client === 'pi' && opts.permissionPolicy !== 'standard') {
426
+ throw httpError(400, 'Pi supporta solo permissionPolicy standard');
427
+ }
428
+ await mutate(defs, (d) => applyCellTransition(findCell(d, cellId), engId, {
429
+ model: opts.model, hasModel,
430
+ policy: hasPolicy ? opts.permissionPolicy : null, hasPolicy,
431
+ }));
396
432
  const saved = reloadDefs(); const target = findCell(saved, cellId);
397
433
  return { ok: true, engine: engId, model: target.model || '' };
398
434
  }
@@ -450,9 +486,14 @@ async function createBuiltinFleet(cfg = {}) {
450
486
  await mutate(defs, (d) => {
451
487
  d.engines = d.engines.filter((e) => e.id !== id);
452
488
  for (const cell of d.cells) {
453
- if (!cell.models || !Object.prototype.hasOwnProperty.call(cell.models, id)) continue;
454
- delete cell.models[id];
455
- if (!Object.keys(cell.models).length) delete cell.models;
489
+ if (cell.models && Object.prototype.hasOwnProperty.call(cell.models, id)) {
490
+ delete cell.models[id];
491
+ if (!Object.keys(cell.models).length) delete cell.models;
492
+ }
493
+ if (cell.permissionPolicies && Object.prototype.hasOwnProperty.call(cell.permissionPolicies, id)) {
494
+ delete cell.permissionPolicies[id];
495
+ if (!Object.keys(cell.permissionPolicies).length) delete cell.permissionPolicies;
496
+ }
456
497
  }
457
498
  });
458
499
  return { ok: true };
@@ -469,19 +510,33 @@ async function createBuiltinFleet(cfg = {}) {
469
510
  if (readonly()) throw httpError(403, 'READONLY: edit-cell bloccato');
470
511
  if (!id) throw httpError(400, 'id cell mancante');
471
512
  const defs = reloadDefs();
472
- if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
513
+ const current = findCell(defs, id);
514
+ if (!current) throw httpError(400, `cell inesistente: ${id}`);
473
515
  if (patch && (Object.prototype.hasOwnProperty.call(patch, 'id') || Object.prototype.hasOwnProperty.call(patch, 'tmuxSession'))) {
474
516
  throw httpError(400, 'id e tmuxSession sono immutabili');
475
517
  }
518
+ const nextEngine = findEngine(defs, typeof patch?.engine === 'string' ? patch.engine : current.engine);
519
+ if (Object.prototype.hasOwnProperty.call(patch || {}, 'permissionPolicy')
520
+ && patch.permissionPolicy === 'unsafe' && nextEngine?.managed?.client === 'pi') {
521
+ throw httpError(400, 'Pi supporta solo permissionPolicy standard');
522
+ }
476
523
  await mutate(defs, (d) => {
477
524
  const target = findCell(d, id);
478
525
  const hasEngine = typeof patch?.engine === 'string';
479
526
  const hasModel = Object.prototype.hasOwnProperty.call(patch || {}, 'model');
527
+ const hasPolicy = Object.prototype.hasOwnProperty.call(patch || {}, 'permissionPolicy');
528
+ if (hasPolicy && patch.permissionPolicy !== 'standard' && patch.permissionPolicy !== 'unsafe' && patch.permissionPolicy !== null) {
529
+ throw httpError(400, 'permissionPolicy non valida (standard|unsafe)');
530
+ }
480
531
  for (const [key, value] of Object.entries(patch || {})) {
481
- if (key === 'engine' || key === 'model') continue;
532
+ if (key === 'engine' || key === 'model' || key === 'permissionPolicy') continue;
482
533
  if (value === null) delete target[key]; else target[key] = value;
483
534
  }
484
- if (hasEngine || hasModel) applyCellEngineModel(target, hasEngine ? patch.engine : target.engine, patch?.model, hasModel);
535
+ if (hasEngine || hasModel || hasPolicy) applyCellTransition(target, hasEngine ? patch.engine : target.engine, {
536
+ model: patch?.model, hasModel,
537
+ policy: hasPolicy ? patch.permissionPolicy : null,
538
+ hasPolicy,
539
+ });
485
540
  });
486
541
  const sessions = await refreshSessions();
487
542
  return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
@@ -501,6 +556,63 @@ async function createBuiltinFleet(cfg = {}) {
501
556
  return { ok: true };
502
557
  }
503
558
 
559
+ // importCell: riconcilia una sessione tmux esistente (es. cella Fleet legacy
560
+ // rimasta orfana su installazione pulita, tipo "jarvis") in una cella GESTITA
561
+ // di fleet.json. Design §reconciliation:
562
+ // - idempotente: se una cella gestita possiede gia' questa tmuxSession -> no-op.
563
+ // - NESSUNA invenzione di engine/provider/model: l'engine DEV'essere gia'
564
+ // dichiarato in fleet.json (l'operatore lo sceglie). model/policy restano i
565
+ // default dell'engine scelto (modificabili dopo dal pannello di lancio).
566
+ // - tmuxSession non canonica (es. "jarvis") è ammessa e round-trip; una
567
+ // sessione legacy canonica cloud-X viene adottata come cella X.
568
+ // - cwd obbligatoria (default = home); la validazione strict (parseCell, sotto
569
+ // home, no cloud-* alias incoerenti) avviene in atomicWrite -> errore chiaro.
570
+ // Dopo l'import la sessione sparisce da "unmanaged" e compare una sola volta in
571
+ // Fleet con gear+power. Elimina/terminate resta solo nelle Impostazioni.
572
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
573
+ function deriveCellId(session, cells) {
574
+ const raw = String(session || '');
575
+ // Una sessione legacy canonica cloud-X si adotta come cella X. Usare
576
+ // direttamente cloud-X come id produrrebbe cloud-cloud-X e fallirebbe la
577
+ // validazione; un id esplicito diverso continua invece a essere rifiutato.
578
+ const canonicalSuffix = raw.startsWith('cloud-') ? raw.slice('cloud-'.length) : '';
579
+ let base = (canonicalSuffix && CELL_ID_RE.test(canonicalSuffix) ? canonicalSuffix : raw)
580
+ .replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'cell';
581
+ const used = new Set(cells.map((c) => c.id));
582
+ if (!used.has(base)) return base;
583
+ if (canonicalSuffix) return base; // il caller produrrà un 409 esplicito
584
+ for (let i = 2; i < 100; i += 1) {
585
+ const candidate = `${base.slice(0, Math.max(1, 32 - String(i).length - 1))}-${i}`;
586
+ if (!used.has(candidate)) return candidate;
587
+ }
588
+ return base;
589
+ }
590
+ async function importCell(b = {}) {
591
+ if (readonly()) throw httpError(403, 'READONLY: import bloccato');
592
+ const tmuxSession = typeof b.tmuxSession === 'string' ? b.tmuxSession.trim() : '';
593
+ if (!tmuxSession || !validTmuxName(tmuxSession)) throw httpError(400, 'tmuxSession non valida');
594
+ const defs = reloadDefs();
595
+ const existing = defs.cells.find((c) => c.tmuxSession === tmuxSession);
596
+ if (existing) return { ok: true, id: existing.id, tmuxSession, idempotent: true };
597
+ const sessions = await refreshSessions();
598
+ if (!sessions.has(tmuxSession)) {
599
+ throw httpError(404, `sessione tmux non trovata: ${tmuxSession}`);
600
+ }
601
+ const engineId = typeof b.engine === 'string' ? b.engine.trim() : '';
602
+ if (!engineId || !findEngine(defs, engineId)) {
603
+ throw httpError(400, `engine non dichiarato: "${engineId || '(manca)'}" — crea prima l'engine in Impostazioni`);
604
+ }
605
+ let id = typeof b.id === 'string' && b.id.trim() ? b.id.trim() : deriveCellId(tmuxSession, defs.cells);
606
+ if (!CELL_ID_RE.test(id)) throw httpError(400, 'id non valido (A-Za-z0-9._- max 32)');
607
+ if (defs.cells.some((c) => c.id === id)) throw httpError(409, `id cella già usato: ${id}`);
608
+ const cwd = typeof b.cwd === 'string' && b.cwd.trim() ? b.cwd.trim() : home;
609
+ const cellDef = { id, cwd, engine: engineId, boot: b.boot === true, tmuxSession };
610
+ try {
611
+ await mutate(defs, (d) => { d.cells.push(cellDef); });
612
+ } catch (e) { throw httpError(400, `import non valido: ${e.message}`); }
613
+ return { ok: true, id, tmuxSession, imported: true };
614
+ }
615
+
504
616
  // Vista editabile ma secret-safe: gli env values restano write-only.
505
617
  function definitions() {
506
618
  const defs = reloadDefs();
@@ -512,7 +624,11 @@ async function createBuiltinFleet(cfg = {}) {
512
624
  if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
513
625
  return out;
514
626
  }),
515
- cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
627
+ cells: defs.cells.map((c) => ({
628
+ ...c,
629
+ ...(c.models ? { models: { ...c.models } } : {}),
630
+ ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
631
+ })),
516
632
  managedCatalog: publicCatalog(),
517
633
  managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
518
634
  };
@@ -581,13 +697,14 @@ async function createBuiltinFleet(cfg = {}) {
581
697
  boot: { type: 'boolean', required: false, default: false },
582
698
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
583
699
  models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
700
+ permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
584
701
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
585
702
  },
586
703
  };
587
704
  }
588
705
 
589
706
  function capabilities() {
590
- return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema', 'definitions'];
707
+ return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'schema', 'definitions'];
591
708
  }
592
709
 
593
710
  return {
@@ -595,7 +712,7 @@ async function createBuiltinFleet(cfg = {}) {
595
712
  provider: 'builtin',
596
713
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
597
714
  defineEngine, editEngine, removeEngine,
598
- defineCell, editCell, removeCell,
715
+ defineCell, editCell, removeCell, importCell,
599
716
  schema, definitions, capabilities,
600
717
  };
601
718
  }
@@ -254,6 +254,24 @@ function parseCell(c, engineIds) {
254
254
  }
255
255
  }
256
256
 
257
+ // permissionPolicies: scelta PER-CELL PER-ENGINE (override del default engine).
258
+ // Stesso confine di `models`: ricorda l'ultima policy usata con ogni engine, così
259
+ // tornando a un provider si ritrova la scelta precedente senza trascinarla altrove
260
+ // e SENZA toccare engine.managed.permissionPolicy (globale: cambierebbe ogni cella
261
+ // che usa quell'engine). Valori ammessi solo 'standard' | 'unsafe'.
262
+ let permissionPolicies;
263
+ if (c.permissionPolicies !== undefined) {
264
+ if (!c.permissionPolicies || typeof c.permissionPolicies !== 'object' || Array.isArray(c.permissionPolicies)) return null;
265
+ const entries = Object.entries(c.permissionPolicies);
266
+ if (entries.length > MAX_ENGINES) return null;
267
+ permissionPolicies = {};
268
+ for (const [engineId, value] of entries) {
269
+ if (!engineIds.has(engineId)) return null;
270
+ if (value !== 'standard' && value !== 'unsafe') return null;
271
+ permissionPolicies[engineId] = value;
272
+ }
273
+ }
274
+
257
275
  // prompt (opzionale, cap)
258
276
  let prompt;
259
277
  if (c.prompt !== undefined) {
@@ -280,6 +298,7 @@ function parseCell(c, engineIds) {
280
298
  const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
281
299
  if (model !== undefined) out.model = model;
282
300
  if (Object.keys(models).length) out.models = models;
301
+ if (permissionPolicies) out.permissionPolicies = permissionPolicies;
283
302
  if (prompt !== undefined) out.prompt = prompt;
284
303
  return out;
285
304
  }
@@ -403,6 +422,7 @@ module.exports = {
403
422
  resolveCwd,
404
423
  loadDefinitions,
405
424
  atomicWrite,
425
+ validTmuxName,
406
426
  CAPS,
407
427
  // Costanti esposte anche piatte (comode per la UI/schema e i test)
408
428
  SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
  const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
3
5
  const { createFleetExec } = require('./exec.js');
4
6
 
5
7
  const STATUS_TTL_MS = 2000;
@@ -135,4 +137,43 @@ async function createFleet(cfg = {}) {
135
137
  };
136
138
  }
137
139
 
138
- module.exports = { createFleet, parseStatus, binTrusted };
140
+ // Discovery del binario fleet legacy cross-platform. In precedenza si cercava
141
+ // solo cfg.fleetBin (~/.local/bin/fleet di default); su Termux un fleet può stare
142
+ // in $PREFIX/bin/fleet. Ordine candidati: (1) cfg.fleetBin (config/env esplicita),
143
+ // (2) $PREFIX/bin/fleet (Termux), (3) ~/.local/bin/fleet (default storico).
144
+ // Dedup + skip vuoti. La validità (regular/trusted + status --json schema valido)
145
+ // la verifica il chiamante (resolveExternalFleet) via binTrusted + createFleet.
146
+ function fleetCandidates(cfg = {}) {
147
+ const home = cfg.home || os.homedir();
148
+ const envPrefix = (cfg.env && cfg.env.PREFIX) || process.env.PREFIX;
149
+ const out = [];
150
+ const push = (p) => { if (typeof p === 'string' && p && !out.includes(p)) out.push(p); };
151
+ push(cfg.fleetBin);
152
+ if (envPrefix) push(path.join(envPrefix, 'bin', 'fleet'));
153
+ push(path.join(home, '.local', 'bin', 'fleet'));
154
+ return out;
155
+ }
156
+
157
+ // In auto mode i fallback cross-platform sono intenzionali. In forced external,
158
+ // invece, un fleetBin fornito è un pin: se non è valido si fallisce chiuso e non
159
+ // si cambia eseguibile alle spalle dell'operatore.
160
+ function externalFleetCandidates(cfg = {}) {
161
+ const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase();
162
+ if (forced === 'external' && typeof cfg.fleetBin === 'string' && cfg.fleetBin) return [cfg.fleetBin];
163
+ return fleetCandidates(cfg);
164
+ }
165
+
166
+ // Prova i candidati in ordine e ritorna il primo external FIDATO (binTrusted) E
167
+ // rispondente al contratto (createFleet -> available, che fa status --json e
168
+ // valida lo schema ai-fleet). Null se nessuno valido. Così un external reale
169
+ // resta external (non cade sul builtin e non doppia il boot).
170
+ async function resolveExternalFleet(cfg = {}) {
171
+ for (const bin of externalFleetCandidates(cfg)) {
172
+ if (!binTrusted(bin)) continue;
173
+ const f = await createFleet({ ...cfg, fleetBin: bin });
174
+ if (f.available) return { fleet: f, bin, reason: `fidato + risponde al contratto (${bin})` };
175
+ }
176
+ return null;
177
+ }
178
+
179
+ module.exports = { createFleet, parseStatus, binTrusted, fleetCandidates, externalFleetCandidates, resolveExternalFleet };
@@ -333,7 +333,18 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
333
333
  const profile = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
334
334
  const cred = credential(profile, spec, cfg, home);
335
335
  const env = {}; const args = []; const model = cell?.model || spec.model;
336
- if (spec.permissionPolicy === 'unsafe') {
336
+ // Effective permission policy: override PER-CELL PER-ENGINE (remembered) vince sul
337
+ // default dell'engine. Mai si mutationa engine.managed.permissionPolicy (globale).
338
+ // Pi resta sempre 'standard' (lo spec normalized rifiuta gia' unsafe per pi).
339
+ const engineId = engine && typeof engine.id === 'string' ? engine.id : '';
340
+ const override = cell && cell.permissionPolicies && Object.prototype.hasOwnProperty.call(cell.permissionPolicies, engineId)
341
+ ? cell.permissionPolicies[engineId] : null;
342
+ let effectivePolicy = (override === 'standard' || override === 'unsafe') ? override : spec.permissionPolicy;
343
+ // Pi resta sempre 'standard': normalizeManagedSpec rifiuta gia' unsafe nello spec
344
+ // dell'engine, ma l'override PER-CELL bypasserebbe quel check -> clamp esplicito.
345
+ if (spec.client === 'pi') effectivePolicy = 'standard';
346
+ info.permissionPolicy = effectivePolicy;
347
+ if (effectivePolicy === 'unsafe') {
337
348
  if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
338
349
  if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
339
350
  }
@@ -345,12 +356,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
345
356
  } else {
346
357
  const endpoint = spec.baseUrl || profile.endpoint;
347
358
  const token = profile.auth === 'none' ? 'ollama' : cred.value;
359
+ const contextWindow = OLLAMA_CONTEXT[model] || (spec.provider === 'zai' ? 1000000 : 200000);
348
360
  Object.assign(env, {
349
361
  ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: token, ANTHROPIC_API_KEY: '',
350
362
  ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model,
351
363
  ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
352
364
  CLAUDE_CODE_SUBAGENT_MODEL: model, API_TIMEOUT_MS: '3000000',
353
- CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || (spec.provider === 'zai' ? 1000000 : 200000)),
365
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextWindow),
366
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(contextWindow),
354
367
  });
355
368
  }
356
369
  if (model) args.push('--model', model);
@@ -14,7 +14,7 @@
14
14
  // Il drift a runtime NON si risolve qui: e' una scelta one-shot. Se l'external
15
15
  // diventa invalido DOPO lo startup, lo status risultante sara' degraded/unavailable
16
16
  // (mai fall-through silenzioso al built-in) — gestito dal layer di status.
17
- const { createFleet, binTrusted } = require('./index.js');
17
+ const { binTrusted, externalFleetCandidates, resolveExternalFleet } = require('./index.js');
18
18
  const { createBuiltinFleet } = require('./builtin.js');
19
19
 
20
20
  const DISABLED_FLEET = Object.freeze({
@@ -37,19 +37,20 @@ async function selectProvider(cfg = {}) {
37
37
 
38
38
  const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase() || null;
39
39
 
40
- // External: fidato (regular file, exec, no symlink, no world-writable) E contratto ok.
40
+ // External: scopre il binario fleet legacy cross-platform (cfg.fleetBin,
41
+ // $PREFIX/bin/fleet su Termux, ~/.local/bin/fleet) e accetta il primo fidato
42
+ // (binTrusted) che risponde al contratto (status --json con schema valido).
41
43
  let extFleet = null;
42
44
  let extReason = null;
43
- if (cfg.fleetBin) {
44
- if (binTrusted(cfg.fleetBin)) {
45
- const f = await createFleet(cfg); // fa status --json al boot
46
- if (f.available) { extFleet = f; extReason = 'fidato + risponde al contratto'; }
47
- else extReason = 'binario fidato ma non risponde al contratto (status invalido)';
48
- } else {
49
- extReason = 'binario non fidato (symlink/world-writable/non exec)';
50
- }
45
+ const resolvedExternal = await resolveExternalFleet(cfg);
46
+ if (resolvedExternal) {
47
+ extFleet = resolvedExternal.fleet;
48
+ extReason = resolvedExternal.reason;
51
49
  } else {
52
- extReason = 'nessun fleetBin esterno configurato';
50
+ // Diagnosi: perché nessun candidato external è valido (fidato + contratto).
51
+ const cands = externalFleetCandidates(cfg);
52
+ if (!cands.length) extReason = 'nessun fleetBin esterno configurato';
53
+ else extReason = `nessun candidato external valido: ${cands.map((b) => (binTrusted(b) ? `${b} (non risponde al contratto)` : `${b} (non fidato)`)).join('; ')}`;
53
54
  }
54
55
 
55
56
  // Builtin: abilitato + fleet.json valido.
@@ -78,7 +78,9 @@ function fleetRoutes(fleetP, cfg = {}) {
78
78
  r.post('/up', guard(async (f, b) => {
79
79
  const cell = String(b.cell || '');
80
80
  if (b.engine && capList(f).includes('edit')) {
81
- await f.engine(cell, String(b.engine), { model: typeof b.model === 'string' ? b.model : '' });
81
+ const opts = { model: typeof b.model === 'string' ? b.model : '' };
82
+ if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
83
+ await f.engine(cell, String(b.engine), opts);
82
84
  }
83
85
  if (Object.prototype.hasOwnProperty.call(b, 'boot') && capList(f).includes('edit') && capList(f).includes('boot')) {
84
86
  await f.boot(cell, b.boot === true);
@@ -91,7 +93,11 @@ function fleetRoutes(fleetP, cfg = {}) {
91
93
  return f.down(cell, { boot: !!b.boot });
92
94
  }, { mutate: true }));
93
95
  r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true }));
94
- r.post('/engine', guard((f, b) => f.engine(String(b.cell || ''), String(b.engine || ''), { model: typeof b.model === 'string' ? b.model : '' }), { mutate: true }));
96
+ r.post('/engine', guard((f, b) => {
97
+ const opts = { model: typeof b.model === 'string' ? b.model : '' };
98
+ if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
99
+ return f.engine(String(b.cell || ''), String(b.engine || ''), opts);
100
+ }, { mutate: true }));
95
101
  r.post('/boot', guard((f, b) => f.boot(String(b.cell || ''), b.enabled === true), { mutate: true }));
96
102
 
97
103
  // --- Estensione B4.2: schema + define/edit/remove (engine e cell) ---
@@ -104,6 +110,9 @@ function fleetRoutes(fleetP, cfg = {}) {
104
110
  r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
105
111
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
106
112
  r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
113
+ // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
114
+ // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
115
+ r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
107
116
 
108
117
  return r;
109
118
  }
@@ -40,13 +40,44 @@ function encodePairing(data) {
40
40
  return Buffer.from(JSON.stringify(data)).toString('base64url');
41
41
  }
42
42
 
43
+ // Allowlist rigorosa per versione (caps). v1: solo {v,instanceId,port,label,invite}.
44
+ // v2 aggiunge name (slug suggerito), ssh (target/alias), sshPort?: routing non
45
+ // segreto. NESSUN campo segreto è ammesso oltre l'invite one-time: niente
46
+ // identityFile, chiave privata, API key, bearer UI. Unknown field -> null.
47
+ const PAIRING_V1_KEYS = new Set(['v', 'instanceId', 'port', 'label', 'invite']);
48
+ const PAIRING_V2_KEYS = new Set(['v', 'instanceId', 'port', 'label', 'invite', 'name', 'ssh', 'sshPort']);
49
+
43
50
  function decodePairing(value) {
51
+ let x;
44
52
  try {
45
- const x = JSON.parse(Buffer.from(String(value || ''), 'base64url').toString('utf8'));
46
- if (!x || x.v !== 1 || !store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
47
- || !store.validToken(x.invite) || typeof x.label !== 'string') return null;
48
- return x;
53
+ x = JSON.parse(Buffer.from(String(value || ''), 'base64url').toString('utf8'));
49
54
  } catch (_) { return null; }
55
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return null;
56
+ const allowed = x.v === 1 ? PAIRING_V1_KEYS : x.v === 2 ? PAIRING_V2_KEYS : null;
57
+ if (!allowed) return null;
58
+ for (const k of Object.keys(x)) if (!allowed.has(k)) return null; // strict: unknown -> null
59
+ // core obbligatorio (entrambe le versioni)
60
+ if (!store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
61
+ || !store.validToken(x.invite) || typeof x.label !== 'string') return null;
62
+ const out = { v: x.v, instanceId: x.instanceId, port: x.port, label: x.label, invite: x.invite };
63
+ if (x.v === 2) {
64
+ if (x.name !== undefined) {
65
+ if (typeof x.name !== 'string' || !store.NODE_NAME_RE.test(x.name)) return null;
66
+ out.name = x.name;
67
+ }
68
+ if (x.ssh !== undefined) {
69
+ // target/alias SSH (user@host o Host alias); MAI secret. parseSshTarget
70
+ // rifiuta whitespace/control/leading '-' (argv-safe, non diventa flag ssh).
71
+ const ssh = typeof x.ssh === 'string' ? store.parseSshTarget(x.ssh) : null;
72
+ if (!ssh) return null;
73
+ out.ssh = ssh.value;
74
+ }
75
+ if (x.sshPort !== undefined) {
76
+ if (!store.isPort(x.sshPort)) return null;
77
+ out.sshPort = x.sshPort;
78
+ }
79
+ }
80
+ return out;
50
81
  }
51
82
 
52
83
  function parsePairingUrl(value) {
@@ -57,14 +88,35 @@ function parsePairingUrl(value) {
57
88
  } catch (_) { return null; }
58
89
  }
59
90
 
60
- function createInvite({ invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes }) {
91
+ function createInvite({
92
+ invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
93
+ ssh, sshPort, name,
94
+ } = {}) {
61
95
  const invite = randomBytes(32).toString('base64url');
62
96
  const expiresAt = now + INVITE_TTL_MS;
63
97
  const rows = readInvites(invitesPath, now);
64
98
  rows.push({ hash: crypto.createHash('sha256').update(invite).digest('hex'), expiresAt, usedAt: null });
65
99
  writeInvites(invitesPath, rows.slice(-32));
66
- const pair = encodePairing({ v: 1, instanceId, port, label: String(label).slice(0, 64), invite });
67
- return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt };
100
+ const payload = { v: 1, instanceId, port, label: String(label).slice(0, 64), invite };
101
+ // v2: include Host/alias SSH + slug quando forniti (routing non segreto). Così
102
+ // il ricevente incolla/scansiona UN solo link e ha tutto per precompilare il
103
+ // form. Senza ssh/name resta v1 (backward-compat con link 0.8.x esistenti).
104
+ const sshVal = typeof ssh === 'string' && ssh.trim() ? store.parseSshTarget(ssh.trim()) : null;
105
+ const requestedName = typeof name === 'string' && name.trim() && store.NODE_NAME_RE.test(name.trim())
106
+ ? name.trim()
107
+ : '';
108
+ // Un link con routing SSH deve sempre portare anche lo slug. Se il chiamante
109
+ // non lo specifica, lo ricaviamo dalla label del dispositivo: il ricevente non
110
+ // deve compilare altri campi per arrivare a "testa e collega".
111
+ const linkName = requestedName || (sshVal ? store.toSlug(payload.label) : '');
112
+ if (sshVal || linkName) {
113
+ payload.v = 2;
114
+ if (linkName) payload.name = linkName;
115
+ if (sshVal) payload.ssh = sshVal.value;
116
+ if (sshVal && store.isPort(sshPort)) payload.sshPort = sshPort;
117
+ }
118
+ const pair = encodePairing(payload);
119
+ return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt, version: payload.v };
68
120
  }
69
121
 
70
122
  function consumeInvite({ invitesPath, invite, now = Date.now() }) {