@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.
@@ -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 la sessione SOLO via `tmux -e` (chiavi validate). PATH lo controlla
17
- // il service, mai la definizione.
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 va in session via -e.
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
- const env = {};
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
- // composeLaunchArgv({tmuxSession, realCwd, engine, cell}) -> argv per new-session
119
- // PURA + testabile. No shell: command e args sono argv diretto. model/prompt sono
120
- // aggiunti solo se c'e' un VALORE effettivo (override cella || default engine).
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 composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
123
- const args = ['new-session', '-d', '-s', tmuxSession, '-c', realCwd];
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 n = line.trim();
265
- if (n) set.add(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);
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), env minimale + engine.env via -e.
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
- const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
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 alive = await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs });
380
- if (!alive) {
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
- throw httpError(500, `client ${launchEngine.command} terminato subito: verifica login, provider, modello e argomenti dell'engine`);
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: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
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 };