@mmmbuto/nexuscrew 0.8.16 → 0.8.18

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.
@@ -1,229 +1,53 @@
1
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).
2
+ // B4.2 — Fleet built-in. FACADE: implementa lo STESSO contratto di
3
+ // lib/fleet/index.js (createFleet: available / status / up / down / engine /
4
+ // boot / isCellSession) leggendo le definizioni da ~/.nexuscrew/fleet.json
5
+ // tramite lib/fleet/definitions.js, e lo ESTENDE con define*/edit*/remove* +
6
+ // schema + capabilities (design §4b/§9c).
6
7
  //
7
- // Agnostico: non conosce "claude"/"glm"/"codex" — lancia solo command+args dichiarati.
8
+ // La responsabilita' runtime/launch (status/up/down/restart + launch/readiness
9
+ // helpers) e' stata estratta in modo behavior-preserving in:
10
+ // - lib/fleet/launch.js toolkit stateless (argv/env/readiness/redaction)
11
+ // - lib/fleet/runtime.js createBuiltinRuntime: cache + status/up/down/restart
12
+ // Qui resta il bootstrap del cfg, l'istanziazione del runtime e il CRUD
13
+ // (define/edit/remove, engine/boot, import, restore, schema, definitions,
14
+ // credentials) che collabora con gli accessor del runtime. Il facade re-esporta
15
+ // i simboli di launch.js per i test che li importano da builtin.js.
8
16
  //
9
- // Sicurezza (design §6 / §9a / §9d / §9e):
17
+ // Agnostico: non conosce "claude"/"glm"/"codex" lancia solo command+args
18
+ // dichiarati.
19
+ //
20
+ // Sicurezza (design §6 / §9a / §9d / §9e) — invariata, vedi launch.js/runtime.js:
10
21
  // - up esegue validateCommandTrust(command) e resolveCwd(cwd) PRIMA di lanciare;
11
22
  // 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 il client solo tramite un broker AF_UNIX privato e monouso. Nessun
17
- // valore passa in argv, `tmux -e`, file temporanei o ambiente globale tmux.
18
- // PATH lo controlla il service, mai la definizione.
23
+ // - command/args/env NON passano per una shell: execFile + argv diretto.
24
+ // - env: il builtin lancia con un env MINIMALE controllato dal service; engine.env
25
+ // raggiunge il client solo tramite un broker AF_UNIX privato e monouso.
19
26
  // - READONLY (cfg.readonlyDefault===true | NEXUSCREW_READONLY=1) blocca ogni
20
27
  // mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
21
- // - promptMode 'send-keys' inietta via `tmux load-buffer` + `paste-buffer -p`
22
- // (bracketed paste), NON send-keys grezzo; se il command e' gia' uscito
23
- // (sessione morta) NON digita (§9e).
24
- const fs = require('node:fs');
28
+ // - promptMode 'send-keys' inietta via bracketed paste; se il command e' gia'
29
+ // uscito (sessione morta) NON digita (§9e).
25
30
  const os = require('node:os');
26
31
  const path = require('node:path');
27
- const { execFile } = require('node:child_process');
28
32
  const {
29
- parseDefinitions, validateCommandTrust, resolveCwd,
30
33
  loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
31
34
  } = require('./definitions.js');
32
35
  const {
33
- publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
36
+ publicCatalog, describeManaged,
34
37
  } = require('./managed.js');
35
38
  const { validEnvKey } = require('./env-key.js');
36
39
  const { setCredential, removeCredential } = require('./credentials.js');
37
40
  const { createLaunchBroker } = require('./launch-broker.js');
