@mmmbuto/nexuscrew 0.8.28 → 0.8.30
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 +42 -0
- package/README.md +26 -5
- package/frontend/dist/assets/index-CJtVoawR.css +32 -0
- package/frontend/dist/assets/index-CPNWWnqN.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +89 -13
- package/lib/cli/doctor.js +25 -0
- package/lib/cli/fleet-service.js +9 -10
- package/lib/cli/init.js +8 -1
- package/lib/diagnostics/store.js +1 -1
- package/lib/fleet/builtin.js +5 -1
- package/lib/fleet/causes.js +1 -0
- package/lib/fleet/launch.js +6 -0
- package/lib/fleet/managed.js +14 -2
- package/lib/fleet/runtime.js +30 -14
- package/lib/mcp/tools.js +141 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-BAzlX2nP.css +0 -32
- package/frontend/dist/assets/index-DiKMLCvW.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-CPNWWnqN.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CJtVoawR.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.30"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -346,9 +346,64 @@ 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
|
|
349
|
+
function fleetServiceDefinitionNeedsRefresh(platform, home, installPath) {
|
|
350
|
+
let target;
|
|
351
|
+
try { target = installPath || fleetInstallPath(platform, home); } catch (_) { return false; }
|
|
352
|
+
try { fs.lstatSync(target); }
|
|
353
|
+
catch (error) { return error.code !== 'ENOENT'; }
|
|
354
|
+
return !checkServiceWorkingDirectory(platform, home, target).ok;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function serviceDefinitionNeedsRefresh(platform, home, installPath, fleetInstallPathOverride) {
|
|
350
358
|
if (servicePinsLegacyPort(platform, home, installPath)) return true;
|
|
351
|
-
|
|
359
|
+
if (!checkServiceWorkingDirectory(platform, home, installPath).ok) return true;
|
|
360
|
+
return fleetServiceDefinitionNeedsRefresh(platform, home, fleetInstallPathOverride);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function serviceMigrationMarkerPath(home, override) {
|
|
364
|
+
return override || path.join(home, '.nexuscrew', 'service-cwd-migration.pending');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function serviceMigrationPending(home, override) {
|
|
368
|
+
const target = serviceMigrationMarkerPath(home, override);
|
|
369
|
+
try {
|
|
370
|
+
const st = fs.lstatSync(target);
|
|
371
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`unsafe service migration marker: ${target}`);
|
|
372
|
+
return true;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error.code === 'ENOENT') return false;
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function ensureServiceMigrationMarker(home, override) {
|
|
380
|
+
const target = serviceMigrationMarkerPath(home, override);
|
|
381
|
+
if (serviceMigrationPending(home, override)) return target;
|
|
382
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
383
|
+
let fd;
|
|
384
|
+
try {
|
|
385
|
+
fd = fs.openSync(target, 'wx', 0o600);
|
|
386
|
+
fs.writeFileSync(fd, 'pending\n');
|
|
387
|
+
fs.fsyncSync(fd);
|
|
388
|
+
fs.fchmodSync(fd, 0o600);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error.code === 'EEXIST' && serviceMigrationPending(home, override)) return target;
|
|
391
|
+
throw error;
|
|
392
|
+
} finally {
|
|
393
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
394
|
+
}
|
|
395
|
+
return target;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function clearServiceMigrationMarker(home, override) {
|
|
399
|
+
const target = serviceMigrationMarkerPath(home, override);
|
|
400
|
+
try {
|
|
401
|
+
const st = fs.lstatSync(target);
|
|
402
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`unsafe service migration marker: ${target}`);
|
|
403
|
+
fs.unlinkSync(target);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error.code !== 'ENOENT') throw error;
|
|
406
|
+
}
|
|
352
407
|
}
|
|
353
408
|
|
|
354
409
|
function portAvailable(port, host = '127.0.0.1') {
|
|
@@ -471,18 +526,37 @@ async function smartUp(opts = {}) {
|
|
|
471
526
|
const home = opts.home || require('node:os').homedir();
|
|
472
527
|
const persistent = bootState({ ...opts, platform }).enabled;
|
|
473
528
|
let serviceRefreshed = false;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
529
|
+
const migrationPending = persistent
|
|
530
|
+
? serviceMigrationPending(home, opts.serviceMigrationMarkerPath) : false;
|
|
531
|
+
if (persistent && (migrationPending || serviceDefinitionNeedsRefresh(
|
|
532
|
+
platform, home, opts.installPath, opts.fleetInstallPath,
|
|
533
|
+
))) {
|
|
534
|
+
ensureServiceMigrationMarker(home, opts.serviceMigrationMarkerPath);
|
|
535
|
+
const initResult = runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
|
|
536
|
+
serviceRefreshed = !serviceDefinitionNeedsRefresh(
|
|
537
|
+
platform, home, opts.installPath, opts.fleetInstallPath,
|
|
538
|
+
);
|
|
539
|
+
if (!serviceRefreshed) {
|
|
540
|
+
throw new Error('service cwd migration failed verification; refusing to continue with a replaceable working directory');
|
|
541
|
+
}
|
|
542
|
+
const installFailures = Array.isArray(initResult?.installFailures) ? initResult.installFailures : [];
|
|
543
|
+
if (installFailures.length) {
|
|
544
|
+
const components = [...new Set(installFailures.map((failure) => failure?.component)
|
|
545
|
+
.filter((component) => component === 'service' || component === 'fleet-companion'))];
|
|
546
|
+
throw new Error(`service cwd migration activation failed verification${components.length ? ` (${components.join(', ')})` : ''}; refusing to continue with an unverified service runtime`);
|
|
547
|
+
}
|
|
548
|
+
// Writing and activating a definition does not prove that an already-active
|
|
549
|
+
// Linux unit adopted it (`systemctl enable --now` is a no-op for that
|
|
550
|
+
// process). Restart every persistent runtime exactly once after the files
|
|
551
|
+
// and activation outcomes are verified. The lifecycle helper preserves the
|
|
552
|
+
// shared tmux server on Linux and uses PID-pinned portable restart on Termux.
|
|
553
|
+
const migrated = restartImpl({ ...opts, platform, log: quiet });
|
|
554
|
+
if (!migrated || migrated.restarted !== true) {
|
|
555
|
+
throw new Error(`service cwd migration restart failed verification (${platform}); refusing to continue with an unverified service runtime`);
|
|
485
556
|
}
|
|
557
|
+
// Only a verified file migration plus successful service activation (and,
|
|
558
|
+
// on Termux, a verified runtime restart) clears the durable retry marker.
|
|
559
|
+
clearServiceMigrationMarker(home, opts.serviceMigrationMarkerPath);
|
|
486
560
|
}
|
|
487
561
|
|
|
488
562
|
if (!port) port = urlmod.loadPort(opts);
|
|
@@ -989,6 +1063,8 @@ module.exports = {
|
|
|
989
1063
|
dispatch, dispatchNodes, serve, start, stop, status, parseFlags, HELP, NODES_HELP,
|
|
990
1064
|
smartUp, url, tokenRotate, logs, doctor, update, restart,
|
|
991
1065
|
serviceDefinitionNeedsRefresh,
|
|
1066
|
+
fleetServiceDefinitionNeedsRefresh,
|
|
1067
|
+
serviceMigrationPending,
|
|
992
1068
|
portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
|
|
993
1069
|
servicePinsLegacyPort,
|
|
994
1070
|
isServiceRunning, readRoles,
|
package/lib/cli/doctor.js
CHANGED
|
@@ -11,6 +11,7 @@ const path = require('node:path');
|
|
|
11
11
|
const { execFileSync } = require('node:child_process');
|
|
12
12
|
const { detectPlatform, uid } = require('./platform.js');
|
|
13
13
|
const { installPath } = require('./service.js');
|
|
14
|
+
const { fleetInstallPath } = require('./fleet-service.js');
|
|
14
15
|
const { resolvePaths } = require('./url.js');
|
|
15
16
|
const { commandExists } = require('./path.js');
|
|
16
17
|
const { loadDefinitions } = require('../fleet/definitions.js');
|
|
@@ -126,6 +127,26 @@ function checkServiceWorkingDirectory(platform, home, installPathOverride) {
|
|
|
126
127
|
};
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
// The Fleet boot companion can win the boot race and create the shared tmux
|
|
131
|
+
// server before the HTTP service. Apply the same stable-HOME invariant to it.
|
|
132
|
+
// A missing companion is non-fatal because Fleet boot is optional; an installed
|
|
133
|
+
// but stale/unsafe definition is a real failure.
|
|
134
|
+
function checkFleetServiceWorkingDirectory(platform, home, installPathOverride) {
|
|
135
|
+
if (!['linux', 'mac', 'termux'].includes(platform)) {
|
|
136
|
+
return { name: 'fleet service cwd stabile', ok: true, detail: `${platform}: non applicabile` };
|
|
137
|
+
}
|
|
138
|
+
const target = installPathOverride || fleetInstallPath(platform, home);
|
|
139
|
+
try {
|
|
140
|
+
fs.lstatSync(target);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error.code === 'ENOENT') {
|
|
143
|
+
return { name: 'fleet service cwd stabile', ok: true, warn: true, detail: `companion non installato (${target})` };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const result = checkServiceWorkingDirectory(platform, home, target);
|
|
147
|
+
return { ...result, name: 'fleet service cwd stabile' };
|
|
148
|
+
}
|
|
149
|
+
|
|
129
150
|
function checkBoot(platform, home, execImpl) {
|
|
130
151
|
if (platform === 'termux') {
|
|
131
152
|
const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
@@ -425,6 +446,9 @@ function doctor(opts = {}) {
|
|
|
425
446
|
checkPty(ptyLoad),
|
|
426
447
|
checkService(platform, home, execImpl, uidVal, opts.installPath),
|
|
427
448
|
checkServiceWorkingDirectory(platform, home, opts.installPath),
|
|
449
|
+
fleetEnabled
|
|
450
|
+
? checkFleetServiceWorkingDirectory(platform, home, opts.fleetInstallPath)
|
|
451
|
+
: { name: 'fleet service cwd stabile', ok: true, warn: true, detail: 'Fleet disabilitata' },
|
|
428
452
|
checkBoot(platform, home, execImpl),
|
|
429
453
|
checkUserLinger(platform, execImpl, uidVal),
|
|
430
454
|
checkTmuxSurvival(platform, execImpl),
|
|
@@ -454,6 +478,7 @@ module.exports = {
|
|
|
454
478
|
checkMacServiceWorkingDirectory,
|
|
455
479
|
checkFleetDefinitions,
|
|
456
480
|
checkServiceWorkingDirectory,
|
|
481
|
+
checkFleetServiceWorkingDirectory,
|
|
457
482
|
checkTmuxSurvival, checkUserLinger,
|
|
458
483
|
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
459
484
|
checkMcpIdentity, checkTermuxExec,
|
package/lib/cli/fleet-service.js
CHANGED
|
@@ -41,13 +41,16 @@ function generateFleetService(opts) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function generateFleetLinux(opts) {
|
|
44
|
-
const
|
|
44
|
+
const home = opts.home || os.homedir();
|
|
45
45
|
const { nodeBin, entryPath } = opts;
|
|
46
46
|
// Parita' di hardening con service.js (M3): reject char non gestibili in systemd.
|
|
47
|
-
assertSystemdSafe('
|
|
47
|
+
assertSystemdSafe('home', home);
|
|
48
48
|
assertSystemdSafe('nodeBin', nodeBin);
|
|
49
49
|
assertSystemdSafe('entryPath', entryPath);
|
|
50
|
-
|
|
50
|
+
// fleet-boot can create the shared tmux server before the HTTP service. Its
|
|
51
|
+
// cwd must therefore be the stable HOME, never the replaceable npm package
|
|
52
|
+
// directory containing entryPath.
|
|
53
|
+
const stableCwd = escapeSystemdPath(home);
|
|
51
54
|
const node = escapeSystemdExec(nodeBin);
|
|
52
55
|
const entry = escapeSystemdExec(entryPath);
|
|
53
56
|
const nodeDir = escapeSystemdPath(path.dirname(nodeBin));
|
|
@@ -61,7 +64,7 @@ Type=oneshot
|
|
|
61
64
|
# fleet-boot may be the process that creates the shared tmux server. Keep
|
|
62
65
|
# systemd from reaping that server and its cells when this oneshot exits.
|
|
63
66
|
KillMode=process
|
|
64
|
-
WorkingDirectory=${
|
|
67
|
+
WorkingDirectory=${stableCwd}
|
|
65
68
|
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
66
69
|
ExecStart=${node} ${entry} fleet-boot
|
|
67
70
|
|
|
@@ -71,11 +74,9 @@ WantedBy=default.target
|
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
function generateFleetMac(opts) {
|
|
74
|
-
const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
|
|
75
77
|
const home = opts.home || os.homedir();
|
|
76
78
|
const nodeXml = escapeXml(opts.nodeBin);
|
|
77
79
|
const entryXml = escapeXml(opts.entryPath);
|
|
78
|
-
const repoXml = escapeXml(repoRoot);
|
|
79
80
|
const homeXml = escapeXml(home);
|
|
80
81
|
const launchPath = [...new Set([
|
|
81
82
|
path.dirname(opts.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
@@ -94,7 +95,7 @@ function generateFleetMac(opts) {
|
|
|
94
95
|
<string>fleet-boot</string>
|
|
95
96
|
</array>
|
|
96
97
|
<key>WorkingDirectory</key>
|
|
97
|
-
<string>${
|
|
98
|
+
<string>${homeXml}</string>
|
|
98
99
|
<key>EnvironmentVariables</key>
|
|
99
100
|
<dict>
|
|
100
101
|
<key>PATH</key>
|
|
@@ -112,10 +113,8 @@ function generateFleetMac(opts) {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
function generateFleetTermux(opts) {
|
|
115
|
-
const repoRoot = opts.repoRoot || deriveRepoRoot(opts.entryPath);
|
|
116
116
|
const nodeQ = shellQuote(opts.nodeBin);
|
|
117
117
|
const entryQ = shellQuote(opts.entryPath);
|
|
118
|
-
const repoQ = shellQuote(repoRoot);
|
|
119
118
|
return `#!/data/data/com.termux/files/usr/bin/sh
|
|
120
119
|
# NexusCrew fleet boot companion (Termux) - avvia le celle boot:true
|
|
121
120
|
export PATH=/data/data/com.termux/files/usr/bin:$PATH
|
|
@@ -124,7 +123,7 @@ export PREFIX=/data/data/com.termux/files/usr
|
|
|
124
123
|
export TMPDIR=$PREFIX/tmp
|
|
125
124
|
export TMUX_TMPDIR=$PREFIX/var/run
|
|
126
125
|
mkdir -p "$TMPDIR" "$TMUX_TMPDIR"
|
|
127
|
-
cd -- $
|
|
126
|
+
cd -- "$HOME"
|
|
128
127
|
exec ${nodeQ} ${entryQ} fleet-boot >> "$HOME/.nexuscrew/fleet-boot.log" 2>&1
|
|
129
128
|
`;
|
|
130
129
|
}
|
package/lib/cli/init.js
CHANGED
|
@@ -109,6 +109,9 @@ function runInit(opts = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
const actions = [];
|
|
112
|
+
// Structured, value-free install outcomes for callers that must fail closed
|
|
113
|
+
// (notably smart-up migrations). Human-readable actions remain compatible.
|
|
114
|
+
const installFailures = [];
|
|
112
115
|
|
|
113
116
|
// porta: opts.port > migration rule > config esistente > default 41820
|
|
114
117
|
let port = opts.port;
|
|
@@ -214,12 +217,14 @@ function runInit(opts = {}) {
|
|
|
214
217
|
const r = installService(platform, content, svcCtx, { execImpl: opts.execImpl });
|
|
215
218
|
if (r.failures && r.failures.length) {
|
|
216
219
|
// file installato MA activation (systemctl/launchctl) fallita [M1]
|
|
220
|
+
installFailures.push({ component: 'service', phase: 'activation' });
|
|
217
221
|
actions.push(`WARN: service file installato ${r.target} MA activation fallita: ${r.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
|
|
218
222
|
} else {
|
|
219
223
|
actions.push(`service installed ${r.target} (mode 0${fileMode(platform).toString(8)})`);
|
|
220
224
|
}
|
|
221
225
|
} catch (e) {
|
|
222
226
|
// failure: preserve file + diagnosi (no rollback) [M8]
|
|
227
|
+
installFailures.push({ component: 'service', phase: 'install' });
|
|
223
228
|
actions.push(`WARN: service install fallito: ${e.message} (file generati preservati)`);
|
|
224
229
|
}
|
|
225
230
|
}
|
|
@@ -273,6 +278,7 @@ function runInit(opts = {}) {
|
|
|
273
278
|
);
|
|
274
279
|
if (fr.failures && fr.failures.length) {
|
|
275
280
|
// file installato MA activation (systemctl/launchctl) fallita [M1]
|
|
281
|
+
installFailures.push({ component: 'fleet-companion', phase: 'activation' });
|
|
276
282
|
actions.push(`WARN: fleet companion file installato ${fr.target} MA activation fallita: ${fr.failures.map((f) => f.cmd).join('; ')} (file preservato, diagnosi)`);
|
|
277
283
|
} else {
|
|
278
284
|
actions.push(`fleet companion installed ${fr.target} (mode 0${fleetFileMode(platform).toString(8)})`);
|
|
@@ -282,6 +288,7 @@ function runInit(opts = {}) {
|
|
|
282
288
|
}
|
|
283
289
|
} catch (e) {
|
|
284
290
|
// qualunque errore del companion: WARN + init principale prosegue intatto.
|
|
291
|
+
installFailures.push({ component: 'fleet-companion', phase: 'install' });
|
|
285
292
|
actions.push(`WARN: fleet companion fallito: ${e.message} (init principale prosegue)`);
|
|
286
293
|
}
|
|
287
294
|
|
|
@@ -303,7 +310,7 @@ function runInit(opts = {}) {
|
|
|
303
310
|
|
|
304
311
|
for (const a of actions) log(a);
|
|
305
312
|
|
|
306
|
-
return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
|
|
313
|
+
return { platform, port, token, url: urlWithToken, actions, installFailures, tmuxOk, dryRun };
|
|
307
314
|
}
|
|
308
315
|
|
|
309
316
|
module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic, ensureFleetDefaults };
|
package/lib/diagnostics/store.js
CHANGED
|
@@ -18,7 +18,7 @@ function redactText(value, max = 160) {
|
|
|
18
18
|
.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
|
|
19
19
|
.replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)_[A-Za-z0-9_-]{8,}\b/g, '[redacted]')
|
|
20
20
|
.replace(/\b(?:token|password|secret|api[_-]?key)\s*[=:]\s*\S+/gi, '[redacted]')
|
|
21
|
-
.replace(/(?:\/home\/[^/\s]+|\/data\/(?:data|user\/\d+)\/[^/\s]+)(?:\/[^\s]*)?/g, '[path]')
|
|
21
|
+
.replace(/(?:\/home\/[^/\s]+|\/Users\/[^/\s]+|\/data\/(?:data|user\/\d+)\/[^/\s]+)(?:\/[^\s]*)?/g, '[path]')
|
|
22
22
|
.replace(/\b[A-Z][A-Z0-9_]{2,}\s*=\s*\S+/g, '[env]');
|
|
23
23
|
return text.slice(0, max);
|
|
24
24
|
}
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -620,8 +620,12 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
620
620
|
if (defs.cells.some((c) => c.id === id)) throw httpError(409, `id cella già usato: ${id}`);
|
|
621
621
|
const cwd = typeof b.cwd === 'string' && b.cwd.trim() ? b.cwd.trim() : home;
|
|
622
622
|
const cellDef = { id, cwd, engine: engineId, boot: b.boot === true, tmuxSession };
|
|
623
|
+
// Import is a write boundary just like define/edit/restore. Resolve and
|
|
624
|
+
// confine the cwd before mutate so a foreign or missing path is never
|
|
625
|
+
// persisted as a cell that can only fail later at up().
|
|
626
|
+
const [resolvedCell] = resolveCellsOrFail([cellDef], home);
|
|
623
627
|
try {
|
|
624
|
-
await mutate(defs, (d) => { d.cells.push(
|
|
628
|
+
await mutate(defs, (d) => { d.cells.push(resolvedCell); });
|
|
625
629
|
} catch (e) { throw httpError(400, `import non valido: ${e.message}`); }
|
|
626
630
|
return { ok: true, id, tmuxSession, imported: true };
|
|
627
631
|
}
|
package/lib/fleet/causes.js
CHANGED
|
@@ -47,6 +47,7 @@ const CODES = [
|
|
|
47
47
|
'SESSION_DUPLICATE',
|
|
48
48
|
// readiness (client started but exited immediately)
|
|
49
49
|
'CLIENT_EARLY_EXIT',
|
|
50
|
+
'SHELL_COMMAND_FAILED',
|
|
50
51
|
// spawn-client (cell client spawn error: ENOENT/EACCES/...)
|
|
51
52
|
'SPAWN_CLIENT_FAILED',
|
|
52
53
|
// bounded fallback for any untagged / legacy / unexpected error
|
package/lib/fleet/launch.js
CHANGED
|
@@ -63,6 +63,7 @@ const REDACTED = '‹redacted›';
|
|
|
63
63
|
// - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
|
|
64
64
|
// - testo del prompt della cella (cell.prompt)
|
|
65
65
|
// - testo del prompt dell'engine (engine.prompt) se presente
|
|
66
|
+
// - comando Shell attivo per cella (cell.commands[cell.engine])
|
|
66
67
|
// Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
|
|
67
68
|
// tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
|
|
68
69
|
// lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
|
|
@@ -76,6 +77,11 @@ function redactSecrets(text, engine, cell) {
|
|
|
76
77
|
}
|
|
77
78
|
if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
|
|
78
79
|
if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
|
|
80
|
+
if (cell && typeof cell.engine === 'string'
|
|
81
|
+
&& cell.commands && typeof cell.commands === 'object' && !Array.isArray(cell.commands)) {
|
|
82
|
+
const activeCommand = cell.commands[cell.engine];
|
|
83
|
+
if (typeof activeCommand === 'string' && activeCommand) secrets.push(activeCommand);
|
|
84
|
+
}
|
|
79
85
|
// Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
|
|
80
86
|
// che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
|
|
81
87
|
secrets.sort((a, b) => b.length - a.length);
|
package/lib/fleet/managed.js
CHANGED
|
@@ -312,6 +312,17 @@ function shellLoginArgs(command) {
|
|
|
312
312
|
return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || ''))) ? ['-l'] : [];
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// Un command configurato deve vedere lo stesso ambiente della shell interattiva
|
|
316
|
+
// che l'utente ottiene lasciando vuoto il campo. Con `-lc`, zsh non carica
|
|
317
|
+
// `.zshrc`: alias e PATH user-locali (per esempio ~/.local/bin) spariscono e il
|
|
318
|
+
// command esce 127 pur essendo disponibile nel terminale normale. I quattro
|
|
319
|
+
// shell POSIX-like supportati vengono quindi avviati login+interactive+command;
|
|
320
|
+
// shell custom conservano il contratto storico -lc.
|
|
321
|
+
function shellConfiguredCommandArgs(command, raw) {
|
|
322
|
+
return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || '')))
|
|
323
|
+
? ['-lic', raw] : ['-lc', raw];
|
|
324
|
+
}
|
|
325
|
+
|
|
315
326
|
// Termux reports process.platform === 'android' and deliberately has no
|
|
316
327
|
// /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
|
|
317
328
|
// `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
|
|
@@ -641,7 +652,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
641
652
|
const raw = cell?.commands && typeof cell.commands[engineId] === 'string'
|
|
642
653
|
? cell.commands[engineId] : '';
|
|
643
654
|
shellOneShot = raw.trim().length > 0;
|
|
644
|
-
if (shellOneShot) args.push(
|
|
655
|
+
if (shellOneShot) args.push(...shellConfiguredCommandArgs(info.binary, raw));
|
|
645
656
|
else args.push(...shellLoginArgs(info.binary));
|
|
646
657
|
} else if (spec.client === 'claude') {
|
|
647
658
|
if (spec.provider === 'native') {
|
|
@@ -792,5 +803,6 @@ module.exports = {
|
|
|
792
803
|
defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
793
804
|
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
794
805
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
|
|
795
|
-
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
|
|
806
|
+
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
|
|
807
|
+
shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,
|
|
796
808
|
};
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -219,11 +219,10 @@ function createBuiltinRuntime(ctx) {
|
|
|
219
219
|
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
|
|
220
220
|
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
221
221
|
// Mantieni il pane morto solo durante la finestra di readiness: permette di
|
|
222
|
-
// catturare un errore reale del client
|
|
223
|
-
// Il separatore e' interpretato da tmux (execFile argv diretto), non
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
222
|
+
// catturare sia un errore reale del client sia l'exit immediato di un command
|
|
223
|
+
// Shell. Il separatore e' interpretato da tmux (execFile argv diretto), non
|
|
224
|
+
// dalla shell che esegue il command configurato.
|
|
225
|
+
argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
|
|
227
226
|
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
228
227
|
if (launch.err) {
|
|
229
228
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
@@ -235,15 +234,6 @@ function createBuiltinRuntime(ctx) {
|
|
|
235
234
|
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
236
235
|
}
|
|
237
236
|
|
|
238
|
-
// Un comando Shell valorizzato e' intenzionalmente one-shot: l'uscita del
|
|
239
|
-
// processo, anche immediata o non-zero, e' il risultato del comando e non
|
|
240
|
-
// un crash del client Fleet. Non si mantiene il pane e non si abilita il
|
|
241
|
-
// supervisore; la cella torna inattiva quando la shell termina.
|
|
242
|
-
if (launchEngine.shellOneShot) {
|
|
243
|
-
cache = { ...cache, at: 0 };
|
|
244
|
-
return { ok: true, cell: cellId, session: cell.tmuxSession, prompt: null, oneShot: true };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
237
|
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
248
238
|
// moment later (missing login, bad model, incompatible provider). Without
|
|
249
239
|
// this readiness gate the PWA reported success and then showed nothing.
|
|
@@ -263,8 +253,23 @@ function createBuiltinRuntime(ctx) {
|
|
|
263
253
|
// nella Fleet o nella lista tmux dopo aver raccolto l'errore.
|
|
264
254
|
await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
|
|
265
255
|
cache = { ...cache, at: 0 };
|
|
256
|
+
// Un command Shell completato rapidamente con exit 0 e' un one-shot
|
|
257
|
+
// riuscito. Qualunque altro exit immediato e' invece osservabile come
|
|
258
|
+
// errore strutturato: prima il runtime restituiva un falso successo e
|
|
259
|
+
// scartava proprio l'exit 127/diagnostica che servivano all'operatore.
|
|
260
|
+
if (launchEngine.shellOneShot && readiness.status === 0) {
|
|
261
|
+
return {
|
|
262
|
+
ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
|
|
263
|
+
oneShot: true, active: false, completed: true, exitCode: 0,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
266
|
const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
|
|
267
267
|
const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
|
|
268
|
+
if (launchEngine.shellOneShot) {
|
|
269
|
+
throw httpError(500,
|
|
270
|
+
`comando Shell terminato subito${status}: ${diagnostic || 'verifica command, PATH e configurazione della shell'}`,
|
|
271
|
+
null, { phase: 'readiness', code: 'SHELL_COMMAND_FAILED' });
|
|
272
|
+
}
|
|
268
273
|
// Cause-preserving (T4): distinguish a cell-client spawn failure (the
|
|
269
274
|
// captured pane carries the stable 'cell spawn failed:' marker produced by
|
|
270
275
|
// cell-exec.js) from a generic early exit. Both stay on the readiness
|
|
@@ -278,6 +283,17 @@ function createBuiltinRuntime(ctx) {
|
|
|
278
283
|
['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
|
|
279
284
|
}
|
|
280
285
|
|
|
286
|
+
// Il command Shell e' partito ed e' ancora vivo dopo la finestra di
|
|
287
|
+
// readiness: la cella deve risultare attiva (per CLI interattive come agy),
|
|
288
|
+
// poi tornera' inattiva quando il processo terminera' naturalmente.
|
|
289
|
+
if (launchEngine.shellOneShot) {
|
|
290
|
+
cache = { ...cache, at: 0 };
|
|
291
|
+
return {
|
|
292
|
+
ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
|
|
293
|
+
oneShot: true, active: true, completed: false,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
281
297
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
282
298
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
283
299
|
let prompt = null;
|
package/lib/mcp/tools.js
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// argomenti e orchestrazione delle chiamate via ctx.api. Ordine, nomi, schemi,
|
|
10
10
|
// handler, identity gate e semantica read/write sono preservati EXACT.
|
|
11
11
|
const path = require('node:path');
|
|
12
|
+
const { codeOf, phaseOf } = require('../fleet/causes.js');
|
|
12
13
|
const {
|
|
13
14
|
NODE_ID_RE, orderedDeckMembers, fleetStatusPath, fleetCellsBySession,
|
|
14
|
-
routePath, topologyOwners, memberOwnerId, parseCellTarget,
|
|
15
|
+
routePath, topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload,
|
|
16
|
+
readCellDirectory,
|
|
15
17
|
} = require('./cells.js');
|
|
16
18
|
|
|
17
19
|
// --- helper input tool (fail-closed) ------------------------------------------
|
|
@@ -57,6 +59,93 @@ function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
|
|
|
57
59
|
);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
// Il command Shell e' utile per diagnosticare una cella locale, ma puo'
|
|
63
|
+
// contenere credenziali scritte per errore. Manteniamo la struttura operativa
|
|
64
|
+
// e redigiamo flag/assegnazioni credential-shaped prima che il valore lasci il
|
|
65
|
+
// bridge MCP. Nessun command entra mai nella directory federata nc_cells.
|
|
66
|
+
function commandForDiagnostics(raw) {
|
|
67
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
68
|
+
return { configured: false, value: null, redacted: false, truncated: false };
|
|
69
|
+
}
|
|
70
|
+
let value = raw.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
71
|
+
const before = value;
|
|
72
|
+
value = value
|
|
73
|
+
.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
|
|
74
|
+
.replace(/((?:--)?(?:api[-_]?key|token|secret|password|credential))(\s*(?:=|:)\s*|\s+)(?:"[^"]*"|'[^']*'|\S+)/gi,
|
|
75
|
+
'$1$2[redacted]')
|
|
76
|
+
.replace(/\b([A-Za-z][A-Za-z0-9_]*(?:_API_KEY|_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIAL|_AUTH)[A-Za-z0-9_]*)\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/gi,
|
|
77
|
+
'$1=[redacted]')
|
|
78
|
+
// env maiuscole generiche senza separatore prima del suffisso credenziale
|
|
79
|
+
// (ZAIKEY=, PASSWD=, MYPASS=, DATABASE_URL=...): coerente con
|
|
80
|
+
// lib/diagnostics/store.js, ma consuma anche valori quotati per intero.
|
|
81
|
+
.replace(/\b([A-Z][A-Z0-9_]{2,})\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '$1=[redacted]')
|
|
82
|
+
.replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)[_-][A-Za-z0-9_-]{8,}\b/g, '[redacted]');
|
|
83
|
+
const truncated = value.length > 4096;
|
|
84
|
+
if (truncated) value = value.slice(0, 4096);
|
|
85
|
+
return { configured: true, value, redacted: value !== before || truncated, truncated };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Accetta soltanto i due eventi failure prodotti dal router Fleet e ricostruisce
|
|
89
|
+
// una causa chiusa. Free text e meta ignoti vengono sempre scartati, anche se
|
|
90
|
+
// arrivano da un runtime precedente o compromesso.
|
|
91
|
+
function failureForDiagnostics(record, cell) {
|
|
92
|
+
if (!record || record.component !== 'fleet' || !record.meta || record.meta.cell !== cell) return null;
|
|
93
|
+
const at = typeof record.ts === 'string' && record.ts.length <= 64 ? record.ts : null;
|
|
94
|
+
const status = Number.isSafeInteger(record.meta.status) && record.meta.status >= 100 && record.meta.status <= 599
|
|
95
|
+
? record.meta.status : null;
|
|
96
|
+
if (record.code === 'CELL_SPAWN_FAILED') {
|
|
97
|
+
return {
|
|
98
|
+
event: 'CELL_SPAWN_FAILED', at, status,
|
|
99
|
+
code: 'SPAWN_CLIENT_FAILED', phase: 'spawn-client',
|
|
100
|
+
errno: /^[A-Z][A-Z0-9_]{0,31}$/.test(String(record.meta.errno || '')) ? record.meta.errno : null,
|
|
101
|
+
client: /^[A-Za-z0-9._+-]{1,128}$/.test(String(record.meta.client || '')) ? record.meta.client : null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (record.code !== 'FLEET_ACTION_FAILED') return null;
|
|
105
|
+
return {
|
|
106
|
+
event: 'FLEET_ACTION_FAILED', at, status,
|
|
107
|
+
code: codeOf(record.meta.code), phase: phaseOf(record.meta.phase),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function lastFailureForCell(records, cell) {
|
|
112
|
+
if (!Array.isArray(records)) return null;
|
|
113
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
114
|
+
const failure = failureForDiagnostics(records[i], cell);
|
|
115
|
+
if (failure) return failure;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function recentDiagnostics(ctx) {
|
|
121
|
+
const out = [];
|
|
122
|
+
let after = 0;
|
|
123
|
+
// Store bounded a 500 record: tre pagine da 200 coprono l'intero buffer senza
|
|
124
|
+
// una query illimitata e senza dipendere dalla modalita' verbose.
|
|
125
|
+
for (let page = 0; page < 3; page += 1) {
|
|
126
|
+
const payload = await ctx.api('GET', `/api/diagnostics/logs?after=${after}&limit=200`);
|
|
127
|
+
const records = Array.isArray(payload && payload.records) ? payload.records : [];
|
|
128
|
+
out.push(...records);
|
|
129
|
+
const cursor = Number.isSafeInteger(payload && payload.cursor) ? payload.cursor : after;
|
|
130
|
+
if (records.length < 200 || cursor <= after) break;
|
|
131
|
+
after = cursor;
|
|
132
|
+
}
|
|
133
|
+
return out.slice(-500);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// La diagnostica command/cause e' intenzionalmente locale: non deve neppure
|
|
137
|
+
// interrogare la topologia o i proxy remoti. Questo helper costruisce la stessa
|
|
138
|
+
// forma normalizzata di nc_cells usando soltanto config + /api/cells locali.
|
|
139
|
+
async function readLocalCellDirectory(ctx, callerSession) {
|
|
140
|
+
const [config, payload] = await Promise.all([
|
|
141
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/cells'),
|
|
142
|
+
]);
|
|
143
|
+
const nodeId = String(config && config.instanceId || '');
|
|
144
|
+
if (!NODE_ID_RE.test(nodeId)) throw new Error('instanceId locale non disponibile');
|
|
145
|
+
const owner = { instanceId: nodeId, route: [], label: 'Local', stale: false };
|
|
146
|
+
return { nodeId, cells: normalizeCellPayload(payload, owner, callerSession) };
|
|
147
|
+
}
|
|
148
|
+
|
|
60
149
|
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
61
150
|
// annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
|
|
62
151
|
const TOOLS = [
|
|
@@ -263,6 +352,53 @@ const TOOLS = [
|
|
|
263
352
|
return readCellDirectory(ctx, callerSession);
|
|
264
353
|
},
|
|
265
354
|
},
|
|
355
|
+
{
|
|
356
|
+
name: 'nc_cell_diagnostics',
|
|
357
|
+
description: 'Diagnostica read-only target-specifica di una cella Fleet locale: command Shell redatto e ultima causa spawn/start bounded. Richiede un caller Fleet locale attivo; non attraversa la federazione.',
|
|
358
|
+
inputSchema: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {
|
|
361
|
+
target: { type: 'string', description: 'id owner-qualified locale restituito da nc_cells: <instanceId>:<cell>' },
|
|
362
|
+
},
|
|
363
|
+
required: ['target'],
|
|
364
|
+
},
|
|
365
|
+
annotations: { readOnlyHint: true },
|
|
366
|
+
async handler(args, ctx) {
|
|
367
|
+
const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
|
|
368
|
+
if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
|
|
369
|
+
const identity = await ctx.identity();
|
|
370
|
+
const callerSession = requireSession(identity.session, 'nc_cell_diagnostics', identity.code);
|
|
371
|
+
const directory = await readLocalCellDirectory(ctx, callerSession);
|
|
372
|
+
const sender = directory.cells.find((cell) => cell.self && cell.active);
|
|
373
|
+
if (!sender) throw new Error('nc_cell_diagnostics: la sessione chiamante non e\' una cella Fleet attiva locale');
|
|
374
|
+
if (targetRef.instanceId !== directory.nodeId) {
|
|
375
|
+
throw new Error('nc_cell_diagnostics: target remoto rifiutato; i command non attraversano la federazione');
|
|
376
|
+
}
|
|
377
|
+
const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
|
|
378
|
+
&& cell.cell === targetRef.cell);
|
|
379
|
+
if (!target) throw new Error('nc_cell_diagnostics: cella locale non trovata');
|
|
380
|
+
|
|
381
|
+
const [definitions, diagnostics] = await Promise.all([
|
|
382
|
+
ctx.api('GET', '/api/fleet/definitions'), recentDiagnostics(ctx),
|
|
383
|
+
]);
|
|
384
|
+
const definition = Array.isArray(definitions && definitions.cells)
|
|
385
|
+
? definitions.cells.find((cell) => cell && cell.id === target.cell) : null;
|
|
386
|
+
if (!definition || definition.tmuxSession !== target.tmuxSession || definition.engine !== target.engine) {
|
|
387
|
+
throw new Error('nc_cell_diagnostics: definizione locale incoerente o non disponibile');
|
|
388
|
+
}
|
|
389
|
+
const rawCommand = definition.commands && typeof definition.commands === 'object'
|
|
390
|
+
? definition.commands[definition.engine] : '';
|
|
391
|
+
return {
|
|
392
|
+
target: target.id,
|
|
393
|
+
cell: target.cell,
|
|
394
|
+
tmuxSession: target.tmuxSession,
|
|
395
|
+
engine: target.engine,
|
|
396
|
+
active: target.active,
|
|
397
|
+
command: commandForDiagnostics(rawCommand),
|
|
398
|
+
lastFailure: lastFailureForCell(diagnostics, target.cell),
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
},
|
|
266
402
|
{
|
|
267
403
|
name: 'nc_send_cell',
|
|
268
404
|
description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
|
|
@@ -346,4 +482,7 @@ const TOOLS = [
|
|
|
346
482
|
},
|
|
347
483
|
];
|
|
348
484
|
|
|
349
|
-
module.exports = {
|
|
485
|
+
module.exports = {
|
|
486
|
+
TOOLS, argString, requireSession, commandForDiagnostics, failureForDiagnostics,
|
|
487
|
+
lastFailureForCell, readLocalCellDirectory, IDENTITY_CODE, IDENTITY_REMEDIATION,
|
|
488
|
+
};
|