@mmmbuto/nexuscrew 0.8.27 → 0.8.29

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.
@@ -30,9 +30,10 @@ const os = require('node:os');
30
30
  const path = require('node:path');
31
31
  const {
32
32
  loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
33
+ resolveCwd, normalizeCwdRel, deriveCwdRel,
33
34
  } = require('./definitions.js');
34
35
  const {
35
- publicCatalog, describeManaged, describeCatalogCredential,
36
+ publicCatalog, describeManaged, describeCatalogCredential, defaultShellEngine,
36
37
  } = require('./managed.js');
37
38
  const { validEnvKey } = require('./env-key.js');
38
39
  const { setCredential, removeCredential } = require('./credentials.js');
@@ -64,10 +65,24 @@ function draftFrom(defs) {
64
65
  ...c,
65
66
  ...(c.models ? { models: { ...c.models } } : {}),
66
67
  ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
68
+ ...(c.commands ? { commands: { ...c.commands } } : {}),
67
69
  })),
68
70
  };
69
71
  }
70
72
 
73
+ // Upgrade locale, idempotente e non distruttivo: installazioni gia' esistenti
74
+ // ricevono l'engine standard Shell senza riscrivere celle o sostituire un id
75
+ // scelto dall'utente. Se lo store e' pieno o la scrittura non e' possibile, il
76
+ // bootstrap resta utilizzabile con le definizioni precedenti.
77
+ function backfillShellEngine(defsPath, defs) {
78
+ if (!defs || defs.engines.some((engine) => engine.managed?.client === 'shell')) return defs;
79
+ if (defs.engines.some((engine) => engine.id === 'shell.local')) return defs;
80
+ if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
81
+ const draft = draftFrom(defs);
82
+ draft.engines.push(defaultShellEngine());
83
+ try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
84
+ }
85
+
71
86
  // Applica engine + modello + policy come un'unica transizione. Ogni engine ricorda
72
87
  // il proprio ultimo modello E l'ultima policy; passando a un altro engine né
73
88
  // l'uno né l'altra attraversano il confine. La policy e' PER-CELL PER-ENGINE:
@@ -93,6 +108,103 @@ function applyCellTransition(target, engineId, { model, hasModel, policy, hasPol
93
108
  if (selected) target.model = selected; else delete target.model;
94
109
  }
95
110
 
111
+ // ---------------------------------------------------------------------------
112
+ // cwd portabile (design §4.3). resolveCellCwd riconcilia cwd (assoluta) e
113
+ // cwdRel (home-relative) in una COPPIA COERENTE prima di ogni scrittura:
114
+ // - solo cwd (assoluta valida) -> deriva cwdRel
115
+ // - solo cwdRel (valida) -> calcola la cwd assoluta target
116
+ // - entrambi -> devono risolvere allo STESSO realpath
117
+ // La risoluzione finale passa SEMPRE da resolveCwd (INVARIATO: realpath su
118
+ // entrambi i lati, confinamento sotto home, directory deve esistere, symlink
119
+ // escape rifiutato). Nessuna creazione directory, nessun fallback a home o al
120
+ // cwd del servizio, nessun rimappaggio automatico di path di altri device.
121
+ // Restituisce { ok:true, cwd, cwdRel } oppure { ok:false, fail:{ reason, ... } }.
122
+ // ---------------------------------------------------------------------------
123
+ function resolveCellCwd(cell, home) {
124
+ if (!cell || typeof cell !== 'object' || Array.isArray(cell)) return { ok: false, fail: { reason: 'invalid-cell' } };
125
+ const hasCwd = typeof cell.cwd === 'string' && cell.cwd.length > 0;
126
+ const hasRel = typeof cell.cwdRel === 'string'; // '' == home, forma valida
127
+ if (!hasCwd && !hasRel) return { ok: false, fail: { reason: 'missing' } };
128
+ let relNorm = null;
129
+ if (hasRel) {
130
+ relNorm = normalizeCwdRel(cell.cwdRel);
131
+ if (relNorm === null) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel } };
132
+ }
133
+ const realHome = resolveCwd(home, home);
134
+ if (!realHome) return { ok: false, fail: { reason: 'home-unavailable' } };
135
+ const realCwd = hasCwd ? resolveCwd(cell.cwd, home) : null;
136
+ const realFromRel = hasRel ? resolveCwd(path.join(realHome, relNorm), home) : null;
137
+ if (hasCwd && hasRel) {
138
+ if (!realCwd) return { ok: false, fail: { reason: 'invalid-cwd', cwd: cell.cwd } };
139
+ if (!realFromRel) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel } };
140
+ if (realCwd !== realFromRel) return { ok: false, fail: { reason: 'mismatch', cwd: cell.cwd, cwdRel: cell.cwdRel } };
141
+ return { ok: true, cwd: realCwd, cwdRel: relNorm };
142
+ }
143
+ if (hasCwd) {
144
+ if (!realCwd) return { ok: false, fail: { reason: 'invalid-cwd', cwd: cell.cwd } };
145
+ const rel = deriveCwdRel(realCwd, realHome);
146
+ if (rel === null) return { ok: false, fail: { reason: 'not-under-home', cwd: cell.cwd } };
147
+ return { ok: true, cwd: realCwd, cwdRel: rel };
148
+ }
149
+ // solo cwdRel
150
+ if (!realFromRel) return { ok: false, fail: { reason: 'invalid-rel', cwdRel: cell.cwdRel } };
151
+ return { ok: true, cwd: realFromRel, cwdRel: relNorm };
152
+ }
153
+
154
+ // suggestion (hint azionabile, MAI applicato in automatico): il basename della
155
+ // cwd fornita combacia con una directory esistente sotto la home locale ->
156
+ // suggerisce quel rel. Altrimenti resta assente. Bounded (singolo segmento).
157
+ function suggestCwdRel(failedCwd, home) {
158
+ try {
159
+ const base = path.basename(String(failedCwd || ''));
160
+ if (!base || normalizeCwdRel(base) === null) return undefined;
161
+ const realHome = resolveCwd(home, home);
162
+ if (!realHome) return undefined;
163
+ if (!resolveCwd(path.join(realHome, base), home)) return undefined;
164
+ return base;
165
+ } catch (_) { return undefined; }
166
+ }
167
+
168
+ // Costruisce l'errore strutturato fail-closed (code 'unportable-cwd'): per ogni
169
+ // cella rifiutata riporta id, eventuale cwd fornita e suggestion bounded.
170
+ // Nessun segreto: cwd e' operazionale (gia' esposta dal messaggio di launch in
171
+ // runtime.js); hint testuale fisso. Nessun log su buffer diagnostici.
172
+ function unportableCwdError(failures, home) {
173
+ const cells = failures.map(({ id, fail }) => {
174
+ const entry = { id };
175
+ const cwd = typeof fail.cwd === 'string' ? fail.cwd : undefined;
176
+ if (cwd !== undefined) entry.cwd = cwd;
177
+ const suggestion = suggestCwdRel(cwd, home);
178
+ if (suggestion) entry.suggestion = suggestion;
179
+ return entry;
180
+ });
181
+ return httpError(400, `cwd non portabile (deve esistere sotto la home) per: ${failures.map((f) => f.id).join(', ')}`, {
182
+ code: 'unportable-cwd',
183
+ cells,
184
+ hint: 'il percorso appartiene a un altro dispositivo o non esiste sotto la home di questo device: scegli una cartella sotto la home',
185
+ });
186
+ }
187
+
188
+ // Prevalida TUTTE le celle prima di mutate/atomicWrite: risolve cwd/cwdRel in
189
+ // coppia coerente per ciascuna; al primo blocco lancia l'errore strutturato e
190
+ // NESSUNA scrittura parziale avviene. Restituisce l'array di celle risolte
191
+ // (con cwd realpath + cwdRel canonico), nell'ordine originale.
192
+ function resolveCellsOrFail(cells, home) {
193
+ const failures = [];
194
+ const resolved = [];
195
+ for (const cell of cells) {
196
+ const r = resolveCellCwd(cell, home);
197
+ if (!r.ok) { failures.push({ id: cell.id || '?', fail: r.fail }); continue; }
198
+ const { cwd, cwdRel } = r;
199
+ const next = { ...cell };
200
+ next.cwd = cwd;
201
+ next.cwdRel = cwdRel;
202
+ resolved.push(next);
203
+ }
204
+ if (failures.length) throw unportableCwdError(failures, home);
205
+ return resolved;
206
+ }
207
+
96
208
  // ---------------------------------------------------------------------------
97
209
  // createBuiltinFleet(cfg) — unica implementazione Fleet di NexusCrew.
98
210
  // cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
