@mmmbuto/nexuscrew 0.8.27 → 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.
@@ -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-CWKyv6KS.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-xxfypte7.css">
14
+ <script type="module" crossorigin src="/assets/index-DiKMLCvW.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-BAzlX2nP.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.27"}
1
+ {"version":"0.8.28"}
@@ -14,7 +14,7 @@ const pidf = require('./pidfile.js');
14
14
  const { runInit, ensureFleetDefaults } = require('./init.js');
15
15
  const { rotateToken } = require('../auth/token.js');
16
16
  const urlmod = require('./url.js');
17
- const { doctor } = require('./doctor.js');
17
+ const { doctor, checkServiceWorkingDirectory } = require('./doctor.js');
18
18
  const nodesStore = require('../nodes/store.js');
19
19
  const nodesTunnel = require('../nodes/tunnel.js');
20
20
  const nodesCmds = require('../nodes/commands.js');
@@ -346,6 +346,11 @@ function servicePinsLegacyPort(platform, home, installPath) {
346
346
  try { return /NEXUSCREW_PORT/.test(fs.readFileSync(target, 'utf8')); } catch (_) { return false; }
347
347
  }
348
348
 
349
+ function serviceDefinitionNeedsRefresh(platform, home, installPath) {
350
+ if (servicePinsLegacyPort(platform, home, installPath)) return true;
351
+ return !checkServiceWorkingDirectory(platform, home, installPath).ok;
352
+ }
353
+
349
354
  function portAvailable(port, host = '127.0.0.1') {
350
355
  return new Promise((resolve) => {
351
356
  const s = net.createServer();
@@ -465,8 +470,19 @@ async function smartUp(opts = {}) {
465
470
  // config.json forever. Regenerate once so config.json becomes authoritative.
466
471
  const home = opts.home || require('node:os').homedir();
467
472
  const persistent = bootState({ ...opts, platform }).enabled;
468
- if (persistent && servicePinsLegacyPort(platform, home, opts.installPath)) {
473
+ let serviceRefreshed = false;
474
+ if (persistent && serviceDefinitionNeedsRefresh(platform, home, opts.installPath)) {
469
475
  runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
476
+ serviceRefreshed = checkServiceWorkingDirectory(platform, home, opts.installPath).ok;
477
+ // Termux:Boot has no service manager, so replacing the script alone cannot
478
+ // change the cwd of the already-running pidfile owner. Restart exactly once
479
+ // after a verified migration; Linux/macOS are restarted by installService.
480
+ if (serviceRefreshed && platform === 'termux') {
481
+ const migrated = restartImpl({ ...opts, platform, log: quiet });
482
+ if (!migrated || migrated.restarted !== true) {
483
+ throw new Error(`service cwd migration restart failed: ${(migrated && migrated.reason) || 'unknown'}`);
484
+ }
485
+ }
470
486
  }
471
487
 
472
488
  if (!port) port = urlmod.loadPort(opts);
@@ -972,6 +988,7 @@ function dispatch(argv, opts = {}) {
972
988
  module.exports = {
973
989
  dispatch, dispatchNodes, serve, start, stop, status, parseFlags, HELP, NODES_HELP,
974
990
  smartUp, url, tokenRotate, logs, doctor, update, restart,
991
+ serviceDefinitionNeedsRefresh,
975
992
  portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
976
993
  servicePinsLegacyPort,
977
994
  isServiceRunning, readRoles,
package/lib/cli/doctor.js CHANGED
@@ -79,22 +79,50 @@ function checkMacServiceWorkingDirectory(platform, home, installPathOverride) {
79
79
  if (platform !== 'mac') {
80
80
  return { name: 'launchd cwd stabile', ok: true, detail: `${platform}: non applicabile` };
81
81
  }
82
- const target = installPathOverride || installPath('mac', home);
82
+ return checkServiceWorkingDirectory(platform, home, installPathOverride);
83
+ }
84
+
85
+ // The service cwd is inherited by a shared tmux server and every future pane.
86
+ // It must therefore be HOME, never the replaceable runtime directory. This
87
+ // check reads only the installed definition and fails closed on missing,
88
+ // symlinked, malformed, or legacy service files.
89
+ function checkServiceWorkingDirectory(platform, home, installPathOverride) {
90
+ if (!['linux', 'mac', 'termux'].includes(platform)) {
91
+ return { name: 'service cwd stabile', ok: true, detail: `${platform}: non applicabile` };
92
+ }
93
+ const target = installPathOverride || installPath(platform, home);
83
94
  let raw;
84
- try { raw = fs.readFileSync(target, 'utf8'); }
95
+ try {
96
+ const st = fs.lstatSync(target);
97
+ if (st.isSymbolicLink() || !st.isFile()) throw Object.assign(new Error('definizione service non regolare'), { code: 'UNSAFE' });
98
+ raw = fs.readFileSync(target, 'utf8');
99
+ }
85
100
  catch (error) {
86
101
  return {
87
- name: 'launchd cwd stabile', ok: false,
88
- detail: error.code === 'ENOENT' ? `plist non installato (${target})` : error.message,
102
+ name: 'service cwd stabile', ok: false,
103
+ detail: error.code === 'ENOENT' ? `service non installato (${target})` : error.message,
89
104
  };
90
105
  }
91
- const match = raw.match(/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/);
92
- if (!match) return { name: 'launchd cwd stabile', ok: false, detail: 'WorkingDirectory assente dal plist' };
93
- const actual = decodeXmlText(match[1]);
94
- const expected = String(home);
106
+ let actual = '';
107
+ let ok = false;
108
+ if (platform === 'mac') {
109
+ const match = raw.match(/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/);
110
+ if (!match) return { name: 'service cwd stabile', ok: false, detail: 'WorkingDirectory assente dal plist' };
111
+ actual = decodeXmlText(match[1]);
112
+ ok = actual === String(home);
113
+ } else if (platform === 'linux') {
114
+ const match = raw.match(/^WorkingDirectory=(.+)$/m);
115
+ if (!match) return { name: 'service cwd stabile', ok: false, detail: 'WorkingDirectory assente dalla unit' };
116
+ actual = match[1].replace(/%%/g, '%');
117
+ ok = actual === String(home);
118
+ } else {
119
+ const stable = /^\s*cd -- "\$HOME"\s*$/m.test(raw);
120
+ actual = stable ? '$HOME' : (/^\s*cd\s+--\s+(.+)$/m.exec(raw)?.[1] || 'cd assente');
121
+ ok = stable;
122
+ }
95
123
  return {
96
- name: 'launchd cwd stabile', ok: actual === expected,
97
- detail: actual === expected ? expected : `${actual} (atteso HOME stabile: ${expected})`,
124
+ name: 'service cwd stabile', ok,
125
+ detail: ok ? (platform === 'termux' ? '$HOME' : String(home)) : `${actual} (atteso HOME stabile)`,
98
126
  };
99
127
  }
100
128
 
@@ -261,6 +289,61 @@ function checkTermuxExec(runtimeEnv, opts = {}) {
261
289
  };
262
290
  }
263
291
 
292
+ // Read-only probe of the long-lived tmux server cwd. A server that retained an
293
+ // unlinked runtime directory keeps accepting clients but makes later children
294
+ // fail getcwd(3). Never reports the path itself; only stable state.
295
+ function checkTmuxServerCwd(platform, execImpl, opts = {}) {
296
+ if (!['linux', 'termux'].includes(platform)) {
297
+ return { name: 'tmux server cwd', ok: true, detail: `${platform}: non applicabile` };
298
+ }
299
+ let rawPid = '';
300
+ try {
301
+ rawPid = String(execImpl(opts.tmuxBin || 'tmux', ['display-message', '-p', '#{pid}'], { encoding: 'utf8' }) || '').trim();
302
+ } catch (_) {
303
+ return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
304
+ }
305
+ if (!/^\d+$/.test(rawPid)) {
306
+ return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non rilevato' };
307
+ }
308
+ try {
309
+ const resolveImpl = opts.procCwdImpl || ((pid) => fs.realpathSync(`/proc/${pid}/cwd`));
310
+ const cwd = resolveImpl(Number(rawPid));
311
+ if (typeof cwd !== 'string' || !cwd) throw new Error('cwd vuota');
312
+ return { name: 'tmux server cwd', ok: true, detail: 'cwd risolvibile' };
313
+ } catch (_) {
314
+ return {
315
+ name: 'tmux server cwd', ok: false,
316
+ detail: 'cwd del server tmux non risolvibile (directory sostituita); termina le sessioni in modo esplicito e riavvia tmux',
317
+ };
318
+ }
319
+ }
320
+
321
+ // Inspect only the single tmux global environment key required by
322
+ // termux-exec. The value is validated and then discarded; it is never logged.
323
+ // This distinguishes a healthy server from RC-2 (stale/no preload) and RC-7
324
+ // (present but rejected by the trust boundary) without killing any session.
325
+ function checkTmuxServerTermuxPreload(runtimeEnv, execImpl, opts = {}) {
326
+ const env = runtimeEnv || process.env;
327
+ const platform = opts.platform || detectPlatform();
328
+ if (!termuxRuntimePaths(env, { platform, home: opts.home })) {
329
+ return { name: 'tmux server termux-exec', ok: true, detail: `${platform}: non applicabile` };
330
+ }
331
+ let raw = '';
332
+ try {
333
+ raw = String(execImpl(opts.tmuxBin || 'tmux', ['show-environment', '-g', 'LD_PRELOAD'], { encoding: 'utf8' }) || '').trim();
334
+ } catch (_) {
335
+ return { name: 'tmux server termux-exec', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
336
+ }
337
+ const match = raw.match(/^LD_PRELOAD=(.+)$/);
338
+ const trusted = match && trustedTermuxPreload({ ...env, LD_PRELOAD: match[1] }, { platform, home: opts.home });
339
+ return trusted
340
+ ? { name: 'tmux server termux-exec', ok: true, detail: 'preload trusted presente nel server tmux' }
341
+ : {
342
+ name: 'tmux server termux-exec', ok: false,
343
+ detail: 'LD_PRELOAD assente o non trusted nel server tmux; server stale o formato non compatibile (nessun kill automatico)',
344
+ };
345
+ }
346
+
264
347
  // Check MCP identity (P0). NON-FAILING per costrutto: `ok` è sempre true, così
265
348
  // chi usa NexusCrew solo come PWA (nessuna integrazione MCP) non vede mai il
266
349
  // doctor andare in FAIL solo per env MCP assente. Il check osserva SOLO la
@@ -341,7 +424,7 @@ function doctor(opts = {}) {
341
424
  checkTmux(existsImpl, opts.tmuxBin),
342
425
  checkPty(ptyLoad),
343
426
  checkService(platform, home, execImpl, uidVal, opts.installPath),
344
- checkMacServiceWorkingDirectory(platform, home, opts.installPath),
427
+ checkServiceWorkingDirectory(platform, home, opts.installPath),
345
428
  checkBoot(platform, home, execImpl),
346
429
  checkUserLinger(platform, execImpl, uidVal),
347
430
  checkTmuxSurvival(platform, execImpl),
@@ -352,6 +435,8 @@ function doctor(opts = {}) {
352
435
  checkAutossh(existsImpl),
353
436
  checkSshPermitlisten(opts.sshVersion),
354
437
  checkMcpIdentity(opts.env),
438
+ checkTmuxServerCwd(platform, execImpl, { tmuxBin: opts.tmuxBin, procCwdImpl: opts.procCwdImpl }),
439
+ checkTmuxServerTermuxPreload(opts.env, execImpl, { platform, home, tmuxBin: opts.tmuxBin }),
355
440
  ];
356
441
 
357
442
  for (const c of checks) {
@@ -368,7 +453,9 @@ module.exports = {
368
453
  checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
369
454
  checkMacServiceWorkingDirectory,
370
455
  checkFleetDefinitions,
456
+ checkServiceWorkingDirectory,
371
457
  checkTmuxSurvival, checkUserLinger,
372
458
  checkSshClient, checkAutossh, checkSshPermitlisten,
373
459
  checkMcpIdentity, checkTermuxExec,
460
+ checkTmuxServerCwd, checkTmuxServerTermuxPreload,
374
461
  };
@@ -59,9 +59,12 @@ function generateService(platform, ctx) {
59
59
  function generateLinux(ctx) {
60
60
  assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
61
61
  assertSystemdSafe('nodeBin', ctx.nodeBin);
62
- const workDir = ctx.runtimeDir || path.join(ctx.home, '.nexuscrew');
63
- assertSystemdSafe('runtimeDir', workDir);
64
- const runtime = escapeSystemdPath(workDir);
62
+ // HOME is stable across init/repair/update. The runtime directory may be
63
+ // atomically replaced; keeping it as the service cwd leaves the HTTP process
64
+ // and a shared tmux server holding an orphaned inode, so every later pane can
65
+ // fail getcwd(3). Runtime state still lives in runtimeDir; only cwd changes.
66
+ assertSystemdSafe('home', ctx.home);
67
+ const runtime = escapeSystemdPath(ctx.home);
65
68
  const node = escapeSystemdExec(ctx.nodeBin);
66
69
  const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
67
70
  const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
@@ -151,7 +154,7 @@ export LANG=\${LANG:-en_US.UTF-8}
151
154
  export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
152
155
  termux-wake-lock 2>/dev/null || true
153
156
  mkdir -p "$HOME/.nexuscrew" "$TMPDIR" "$TMUX_TMPDIR"
154
- cd -- "$HOME/.nexuscrew"
157
+ cd -- "$HOME"
155
158
  exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
156
159
  `;
157
160
  }
@@ -7,7 +7,7 @@ const ALLOWED_DURATIONS = new Set([300, 900, 1800, 3600]);
7
7
  const LEVELS = new Set(['debug', 'info', 'warn', 'error']);
8
8
  const META_KEYS = new Set([
9
9
  'port', 'platform', 'reason', 'durationSeconds', 'count', 'errno', 'client',
10
- 'cell', 'engine', 'action', 'state', 'transport', 'phase', 'version', 'status', 'node',
10
+ 'cell', 'engine', 'action', 'state', 'transport', 'phase', 'version', 'status', 'node', 'code',
11
11
  ]);
12
12
  const DENIED_KEY = /(authorization|cookie|token|secret|credential|password|prompt|terminal|argv|command|env|content|payload|file|path|endpoint|url)/i;
13
13
 
@@ -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,