@mmmbuto/nexuscrew 0.8.22 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,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-ClJP2j6k.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-C0QL3gEp.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.22"}
1
+ {"version":"0.8.23"}
@@ -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');
@@ -110,6 +110,10 @@ function parseFlags(argv, valueFlags) {
110
110
 
111
111
  function serve(opts = {}) {
112
112
  const serverStart = opts.serverStart || require('../server.js').start;
113
+ // Service manager e Termux:Boot entrano direttamente da `serve`, senza
114
+ // passare per smartUp. Ripara solo fleet.json MANCANTE; un file invalido
115
+ // resta intatto e il provider continua a fallire chiuso.
116
+ (opts.ensureFleetDefaultsImpl || ensureFleetDefaults)(opts);
113
117
  if (opts.pidfile) {
114
118
  const pidPath = pidf.defaultPidfilePath(opts.home);
115
119
  // already-running check
@@ -450,6 +454,11 @@ async function smartUp(opts = {}) {
450
454
  initialized = true;
451
455
  }
452
456
 
457
+ // Config+token da soli non garantiscono un'installazione completa. Questo
458
+ // era il buco delle installazioni/migrazioni Termux che lasciava il provider
459
+ // disabilitato e l'editor Fleet irraggiungibile.
460
+ const fleetBootstrap = (opts.ensureFleetDefaultsImpl || ensureFleetDefaults)(opts);
461
+
453
462
  // 0.8.0 services embedded NEXUSCREW_PORT in their environment, overriding
454
463
  // config.json forever. Regenerate once so config.json becomes authoritative.
455
464
  const home = opts.home || require('node:os').homedir();
@@ -463,6 +472,19 @@ async function smartUp(opts = {}) {
463
472
  let running = await probe(port, token, opts);
464
473
  let portableAttempted = false;
465
474
  let runtime = resolveRuntimeOwner({ ...opts, platform });
475
+
476
+ // selectProvider() viene risolto una volta allo startup. Se abbiamo creato
477
+ // fleet.json mentre un vecchio processo era gia' vivo, serve un restart
478
+ // verificato per fargli acquisire il provider builtin.
479
+ if (running && fleetBootstrap.created) {
480
+ const restarted = restartImpl({ ...opts, platform, log: quiet });
481
+ if (!restarted || restarted.restarted !== true) {
482
+ throw new Error(`fleet bootstrap completato ma restart fallito: ${(restarted && restarted.reason) || 'esito non verificato'}`);
483
+ }
484
+ running = await waitForNexusCrew(port, token, opts);
485
+ if (!running) throw new Error(`server non pronto dopo il bootstrap Fleet su 127.0.0.1:${port}`);
486
+ runtime = resolveRuntimeOwner({ ...opts, platform });
487
+ }
466
488
  if (!running && runtime.owner !== 'stopped') running = await waitForNexusCrew(port, token, opts);
467
489
 
468
490
  if (!running) {
@@ -911,7 +933,7 @@ function dispatch(argv, opts = {}) {
911
933
  // Internal runtime commands used by service managers and MCP clients. They
912
934
  // are intentionally omitted from HELP and are not configuration surfaces.
913
935
  if (cmd === 'serve') {
914
- serve({ pidfile: flags.pidfile, serverStart: opts.serverStart });
936
+ serve({ ...opts, pidfile: flags.pidfile, serverStart: opts.serverStart });
915
937
  return { code: 0, keepAlive: true }; // server.listen tiene il processo vivo; non exit
916
938
  }
917
939
  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
- try {
115
- const value = String(execImpl('systemctl', [
116
- '--user', 'show', 'nexuscrew.service', '--property=KillMode', '--value',
117
- ], { encoding: 'utf8' }) || '').trim();
118
- const ok = value === 'process';
119
- return {
120
- name: 'tmux survival on service restart', ok,
121
- detail: ok ? 'KillMode=process' : `KillMode=${value || 'sconosciuto'} (restart NexusCrew puo terminare tmux)`,
122
- };
123
- } catch (error) {
124
- return { name: 'tmux survival on service restart', ok: false, detail: `KillMode non verificabile: ${error.message || error}` };
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
  };
@@ -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 && !fs.existsSync(fleetDefsPath)) {
145
+ if (!dryRun) {
122
146
  try {
123
- writeFleet(fleetDefsPath, defaultDefinitions());
124
- actions.push(`created fleet defaults ${fleetDefsPath} (claude.native, codex.native, codex-vl.native, pi.native)`);
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;
@@ -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,
@@ -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 = {}) {
@@ -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();
@@ -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/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
  }));
@@ -51,7 +51,8 @@ function buildCreateArgs(name, realCwd, preset, extraPresets) {
51
51
 
52
52
  function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
53
53
 
54
- function createSession(tmuxBin, { name, cwd, preset }, { home, presets } = {}) {
54
+ async function createSession(tmuxBin, { name, cwd, preset }, { home, presets, ensureProtection } = {}) {
55
+ if (typeof ensureProtection === 'function') await ensureProtection();
55
56
  return new Promise((resolve, reject) => {
56
57
  if (!validSessionName(name)) return reject(httpError(400, 'nome sessione non valido'));
57
58
  // Il namespace cloud-* e' delle celle fleet (audit finale #1): una generica
@@ -71,7 +72,8 @@ function createSession(tmuxBin, { name, cwd, preset }, { home, presets } = {}) {
71
72
  });
72
73
  }
73
74
 
74
- function killSession(tmuxBin, name) {
75
+ async function killSession(tmuxBin, name, { ensureProtection } = {}) {
76
+ if (typeof ensureProtection === 'function') await ensureProtection();
75
77
  return new Promise((resolve, reject) => {
76
78
  execFile(tmuxBin, ['kill-session', '-t', `=${name}`], (err, _o, stderr) => {
77
79
  if (err) {
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+ const { execFile } = require('node:child_process');
5
+ const { minimalRuntimeEnv } = require('../runtime/env.js');
6
+
7
+ // Operational guard for NexusCrew's shared tmux server. tmux command aliases
8
+ // are not a security boundary against another process running as the same UID,
9
+ // but they neutralize accidental `tmux kill-server` calls while exit-empty=off
10
+ // keeps the server alive when the last managed session is intentionally stopped.
11
+ const KILL_SERVER_ALIAS_INDEX = 'command-alias[100]';
12
+ const KILL_SERVER_ALIAS = 'kill-server=display-message "DENIED: kill-server disabled by NexusCrew"';
13
+
14
+ function protectionArgs() {
15
+ return [
16
+ 'start-server',
17
+ ';', 'set-option', '-s', 'exit-empty', 'off',
18
+ ';', 'set-option', '-s', KILL_SERVER_ALIAS_INDEX, KILL_SERVER_ALIAS,
19
+ ];
20
+ }
21
+
22
+ function protectSharedTmuxServer(tmuxBin = 'tmux', opts = {}) {
23
+ if (opts.enabled === false) return Promise.resolve({ ok: true, protected: false, reason: 'disabled' });
24
+ const execFileImpl = opts.execFileImpl || execFile;
25
+ const env = opts.env || minimalRuntimeEnv(process.env, { home: opts.home || os.homedir() });
26
+ return new Promise((resolve) => {
27
+ try {
28
+ execFileImpl(tmuxBin, protectionArgs(), { env, timeout: opts.timeoutMs || 5000 }, (err, _stdout, stderr) => {
29
+ if (err) {
30
+ return resolve({
31
+ ok: false,
32
+ protected: false,
33
+ reason: String(stderr || err.message || 'tmux protection failed').trim(),
34
+ });
35
+ }
36
+ resolve({ ok: true, protected: true, reason: 'kill-server guarded; exit-empty off' });
37
+ });
38
+ } catch (error) {
39
+ resolve({ ok: false, protected: false, reason: String(error && error.message || error) });
40
+ }
41
+ });
42
+ }
43
+
44
+ async function requireSharedTmuxProtection(tmuxBin, opts = {}) {
45
+ const result = await protectSharedTmuxServer(tmuxBin, opts);
46
+ if (!result.ok) {
47
+ const error = new Error(`tmux shared-server protection failed: ${result.reason || 'unknown error'}`);
48
+ error.status = 500;
49
+ throw error;
50
+ }
51
+ return result;
52
+ }
53
+
54
+ module.exports = {
55
+ KILL_SERVER_ALIAS_INDEX,
56
+ KILL_SERVER_ALIAS,
57
+ protectionArgs,
58
+ protectSharedTmuxServer,
59
+ requireSharedTmuxProtection,
60
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.22",
3
+ "version": "0.8.23",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {