@mmmbuto/nexuscrew 0.8.12 → 0.8.13

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DrroT7uq.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-PkKeNfT7.css">
14
+ <script type="module" crossorigin src="/assets/index-DuIR61l-.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-4rNd0SwZ.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.12"}
1
+ {"version":"0.8.13"}
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { isValidSession } = require('../files/store.js');
5
+ const { submitTextOk } = require('../tmux/actions.js');
6
+
7
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
8
+ const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
9
+ const MESSAGE_ID_RE = /^[a-f0-9-]{16,64}$/;
10
+
11
+ function publicCells(status, instanceId, now = Date.now()) {
12
+ if (!NODE_ID_RE.test(String(instanceId || '')) || !status || status.available !== true
13
+ || !Array.isArray(status.cells)) return [];
14
+ const seen = new Set();
15
+ const cells = [];
16
+ for (const raw of status.cells) {
17
+ if (!raw || !CELL_ID_RE.test(String(raw.cell || ''))
18
+ || !isValidSession(raw.tmuxSession) || seen.has(raw.cell)) continue;
19
+ seen.add(raw.cell);
20
+ // Provider external legacy may not expose `tmux`; an explicit false still
21
+ // wins, while the historical `active:true` contract remains compatible.
22
+ const active = raw.active === true && raw.tmux !== false;
23
+ cells.push({
24
+ id: `${instanceId}:${raw.cell}`,
25
+ instanceId,
26
+ cell: raw.cell,
27
+ tmuxSession: raw.tmuxSession,
28
+ engine: typeof raw.engine === 'string' ? raw.engine : '',
29
+ model: typeof raw.model === 'string' ? raw.model : '',
30
+ active,
31
+ canReceive: active,
32
+ lastSeen: active ? now : null,
33
+ });
34
+ }
35
+ return cells;
36
+ }
37
+
38
+ function parseVisited(req) {
39
+ const raw = String(req.headers['x-nexuscrew-visited'] || '');
40
+ if (!raw) return [];
41
+ const ids = raw.split(',');
42
+ if (!ids.length || ids.length > 5 || ids.some((id) => !NODE_ID_RE.test(id))
43
+ || new Set(ids).size !== ids.length) return null;
44
+ return ids;
45
+ }
46
+
47
+ function validIdentity(value) {
48
+ return value && typeof value === 'object' && !Array.isArray(value)
49
+ && NODE_ID_RE.test(String(value.instanceId || ''))
50
+ && CELL_ID_RE.test(String(value.cell || ''))
51
+ && isValidSession(value.tmuxSession);
52
+ }
53
+
54
+ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now = () => Date.now() }) {
55
+ const r = express.Router();
56
+
57
+ async function status() {
58
+ const fleet = await fleetP;
59
+ if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') {
60
+ return { available: false, cells: [] };
61
+ }
62
+ return fleet.status();
63
+ }
64
+
65
+ r.get('/', async (_req, res) => {
66
+ try {
67
+ const nodeId = instanceId();
68
+ const st = await status();
69
+ res.json({
70
+ instanceId: NODE_ID_RE.test(String(nodeId || '')) ? nodeId : null,
71
+ available: st.available === true,
72
+ at: now(),
73
+ cells: publicCells(st, nodeId, now()),
74
+ });
75
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
76
+ });
77
+
78
+ r.post('/send', express.json({ limit: '16kb' }), async (req, res) => {
79
+ if (readonly()) return res.status(403).json({ error: 'READONLY: invio cella bloccato' });
80
+ const body = req.body || {};
81
+ const keys = Object.keys(body);
82
+ if (keys.some((key) => !['id', 'from', 'to', 'message'].includes(key))
83
+ || !MESSAGE_ID_RE.test(String(body.id || ''))
84
+ || !validIdentity(body.from) || !validIdentity(body.to)
85
+ || !submitTextOk(body.message)) {
86
+ return res.status(400).json({ error: 'messaggio cella non valido' });
87
+ }
88
+ const localId = instanceId();
89
+ if (!NODE_ID_RE.test(String(localId || '')) || body.to.instanceId !== localId) {
90
+ return res.status(409).json({ error: 'destinazione non appartiene a questo nodo' });
91
+ }
92
+ const visited = parseVisited(req);
93
+ if (visited === null || (visited.length && (visited.at(-1) !== localId
94
+ || body.from.instanceId !== visited[0]))) {
95
+ return res.status(403).json({ error: 'identita mittente non verificata' });
96
+ }
97
+ if (!visited.length && body.from.instanceId !== localId) {
98
+ return res.status(403).json({ error: 'mittente remoto senza route autenticata' });
99
+ }
100
+ try {
101
+ const cells = publicCells(await status(), localId, now());
102
+ const target = cells.find((cell) => cell.cell === body.to.cell
103
+ && cell.tmuxSession === body.to.tmuxSession);
104
+ if (!target) return res.status(404).json({ error: 'cella destinataria sconosciuta' });
105
+ if (!target.canReceive) return res.status(409).json({ error: 'cella destinataria non attiva' });
106
+ const label = `${body.from.cell}@${body.from.instanceId.slice(0, 8)}`;
107
+ // End on printable text even when the source message ends in a newline:
108
+ // Pi may auto-submit a bracketed paste that ends with LF. NexusCrew owns
109
+ // the single explicit Enter used by the transport.
110
+ const envelope = `[NexusCrew message ${body.id} from ${label}]\n${body.message}\n[End NexusCrew message]`;
111
+ const outcome = await submit(target.tmuxSession, envelope, { engine: target.engine });
112
+ if (!outcome || outcome.submitted !== true) {
113
+ return res.status(409).json({ error: outcome?.reason || 'consegna non riuscita' });
114
+ }
115
+ const at = now();
116
+ return res.json({
117
+ id: body.id,
118
+ status: 'submitted',
119
+ at,
120
+ to: { instanceId: localId, cell: target.cell, tmuxSession: target.tmuxSession },
121
+ note: 'submitted conferma solo paste+Enter nel TUI, non elaborazione o completamento',
122
+ });
123
+ } catch (e) { return res.status(500).json({ error: String(e.message || e) }); }
124
+ });
125
+
126
+ r.use((err, _req, res, _next) => {
127
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
128
+ return res.status(413).json({ error: 'body troppo grande' });
129
+ }
130
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
131
+ return res.status(err.status || 400).json({ error: String(err.message || err) });
132
+ });
133
+
134
+ return r;
135
+ }
136
+
137
+ module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity };
@@ -59,7 +59,9 @@ function generateService(platform, ctx) {
59
59
  function generateLinux(ctx) {
60
60
  assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
61
61
  assertSystemdSafe('nodeBin', ctx.nodeBin);
62
- const repo = escapeSystemdPath(ctx.repoRoot);
62
+ const workDir = ctx.runtimeDir || path.join(ctx.home, '.nexuscrew');
63
+ assertSystemdSafe('runtimeDir', workDir);
64
+ const runtime = escapeSystemdPath(workDir);
63
65
  const node = escapeSystemdExec(ctx.nodeBin);
64
66
  const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
65
67
  const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
@@ -74,7 +76,7 @@ Type=simple
74
76
  # The HTTP service may be the process that creates the shared tmux server.
75
77
  # Restart only NexusCrew itself; tmux sessions are independent user workloads.
76
78
  KillMode=process
77
- WorkingDirectory=${repo}
79
+ WorkingDirectory=${runtime}
78
80
  Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
79
81
  ExecStart=${node} ${repoBin} serve
80
82
  Restart=on-failure
@@ -88,7 +90,7 @@ WantedBy=default.target
88
90
  function generateMac(ctx) {
89
91
  const nodeXml = escapeXml(ctx.nodeBin);
90
92
  const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
91
- const repoXml = escapeXml(ctx.repoRoot);
93
+ const runtimeXml = escapeXml(ctx.runtimeDir || path.join(ctx.home, '.nexuscrew'));
92
94
  const homeXml = escapeXml(ctx.home);
93
95
  const launchPath = [...new Set([
94
96
  path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
@@ -107,11 +109,15 @@ function generateMac(ctx) {
107
109
  <string>serve</string>
108
110
  </array>
109
111
  <key>WorkingDirectory</key>
110
- <string>${repoXml}</string>
112
+ <string>${runtimeXml}</string>
111
113
  <key>EnvironmentVariables</key>
112
114
  <dict>
113
115
  <key>PATH</key>
114
116
  <string>${launchPathXml}</string>
117
+ <key>LANG</key>
118
+ <string>en_US.UTF-8</string>
119
+ <key>LC_CTYPE</key>
120
+ <string>UTF-8</string>
115
121
  </dict>
116
122
  <key>RunAtLoad</key>
117
123
  <true/>
@@ -132,14 +138,15 @@ function generateMac(ctx) {
132
138
  function generateTermux(ctx) {
133
139
  const nodeQ = shellQuote(ctx.nodeBin);
134
140
  const repoBinQ = shellQuote(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
135
- const repoQ = shellQuote(ctx.repoRoot);
136
141
  return `#!/data/data/com.termux/files/usr/bin/sh
137
142
  # NexusCrew boot (Termux) - loopback, localhost del telefono
138
143
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
139
144
  export HOME=/data/data/com.termux/files/home
140
- cd -- ${repoQ}
145
+ export LANG=\${LANG:-en_US.UTF-8}
146
+ export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
141
147
  termux-wake-lock 2>/dev/null || true
142
148
  mkdir -p "$HOME/.nexuscrew"
149
+ cd -- "$HOME/.nexuscrew"
143
150
  exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
144
151
  `;
145
152
  }
@@ -229,6 +236,11 @@ function installService(platform, content, ctx, { dryRun = false, execImpl = exe
229
236
  return { target, mode, written: false, failures: [], note: 'dry-run: nessuna scrittura' };
230
237
  }
231
238
 
239
+ const runtimeDir = ctx.runtimeDir || path.join(home, '.nexuscrew');
240
+ // A real install always has an existing home. Test/dry relocation may pass a
241
+ // synthetic home together with a custom installPath; do not escape that
242
+ // relocation merely to create a WorkingDirectory that will never be used.
243
+ if (fs.existsSync(home) || ctx.runtimeDir) fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
232
244
  fs.mkdirSync(path.dirname(target), { recursive: true });
233
245
  // temp file stessa dir (per atomic rename su stesso filesystem)
234
246
  const tmp = target + '.tmp.' + process.pid;
package/lib/config.js CHANGED
@@ -30,6 +30,9 @@ function baseDefaults() {
30
30
  fleetEnabled: true,
31
31
  fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
32
32
  providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
33
+ // Existing user-owned shell exports. NexusCrew parses simple assignments
34
+ // as data; it never executes/sources this file and never copies values.
35
+ providerShellPath: path.join(os.homedir(), '.config', 'ai-shell', 'providers.zsh'),
33
36
  // Installazioni npm globali controllano periodicamente il dist-tag latest.
34
37
  // Il manager aggiorna solo verso una semver superiore: mai downgrade.
35
38
  autoUpdate: true,
@@ -66,6 +69,7 @@ function envOverrides() {
66
69
  if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
67
70
  if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
68
71
  if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
72
+ if (process.env.NEXUSCREW_PROVIDER_SHELL) e.providerShellPath = process.env.NEXUSCREW_PROVIDER_SHELL;
69
73
  if (process.env.NEXUSCREW_AUTO_UPDATE !== undefined) {
70
74
  e.autoUpdate = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_AUTO_UPDATE).toLowerCase());
71
75
  }
@@ -31,6 +31,7 @@ const {
31
31
  const {
32
32
  publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
33
33
  } = require('./managed.js');
34
+ const { MINIMAL_ENV_KEYS, minimalRuntimeEnv } = require('../runtime/env.js');
34
35
 
35
36
  const STATUS_TTL_MS = 2000;
36
37
 
@@ -40,20 +41,8 @@ const STATUS_TTL_MS = 2000;
40
41
  // Nota: se un server tmux e' gia' in esecuzione (avviato fuori dal service), i comandi
41
42
  // ereditano l'env di quel server; la garanzia dura resta: le definizioni non possono
42
43
  // 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
44
  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;
45
+ return minimalRuntimeEnv(process.env, { home: os.homedir() });
57
46
  }
58
47
 
59
48
  function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
@@ -88,6 +77,32 @@ function redactSecrets(text, engine, cell) {
88
77
  return out;
89
78
  }
90
79
 
80
+ const MAX_EARLY_DIAGNOSTIC = 1200;
81
+
82
+ function sanitizeEarlyDiagnostic(text, engine, cell, home) {
83
+ let out = redactSecrets(String(text || ''), engine, cell);
84
+ // ANSI CSI/OSC e byte di controllo non devono arrivare nell'errore JSON/UI.
85
+ out = out.replace(/\x1b\][^\x07]*(?:\x07|$)/g, '')
86
+ .replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
87
+ let clean = '';
88
+ for (let i = 0; i < out.length; i += 1) {
89
+ const code = out.charCodeAt(i);
90
+ if (code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)) clean += out[i];
91
+ }
92
+ out = clean;
93
+ if (typeof home === 'string' && home) out = out.split(home).join('~');
94
+ out = out
95
+ .replace(/\bBearer\s+\S+/gi, `Bearer ${REDACTED}`)
96
+ .replace(/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|AUTH)[A-Z0-9_]*)(\s*[:=]\s*)\S+/g,
97
+ (_m, key, sep) => `${key}${sep}${REDACTED}`)
98
+ .replace(/\b(?:sk|fw|fpk|hf|zai)-[A-Za-z0-9._-]{8,}\b/gi, REDACTED);
99
+ const lines = out.split(/\r?\n/).map((line) => line.trimEnd())
100
+ .filter((line) => line.trim() && !/^Pane is dead \(status /i.test(line.trim()));
101
+ out = lines.join('\n').trim();
102
+ if (out.length > MAX_EARLY_DIAGNOSTIC) out = `…${out.slice(-(MAX_EARLY_DIAGNOSTIC - 1))}`;
103
+ return out;
104
+ }
105
+
91
106
  // Esecutore tmux: argv diretto (MAI shell). Risolve sempre {err,stdout,stderr,code}
92
107
  // cosi' il chiamante distingue "sessione assente" (code!==0 atteso) da errori reali.
93
108
  function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
@@ -152,6 +167,25 @@ async function waitAlive(tmuxBin, session, { env, readyMs }) {
152
167
  }
153
168
  }
154
169
 
170
+ async function waitStablePane(tmuxBin, target, { env, readyMs }) {
171
+ const deadline = Date.now() + Math.max(0, readyMs | 0);
172
+ for (;;) {
173
+ const state = await tmuxExec(tmuxBin,
174
+ ['display-message', '-p', '-t', target, '#{pane_dead}\t#{pane_dead_status}\t#{pane_id}'],
175
+ { env, timeoutMs: 2000 });
176
+ if (state.err) return { alive: false, status: null, target: null };
177
+ const [dead, rawStatus, paneId] = state.stdout.trim().split('\t');
178
+ if (!/^%[0-9]+$/.test(paneId || '')) return { alive: false, status: null, target: null };
179
+ if (dead === '1') {
180
+ const status = /^-?[0-9]+$/.test(rawStatus || '') ? Number(rawStatus) : null;
181
+ return { alive: false, status, target: paneId };
182
+ }
183
+ if (dead !== '0') return { alive: false, status: null, target: null };
184
+ if (Date.now() >= deadline) return { alive: true, status: null, target: paneId };
185
+ await sleep(60);
186
+ }
187
+ }
188
+
155
189
  // Iniezione prompt send-keys via bracketed paste (come skills/.../nc-send):
156
190
  // load-buffer del prompt in un buffer nominato + paste-buffer -p (bracketed),
157
191
  // poi cleanup. Readiness best-effort: se la sessione non e' viva quando paste-iamo
@@ -257,12 +291,14 @@ async function createBuiltinFleet(cfg = {}) {
257
291
  function findEngine(defs, id) { return defs.engines.find((e) => e.id === id) || null; }
258
292
 
259
293
  async function refreshSessions() {
260
- const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { env: minimalEnv() });
294
+ const r = await tmuxExec(tmuxBin, ['list-sessions', '-F', '#{session_name}\t#{session_windows}'], { env: minimalEnv() });
261
295
  if (r.err) return new Set(); // nessun server / nessuna sessione
262
296
  const set = new Set();
263
297
  for (const line of r.stdout.split('\n')) {
264
- const n = line.trim();
265
- if (n) set.add(n);
298
+ const [rawName, rawWindows] = line.split('\t');
299
+ const n = String(rawName || '').trim();
300
+ const windows = rawWindows === undefined ? 1 : Number(rawWindows);
301
+ if (n && Number.isFinite(windows) && windows > 0) set.add(n);
266
302
  }
267
303
  return set;
268
304
  }
@@ -362,6 +398,10 @@ async function createBuiltinFleet(cfg = {}) {
362
398
  // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
363
399
  const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: launchEngine, cell });
364
400
  argv.splice(2, 0, '-P', '-F', '#{pane_id}');
401
+ // Mantieni il pane morto solo durante la finestra di readiness: permette di
402
+ // catturare un errore reale del client senza lasciare una sessione fantasma.
403
+ // Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
404
+ argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
365
405
  const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
366
406
  if (launch.err) {
367
407
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
@@ -376,17 +416,34 @@ async function createBuiltinFleet(cfg = {}) {
376
416
  // this readiness gate the PWA reported success and then showed nothing.
377
417
  // Always verify liveness, including cells without a system prompt.
378
418
  const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
379
- const alive = await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs });
380
- if (!alive) {
419
+ const paneId = launch.stdout.trim().split('\n')[0] || '';
420
+ const readiness = paneId.startsWith('%')
421
+ ? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
422
+ : { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
423
+ if (!readiness.alive) {
424
+ let diagnostic = '';
425
+ if (readiness.target) {
426
+ const captured = await tmuxExec(tmuxBin,
427
+ ['capture-pane', '-p', '-S', '-80', '-t', readiness.target], { env: minimalEnv(), timeoutMs: 2000 });
428
+ if (!captured.err) diagnostic = sanitizeEarlyDiagnostic(captured.stdout, launchEngine, cell, home);
429
+ }
430
+ // remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
431
+ // nella Fleet o nella lista tmux dopo aver raccolto l'errore.
432
+ await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
381
433
  cache = { ...cache, at: 0 };
382
- throw httpError(500, `client ${launchEngine.command} terminato subito: verifica login, provider, modello e argomenti dell'engine`);
434
+ const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
435
+ const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
436
+ throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
437
+ }
438
+ if (readiness.target) {
439
+ await tmuxExec(tmuxBin,
440
+ ['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
383
441
  }
384
442
 
385
443
  // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
386
444
  // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
387
445
  let prompt = null;
388
446
  if (launchEngine.promptMode === 'send-keys' && cell.prompt) {
389
- const paneId = launch.stdout.trim().split('\n')[0] || '';
390
447
  const target = paneId.startsWith('%') ? paneId : `=${cell.tmuxSession}`;
391
448
  prompt = await injectPrompt(tmuxBin, cell.tmuxSession, cell.prompt, {
392
449
  env: minimalEnv(),
@@ -605,6 +662,68 @@ async function createBuiltinFleet(cfg = {}) {
605
662
  .filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
606
663
  };
607
664
  }
665
+
666
+ // Ripristino engine separato e atomico. Il formato portatile non accetta mai
667
+ // `env` values: per un engine custom nuovo parte da env vuoto; in overwrite
668
+ // conserva gli eventuali valori write-only già configurati sul target.
669
+ async function restoreEngines(engines, opts = {}) {
670
+ if (readonly()) throw httpError(403, 'READONLY: restore-engines bloccato');
671
+ if (!Array.isArray(engines) || engines.length < 1 || engines.length > 24) {
672
+ throw httpError(400, 'engines deve contenere 1..24 definizioni');
673
+ }
674
+ const allowed = new Set(['id', 'label', 'rc', 'managed', 'command', 'args', 'envKeys', 'model', 'promptMode', 'promptFlag']);
675
+ const seen = new Set();
676
+ for (const engine of engines) {
677
+ if (!engine || typeof engine !== 'object' || Array.isArray(engine)) throw httpError(400, 'definizione engine non valida');
678
+ for (const key of Object.keys(engine)) if (!allowed.has(key)) throw httpError(400, `campo engine backup non ammesso: ${key}`);
679
+ if (typeof engine.id !== 'string' || seen.has(engine.id)) throw httpError(400, `id engine duplicato o mancante: ${engine.id || '?'}`);
680
+ if (engine.managed && engine.envKeys !== undefined) throw httpError(400, `envKeys non ammesso per engine managed: ${engine.id}`);
681
+ if (!engine.managed && (!Array.isArray(engine.envKeys)
682
+ || engine.envKeys.length > 32
683
+ || engine.envKeys.some((key) => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key))
684
+ || new Set(engine.envKeys).size !== engine.envKeys.length)) {
685
+ throw httpError(400, `envKeys non valido per engine: ${engine.id}`);
686
+ }
687
+ if (!engine.managed && Array.isArray(engine.args) && engine.args.some((arg) => {
688
+ const text = String(arg || '');
689
+ return /(?:bearer\s+|authorization\s*[:=]|(?:api[_-]?key|secret|token)\s*[:=])/i.test(text)
690
+ || /^-{1,2}(?:api[-_]?key|access[-_]?key|auth(?:orization)?[-_]?token|token|secret|password|credential)(?:$|=)/i.test(text)
691
+ || /\b(?:sk|fw|fpk|hf|zai)[-_][A-Za-z0-9._-]{8,}\b/i.test(text)
692
+ || /https?:\/\/[^\s/@:]+:[^\s/@]+@/i.test(text);
693
+ })) {
694
+ throw httpError(400, `argomento potenzialmente segreto non ammesso per engine: ${engine.id}`);
695
+ }
696
+ seen.add(engine.id);
697
+ }
698
+ const defs = reloadDefs();
699
+ const conflicts = engines.filter((engine) => !!findEngine(defs, engine.id)).map((engine) => engine.id);
700
+ if (conflicts.length && opts.overwrite !== true) {
701
+ throw httpError(409, `engine già esistenti: ${conflicts.join(', ')}`, { code: 'engine-conflicts', conflicts });
702
+ }
703
+ const sessions = await refreshSessions();
704
+ const affected = defs.cells.filter((cell) => conflicts.includes(cell.engine) && sessions.has(cell.tmuxSession)).map((cell) => cell.id);
705
+ await mutate(defs, (draft) => {
706
+ for (const portable of engines) {
707
+ const index = draft.engines.findIndex((current) => current.id === portable.id);
708
+ const current = index >= 0 ? draft.engines[index] : null;
709
+ const next = { ...portable };
710
+ if (!next.managed) {
711
+ const keys = next.envKeys;
712
+ delete next.envKeys;
713
+ next.env = Object.fromEntries(keys.map((key) => [key,
714
+ current && !current.managed && Object.prototype.hasOwnProperty.call(current.env || {}, key)
715
+ ? current.env[key] : '',
716
+ ]));
717
+ }
718
+ if (index >= 0) draft.engines[index] = next; else draft.engines.push(next);
719
+ }
720
+ });
721
+ return {
722
+ ok: true, count: engines.length,
723
+ created: engines.filter((engine) => !conflicts.includes(engine.id)).map((engine) => engine.id),
724
+ replaced: conflicts, needsRestart: affected,
725
+ };
726
+ }
608
727
  async function removeCell(id, opts = {}) {
609
728
  if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
610
729
  let defs = reloadDefs();
@@ -694,7 +813,10 @@ async function createBuiltinFleet(cfg = {}) {
694
813
  ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
695
814
  })),
696
815
  managedCatalog: publicCatalog(),
697
- managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
816
+ managedConfig: {
817
+ providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
818
+ providerShellPath: cfg.providerShellPath || path.join(home, '.config', 'ai-shell', 'providers.zsh'),
819
+ },
698
820
  };
699
821
  }
700
822
 
@@ -776,7 +898,7 @@ async function createBuiltinFleet(cfg = {}) {
776
898
  provider: 'builtin',
777
899
  status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
778
900
  defineEngine, editEngine, removeEngine,
779
- defineCell, editCell, removeCell, importCell, restoreCells,
901
+ defineCell, editCell, removeCell, importCell, restoreCells, restoreEngines,
780
902
  schema, definitions, capabilities,
781
903
  };
782
904
  }
@@ -787,5 +909,7 @@ module.exports = {
787
909
  minimalEnv,
788
910
  promptCharsOk,
789
911
  redactSecrets,
912
+ sanitizeEarlyDiagnostic,
913
+ waitStablePane,
790
914
  MINIMAL_ENV_KEYS,
791
915
  };
@@ -140,24 +140,38 @@ function defaultDefinitions() {
140
140
  };
141
141
  }
142
142
 
143
- function parseEnvFile(file) {
143
+ function parseAssignments(raw) {
144
144
  const out = {};
145
- let raw;
146
- try {
147
- const st = fs.lstatSync(file);
148
- if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077)) return out;
149
- raw = fs.readFileSync(file, 'utf8');
150
- } catch (_) { return out; }
151
145
  for (const line of raw.split(/\r?\n/)) {
152
146
  const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
153
147
  if (!m) continue;
154
148
  let v = m[2];
155
149
  if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
150
+ // This is a data parser, not a shell. Reject syntax that would have a
151
+ // different meaning if sourced instead of treating it as a credential.
152
+ if (/\$\(|`|\x00|\r|\n/.test(v)) continue;
156
153
  out[m[1]] = v;
157
154
  }
158
155
  return out;
159
156
  }
160
157
 
158
+ function parseEnvFile(file) {
159
+ try {
160
+ const st = fs.lstatSync(file);
161
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077) || st.size > 256 * 1024) return {};
162
+ return parseAssignments(fs.readFileSync(file, 'utf8'));
163
+ } catch (_) { return {}; }
164
+ }
165
+
166
+ function parseProviderShellFile(file) {
167
+ try {
168
+ const st = fs.lstatSync(file);
169
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o022) || st.size > 256 * 1024) return {};
170
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return {};
171
+ return parseAssignments(fs.readFileSync(file, 'utf8'));
172
+ } catch (_) { return {}; }
173
+ }
174
+
161
175
  function binaryCandidates(client, home) {
162
176
  const prefix = process.env.PREFIX || '';
163
177
  const bin = client;
@@ -180,17 +194,51 @@ function findBinary(client, home) {
180
194
  return null;
181
195
  }
182
196
 
197
+ // Termux reports process.platform === 'android' and deliberately has no
198
+ // /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
199
+ // `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
200
+ // client starts. Detect only that explicit Node shebang and invoke it through
201
+ // the already-running trusted Node executable. Native and shell binaries keep
202
+ // their original direct-exec path.
203
+ function needsExplicitNode(binary, platform = process.platform) {
204
+ if (platform !== 'android') return false;
205
+ try {
206
+ const fd = fs.openSync(binary, 'r');
207
+ try {
208
+ const buffer = Buffer.alloc(160);
209
+ const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
210
+ const first = buffer.subarray(0, length).toString('utf8').split(/\r?\n/, 1)[0];
211
+ return /^#!\s*\/usr\/bin\/env(?:\s+-S)?\s+node(?:\s|$)/.test(first);
212
+ } finally { fs.closeSync(fd); }
213
+ } catch (_) { return false; }
214
+ }
215
+
183
216
  function secretsPath(cfg, home) {
184
217
  return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS || path.join(home, '.nexuscrew', 'providers.env');
185
218
  }
186
219
 
220
+ function shellProvidersPath(cfg, home) {
221
+ return cfg.providerShellPath || process.env.NEXUSCREW_PROVIDER_SHELL
222
+ || path.join(home, '.config', 'ai-shell', 'providers.zsh');
223
+ }
224
+
225
+ function credentialSources(cfg, home) {
226
+ return {
227
+ runtime: cfg.env || process.env,
228
+ shell: parseProviderShellFile(shellProvidersPath(cfg, home)),
229
+ legacy: parseEnvFile(secretsPath(cfg, home)),
230
+ };
231
+ }
232
+
187
233
  function credential(profile, spec, cfg, home) {
188
234
  if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '' };
189
235
  const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
190
- const runtimeEnv = cfg.env || process.env;
191
- let value = runtimeEnv[envKey] || '';
192
- // Preserve 0.8.0 Z.AI/Ollama Cloud behavior, but new Custom never reads disk.
193
- if (!value && profile.legacySecrets) value = parseEnvFile(secretsPath(cfg, home))[envKey] || '';
236
+ const sources = credentialSources(cfg, home);
237
+ // The fixed shell file is already the user's environment source. Values are
238
+ // consumed only in memory and passed to the selected child; never persisted
239
+ // in fleet.json, service files, API responses or logs.
240
+ let value = sources.runtime[envKey] || sources.shell[envKey] || '';
241
+ if (!value && profile.legacySecrets) value = sources.legacy[envKey] || '';
194
242
  return { envKey, value };
195
243
  }
196
244
 
@@ -202,7 +250,9 @@ async function discoverOllamaModels(opts = {}) {
202
250
  if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
203
251
  try {
204
252
  const home = opts.home || require('node:os').homedir();
205
- const apiKey = opts.apiKey || (opts.env || process.env).OLLAMA_API_KEY || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
253
+ const sources = credentialSources(opts, home);
254
+ const apiKey = opts.apiKey || sources.runtime.OLLAMA_API_KEY
255
+ || sources.shell.OLLAMA_API_KEY || sources.legacy.OLLAMA_API_KEY;
206
256
  if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
207
257
  const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
208
258
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -393,7 +443,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
393
443
  if (model) args.push('--model', model);
394
444
  }
395
445
  if (cell?.prompt) args.push(cell.prompt);
396
- return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
446
+ let command = info.binary;
447
+ if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
448
+ command = cfg.nodeExecPath || process.execPath;
449
+ args.unshift(info.binary);
450
+ }
451
+ return { ok: true, info, engine: {
452
+ ...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
453
+ } };
397
454
  }
398
455
 
399
456
  function publicCatalog() {
@@ -410,6 +467,6 @@ function publicCatalog() {
410
467
 
411
468
  module.exports = {
412
469
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
413
- defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
414
- discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
470
+ defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
471
+ discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
415
472
  };
@@ -27,7 +27,7 @@ function fleetRoutes(fleetP, cfg = {}) {
27
27
  const r = express.Router();
28
28
  const smallJson = express.json({ limit: '4kb' });
29
29
  const restoreJson = express.json({ limit: '256kb' });
30
- r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
30
+ r.use((req, res, next) => (req.path === '/restore-cells' || req.path === '/restore-engines' ? restoreJson : smallJson)(req, res, next));
31
31
 
32
32
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
33
33
 
@@ -113,6 +113,11 @@ function fleetRoutes(fleetP, cfg = {}) {
113
113
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
114
114
  r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
115
115
  r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
116
+ r.post('/restore-engines', guard((f, b) => {
117
+ requireCap(f, 'restore');
118
+ if (typeof f.restoreEngines !== 'function') { const e = new Error('not supported by this fleet provider'); e.status = 501; throw e; }
119
+ return f.restoreEngines(b.engines, { overwrite: b.overwrite === true });
120
+ }, { mutate: true }));
116
121
  // Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
117
122
  // gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
118
123
  r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));