38
- const { MINIMAL_ENV_KEYS, minimalRuntimeEnv } = require('../runtime/env.js');
39
-
40
- const STATUS_TTL_MS = 2000;
41
-
42
- // Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
43
- // non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
44
- // qui NON passiamo MAI l'env del processo per intero. engine.env viene consegnato
45
- // direttamente al processo figlio dal broker, senza entrare nello stato tmux.
46
- // Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
47
- // ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
48
- // iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
49
- function minimalEnv() {
50
- return minimalRuntimeEnv(process.env, { home: os.homedir() });
51
- }
52
-
53
- function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
54
-
55
- // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
56
- // NON devono mai ecoare i segreti delle definizioni.
57
- const REDACTED = '‹redacted›';
58
-
59
- // redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
60
- // delle definizioni sostituita da '‹redacted›'. Segreti coperti (§9h):
61
- // - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
62
- // - testo del prompt della cella (cell.prompt)
63
- // - testo del prompt dell'engine (engine.prompt) se presente
64
- // Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
65
- // tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
66
- // lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
67
- function redactSecrets(text, engine, cell) {
68
- if (typeof text !== 'string' || text === '') return text;
69
- const secrets = [];
70
- if (engine && typeof engine === 'object' && engine.env) {
71
- for (const v of Object.values(engine.env)) {
72
- if (typeof v === 'string' && v) secrets.push(v);
73
- }
74
- }
75
- if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
76
- if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
77
- // Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
78
- // che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
79
- secrets.sort((a, b) => b.length - a.length);
80
- let out = text;
81
- for (const s of secrets) out = out.split(s).join(REDACTED); // replace globale, regex-free
82
- return out;
83
- }
84
-
85
- const MAX_EARLY_DIAGNOSTIC = 1200;
86
-
87
- function sanitizeEarlyDiagnostic(text, engine, cell, home) {
88
- let out = redactSecrets(String(text || ''), engine, cell);
89
- // ANSI CSI/OSC e byte di controllo non devono arrivare nell'errore JSON/UI.
90
- out = out.replace(/\x1b\][^\x07]*(?:\x07|$)/g, '')
91
- .replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
92
- let clean = '';
93
- for (let i = 0; i < out.length; i += 1) {
94
- const code = out.charCodeAt(i);
95
- if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) clean += out[i];
96
- }
97
- out = clean;
98
- if (typeof home === 'string' && home) out = out.split(home).join('~');
99
- out = out
100
- .replace(/\bBearer\s+\S+/gi, `Bearer ${REDACTED}`)
101
- .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z0-9_]*)(\s*[:=]\s*)\S+/g,
102
- (_m, key, sep) => `${key}${sep}${REDACTED}`)
103
- .replace(/\b(?:sk|fw|fpk|hf|zai)-[A-Za-z0-9._-]{8,}\b/gi, REDACTED);
104
- const lines = out.split(/\r?\n/).map((line) => line.trimEnd())
105
- .filter((line) => line.trim() && !/^Pane is dead \(status /i.test(line.trim()));
106
- out = lines.join('\n').trim();
107
- if (out.length > MAX_EARLY_DIAGNOSTIC) out = `…${out.slice(-(MAX_EARLY_DIAGNOSTIC - 1))}`;
108
- return out;
109
- }
110
-
111
- // Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
112
- // cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
113
- function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
114
- return new Promise((resolve) => {
115
- execFile(tmuxBin, args, { env, timeout: timeoutMs }, (err, stdout, stderr) => {
116
- const code = err && typeof err.code === 'number' ? err.code : (err ? 1 : 0);
117
- resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code });
118
- });
119
- });
120
- }
121
-
122
- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
123
-
124
- // Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
125
- // rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
126
- // iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
127
- function promptCharsOk(prompt) {
128
- if (typeof prompt !== 'string') return false;
129
- for (let i = 0; i < prompt.length; i += 1) {
130
- const c = prompt.charCodeAt(i);
131
- if (c === 9 || c === 10 || c === 13) continue; // \t \n \r ammessi
132
- if (c < 32 || c === 127) return false; // ESC/null/altri control
133
- }
134
- return true;
135
- }
136
-
137
- // ---------------------------------------------------------------------------
138
- // Build the direct child invocation separately from tmux. This lets the secure
139
- // launch broker carry the complete child argv and environment in memory while
140
- // tmux receives only the broker helper path and a one-time nonce.
141
- // ---------------------------------------------------------------------------
142
- function composeClientInvocation(engine, cell) {
143
- const args = [...(engine.args || [])];
144
- // model: flag + (override cella || valore engine), solo se c'e' un valore
145
- if (engine.model) {
146
- const val = (cell.model != null && cell.model !== '') ? cell.model : engine.model.value;
147
- if (val) args.push(engine.model.flag, val);
148
- }
149
- // prompt flag-mode: promptFlag + prompt cella, solo se c'e' un prompt effettivo.
150
- // SICUREZZA (design §9h): promptMode 'flag' mette il prompt in ARGV -> e' visibile
151
- // nella process list (ps) / argv della sessione, a differenza di 'send-keys' che lo
152
- // inietta DOPO via bracketed paste. Va quindi vincolato a prompt NON-segreti.
153
- if (engine.promptMode === 'flag' && cell.prompt) {
154
- args.push(engine.promptFlag, cell.prompt);
155
- }
156
- return { command: engine.command, args };
157
- }
158
-
159
- // composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
160
- // PURA + testabile. Provider values are deliberately absent: no `tmux -e`, no
161
- // environment value and no broker payload ever appears in the tmux client argv.
162
- function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
163
- const child = composeClientInvocation(engine, cell);
164
- return ['new-session', '-d', '-s', tmuxSession, '-c', realCwd, child.command, ...child.args];
165
- }
166
-
167
- // Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
168
- // e' viva entro la deadline, false altrimenti (command uscito / mai partita).
169
- async function waitAlive(tmuxBin, session, { env, readyMs }) {
170
- const deadline = Date.now() + Math.max(0, readyMs | 0);
171
- for (;;) {
172
- const r = await tmuxExec(tmuxBin, ['has-session', '-t', `=${session}`], { env, timeoutMs: 2000 });
173
- if (!r.err) return true;
174
- if (Date.now() >= deadline) return false;
175
- await sleep(60);
176
- }
177
- }
178
-
179
- async function waitStablePane(tmuxBin, target, { env, readyMs }) {
180
- const deadline = Date.now() + Math.max(0, readyMs | 0);
181
- for (;;) {
182
- const state = await tmuxExec(tmuxBin,
183
- ['display-message', '-p', '-t', target, '#{pane_dead}\t#{pane_dead_status}\t#{pane_id}'],
184
- { env, timeoutMs: 2000 });
185
- if (state.err) return { alive: false, status: null, target: null };
186
- const [dead, rawStatus, paneId] = state.stdout.trim().split('\t');
187
- if (!/^%[0-9]+$/.test(paneId || '')) return { alive: false, status: null, target: null };
188
- if (dead === '1') {
189
- const status = /^-?[0-9]+$/.test(rawStatus || '') ? Number(rawStatus) : null;
190
- return { alive: false, status, target: paneId };
191
- }
192
- if (dead !== '0') return { alive: false, status: null, target: null };
193
- if (Date.now() >= deadline) return { alive: true, status: null, target: paneId };
194
- await sleep(60);
195
- }
196
- }
197
-
198
- // Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
199
- // load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
200
- // poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
201
- // (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
202
- async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
203
- if (!promptCharsOk(prompt)) {
204
- return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
205
- }
206
- let tmp = null;
207
- try {
208
- tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
209
- fs.writeFileSync(tmp, prompt, { mode: 0o600 });
210
- fs.chmodSync(tmp, 0o600);
41
+ const { MINIMAL_ENV_KEYS } = require('../runtime/env.js');
211
42
 
212
- const alive = await waitAlive(tmuxBin, session, { env, readyMs });
213
- if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
214
-
215
- // Target esatto: pane id (%N) se disponibile, altrimenti '=sessione' (match
216
- // esatto, mai prefix-match) — audit impl #5.
217
- const to = target || `=${session}`;
218
- await tmuxExec(tmuxBin, ['load-buffer', '-b', 'ncsend', tmp], { env });
219
- const paste = await tmuxExec(tmuxBin, ['paste-buffer', '-p', '-t', to, '-b', 'ncsend'], { env });
220
- if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
221
- return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
222
- } finally {
223
- try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
224
- try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
225
- }
226
- }
43
+ // Toolkit stateless + runtime estratti (behavior-preserving). I simboli di
44
+ // launch.js sono re-esportati in module.exports per i test che li importano
45
+ // da builtin.js; httpError e' usato anche dal CRUD del facade.
46
+ const { createBuiltinRuntime } = require('./runtime.js');
47
+ const {
48
+ composeLaunchArgv, composeClientInvocation, minimalEnv, promptCharsOk,
49
+ redactSecrets, sanitizeEarlyDiagnostic, waitStablePane, httpError,
50
+ } = require('./launch.js');
227
51
 
228
52
  // Copia deep delle definizioni per il draft di mutazione (round-trip sicuro su disco).
229
53
  function draftFrom(defs) {
@@ -272,7 +96,7 @@ function applyCellTransition(target, engineId, { model, hasModel, policy, hasPol
272
96
  // ---------------------------------------------------------------------------
273
97
  // createBuiltinFleet(cfg) — async per parita' di contratto con createFleet.
274
98
  // cfg: { fleetDefsPath?, tmuxBin?, home?, builtinEnabled?, readonlyDefault?,
275
- // sendKeysReadyMs?, fleetProviderReason? }
99
+ // sendKeysReadyMs?, fleetProviderReason?, launchBroker?, launchReadyMs? }
276
100
  // ---------------------------------------------------------------------------
277
101
  async function createBuiltinFleet(cfg = {}) {
278
102
  const off = {
@@ -290,219 +114,32 @@ async function createBuiltinFleet(cfg = {}) {
290
114
  // Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
291
115
  const boot = loadDefinitions(defsPath);
292
116
  if (!boot) return off;
293
- let cache = { at: 0, defs: boot, sessions: new Set() };
294
-
295
- function reloadDefs() {
296
- const d = loadDefinitions(defsPath);
297
- if (d) cache = { ...cache, at: 0, defs: d };
298
- return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
299
- }
300
- function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
301
- function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
302
-
303
- async function refreshSessions() {
304
- const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
305
- if (r.err) return new Set(); // nessun server / nessuna sessione
306
- const set = new Set();
307
- for (const line of r.stdout.split('\n')) {
308
- const [rawName, rawWindows] = line.split('\t');
309
- const n = String(rawName || '').trim();
310
- const windows = rawWindows === undefined ? 1 : Number(rawWindows);
311
- if (n && Number.isFinite(windows) && windows > 0) set.add(n);
312
- }
313
- return set;
314
- }
315
-
316
- async function status() {
317
- if (Date.now() - cache.at > STATUS_TTL_MS) {
318
- reloadDefs(); // pick-up di edit esterne/file
319
- const sessions = await refreshSessions();
320
- cache = { at: Date.now(), defs: cache.defs, sessions };
321
- }
322
- const sessions = cache.sessions;
323
- const cells = cache.defs.cells.map((c) => {
324
- const alive = sessions.has(c.tmuxSession);
325
- // Effective policy per cella: override remembered per-engine, altrimenti il
326
- // default dell'engine gestito. Esposta SOLO come policy effettiva (mai segreti).
327
- const engineDef = findEngine(cache.defs, c.engine);
328
- const engineDefault = engineDef && engineDef.managed ? engineDef.managed.permissionPolicy : '';
329
- const remembered = c.permissionPolicies
330
- && (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
331
- ? c.permissionPolicies[c.engine] : null;
332
- const effectivePolicy = engineDef?.managed?.client === 'pi'
333
- ? 'standard'
334
- : (remembered || engineDefault || '');
335
- return {
336
- cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
337
- model: c.model || '', models: { ...(c.models || {}) },
338
- permissionPolicy: effectivePolicy,
339
- permissionPolicies: { ...(c.permissionPolicies || {}) },
340
- active: alive, boot: c.boot, tmux: alive,
341
- rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
342
- };
343
- });
344
- const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
345
- const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
346
- const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
347
- const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
348
- const engines = cache.defs.engines.map((e) => {
349
- const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
350
- return {
351
- id: e.id, label: e.label, rc: !!e.rc,
352
- ...(managed ? {
353
- kind: 'managed', client: managed.client, provider: managed.provider,
354
- model: e.managed.model || managed.defaultModel || '',
355
- models: managed.provider === 'ollama-cloud' ? ollamaModels
356
- : (managed.client === 'pi'
357
- ? (e.managed.provider === 'custom'
358
- ? [e.managed.model]
359
- : (e.managed.provider === 'native'
360
- ? [...new Set(Object.values(piModels).flat())]
361
- : (piModels[e.managed.provider] || [])))
362
- : managed.models),
363
- configured: managed.configured, reason: managed.reason,
364
- } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
365
- };
366
- });
367
- return {
368
- available: true,
369
- provider: 'builtin',
370
- bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
371
- reason: cfg.fleetProviderReason || 'fleet.json definitions',
372
- cells, engines,
373
- };
374
- }
375
-
376
- function isCellSession(name) {
377
- return cache.defs.cells.some((c) => c.tmuxSession === String(name));
378
- }
379
-
380
- // up — ordine obbligatorio (design §9a, task B4.2).
381
- // Le override {engine,boot} del contratto route sono ignorate: il builtin e'
382
- // definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
383
- // stato persistente gestito da boot()). Lancia SENZA shell.
384
- async function up(cellId /* , { engine, boot } = {} */) {
385
- if (readonly()) throw httpError(403, 'READONLY: up bloccato');
386
- const defs = reloadDefs();
387
- const cell = findCell(defs, cellId);
388
- if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
389
- const engine = findEngine(defs, cell.engine);
390
- if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
391
- let launchEngine = engine;
392
- if (engine.managed) {
393
- const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
394
- if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
395
- launchEngine = resolved.engine;
396
- }
397
-
398
- // (2) trust del command PRIMA di lanciare
399
- const trust = validateCommandTrust(launchEngine.command);
400
- if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
401
- // (3) cwd reale sotto la home
402
- const realCwd = resolveCwd(cell.cwd, home);
403
- if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
404
-
405
- // (4)+(5) argv diretto (no shell). Environment-bearing launches go
406
- // through a private, one-shot Unix-socket broker so credentials never
407
- // appear in `ps`, tmux argv, tmux global/session env or a temporary file.
408
- // '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
409
- // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
410
- // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
411
- let tmuxLaunchEngine = launchEngine;
412
- if (Object.keys(launchEngine.env || {}).length) {
413
- const child = composeClientInvocation(launchEngine, cell);
414
- const ticket = await launchBroker.issue({
415
- command: child.command,
416
- args: child.args,
417
- env: { ...minimalEnv(), ...launchEngine.env },
418
- });
419
- tmuxLaunchEngine = {
420
- command: process.execPath,
421
- args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
422
- env: {}, promptMode: 'managed-argv',
423
- };
424
- }
425
- const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
426
- argv.splice(2, 0, '-P', '-F', '#{pane_id}');
427
- // Mantieni il pane morto solo durante la finestra di readiness: permette di
428
- // catturare un errore reale del client senza lasciare una sessione fantasma.
429
- // Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
430
- argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
431
- const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
432
- if (launch.err) {
433
- // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
434
- const why = /duplicate session/i.test(launch.stderr)
435
- ? 'sessione già in esecuzione'
436
- : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
437
- throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
438
- }
439
-
440
- // `tmux new-session -d` can return 0 even when the launched CLI exits a
441
- // moment later (missing login, bad model, incompatible provider). Without
442
- // this readiness gate the PWA reported success and then showed nothing.
443
- // Always verify liveness, including cells without a system prompt.
444
- const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
445
- const paneId = launch.stdout.trim().split('\n')[0] || '';
446
- const readiness = paneId.startsWith('%')
447
- ? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
448
- : { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
449
- if (!readiness.alive) {
450
- let diagnostic = '';
451
- if (readiness.target) {
452
- const captured = await tmuxExec(tmuxBin,
453
- ['capture-pane', '-p', '-S', '-80', '-t', readiness.target], { env: minimalEnv(), timeoutMs: 2000 });
454
- if (!captured.err) diagnostic = sanitizeEarlyDiagnostic(captured.stdout, launchEngine, cell, home);
455
- }
456
- // remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
457
- // nella Fleet o nella lista tmux dopo aver raccolto l'errore.
458
- await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
459
- cache = { ...cache, at: 0 };
460
- const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
461
- const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
462
- throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
463
- }
464
- if (readiness.target) {
465
- await tmuxExec(tmuxBin,
466
- ['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
467
- }
468
117
 
469
- // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
470
- // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
471
- let prompt = null;
472
- if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
473
- const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
474
- prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
475
- env: minimalEnv(),
476
- readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
477
- target,
478
- engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
479
- });
480
- }
481
- cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
482
- return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
483
- }
118
+ // Runtime estratto (lib/fleet/runtime.js): possiede cache + definizioni e
119
+ // espone status/up/down/restart/isCellSession + gli accessor allo store
120
+ // (reloadDefs/findCell/findEngine/refreshSessions/commitDefs) che il CRUD
121
+ // qui sotto riusa. status/up/down/restart sono INVARIATI.
122
+ const rt = createBuiltinRuntime({
123
+ cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot,
124
+ });
125
+ const {
126
+ status, up, down, restart, isCellSession,
127
+ reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
128
+ } = rt;
484
129
 
485
- async function down(cellId /* , opts */) {
486
- if (readonly()) throw httpError(403, 'READONLY: down bloccato');
487
- const defs = reloadDefs();
488
- const cell = findCell(defs, cellId);
489
- if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
490
- const engine = findEngine(defs, cell.engine) || {};
491
- const r = await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv() });
492
- if (r.err && !/no server running|can't find session|not found/i.test(r.stderr)) {
493
- throw httpError(500, `tmux kill-session failed: ${redactSecrets(r.stderr.trim(), engine, cell)}`);
130
+ // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
131
+ // invalido: backup predecessore + throw -> httpError(400) (mai garbage).
132
+ async function mutate(defs, mutator) {
133
+ const draft = draftFrom(defs);
134
+ mutator(draft);
135
+ let parsed;
136
+ try {
137
+ parsed = atomicWrite(defsPath, draft);
138
+ } catch (e) {
139
+ throw httpError(400, `definizioni non valide: ${e.message}`);
494
140
  }
495
- cache = { ...cache, at: 0 };
496
- return { ok: true, killed: !r.err };
497
- }
498
-
499
- // restart = down (riusa la kill esistente; sessione non viva NON e' errore,
500
- // come down) seguito da up (rilancia secondo la definizione corrente).
501
- // Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
502
- async function restart(cellId) {
503
- if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
504
- await down(cellId); // idempotente: cella non viva -> nessun errore
505
- return up(cellId);
141
+ commitDefs(parsed);
142
+ return parsed;
506
143
  }
507
144
 
508
145
  async function setEngine(cellId, engId, opts = {}) {
@@ -897,21 +534,6 @@ async function createBuiltinFleet(cfg = {}) {
897
534
  return credentialStatus();
898
535
  }
899
536
 
900
- // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
901
- // invalido: backup predecessore + throw -> httpError(400) (mai garbage).
902
- async function mutate(defs, mutator) {
903
- const draft = draftFrom(defs);
904
- mutator(draft);
905
- let parsed;
906
- try {
907
- parsed = atomicWrite(defsPath, draft);
908
- } catch (e) {
909
- throw httpError(400, `definizioni non valide: ${e.message}`);
910
- }
911
- cache = { ...cache, at: 0, defs: parsed };
912
- return parsed;
913
- }
914
-
915
537
  // schema() — descrittore campi per editor UI schema-driven (design §5/§9f).
916
538
  function schema() {
917
539
  return {