@mmmbuto/nexuscrew 0.7.5 → 0.7.7
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 +22 -3
- package/frontend/dist/assets/index-DUbtTZMj.css +32 -0
- package/frontend/dist/assets/index-EVa9bnAC.js +81 -0
- package/frontend/dist/index.html +2 -2
- package/lib/cli/commands.js +48 -1
- package/lib/cli/fleet-service.js +306 -0
- package/lib/cli/init.js +68 -1
- package/lib/files/routes.js +4 -1
- package/lib/fleet/boot.js +62 -0
- package/lib/fleet/builtin.js +477 -0
- package/lib/fleet/definitions.js +384 -0
- package/lib/fleet/index.js +49 -10
- package/lib/fleet/provider.js +102 -0
- package/lib/fleet/routes.js +77 -8
- package/lib/fs/routes.js +56 -0
- package/lib/server.js +8 -3
- package/package.json +2 -2
- 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-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,477 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// B4.2 — Fleet built-in. Implementa lo STESSO contratto di lib/fleet/index.js
|
|
3
|
+
// (createFleet: available / status / up / down / engine / boot / isCellSession)
|
|
4
|
+
// leggendo le definizioni da ~/.nexuscrew/fleet.json tramite lib/fleet/definitions.js,
|
|
5
|
+
// e lo ESTENDE con define*/edit*/remove* + schema + capabilities (design §4b/§9c).
|
|
6
|
+
//
|
|
7
|
+
// Agnostico: non conosce "claude"/"glm"/"codex" — lancia solo command+args dichiarati.
|
|
8
|
+
//
|
|
9
|
+
// Sicurezza (design §6 / §9a / §9d / §9e):
|
|
10
|
+
// - up esegue validateCommandTrust(command) e resolveCwd(cwd) PRIMA di lanciare;
|
|
11
|
+
// una cella/engine mancante o non trusted -> httpError(400), NON lancia nulla.
|
|
12
|
+
// - command/args/env NON passano per una shell: execFile + argv diretto (tmux fa
|
|
13
|
+
// exec del comando, NON sh -c — verificato: ';','|','$' passano verbatim).
|
|
14
|
+
// - env: il builtin lancia con un env MINIMALE controllato dal service (allowlist
|
|
15
|
+
// dura); engine.env — gia' ripulito dalle loader-key da parseDefinitions —
|
|
16
|
+
// raggiunge la sessione SOLO via `tmux -e` (chiavi validate). PATH lo controlla
|
|
17
|
+
// il service, mai la definizione.
|
|
18
|
+
// - READONLY (cfg.readonlyDefault===true | NEXUSCREW_READONLY=1) blocca ogni
|
|
19
|
+
// mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
|
|
20
|
+
// - promptMode 'send-keys' inietta via `tmux load-buffer` + `paste-buffer -p`
|
|
21
|
+
// (bracketed paste), NON send-keys grezzo; se il command e' gia' uscito
|
|
22
|
+
// (sessione morta) NON digita (§9e).
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const os = require('node:os');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const { execFile } = require('node:child_process');
|
|
27
|
+
const {
|
|
28
|
+
parseDefinitions, validateCommandTrust, resolveCwd,
|
|
29
|
+
loadDefinitions, atomicWrite, CAPS,
|
|
30
|
+
} = require('./definitions.js');
|
|
31
|
+
|
|
32
|
+
const STATUS_TTL_MS = 2000;
|
|
33
|
+
|
|
34
|
+
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
35
|
+
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
36
|
+
// qui NON passiamo MAI l'env del processo per intero. engine.env va in session via -e.
|
|
37
|
+
// Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
|
|
38
|
+
// ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
|
|
39
|
+
// iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
|
|
40
|
+
const MINIMAL_ENV_KEYS = [
|
|
41
|
+
'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE',
|
|
42
|
+
'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME',
|
|
43
|
+
'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS',
|
|
44
|
+
];
|
|
45
|
+
function minimalEnv() {
|
|
46
|
+
const env = {};
|
|
47
|
+
for (const k of MINIMAL_ENV_KEYS) {
|
|
48
|
+
if (process.env[k] !== undefined && process.env[k] !== '') env[k] = process.env[k];
|
|
49
|
+
}
|
|
50
|
+
if (!env.PATH) env.PATH = '/usr/local/bin:/usr/bin:/bin';
|
|
51
|
+
if (!env.HOME) env.HOME = os.homedir();
|
|
52
|
+
if (!env.TERM) env.TERM = 'xterm-256color';
|
|
53
|
+
return env;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
|
|
57
|
+
|
|
58
|
+
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
59
|
+
// NON devono mai ecoare i segreti delle definizioni.
|
|
60
|
+
const REDACTED = '‹redacted›';
|
|
61
|
+
|
|
62
|
+
// redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
|
|
63
|
+
// delle definizioni sostituita da '‹redacted›'. Segreti coperti (§9h):
|
|
64
|
+
// - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
|
|
65
|
+
// - testo del prompt della cella (cell.prompt)
|
|
66
|
+
// - testo del prompt dell'engine (engine.prompt) se presente
|
|
67
|
+
// Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
|
|
68
|
+
// tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
|
|
69
|
+
// lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
|
|
70
|
+
function redactSecrets(text, engine, cell) {
|
|
71
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
72
|
+
const secrets = [];
|
|
73
|
+
if (engine && typeof engine === 'object' && engine.env) {
|
|
74
|
+
for (const v of Object.values(engine.env)) {
|
|
75
|
+
if (typeof v === 'string' && v) secrets.push(v);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
|
|
79
|
+
if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
|
|
80
|
+
// Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
|
|
81
|
+
// che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
|
|
82
|
+
secrets.sort((a, b) => b.length - a.length);
|
|
83
|
+
let out = text;
|
|
84
|
+
for (const s of secrets) out = out.split(s).join(REDACTED); // replace globale, regex-free
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
|
|
89
|
+
// cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
|
|
90
|
+
function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
execFile(tmuxBin, args, { env, timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
93
|
+
const code = err && typeof err.code === 'number' ? err.code : (err ? 1 : 0);
|
|
94
|
+
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
100
|
+
|
|
101
|
+
// Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
|
|
102
|
+
// rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
|
|
103
|
+
// iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
|
|
104
|
+
function promptCharsOk(prompt) {
|
|
105
|
+
if (typeof prompt !== 'string') return false;
|
|
106
|
+
for (let i = 0; i < prompt.length; i += 1) {
|
|
107
|
+
const c = prompt.charCodeAt(i);
|
|
108
|
+
if (c === 9 || c === 10 || c === 13) continue; // \t \n \r ammessi
|
|
109
|
+
if (c < 32 || c === 127) return false; // ESC/null/altri control
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
|
|
116
|
+
// PURA + testabile. No shell: command e args sono argv diretto. model/prompt sono
|
|
117
|
+
// aggiunti solo se c'e' un VALORE effettivo (override cella || default engine).
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
|
|
120
|
+
const args = ['new-session', '-d', '-s', tmuxSession, '-c', realCwd];
|
|
121
|
+
// engine.env -> env di sessione via -e (chiavi gia' validate, no loader-key)
|
|
122
|
+
for (const [k, v] of Object.entries(engine.env || {})) args.push('-e', `${k}=${v}`);
|
|
123
|
+
// command + args: argv diretto (tmux exec, NON sh -c)
|
|
124
|
+
args.push(engine.command, ...(engine.args || []));
|
|
125
|
+
// model: flag + (override cella || valore engine), solo se c'e' un valore
|
|
126
|
+
if (engine.model) {
|
|
127
|
+
const val = (cell.model != null && cell.model !== '') ? cell.model : engine.model.value;
|
|
128
|
+
if (val) args.push(engine.model.flag, val);
|
|
129
|
+
}
|
|
130
|
+
// prompt flag-mode: promptFlag + prompt cella, solo se c'e' un prompt effettivo.
|
|
131
|
+
// SICUREZZA (design §9h): promptMode 'flag' mette il prompt in ARGV -> e' visibile
|
|
132
|
+
// nella process list (ps) / argv della sessione, a differenza di 'send-keys' che lo
|
|
133
|
+
// inietta DOPO via bracketed paste. Va quindi vincolato a prompt NON-segreti.
|
|
134
|
+
if (engine.promptMode === 'flag' && cell.prompt) {
|
|
135
|
+
args.push(engine.promptFlag, cell.prompt);
|
|
136
|
+
}
|
|
137
|
+
return args;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
|
|
141
|
+
// e' viva entro la deadline, false altrimenti (command uscito / mai partita).
|
|
142
|
+
async function waitAlive(tmuxBin, session, { env, readyMs }) {
|
|
143
|
+
const deadline = Date.now() + Math.max(0, readyMs | 0);
|
|
144
|
+
for (;;) {
|
|
145
|
+
const r = await tmuxExec(tmuxBin, ['has-session', '-t', `=${session}`], { env, timeoutMs: 2000 });
|
|
146
|
+
if (!r.err) return true;
|
|
147
|
+
if (Date.now() >= deadline) return false;
|
|
148
|
+
await sleep(60);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
|
|
153
|
+
// load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
|
|
154
|
+
// poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
|
|
155
|
+
// (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
|
|
156
|
+
async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
|
|
157
|
+
if (!promptCharsOk(prompt)) {
|
|
158
|
+
return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
|
|
159
|
+
}
|
|
160
|
+
let tmp = null;
|
|
161
|
+
try {
|
|
162
|
+
tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
|
|
163
|
+
fs.writeFileSync(tmp, prompt, { mode: 0o600 });
|
|
164
|
+
fs.chmodSync(tmp, 0o600);
|
|
165
|
+
|
|
166
|
+
const alive = await waitAlive(tmuxBin, session, { env, readyMs });
|
|
167
|
+
if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
|
|
168
|
+
|
|
169
|
+
// Target esatto: pane id (%N) se disponibile, altrimenti '=sessione' (match
|
|
170
|
+
// esatto, mai prefix-match) — audit impl #5.
|
|
171
|
+
const to = target || `=${session}`;
|
|
172
|
+
await tmuxExec(tmuxBin, ['load-buffer', '-b', 'ncsend', tmp], { env });
|
|
173
|
+
const paste = await tmuxExec(tmuxBin, ['paste-buffer', '-p', '-t', to, '-b', 'ncsend'], { env });
|
|
174
|
+
if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
|
|
175
|
+
return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
|
|
176
|
+
} finally {
|
|
177
|
+
try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
|
|
178
|
+
try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Copia deep delle definizioni per il draft di mutazione (round-trip sicuro su disco).
|
|
183
|
+
function draftFrom(defs) {
|
|
184
|
+
return {
|
|
185
|
+
schemaVersion: defs.schemaVersion,
|
|
186
|
+
engines: defs.engines.map((e) => ({ ...e })),
|
|
187
|
+
cells: defs.cells.map((c) => ({ ...c })),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
|
|
193
|
+
// cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
|
|
194
|
+
// sendKeysReadyMs?, fleetProviderReason? }
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
async function createBuiltinFleet(cfg = {}) {
|
|
197
|
+
const off = {
|
|
198
|
+
available: false, provider: 'builtin',
|
|
199
|
+
isCellSession: () => false, capabilities: () => [],
|
|
200
|
+
};
|
|
201
|
+
if (cfg.builtinEnabled === false) return off;
|
|
202
|
+
|
|
203
|
+
const home = cfg.home || os.homedir();
|
|
204
|
+
const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
|
|
205
|
+
const tmuxBin = cfg.tmuxBin || process.env.TMUX_BIN || 'tmux';
|
|
206
|
+
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
207
|
+
|
|
208
|
+
// Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
|
|
209
|
+
const boot = loadDefinitions(defsPath);
|
|
210
|
+
if (!boot) return off;
|
|
211
|
+
let cache = { at: 0, defs: boot, sessions: new Set() };
|
|
212
|
+
|
|
213
|
+
function reloadDefs() {
|
|
214
|
+
const d = loadDefinitions(defsPath);
|
|
215
|
+
if (d) cache = { ...cache, at: 0, defs: d };
|
|
216
|
+
return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
|
|
217
|
+
}
|
|
218
|
+
function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
|
|
219
|
+
function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
|
|
220
|
+
|
|
221
|
+
async function refreshSessions() {
|
|
222
|
+
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { env: minimalEnv() });
|
|
223
|
+
if (r.err) return new Set(); // nessun server / nessuna sessione
|
|
224
|
+
const set = new Set();
|
|
225
|
+
for (const line of r.stdout.split('\n')) {
|
|
226
|
+
const n = line.trim();
|
|
227
|
+
if (n) set.add(n);
|
|
228
|
+
}
|
|
229
|
+
return set;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function status() {
|
|
233
|
+
if (Date.now() - cache.at > STATUS_TTL_MS) {
|
|
234
|
+
reloadDefs(); // pick-up di edit esterne/file
|
|
235
|
+
const sessions = await refreshSessions();
|
|
236
|
+
cache = { at: Date.now(), defs: cache.defs, sessions };
|
|
237
|
+
}
|
|
238
|
+
const sessions = cache.sessions;
|
|
239
|
+
const cells = cache.defs.cells.map((c) => {
|
|
240
|
+
const alive = sessions.has(c.tmuxSession);
|
|
241
|
+
return {
|
|
242
|
+
cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
|
|
243
|
+
active: alive, boot: c.boot, tmux: alive,
|
|
244
|
+
rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
const engines = cache.defs.engines.map((e) => ({ id: e.id, label: e.label, rc: !!e.rc }));
|
|
248
|
+
return {
|
|
249
|
+
available: true,
|
|
250
|
+
provider: 'builtin',
|
|
251
|
+
bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
|
|
252
|
+
reason: cfg.fleetProviderReason || 'fleet.json definitions',
|
|
253
|
+
cells, engines,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isCellSession(name) {
|
|
258
|
+
return cache.defs.cells.some((c) => c.tmuxSession === String(name));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// up — ordine obbligatorio (design §9a, task B4.2).
|
|
262
|
+
// Le override {engine,boot} del contratto route sono ignorate: il builtin e'
|
|
263
|
+
// definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
|
|
264
|
+
// stato persistente gestito da boot()). Lancia SENZA shell.
|
|
265
|
+
async function up(cellId /* , { engine, boot } = {} */) {
|
|
266
|
+
if (readonly()) throw httpError(403, 'READONLY: up bloccato');
|
|
267
|
+
const defs = reloadDefs();
|
|
268
|
+
const cell = findCell(defs, cellId);
|
|
269
|
+
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
270
|
+
const engine = findEngine(defs, cell.engine);
|
|
271
|
+
if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
|
|
272
|
+
|
|
273
|
+
// (2) trust del command PRIMA di lanciare
|
|
274
|
+
const trust = validateCommandTrust(engine.command);
|
|
275
|
+
if (!trust.ok) throw httpError(400, `command non trusted (${engine.command}): ${trust.reason}`);
|
|
276
|
+
// (3) cwd reale sotto la home
|
|
277
|
+
const realCwd = resolveCwd(cell.cwd, home);
|
|
278
|
+
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
279
|
+
|
|
280
|
+
// (4)+(5) argv diretto (no shell), env minimale + engine.env via -e.
|
|
281
|
+
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
282
|
+
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
283
|
+
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
284
|
+
const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine, cell });
|
|
285
|
+
argv.splice(2, 0, '-P', '-F', '#{pane_id}');
|
|
286
|
+
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
287
|
+
if (launch.err) {
|
|
288
|
+
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
289
|
+
const why = /duplicate session/i.test(launch.stderr)
|
|
290
|
+
? 'sessione già in esecuzione'
|
|
291
|
+
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, engine, cell)}`;
|
|
292
|
+
throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
296
|
+
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
297
|
+
let prompt = null;
|
|
298
|
+
if (engine.promptMode === 'send-keys' && cell.prompt) {
|
|
299
|
+
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
300
|
+
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
301
|
+
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
302
|
+
env: minimalEnv(),
|
|
303
|
+
readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : 400,
|
|
304
|
+
target,
|
|
305
|
+
engine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
|
|
309
|
+
return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function down(cellId /* , opts */) {
|
|
313
|
+
if (readonly()) throw httpError(403, 'READONLY: down bloccato');
|
|
314
|
+
const defs = reloadDefs();
|
|
315
|
+
const cell = findCell(defs, cellId);
|
|
316
|
+
if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
317
|
+
const engine = findEngine(defs, cell.engine) || {};
|
|
318
|
+
const r = await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv() });
|
|
319
|
+
if (r.err && !/no server running|can't find session|not found/i.test(r.stderr)) {
|
|
320
|
+
throw httpError(500, `tmux kill-session failed: ${redactSecrets(r.stderr.trim(), engine, cell)}`);
|
|
321
|
+
}
|
|
322
|
+
cache = { ...cache, at: 0 };
|
|
323
|
+
return { ok: true, killed: !r.err };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// restart = down (riusa la kill esistente; sessione non viva NON e' errore,
|
|
327
|
+
// come down) seguito da up (rilancia secondo la definizione corrente).
|
|
328
|
+
// Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
|
|
329
|
+
async function restart(cellId) {
|
|
330
|
+
if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
|
|
331
|
+
await down(cellId); // idempotente: cella non viva -> nessun errore
|
|
332
|
+
return up(cellId);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function setEngine(cellId, engId) {
|
|
336
|
+
if (readonly()) throw httpError(403, 'READONLY: engine bloccato');
|
|
337
|
+
const defs = reloadDefs();
|
|
338
|
+
if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
339
|
+
if (!findEngine(defs, engId)) throw httpError(400, `engine non valido: ${engId}`);
|
|
340
|
+
await mutate(defs, (d) => { findCell(d, cellId).engine = engId; });
|
|
341
|
+
return { ok: true };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function setBoot(cellId, enabled) {
|
|
345
|
+
if (readonly()) throw httpError(403, 'READONLY: boot bloccato');
|
|
346
|
+
const defs = reloadDefs();
|
|
347
|
+
if (!findCell(defs, cellId)) throw httpError(400, `cella sconosciuta: ${cellId}`);
|
|
348
|
+
await mutate(defs, (d) => { findCell(d, cellId).boot = !!enabled; });
|
|
349
|
+
return { ok: true };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- define / edit / remove (engine e cell) ---
|
|
353
|
+
// atomicWrite valida PRIMA di scrivere (fail-closed); input invalido -> backup
|
|
354
|
+
// predecessore + throw -> httpError(400). MAI garbage su disco.
|
|
355
|
+
async function defineEngine(def) {
|
|
356
|
+
if (readonly()) throw httpError(403, 'READONLY: define-engine bloccato');
|
|
357
|
+
if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione engine mancante');
|
|
358
|
+
if (def.id != null && findEngine(reloadDefs(), def.id)) throw httpError(400, `engine esiste già: ${def.id}`);
|
|
359
|
+
await mutate(reloadDefs(), (d) => { d.engines.push(def); });
|
|
360
|
+
return { ok: true, id: def.id };
|
|
361
|
+
}
|
|
362
|
+
async function editEngine(id, patch) {
|
|
363
|
+
if (readonly()) throw httpError(403, 'READONLY: edit-engine bloccato');
|
|
364
|
+
if (!id) throw httpError(400, 'id engine mancante');
|
|
365
|
+
const defs = reloadDefs();
|
|
366
|
+
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
367
|
+
await mutate(defs, (d) => { Object.assign(findEngine(d, id), patch || {}); });
|
|
368
|
+
return { ok: true };
|
|
369
|
+
}
|
|
370
|
+
async function removeEngine(id) {
|
|
371
|
+
if (readonly()) throw httpError(403, 'READONLY: remove-engine bloccato');
|
|
372
|
+
const defs = reloadDefs();
|
|
373
|
+
if (!findEngine(defs, id)) throw httpError(400, `engine inesistente: ${id}`);
|
|
374
|
+
const used = defs.cells.filter((c) => c.engine === id);
|
|
375
|
+
if (used.length) throw httpError(400, `engine in uso da ${used.length} cella/e: rimuovi prima la cella`);
|
|
376
|
+
await mutate(defs, (d) => { d.engines = d.engines.filter((e) => e.id !== id); });
|
|
377
|
+
return { ok: true };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function defineCell(def) {
|
|
381
|
+
if (readonly()) throw httpError(403, 'READONLY: define-cell bloccato');
|
|
382
|
+
if (!def || typeof def !== 'object' || Array.isArray(def)) throw httpError(400, 'definizione cell mancante');
|
|
383
|
+
if (def.id != null && findCell(reloadDefs(), def.id)) throw httpError(400, `cell esiste già: ${def.id}`);
|
|
384
|
+
await mutate(reloadDefs(), (d) => { d.cells.push(def); });
|
|
385
|
+
return { ok: true, id: def.id };
|
|
386
|
+
}
|
|
387
|
+
async function editCell(id, patch) {
|
|
388
|
+
if (readonly()) throw httpError(403, 'READONLY: edit-cell bloccato');
|
|
389
|
+
if (!id) throw httpError(400, 'id cell mancante');
|
|
390
|
+
const defs = reloadDefs();
|
|
391
|
+
if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
|
|
392
|
+
await mutate(defs, (d) => { Object.assign(findCell(d, id), patch || {}); });
|
|
393
|
+
return { ok: true };
|
|
394
|
+
}
|
|
395
|
+
async function removeCell(id) {
|
|
396
|
+
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
397
|
+
const defs = reloadDefs();
|
|
398
|
+
if (!findCell(defs, id)) throw httpError(400, `cell inesistente: ${id}`);
|
|
399
|
+
await mutate(defs, (d) => { d.cells = d.cells.filter((c) => c.id !== id); });
|
|
400
|
+
return { ok: true };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
404
|
+
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
405
|
+
async function mutate(defs, mutator) {
|
|
406
|
+
const draft = draftFrom(defs);
|
|
407
|
+
mutator(draft);
|
|
408
|
+
let parsed;
|
|
409
|
+
try {
|
|
410
|
+
parsed = atomicWrite(defsPath, draft);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
throw httpError(400, `definizioni non valide: ${e.message}`);
|
|
413
|
+
}
|
|
414
|
+
cache = { ...cache, at: 0, defs: parsed };
|
|
415
|
+
return parsed;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// schema() — descrittore campi per editor UI schema-driven (design §5/§9f).
|
|
419
|
+
function schema() {
|
|
420
|
+
return {
|
|
421
|
+
schemaVersion: 1,
|
|
422
|
+
caps: CAPS,
|
|
423
|
+
engine: {
|
|
424
|
+
id: { type: 'string', required: true, pattern: '^[a-z0-9._-]{1,32}$', max: 32 },
|
|
425
|
+
label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN, default: '<id>' },
|
|
426
|
+
rc: { type: 'boolean', required: false, default: false },
|
|
427
|
+
command: { type: 'string', required: true, max: CAPS.MAX_COMMAND_LEN, absolute: true },
|
|
428
|
+
args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
|
|
429
|
+
env: {
|
|
430
|
+
type: 'object', required: false, maxKeys: CAPS.MAX_ENV_KEYS,
|
|
431
|
+
keyPattern: '^[A-Za-z_][A-Za-z0-9_]*$', keyMax: CAPS.MAX_ENV_KEY_LEN,
|
|
432
|
+
valueMax: CAPS.MAX_ENV_VAL_LEN,
|
|
433
|
+
denylist: ['PATH', 'SHELL', 'HOME', 'NODE_OPTIONS', 'LD_*', 'DYLD_*', 'NPM_CONFIG_*'],
|
|
434
|
+
},
|
|
435
|
+
model: {
|
|
436
|
+
type: 'object', required: false,
|
|
437
|
+
fields: {
|
|
438
|
+
flag: { type: 'string', required: true, singleArgv: true, max: CAPS.MAX_MODEL_FLAG_LEN },
|
|
439
|
+
value: { type: 'string', required: false, default: '', max: CAPS.MAX_MODEL_VAL_LEN },
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
promptMode: { type: 'enum', required: true, values: ['flag', 'send-keys'] },
|
|
443
|
+
promptFlag: { type: 'string', requiredIf: 'promptMode=flag', singleArgv: true, max: CAPS.MAX_PROMPTFLAG_LEN },
|
|
444
|
+
},
|
|
445
|
+
cell: {
|
|
446
|
+
id: { type: 'string', required: true, pattern: '^[A-Za-z0-9._-]{1,32}$', max: 32 },
|
|
447
|
+
cwd: { type: 'string', required: true, max: CAPS.MAX_CWD_LEN, underHome: true },
|
|
448
|
+
engine: { type: 'string', required: true, ref: 'engine.id' },
|
|
449
|
+
boot: { type: 'boolean', required: false, default: false },
|
|
450
|
+
model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
|
|
451
|
+
prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function capabilities() {
|
|
457
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'schema'];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
available: true,
|
|
462
|
+
provider: 'builtin',
|
|
463
|
+
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
464
|
+
defineEngine, editEngine, removeEngine,
|
|
465
|
+
defineCell, editCell, removeCell,
|
|
466
|
+
schema, capabilities,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = {
|
|
471
|
+
createBuiltinFleet,
|
|
472
|
+
composeLaunchArgv,
|
|
473
|
+
minimalEnv,
|
|
474
|
+
promptCharsOk,
|
|
475
|
+
redactSecrets,
|
|
476
|
+
MINIMAL_ENV_KEYS,
|
|
477
|
+
};
|