@mmmbuto/nexuscrew 0.8.21 → 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.
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',
@@ -28,7 +31,6 @@ function baseDefaults() {
28
31
  voiceToken: '',
29
32
  voiceTokenFile: null,
30
33
  fleetEnabled: true,
31
- fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
32
34
  providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
33
35
  // Existing user-owned shell exports. NexusCrew parses simple assignments
34
36
  // as data; it never executes/sources this file and never copies values.
@@ -66,6 +68,10 @@ function envOverrides() {
66
68
  if (process.env.NEXUSCREW_PORT) e.port = Number(process.env.NEXUSCREW_PORT);
67
69
  if (process.env.NEXUSCREW_TOKEN_FILE) e.tokenPath = process.env.NEXUSCREW_TOKEN_FILE;
68
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
+ }
69
75
  if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
70
76
  if (process.env.NEXUSCREW_REPLY_LABEL) e.replyLabel = process.env.NEXUSCREW_REPLY_LABEL;
71
77
  if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
@@ -74,7 +80,6 @@ function envOverrides() {
74
80
  if (process.env.NEXUSCREW_VOICE_TOKEN) e.voiceToken = process.env.NEXUSCREW_VOICE_TOKEN;
75
81
  if (process.env.NEXUSCREW_VOICE_TOKEN_FILE) e.voiceTokenFile = process.env.NEXUSCREW_VOICE_TOKEN_FILE;
76
82
  if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
77
- if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
78
83
  if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
79
84
  if (process.env.NEXUSCREW_PROVIDER_SHELL) e.providerShellPath = process.env.NEXUSCREW_PROVIDER_SHELL;
80
85
  if (process.env.NEXUSCREW_PROVIDER_KEYS) e.providerKeysPath = process.env.NEXUSCREW_PROVIDER_KEYS;
@@ -1,8 +1,7 @@
1
1
  'use strict';
2
- // B4.2 — Fleet built-in. FACADE: implementa lo STESSO contratto di
3
- // lib/fleet/index.js (createFleet: available / status / up / down / engine /
4
- // boot / isCellSession) leggendo le definizioni da ~/.nexuscrew/fleet.json
5
- // tramite lib/fleet/definitions.js, e lo ESTENDE con define*/edit*/remove* +
2
+ // Fleet built-in. FACADE: espone available/status/up/down/engine/boot e
3
+ // isCellSession leggendo le definizioni da ~/.nexuscrew/fleet.json tramite
4
+ // lib/fleet/definitions.js, insieme a define*/edit*/remove* +
6
5
  // schema + capabilities (design §4b/§9c).
7
6
  //
8
7
  // La responsabilita' runtime/launch (status/up/down/restart + launch/readiness
@@ -39,6 +38,7 @@ const { validEnvKey } = require('./env-key.js');
39
38
  const { setCredential, removeCredential } = require('./credentials.js');
40
39
  const { createLaunchBroker } = require('./launch-broker.js');
41
40
  const { MINIMAL_ENV_KEYS } = require('../runtime/env.js');
41
+ const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
42
42
 
43
43
  // Toolkit stateless + runtime estratti (behavior-preserving). I simboli di
44
44
  // launch.js sono re-esportati in module.exports per i test che li importano
@@ -94,7 +94,7 @@ function applyCellTransition(target, engineId, { model, hasModel, policy, hasPol
94
94
  }
95
95
 
96
96
  // ---------------------------------------------------------------------------
97
- // createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
97
+ // createBuiltinFleet(cfg) — unica implementazione Fleet di NexusCrew.
98
98
  // cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
99
99
  // sendKeysReadyMs?, fleetProviderReason?, launchBroker?, launchReadyMs? }
100
100
  // ---------------------------------------------------------------------------
@@ -115,12 +115,23 @@ async function createBuiltinFleet(cfg = {}) {
115
115
  const boot = loadDefinitions(defsPath);
116
116
  if (!boot) return off;
117
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
+
118
129
  // Runtime estratto (lib/fleet/runtime.js): possiede cache + definizioni e
119
130
  // espone status/up/down/restart/isCellSession + gli accessor allo store
120
131
  // (reloadDefs/findCell/findEngine/refreshSessions/commitDefs) che il CRUD
121
132
  // qui sotto riusa. status/up/down/restart sono INVARIATI.
122
133
  const rt = createBuiltinRuntime({
123
- cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot,
134
+ cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot, ensureProtection,
124
135
  });
125
136
  const {
126
137
  status, up, down, restart, isCellSession,
@@ -1,13 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- // Tiny launch helper. It receives only a local socket path and a random,
5
- // single-use nonce in argv. Provider values arrive over the private Unix
6
- // socket, stay in memory and are passed directly to the child process.
4
+ // Private per-cell launcher and supervisor. tmux sees only this helper plus a
5
+ // single-use broker ticket; the real command, provider environment and restart
6
+ // policy arrive in memory over the local 0600 Unix socket.
7
7
  const net = require('node:net');
8
8
  const { spawn } = require('node:child_process');
9
9
  const { MAX_PAYLOAD } = require('./launch-broker.js');
10
10
 
11
+ const DEFAULT_SUPERVISE = Object.freeze({
12
+ enabled: true,
13
+ initialReadyMs: 500,
14
+ restartDelayMs: 1000,
15
+ maxRestartDelayMs: 60000,
16
+ resetAfterMs: 30000,
17
+ rapidWindowMs: 60000,
18
+ maxRapidRestarts: 8,
19
+ });
20
+
11
21
  function parseArgs(argv) {
12
22
  const out = {};
13
23
  for (let i = 0; i < argv.length; i += 2) {
@@ -20,12 +30,57 @@ function parseArgs(argv) {
20
30
  return out;
21
31
  }
22
32
 
33
+ function validInteger(value, min, max) {
34
+ return Number.isInteger(value) && value >= min && value <= max;
35
+ }
36
+
37
+ function validSupervise(value) {
38
+ if (value === undefined) return true;
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
40
+ const keys = new Set([
41
+ 'enabled', 'initialReadyMs', 'restartDelayMs', 'maxRestartDelayMs',
42
+ 'resetAfterMs', 'rapidWindowMs', 'maxRapidRestarts',
43
+ ]);
44
+ if (Object.keys(value).some((key) => !keys.has(key))) return false;
45
+ if (value.enabled !== undefined && typeof value.enabled !== 'boolean') return false;
46
+ const checks = [
47
+ ['initialReadyMs', 50, 30000], ['restartDelayMs', 50, 60000],
48
+ ['maxRestartDelayMs', 100, 300000], ['resetAfterMs', 1000, 3600000],
49
+ ['rapidWindowMs', 1000, 3600000], ['maxRapidRestarts', 1, 100],
50
+ ];
51
+ return checks.every(([key, min, max]) => value[key] === undefined || validInteger(value[key], min, max));
52
+ }
53
+
54
+ function promptCharsOk(prompt) {
55
+ if (typeof prompt !== 'string' || prompt.length > 131072) return false;
56
+ for (let i = 0; i < prompt.length; i += 1) {
57
+ const code = prompt.charCodeAt(i);
58
+ if (code === 9 || code === 10 || code === 13) continue;
59
+ if (code < 32 || code === 127) return false;
60
+ }
61
+ return true;
62
+ }
63
+
64
+ function validRestartPrompt(value) {
65
+ if (value === undefined) return true;
66
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
67
+ if (Object.keys(value).some((key) => !['tmuxBin', 'tmuxSession', 'prompt', 'readyMs'].includes(key))) return false;
68
+ return typeof value.tmuxBin === 'string' && value.tmuxBin.length > 0 && value.tmuxBin.length <= 4096
69
+ && !/[\0\r\n]/.test(value.tmuxBin)
70
+ && typeof value.tmuxSession === 'string' && /^[\w.@%:+-]{1,128}$/.test(value.tmuxSession)
71
+ && promptCharsOk(value.prompt)
72
+ && (value.readyMs === undefined || validInteger(value.readyMs, 0, 30000));
73
+ }
74
+
23
75
  function validPayload(payload) {
24
76
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
77
+ if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt'].includes(key))) return false;
25
78
  if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
26
79
  if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
27
80
  return payload.args.every((v) => typeof v === 'string')
28
- && Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string');
81
+ && Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string')
82
+ && validSupervise(payload.supervise)
83
+ && validRestartPrompt(payload.restartPrompt);
29
84
  }
30
85
 
31
86
  function receivePayload(socketPath, nonce, timeoutMs = 5000) {
@@ -57,19 +112,115 @@ function receivePayload(socketPath, nonce, timeoutMs = 5000) {
57
112
  });
58
113
  }
59
114
 
115
+ function normalizeSupervise(value = {}) {
116
+ return { ...DEFAULT_SUPERVISE, ...(value || {}) };
117
+ }
118
+
119
+ function waitChild(child) {
120
+ return new Promise((resolve) => {
121
+ let settled = false;
122
+ const finish = (code, signal, error = null) => {
123
+ if (settled) return; settled = true; resolve({ code: code == null ? 1 : code, signal, error });
124
+ };
125
+ child.once('error', (error) => finish(1, null, error));
126
+ child.once('exit', (code, signal) => finish(code, signal));
127
+ });
128
+ }
129
+
130
+ function scheduleRestartPrompt(config, childState, seams = {}) {
131
+ if (!config) return { cancel() {} };
132
+ let timer = null; let cancelled = false;
133
+ const setTimer = seams.setTimeout || setTimeout;
134
+ const clearTimer = seams.clearTimeout || clearTimeout;
135
+ timer = setTimer(async () => {
136
+ timer = null;
137
+ if (cancelled || childState.exited) return;
138
+ try {
139
+ const inject = seams.injectPrompt || require('./launch.js').injectPrompt;
140
+ await inject(config.tmuxBin, config.tmuxSession, config.prompt, {
141
+ target: process.env.TMUX_PANE || `=${config.tmuxSession}`,
142
+ readyMs: 0,
143
+ });
144
+ } catch (_) { /* keepalive must not die because prompt reinjection failed */ }
145
+ }, config.readyMs ?? 400);
146
+ timer.unref?.();
147
+ return { cancel() { cancelled = true; if (timer) clearTimer(timer); timer = null; } };
148
+ }
149
+
60
150
  async function main(argv = process.argv.slice(2), seams = {}) {
61
151
  const parsed = parseArgs(argv);
62
152
  if (!parsed) throw new Error('usage: cell-exec --socket <path> --nonce <hex>');
63
153
  const payload = await (seams.receivePayload || receivePayload)(parsed.socketPath, parsed.nonce);
154
+ const supervise = normalizeSupervise(payload.supervise);
64
155
  const spawnImpl = seams.spawn || spawn;
65
- const child = spawnImpl(payload.command, payload.args, { env: payload.env, stdio: 'inherit' });
156
+ const now = seams.now || Date.now;
157
+ const sleep = seams.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
158
+ const proc = seams.process || process;
159
+ const writeError = seams.writeError || ((message) => process.stderr.write(message));
160
+ const childEnv = { ...payload.env };
161
+ // tmux injects these only after the broker ticket was created. Preserve them
162
+ // for the actual TUI and bind NexusCrew MCP callbacks to the owning session.
163
+ if (process.env.TMUX) childEnv.TMUX = process.env.TMUX;
164
+ if (process.env.TMUX_PANE) childEnv.TMUX_PANE = process.env.TMUX_PANE;
165
+
166
+ let current = null; let stopping = false;
167
+ const handlers = new Map();
66
168
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
67
- process.once(signal, () => { try { child.kill(signal); } catch (_) {} });
169
+ const handler = () => {
170
+ stopping = true;
171
+ try { if (current) current.kill(signal); } catch (_) {}
172
+ };
173
+ handlers.set(signal, handler);
174
+ proc.once?.(signal, handler);
68
175
  }
69
- return new Promise((resolve, reject) => {
70
- child.once('error', reject);
71
- child.once('exit', (code, signal) => resolve(signal ? 128 : (code == null ? 1 : code)));
72
- });
176
+ const cleanup = () => {
177
+ for (const [signal, handler] of handlers) proc.off?.(signal, handler);
178
+ };
179
+
180
+ let generation = 0;
181
+ let delayMs = supervise.restartDelayMs;
182
+ let rapid = [];
183
+ try {
184
+ for (;;) {
185
+ if (stopping) return 0;
186
+ const startedAt = now();
187
+ current = spawnImpl(payload.command, payload.args, { env: childEnv, stdio: 'inherit' });
188
+ const childState = { exited: false };
189
+ const prompt = generation > 0 ? scheduleRestartPrompt(payload.restartPrompt, childState, seams) : null;
190
+ const result = await waitChild(current);
191
+ childState.exited = true;
192
+ prompt?.cancel();
193
+ current = null;
194
+ const runtimeMs = Math.max(0, now() - startedAt);
195
+ if (stopping) return 0;
196
+ if (!supervise.enabled) return result.signal ? 128 : result.code;
197
+
198
+ // Preserve the launch readiness contract: a first child that dies before
199
+ // the gate is a failed start, not a successfully supervised cell.
200
+ if (generation === 0 && runtimeMs < supervise.initialReadyMs) {
201
+ return result.signal ? 128 : (result.code || 1);
202
+ }
203
+
204
+ const stamp = now();
205
+ if (runtimeMs >= supervise.resetAfterMs) {
206
+ rapid = [];
207
+ delayMs = supervise.restartDelayMs;
208
+ } else {
209
+ rapid = rapid.filter((value) => stamp - value <= supervise.rapidWindowMs);
210
+ rapid.push(stamp);
211
+ if (rapid.length > supervise.maxRapidRestarts) {
212
+ writeError('nexuscrew cell supervisor stopped after repeated early exits\n');
213
+ return result.signal ? 128 : (result.code || 1);
214
+ }
215
+ }
216
+ await sleep(delayMs);
217
+ // A down/kill-session can reach the supervisor while it is waiting in
218
+ // backoff. Never start another client after that stop signal.
219
+ if (stopping) return 0;
220
+ delayMs = Math.min(supervise.maxRestartDelayMs, Math.max(supervise.restartDelayMs, delayMs * 2));
221
+ generation += 1;
222
+ }
223
+ } finally { cleanup(); }
73
224
  }
74
225
 
75
226
  if (require.main === module) {
@@ -78,4 +229,7 @@ if (require.main === module) {
78
229
  });
79
230
  }
80
231
 
81
- module.exports = { parseArgs, validPayload, receivePayload, main };
232
+ module.exports = {
233
+ DEFAULT_SUPERVISE, parseArgs, validSupervise, validRestartPrompt, validPayload,
234
+ receivePayload, normalizeSupervise, waitChild, scheduleRestartPrompt, main,
235
+ };
@@ -306,7 +306,7 @@ function parseCell(c, engineIds) {
306
306
  // ---------------------------------------------------------------------------
307
307
  // validateCommandTrust(command) -> {ok, reason}
308
308
  // Path assoluto, regular file, owner-executable, NON symlink (lstat), NON
309
- // world-writable. Riutilizza il pattern di binTrusted in lib/fleet/index.js.
309
+ // world-writable. Questa è la trust boundary dei comandi engine built-in.
310
310
  // ---------------------------------------------------------------------------
311
311
  function validateCommandTrust(command) {
312
312
  if (typeof command !== 'string' || !command) return { ok: false, reason: 'command vuoto' };
@@ -1,103 +1,23 @@
1
1
  'use strict';
2
- // B4.2 Provider selection. Sceglie UNA volta, a startup, quale fleet governa il
3
- // runtime (design §4b/§9b/§9g). Ritorna { mode, reason, fleet }.
4
- //
5
- // mode ∈ 'external' | 'builtin' | 'disabled'
6
- //
7
- // Regole (design §9g):
8
- // - forced (cfg.fleetProvider | NEXUSCREW_FLEET_PROVIDER): onorato e FAIL-CLOSED
9
- // se indisponibile. NIENTE auto-fallback silenzioso al built-in.
10
- // - auto: 'external' vince solo se fidato (binTrusted, riusato da index.js) E
11
- // risponde al contratto (createFleet → available). Altrimenti 'builtin' se
12
- // abilitato e fleet.json valido. Altrimenti 'disabled'.
13
- //
14
- // Il drift a runtime NON si risolve qui: e' una scelta one-shot. Se l'external
15
- // diventa invalido DOPO lo startup, lo status risultante sara' degraded/unavailable
16
- // (mai fall-through silenzioso al built-in) — gestito dal layer di status.
17
- const { binTrusted, externalFleetCandidates, resolveExternalFleet } = require('./index.js');
2
+ // NexusCrew is the single Fleet authority. The legacy external `fleet` binary
3
+ // is intentionally not discovered or executed: definitions, credentials,
4
+ // lifecycle and boot ownership all live in the builtin provider.
18
5
  const { createBuiltinFleet } = require('./builtin.js');
19
6
 
20
7
  const DISABLED_FLEET = Object.freeze({
21
8
  available: false, provider: 'disabled', isCellSession: () => false, capabilities: () => [],
22
9
  });
23
10
 
24
- function failClosed(mode) {
25
- return {
26
- mode: 'disabled',
27
- reason: `fail-closed: provider forzato "${mode}" non disponibile (nessun auto-fallback, §9g)`,
28
- fleet: DISABLED_FLEET,
29
- };
11
+ function disabled(reason) {
12
+ return { mode: 'disabled', reason, fleet: { ...DISABLED_FLEET, reason } };
30
13
  }
31
14
 
32
- // Costruisce (una volta) i candidati e ne valuta la disponibilita'.
33
15
  async function selectProvider(cfg = {}) {
34
- if (cfg.fleetEnabled === false) {
35
- return { mode: 'disabled', reason: 'fleet disabilitato (fleetEnabled=false)', fleet: DISABLED_FLEET };
36
- }
37
-
38
- const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase() || null;
39
-
40
- // External: scopre il binario fleet legacy cross-platform (cfg.fleetBin,
41
- // $PREFIX/bin/fleet su Termux, ~/.local/bin/fleet) e accetta il primo fidato
42
- // (binTrusted) che risponde al contratto (status --json con schema valido).
43
- let extFleet = null;
44
- let extReason = null;
45
- const resolvedExternal = await resolveExternalFleet(cfg);
46
- if (resolvedExternal) {
47
- extFleet = resolvedExternal.fleet;
48
- extReason = resolvedExternal.reason;
49
- } else {
50
- // Diagnosi: perché nessun candidato external è valido (fidato + contratto).
51
- const cands = externalFleetCandidates(cfg);
52
- if (!cands.length) extReason = 'nessun fleetBin esterno configurato';
53
- else extReason = `nessun candidato external valido: ${cands.map((b) => (binTrusted(b) ? `${b} (non risponde al contratto)` : `${b} (non fidato)`)).join('; ')}`;
54
- }
55
-
56
- // Builtin: abilitato + fleet.json valido.
57
- let biFleet = null;
58
- let biReason = null;
59
- if (cfg.builtinEnabled !== false) {
60
- const f = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'fleet.json definitions' });
61
- if (f.available) { biFleet = f; biReason = 'fleet.json valido'; }
62
- else biReason = 'fleet.json mancante o invalido (fail-closed)';
63
- } else {
64
- biReason = 'builtin disabilitato (builtinEnabled=false)';
65
- }
66
-
67
- // --- Mode FORZATO: fail-closed se il richiesto non e' disponibile ---
68
- if (forced === 'external') {
69
- return extFleet
70
- ? { mode: 'external', reason: `forced external (${extReason})`, fleet: extFleet }
71
- : failClosed('external');
72
- }
73
- if (forced === 'builtin') {
74
- return biFleet
75
- ? { mode: 'builtin', reason: `forced builtin (${biReason})`, fleet: biFleet }
76
- : failClosed('builtin');
77
- }
78
- if (forced === 'disabled') {
79
- return { mode: 'disabled', reason: 'forced disabled', fleet: DISABLED_FLEET };
80
- }
81
- if (forced) {
82
- return failClosed(forced); // valore forzato non riconosciuto
83
- }
84
-
85
- // --- AUTO: external fidato+contratto vince, poi builtin, poi disabled ---
86
- if (extFleet) {
87
- return { mode: 'external', reason: `auto: external ${extReason}`, fleet: extFleet };
88
- }
89
- if (biFleet) {
90
- return {
91
- mode: 'builtin',
92
- reason: `auto: external scartato (${extReason}) → builtin (${biReason})`,
93
- fleet: biFleet,
94
- };
95
- }
96
- return {
97
- mode: 'disabled',
98
- reason: `auto: nessun provider disponibile — external: ${extReason}; builtin: ${biReason}`,
99
- fleet: DISABLED_FLEET,
100
- };
16
+ if (cfg.fleetEnabled === false) return disabled('fleet disabilitata (fleetEnabled=false)');
17
+ if (cfg.builtinEnabled === false) return disabled('fleet builtin disabilitata (builtinEnabled=false)');
18
+ const fleet = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'NexusCrew builtin fleet' });
19
+ if (!fleet.available) return disabled('fleet.json mancante o invalido (fail-closed)');
20
+ return { mode: 'builtin', reason: 'NexusCrew builtin fleet', fleet };
101
21
  }
102
22
 
103
23
  module.exports = { selectProvider, DISABLED_FLEET };
@@ -1,9 +1,8 @@
1
1
  'use strict';
2
2
  const express = require('express');
3
3
 
4
- // Capability minime del contratto storico per i fleet external/legacy che NON
5
- // dichiarano capabilities() (design §9c): status/up/down/engine/boot.
6
- // Il built-in B4 aggiunge define/edit/remove/schema.
4
+ // Fallback difensivo per un adapter builtin incompleto nei test. Il provider di
5
+ // prodotto dichiara sempre la propria capability list.
7
6
  const DEFAULT_CAPS = ['status', 'up', 'down', 'engine', 'boot'];
8
7
 
9
8
  function capList(fleet) {
@@ -33,8 +32,7 @@ function fleetRoutes(fleetP, cfg = {}) {
33
32
 
34
33
  const guard = (fn, opts = {}) => async (req, res) => {
35
34
  try {
36
- // READONLY e' un gate di prodotto, non solo del provider built-in: deve
37
- // bloccare anche il fleet esterno legacy prima che tocchi systemd/tmux reali.
35
+ // READONLY e' un gate di prodotto oltre che del provider built-in.
38
36
  if (opts.mutate && readonly()) return res.status(403).json({ error: 'READONLY: mutazione fleet bloccata' });
39
37
  const fleet = await fleetP;
40
38
  if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
@@ -45,8 +43,7 @@ function fleetRoutes(fleetP, cfg = {}) {
45
43
  };
46
44
 
47
45
  // /status espone anche `provider` e `capabilities` (design §9b/§9c), oltre ai
48
- // campi storici (available/cells/engines). Provider senza capabilities()
49
- // (external legacy) → DEFAULT_CAPS; external senza campo provider → 'external'.
46
+ // campi storici (available/cells/engines).
50
47
  r.get('/status', async (_req, res) => {
51
48
  try {
52
49
  const fleet = await fleetP;
@@ -56,14 +53,12 @@ function fleetRoutes(fleetP, cfg = {}) {
56
53
  provider: fleet.provider || 'disabled',
57
54
  bootOwner: 'none', // §9b: provider non disponibile -> nessun boot owner
58
55
  capabilities: capList(fleet),
56
+ ...(fleet.reason ? { reason: fleet.reason } : {}),
59
57
  });
60
58
  }
61
59
  const st = await fleet.status();
62
- const provider = st.provider || fleet.provider || 'external';
63
- // bootOwner (§9b): preferisce quello dichiarato dal provider (st.bootOwner);
64
- // altrimenti deriva dal mode: builtin -> 'builtin', disabled -> 'none',
65
- // external -> 'external'. Contratto valori: 'builtin' | 'external' | 'none'.
66
- const bootOwner = st.bootOwner || (provider === 'builtin' ? 'builtin' : provider === 'disabled' ? 'none' : 'external');
60
+ const provider = st.provider || fleet.provider || 'builtin';
61
+ const bootOwner = st.bootOwner || (provider === 'disabled' ? 'none' : 'builtin');
67
62
  res.json({
68
63
  ...st,
69
64
  provider,
@@ -76,7 +71,6 @@ function fleetRoutes(fleetP, cfg = {}) {
76
71
  // /up: il built-in (capability 'edit') è definitions-driven — se il body porta
77
72
  // un engine lo persiste sulla cella (f.engine) PRIMA di up, perché l'engine
78
73
  // dichiarato vince sull'override runtime (ignored dal builtin.up).
79
- // External legacy: passthrough up(cell,{engine,boot}) invariato.
80
74
  r.post('/up', guard(async (f, b) => {
81
75
  const cell = String(b.cell || '');
82
76
  if (b.engine && capList(f).includes('edit')) {
@@ -129,7 +123,7 @@ function fleetRoutes(fleetP, cfg = {}) {
129
123
  return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
130
124
  }, { mutate: true }));
131
125
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
132
- // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
126
+ // gestita fleet.json.
133
127
  r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
134
128
 
135
129
  r.use((err, _req, res, _next) => {
@@ -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() {
@@ -93,7 +94,8 @@ function createBuiltinRuntime(ctx) {
93
94
  permissionPolicy: effectivePolicy,
94
95
  permissionPolicies: { ...(c.permissionPolicies || {}) },
95
96
  active: alive, boot: c.boot, tmux: alive,
96
- rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
97
+ supervised: true, keepalive: true,
98
+ rc: '', key: '', degraded: false, // supervisor vivo <=> sessione tmux viva
97
99
  };
98
100
  });
99
101
  const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
@@ -138,6 +140,7 @@ function createBuiltinRuntime(ctx) {
138
140
  // stato persistente gestito da boot()). Lancia SENZA shell.
139
141
  async function up(cellId /* , { engine, boot } = {} */) {
140
142
  if (readonly()) throw httpError(403, 'READONLY: up bloccato');
143
+ if (typeof ensureProtection === 'function') await ensureProtection();
141
144
  const defs = reloadDefs();
142
145
  const cell = findCell(defs, cellId);
143
146
  if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
@@ -157,26 +160,45 @@ function createBuiltinRuntime(ctx) {
157
160
  const realCwd = resolveCwd(cell.cwd, home);
158
161
  if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
159
162
 
160
- // (4)+(5) argv diretto (no shell). Environment-bearing launches go
161
- // through a private, one-shot Unix-socket broker so credentials never
162
- // appear in `ps`, tmux argv, tmux global/session env or a temporary file.
163
+ // (4)+(5) argv diretto (no shell). Every cell goes through the private
164
+ // broker-backed supervisor: credentials never enter tmux state/argv and a
165
+ // client that exits after readiness is restarted with bounded backoff.
163
166
  // '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
164
167
  // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
165
168
  // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
166
- let tmuxLaunchEngine = launchEngine;
167
- if (Object.keys(launchEngine.env || {}).length) {
168
- const child = composeClientInvocation(launchEngine, cell);
169
- const ticket = await launchBroker.issue({
170
- command: child.command,
171
- args: child.args,
172
- env: { ...minimalEnv(), ...launchEngine.env },
173
- });
174
- tmuxLaunchEngine = {
175
- command: process.execPath,
176
- args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
177
- env: {}, promptMode: 'managed-argv',
178
- };
179
- }
169
+ const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
170
+ const child = composeClientInvocation(launchEngine, cell);
171
+ const ticket = await launchBroker.issue({
172
+ command: child.command,
173
+ args: child.args,
174
+ env: {
175
+ ...minimalEnv(),
176
+ ...launchEngine.env,
177
+ NEXUSCREW_MCP_SESSION: cell.tmuxSession,
178
+ },
179
+ supervise: {
180
+ enabled: true,
181
+ initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
182
+ restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
183
+ maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
184
+ resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
185
+ rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
186
+ maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
187
+ },
188
+ ...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
189
+ restartPrompt: {
190
+ tmuxBin,
191
+ tmuxSession: cell.tmuxSession,
192
+ prompt: cell.prompt,
193
+ readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
194
+ },
195
+ } : {}),
196
+ });
197
+ const tmuxLaunchEngine = {
198
+ command: process.execPath,
199
+ args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
200
+ env: {}, promptMode: 'managed-argv',
201
+ };
180
202
  const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
181
203
  argv.splice(2, 0, '-P', '-F', '#{pane_id}');
182
204
  // Mantieni il pane morto solo durante la finestra di readiness: permette di
@@ -196,7 +218,6 @@ function createBuiltinRuntime(ctx) {
196
218
  // moment later (missing login, bad model, incompatible provider). Without
197
219
  // this readiness gate the PWA reported success and then showed nothing.
198
220
  // Always verify liveness, including cells without a system prompt.
199
- const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
200
221
  const paneId = launch.stdout.trim().split('\n')[0] || '';
201
222
  const readiness = paneId.startsWith('%')
202
223
  ? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
@@ -239,6 +260,7 @@ function createBuiltinRuntime(ctx) {
239
260
 
240
261
  async function down(cellId /* , opts */) {
241
262
  if (readonly()) throw httpError(403, 'READONLY: down bloccato');
263
+ if (typeof ensureProtection === 'function') await ensureProtection();
242
264
  const defs = reloadDefs();
243
265
  const cell = findCell(defs, cellId);
244
266
  if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
@@ -253,7 +275,7 @@ function createBuiltinRuntime(ctx) {
253
275
 
254
276
  // restart = down (riusa la kill esistente; sessione non viva NON e' errore,
255
277
  // come down) seguito da up (rilancia secondo la definizione corrente).
256
- // Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
278
+ // Restart è implementato dal runtime built-in come transizione intenzionale.
257
279
  async function restart(cellId) {
258
280
  if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
259
281
  await down(cellId); // idempotente: cella non viva -> nessun errore