@mmmbuto/nexuscrew 0.8.26 → 0.8.28

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) {
@@ -487,6 +643,8 @@ async function createBuiltinFleet(cfg = {}) {
487
643
  credentialUsedBy: [...usedBy].sort(),
488
644
  };
489
645
  });
646
+ // realHome una volta per le vista cwdRel/needsRepair (derivate, NON persistite).
647
+ const realHome = resolveCwd(home, home);
490
648
  return {
491
649
  schemaVersion: defs.schemaVersion,
492
650
  engines: defs.engines.map((e) => {
@@ -495,11 +653,34 @@ async function createBuiltinFleet(cfg = {}) {
495
653
  if (e.managed) out.managedInfo = describeManaged(e.managed, { ...cfg, home });
496
654
  return out;
497
655
  }),
498
- cells: defs.cells.map((c) => ({
499
- ...c,
500
- ...(c.models ? { models: { ...c.models } } : {}),
501
- ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
502
- })),
656
+ cells: defs.cells.map((c) => {
657
+ const out = {
658
+ ...c,
659
+ ...(c.models ? { models: { ...c.models } } : {}),
660
+ ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
661
+ ...(c.commands ? { commands: { ...c.commands } } : {}),
662
+ };
663
+ // cwdRel portatile derivato (solo vista): per celle valide (cwd reale
664
+ // sotto home) espone il rel canonico; per celle persistite ma non
665
+ // valide/fuori home espone needsRepair:true. NESSUNA mutazione di
666
+ // fleet.json (la vista non scrive). cwdRel persistito e' sempre
667
+ // ricalcolato dal realpath (autoritativo) cosi' export e UI sono coerenti.
668
+ const realCwd = resolveCwd(c.cwd, home);
669
+ if (realCwd && realHome) {
670
+ const rel = deriveCwdRel(realCwd, realHome);
671
+ if (rel !== null) out.cwdRel = rel; else out.needsRepair = true;
672
+ } else {
673
+ out.needsRepair = true;
674
+ }
675
+ // Suggerimento azionabile ma mai applicato: se il basename della cwd
676
+ // foreign esiste gia' sotto la home target, la UI puo' offrirlo come
677
+ // cwdRel esplicita senza mostrare o reinviare il path sorgente.
678
+ if (out.needsRepair) {
679
+ const suggestion = suggestCwdRel(c.cwd, home);
680
+ if (suggestion) out.cwdSuggestion = suggestion;
681
+ }
682
+ return out;
683
+ }),
503
684
  managedCatalog,
504
685
  managedConfig: {
505
686
  providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
@@ -571,7 +752,7 @@ async function createBuiltinFleet(cfg = {}) {
571
752
  rc: { type: 'boolean', required: false, default: false },
572
753
  managed: {
573
754
  type: 'object', requiredFor: 'managed',
574
- client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi'] },
755
+ client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi', 'shell'] },
575
756
  provider: { type: 'catalog', source: 'managedCatalog' },
576
757
  credentialProfile: { type: 'string', required: false, max: 32 },
577
758
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
@@ -603,11 +784,13 @@ async function createBuiltinFleet(cfg = {}) {
603
784
  cell: {
604
785
  id: { type: 'string', required: true, pattern: '^[A-Za-z0-9._-]{1,32}$', max: 32 },
605
786
  cwd: { type: 'string', required: true, max: CAPS.MAX_CWD_LEN, underHome: true },
787
+ cwdRel: { type: 'string', required: false, max: CAPS.MAX_CWD_LEN, portable: true, default: '' },
606
788
  engine: { type: 'string', required: true, ref: 'engine.id' },
607
789
  boot: { type: 'boolean', required: false, default: false },
608
790
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
609
791
  models: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_MODEL_VAL_LEN },
610
792
  permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
793
+ commands: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_CELL_COMMAND_LEN, managedClient: 'shell' },
611
794
  prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
612
795
  },
613
796
  };
@@ -632,6 +815,8 @@ async function createBuiltinFleet(cfg = {}) {
632
815
 
633
816
  module.exports = {
634
817
  createBuiltinFleet,
818
+ backfillShellEngine,
819
+ resolveCellCwd,
635
820
  composeLaunchArgv,
636
821
  composeClientInvocation,
637
822
  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
+ };
@@ -5,6 +5,7 @@
5
5
  // single-use broker ticket; the real command, provider environment and restart
6
6
  // policy arrive in memory over the local 0600 Unix socket.
7
7
  const net = require('node:net');
8
+ const path = require('node:path');
8
9
  const { spawn } = require('node:child_process');
9
10
  const { MAX_PAYLOAD } = require('./launch-broker.js');
10
11
 
@@ -112,6 +113,16 @@ function receivePayload(socketPath, nonce, timeoutMs = 5000) {
112
113
  });
113
114
  }
114
115
 
116
+ function sanitizeSpawnError(error, command) {
117
+ const rawCode = error && typeof error.code === 'string' ? error.code : '';
118
+ const code = /^[A-Z][A-Z0-9_]{0,31}$/.test(rawCode) ? rawCode : 'SPAWN_ERROR';
119
+ let base = '';
120
+ try { base = path.basename(String(command || '')); } catch (_) { base = ''; }
121
+ base = base.replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 128);
122
+ if (!base) base = 'client';
123
+ return `nexuscrew cell spawn failed: ${code} ${base}`;
124
+ }
125
+
115
126
  function normalizeSupervise(value = {}) {
116
127
  return { ...DEFAULT_SUPERVISE, ...(value || {}) };
117
128
  }
@@ -156,7 +167,7 @@ async function main(argv = process.argv.slice(2), seams = {}) {
156
167
  const now = seams.now || Date.now;
157
168
  const sleep = seams.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
158
169
  const proc = seams.process || process;
159
- const writeError = seams.writeError || ((message) => process.stderr.write(message));
170
+ const writeError = seams.writeError || seams.stderrWrite || ((message) => process.stderr.write(message));
160
171
  const childEnv = { ...payload.env };
161
172
  // tmux injects these only after the broker ticket was created. Preserve them
162
173
  // for the actual TUI and bind NexusCrew MCP callbacks to the owning session.
@@ -191,6 +202,10 @@ async function main(argv = process.argv.slice(2), seams = {}) {
191
202
  childState.exited = true;
192
203
  prompt?.cancel();
193
204
  current = null;
205
+ if (result.error) {
206
+ writeError(`${sanitizeSpawnError(result.error, payload.command)}\n`);
207
+ return 1;
208
+ }
194
209
  const runtimeMs = Math.max(0, now() - startedAt);
195
210
  if (stopping) return 0;
196
211
  if (!supervise.enabled) return result.signal ? 128 : result.code;
@@ -231,5 +246,5 @@ if (require.main === module) {
231
246
 
232
247
  module.exports = {
233
248
  DEFAULT_SUPERVISE, parseArgs, validSupervise, validRestartPrompt, validPayload,
234
- receivePayload, normalizeSupervise, waitChild, scheduleRestartPrompt, main,
249
+ receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, scheduleRestartPrompt, main,
235
250
  };
@@ -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
  };