@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.
- package/CHANGELOG.md +23 -0
- package/README.md +23 -8
- package/frontend/dist/assets/{index-xxfypte7.css → index-BAzlX2nP.css} +1 -1
- package/frontend/dist/assets/index-DiKMLCvW.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +19 -2
- package/lib/cli/doctor.js +98 -11
- package/lib/cli/service.js +7 -4
- package/lib/diagnostics/store.js +1 -1
- package/lib/fleet/builtin.js +201 -16
- package/lib/fleet/causes.js +68 -0
- package/lib/fleet/definitions.js +86 -4
- package/lib/fleet/launch.js +19 -1
- package/lib/fleet/managed.js +62 -9
- package/lib/fleet/routes.js +16 -2
- package/lib/fleet/runtime.js +69 -33
- package/package.json +1 -1
- package/frontend/dist/assets/index-CWKyv6KS.js +0 -93
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
1
|
+
{"version":"0.8.28"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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: '
|
|
88
|
-
detail: error.code === 'ENOENT' ? `
|
|
102
|
+
name: 'service cwd stabile', ok: false,
|
|
103
|
+
detail: error.code === 'ENOENT' ? `service non installato (${target})` : error.message,
|
|
89
104
|
};
|
|
90
105
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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: '
|
|
97
|
-
detail:
|
|
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
|
-
|
|
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
|
};
|
package/lib/cli/service.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
157
|
+
cd -- "$HOME"
|
|
155
158
|
exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
|
|
156
159
|
`;
|
|
157
160
|
}
|
package/lib/diagnostics/store.js
CHANGED
|
@@ -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
|
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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,
|