@mmmbuto/nexuscrew 0.8.20 → 0.8.22
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 +33 -21
- package/frontend/dist/assets/{index-DQbVJLji.css → index-77r8nzQf.css} +1 -1
- package/frontend/dist/assets/index-ClJP2j6k.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +2 -2
- package/lib/cli/commands.js +233 -8
- package/lib/cli/fleet-service.js +7 -41
- package/lib/cli/init.js +0 -2
- package/lib/config.js +0 -2
- package/lib/fleet/builtin.js +4 -5
- package/lib/fleet/cell-exec.js +165 -11
- package/lib/fleet/definitions.js +1 -1
- package/lib/fleet/provider.js +10 -90
- package/lib/fleet/routes.js +7 -14
- package/lib/fleet/runtime.js +39 -20
- package/lib/nodes/commands.js +160 -19
- package/lib/nodes/inventory.js +81 -0
- package/lib/nodes/tunnel.js +9 -1
- package/lib/server.js +31 -3
- package/lib/settings/routes.js +68 -32
- package/package.json +1 -1
- package/frontend/dist/assets/index-CQnOyaXz.js +0 -93
- package/lib/fleet/exec.js +0 -32
- package/lib/fleet/index.js +0 -180
package/lib/fleet/cell-exec.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
// single-use
|
|
6
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 = {
|
|
232
|
+
module.exports = {
|
|
233
|
+
DEFAULT_SUPERVISE, parseArgs, validSupervise, validRestartPrompt, validPayload,
|
|
234
|
+
receivePayload, normalizeSupervise, waitChild, scheduleRestartPrompt, main,
|
|
235
|
+
};
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -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.
|
|
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' };
|
package/lib/fleet/provider.js
CHANGED
|
@@ -1,103 +1,23 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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 };
|
|
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
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
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 };
|
package/lib/fleet/routes.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const express = require('express');
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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).
|
|
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;
|
|
@@ -59,11 +56,8 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
59
56
|
});
|
|
60
57
|
}
|
|
61
58
|
const st = await fleet.status();
|
|
62
|
-
const provider = st.provider || fleet.provider || '
|
|
63
|
-
|
|
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');
|
|
59
|
+
const provider = st.provider || fleet.provider || 'builtin';
|
|
60
|
+
const bootOwner = st.bootOwner || (provider === 'disabled' ? 'none' : 'builtin');
|
|
67
61
|
res.json({
|
|
68
62
|
...st,
|
|
69
63
|
provider,
|
|
@@ -76,7 +70,6 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
76
70
|
// /up: il built-in (capability 'edit') è definitions-driven — se il body porta
|
|
77
71
|
// un engine lo persiste sulla cella (f.engine) PRIMA di up, perché l'engine
|
|
78
72
|
// dichiarato vince sull'override runtime (ignored dal builtin.up).
|
|
79
|
-
// External legacy: passthrough up(cell,{engine,boot}) invariato.
|
|
80
73
|
r.post('/up', guard(async (f, b) => {
|
|
81
74
|
const cell = String(b.cell || '');
|
|
82
75
|
if (b.engine && capList(f).includes('edit')) {
|
|
@@ -129,7 +122,7 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
129
122
|
return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
|
|
130
123
|
}, { mutate: true }));
|
|
131
124
|
// Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
|
|
132
|
-
// gestita fleet.json.
|
|
125
|
+
// gestita fleet.json.
|
|
133
126
|
r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
|
|
134
127
|
|
|
135
128
|
r.use((err, _req, res, _next) => {
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -93,7 +93,8 @@ function createBuiltinRuntime(ctx) {
|
|
|
93
93
|
permissionPolicy: effectivePolicy,
|
|
94
94
|
permissionPolicies: { ...(c.permissionPolicies || {}) },
|
|
95
95
|
active: alive, boot: c.boot, tmux: alive,
|
|
96
|
-
|
|
96
|
+
supervised: true, keepalive: true,
|
|
97
|
+
rc: '', key: '', degraded: false, // supervisor vivo <=> sessione tmux viva
|
|
97
98
|
};
|
|
98
99
|
});
|
|
99
100
|
const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
|
|
@@ -157,26 +158,45 @@ function createBuiltinRuntime(ctx) {
|
|
|
157
158
|
const realCwd = resolveCwd(cell.cwd, home);
|
|
158
159
|
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
159
160
|
|
|
160
|
-
// (4)+(5) argv diretto (no shell).
|
|
161
|
-
//
|
|
162
|
-
//
|
|
161
|
+
// (4)+(5) argv diretto (no shell). Every cell goes through the private
|
|
162
|
+
// broker-backed supervisor: credentials never enter tmux state/argv and a
|
|
163
|
+
// client that exits after readiness is restarted with bounded backoff.
|
|
163
164
|
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
164
165
|
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
165
166
|
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
167
|
+
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
168
|
+
const child = composeClientInvocation(launchEngine, cell);
|
|
169
|
+
const ticket = await launchBroker.issue({
|
|
170
|
+
command: child.command,
|
|
171
|
+
args: child.args,
|
|
172
|
+
env: {
|
|
173
|
+
...minimalEnv(),
|
|
174
|
+
...launchEngine.env,
|
|
175
|
+
NEXUSCREW_MCP_SESSION: cell.tmuxSession,
|
|
176
|
+
},
|
|
177
|
+
supervise: {
|
|
178
|
+
enabled: true,
|
|
179
|
+
initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
|
|
180
|
+
restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
|
|
181
|
+
maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
|
|
182
|
+
resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
|
|
183
|
+
rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
|
|
184
|
+
maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
|
|
185
|
+
},
|
|
186
|
+
...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
|
|
187
|
+
restartPrompt: {
|
|
188
|
+
tmuxBin,
|
|
189
|
+
tmuxSession: cell.tmuxSession,
|
|
190
|
+
prompt: cell.prompt,
|
|
191
|
+
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
192
|
+
},
|
|
193
|
+
} : {}),
|
|
194
|
+
});
|
|
195
|
+
const tmuxLaunchEngine = {
|
|
196
|
+
command: process.execPath,
|
|
197
|
+
args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
|
|
198
|
+
env: {}, promptMode: 'managed-argv',
|
|
199
|
+
};
|
|
180
200
|
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
|
|
181
201
|
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
182
202
|
// Mantieni il pane morto solo durante la finestra di readiness: permette di
|
|
@@ -196,7 +216,6 @@ function createBuiltinRuntime(ctx) {
|
|
|
196
216
|
// moment later (missing login, bad model, incompatible provider). Without
|
|
197
217
|
// this readiness gate the PWA reported success and then showed nothing.
|
|
198
218
|
// Always verify liveness, including cells without a system prompt.
|
|
199
|
-
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
200
219
|
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
201
220
|
const readiness = paneId.startsWith('%')
|
|
202
221
|
? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
|
|
@@ -253,7 +272,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
253
272
|
|
|
254
273
|
// restart = down (riusa la kill esistente; sessione non viva NON e' errore,
|
|
255
274
|
// come down) seguito da up (rilancia secondo la definizione corrente).
|
|
256
|
-
//
|
|
275
|
+
// Restart è implementato dal runtime built-in come transizione intenzionale.
|
|
257
276
|
async function restart(cellId) {
|
|
258
277
|
if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
|
|
259
278
|
await down(cellId); // idempotente: cella non viva -> nessun errore
|