@mmmbuto/nexuscrew 0.8.15 → 0.8.17

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.
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+ // Launch + readiness toolkit del fleet built-in (estratto da builtin.js in
3
+ // modo behavior-preserving). Tutto cio' che sta qui e' STATELESS: ogni
4
+ // funzione riceve le proprie dipendenze come argomento e non tocca lo stato
5
+ // del fleet. createBuiltinRuntime() in runtime.js ne fa uso; builtin.js e'
6
+ // ora un facade che re-esporta questi simboli per i test.
7
+ //
8
+ // Sicurezza (design §9a/§9e/§9h) — invariata rispetto a builtin.js:
9
+ // - command/args/env NON passano per una shell: execFile + argv diretto
10
+ // (tmux fa exec del comando, NON sh -c — verificato: ';','|','$' passano
11
+ // verbatim). Nessun valore passa in argv, `tmux -e`, file temporanei o
12
+ // ambiente globale tmux. PATH lo controlla il service, mai la definizione.
13
+ // - env minimale controllato dal service (allowlist dura); le definizioni non
14
+ // possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env).
15
+ // - promptMode 'send-keys' inietta via `tmux load-buffer` + `paste-buffer -p`
16
+ // (bracketed paste), NON send-keys grezzo; se il command e' gia' uscito
17
+ // (sessione morta) NON digita (§9e).
18
+ // - redactSecrets/sanitizeEarlyDiagnostic (§9h): stderr/stdout dei comandi
19
+ // tmux falliti NON devono mai ecoare i segreti delle definizioni.
20
+ const fs = require('node:fs');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+ const { execFile } = require('node:child_process');
24
+ const { minimalRuntimeEnv } = require('../runtime/env.js');
25
+
26
+ // Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
27
+ // non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
28
+ // qui NON passiamo MAI l'env del processo per intero. engine.env viene consegnato
29
+ // direttamente al processo figlio dal broker, senza entrare nello stato tmux.
30
+ // Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
31
+ // ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
32
+ // iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
33
+ function minimalEnv() {
34
+ return minimalRuntimeEnv(process.env, { home: os.homedir() });
35
+ }
36
+
37
+ function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
38
+
39
+ // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
40
+ // NON devono mai ecoare i segreti delle definizioni.
41
+ const REDACTED = '‹redacted›';
42
+
43
+ // redactSecrets(text, engine, cell) -> string con ogni occorrenza dei segreti
44
+ // delle definizioni sostituita da '‹redacted›'. Segreti coperti (§9h):
45
+ // - valori di engine.env (le CHIAVI restano, i VALUES vengono redatti)
46
+ // - testo del prompt della cella (cell.prompt)
47
+ // - testo del prompt dell'engine (engine.prompt) se presente
48
+ // Applicato a OGNI messaggio d'errore che incorpora stderr/stdout dei comandi
49
+ // tmux falliti (up / down / injectPrompt): tmux puo' ecoare argv/env del comando
50
+ // lanciato nei suoi log di errore. Pura + senza dipendenze: testabile direttamente.
51
+ function redactSecrets(text, engine, cell) {
52
+ if (typeof text !== 'string' || text === '') return text;
53
+ const secrets = [];
54
+ if (engine && typeof engine === 'object' && engine.env) {
55
+ for (const v of Object.values(engine.env)) {
56
+ if (typeof v === 'string' && v) secrets.push(v);
57
+ }
58
+ }
59
+ if (engine && typeof engine.prompt === 'string' && engine.prompt) secrets.push(engine.prompt);
60
+ if (cell && typeof cell.prompt === 'string' && cell.prompt) secrets.push(cell.prompt);
61
+ // Ordina per lunghezza DECRESCENTE: i segreti piu' lunghi prima, cosi' un segreto
62
+ // che e' prefisso/sottostringa di un altro non ne maschera il rimpiazzo completo.
63
+ secrets.sort((a, b) => b.length - a.length);
64
+ let out = text;
65
+ for (const s of secrets) out = out.split(s).join(REDACTED); // replace globale, regex-free
66
+ return out;
67
+ }
68
+
69
+ const MAX_EARLY_DIAGNOSTIC = 1200;
70
+
71
+ function sanitizeEarlyDiagnostic(text, engine, cell, home) {
72
+ let out = redactSecrets(String(text || ''), engine, cell);
73
+ // ANSI CSI/OSC e byte di controllo non devono arrivare nell'errore JSON/UI.
74
+ out = out.replace(/\x1b\][^\x07]*(?:\x07|$)/g, '')
75
+ .replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
76
+ let clean = '';
77
+ for (let i = 0; i < out.length; i += 1) {
78
+ const code = out.charCodeAt(i);
79
+ if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) clean += out[i];
80
+ }
81
+ out = clean;
82
+ if (typeof home === 'string' && home) out = out.split(home).join('~');
83
+ out = out
84
+ .replace(/\bBearer\s+\S+/gi, `Bearer ${REDACTED}`)
85
+ .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z0-9_]*)(\s*[:=]\s*)\S+/g,
86
+ (_m, key, sep) => `${key}${sep}${REDACTED}`)
87
+ .replace(/\b(?:sk|fw|fpk|hf|zai)-[A-Za-z0-9._-]{8,}\b/gi, REDACTED);
88
+ const lines = out.split(/\r?\n/).map((line) => line.trimEnd())
89
+ .filter((line) => line.trim() && !/^Pane is dead \(status /i.test(line.trim()));
90
+ out = lines.join('\n').trim();
91
+ if (out.length > MAX_EARLY_DIAGNOSTIC) out = `…${out.slice(-(MAX_EARLY_DIAGNOSTIC - 1))}`;
92
+ return out;
93
+ }
94
+
95
+ // Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
96
+ // cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
97
+ function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
98
+ return new Promise((resolve) => {
99
+ execFile(tmuxBin, args, { env, timeout: timeoutMs }, (err, stdout, stderr) => {
100
+ const code = err && typeof err.code === 'number' ? err.code : (err ? 1 : 0);
101
+ resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || ''), code });
102
+ });
103
+ });
104
+ }
105
+
106
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
107
+
108
+ // Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
109
+ // rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
110
+ // iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
111
+ function promptCharsOk(prompt) {
112
+ if (typeof prompt !== 'string') return false;
113
+ for (let i = 0; i < prompt.length; i += 1) {
114
+ const c = prompt.charCodeAt(i);
115
+ if (c === 9 || c === 10 || c === 13) continue; // \t \n \r ammessi
116
+ if (c < 32 || c === 127) return false; // ESC/null/altri control
117
+ }
118
+ return true;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Build the direct child invocation separately from tmux. This lets the secure
123
+ // launch broker carry the complete child argv and environment in memory while
124
+ // tmux receives only the broker helper path and a one-time nonce.
125
+ // ---------------------------------------------------------------------------
126
+ function composeClientInvocation(engine, cell) {
127
+ const args = [...(engine.args || [])];
128
+ // model: flag + (override cella || valore engine), solo se c'e' un valore
129
+ if (engine.model) {
130
+ const val = (cell.model != null && cell.model !== '') ? cell.model : engine.model.value;
131
+ if (val) args.push(engine.model.flag, val);
132
+ }
133
+ // prompt flag-mode: promptFlag + prompt cella, solo se c'e' un prompt effettivo.
134
+ // SICUREZZA (design §9h): promptMode 'flag' mette il prompt in ARGV -> e' visibile
135
+ // nella process list (ps) / argv della sessione, a differenza di 'send-keys' che lo
136
+ // inietta DOPO via bracketed paste. Va quindi vincolato a prompt NON-segreti.
137
+ if (engine.promptMode === 'flag' && cell.prompt) {
138
+ args.push(engine.promptFlag, cell.prompt);
139
+ }
140
+ return { command: engine.command, args };
141
+ }
142
+
143
+ // composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
144
+ // PURA + testabile. Provider values are deliberately absent: no `tmux -e`, no
145
+ // environment value and no broker payload ever appears in the tmux client argv.
146
+ function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
147
+ const child = composeClientInvocation(engine, cell);
148
+ return ['new-session', '-d', '-s', tmuxSession, '-c', realCwd, child.command, ...child.args];
149
+ }
150
+
151
+ // Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
152
+ // e' viva entro la deadline, false altrimenti (command uscito / mai partita).
153
+ async function waitAlive(tmuxBin, session, { env, readyMs }) {
154
+ const deadline = Date.now() + Math.max(0, readyMs | 0);
155
+ for (;;) {
156
+ const r = await tmuxExec(tmuxBin, ['has-session', '-t', `=${session}`], { env, timeoutMs: 2000 });
157
+ if (!r.err) return true;
158
+ if (Date.now() >= deadline) return false;
159
+ await sleep(60);
160
+ }
161
+ }
162
+
163
+ async function waitStablePane(tmuxBin, target, { env, readyMs }) {
164
+ const deadline = Date.now() + Math.max(0, readyMs | 0);
165
+ for (;;) {
166
+ const state = await tmuxExec(tmuxBin,
167
+ ['display-message', '-p', '-t', target, '#{pane_dead}\t#{pane_dead_status}\t#{pane_id}'],
168
+ { env, timeoutMs: 2000 });
169
+ if (state.err) return { alive: false, status: null, target: null };
170
+ const [dead, rawStatus, paneId] = state.stdout.trim().split('\t');
171
+ if (!/^%[0-9]+$/.test(paneId || '')) return { alive: false, status: null, target: null };
172
+ if (dead === '1') {
173
+ const status = /^-?[0-9]+$/.test(rawStatus || '') ? Number(rawStatus) : null;
174
+ return { alive: false, status, target: paneId };
175
+ }
176
+ if (dead !== '0') return { alive: false, status: null, target: null };
177
+ if (Date.now() >= deadline) return { alive: true, status: null, target: paneId };
178
+ await sleep(60);
179
+ }
180
+ }
181
+
182
+ // Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
183
+ // load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
184
+ // poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
185
+ // (command gia' uscito) NON digita (design §9e). Ritorna {injected, reason}.
186
+ async function injectPrompt(tmuxBin, session, prompt, { env, readyMs = 400, target, engine, cell } = {}) {
187
+ if (!promptCharsOk(prompt)) {
188
+ return { injected: false, reason: 'prompt contiene byte di controllo (rifiutato)' };
189
+ }
190
+ let tmp = null;
191
+ try {
192
+ tmp = path.join(os.tmpdir(), `.ncsend.${session}.${process.pid}.txt`);
193
+ fs.writeFileSync(tmp, prompt, { mode: 0o600 });
194
+ fs.chmodSync(tmp, 0o600);
195
+
196
+ const alive = await waitAlive(tmuxBin, session, { env, readyMs });
197
+ if (!alive) return { injected: false, reason: 'sessione non viva (command uscito?): nessuna digitazione' };
198
+
199
+ // Target esatto: pane id (%N) se disponibile, altrimenti '=sessione' (match
200
+ // esatto, mai prefix-match) — audit impl #5.
201
+ const to = target || `=${session}`;
202
+ await tmuxExec(tmuxBin, ['load-buffer', '-b', 'ncsend', tmp], { env });
203
+ const paste = await tmuxExec(tmuxBin, ['paste-buffer', '-p', '-t', to, '-b', 'ncsend'], { env });
204
+ if (paste.err) return { injected: false, reason: redactSecrets(`paste-buffer failed: ${paste.stderr.trim()}`, engine, cell) };
205
+ return { injected: true, reason: 'bracketed paste (load-buffer + paste-buffer -p)' };
206
+ } finally {
207
+ try { if (tmp) fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
208
+ try { await tmuxExec(tmuxBin, ['delete-buffer', '-b', 'ncsend'], { env }); } catch (_) { /* best-effort */ }
209
+ }
210
+ }
211
+
212
+ module.exports = {
213
+ REDACTED,
214
+ MAX_EARLY_DIAGNOSTIC,
215
+ httpError,
216
+ minimalEnv,
217
+ tmuxExec,
218
+ sleep,
219
+ promptCharsOk,
220
+ composeClientInvocation,
221
+ composeLaunchArgv,
222
+ waitAlive,
223
+ waitStablePane,
224
+ injectPrompt,
225
+ redactSecrets,
226
+ sanitizeEarlyDiagnostic,
227
+ };
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+ // Runtime del fleet built-in (estratto da builtin.js in modo behavior-preserving).
3
+ // Possiede lo stato condiviso (cache definizioni + sessioni tmux) ed espone:
4
+ // status / up / down / restart / isCellSession — contratto runtime
5
+ // reloadDefs / findCell / findEngine / refreshSessions / commitDefs
6
+ // — accessor allo store, riusati dal facade CRUD di builtin.js
7
+ //
8
+ // Tutto l'argv/env/readiness/redaction e' delegato al toolkit stateless
9
+ // launch.js. createBuiltinFleet() (builtin.js) istanzia questo runtime e
10
+ // costruisce sopra di esso il CRUD/schema/credentials.
11
+ //
12
+ // Il contratto pubblico, l'argv, l'env, i readiness check, la permission
13
+ // policy, la redazione e il testo degli errori sono INVARIATI rispetto a
14
+ // builtin.js prima dell'estrazione.
15
+ const path = require('node:path');
16
+ const {
17
+ loadDefinitions, validateCommandTrust, resolveCwd,
18
+ } = require('./definitions.js');
19
+ const {
20
+ describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
21
+ } = require('./managed.js');
22
+ const {
23
+ httpError, minimalEnv, tmuxExec,
24
+ composeClientInvocation, composeLaunchArgv,
25
+ waitAlive, waitStablePane, injectPrompt,
26
+ redactSecrets, sanitizeEarlyDiagnostic,
27
+ } = require('./launch.js');
28
+
29
+ // TTL della cache status (ms): scaduto, status rilegge tmux + defs da disco.
30
+ const STATUS_TTL_MS = 2000;
31
+
32
+ function findCell(defs, id) { return defs.cells.find((c) => c.id === id) || null; }
33
+ function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // createBuiltinRuntime(ctx)
37
+ // ctx: { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot }
38
+ // boot = definizioni iniziali (loadDefinitions, non null: il caller gia'
39
+ // e' tornato unavailable su garbage).
40
+ // ---------------------------------------------------------------------------
41
+ function createBuiltinRuntime(ctx) {
42
+ const { cfg, home, defsPath, tmuxBin, readonly, launchBroker, boot } = ctx;
43
+ let cache = { at: 0, defs: boot, sessions: new Set() };
44
+
45
+ function reloadDefs() {
46
+ const d = loadDefinitions(defsPath);
47
+ if (d) cache = { ...cache, at: 0, defs: d };
48
+ return cache.defs; // mai null: boot non-null e reload mantiene l'ultimo valido
49
+ }
50
+
51
+ // Commit del defs mutato dal facade CRUD (builtin.js mutate()). invalida anche
52
+ // la TTL dello status, esattamente come faceva builtin.js prima dell'estrazione.
53
+ function commitDefs(defs) {
54
+ cache = { ...cache, at: 0, defs };
55
+ return defs;
56
+ }
57
+
58
+ async function refreshSessions() {
59
+ const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
60
+ if (r.err) return new Set(); // nessun server / nessuna sessione
61
+ const set = new Set();
62
+ for (const line of r.stdout.split('\n')) {
63
+ const [rawName, rawWindows] = line.split('\t');
64
+ const n = String(rawName || '').trim();
65
+ const windows = rawWindows === undefined ? 1 : Number(rawWindows);
66
+ if (n && Number.isFinite(windows) && windows > 0) set.add(n);
67
+ }
68
+ return set;
69
+ }
70
+
71
+ async function status() {
72
+ if (Date.now() - cache.at > STATUS_TTL_MS) {
73
+ reloadDefs(); // pick-up di edit esterne/file
74
+ const sessions = await refreshSessions();
75
+ cache = { at: Date.now(), defs: cache.defs, sessions };
76
+ }
77
+ const sessions = cache.sessions;
78
+ const cells = cache.defs.cells.map((c) => {
79
+ const alive = sessions.has(c.tmuxSession);
80
+ // Effective policy per cella: override remembered per-engine, altrimenti il
81
+ // default dell'engine gestito. Esposta SOLO come policy effettiva (mai segreti).
82
+ const engineDef = findEngine(cache.defs, c.engine);
83
+ const engineDefault = engineDef && engineDef.managed ? engineDef.managed.permissionPolicy : '';
84
+ const remembered = c.permissionPolicies
85
+ && (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
86
+ ? c.permissionPolicies[c.engine] : null;
87
+ const effectivePolicy = engineDef?.managed?.client === 'pi'
88
+ ? 'standard'
89
+ : (remembered || engineDefault || '');
90
+ return {
91
+ cell: c.id, tmuxSession: c.tmuxSession, engine: c.engine,
92
+ model: c.model || '', models: { ...(c.models || {}) },
93
+ permissionPolicy: effectivePolicy,
94
+ permissionPolicies: { ...(c.permissionPolicies || {}) },
95
+ active: alive, boot: c.boot, tmux: alive,
96
+ rc: '', key: '', degraded: false, // builtin: cella "up" <=> sessione tmux viva
97
+ };
98
+ });
99
+ const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
100
+ const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
101
+ const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
102
+ const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
103
+ const engines = cache.defs.engines.map((e) => {
104
+ const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
105
+ return {
106
+ id: e.id, label: e.label, rc: !!e.rc,
107
+ ...(managed ? {
108
+ kind: 'managed', client: managed.client, provider: managed.provider,
109
+ model: e.managed.model || managed.defaultModel || '',
110
+ models: managed.provider === 'ollama-cloud' ? ollamaModels
111
+ : (managed.client === 'pi'
112
+ ? (e.managed.provider === 'custom'
113
+ ? [e.managed.model]
114
+ : (e.managed.provider === 'native'
115
+ ? [...new Set(Object.values(piModels).flat())]
116
+ : (piModels[e.managed.provider] || [])))
117
+ : managed.models),
118
+ configured: managed.configured, reason: managed.reason,
119
+ } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
120
+ };
121
+ });
122
+ return {
123
+ available: true,
124
+ provider: 'builtin',
125
+ bootOwner: 'builtin', // §9b: la UI non puo' mentire su chi possiede il boot
126
+ reason: cfg.fleetProviderReason || 'fleet.json definitions',
127
+ cells, engines,
128
+ };
129
+ }
130
+
131
+ function isCellSession(name) {
132
+ return cache.defs.cells.some((c) => c.tmuxSession === String(name));
133
+ }
134
+
135
+ // up — ordine obbligatorio (design §9a, task B4.2).
136
+ // Le override {engine,boot} del contratto route sono ignorate: il builtin e'
137
+ // definitions-driven (l'engine della cella e' quello dichiarato; boot e' uno
138
+ // stato persistente gestito da boot()). Lancia SENZA shell.
139
+ async function up(cellId /* , { engine, boot } = {} */) {
140
+ if (readonly()) throw httpError(403, 'READONLY: up bloccato');
141
+ const defs = reloadDefs();
142
+ const cell = findCell(defs, cellId);
143
+ if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
144
+ const engine = findEngine(defs, cell.engine);
145
+ if (!engine) throw httpError(400, `engine dangling per cella ${cellId}: ${cell.engine}`);
146
+ let launchEngine = engine;
147
+ if (engine.managed) {
148
+ const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
149
+ if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
150
+ launchEngine = resolved.engine;
151
+ }
152
+
153
+ // (2) trust del command PRIMA di lanciare
154
+ const trust = validateCommandTrust(launchEngine.command);
155
+ if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
156
+ // (3) cwd reale sotto la home
157
+ const realCwd = resolveCwd(cell.cwd, home);
158
+ if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
159
+
160
+ // (4)+(5) argv diretto (no shell). Environment-bearing launches go
161
+ // through a private, one-shot Unix-socket broker so credentials never
162
+ // appear in `ps`, tmux argv, tmux global/session env or a temporary file.
163
+ // '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
164
+ // cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
165
+ // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
166
+ let tmuxLaunchEngine = launchEngine;
167
+ if (Object.keys(launchEngine.env || {}).length) {
168
+ const child = composeClientInvocation(launchEngine, cell);
169
+ const ticket = await launchBroker.issue({
170
+ command: child.command,
171
+ args: child.args,
172
+ env: { ...minimalEnv(), ...launchEngine.env },
173
+ });
174
+ tmuxLaunchEngine = {
175
+ command: process.execPath,
176
+ args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
177
+ env: {}, promptMode: 'managed-argv',
178
+ };
179
+ }
180
+ const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
181
+ argv.splice(2, 0, '-P', '-F', '#{pane_id}');
182
+ // Mantieni il pane morto solo durante la finestra di readiness: permette di
183
+ // catturare un errore reale del client senza lasciare una sessione fantasma.
184
+ // Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
185
+ argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
186
+ const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
187
+ if (launch.err) {
188
+ // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
189
+ const why = /duplicate session/i.test(launch.stderr)
190
+ ? 'sessione già in esecuzione'
191
+ : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
192
+ throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
193
+ }
194
+
195
+ // `tmux new-session -d` can return 0 even when the launched CLI exits a
196
+ // moment later (missing login, bad model, incompatible provider). Without
197
+ // this readiness gate the PWA reported success and then showed nothing.
198
+ // Always verify liveness, including cells without a system prompt.
199
+ const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
200
+ const paneId = launch.stdout.trim().split('\n')[0] || '';
201
+ const readiness = paneId.startsWith('%')
202
+ ? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
203
+ : { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
204
+ if (!readiness.alive) {
205
+ let diagnostic = '';
206
+ if (readiness.target) {
207
+ const captured = await tmuxExec(tmuxBin,
208
+ ['capture-pane', '-p', '-S', '-80', '-t', readiness.target], { env: minimalEnv(), timeoutMs: 2000 });
209
+ if (!captured.err) diagnostic = sanitizeEarlyDiagnostic(captured.stdout, launchEngine, cell, home);
210
+ }
211
+ // remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
212
+ // nella Fleet o nella lista tmux dopo aver raccolto l'errore.
213
+ await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
214
+ cache = { ...cache, at: 0 };
215
+ const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
216
+ const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
217
+ throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
218
+ }
219
+ if (readiness.target) {
220
+ await tmuxExec(tmuxBin,
221
+ ['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
222
+ }
223
+
224
+ // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
225
+ // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
226
+ let prompt = null;
227
+ if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
228
+ const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
229
+ prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
230
+ env: minimalEnv(),
231
+ readyMs: cfg.sendKeysReadyMs != null ? cfg.sendKeysReadyMs : readyMs,
232
+ target,
233
+ engine: launchEngine, cell, // per la redazione del reason se paste-buffer fallisce (§9h)
234
+ });
235
+ }
236
+ cache = { ...cache, at: 0 }; // invalida: prossimo status rilegge tmux
237
+ return { ok: true, cell: cellId, session: cell.tmuxSession, prompt };
238
+ }
239
+
240
+ async function down(cellId /* , opts */) {
241
+ if (readonly()) throw httpError(403, 'READONLY: down bloccato');
242
+ const defs = reloadDefs();
243
+ const cell = findCell(defs, cellId);
244
+ if (!cell) throw httpError(400, `cella sconosciuta: ${cellId}`);
245
+ const engine = findEngine(defs, cell.engine) || {};
246
+ const r = await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv() });
247
+ if (r.err && !/no server running|can't find session|not found/i.test(r.stderr)) {
248
+ throw httpError(500, `tmux kill-session failed: ${redactSecrets(r.stderr.trim(), engine, cell)}`);
249
+ }
250
+ cache = { ...cache, at: 0 };
251
+ return { ok: true, killed: !r.err };
252
+ }
253
+
254
+ // restart = down (riusa la kill esistente; sessione non viva NON e' errore,
255
+ // come down) seguito da up (rilancia secondo la definizione corrente).
256
+ // Capability del SOLO built-in: i provider legacy non la espongono (501 in route).
257
+ async function restart(cellId) {
258
+ if (readonly()) throw httpError(403, 'READONLY: restart bloccato');
259
+ await down(cellId); // idempotente: cella non viva -> nessun errore
260
+ return up(cellId);
261
+ }
262
+
263
+ return {
264
+ status, up, down, restart, isCellSession,
265
+ reloadDefs, findCell, findEngine, refreshSessions, commitDefs,
266
+ };
267
+ }
268
+
269
+ module.exports = { createBuiltinRuntime, findCell, findEngine, STATUS_TTL_MS };
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+ // Helper cella/deck/topologia per il server MCP (`lib/mcp/server.js`).
3
+ //
4
+ // Cohesione di tutto cio' che legge la directory delle celle Fleet e risolve
5
+ // owner/route/topologia: layout deck (ordine visuale), normalizzazione payload
6
+ // celle, parsing del target owner-qualified, costruzione della route federata
7
+ // e della directory aggregata locale+remota. Queste funzioni parlano SOLO via
8
+ // l'astrazione `ctx.api` (loopback + Bearer del bridge); ACL e identita'
9
+ // owner-qualified sono applicate lato server HTTP.
10
+ const { isValidSession } = require('../files/store.js');
11
+
12
+ const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
13
+ const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
14
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
15
+
16
+ // Deck layout is stored column-major, while the UI is read row-major. Preserve
17
+ // the visual order so an agent sees the same neighbourhood as the operator.
18
+ function orderedDeckMembers(deck) {
19
+ const columns = deck && deck.layout && Array.isArray(deck.layout.columns)
20
+ ? deck.layout.columns : [];
21
+ const rows = Math.max(0, ...columns.map((column) => (
22
+ column && Array.isArray(column.tiles) ? column.tiles.length : 0
23
+ )));
24
+ const out = [];
25
+ for (let row = 0; row < rows; row += 1) {
26
+ for (const column of columns) {
27
+ const tile = column && Array.isArray(column.tiles) ? column.tiles[row] : null;
28
+ if (!tile || typeof tile.session !== 'string' || !tile.session) continue;
29
+ const member = { tmuxSession: tile.session };
30
+ if (typeof tile.node === 'string' && tile.node) member.node = tile.node;
31
+ if (typeof tile.ownerId === 'string' && NODE_ID_RE.test(tile.ownerId)) member.ownerId = tile.ownerId;
32
+ out.push(member);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function fleetStatusPath(node) {
39
+ if (!node) return '/api/fleet/status';
40
+ const parts = String(node).split('/');
41
+ if (!parts.length || parts.some((part) => !NODE_PART_RE.test(part))
42
+ || new Set(parts).size !== parts.length) return null;
43
+ return `/api/route/${parts.map(encodeURIComponent).join('/')}/_/fleet/status`;
44
+ }
45
+
46
+ function fleetCellsBySession(payload) {
47
+ const out = new Map();
48
+ if (!payload || payload.available !== true || !Array.isArray(payload.cells)) return out;
49
+ for (const cell of payload.cells) {
50
+ if (!cell || typeof cell.tmuxSession !== 'string' || !cell.tmuxSession
51
+ || typeof cell.cell !== 'string' || !cell.cell) continue;
52
+ out.set(cell.tmuxSession, cell.cell);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function routePath(route, resource) {
58
+ if (!Array.isArray(route) || !route.length || route.length > 4
59
+ || route.some((part) => !NODE_PART_RE.test(part)) || new Set(route).size !== route.length) return null;
60
+ return `/api/route/${route.map(encodeURIComponent).join('/')}/_/${resource}`;
61
+ }
62
+
63
+ function topologyOwners(payload) {
64
+ const out = [];
65
+ const seen = new Set();
66
+ for (const node of (payload && Array.isArray(payload.nodes) ? payload.nodes : [])) {
67
+ if (!node || !NODE_ID_RE.test(String(node.instanceId || '')) || seen.has(node.instanceId)
68
+ || !Array.isArray(node.route) || !routePath(node.route, 'decks')) continue;
69
+ seen.add(node.instanceId);
70
+ out.push({
71
+ instanceId: node.instanceId,
72
+ route: [...node.route],
73
+ label: typeof node.label === 'string' && node.label ? node.label : (node.name || node.route.join(' › ')),
74
+ stale: node.stale === true,
75
+ });
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function memberOwnerId(member, deckOwner, ownerTopology) {
81
+ if (member.ownerId && NODE_ID_RE.test(member.ownerId)) return member.ownerId;
82
+ if (!member.node) return deckOwner.instanceId;
83
+ const found = ownerTopology.find((node) => Array.isArray(node.route) && node.route.join('/') === member.node);
84
+ return found ? found.instanceId : null;
85
+ }
86
+
87
+ function parseCellTarget(value) {
88
+ if (typeof value !== 'string') return null;
89
+ const split = value.indexOf(':');
90
+ if (split < 16) return null;
91
+ const instanceId = value.slice(0, split);
92
+ const cell = value.slice(split + 1);
93
+ return NODE_ID_RE.test(instanceId) && CELL_ID_RE.test(cell) ? { instanceId, cell } : null;
94
+ }
95
+
96
+ function normalizeCellPayload(payload, owner, callerSession = null) {
97
+ if (!payload || payload.instanceId !== owner.instanceId || !Array.isArray(payload.cells)) return [];
98
+ const route = owner.route.length ? owner.route.join('/') : 'local';
99
+ const seen = new Set();
100
+ const out = [];
101
+ for (const raw of payload.cells) {
102
+ if (!raw || raw.instanceId !== owner.instanceId || !CELL_ID_RE.test(String(raw.cell || ''))
103
+ || typeof raw.tmuxSession !== 'string' || !isValidSession(raw.tmuxSession)
104
+ || seen.has(raw.cell)) continue;
105
+ seen.add(raw.cell);
106
+ out.push({
107
+ id: `${owner.instanceId}:${raw.cell}`,
108
+ instanceId: owner.instanceId,
109
+ owner: owner.label,
110
+ route,
111
+ cell: raw.cell,
112
+ tmuxSession: raw.tmuxSession,
113
+ engine: typeof raw.engine === 'string' ? raw.engine : '',
114
+ model: typeof raw.model === 'string' ? raw.model : '',
115
+ active: raw.active === true,
116
+ canReceive: raw.canReceive === true,
117
+ lastSeen: Number.isFinite(raw.lastSeen) ? raw.lastSeen : null,
118
+ self: owner.route.length === 0 && callerSession === raw.tmuxSession,
119
+ });
120
+ }
121
+ return out;
122
+ }
123
+
124
+ async function readCellDirectory(ctx, callerSession = null) {
125
+ const [config, topology] = await Promise.all([
126
+ ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'),
127
+ ]);
128
+ const localId = String(config && config.instanceId || '');
129
+ if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
130
+ const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
131
+ ...topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localId)];
132
+ const cells = [];
133
+ const unavailable = [];
134
+ await Promise.all(owners.map(async (owner) => {
135
+ const apiPath = owner.route.length ? routePath(owner.route, 'cells') : '/api/cells';
136
+ if (!apiPath) return;
137
+ try {
138
+ cells.push(...normalizeCellPayload(await ctx.api('GET', apiPath), owner, callerSession));
139
+ } catch (_) {
140
+ unavailable.push({ instanceId: owner.instanceId, owner: owner.label,
141
+ route: owner.route.length ? owner.route.join('/') : 'local' });
142
+ }
143
+ }));
144
+ cells.sort((a, b) => (a.route === 'local' ? -1 : b.route === 'local' ? 1
145
+ : a.route.localeCompare(b.route)) || a.cell.localeCompare(b.cell));
146
+ unavailable.sort((a, b) => a.route.localeCompare(b.route));
147
+ return { nodeId: localId, cells, unavailable };
148
+ }
149
+
150
+ module.exports = {
151
+ NODE_PART_RE, NODE_ID_RE, CELL_ID_RE,
152
+ orderedDeckMembers, fleetStatusPath, fleetCellsBySession, routePath,
153
+ topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload, readCellDirectory,
154
+ };