@mmmbuto/nexuscrew 0.8.12 → 0.8.14
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 +90 -36
- package/frontend/dist/assets/index-BR2Qfqi2.js +91 -0
- package/frontend/dist/assets/{index-PkKeNfT7.css → index-CopPQTMk.css} +2 -2
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +137 -0
- package/lib/cli/service.js +18 -6
- package/lib/config.js +14 -0
- package/lib/fleet/builtin.js +243 -38
- package/lib/fleet/cell-exec.js +81 -0
- package/lib/fleet/credentials.js +135 -0
- package/lib/fleet/env-key.js +12 -0
- package/lib/fleet/launch-broker.js +136 -0
- package/lib/fleet/managed.js +139 -19
- package/lib/fleet/routes.js +16 -1
- package/lib/mcp/server.js +131 -3
- package/lib/proxy/federation.js +47 -7
- package/lib/pty/attach.js +3 -2
- package/lib/runtime/env.js +50 -0
- package/lib/server.js +26 -2
- package/lib/settings/routes.js +4 -3
- package/lib/tmux/actions.js +96 -1
- package/lib/tmux/list.js +22 -3
- package/lib/update/core.js +35 -8
- package/lib/update/manager.js +6 -3
- package/lib/update/runner.js +7 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +14 -3
- package/frontend/dist/assets/index-DrroT7uq.js +0 -91
package/lib/fleet/builtin.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
// exec del comando, NON sh -c — verificato: ';','|','$' passano verbatim).
|
|
14
14
|
// - env: il builtin lancia con un env MINIMALE controllato dal service (allowlist
|
|
15
15
|
// dura); engine.env — gia' ripulito dalle loader-key da parseDefinitions —
|
|
16
|
-
// raggiunge
|
|
17
|
-
//
|
|
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.
|
|
18
19
|
// - READONLY (cfg.readonlyDefault===true | NEXUSCREW_READONLY=1) blocca ogni
|
|
19
20
|
// mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
|
|
20
21
|
// - promptMode 'send-keys' inietta via `tmux load-buffer` + `paste-buffer -p`
|
|
@@ -31,29 +32,22 @@ const {
|
|
|
31
32
|
const {
|
|
32
33
|
publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
|
|
33
34
|
} = require('./managed.js');
|
|
35
|
+
const { validEnvKey } = require('./env-key.js');
|
|
36
|
+
const { setCredential, removeCredential } = require('./credentials.js');
|
|
37
|
+
const { createLaunchBroker } = require('./launch-broker.js');
|
|
38
|
+
const { MINIMAL_ENV_KEYS, minimalRuntimeEnv } = require('../runtime/env.js');
|
|
34
39
|
|
|
35
40
|
const STATUS_TTL_MS = 2000;
|
|
36
41
|
|
|
37
42
|
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
38
43
|
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
39
|
-
// qui NON passiamo MAI l'env del processo per intero. engine.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.
|
|
40
46
|
// Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
|
|
41
47
|
// ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
|
|
42
48
|
// iniettare loader-key, e engine.env arriva al pane SOLO tramite chiavi validate.
|
|
43
|
-
const MINIMAL_ENV_KEYS = [
|
|
44
|
-
'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LANGUAGE',
|
|
45
|
-
'LC_ALL', 'LC_CTYPE', 'USER', 'LOGNAME',
|
|
46
|
-
'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS',
|
|
47
|
-
];
|
|
48
49
|
function minimalEnv() {
|
|
49
|
-
|
|
50
|
-
for (const k of MINIMAL_ENV_KEYS) {
|
|
51
|
-
if (process.env[k] !== undefined && process.env[k] !== '') env[k] = process.env[k];
|
|
52
|
-
}
|
|
53
|
-
if (!env.PATH) env.PATH = '/usr/local/bin:/usr/bin:/bin';
|
|
54
|
-
if (!env.HOME) env.HOME = os.homedir();
|
|
55
|
-
if (!env.TERM) env.TERM = 'xterm-256color';
|
|
56
|
-
return env;
|
|
50
|
+
return minimalRuntimeEnv(process.env, { home: os.homedir() });
|
|
57
51
|
}
|
|
58
52
|
|
|
59
53
|
function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
|
|
@@ -88,6 +82,32 @@ function redactSecrets(text, engine, cell) {
|
|
|
88
82
|
return out;
|
|
89
83
|
}
|
|
90
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
|
+
|
|
91
111
|
// Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
|
|
92
112
|
// cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
|
|
93
113
|
function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
|
|
@@ -115,16 +135,12 @@ function promptCharsOk(prompt) {
|
|
|
115
135
|
}
|
|
116
136
|
|
|
117
137
|
// ---------------------------------------------------------------------------
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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.
|
|
121
141
|
// ---------------------------------------------------------------------------
|
|
122
|
-
function
|
|
123
|
-
const args = [
|
|
124
|
-
// engine.env -> env di sessione via -e (chiavi gia' validate, no loader-key)
|
|
125
|
-
for (const [k, v] of Object.entries(engine.env || {})) args.push('-e', `${k}=${v}`);
|
|
126
|
-
// command + args: argv diretto (tmux exec, NON sh -c)
|
|
127
|
-
args.push(engine.command, ...(engine.args || []));
|
|
142
|
+
function composeClientInvocation(engine, cell) {
|
|
143
|
+
const args = [...(engine.args || [])];
|
|
128
144
|
// model: flag + (override cella || valore engine), solo se c'e' un valore
|
|
129
145
|
if (engine.model) {
|
|
130
146
|
const val = (cell.model != null && cell.model !== '') ? cell.model : engine.model.value;
|
|
@@ -137,7 +153,15 @@ function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
|
|
|
137
153
|
if (engine.promptMode === 'flag' && cell.prompt) {
|
|
138
154
|
args.push(engine.promptFlag, cell.prompt);
|
|
139
155
|
}
|
|
140
|
-
return args;
|
|
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];
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
// Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
|
|
@@ -152,6 +176,25 @@ async function waitAlive(tmuxBin, session, { env, readyMs }) {
|
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
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
|
+
|
|
155
198
|
// Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
|
|
156
199
|
// load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
|
|
157
200
|
// poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
|
|
@@ -242,6 +285,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
242
285
|
const defsPath = cfg.fleetDefsPath || path.join(home, '.nexuscrew', 'fleet.json');
|
|
243
286
|
const tmuxBin = cfg.tmuxBin || process.env.TMUX_BIN || 'tmux';
|
|
244
287
|
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
288
|
+
const launchBroker = cfg.launchBroker || createLaunchBroker({ ...cfg, home });
|
|
245
289
|
|
|
246
290
|
// Bootstrap: fleet.json valido? garbage -> unavailable (fail-closed, design §7)
|
|
247
291
|
const boot = loadDefinitions(defsPath);
|
|
@@ -257,12 +301,14 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
257
301
|
function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
|
|
258
302
|
|
|
259
303
|
async function refreshSessions() {
|
|
260
|
-
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { env: minimalEnv() });
|
|
304
|
+
const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
|
|
261
305
|
if (r.err) return new Set(); // nessun server / nessuna sessione
|
|
262
306
|
const set = new Set();
|
|
263
307
|
for (const line of r.stdout.split('\n')) {
|
|
264
|
-
const
|
|
265
|
-
|
|
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);
|
|
266
312
|
}
|
|
267
313
|
return set;
|
|
268
314
|
}
|
|
@@ -356,12 +402,32 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
356
402
|
const realCwd = resolveCwd(cell.cwd, home);
|
|
357
403
|
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
|
|
358
404
|
|
|
359
|
-
// (4)+(5) argv diretto (no shell)
|
|
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.
|
|
360
408
|
// '-P -F #{pane_id}': tmux stampa il pane id della sessione appena creata,
|
|
361
409
|
// cosi' l'iniezione del prompt bersaglia ESATTAMENTE quel pane (audit impl
|
|
362
410
|
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
363
|
-
|
|
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 });
|
|
364
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');
|
|
365
431
|
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
366
432
|
if (launch.err) {
|
|
367
433
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
@@ -376,17 +442,34 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
376
442
|
// this readiness gate the PWA reported success and then showed nothing.
|
|
377
443
|
// Always verify liveness, including cells without a system prompt.
|
|
378
444
|
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
379
|
-
const
|
|
380
|
-
|
|
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 });
|
|
381
459
|
cache = { ...cache, at: 0 };
|
|
382
|
-
|
|
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 });
|
|
383
467
|
}
|
|
384
468
|
|
|
385
469
|
// (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
|
|
386
470
|
// (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
|
|
387
471
|
let prompt = null;
|
|
388
472
|
if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
|
|
389
|
-
const paneId = launch.stdout.trim().split('\n')[0] || '';
|
|
390
473
|
const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
|
|
391
474
|
prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
|
|
392
475
|
env: minimalEnv(),
|
|
@@ -605,6 +688,68 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
605
688
|
.filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
|
|
606
689
|
};
|
|
607
690
|
}
|
|
691
|
+
|
|
692
|
+
// Ripristino engine separato e atomico. Il formato portatile non accetta mai
|
|
693
|
+
// `env` values: per un engine custom nuovo parte da env vuoto; in overwrite
|
|
694
|
+
// conserva gli eventuali valori write-only già configurati sul target.
|
|
695
|
+
async function restoreEngines(engines, opts = {}) {
|
|
696
|
+
if (readonly()) throw httpError(403, 'READONLY: restore-engines bloccato');
|
|
697
|
+
if (!Array.isArray(engines) || engines.length < 1 || engines.length > 24) {
|
|
698
|
+
throw httpError(400, 'engines deve contenere 1..24 definizioni');
|
|
699
|
+
}
|
|
700
|
+
const allowed = new Set(['id', 'label', 'rc', 'managed', 'command', 'args', 'envKeys', 'model', 'promptMode', 'promptFlag']);
|
|
701
|
+
const seen = new Set();
|
|
702
|
+
for (const engine of engines) {
|
|
703
|
+
if (!engine || typeof engine !== 'object' || Array.isArray(engine)) throw httpError(400, 'definizione engine non valida');
|
|
704
|
+
for (const key of Object.keys(engine)) if (!allowed.has(key)) throw httpError(400, `campo engine backup non ammesso: ${key}`);
|
|
705
|
+
if (typeof engine.id !== 'string' || seen.has(engine.id)) throw httpError(400, `id engine duplicato o mancante: ${engine.id || '?'}`);
|
|
706
|
+
if (engine.managed && engine.envKeys !== undefined) throw httpError(400, `envKeys non ammesso per engine managed: ${engine.id}`);
|
|
707
|
+
if (!engine.managed && (!Array.isArray(engine.envKeys)
|
|
708
|
+
|| engine.envKeys.length > 32
|
|
709
|
+
|| engine.envKeys.some((key) => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key))
|
|
710
|
+
|| new Set(engine.envKeys).size !== engine.envKeys.length)) {
|
|
711
|
+
throw httpError(400, `envKeys non valido per engine: ${engine.id}`);
|
|
712
|
+
}
|
|
713
|
+
if (!engine.managed && Array.isArray(engine.args) && engine.args.some((arg) => {
|
|
714
|
+
const text = String(arg || '');
|
|
715
|
+
return /(?:bearer\s+|authorization\s*[:=]|(?:api[_-]?key|secret|token)\s*[:=])/i.test(text)
|
|
716
|
+
|| /^-{1,2}(?:api[-_]?key|access[-_]?key|auth(?:orization)?[-_]?token|token|secret|password|credential)(?:$|=)/i.test(text)
|
|
717
|
+
|| /\b(?:sk|fw|fpk|hf|zai)[-_][A-Za-z0-9._-]{8,}\b/i.test(text)
|
|
718
|
+
|| /https?:\/\/[^\s/@:]+:[^\s/@]+@/i.test(text);
|
|
719
|
+
})) {
|
|
720
|
+
throw httpError(400, `argomento potenzialmente segreto non ammesso per engine: ${engine.id}`);
|
|
721
|
+
}
|
|
722
|
+
seen.add(engine.id);
|
|
723
|
+
}
|
|
724
|
+
const defs = reloadDefs();
|
|
725
|
+
const conflicts = engines.filter((engine) => !!findEngine(defs, engine.id)).map((engine) => engine.id);
|
|
726
|
+
if (conflicts.length && opts.overwrite !== true) {
|
|
727
|
+
throw httpError(409, `engine già esistenti: ${conflicts.join(', ')}`, { code: 'engine-conflicts', conflicts });
|
|
728
|
+
}
|
|
729
|
+
const sessions = await refreshSessions();
|
|
730
|
+
const affected = defs.cells.filter((cell) => conflicts.includes(cell.engine) && sessions.has(cell.tmuxSession)).map((cell) => cell.id);
|
|
731
|
+
await mutate(defs, (draft) => {
|
|
732
|
+
for (const portable of engines) {
|
|
733
|
+
const index = draft.engines.findIndex((current) => current.id === portable.id);
|
|
734
|
+
const current = index >= 0 ? draft.engines[index] : null;
|
|
735
|
+
const next = { ...portable };
|
|
736
|
+
if (!next.managed) {
|
|
737
|
+
const keys = next.envKeys;
|
|
738
|
+
delete next.envKeys;
|
|
739
|
+
next.env = Object.fromEntries(keys.map((key) => [key,
|
|
740
|
+
current && !current.managed && Object.prototype.hasOwnProperty.call(current.env || {}, key)
|
|
741
|
+
? current.env[key] : '',
|
|
742
|
+
]));
|
|
743
|
+
}
|
|
744
|
+
if (index >= 0) draft.engines[index] = next; else draft.engines.push(next);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
return {
|
|
748
|
+
ok: true, count: engines.length,
|
|
749
|
+
created: engines.filter((engine) => !conflicts.includes(engine.id)).map((engine) => engine.id),
|
|
750
|
+
replaced: conflicts, needsRestart: affected,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
608
753
|
async function removeCell(id, opts = {}) {
|
|
609
754
|
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
610
755
|
let defs = reloadDefs();
|
|
@@ -694,10 +839,64 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
694
839
|
...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
|
|
695
840
|
})),
|
|
696
841
|
managedCatalog: publicCatalog(),
|
|
697
|
-
managedConfig: {
|
|
842
|
+
managedConfig: {
|
|
843
|
+
providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
|
|
844
|
+
providerShellPath: cfg.providerShellPath || path.join(home, '.config', 'ai-shell', 'providers.zsh'),
|
|
845
|
+
providerKeysPath: cfg.providerKeysPath || path.join(home, '.config', 'keys', 'ai.env'),
|
|
846
|
+
providerSecurePath: cfg.providerSecurePath || path.join(home, '.config', 'secure', '.env'),
|
|
847
|
+
localCredentialStore: true,
|
|
848
|
+
},
|
|
698
849
|
};
|
|
699
850
|
}
|
|
700
851
|
|
|
852
|
+
function credentialRequirements() {
|
|
853
|
+
const defs = reloadDefs(); const map = new Map();
|
|
854
|
+
for (const engine of defs.engines) {
|
|
855
|
+
if (!engine.managed) continue;
|
|
856
|
+
const info = describeManaged(engine.managed, { ...cfg, home });
|
|
857
|
+
const envKey = info.auth;
|
|
858
|
+
if (!validEnvKey(envKey) || envKey === 'login' || envKey === 'none') continue;
|
|
859
|
+
const current = map.get(envKey) || {
|
|
860
|
+
envKey, configured: false, source: 'missing', engines: [], activeCells: [],
|
|
861
|
+
};
|
|
862
|
+
current.configured ||= info.authConfigured === true;
|
|
863
|
+
if (info.credentialSource && info.credentialSource !== 'missing') current.source = info.credentialSource;
|
|
864
|
+
current.engines.push(engine.id);
|
|
865
|
+
map.set(envKey, current);
|
|
866
|
+
}
|
|
867
|
+
return { defs, map };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function credentialStatus() {
|
|
871
|
+
const { defs, map } = credentialRequirements();
|
|
872
|
+
const sessions = await refreshSessions();
|
|
873
|
+
for (const entry of map.values()) {
|
|
874
|
+
entry.engines.sort();
|
|
875
|
+
entry.activeCells = defs.cells
|
|
876
|
+
.filter((cell) => entry.engines.includes(cell.engine) && sessions.has(cell.tmuxSession))
|
|
877
|
+
.map((cell) => cell.id).sort();
|
|
878
|
+
}
|
|
879
|
+
return { credentials: [...map.values()].sort((a, b) => a.envKey.localeCompare(b.envKey)) };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function setLocalCredential(envKey, value) {
|
|
883
|
+
if (readonly()) throw httpError(403, 'READONLY: credential write blocked');
|
|
884
|
+
const { map } = credentialRequirements();
|
|
885
|
+
if (!map.has(envKey)) throw httpError(400, 'credential key is not required by a configured engine');
|
|
886
|
+
try { setCredential({ ...cfg, home }, envKey, value, home); }
|
|
887
|
+
catch (error) { throw httpError(400, error.message); }
|
|
888
|
+
return credentialStatus();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function removeLocalCredential(envKey) {
|
|
892
|
+
if (readonly()) throw httpError(403, 'READONLY: credential removal blocked');
|
|
893
|
+
const { map } = credentialRequirements();
|
|
894
|
+
if (!map.has(envKey)) throw httpError(400, 'credential key is not required by a configured engine');
|
|
895
|
+
try { removeCredential({ ...cfg, home }, envKey, home); }
|
|
896
|
+
catch (error) { throw httpError(400, error.message); }
|
|
897
|
+
return credentialStatus();
|
|
898
|
+
}
|
|
899
|
+
|
|
701
900
|
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
702
901
|
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
703
902
|
async function mutate(defs, mutator) {
|
|
@@ -768,24 +967,30 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
768
967
|
}
|
|
769
968
|
|
|
770
969
|
function capabilities() {
|
|
771
|
-
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions'];
|
|
970
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions', 'credentials'];
|
|
772
971
|
}
|
|
773
972
|
|
|
973
|
+
async function close() { await launchBroker.close(); }
|
|
974
|
+
|
|
774
975
|
return {
|
|
775
976
|
available: true,
|
|
776
977
|
provider: 'builtin',
|
|
777
978
|
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
778
979
|
defineEngine, editEngine, removeEngine,
|
|
779
|
-
defineCell, editCell, removeCell, importCell, restoreCells,
|
|
980
|
+
defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
|
|
780
981
|
schema, definitions, capabilities,
|
|
982
|
+
credentialStatus, setLocalCredential, removeLocalCredential, close,
|
|
781
983
|
};
|
|
782
984
|
}
|
|
783
985
|
|
|
784
986
|
module.exports = {
|
|
785
987
|
createBuiltinFleet,
|
|
786
988
|
composeLaunchArgv,
|
|
989
|
+
composeClientInvocation,
|
|
787
990
|
minimalEnv,
|
|
788
991
|
promptCharsOk,
|
|
789
992
|
redactSecrets,
|
|
993
|
+
sanitizeEarlyDiagnostic,
|
|
994
|
+
waitStablePane,
|
|
790
995
|
MINIMAL_ENV_KEYS,
|
|
791
996
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Tiny launch helper. It receives only a local socket path and a random,
|
|
5
|
+
// single-use nonce in argv. Provider values arrive over the private Unix
|
|
6
|
+
// socket, stay in memory and are passed directly to the child process.
|
|
7
|
+
const net = require('node:net');
|
|
8
|
+
const { spawn } = require('node:child_process');
|
|
9
|
+
const { MAX_PAYLOAD } = require('./launch-broker.js');
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
14
|
+
if (argv[i] === '--socket') out.socketPath = argv[i + 1];
|
|
15
|
+
else if (argv[i] === '--nonce') out.nonce = argv[i + 1];
|
|
16
|
+
else return null;
|
|
17
|
+
}
|
|
18
|
+
if (typeof out.socketPath !== 'string' || !out.socketPath
|
|
19
|
+
|| typeof out.nonce !== 'string' || !/^[a-f0-9]{64}$/.test(out.nonce)) return null;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validPayload(payload) {
|
|
24
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
25
|
+
if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
|
|
26
|
+
if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
|
|
27
|
+
return payload.args.every((v) => typeof v === 'string')
|
|
28
|
+
&& Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function receivePayload(socketPath, nonce, timeoutMs = 5000) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const socket = net.createConnection(socketPath);
|
|
34
|
+
let data = Buffer.alloc(0); let expected = null; let done = false;
|
|
35
|
+
const finish = (error, payload) => {
|
|
36
|
+
if (done) return; done = true; socket.destroy();
|
|
37
|
+
if (error) reject(error); else resolve(payload);
|
|
38
|
+
};
|
|
39
|
+
socket.setTimeout(timeoutMs, () => finish(new Error('launch broker timed out')));
|
|
40
|
+
socket.once('connect', () => socket.write(`${JSON.stringify({ nonce })}\n`));
|
|
41
|
+
socket.on('data', (chunk) => {
|
|
42
|
+
data = Buffer.concat([data, chunk]);
|
|
43
|
+
if (expected === null && data.length >= 4) {
|
|
44
|
+
expected = data.readUInt32BE(0); data = data.subarray(4);
|
|
45
|
+
if (!expected || expected > MAX_PAYLOAD) return finish(new Error('invalid launch payload length'));
|
|
46
|
+
}
|
|
47
|
+
if (expected !== null && data.length >= expected) {
|
|
48
|
+
try {
|
|
49
|
+
const payload = JSON.parse(data.subarray(0, expected).toString('utf8'));
|
|
50
|
+
if (!validPayload(payload)) return finish(new Error('invalid launch payload'));
|
|
51
|
+
finish(null, payload);
|
|
52
|
+
} catch (error) { finish(error); }
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
socket.once('error', (error) => finish(error));
|
|
56
|
+
socket.once('end', () => { if (!done) finish(new Error('launch broker closed early')); });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main(argv = process.argv.slice(2), seams = {}) {
|
|
61
|
+
const parsed = parseArgs(argv);
|
|
62
|
+
if (!parsed) throw new Error('usage: cell-exec --socket <path> --nonce <hex>');
|
|
63
|
+
const payload = await (seams.receivePayload || receivePayload)(parsed.socketPath, parsed.nonce);
|
|
64
|
+
const spawnImpl = seams.spawn || spawn;
|
|
65
|
+
const child = spawnImpl(payload.command, payload.args, { env: payload.env, stdio: 'inherit' });
|
|
66
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
67
|
+
process.once(signal, () => { try { child.kill(signal); } catch (_) {} });
|
|
68
|
+
}
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
child.once('error', reject);
|
|
71
|
+
child.once('exit', (code, signal) => resolve(signal ? 128 : (code == null ? 1 : code)));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (require.main === module) {
|
|
76
|
+
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
77
|
+
process.stderr.write(`nexuscrew cell launch failed: ${error.message}\n`); process.exitCode = 1;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { parseArgs, validPayload, receivePayload, main };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { validEnvKey } = require('./env-key.js');
|
|
8
|
+
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
11
|
+
const MAX_VALUE_BYTES = 16 * 1024;
|
|
12
|
+
|
|
13
|
+
function credentialsPath(cfg = {}, home = cfg.home || os.homedir()) {
|
|
14
|
+
return cfg.credentialsPath || process.env.NEXUSCREW_CREDENTIALS_FILE
|
|
15
|
+
|| path.join(home, '.nexuscrew', 'credentials.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ownedByCurrentUser(st) {
|
|
19
|
+
return typeof process.getuid !== 'function' || st.uid === process.getuid();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safePrivateDir(dir, { create = false } = {}) {
|
|
23
|
+
let st;
|
|
24
|
+
try { st = fs.lstatSync(dir); }
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== 'ENOENT' || !create) throw error;
|
|
27
|
+
fs.mkdirSync(dir, { recursive: false, mode: 0o700 });
|
|
28
|
+
st = fs.lstatSync(dir);
|
|
29
|
+
}
|
|
30
|
+
if (!st.isSymbolicLink() && st.isDirectory() && ownedByCurrentUser(st) && (st.mode & 0o077) && create) {
|
|
31
|
+
fs.chmodSync(dir, 0o700); st = fs.lstatSync(dir);
|
|
32
|
+
}
|
|
33
|
+
if (st.isSymbolicLink() || !st.isDirectory() || !ownedByCurrentUser(st) || (st.mode & 0o077)) {
|
|
34
|
+
throw new Error('unsafe credential directory (must be user-owned mode 0700, not a symlink)');
|
|
35
|
+
}
|
|
36
|
+
return st;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeCredentials(raw) {
|
|
40
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('invalid credential store');
|
|
41
|
+
if (raw.schemaVersion !== SCHEMA_VERSION || !raw.credentials
|
|
42
|
+
|| typeof raw.credentials !== 'object' || Array.isArray(raw.credentials)) {
|
|
43
|
+
throw new Error('invalid credential store schema');
|
|
44
|
+
}
|
|
45
|
+
const credentials = {};
|
|
46
|
+
for (const [key, value] of Object.entries(raw.credentials)) {
|
|
47
|
+
if (!validEnvKey(key) || typeof value !== 'string' || !value
|
|
48
|
+
|| Buffer.byteLength(value) > MAX_VALUE_BYTES || /[\x00\r\n]/.test(value)) {
|
|
49
|
+
throw new Error('invalid credential store entry');
|
|
50
|
+
}
|
|
51
|
+
credentials[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return credentials;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readCredentialStore(cfg = {}, home = cfg.home || os.homedir()) {
|
|
57
|
+
const file = credentialsPath(cfg, home);
|
|
58
|
+
try {
|
|
59
|
+
const parent = path.dirname(file);
|
|
60
|
+
safePrivateDir(parent);
|
|
61
|
+
const st = fs.lstatSync(file);
|
|
62
|
+
if (st.isSymbolicLink() || !st.isFile() || !ownedByCurrentUser(st)
|
|
63
|
+
|| (st.mode & 0o077) || st.size > MAX_FILE_BYTES) {
|
|
64
|
+
throw new Error('unsafe credential store (must be user-owned mode 0600, not a symlink)');
|
|
65
|
+
}
|
|
66
|
+
return normalizeCredentials(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT') return {};
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateCredential(key, value) {
|
|
74
|
+
if (!validEnvKey(key)) throw new Error('invalid credential environment key');
|
|
75
|
+
if (typeof value !== 'string' || !value || Buffer.byteLength(value) > MAX_VALUE_BYTES
|
|
76
|
+
|| /[\x00\r\n]/.test(value)) {
|
|
77
|
+
throw new Error('credential must be 1-16384 bytes without control line breaks');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function atomicWriteStore(file, credentials) {
|
|
82
|
+
const dir = path.dirname(file);
|
|
83
|
+
safePrivateDir(dir, { create: true });
|
|
84
|
+
try {
|
|
85
|
+
const existing = fs.lstatSync(file);
|
|
86
|
+
if (existing.isSymbolicLink() || !existing.isFile() || !ownedByCurrentUser(existing)
|
|
87
|
+
|| (existing.mode & 0o077)) throw new Error('unsafe existing credential store');
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code !== 'ENOENT') throw error;
|
|
90
|
+
}
|
|
91
|
+
const body = `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, credentials }, null, 2)}\n`;
|
|
92
|
+
if (Buffer.byteLength(body) > MAX_FILE_BYTES) throw new Error('credential store is too large');
|
|
93
|
+
const tmp = path.join(dir, `.credentials.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
94
|
+
let fd;
|
|
95
|
+
try {
|
|
96
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
97
|
+
fs.writeFileSync(fd, body, 'utf8');
|
|
98
|
+
fs.fsyncSync(fd);
|
|
99
|
+
fs.closeSync(fd); fd = undefined;
|
|
100
|
+
fs.chmodSync(tmp, 0o600);
|
|
101
|
+
fs.renameSync(tmp, file);
|
|
102
|
+
// Best effort directory fsync: supported on Linux/macOS, harmlessly
|
|
103
|
+
// skipped on filesystems that do not permit opening directories.
|
|
104
|
+
try {
|
|
105
|
+
const dfd = fs.openSync(dir, 'r');
|
|
106
|
+
try { fs.fsyncSync(dfd); } finally { fs.closeSync(dfd); }
|
|
107
|
+
} catch (_) {}
|
|
108
|
+
} finally {
|
|
109
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
|
|
110
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function setCredential(cfg, key, value, home = cfg.home || os.homedir()) {
|
|
115
|
+
validateCredential(key, value);
|
|
116
|
+
safePrivateDir(path.dirname(credentialsPath(cfg, home)), { create: true });
|
|
117
|
+
const current = readCredentialStore(cfg, home);
|
|
118
|
+
atomicWriteStore(credentialsPath(cfg, home), { ...current, [key]: value });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function removeCredential(cfg, key, home = cfg.home || os.homedir()) {
|
|
122
|
+
if (!validEnvKey(key)) throw new Error('invalid credential environment key');
|
|
123
|
+
safePrivateDir(path.dirname(credentialsPath(cfg, home)), { create: true });
|
|
124
|
+
const current = readCredentialStore(cfg, home);
|
|
125
|
+
if (!Object.prototype.hasOwnProperty.call(current, key)) return false;
|
|
126
|
+
delete current[key];
|
|
127
|
+
atomicWriteStore(credentialsPath(cfg, home), current);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
SCHEMA_VERSION, MAX_FILE_BYTES, MAX_VALUE_BYTES,
|
|
133
|
+
credentialsPath, readCredentialStore, setCredential, removeCredential,
|
|
134
|
+
safePrivateDir, validateCredential,
|
|
135
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared by managed providers, the local credential store and the HTTP API.
|
|
4
|
+
// Mixed-case names are valid POSIX environment keys and are intentionally
|
|
5
|
+
// preserved for custom providers.
|
|
6
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
7
|
+
|
|
8
|
+
function validEnvKey(value) {
|
|
9
|
+
return typeof value === 'string' && ENV_KEY_RE.test(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { ENV_KEY_RE, validEnvKey };
|