@@ -112,8 +224,9 @@ async function createBuiltinFleet(cfg = {}) {
112
224
  const launchBroker = cfg.launchBroker || createLaunchBroker({ ...cfg, home });
113
225
 
114
226
  // Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
115
- const boot = loadDefinitions(defsPath);
227
+ let boot = loadDefinitions(defsPath);
116
228
  if (!boot) return off;
229
+ if (!readonly()) boot = backfillShellEngine(defsPath, boot);
117
230
 
118
231
  // Adopt or create the shared server before exposing a mutable Fleet. Reapply
119
232
  // before every lifecycle mutation so an accidental config reload cannot
@@ -164,8 +277,8 @@ async function createBuiltinFleet(cfg = {}) {
164
277
  if (hasPolicy && opts.permissionPolicy !== 'standard' && opts.permissionPolicy !== 'unsafe') {
165
278
  throw httpError(400, 'permissionPolicy non valida (standard|unsafe)');
166
279
  }
167
- if (hasPolicy && selectedEngine.managed?.client === 'pi' && opts.permissionPolicy !== 'standard') {
168
- throw httpError(400, 'Pi supporta solo permissionPolicy standard');
280
+ if (hasPolicy && ['pi', 'shell'].includes(selectedEngine.managed?.client) && opts.permissionPolicy !== 'standard') {
281
+ throw httpError(400, `${selectedEngine.managed.client === 'shell' ? 'Shell' : 'Pi'} supporta solo permissionPolicy standard`);
169
282
  }
170
283
  await mutate(defs, (d) => applyCellTransition(findCell(d, cellId), engId, {
171
284
  model: opts.model, hasModel,
@@ -236,6 +349,10 @@ async function createBuiltinFleet(cfg = {}) {
236
349
  delete cell.permissionPolicies[id];
237
350
  if (!Object.keys(cell.permissionPolicies).length) delete cell.permissionPolicies;
238
351
  }
352
+ if (cell.commands && Object.prototype.hasOwnProperty.call(cell.commands, id)) {
353
+ delete cell.commands[id];
354
+ if (!Object.keys(cell.commands).length) delete cell.commands;
355
+ }
239
356
  }
240
357
  });
241
358
  return { ok: true };
@@ -245,7 +362,10 @@ async function createBuiltinFleet(cfg = {}) {
245
362
  if (readonly()) throw httpError(403, 'READONLY: define-cell bloccato');
246
363
  if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione cell mancante');
247
364
  if (def.id != null && findCell(reloadDefs(), def.id)) throw httpError(400, `cell esiste già: ${def.id}`);
248
- await mutate(reloadDefs(), (d) => { d.cells.push(def); });
365
+ // cwd portabile fail-closed: risolve cwd/cwdRel in coppia coerente PRIMA di
366
+ // scrivere (nessuna scrittura parziale su input non portabile).
367
+ const [cellDef] = resolveCellsOrFail([def], home);
368
+ await mutate(reloadDefs(), (d) => { d.cells.push(cellDef); });
249
369
  return { ok: true, id: def.id };
250
370
  }
251
371
  async function editCell(id, patch) {
@@ -259,8 +379,37 @@ async function createBuiltinFleet(cfg = {}) {
259
379
  }
260
380
  const nextEngine = findEngine(defs, typeof patch?.engine === 'string' ? patch.engine : current.engine);
261
381
  if (Object.prototype.hasOwnProperty.call(patch || {}, 'permissionPolicy')
262
- && patch.permissionPolicy === 'unsafe' && nextEngine?.managed?.client === 'pi') {
263
- throw httpError(400, 'Pi supporta solo permissionPolicy standard');
382
+ && patch.permissionPolicy === 'unsafe' && ['pi', 'shell'].includes(nextEngine?.managed?.client)) {
383
+ throw httpError(400, `${nextEngine.managed.client === 'shell' ? 'Shell' : 'Pi'} supporta solo permissionPolicy standard`);
384
+ }
385
+ // cwd portabile fail-closed: se la patch tocca cwd/cwdRel, riconcilia in
386
+ // coppia coerente PRIMA di mutate. cwd/cwdRel non sono chiudibili (null):
387
+ // cwd e' obbligatoria, cwdRel e' canonica derivata. Prevalida => nessuna
388
+ // scrittura parziale.
389
+ const hasCwdPatch = !!(patch && Object.prototype.hasOwnProperty.call(patch, 'cwd'));
390
+ const hasRelPatch = !!(patch && Object.prototype.hasOwnProperty.call(patch, 'cwdRel'));
391
+ let resolvedCwd = null;
392
+ if (hasCwdPatch || hasRelPatch) {
393
+ const candidate = { id };
394
+ if (hasCwdPatch) {
395
+ if (typeof patch.cwd !== 'string' || !patch.cwd) {
396
+ throw unportableCwdError([{ id, fail: { reason: 'invalid-cwd', cwd: patch.cwd } }], home);
397
+ }
398
+ candidate.cwd = patch.cwd;
399
+ }
400
+ if (hasRelPatch) {
401
+ if (typeof patch.cwdRel !== 'string') {
402
+ throw unportableCwdError([{ id, fail: { reason: 'invalid-rel', cwdRel: patch.cwdRel } }], home);
403
+ }
404
+ candidate.cwdRel = patch.cwdRel;
405
+ }
406
+ // Se arriva una sola coordinata, quella e' la nuova sorgente autoritativa:
407
+ // l'altra va ricalcolata, non confrontata col valore persistito precedente.
408
+ // Solo quando la patch porta entrambe si verifica esplicitamente la
409
+ // coerenza della coppia. Questo rende possibile cambiare directory e la
410
+ // futura repair UI senza indebolire il controllo mismatch.
411
+ const [resolved] = resolveCellsOrFail([candidate], home);
412
+ resolvedCwd = { cwd: resolved.cwd, cwdRel: resolved.cwdRel };
264
413
  }
265
414
  await mutate(defs, (d) => {
266
415
  const target = findCell(d, id);
@@ -271,9 +420,10 @@ async function createBuiltinFleet(cfg = {}) {
271
420
  throw httpError(400, 'permissionPolicy non valida (standard|unsafe)');
272
421
  }
273
422
  for (const [key, value] of Object.entries(patch || {})) {
274
- if (key === 'engine' || key === 'model' || key === 'permissionPolicy') continue;
423
+ if (key === 'engine' || key === 'model' || key === 'permissionPolicy' || key === 'cwd' || key === 'cwdRel') continue;
275
424
  if (value === null) delete target[key]; else target[key] = value;
276
425
  }
426
+ if (resolvedCwd) { target.cwd = resolvedCwd.cwd; target.cwdRel = resolvedCwd.cwdRel; }
277
427
  if (hasEngine || hasModel || hasPolicy) applyCellTransition(target, hasEngine ? patch.engine : target.engine, {
278
428
  model: patch?.model, hasModel,
279
429
  policy: hasPolicy ? patch.permissionPolicy : null,
@@ -292,7 +442,7 @@ async function createBuiltinFleet(cfg = {}) {
292
442
  if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
293
443
  throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
294
444
  }
295
- const allowed = new Set(['id', 'cwd', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'prompt']);
445
+ const allowed = new Set(['id', 'cwd', 'cwdRel', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'commands', 'prompt']);
296
446
  const seen = new Set();
297
447
  for (const cell of cells) {
298
448
  if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
@@ -307,6 +457,7 @@ async function createBuiltinFleet(cfg = {}) {
307
457
  referencedEngines.add(cell.engine);
308
458
  for (const id of Object.keys(cell.models || {})) referencedEngines.add(id);
309
459
  for (const id of Object.keys(cell.permissionPolicies || {})) referencedEngines.add(id);
460
+ for (const id of Object.keys(cell.commands || {})) referencedEngines.add(id);
310
461
  }
311
462
  const missingEngines = [...referencedEngines].filter((id) => !availableEngines.has(id)).sort();
312
463
  if (missingEngines.length) {
@@ -317,8 +468,13 @@ async function createBuiltinFleet(cfg = {}) {
317
468
  }
318
469
  const replaced = cells.filter((cell) => !!findCell(defs, cell.id)).map((cell) => cell.id);
319
470
  const created = cells.filter((cell) => !findCell(defs, cell.id)).map((cell) => cell.id);
471
+ // cwd portabile fail-closed: prevalida TUTTE le celle (cwd assoluta legacy
472
+ // oppure cwdRel portatile v3) prima di mutate. Al primo rifiuto nessuna
473
+ // scrittura parziale. v1/v2 portano cwd assoluta e vengono rifiutate in modo
474
+ // strutturato se non valide sul target (path di un altro device / inesistenti).
475
+ const resolvedCells = resolveCellsOrFail(cells, home);
320
476
  await mutate(defs, (draft) => {
321
- for (const cell of cells) {
477
+ for (const cell of resolvedCells) {
322
478
  const index = draft.cells.findIndex((current) => current.id === cell.id);
323
479
  const next = { ...cell };
324
480
  if (index >= 0) {
@@ -464,8 +620,12 @@ async function createBuiltinFleet(cfg = {}) {
464
620
  if (defs.cells.some((c) => c.id === id)) throw httpError(409, `id cella già usato: ${id}`);
465
621
  const cwd = typeof b.cwd === 'string' && b.cwd.trim() ? b.cwd.trim() : home;
466
622
  const cellDef = { id, cwd, engine: engineId, boot: b.boot === true, tmuxSession };
623
+ // Import is a write boundary just like define/edit/restore. Resolve and
624
+ // confine the cwd before mutate so a foreign or missing path is never
625
+ // persisted as a cell that can only fail later at up().
626
+ const [resolvedCell] = resolveCellsOrFail([cellDef], home);
467
627
  try {
468
- await mutate(defs, (d) => { d.cells.push(cellDef); });
628
+ await mutate(defs, (d) => { d.cells.push(resolvedCell); });
469
629
  } catch (e) { throw httpError(400, `import non valido: ${e.message}`); }
470
630
  return { ok: true, id, tmuxSession, imported: true };
471
631
  }
@@ -487,6 +647,8 @@ async function createBuiltinFleet(cfg = {}) {
487
647
  credentialUsedBy: [...usedBy].sort(),
488
648
  };
489
649
  });
650
+ // realHome una volta per le vista cwdRel/needsRepair (derivate, NON persistite).
651
+ const realHome = resolveCwd(home, home);
490
652
  return {
491
653
  schemaVersion: defs.schemaVersion,
492
654
  engines: defs.engines.map((e) => {
@@ -495,11 +657,34 @@ async function createBuiltinFleet(cfg = {}) {
495
657
  if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
496
658
  return out;
497
659
  }),
498
- cells: defs.cells.map((c) => ({
499
- ...c,
500
- ...(c.models ? { models: { ...c.models } } : {}),
501
- ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
502
- })),
660
+ cells: defs.cells.map((c) => {
661
+ const out = {
662
+ ...c,
663
+ ...(c.models ? { models: { ...c.models } } : {}),
664
+ ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
665
+ ...(c.commands ? { commands: { ...c.commands } } : {}),
666
+ };
667
+ // cwdRel portatile derivato (solo vista): per celle valide (cwd reale
668
+ // sotto home) espone il rel canonico; per celle persistite ma non
669
+ // valide/fuori home espone needsRepair:true. NESSUNA mutazione di
670
+ // fleet.json (la vista non scrive). cwdRel persistito e' sempre
671
+ // ricalcolato dal realpath (autoritativo) cosi' export e UI sono coerenti.
672
+ const realCwd = resolveCwd(c.cwd, home);
673
+ if (realCwd && realHome) {
674
+ const rel = deriveCwdRel(realCwd, realHome);
675
+ if (rel !== null) out.cwdRel = rel; else out.needsRepair = true;
676
+ } else {
677
+ out.needsRepair = true;
678
+ }
679
+ // Suggerimento azionabile ma mai applicato: se il basename della cwd
680
+ // foreign esiste gia' sotto la home target, la UI puo' offrirlo come
681
+ // cwdRel esplicita senza mostrare o reinviare il path sorgente.
682
+ if (out.needsRepair) {
683
+ const suggestion = suggestCwdRel(c.cwd, home);
684
+ if (suggestion) out.cwdSuggestion = suggestion;
685
+ }
686
+ return out;
687
+ }),
503
688
  managedCatalog,
504
689
  managedConfig: {
505
690
  providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
@@ -571,7 +756,7 @@ async function createBuiltinFleet(cfg = {}) {
571
756
  rc: { type: 'boolean', required: false, default: false },
572
757
  managed: {
573
758
  type: 'object', requiredFor: 'managed',
574
- client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi'] },
759
+ client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi', 'shell'] },
575
760
  provider: { type: 'catalog', source: 'managedCatalog' },
576
761
  credentialProfile: { type: 'string', required: false, max: 32 },
577
762
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
@@ -603,11 +788,13 @@ async function createBuiltinFleet(cfg = {}) {
603
788
  cell: {
604
789
  id: { type: 'string', required: true, pattern: '^[A-Za-z0-9._-]{1,32}$', max: 32 },
605
790
  cwd: { type: 'string', required: true, max: CAPS.MAX_CWD_LEN, underHome: true },
791
+ cwdRel: { type: 'string', required: false, max: CAPS.MAX_CWD_LEN, portable: true, default: '' },
606
792
  engine: { type: 'string', required: true, ref: 'engine.id' },
607
793
  boot: { type: 'boolean', required: false, default: false },
608
794
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
609
795
  models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
610
796
  permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
797
+ commands: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_CELL_COMMAND_LEN, managedClient: 'shell' },
611
798
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
612
799
  },
613
800
  };
@@ -632,6 +819,8 @@ async function createBuiltinFleet(cfg = {}) {
632
819
 
633
820
  module.exports = {
634
821
  createBuiltinFleet,
822
+ backfillShellEngine,
823
+ resolveCellCwd,
635
824
  composeLaunchArgv,
636
825
  composeClientInvocation,
637
826
  minimalEnv,
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // Cause-preserving diagnostics for Fleet up() failures (T4).
4
+ //
5
+ // The up() path of the built-in fleet crosses five boundaries:
6
+ // 1. preflight gates (command trust, cwd, managed-engine config)
7
+ // 2. the secure launch broker (private runtime dir, payload, lifecycle)
8
+ // 3. tmux new-session (session creation / duplicate)
9
+ // 4. pane/client early-exit (readiness / liveness gate)
10
+ // 5. the cell client spawn (ENOENT/EACCES/... surfaced via cell-exec)
11
+ //
12
+ // When one of them failed, the cause used to be buried in a free-text error
13
+ // message. These bounded enums travel on structured HTTP errors instead, so
14
+ // FLEET_ACTION_FAILED can report a closed {status, code, phase} triple that
15
+ // NEVER embeds cwd/path, argv, env, prompt, token or credential data — only
16
+ // stable enum strings, which are safe to persist and to show.
17
+ //
18
+ // Add a value here ONLY when a new reachable boundary needs a stable cause.
19
+ // Anything not in the enum degrades to UNKNOWN (bounded) at the coercion gate,
20
+ // so an untagged/legacy/unexpected error can never leak an unbounded string.
21
+
22
+ const UNKNOWN = 'UNKNOWN';
23
+
24
+ // Lifecycle phase of up() where a boundary failed. Closed enum.
25
+ // preflight -> pre-launch validation gates
26
+ // launch-broker -> secure launch broker
27
+ // new-session -> tmux new-session creation
28
+ // readiness -> pane/client early-exit liveness gate
29
+ // spawn-client -> cell client spawn error surfaced via cell-exec
30
+ const PHASES = ['preflight', 'launch-broker', 'new-session', 'readiness', 'spawn-client'];
31
+ const PHASE_SET = new Set(PHASES);
32
+
33
+ // Stable failure code per boundary. Closed enum; free text never enters here.
34
+ const CODES = [
35
+ // preflight (pre-launch gates)
36
+ 'COMMAND_UNTRUSTED',
37
+ 'CWD_INVALID',
38
+ 'ENGINE_UNCONFIGURED',
39
+ 'SHELL_NOT_AVAILABLE',
40
+ // launch-broker
41
+ 'LAUNCH_BROKER_UNSAFE',
42
+ 'LAUNCH_BROKER_PAYLOAD',
43
+ 'LAUNCH_BROKER_CLOSED',
44
+ 'LAUNCH_BROKER_FAILED',
45
+ // new-session
46
+ 'NEW_SESSION_FAILED',
47
+ 'SESSION_DUPLICATE',
48
+ // readiness (client started but exited immediately)
49
+ 'CLIENT_EARLY_EXIT',
50
+ // spawn-client (cell client spawn error: ENOENT/EACCES/...)
51
+ 'SPAWN_CLIENT_FAILED',
52
+ // bounded fallback for any untagged / legacy / unexpected error
53
+ UNKNOWN,
54
+ ];
55
+ const CODE_SET = new Set(CODES);
56
+
57
+ function coerce(value, allowed, fallback) {
58
+ return typeof value === 'string' && allowed.has(value) ? value : fallback;
59
+ }
60
+
61
+ // Coerce a raw code/phase to the bounded enum, degrading to UNKNOWN.
62
+ // Pure + without dependencies: testable directly.
63
+ function codeOf(value) { return coerce(value, CODE_SET, UNKNOWN); }
64
+ function phaseOf(value) { return coerce(value, PHASE_SET, UNKNOWN); }
65
+
66
+ module.exports = {
67
+ UNKNOWN, PHASES, CODES, codeOf, phaseOf,
68
+ };
@@ -29,6 +29,7 @@ const MAX_MODEL_FLAG_LEN = 32;
29
29
  const MAX_MODEL_VAL_LEN = 128;
30
30
  const MAX_PROMPTFLAG_LEN = 32;
31
31
  const MAX_PROMPT_LEN = 8192; // 8 KB
32
+ const MAX_CELL_COMMAND_LEN = 4096;
32
33
  const MAX_TMUXSESSION_LEN = 64;
33
34
 
34
35
  const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
@@ -97,12 +98,14 @@ function parseDefinitions(raw) {
97
98
  if (d.cells.length > MAX_CELLS) return null;
98
99
 
99
100
  const engineIds = new Set();
101
+ const engineMap = new Map();
100
102
  const engines = [];
101
103
  for (const e of d.engines) {
102
104
  const eng = parseEngine(e);
103
105
  if (!eng) return null;
104
106
  if (engineIds.has(eng.id)) return null; // id engine univoco
105
107
  engineIds.add(eng.id);
108
+ engineMap.set(eng.id, eng);
106
109
  engines.push(eng);
107
110
  }
108
111
 
@@ -110,7 +113,7 @@ function parseDefinitions(raw) {
110
113
  const cellIds = new Set();
111
114
  const cells = [];
112
115
  for (const c of d.cells) {
113
- const cell = parseCell(c, engineIds);
116
+ const cell = parseCell(c, engineIds, engineMap);
114
117
  if (!cell) return null;
115
118
  if (cellIds.has(cell.id)) return null; // id cell univoco
116
119
  cellIds.add(cell.id);
@@ -215,7 +218,7 @@ function parseEngine(e) {
215
218
  return out;
216
219
  }
217
220
 
218
- function parseCell(c, engineIds) {
221
+ function parseCell(c, engineIds, engineMap = new Map()) {
219
222
  if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
220
223
 
221
224
  // id
@@ -224,6 +227,18 @@ function parseCell(c, engineIds) {
224
227
  // cwd (obbligatorio; la risoluzione/confinamento avviene via resolveCwd a runtime)
225
228
  if (typeof c.cwd !== 'string' || !c.cwd || c.cwd.length > MAX_CWD_LEN) return null;
226
229
 
230
+ // cwdRel (opzionale canonico, design §4.3): forma portatile home-relative.
231
+ // Qui si valida solo il FORMATO (stringa canonica): la coerenza cwd<->cwdRel
232
+ // e' un invariante di SCRITTURA (define/edit/restore), non di lettura — così
233
+ // le definizioni legacy (solo cwd) e quelle nuove (cwd+cwdRel) caricano senza
234
+ // riscrittura on-read e senza rendere il file illeggibile per disallineamenti.
235
+ let cwdRel;
236
+ if (c.cwdRel !== undefined) {
237
+ if (typeof c.cwdRel !== 'string') return null;
238
+ cwdRel = normalizeCwdRel(c.cwdRel);
239
+ if (cwdRel === null) return null;
240
+ }
241
+
227
242
  // engine = riferimento a engines[].id esistente (dangling -> null)
228
243
  if (typeof c.engine !== 'string' || !engineIds.has(c.engine)) return null;
229
244
 
@@ -268,10 +283,27 @@ function parseCell(c, engineIds) {
268
283
  for (const [engineId, value] of entries) {
269
284
  if (!engineIds.has(engineId)) return null;
270
285
  if (value !== 'standard' && value !== 'unsafe') return null;
286
+ if (engineMap.get(engineId)?.managed?.client === 'shell' && value !== 'standard') return null;
271
287
  permissionPolicies[engineId] = value;
272
288
  }
273
289
  }
274
290
 
291
+ // commands: comando Shell PER-CELL PER-ENGINE. La stringa resta opaca e
292
+ // viene interpretata solo dalla shell target con `-lc`; qui si applicano
293
+ // limiti e forma chiusa. Sono ammesse soltanto chiavi di engine Shell.
294
+ let commands;
295
+ if (c.commands !== undefined) {
296
+ if (!c.commands || typeof c.commands !== 'object' || Array.isArray(c.commands)) return null;
297
+ const entries = Object.entries(c.commands);
298
+ if (entries.length > MAX_ENGINES) return null;
299
+ commands = {};
300
+ for (const [engineId, value] of entries) {
301
+ if (!engineIds.has(engineId) || engineMap.get(engineId)?.managed?.client !== 'shell') return null;
302
+ if (typeof value !== 'string' || value.length > MAX_CELL_COMMAND_LEN || /[\x00-\x1f\x7f]/.test(value)) return null;
303
+ commands[engineId] = value;
304
+ }
305
+ }
306
+
275
307
  // prompt (opzionale, cap)
276
308
  let prompt;
277
309
  if (c.prompt !== undefined) {
@@ -296,9 +328,11 @@ function parseCell(c, engineIds) {
296
328
  }
297
329
 
298
330
  const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
331
+ if (cwdRel !== undefined) out.cwdRel = cwdRel;
299
332
  if (model !== undefined) out.model = model;
300
333
  if (Object.keys(models).length) out.models = models;
301
334
  if (permissionPolicies) out.permissionPolicies = permissionPolicies;
335
+ if (commands && Object.keys(commands).length) out.commands = commands;
302
336
  if (prompt !== undefined) out.prompt = prompt;
303
337
  return out;
304
338
  }
@@ -345,6 +379,52 @@ function resolveCwd(cwd, home) {
345
379
  } catch (_) { return null; }
346
380
  }
347
381
 
382
+ // ---------------------------------------------------------------------------
383
+ // cwdRel — cwd home-relative PORTATILE (design §4.3 / backup v3).
384
+ // Rappresentazione canonica di una cwd come percorso relativo alla home del
385
+ // device target: '' == la home stessa; 'personal' == <home>/personal.
386
+ // Helper PURI (nessun fs): la normalizzazione e' string-only e fail-closed.
387
+ // La risoluzione/confinamento finale resta demandata a resolveCwd (realpath su
388
+ // entrambi i lati), INVARIATO: cwdRel aggiunge un vincolo in scrittura, non lo
389
+ // indebolisce in lettura. Nessun '..'/assoluto/control/backslash/drive letter.
390
+ // ---------------------------------------------------------------------------
391
+ // Restituisce la forma canonica ('' = home) oppure null (input non portabile).
392
+ // Normalizza (collassa '.' e segmenti vuoti, scosta lo slash finale) RIFIUTANDO
393
+ // traversal, path assoluti, drive letter (Win), NUL/C0/DEL e backslash.
394
+ function normalizeCwdRel(rel, maxLen = MAX_CWD_LEN) {
395
+ if (typeof rel !== 'string') return null;
396
+ if (rel.length > maxLen) return null;
397
+ for (let i = 0; i < rel.length; i += 1) {
398
+ const c = rel.charCodeAt(i);
399
+ if (c <= 0x1f || c === 0x7f || c === 0x5c) return null; // C0, DEL, backslash
400
+ }
401
+ if (rel === '') return ''; // la home stessa
402
+ if (rel.charAt(0) === '/') return null; // path assoluto (leading sep)
403
+ if (/^[A-Za-z]:/.test(rel)) return null; // drive letter (Win-like)
404
+ const out = [];
405
+ for (const seg of rel.split('/')) {
406
+ if (seg === '' || seg === '.') continue; // collassa vuoti/dot
407
+ if (seg === '..') return null; // traversal
408
+ out.push(seg);
409
+ }
410
+ return out.join('/');
411
+ }
412
+
413
+ // Deriva il cwdRel canonico da una cwd ASSOLUTA rispetto a una home (entrambe
414
+ // gia' realpath: il caller passa realpath). Restituisce '' (== home), un rel
415
+ // normalizzato, oppure null se la cwd non e' esprimibile sotto la home.
416
+ // Pura: nessun fs. Usa path.relative sulle stringhe (sicuro perche' entrambi
417
+ // realpath e cwd confinato sotto home).
418
+ function deriveCwdRel(absCwd, home) {
419
+ if (typeof absCwd !== 'string' || !absCwd || typeof home !== 'string' || !home) return null;
420
+ if (absCwd.includes('\0') || home.includes('\0')) return null;
421
+ const rel = path.relative(home, absCwd);
422
+ if (rel === '') return ''; // cwd == home
423
+ if (path.isAbsolute(rel)) return null; // drive diverso (Win)
424
+ if (rel === '..' || rel.startsWith('..' + path.sep)) return null; // fuori home
425
+ return normalizeCwdRel(rel);
426
+ }
427
+
348
428
  // ---------------------------------------------------------------------------
349
429
  // loadDefinitions(p) -> parsed | null
350
430
  // Legge il file rifiutando i symlink; parse strict. Mai throw.
@@ -413,18 +493,20 @@ const CAPS = {
413
493
  SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
414
494
  MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
415
495
  MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
416
- MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_TMUXSESSION_LEN,
496
+ MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN, MAX_TMUXSESSION_LEN,
417
497
  };
418
498
 
419
499
  module.exports = {
420
500
  parseDefinitions,
421
501
  validateCommandTrust,
422
502
  resolveCwd,
503
+ normalizeCwdRel,
504
+ deriveCwdRel,
423
505
  loadDefinitions,
424
506
  atomicWrite,
425
507
  validTmuxName,
426
508
  CAPS,
427
509
  // Costanti esposte anche piatte (comode per la UI/schema e i test)
428
510
  SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
429
- MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN,
511
+ MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN,
430
512
  };
@@ -22,6 +22,7 @@ const os = require('node:os');
22
22
  const path = require('node:path');
23
23
  const { execFile } = require('node:child_process');
24
24
  const { minimalRuntimeEnv } = require('../runtime/env.js');
25
+ const { codeOf, phaseOf } = require('./causes.js');
25
26
 
26
27
  // Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
27
28
  // non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
@@ -34,7 +35,24 @@ function minimalEnv() {
34
35
  return minimalRuntimeEnv(process.env, { home: os.homedir() });
35
36
  }
36
37
 
37
- function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
38
+ // httpError(status, msg, data?, cause?) structured HTTP error. `data` carries
39
+ // arbitrary API detail for the response body; `cause` (T4) is the OPTIONAL
40
+ // bounded failure triple {phase, code} of the up() boundary that failed. The
41
+ // cause is coerced through the closed enum in causes.js (anything not
42
+ // allowlisted degrades to UNKNOWN) and attached as e.fleetCode / e.fleetPhase,
43
+ // so the fleet router can surface {status, code, phase} WITHOUT ever embedding
44
+ // cwd/path, argv, env, prompt, token or credentials. The two channels are kept
45
+ // distinct: `data` is free API detail, `cause` is the bounded failure triple.
46
+ function httpError(status, msg, data = null, cause = null) {
47
+ const e = new Error(msg);
48
+ e.status = status;
49
+ if (data) e.data = data;
50
+ if (cause) {
51
+ e.fleetCode = codeOf(cause.code);
52
+ e.fleetPhase = phaseOf(cause.phase);
53
+ }
54
+ return e;
55
+ }
38
56
 
39
57
  // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
40
58
  // NON devono mai ecoare i segreti delle definizioni.
@@ -45,6 +63,7 @@ const REDACTED = '‹redacted›';
45
63
  // - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
46
64
  // - testo del prompt della cella (cell.prompt)
47
65
  // - testo del prompt dell'engine (engine.prompt) se presente
66
+ // - comando Shell attivo per cella (cell.commands[cell.engine])
48
67
  // Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
49
68
  // tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
50
69
  // lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
@@ -58,6 +77,11 @@ function redactSecrets(text, engine, cell) {
58
77
  }
59
78
  if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
60
79
  if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
80
+ if (cell && typeof cell.engine === 'string'
81
+ && cell.commands && typeof cell.commands === 'object' && !Array.isArray(cell.commands)) {
82
+ const activeCommand = cell.commands[cell.engine];
83
+ if (typeof activeCommand === 'string' && activeCommand) secrets.push(activeCommand);
84
+ }
61
85
  // Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
62
86
  // che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
63
87
  secrets.sort((a, b) => b.length - a.length);