@mmmbuto/nexuscrew 0.8.22 → 0.8.24
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/README.md +11 -3
- package/frontend/dist/assets/index-CU9OOtL-.js +91 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +40 -4
- package/lib/cli/doctor.js +65 -11
- package/lib/cli/fleet-service.js +3 -0
- package/lib/cli/init.js +30 -6
- package/lib/config.js +7 -0
- package/lib/fleet/builtin.js +13 -1
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/routes.js +1 -0
- package/lib/fleet/runtime.js +5 -2
- package/lib/nodes/store.js +40 -1
- package/lib/server.js +9 -3
- package/lib/settings/pairing-coordinator.js +22 -1
- package/lib/settings/public-peering-routes.js +11 -2
- package/lib/settings/routes.js +14 -5
- package/lib/tmux/lifecycle.js +4 -2
- package/lib/tmux/shared-server.js +60 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-ClJP2j6k.js +0 -91
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-CU9OOtL-.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-77r8nzQf.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.24"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -11,7 +11,7 @@ const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
|
11
11
|
const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
|
|
12
12
|
const { fleetInstallPath } = require('./fleet-service.js');
|
|
13
13
|
const pidf = require('./pidfile.js');
|
|
14
|
-
const { runInit } = require('./init.js');
|
|
14
|
+
const { runInit, ensureFleetDefaults } = require('./init.js');
|
|
15
15
|
const { rotateToken } = require('../auth/token.js');
|
|
16
16
|
const urlmod = require('./url.js');
|
|
17
17
|
const { doctor } = require('./doctor.js');
|
|
@@ -31,6 +31,7 @@ const HELP = `NexusCrew — PWA for local and remote AI workers.
|
|
|
31
31
|
|
|
32
32
|
Usage:
|
|
33
33
|
nexuscrew start in background; show status and quick guide
|
|
34
|
+
nexuscrew init initialize missing stores idempotently
|
|
34
35
|
nexuscrew show start when needed and open the authenticated PWA
|
|
35
36
|
nexuscrew show token print the clickable authenticated URL
|
|
36
37
|
nexuscrew boot enable startup at boot (use: boot off|status)
|
|
@@ -66,6 +67,7 @@ Usage:
|
|
|
66
67
|
[--label TEXT] [--json]
|
|
67
68
|
printf '%s' "$PAIRING_URL" | nexuscrew nodes pair|join
|
|
68
69
|
[--ssh TARGET] [--name SLUG] [--label TEXT]
|
|
70
|
+
[--local-name SLUG] [--local-label TEXT]
|
|
69
71
|
nexuscrew nodes identity [--json]
|
|
70
72
|
|
|
71
73
|
Pairing links are read from stdin, never from argv. Mutations honor
|
|
@@ -79,7 +81,7 @@ Lifecycle semantics:
|
|
|
79
81
|
|
|
80
82
|
const CLI_VALUE_FLAGS = new Set([
|
|
81
83
|
'label', 'ssh', 'ssh-port', 'autostart', 'visibility', 'selected',
|
|
82
|
-
'name', 'local-label', 'identity-file',
|
|
84
|
+
'name', 'local-name', 'local-label', 'identity-file', 'port',
|
|
83
85
|
]);
|
|
84
86
|
|
|
85
87
|
// valueFlags (Set|array opzionale): flag che consumano il token successivo nella
|
|
@@ -110,6 +112,10 @@ function parseFlags(argv, valueFlags) {
|
|
|
110
112
|
|
|
111
113
|
function serve(opts = {}) {
|
|
112
114
|
const serverStart = opts.serverStart || require('../server.js').start;
|
|
115
|
+
// Service manager e Termux:Boot entrano direttamente da `serve`, senza
|
|
116
|
+
// passare per smartUp. Ripara solo fleet.json MANCANTE; un file invalido
|
|
117
|
+
// resta intatto e il provider continua a fallire chiuso.
|
|
118
|
+
(opts.ensureFleetDefaultsImpl || ensureFleetDefaults)(opts);
|
|
113
119
|
if (opts.pidfile) {
|
|
114
120
|
const pidPath = pidf.defaultPidfilePath(opts.home);
|
|
115
121
|
// already-running check
|
|
@@ -450,6 +456,11 @@ async function smartUp(opts = {}) {
|
|
|
450
456
|
initialized = true;
|
|
451
457
|
}
|
|
452
458
|
|
|
459
|
+
// Config+token da soli non garantiscono un'installazione completa. Questo
|
|
460
|
+
// era il buco delle installazioni/migrazioni Termux che lasciava il provider
|
|
461
|
+
// disabilitato e l'editor Fleet irraggiungibile.
|
|
462
|
+
const fleetBootstrap = (opts.ensureFleetDefaultsImpl || ensureFleetDefaults)(opts);
|
|
463
|
+
|
|
453
464
|
// 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
|
|
454
465
|
// config.json forever. Regenerate once so config.json becomes authoritative.
|
|
455
466
|
const home = opts.home || require('node:os').homedir();
|
|
@@ -463,6 +474,19 @@ async function smartUp(opts = {}) {
|
|
|
463
474
|
let running = await probe(port, token, opts);
|
|
464
475
|
let portableAttempted = false;
|
|
465
476
|
let runtime = resolveRuntimeOwner({ ...opts, platform });
|
|
477
|
+
|
|
478
|
+
// selectProvider() viene risolto una volta allo startup. Se abbiamo creato
|
|
479
|
+
// fleet.json mentre un vecchio processo era gia' vivo, serve un restart
|
|
480
|
+
// verificato per fargli acquisire il provider builtin.
|
|
481
|
+
if (running && fleetBootstrap.created) {
|
|
482
|
+
const restarted = restartImpl({ ...opts, platform, log: quiet });
|
|
483
|
+
if (!restarted || restarted.restarted !== true) {
|
|
484
|
+
throw new Error(`fleet bootstrap completato ma restart fallito: ${(restarted && restarted.reason) || 'esito non verificato'}`);
|
|
485
|
+
}
|
|
486
|
+
running = await waitForNexusCrew(port, token, opts);
|
|
487
|
+
if (!running) throw new Error(`server non pronto dopo il bootstrap Fleet su 127.0.0.1:${port}`);
|
|
488
|
+
runtime = resolveRuntimeOwner({ ...opts, platform });
|
|
489
|
+
}
|
|
466
490
|
if (!running && runtime.owner !== 'stopped') running = await waitForNexusCrew(port, token, opts);
|
|
467
491
|
|
|
468
492
|
if (!running) {
|
|
@@ -824,6 +848,7 @@ async function dispatchNodes(rest, flags, opts = {}) {
|
|
|
824
848
|
...(flags.label !== undefined || decoded.label ? { label: flags.label || decoded.label } : {}),
|
|
825
849
|
...(flags['ssh-port'] !== undefined || decoded.sshPort ? { sshPort: Number(flags['ssh-port'] || decoded.sshPort) } : {}),
|
|
826
850
|
...(flags['local-label'] !== undefined ? { localLabel: flags['local-label'] } : {}),
|
|
851
|
+
...(flags['local-name'] !== undefined ? { localName: flags['local-name'] } : {}),
|
|
827
852
|
...(flags['identity-file'] !== undefined ? { identityFile: flags['identity-file'] } : {}),
|
|
828
853
|
};
|
|
829
854
|
if (!body.name || !body.ssh) {
|
|
@@ -864,7 +889,7 @@ function dispatch(argv, opts = {}) {
|
|
|
864
889
|
log(HELP);
|
|
865
890
|
return { code: 0 };
|
|
866
891
|
}
|
|
867
|
-
// Public surface: normal start, show, boot, doctor, help, version.
|
|
892
|
+
// Public surface: normal start, init, show, boot, doctor, help, version.
|
|
868
893
|
if (!cmd) {
|
|
869
894
|
if (Object.keys(flags).length) {
|
|
870
895
|
log(`unknown option: --${Object.keys(flags)[0]}\n\n${HELP}`);
|
|
@@ -882,6 +907,17 @@ function dispatch(argv, opts = {}) {
|
|
|
882
907
|
if (rest[1]) { log('usage: nexuscrew show [token]'); return { code: 1 }; }
|
|
883
908
|
return smartUp({ ...opts, forceOpen: true, log: opts.lifecycleLog || (() => {}) }).then(() => ({ code: 0 }));
|
|
884
909
|
}
|
|
910
|
+
if (cmd === 'init') {
|
|
911
|
+
const port = flags.port === undefined ? undefined : Number(flags.port);
|
|
912
|
+
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
913
|
+
log('usage: nexuscrew init [--dry-run] [--port PORT]');
|
|
914
|
+
return { code: 1 };
|
|
915
|
+
}
|
|
916
|
+
(opts.runInitImpl || runInit)({
|
|
917
|
+
...opts, dryRun: flags['dry-run'] === true, port, log,
|
|
918
|
+
});
|
|
919
|
+
return { code: 0 };
|
|
920
|
+
}
|
|
885
921
|
if (cmd === 'boot') {
|
|
886
922
|
const sub = rest[1] || 'on';
|
|
887
923
|
if (sub === 'status') {
|
|
@@ -911,7 +947,7 @@ function dispatch(argv, opts = {}) {
|
|
|
911
947
|
// Internal runtime commands used by service managers and MCP clients. They
|
|
912
948
|
// are intentionally omitted from HELP and are not configuration surfaces.
|
|
913
949
|
if (cmd === 'serve') {
|
|
914
|
-
serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
|
|
950
|
+
serve({ ...opts, pidfile: flags.pidfile, serverStart: opts.serverStart });
|
|
915
951
|
return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
|
|
916
952
|
}
|
|
917
953
|
if (cmd === 'doctor') {
|
package/lib/cli/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@ const { detectPlatform, uid } = require('./platform.js');
|
|
|
13
13
|
const { installPath } = require('./service.js');
|
|
14
14
|
const { resolvePaths } = require('./url.js');
|
|
15
15
|
const { commandExists } = require('./path.js');
|
|
16
|
+
const { loadDefinitions } = require('../fleet/definitions.js');
|
|
16
17
|
|
|
17
18
|
function nodeMajor() {
|
|
18
19
|
return parseInt(String(process.versions.node).split('.')[0], 10);
|
|
@@ -111,18 +112,38 @@ function checkTmuxSurvival(platform, execImpl) {
|
|
|
111
112
|
if (platform !== 'linux') {
|
|
112
113
|
return { name: 'tmux survival on service restart', ok: true, detail: `${platform}: systemd cgroup non applicabile` };
|
|
113
114
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
115
|
+
const units = ['nexuscrew.service', 'nexuscrew-fleet.service'];
|
|
116
|
+
const results = [];
|
|
117
|
+
for (const unit of units) {
|
|
118
|
+
try {
|
|
119
|
+
const loadState = String(execImpl('systemctl', [
|
|
120
|
+
'--user', 'show', unit, '--property=LoadState', '--value',
|
|
121
|
+
], { encoding: 'utf8' }) || '').trim();
|
|
122
|
+
if (loadState === 'not-found') {
|
|
123
|
+
results.push({ unit, skipped: true, detail: 'non installata' });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const value = String(execImpl('systemctl', [
|
|
127
|
+
'--user', 'show', unit, '--property=KillMode', '--value',
|
|
128
|
+
], { encoding: 'utf8' }) || '').trim();
|
|
129
|
+
results.push({ unit, ok: value === 'process', value: value || 'sconosciuto' });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (/not[ -]?found|could not be found|not loaded/i.test(String(error && error.message || error))) {
|
|
132
|
+
results.push({ unit, skipped: true, detail: 'non installata' });
|
|
133
|
+
} else {
|
|
134
|
+
results.push({ unit, ok: false, value: `non verificabile: ${error.message || error}` });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
125
137
|
}
|
|
138
|
+
const checked = results.filter((result) => !result.skipped);
|
|
139
|
+
const ok = checked.length > 0 && checked.every((result) => result.ok);
|
|
140
|
+
const detail = results.map((result) => result.skipped
|
|
141
|
+
? `${result.unit}: ${result.detail}`
|
|
142
|
+
: `${result.unit}: KillMode=${result.value}`).join(' · ');
|
|
143
|
+
return {
|
|
144
|
+
name: 'tmux survival on service restart', ok,
|
|
145
|
+
detail: ok ? detail : `${detail} (restart/oneshot puo terminare tmux)`,
|
|
146
|
+
};
|
|
126
147
|
}
|
|
127
148
|
|
|
128
149
|
// ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
|
|
@@ -165,6 +186,34 @@ function checkTokenPerms(tokenPath) {
|
|
|
165
186
|
}
|
|
166
187
|
}
|
|
167
188
|
|
|
189
|
+
function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
|
|
190
|
+
const target = fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
|
|
191
|
+
if (!enabled) {
|
|
192
|
+
return { name: 'Fleet builtin definitions', ok: true, warn: true, detail: 'disabilitata intenzionalmente' };
|
|
193
|
+
}
|
|
194
|
+
let st;
|
|
195
|
+
try {
|
|
196
|
+
st = fs.lstatSync(target);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return {
|
|
199
|
+
name: 'Fleet builtin definitions', ok: false,
|
|
200
|
+
detail: e.code === 'ENOENT' ? `fleet.json assente (${target}); esegui nexuscrew per riparare` : e.message,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (!st.isFile() || st.isSymbolicLink()) {
|
|
204
|
+
return { name: 'Fleet builtin definitions', ok: false, detail: `target non sicuro o non regolare (${target})` };
|
|
205
|
+
}
|
|
206
|
+
const defs = loadDefinitions(target);
|
|
207
|
+
if (!defs) {
|
|
208
|
+
return { name: 'Fleet builtin definitions', ok: false, detail: `fleet.json invalido (${target}); preservato, non sovrascritto` };
|
|
209
|
+
}
|
|
210
|
+
const mode = st.mode & 0o777;
|
|
211
|
+
return {
|
|
212
|
+
name: 'Fleet builtin definitions', ok: true, warn: mode !== 0o600,
|
|
213
|
+
detail: `${defs.engines.length} engine · ${defs.cells.length} celle · mode 0${mode.toString(8)}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
168
217
|
// Esegue tutti i check. Seam iniettabili per test (platform, home, execImpl, ptyLoad).
|
|
169
218
|
function doctor(opts = {}) {
|
|
170
219
|
const platform = opts.platform || detectPlatform();
|
|
@@ -175,6 +224,9 @@ function doctor(opts = {}) {
|
|
|
175
224
|
const ptyLoad = opts.ptyLoad || (() => require('../pty/provider.js').loadPty());
|
|
176
225
|
const existsImpl = opts.commandExists || commandExists;
|
|
177
226
|
const { tokenPath } = resolvePaths(opts);
|
|
227
|
+
const fleetEnabled = opts.fleetEnabled !== false
|
|
228
|
+
&& opts.builtinEnabled !== false
|
|
229
|
+
&& process.env.NEXUSCREW_FLEET !== '0';
|
|
178
230
|
|
|
179
231
|
const checks = [
|
|
180
232
|
checkNode(),
|
|
@@ -185,6 +237,7 @@ function doctor(opts = {}) {
|
|
|
185
237
|
checkUserLinger(platform, execImpl, uidVal),
|
|
186
238
|
checkTmuxSurvival(platform, execImpl),
|
|
187
239
|
checkTokenPerms(tokenPath),
|
|
240
|
+
checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
|
|
188
241
|
checkSshClient(existsImpl),
|
|
189
242
|
checkAutossh(existsImpl),
|
|
190
243
|
checkSshPermitlisten(opts.sshVersion),
|
|
@@ -202,6 +255,7 @@ function doctor(opts = {}) {
|
|
|
202
255
|
module.exports = {
|
|
203
256
|
doctor, nodeMajor,
|
|
204
257
|
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
258
|
+
checkFleetDefinitions,
|
|
205
259
|
checkTmuxSurvival, checkUserLinger,
|
|
206
260
|
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
207
261
|
};
|
package/lib/cli/fleet-service.js
CHANGED
|
@@ -58,6 +58,9 @@ After=network-online.target
|
|
|
58
58
|
|
|
59
59
|
[Service]
|
|
60
60
|
Type=oneshot
|
|
61
|
+
# fleet-boot may be the process that creates the shared tmux server. Keep
|
|
62
|
+
# systemd from reaping that server and its cells when this oneshot exits.
|
|
63
|
+
KillMode=process
|
|
61
64
|
WorkingDirectory=${repo}
|
|
62
65
|
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
63
66
|
ExecStart=${node} ${entry} fleet-boot
|
package/lib/cli/init.js
CHANGED
|
@@ -47,6 +47,30 @@ function writeConfigAtomic(configPath, value) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// Bootstrap/migration idempotente degli artefatti Fleet. Un file presente non
|
|
51
|
+
// viene MAI riscritto qui: se e' invalido, il provider e doctor devono restare
|
|
52
|
+
// fail-closed e mostrarne la causa. Questo helper copre anche installazioni
|
|
53
|
+
// parziali in cui config+token esistono ma fleet.json non fu mai creato.
|
|
54
|
+
function ensureFleetDefaults(opts = {}) {
|
|
55
|
+
const home = opts.home || os.homedir();
|
|
56
|
+
const configDir = opts.configDir || path.join(home, '.nexuscrew');
|
|
57
|
+
const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
|
|
58
|
+
const enabled = opts.fleetEnabled !== false
|
|
59
|
+
&& opts.builtinEnabled !== false
|
|
60
|
+
&& process.env.NEXUSCREW_FLEET !== '0';
|
|
61
|
+
if (!enabled) return { path: fleetDefsPath, created: false, enabled: false };
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
fs.lstatSync(fleetDefsPath);
|
|
65
|
+
return { path: fleetDefsPath, created: false, enabled: true };
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (e.code !== 'ENOENT') throw e;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
writeFleet(fleetDefsPath, defaultDefinitions());
|
|
71
|
+
return { path: fleetDefsPath, created: true, enabled: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
// Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
|
|
51
75
|
function readExistingPort(platform, home, installPathOverride) {
|
|
52
76
|
const p = installPathOverride || svcInstallPath(platform, home);
|
|
@@ -118,15 +142,15 @@ function runInit(opts = {}) {
|
|
|
118
142
|
// Fleet app defaults: soltanto i quattro client nativi. Provider cloud/Z.AI sono
|
|
119
143
|
// disponibili nel catalogo managed ma vanno aggiunti esplicitamente.
|
|
120
144
|
const fleetDefsPath = opts.fleetDefsPath || path.join(configDir, 'fleet.json');
|
|
121
|
-
if (!dryRun
|
|
145
|
+
if (!dryRun) {
|
|
122
146
|
try {
|
|
123
|
-
|
|
124
|
-
actions.push(
|
|
147
|
+
const fleetBootstrap = ensureFleetDefaults({ ...opts, home, configDir, fleetDefsPath });
|
|
148
|
+
if (!fleetBootstrap.enabled) actions.push('fleet defaults: disabilitati intenzionalmente');
|
|
149
|
+
else if (fleetBootstrap.created) actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native, pi.native)`);
|
|
150
|
+
else actions.push(`preserved fleet definitions ${fleetDefsPath}`);
|
|
125
151
|
} catch (e) {
|
|
126
152
|
actions.push(`WARN: fleet defaults non creati: ${e.message}`);
|
|
127
153
|
}
|
|
128
|
-
} else if (!dryRun) {
|
|
129
|
-
actions.push(`preserved fleet definitions ${fleetDefsPath}`);
|
|
130
154
|
}
|
|
131
155
|
if (!port) port = 41820;
|
|
132
156
|
|
|
@@ -282,4 +306,4 @@ function runInit(opts = {}) {
|
|
|
282
306
|
return { platform, port, token, url: urlWithToken, actions, tmuxOk, dryRun };
|
|
283
307
|
}
|
|
284
308
|
|
|
285
|
-
module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic };
|
|
309
|
+
module.exports = { runInit, readExistingPort, haveTmux, nodeMajor, writeConfigAtomic, ensureFleetDefaults };
|
package/lib/config.js
CHANGED
|
@@ -19,6 +19,9 @@ function baseDefaults() {
|
|
|
19
19
|
port: 41820,
|
|
20
20
|
tokenPath: path.join(os.homedir(), '.nexuscrew', 'token'),
|
|
21
21
|
tmuxBin: 'tmux',
|
|
22
|
+
// Shared-server safety is operational hardening, not a same-UID security
|
|
23
|
+
// boundary. Disable only when the operator deliberately owns tmux policy.
|
|
24
|
+
protectSharedTmuxServer: true,
|
|
22
25
|
readonlyDefault: false,
|
|
23
26
|
// Etichetta neutra usata nel prefisso delle risposte ask incollate in TUI.
|
|
24
27
|
replyLabel: 'human',
|
|
@@ -65,6 +68,10 @@ function envOverrides() {
|
|
|
65
68
|
if (process.env.NEXUSCREW_PORT) e.port = Number(process.env.NEXUSCREW_PORT);
|
|
66
69
|
if (process.env.NEXUSCREW_TOKEN_FILE) e.tokenPath = process.env.NEXUSCREW_TOKEN_FILE;
|
|
67
70
|
if (process.env.NEXUSCREW_TMUX) e.tmuxBin = process.env.NEXUSCREW_TMUX;
|
|
71
|
+
if (process.env.NEXUSCREW_PROTECT_SHARED_TMUX_SERVER !== undefined) {
|
|
72
|
+
e.protectSharedTmuxServer = !['', '0', 'false', 'no', 'off']
|
|
73
|
+
.includes(String(process.env.NEXUSCREW_PROTECT_SHARED_TMUX_SERVER).toLowerCase());
|
|
74
|
+
}
|
|
68
75
|
if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
|
|
69
76
|
if (process.env.NEXUSCREW_REPLY_LABEL) e.replyLabel = process.env.NEXUSCREW_REPLY_LABEL;
|
|
70
77
|
if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -38,6 +38,7 @@ const { validEnvKey } = require('./env-key.js');
|
|
|
38
38
|
const { setCredential, removeCredential } = require('./credentials.js');
|
|
39
39
|
const { createLaunchBroker } = require('./launch-broker.js');
|
|
40
40
|
const { MINIMAL_ENV_KEYS } = require('../runtime/env.js');
|
|
41
|
+
const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
|
|
41
42
|
|
|
42
43
|
// Toolkit stateless + runtime estratti (behavior-preserving). I simboli di
|
|
43
44
|
// launch.js sono re-esportati in module.exports per i test che li importano
|
|
@@ -114,12 +115,23 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
114
115
|
const boot = loadDefinitions(defsPath);
|
|
115
116
|
if (!boot) return off;
|
|
116
117
|
|
|
118
|
+
// Adopt or create the shared server before exposing a mutable Fleet. Reapply
|
|
119
|
+
// before every lifecycle mutation so an accidental config reload cannot
|
|
120
|
+
// silently leave NexusCrew operations unguarded.
|
|
121
|
+
const ensureProtection = typeof cfg.ensureTmuxProtection === 'function'
|
|
122
|
+
? cfg.ensureTmuxProtection
|
|
123
|
+
: () => requireSharedTmuxProtection(tmuxBin, {
|
|
124
|
+
enabled: cfg.protectSharedTmuxServer !== false,
|
|
125
|
+
home,
|
|
126
|
+
});
|
|
127
|
+
await ensureProtection();
|
|
128
|
+
|
|
117
129
|
// Runtime estratto (lib/fleet/runtime.js): possiede cache + definizioni e
|
|
118
130
|
// espone status/up/down/restart/isCellSession + gli accessor allo store
|
|
119
131
|
// (reloadDefs/findCell/findEngine/refreshSessions/commitDefs) che il CRUD
|
|
120
132
|
// qui sotto riusa. status/up/down/restart sono INVARIATI.
|
|
121
133
|
const rt = createBuiltinRuntime({
|
|
122
|
-
cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot,
|
|
134
|
+
cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot, ensureProtection,
|
|
123
135
|
});
|
|
124
136
|
const {
|
|
125
137
|
status, up, down, restart, isCellSession,
|
package/lib/fleet/provider.js
CHANGED
|
@@ -9,7 +9,7 @@ const DISABLED_FLEET = Object.freeze({
|
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
function disabled(reason) {
|
|
12
|
-
return { mode: 'disabled', reason, fleet: DISABLED_FLEET };
|
|
12
|
+
return { mode: 'disabled', reason, fleet: { ...DISABLED_FLEET, reason } };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
async function selectProvider(cfg = {}) {
|
package/lib/fleet/routes.js
CHANGED
|
@@ -53,6 +53,7 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
53
53
|
provider: fleet.provider || 'disabled',
|
|
54
54
|
bootOwner: 'none', // §9b: provider non disponibile -> nessun boot owner
|
|
55
55
|
capabilities: capList(fleet),
|
|
56
|
+
...(fleet.reason ? { reason: fleet.reason } : {}),
|
|
56
57
|
});
|
|
57
58
|
}
|
|
58
59
|
const st = await fleet.status();
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -34,12 +34,13 @@ function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) ||
|
|
|
34
34
|
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
// createBuiltinRuntime(ctx)
|
|
37
|
-
// ctx: { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot
|
|
37
|
+
// ctx: { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot,
|
|
38
|
+
// ensureProtection }
|
|
38
39
|
// boot = definizioni iniziali (loadDefinitions, non null: il caller gia'
|
|
39
40
|
// e' tornato unavailable su garbage).
|
|
40
41
|
// ---------------------------------------------------------------------------
|
|
41
42
|
function createBuiltinRuntime(ctx) {
|
|
42
|
-
const { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot } = ctx;
|
|
43
|
+
const { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot, ensureProtection } = ctx;
|
|
43
44
|
let cache = { at: 0, defs: boot, sessions: new Set() };
|
|
44
45
|
|
|
45
46
|
function reloadDefs() {
|
|
@@ -139,6 +140,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
139
140
|
// stato persistente gestito da boot()). Lancia SENZA shell.
|
|
140
141
|
async function up(cellId /* , { engine, boot } = {} */) {
|
|
141
142
|
if (readonly()) throw httpError(403, 'READONLY: up bloccato');
|
|
143
|
+
if (typeof ensureProtection === 'function') await ensureProtection();
|
|
142
144
|
const defs = reloadDefs();
|
|
143
145
|
const cell = findCell(defs, cellId);
|
|
144
146
|
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
@@ -258,6 +260,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
258
260
|
|
|
259
261
|
async function down(cellId /* , opts */) {
|
|
260
262
|
if (readonly()) throw httpError(403, 'READONLY: down bloccato');
|
|
263
|
+
if (typeof ensureProtection === 'function') await ensureProtection();
|
|
261
264
|
const defs = reloadDefs();
|
|
262
265
|
const cell = findCell(defs, cellId);
|
|
263
266
|
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
package/lib/nodes/store.js
CHANGED
|
@@ -520,6 +520,45 @@ function toSlug(input) {
|
|
|
520
520
|
return s || 'node';
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
+
// Handle tecnico locale proposto durante il pairing. L'identita' resta il
|
|
524
|
+
// nodeId; questo slug e' soltanto il segmento route leggibile visto dai peer.
|
|
525
|
+
// Il suffisso deriva dal nodeId stabile, quindi due Termux con hostname
|
|
526
|
+
// "localhost" ottengono handle diversi e ogni retry dello stesso device
|
|
527
|
+
// ripropone esattamente lo stesso valore (nessun random per tentativo).
|
|
528
|
+
function deriveNodeHandle(label, hostname, nodeId, existing = []) {
|
|
529
|
+
const id = String(nodeId || '').toLowerCase();
|
|
530
|
+
if (!NODE_ID_RE.test(id)) throw new Error('nodeId non valido per derivare il route handle');
|
|
531
|
+
const used = new Set(Array.isArray(existing) ? existing.filter((x) => typeof x === 'string') : []);
|
|
532
|
+
const readable = String(label || hostname || 'NexusCrew').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
533
|
+
let base = toSlug(readable);
|
|
534
|
+
if (base === 'localhost' || base === 'node') base = 'nexuscrew';
|
|
535
|
+
|
|
536
|
+
// Se il chiamante ripropone un handle gia' suffissato (es. dopo un 409),
|
|
537
|
+
// evita di produrre asus-5bd6-5bd6 quando rigeneriamo un suffisso piu lungo.
|
|
538
|
+
for (const length of [32, 24, 16, 12, 8, 6, 4]) {
|
|
539
|
+
const suffix = `-${id.slice(0, length)}`;
|
|
540
|
+
if (base.endsWith(suffix) && base.length > suffix.length) {
|
|
541
|
+
base = base.slice(0, -suffix.length).replace(/-+$/g, '') || 'nexuscrew';
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const candidate = (suffix) => {
|
|
547
|
+
const room = Math.max(1, 32 - suffix.length - 1);
|
|
548
|
+
const prefix = base.slice(0, room).replace(/-+$/g, '') || 'n';
|
|
549
|
+
return `${prefix}-${suffix}`;
|
|
550
|
+
};
|
|
551
|
+
for (const length of [4, 6, 8, 12, 16, 24]) {
|
|
552
|
+
const value = candidate(id.slice(0, length));
|
|
553
|
+
if (!used.has(value)) return value;
|
|
554
|
+
}
|
|
555
|
+
for (let n = 2; n < 100; n += 1) {
|
|
556
|
+
const value = candidate(`${id.slice(0, 8)}-${n}`);
|
|
557
|
+
if (!used.has(value)) return value;
|
|
558
|
+
}
|
|
559
|
+
throw new Error('nessun route handle univoco disponibile per nodeId');
|
|
560
|
+
}
|
|
561
|
+
|
|
523
562
|
// suggestNodeName: slug univoco dato un input libero e l'elenco dei name gia'
|
|
524
563
|
// usati. Disambigua con suffisso -2/-3/... Rende la creazione di un nodo non
|
|
525
564
|
// fallibile per collisione di slug (l'utente scrive "Home Relay" e ottiene
|
|
@@ -547,7 +586,7 @@ module.exports = {
|
|
|
547
586
|
// migrazione
|
|
548
587
|
migrateLegacyNodes,
|
|
549
588
|
// label / slug
|
|
550
|
-
nodeLabel, validLabel, sanitizeLabel, toSlug, suggestNodeName, LABEL_MAX,
|
|
589
|
+
nodeLabel, validLabel, sanitizeLabel, toSlug, deriveNodeHandle, suggestNodeName, LABEL_MAX,
|
|
551
590
|
// costanti
|
|
552
591
|
SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
553
592
|
};
|
package/lib/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const { listSessions, attachedClients, setSessionVisibility } = require('./tmux/
|
|
|
12
12
|
const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
|
|
13
13
|
const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
|
|
14
14
|
const { createPreviewSampler } = require('./tmux/preview.js');
|
|
15
|
+
const { requireSharedTmuxProtection } = require('./tmux/shared-server.js');
|
|
15
16
|
const { openAttach } = require('./pty/attach.js');
|
|
16
17
|
const { bindWs } = require('./ws/bridge.js');
|
|
17
18
|
const { loadOrCreateToken, verify } = require('./auth/token.js');
|
|
@@ -109,10 +110,14 @@ function createServer(opts = {}) {
|
|
|
109
110
|
...(cfg.updateSeams || {}),
|
|
110
111
|
});
|
|
111
112
|
const attachedWs = new Map(); // ws -> session (per il push dei frame files)
|
|
113
|
+
const ensureTmuxProtection = () => requireSharedTmuxProtection(cfg.tmuxBin, {
|
|
114
|
+
enabled: cfg.protectSharedTmuxServer !== false,
|
|
115
|
+
home: cfg.home || os.homedir(),
|
|
116
|
+
});
|
|
112
117
|
// selectProvider sceglie UNA volta (startup) builtin|disabled e ritorna
|
|
113
118
|
// {mode,reason,fleet}; routes consumano il .fleet,
|
|
114
119
|
// quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
|
|
115
|
-
const fleetP = selectProvider(cfg).then((p) => p.fleet);
|
|
120
|
+
const fleetP = selectProvider({ ...cfg, ensureTmuxProtection }).then((p) => p.fleet);
|
|
116
121
|
|
|
117
122
|
// Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
|
|
118
123
|
// -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
|
|
@@ -211,7 +216,7 @@ function createServer(opts = {}) {
|
|
|
211
216
|
try {
|
|
212
217
|
const { name, cwd, preset } = req.body || {};
|
|
213
218
|
await createSession(cfg.tmuxBin, { name, cwd, preset },
|
|
214
|
-
{ home: os.homedir(), presets: cfg.sessionPresets });
|
|
219
|
+
{ home: os.homedir(), presets: cfg.sessionPresets, ensureProtection: ensureTmuxProtection });
|
|
215
220
|
res.status(201).json({ created: true, name });
|
|
216
221
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
217
222
|
});
|
|
@@ -223,7 +228,7 @@ function createServer(opts = {}) {
|
|
|
223
228
|
if (isProtectedSession(name, fleet.isCellSession)) {
|
|
224
229
|
return res.status(409).json({ error: 'sessione di cella: usa fleet down' });
|
|
225
230
|
}
|
|
226
|
-
const killed = await killSession(cfg.tmuxBin, name);
|
|
231
|
+
const killed = await killSession(cfg.tmuxBin, name, { ensureProtection: ensureTmuxProtection });
|
|
227
232
|
if (!killed) return res.status(404).json({ error: 'sessione inesistente' });
|
|
228
233
|
res.json({ killed: true });
|
|
229
234
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
@@ -242,6 +247,7 @@ function createServer(opts = {}) {
|
|
|
242
247
|
api.get('/config', (_req, res) => res.json({
|
|
243
248
|
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
244
249
|
bind: cfg.bind, port: cfg.port,
|
|
250
|
+
protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
|
|
245
251
|
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
246
252
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
247
253
|
}));
|
|
@@ -43,6 +43,7 @@ function createPairHandler(deps) {
|
|
|
43
43
|
send, validName, defaultDeviceName,
|
|
44
44
|
nodesPath, configPath, home, seams, runtimePort,
|
|
45
45
|
} = deps;
|
|
46
|
+
const localHostname = typeof deps.localHostname === 'function' ? deps.localHostname : os.hostname;
|
|
46
47
|
|
|
47
48
|
return async (req, res) => {
|
|
48
49
|
const b = req.body || {};
|
|
@@ -60,6 +61,9 @@ function createPairHandler(deps) {
|
|
|
60
61
|
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return fail(400, 'validation', 'bad-identity-file', 'identityFile non valido (path assoluto)', { retryable: true });
|
|
61
62
|
if (b.label !== undefined && !nodesStore.validLabel(b.label)) return fail(400, 'validation', 'bad-label', 'label non valida (max 64 char, niente a capo)', { retryable: true });
|
|
62
63
|
if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return fail(400, 'validation', 'bad-label', 'localLabel non valida (max 64 char, niente a capo)', { retryable: true });
|
|
64
|
+
if (b.localName !== undefined && (!validName(b.localName) || b.localName === 'localhost')) {
|
|
65
|
+
return fail(400, 'validation', 'bad-local-name', 'localName non valido (slug univoco a-z, 0-9, -, max 32; localhost non ammesso)', { retryable: true });
|
|
66
|
+
}
|
|
63
67
|
// label umana del peer come lo vedro' io (display); se assente usa lo slug.
|
|
64
68
|
const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
|
|
65
69
|
// etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
|
|
@@ -118,6 +122,12 @@ function createPairHandler(deps) {
|
|
|
118
122
|
try {
|
|
119
123
|
// --- conflict: il nome non deve gia' esistere --------------------------
|
|
120
124
|
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
125
|
+
// Backcompat per client 0.8.23 iniziali: se localName manca, derivarlo da
|
|
126
|
+
// label/hostname + suffisso nodeId stabile. Mai hostname puro o random per
|
|
127
|
+
// retry; il nodeId resta l'identita', questo e' soltanto il route handle.
|
|
128
|
+
const localName = b.localName || nodesStore.deriveNodeHandle(
|
|
129
|
+
localLabel, localHostname(), st.nodeId,
|
|
130
|
+
);
|
|
121
131
|
if (st.nodes.some((n) => n.name === b.name)) {
|
|
122
132
|
return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
|
|
123
133
|
retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
|
|
@@ -206,7 +216,7 @@ function createPairHandler(deps) {
|
|
|
206
216
|
body: JSON.stringify({
|
|
207
217
|
invite: pair.invite,
|
|
208
218
|
instanceId: st.nodeId,
|
|
209
|
-
name:
|
|
219
|
+
name: localName,
|
|
210
220
|
label: localLabel,
|
|
211
221
|
port: runtimePort(),
|
|
212
222
|
acceptToken,
|
|
@@ -230,6 +240,17 @@ function createPairHandler(deps) {
|
|
|
230
240
|
hint: 'rigenera un nuovo link sul dispositivo che invita',
|
|
231
241
|
});
|
|
232
242
|
}
|
|
243
|
+
if (jr.status === 409 && joined.code === 'peer-name-conflict') {
|
|
244
|
+
const suggestedName = validName(joined.suggestedName) && joined.suggestedName !== 'localhost'
|
|
245
|
+
? joined.suggestedName
|
|
246
|
+
: nodesStore.deriveNodeHandle(localLabel, localHostname(), st.nodeId, [localName]);
|
|
247
|
+
return failRolledBack(409, 'conflict', 'peer-name-conflict',
|
|
248
|
+
joined.error || `nome peer gia' in uso: ${localName}`, {
|
|
249
|
+
retryable: true,
|
|
250
|
+
suggestedName,
|
|
251
|
+
hint: `usa l'handle locale proposto "${suggestedName}" e riprova: l'invito non e' stato consumato`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
233
254
|
if (!jr.ok) {
|
|
234
255
|
return failRolledBack(502, 'join', 'join-rejected', joined.error || `join rifiutato dal peer (HTTP ${jr.status})`, {
|
|
235
256
|
hint: 'rigenera un nuovo link e riprova',
|
|
@@ -75,11 +75,20 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
75
75
|
if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)
|
|
76
76
|
|| pending.some((row) => row.instanceId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
|
|
77
77
|
const reservedNames = new Set(pending.map((row) => row.name).filter(validPeerName));
|
|
78
|
-
|
|
78
|
+
// `localhost` e' sintatticamente uno slug valido, ma su Termux non e'
|
|
79
|
+
// un handle di route: piu device espongono lo stesso hostname. Trattalo
|
|
80
|
+
// come conflitto risolvibile anche quando e' il primo, senza consumare
|
|
81
|
+
// l'invite; il client puo' riprovare col suffisso stabile proposto.
|
|
82
|
+
if (b.name === 'localhost' || nodesStore.getNode(st, b.name) || reservedNames.has(b.name)) {
|
|
83
|
+
const usedNames = [...st.nodes.map((node) => node.name), ...reservedNames];
|
|
84
|
+
const suggestedName = nodesStore.deriveNodeHandle(
|
|
85
|
+
b.label || b.name, b.name, b.instanceId, usedNames,
|
|
86
|
+
);
|
|
79
87
|
return res.status(409).json({
|
|
80
88
|
error: `nome peer gia' in uso: ${b.name}`,
|
|
81
89
|
code: 'peer-name-conflict',
|
|
82
|
-
|
|
90
|
+
suggestedName,
|
|
91
|
+
hint: `usa l'handle proposto "${suggestedName}" e riprova con lo stesso invito`,
|
|
83
92
|
});
|
|
84
93
|
}
|
|
85
94
|
const name = b.name;
|