@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/README.md +27 -22
- package/frontend/dist/assets/{index-DQbVJLji.css → index-77r8nzQf.css} +1 -1
- package/frontend/dist/assets/index-C0QL3gEp.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 +257 -10
- package/lib/cli/doctor.js +65 -11
- package/lib/cli/fleet-service.js +10 -41
- package/lib/cli/init.js +30 -8
- package/lib/config.js +7 -2
- package/lib/fleet/builtin.js +17 -6
- 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 +8 -14
- package/lib/fleet/runtime.js +44 -22
- package/lib/nodes/commands.js +160 -19
- package/lib/nodes/inventory.js +81 -0
- package/lib/server.js +40 -6
- package/lib/settings/routes.js +43 -23
- 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-DaFQbMHq.js +0 -93
- package/lib/fleet/exec.js +0 -32
- package/lib/fleet/index.js +0 -180
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;
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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) —
|
|
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,
|
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, 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
|
-
|
|
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;
|
|
@@ -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 || '
|
|
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');
|
|
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.
|
|
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) => {
|
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() {
|
|
@@ -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
|
-
|
|
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).
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
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
|