@mmmbuto/nexuscrew 0.7.6 → 0.8.0
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 +173 -18
- package/frontend/dist/assets/index-BBOgTCoO.css +32 -0
- package/frontend/dist/assets/index-DQrDBogT.js +83 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +462 -31
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +319 -0
- package/lib/cli/init.js +107 -7
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -3
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +117 -0
- package/lib/files/routes.js +59 -2
- package/lib/files/store.js +16 -1
- package/lib/fleet/boot.js +62 -0
- package/lib/fleet/builtin.js +594 -0
- package/lib/fleet/definitions.js +410 -0
- package/lib/fleet/index.js +49 -10
- package/lib/fleet/managed.js +211 -0
- package/lib/fleet/provider.js +102 -0
- package/lib/fleet/routes.js +78 -8
- package/lib/fs/routes.js +56 -0
- package/lib/mcp/server.js +362 -0
- package/lib/nodes/commands.js +396 -0
- package/lib/nodes/store.js +358 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +300 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/node-proxy.js +292 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +214 -15
- package/lib/settings/routes.js +473 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-BWhSqR3J.css +0 -32
- package/frontend/dist/assets/index-Bx8vdLZY.css +0 -32
- package/frontend/dist/assets/index-CLb2xmyu.js +0 -81
- package/frontend/dist/assets/index-CgxfkmRo.js +0 -81
- package/frontend/dist/assets/index-DE67kR0M.js +0 -81
- package/frontend/dist/assets/index-DQMx2p4x.js +0 -81
- package/frontend/dist/assets/index-DWhfVwKW.css +0 -32
- package/frontend/dist/assets/index-DoPZ_3-h.js +0 -81
- package/frontend/dist/assets/index-o9l7pPAf.css +0 -32
- package/frontend/dist/assets/index-q9PUrGO0.js +0 -81
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// B4.1 — Definizioni fleet editabili (~/.nexuscrew/fleet.json).
|
|
3
|
+
// Modulo PURO: nessun side-effect all'import. Tutto l'I/O vive in
|
|
4
|
+
// loadDefinitions/atomicWrite; parseDefinitions/validateCommandTrust non
|
|
5
|
+
// toccano il filesystem se non per le stat di trust (sincrone, come binTrusted).
|
|
6
|
+
//
|
|
7
|
+
// Principio: fail-closed. Qualunque dato malformato -> null, MAI throw non
|
|
8
|
+
// gestito. Le definizioni contengono comandi arbitrari (design §6), quindi la
|
|
9
|
+
// validazione e' STRICT (garbage -> errore, non guess). Stesso confinamento di
|
|
10
|
+
// lib/fs/routes.js e lib/tmux/lifecycle.js.
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const { normalizeManagedSpec } = require('./managed.js');
|
|
15
|
+
|
|
16
|
+
// --- Cap + identita' (dichiarati; ragionevoli per un file di flotta locale) ---
|
|
17
|
+
const SCHEMA_VERSION = 1;
|
|
18
|
+
const MAX_ENGINES = 24;
|
|
19
|
+
const MAX_CELLS = 32;
|
|
20
|
+
const MAX_ARGS = 32; // argv: array, mai stringa spezzata (no shell)
|
|
21
|
+
const MAX_ARG_LEN = 1024; // 1 KB per arg
|
|
22
|
+
const MAX_ENV_KEYS = 32;
|
|
23
|
+
const MAX_ENV_KEY_LEN = 64;
|
|
24
|
+
const MAX_ENV_VAL_LEN = 4096; // 4 KB
|
|
25
|
+
const MAX_LABEL_LEN = 64;
|
|
26
|
+
const MAX_COMMAND_LEN = 512;
|
|
27
|
+
const MAX_CWD_LEN = 4096;
|
|
28
|
+
const MAX_MODEL_FLAG_LEN = 32;
|
|
29
|
+
const MAX_MODEL_VAL_LEN = 128;
|
|
30
|
+
const MAX_PROMPTFLAG_LEN = 32;
|
|
31
|
+
const MAX_PROMPT_LEN = 8192; // 8 KB
|
|
32
|
+
const MAX_TMUXSESSION_LEN = 64;
|
|
33
|
+
|
|
34
|
+
const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
|
|
35
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/; // cell id: ammette maiuscole
|
|
36
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // identificatore env POSIX-like
|
|
37
|
+
const TMUX_NAME_RE = /^[\w.-]{1,64}$/; // come lifecycle NAME_RE
|
|
38
|
+
|
|
39
|
+
// Denylist dura di chiavi loader/runtime (design 9a): chi le imposta altera
|
|
40
|
+
// l'esecuzione controllata dal service -> rifiuta l'INTERO documento.
|
|
41
|
+
const ENV_DENY_EXACT = new Set(['PATH', 'SHELL', 'HOME', 'NODE_OPTIONS']);
|
|
42
|
+
const ENV_DENY_PREFIX = ['NPM_CONFIG_', 'LD_', 'DYLD_'];
|
|
43
|
+
|
|
44
|
+
function envKeyDenied(k) {
|
|
45
|
+
if (ENV_DENY_EXACT.has(k)) return true;
|
|
46
|
+
for (const p of ENV_DENY_PREFIX) { if (k.startsWith(p)) return true; }
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Solo testo stampabile per le label UI (no control char 0x00-0x1f, no DEL).
|
|
51
|
+
function isPrintable(s) {
|
|
52
|
+
if (typeof s !== 'string') return false;
|
|
53
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
54
|
+
const c = s.charCodeAt(i);
|
|
55
|
+
if (c <= 0x1f || c === 0x7f) return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Singolo elemento argv: no whitespace, no control char (design 9f: niente
|
|
61
|
+
// spazi/shell). Vale per model.flag e promptFlag.
|
|
62
|
+
function isSingleArgv(s) {
|
|
63
|
+
if (typeof s !== 'string' || !s) return false;
|
|
64
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
65
|
+
const c = s.charCodeAt(i);
|
|
66
|
+
if (c <= 0x20 || c === 0x7f) return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validTmuxName(name) {
|
|
72
|
+
return typeof name === 'string'
|
|
73
|
+
&& name.length <= MAX_TMUXSESSION_LEN
|
|
74
|
+
&& TMUX_NAME_RE.test(name)
|
|
75
|
+
&& !name.startsWith('-');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// parseDefinitions(raw) -> {schemaVersion, engines, cells} | null
|
|
80
|
+
// Accetta stringa JSON o oggetto gia' parsato. Strict + fail-closed.
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
function parseDefinitions(raw) {
|
|
83
|
+
try {
|
|
84
|
+
let d;
|
|
85
|
+
if (typeof raw === 'string') {
|
|
86
|
+
try { d = JSON.parse(raw); } catch (_) { return null; }
|
|
87
|
+
} else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
88
|
+
d = raw;
|
|
89
|
+
} else {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (d.schemaVersion !== SCHEMA_VERSION) return null;
|
|
94
|
+
if (!Array.isArray(d.engines)) return null; // engines obbligatorio (array)
|
|
95
|
+
if (d.engines.length > MAX_ENGINES) return null;
|
|
96
|
+
if (!Array.isArray(d.cells)) return null; // cells obbligatorio (array)
|
|
97
|
+
if (d.cells.length > MAX_CELLS) return null;
|
|
98
|
+
|
|
99
|
+
const engineIds = new Set();
|
|
100
|
+
const engines = [];
|
|
101
|
+
for (const e of d.engines) {
|
|
102
|
+
const eng = parseEngine(e);
|
|
103
|
+
if (!eng) return null;
|
|
104
|
+
if (engineIds.has(eng.id)) return null; // id engine univoco
|
|
105
|
+
engineIds.add(eng.id);
|
|
106
|
+
engines.push(eng);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const tmuxSeen = new Set();
|
|
110
|
+
const cellIds = new Set();
|
|
111
|
+
const cells = [];
|
|
112
|
+
for (const c of d.cells) {
|
|
113
|
+
const cell = parseCell(c, engineIds);
|
|
114
|
+
if (!cell) return null;
|
|
115
|
+
if (cellIds.has(cell.id)) return null; // id cell univoco
|
|
116
|
+
cellIds.add(cell.id);
|
|
117
|
+
if (tmuxSeen.has(cell.tmuxSession)) return null; // tmuxSession univoco
|
|
118
|
+
tmuxSeen.add(cell.tmuxSession);
|
|
119
|
+
cells.push(cell);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { schemaVersion: SCHEMA_VERSION, engines, cells };
|
|
123
|
+
} catch (_) {
|
|
124
|
+
return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseEngine(e) {
|
|
129
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) return null;
|
|
130
|
+
|
|
131
|
+
// id
|
|
132
|
+
if (typeof e.id !== 'string' || !ENGINE_ID_RE.test(e.id)) return null;
|
|
133
|
+
|
|
134
|
+
// label (opzionale, default = id; solo stampabile)
|
|
135
|
+
let label = e.id;
|
|
136
|
+
if (e.label !== undefined) {
|
|
137
|
+
if (typeof e.label !== 'string' || !isPrintable(e.label) || e.label.length > MAX_LABEL_LEN) return null;
|
|
138
|
+
label = e.label;
|
|
139
|
+
}
|
|
140
|
+
if (!label) label = e.id; // etichetta vuota -> fallback id
|
|
141
|
+
|
|
142
|
+
// rc (opzionale, default false: remote-control e' l'eccezione)
|
|
143
|
+
let rc = false;
|
|
144
|
+
if (e.rc !== undefined) {
|
|
145
|
+
if (typeof e.rc !== 'boolean') return null;
|
|
146
|
+
rc = e.rc;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Managed: NexusCrew conosce client/provider e compone internamente il
|
|
150
|
+
// processo. Nessun command/env/argv o segreto vive nella definizione.
|
|
151
|
+
if (e.managed !== undefined) {
|
|
152
|
+
const managed = normalizeManagedSpec(e.managed);
|
|
153
|
+
if (!managed) return null;
|
|
154
|
+
for (const key of ['command', 'args', 'env', 'promptMode', 'promptFlag', 'model']) {
|
|
155
|
+
if (e[key] !== undefined) return null;
|
|
156
|
+
}
|
|
157
|
+
return { id: e.id, label, rc, managed };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// command (obbligatorio, stringa non vuota; il trust si verifica a parte)
|
|
161
|
+
if (typeof e.command !== 'string' || !e.command || e.command.length > MAX_COMMAND_LEN) return null;
|
|
162
|
+
|
|
163
|
+
// args (opzionale, default [])
|
|
164
|
+
let args = [];
|
|
165
|
+
if (e.args !== undefined) {
|
|
166
|
+
if (!Array.isArray(e.args) || e.args.length > MAX_ARGS) return null;
|
|
167
|
+
args = [];
|
|
168
|
+
for (const a of e.args) {
|
|
169
|
+
if (typeof a !== 'string' || a.length > MAX_ARG_LEN) return null;
|
|
170
|
+
args.push(a);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// env (opzionale, default {}); chiavi identificadori + denylist dura
|
|
175
|
+
let env = {};
|
|
176
|
+
if (e.env !== undefined) {
|
|
177
|
+
if (!e.env || typeof e.env !== 'object' || Array.isArray(e.env)) return null;
|
|
178
|
+
const keys = Object.keys(e.env);
|
|
179
|
+
if (keys.length > MAX_ENV_KEYS) return null;
|
|
180
|
+
env = {};
|
|
181
|
+
for (const k of keys) {
|
|
182
|
+
if (k.length > MAX_ENV_KEY_LEN || !ENV_KEY_RE.test(k)) return null;
|
|
183
|
+
if (envKeyDenied(k)) return null; // loader/runtime key -> rifiuta tutto
|
|
184
|
+
const v = e.env[k];
|
|
185
|
+
if (typeof v !== 'string' || v.length > MAX_ENV_VAL_LEN) return null;
|
|
186
|
+
env[k] = v;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// promptMode (obbligatorio: l'engine dichiara come iniettare il prompt)
|
|
191
|
+
if (e.promptMode !== 'flag' && e.promptMode !== 'send-keys') return null;
|
|
192
|
+
const promptMode = e.promptMode;
|
|
193
|
+
|
|
194
|
+
// model (opzionale {flag, value}); flag = singolo argv senza spazi
|
|
195
|
+
let model;
|
|
196
|
+
if (e.model !== undefined) {
|
|
197
|
+
if (!e.model || typeof e.model !== 'object' || Array.isArray(e.model)) return null;
|
|
198
|
+
if (typeof e.model.flag !== 'string' || !isSingleArgv(e.model.flag) || e.model.flag.length > MAX_MODEL_FLAG_LEN) return null;
|
|
199
|
+
const value = e.model.value !== undefined ? e.model.value : '';
|
|
200
|
+
if (typeof value !== 'string' || value.length > MAX_MODEL_VAL_LEN) return null;
|
|
201
|
+
model = { flag: e.model.flag, value };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// promptFlag (richiesto solo se promptMode==='flag'; singolo argv)
|
|
205
|
+
let promptFlag;
|
|
206
|
+
if (promptMode === 'flag') {
|
|
207
|
+
if (typeof e.promptFlag !== 'string' || !isSingleArgv(e.promptFlag) || e.promptFlag.length > MAX_PROMPTFLAG_LEN) return null;
|
|
208
|
+
promptFlag = e.promptFlag;
|
|
209
|
+
}
|
|
210
|
+
// promptMode!=='flag' con promptFlag presente -> ignorato (campo non rilevante)
|
|
211
|
+
|
|
212
|
+
const out = { id: e.id, label, rc, command: e.command, args, env, promptMode };
|
|
213
|
+
if (model) out.model = model;
|
|
214
|
+
if (promptFlag !== undefined) out.promptFlag = promptFlag;
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseCell(c, engineIds) {
|
|
219
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
|
220
|
+
|
|
221
|
+
// id
|
|
222
|
+
if (typeof c.id !== 'string' || !CELL_ID_RE.test(c.id)) return null;
|
|
223
|
+
|
|
224
|
+
// cwd (obbligatorio; la risoluzione/confinamento avviene via resolveCwd a runtime)
|
|
225
|
+
if (typeof c.cwd !== 'string' || !c.cwd || c.cwd.length > MAX_CWD_LEN) return null;
|
|
226
|
+
|
|
227
|
+
// engine = riferimento a engines[].id esistente (dangling -> null)
|
|
228
|
+
if (typeof c.engine !== 'string' || !engineIds.has(c.engine)) return null;
|
|
229
|
+
|
|
230
|
+
// boot (opzionale, default false)
|
|
231
|
+
let boot = false;
|
|
232
|
+
if (c.boot !== undefined) {
|
|
233
|
+
if (typeof c.boot !== 'boolean') return null;
|
|
234
|
+
boot = c.boot;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// model override (opzionale, stringa = value per l'engine)
|
|
238
|
+
let model;
|
|
239
|
+
if (c.model !== undefined) {
|
|
240
|
+
if (typeof c.model !== 'string' || c.model.length > MAX_MODEL_VAL_LEN) return null;
|
|
241
|
+
model = c.model;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Ultimo modello per engine, persistito per cella. Consente di tornare a un
|
|
245
|
+
// provider e ritrovare la scelta precedente senza trascinarla su altri engine.
|
|
246
|
+
let models = {};
|
|
247
|
+
if (c.models !== undefined) {
|
|
248
|
+
if (!c.models || typeof c.models !== 'object' || Array.isArray(c.models)) return null;
|
|
249
|
+
const entries = Object.entries(c.models);
|
|
250
|
+
if (entries.length > MAX_ENGINES) return null;
|
|
251
|
+
for (const [engineId, value] of entries) {
|
|
252
|
+
if (!engineIds.has(engineId) || typeof value !== 'string' || !value || value.length > MAX_MODEL_VAL_LEN) return null;
|
|
253
|
+
models[engineId] = value;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// prompt (opzionale, cap)
|
|
258
|
+
let prompt;
|
|
259
|
+
if (c.prompt !== undefined) {
|
|
260
|
+
if (typeof c.prompt !== 'string' || c.prompt.length > MAX_PROMPT_LEN) return null;
|
|
261
|
+
prompt = c.prompt;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// tmuxSession: campo esplicito o derivato da id. UNIVOCO (check in caller).
|
|
265
|
+
// Namespace cloud-* RISERVATO al fleet (coerente con lifecycle.js: le celle
|
|
266
|
+
// sono cloud-<Cell>): un override esplicito che usa cloud-* e' rifiutato,
|
|
267
|
+
// PERCHE' accettare un cloud-* diverso dal proprio aliaserebbe la sessione
|
|
268
|
+
// di un'altra cella fleet. Si ammette SOLO il derivato canonico cloud-<id>
|
|
269
|
+
// della cella stessa (forma normale, sopravvive al round-trip su disco).
|
|
270
|
+
const canonical = `cloud-${c.id}`;
|
|
271
|
+
let tmuxSession;
|
|
272
|
+
if (c.tmuxSession !== undefined) {
|
|
273
|
+
if (typeof c.tmuxSession !== 'string' || !validTmuxName(c.tmuxSession)) return null;
|
|
274
|
+
if (/^cloud-/i.test(c.tmuxSession) && c.tmuxSession !== canonical) return null; // alias cloud-* -> null
|
|
275
|
+
tmuxSession = c.tmuxSession;
|
|
276
|
+
} else {
|
|
277
|
+
tmuxSession = canonical;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
|
|
281
|
+
if (model !== undefined) out.model = model;
|
|
282
|
+
if (Object.keys(models).length) out.models = models;
|
|
283
|
+
if (prompt !== undefined) out.prompt = prompt;
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// validateCommandTrust(command) -> {ok, reason}
|
|
289
|
+
// Path assoluto, regular file, owner-executable, NON symlink (lstat), NON
|
|
290
|
+
// world-writable. Riutilizza il pattern di binTrusted in lib/fleet/index.js.
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
function validateCommandTrust(command) {
|
|
293
|
+
if (typeof command !== 'string' || !command) return { ok: false, reason: 'command vuoto' };
|
|
294
|
+
if (!path.isAbsolute(command)) return { ok: false, reason: 'command deve essere un path assoluto' };
|
|
295
|
+
let st;
|
|
296
|
+
try { st = fs.lstatSync(command); } catch (e) { return { ok: false, reason: `non accessibile (${e.code || e.message})` }; }
|
|
297
|
+
if (!st.isFile()) return { ok: false, reason: 'non e\' un file regolare (symlink o speciale)' }; // lstat: symlink -> isFile()=false
|
|
298
|
+
if (!(st.mode & 0o100)) return { ok: false, reason: 'non eseguibile dall\'owner' };
|
|
299
|
+
if (st.mode & 0o002) return { ok: false, reason: 'world-writable' };
|
|
300
|
+
// Owner check (design §9a, audit impl #4): il command deve appartenere
|
|
301
|
+
// all'utente del service o a root — un owner terzo potrebbe sostituire
|
|
302
|
+
// l'eseguibile mantenendo il path "trusted".
|
|
303
|
+
if (typeof process.getuid === 'function') {
|
|
304
|
+
const uid = process.getuid();
|
|
305
|
+
if (st.uid !== uid && st.uid !== 0) return { ok: false, reason: 'owner non fidato (ne\' utente del service ne\' root)' };
|
|
306
|
+
}
|
|
307
|
+
return { ok: true, reason: 'trusted' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// resolveCwd(cwd, home) -> path|null
|
|
312
|
+
// realpath SOTTO la home (default process.env.HOME); stesso confinamento di
|
|
313
|
+
// lib/tmux/lifecycle.js: realpath su entrambi (symlink dentro home che punta
|
|
314
|
+
// fuori -> rifiutato) e deve essere una directory.
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
function resolveCwd(cwd, home) {
|
|
317
|
+
try {
|
|
318
|
+
const h = home || process.env.HOME;
|
|
319
|
+
if (typeof cwd !== 'string' || !cwd || typeof h !== 'string' || !h) return null;
|
|
320
|
+
if (cwd.includes('\0') || h.includes('\0')) return null;
|
|
321
|
+
const real = fs.realpathSync(cwd);
|
|
322
|
+
const realHome = fs.realpathSync(h);
|
|
323
|
+
if (!fs.statSync(real).isDirectory()) return null;
|
|
324
|
+
if (real !== realHome && !real.startsWith(realHome + path.sep)) return null;
|
|
325
|
+
return real;
|
|
326
|
+
} catch (_) { return null; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// loadDefinitions(p) -> parsed | null
|
|
331
|
+
// Legge il file rifiutando i symlink; parse strict. Mai throw.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
function loadDefinitions(p) {
|
|
334
|
+
try {
|
|
335
|
+
let st;
|
|
336
|
+
try { st = fs.lstatSync(p); } catch (_) { return null; } // missing -> null
|
|
337
|
+
if (st.isSymbolicLink()) return null; // no symlink
|
|
338
|
+
if (!st.isFile()) return null;
|
|
339
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
340
|
+
return parseDefinitions(raw);
|
|
341
|
+
} catch (_) { return null; }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Backup best-effort del predecessore (su fallimento di validazione, o comunque
|
|
345
|
+
// prima di sovrascrivere). Sempre 0600.
|
|
346
|
+
function backupPredecessor(p) {
|
|
347
|
+
try {
|
|
348
|
+
if (!fs.lstatSync(p).isFile()) return;
|
|
349
|
+
const bak = `${p}.bak`;
|
|
350
|
+
fs.copyFileSync(p, bak);
|
|
351
|
+
fs.chmodSync(bak, 0o600);
|
|
352
|
+
} catch (_) { /* best-effort */ }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// atomicWrite(p, data) -> parsed
|
|
357
|
+
// data: oggetto definizioni OPPURE stringa JSON. Valida PRIMA di scrivere
|
|
358
|
+
// (fail-closed: dati invalidi -> backup del predecessore + throw, mai scritti).
|
|
359
|
+
// Scrittura atomica: tmp nella stessa dir + rename; file mode 0600; rifiuto
|
|
360
|
+
// se il target esiste ed e' un symlink.
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
function atomicWrite(p, data) {
|
|
363
|
+
// Rifiuta symlink come target: mai scrivere attraverso un link.
|
|
364
|
+
try {
|
|
365
|
+
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
366
|
+
throw new Error('refuse to write: il target e\' un symlink');
|
|
367
|
+
}
|
|
368
|
+
} catch (e) {
|
|
369
|
+
if (e.code === 'ENOENT') { /* nuovo file, ok */ }
|
|
370
|
+
else throw e; // inclusi i nostri 'refuse to write'
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const parsed = parseDefinitions(data);
|
|
374
|
+
if (!parsed) {
|
|
375
|
+
backupPredecessor(p); // conserva il precedente per recovery/forensics
|
|
376
|
+
throw new Error('definizioni fleet non valide: validazione fallita');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const dir = path.dirname(p);
|
|
380
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
381
|
+
const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
382
|
+
try {
|
|
383
|
+
fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
384
|
+
fs.chmodSync(tmp, 0o600); // forza 0600 a prescindere da umask/file preesistente
|
|
385
|
+
fs.renameSync(tmp, p); // atomico sullo stesso filesystem (stessa dir)
|
|
386
|
+
} catch (e) {
|
|
387
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
|
|
388
|
+
throw e;
|
|
389
|
+
}
|
|
390
|
+
return parsed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const CAPS = {
|
|
394
|
+
SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
|
|
395
|
+
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
|
|
396
|
+
MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
|
|
397
|
+
MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_TMUXSESSION_LEN,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
module.exports = {
|
|
401
|
+
parseDefinitions,
|
|
402
|
+
validateCommandTrust,
|
|
403
|
+
resolveCwd,
|
|
404
|
+
loadDefinitions,
|
|
405
|
+
atomicWrite,
|
|
406
|
+
CAPS,
|
|
407
|
+
// Costanti esposte anche piatte (comode per la UI/schema e i test)
|
|
408
|
+
SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
|
|
409
|
+
MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN,
|
|
410
|
+
};
|
package/lib/fleet/index.js
CHANGED
|
@@ -2,8 +2,33 @@
|
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
const { createFleetExec } = require('./exec.js');
|
|
4
4
|
|
|
5
|
-
const ENGINES = new Set(['native', 'glm', 'glm-a', 'glm-p', 'ollama', 'ollama-cloud', 'codex-vl']);
|
|
6
5
|
const STATUS_TTL_MS = 2000;
|
|
6
|
+
const ENGINE_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
7
|
+
const MAX_ENGINES = 24;
|
|
8
|
+
|
|
9
|
+
// Engines dichiarati dal contratto fleet (opzionale, additivo al v1):
|
|
10
|
+
// array di stringhe o {id, label?, rc?}. id per i comandi, label per la UI,
|
|
11
|
+
// rc=true se l'engine supporta il remote-control (default: solo id 'native',
|
|
12
|
+
// compat col vincolo storico). Malformato → null (fail-closed come le celle).
|
|
13
|
+
function parseEngines(raw) {
|
|
14
|
+
if (raw == null) return [];
|
|
15
|
+
if (!Array.isArray(raw) || raw.length > MAX_ENGINES) return null;
|
|
16
|
+
const out = []; const seen = new Set();
|
|
17
|
+
for (const e of raw) {
|
|
18
|
+
let id; let label; let rc;
|
|
19
|
+
if (typeof e === 'string') { id = e; } else if (e && typeof e === 'object' && typeof e.id === 'string') {
|
|
20
|
+
id = e.id;
|
|
21
|
+
if (e.label != null && typeof e.label !== 'string') return null;
|
|
22
|
+
label = e.label;
|
|
23
|
+
if (e.rc != null && typeof e.rc !== 'boolean') return null;
|
|
24
|
+
rc = e.rc;
|
|
25
|
+
} else return null;
|
|
26
|
+
if (!ENGINE_ID_RE.test(id) || seen.has(id)) return null;
|
|
27
|
+
seen.add(id);
|
|
28
|
+
out.push({ id, label: (label || id).slice(0, 48), rc: rc != null ? rc : id === 'native' });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
7
32
|
|
|
8
33
|
// Trust boundary sul binario (audit F3): regular file, NO symlink,
|
|
9
34
|
// eseguibile dall'owner, NON world-writable.
|
|
@@ -38,7 +63,21 @@ function parseStatus(raw) {
|
|
|
38
63
|
degraded: c.active !== c.tmux, // unit e tmux in disaccordo
|
|
39
64
|
});
|
|
40
65
|
}
|
|
41
|
-
|
|
66
|
+
const engines = parseEngines(d.engines);
|
|
67
|
+
if (engines === null) return null; // engines malformati → fail-closed
|
|
68
|
+
return { cells, engines };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Lista engine effettiva: dichiarata dal fleet se presente, altrimenti
|
|
72
|
+
// derivata dagli engine in uso nelle celle (fallback conservativo, no hardcode).
|
|
73
|
+
function effectiveEngines(cache) {
|
|
74
|
+
if (cache.engines.length) return cache.engines;
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const c of cache.cells) {
|
|
78
|
+
if (!seen.has(c.engine)) { seen.add(c.engine); out.push({ id: c.engine, label: c.engine, rc: c.engine === 'native' }); }
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
42
81
|
}
|
|
43
82
|
|
|
44
83
|
function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
|
|
@@ -50,30 +89,30 @@ async function createFleet(cfg = {}) {
|
|
|
50
89
|
if (!bin || !binTrusted(bin)) return off;
|
|
51
90
|
|
|
52
91
|
const fx = createFleetExec(bin);
|
|
53
|
-
let
|
|
54
|
-
try {
|
|
55
|
-
if (!
|
|
92
|
+
let parsed;
|
|
93
|
+
try { parsed = parseStatus(await fx.run(['status', '--json'])); } catch (_) { return off; }
|
|
94
|
+
if (!parsed) return off; // schema estraneo → feature spenta
|
|
56
95
|
|
|
57
|
-
let cache = { at: Date.now(), cells };
|
|
96
|
+
let cache = { at: Date.now(), cells: parsed.cells, engines: parsed.engines };
|
|
58
97
|
const sessions = () => new Set(cache.cells.map((c) => c.tmuxSession));
|
|
59
98
|
|
|
60
99
|
async function status() {
|
|
61
100
|
if (Date.now() - cache.at > STATUS_TTL_MS) {
|
|
62
101
|
const fresh = parseStatus(await fx.run(['status', '--json']));
|
|
63
|
-
if (fresh) cache = { at: Date.now(), cells: fresh };
|
|
102
|
+
if (fresh) cache = { at: Date.now(), cells: fresh.cells, engines: fresh.engines };
|
|
64
103
|
}
|
|
65
|
-
return { available: true, cells: cache.cells };
|
|
104
|
+
return { available: true, cells: cache.cells, engines: effectiveEngines(cache) };
|
|
66
105
|
}
|
|
67
106
|
|
|
68
107
|
function assertCell(cell) {
|
|
69
108
|
if (!cache.cells.some((c) => c.cell === cell)) throw httpError(400, `cella sconosciuta: ${cell}`);
|
|
70
109
|
}
|
|
71
110
|
function assertEngine(eng) {
|
|
72
|
-
if (!
|
|
111
|
+
if (!effectiveEngines(cache).some((e) => e.id === eng)) throw httpError(400, `engine non valido: ${eng}`);
|
|
73
112
|
}
|
|
74
113
|
async function cmd(args) {
|
|
75
114
|
try { await fx.run(args); } catch (e) { throw httpError(502, e.message); }
|
|
76
|
-
cache = { at: 0
|
|
115
|
+
cache = { ...cache, at: 0 }; // invalida: il prossimo status rilegge
|
|
77
116
|
return { ok: true };
|
|
78
117
|
}
|
|
79
118
|
|