@mmmbuto/nexuscrew 0.8.26 → 0.8.28

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.
@@ -22,6 +22,7 @@ const os = require('node:os');
22
22
  const path = require('node:path');
23
23
  const { execFile } = require('node:child_process');
24
24
  const { minimalRuntimeEnv } = require('../runtime/env.js');
25
+ const { codeOf, phaseOf } = require('./causes.js');
25
26
 
26
27
  // Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
27
28
  // non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
@@ -34,7 +35,24 @@ function minimalEnv() {
34
35
  return minimalRuntimeEnv(process.env, { home: os.homedir() });
35
36
  }
36
37
 
37
- function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
38
+ // httpError(status, msg, data?, cause?) structured HTTP error. `data` carries
39
+ // arbitrary API detail for the response body; `cause` (T4) is the OPTIONAL
40
+ // bounded failure triple {phase, code} of the up() boundary that failed. The
41
+ // cause is coerced through the closed enum in causes.js (anything not
42
+ // allowlisted degrades to UNKNOWN) and attached as e.fleetCode / e.fleetPhase,
43
+ // so the fleet router can surface {status, code, phase} WITHOUT ever embedding
44
+ // cwd/path, argv, env, prompt, token or credentials. The two channels are kept
45
+ // distinct: `data` is free API detail, `cause` is the bounded failure triple.
46
+ function httpError(status, msg, data = null, cause = null) {
47
+ const e = new Error(msg);
48
+ e.status = status;
49
+ if (data) e.data = data;
50
+ if (cause) {
51
+ e.fleetCode = codeOf(cause.code);
52
+ e.fleetPhase = phaseOf(cause.phase);
53
+ }
54
+ return e;
55
+ }
38
56
 
39
57
  // Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
40
58
  // NON devono mai ecoare i segreti delle definizioni.
@@ -7,6 +7,7 @@ const path = require('node:path');
7
7
  const crypto = require('node:crypto');
8
8
  const { execFile } = require('node:child_process');
9
9
  const { ENV_KEY_RE } = require('./env-key.js');
10
+ const { termuxRuntimePaths } = require('../runtime/env.js');
10
11
  const { readCredentialStore, safePrivateDir } = require('./credentials.js');
11
12
 
12
13
  const OLLAMA_CLOUD_MODELS = Object.freeze([
@@ -60,7 +61,7 @@ const ALIBABA_PI_MODELS = Object.freeze([
60
61
 
61
62
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
62
63
  const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
63
- const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi' });
64
+ const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', shell: 'Shell' });
64
65
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
65
66
 
66
67
  function validBaseUrl(value) {
@@ -108,6 +109,9 @@ const CATALOG = Object.freeze([
108
109
  // Pi uses its real provider IDs directly. OAuth providers do not need env keys.
109
110
  { id: 'pi.native', client: 'pi', provider: 'native', label: 'Pi configured default', auth: 'login', protocol: 'pi_native', default: true, core: true },
110
111
  { id: 'pi.alibaba-token-plan', client: 'pi', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai-completions', model: 'qwen3.8-max-preview', models: ALIBABA_TOKEN_PLAN_MODELS, strictModels: true, piProvider: 'alibaba-token-plan', piExtension: { baseUrl: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', models: ALIBABA_PI_MODELS }, delegatePiAuth: false, core: true, notice: 'alibaba-token-plan' },
112
+ // Device-local shell. Kept after the established default clients so adding
113
+ // it does not change the preselected engine for a newly-created cell.
114
+ { id: 'shell.local', client: 'shell', provider: 'local', label: 'Shell', auth: 'none', protocol: 'shell', default: true, core: true },
111
115
  { id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic', core: true },
112
116
  { id: 'pi.openai', client: 'pi', provider: 'openai', label: 'OpenAI API', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai', core: true },
113
117
  { id: 'pi.openai-codex', client: 'pi', provider: 'openai-codex', label: 'OpenAI Codex OAuth', auth: 'login', protocol: 'pi_native', piProvider: 'openai-codex', core: true },
@@ -147,11 +151,12 @@ function normalizeManagedSpec(value) {
147
151
  if (!profile) return null;
148
152
  const model = value.model === undefined ? (profile.model || '') : value.model;
149
153
  if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
154
+ if (value.client === 'shell' && model) return null;
150
155
  if (profile.requiresModel && !model) return null;
151
156
  if (profile.strictModels && !(profile.models || []).includes(model)) return null;
152
157
  const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
153
158
  if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
154
- if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
159
+ if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
155
160
  const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
156
161
  if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
157
162
  if (profile.credentialEnv) {
@@ -187,6 +192,16 @@ function defaultDefinitions() {
187
192
  };
188
193
  }
189
194
 
195
+ function defaultShellEngine() {
196
+ const profile = CATALOG.find((entry) => entry.id === 'shell.local');
197
+ return {
198
+ id: profile.id,
199
+ label: CLIENT_LABELS.shell,
200
+ rc: false,
201
+ managed: { client: 'shell', provider: 'local', model: '', permissionPolicy: 'standard' },
202
+ };
203
+ }
204
+
190
205
  function parseAssignments(raw) {
191
206
  const out = {};
192
207
  for (const line of raw.split(/\r?\n/)) {
@@ -268,14 +283,50 @@ function findBinary(client, home) {
268
283
  return null;
269
284
  }
270
285
 
286
+ // Resolve a device-local interactive shell without persisting a path in
287
+ // fleet.json. Candidates are ordered and fail closed. Symlinks are resolved
288
+ // first, then the existing command trust policy is applied to the real file.
289
+ function resolveInteractiveShell(cfg = {}) {
290
+ const env = cfg.env || process.env;
291
+ const platform = cfg.platform || process.platform;
292
+ const termux = termuxRuntimePaths(env, { platform, home: cfg.home });
293
+ const candidates = [];
294
+ if (typeof env.SHELL === 'string' && path.isAbsolute(env.SHELL)) candidates.push(env.SHELL);
295
+ if (termux?.prefix) {
296
+ candidates.push(path.join(termux.prefix, 'bin', 'bash'));
297
+ candidates.push(path.join(termux.prefix, 'bin', 'sh'));
298
+ }
299
+ candidates.push('/bin/bash', '/bin/sh');
300
+ const validate = cfg.validateCommandTrust
301
+ || ((command) => require('./definitions.js').validateCommandTrust(command));
302
+ for (const candidate of [...new Set(candidates)]) {
303
+ try {
304
+ const real = fs.realpathSync(candidate);
305
+ if (validate(real).ok) return real;
306
+ } catch (_) { /* next candidate */ }
307
+ }
308
+ return null;
309
+ }
310
+
311
+ function shellLoginArgs(command) {
312
+ return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || ''))) ? ['-l'] : [];
313
+ }
314
+
271
315
  // Termux reports process.platform === 'android' and deliberately has no
272
316
  // /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
273
317
  // `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
274
318
  // client starts. Detect only that explicit Node shebang and invoke it through
275
319
  // the already-running trusted Node executable. Native and shell binaries keep
276
320
  // their original direct-exec path.
277
- function needsExplicitNode(binary, platform = process.platform) {
278
- if (platform !== 'android') return false;
321
+ //
322
+ // Detection uses both process.platform AND the runtime Termux layout (PREFIX /
323
+ // files/home) so that a Node build that reports `linux` while actually running
324
+ // under Termux (proot / custom build) still gets the shebang workaround. The
325
+ // optional `env` argument lets tests inject a synthetic environment; the public
326
+ // two-argument call form is unchanged.
327
+ function needsExplicitNode(binary, platform = process.platform, env = process.env) {
328
+ const termux = platform === 'android' || termuxRuntimePaths(env, { platform }) !== null;
329
+ if (!termux) return false;
279
330
  try {
280
331
  const fd = fs.openSync(binary, 'r');
281
332
  try {
@@ -414,7 +465,9 @@ function describeManaged(spec, cfg = {}) {
414
465
  if (!normalized) return { configured: false, reason: 'invalid managed profile' };
415
466
  const home = cfg.home || require('node:os').homedir();
416
467
  const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
417
- const binary = findBinary(normalized.client, home);
468
+ const binary = normalized.client === 'shell'
469
+ ? resolveInteractiveShell({ ...cfg, home })
470
+ : findBinary(normalized.client, home);
418
471
  const cred = credential(profile, normalized, cfg, home);
419
472
  // Pi can resolve credentials from its own documented /login auth store. Do
420
473
  // not inspect or copy that store; delegate native-provider auth to Pi.
@@ -577,13 +630,20 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
577
630
  let effectivePolicy = (override === 'standard' || override === 'unsafe') ? override : spec.permissionPolicy;
578
631
  // Pi resta sempre 'standard': normalizeManagedSpec rifiuta gia' unsafe nello spec
579
632
  // dell'engine, ma l'override PER-CELL bypasserebbe quel check -> clamp esplicito.
580
- if (spec.client === 'pi') effectivePolicy = 'standard';
633
+ if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
581
634
  info.permissionPolicy = effectivePolicy;
582
635
  if (effectivePolicy === 'unsafe') {
583
636
  if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
584
637
  if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
585
638
  }
586
- if (spec.client === 'claude') {
639
+ let shellOneShot = false;
640
+ if (spec.client === 'shell') {
641
+ const raw = cell?.commands && typeof cell.commands[engineId] === 'string'
642
+ ? cell.commands[engineId] : '';
643
+ shellOneShot = raw.trim().length > 0;
644
+ if (shellOneShot) args.push('-lc', raw);
645
+ else args.push(...shellLoginArgs(info.binary));
646
+ } else if (spec.client === 'claude') {
587
647
  if (spec.provider === 'native') {
588
648
  if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
589
649
  } else if (profile.providerEnv) {
@@ -700,14 +760,15 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
700
760
  if (model) args.push('--model', model);
701
761
  if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
702
762
  }
703
- if (cell?.prompt) args.push(cell.prompt);
763
+ if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
704
764
  let command = info.binary;
705
- if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
765
+ if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
706
766
  command = cfg.nodeExecPath || process.execPath;
707
767
  args.unshift(info.binary);
708
768
  }
709
769
  return { ok: true, info, engine: {
710
770
  ...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
771
+ ...(spec.client === 'shell' ? { shellOneShot } : {}),
711
772
  } };
712
773
  }
713
774
 
@@ -716,7 +777,7 @@ function publicCatalog() {
716
777
  id: p.id, client: p.client, clientLabel: CLIENT_LABELS[p.client], provider: p.provider,
717
778
  credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
718
779
  auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
719
- protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: p.client !== 'pi', requiresModel: !!p.requiresModel || !!p.custom,
780
+ protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: !['pi', 'shell'].includes(p.client), requiresModel: !!p.requiresModel || !!p.custom,
720
781
  permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
721
782
  rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
722
783
  credentialEnv: p.auth === 'dynamic' ? !!p.credentialEnv : (ENV_KEY_RE.test(p.auth || '') ? p.auth : false),
@@ -728,8 +789,8 @@ module.exports = {
728
789
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
729
790
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
730
791
  CLIENT_LABELS, normalizeManagedSpec,
731
- defaultDefinitions, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
792
+ defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
732
793
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
733
794
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
734
- ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, ENV_KEY_RE,
795
+ ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell, shellLoginArgs, ENV_KEY_RE,
735
796
  };
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const express = require('express');
3
+ const { codeOf, phaseOf } = require('./causes.js');
3
4
 
4
5
  // Fallback difensivo per un adapter builtin incompleto nei test. Il provider di
5
6
  // prodotto dichiara sempre la propria capability list.
@@ -29,6 +30,14 @@ function fleetRoutes(fleetP, cfg = {}) {
29
30
  r.use((req, res, next) => (req.path === '/restore-cells' || req.path === '/restore-engines' ? restoreJson : smallJson)(req, res, next));
30
31
 
31
32
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
33
+ const diagnostics = cfg.diagnostics || null;
34
+ const safeCell = (body) => {
35
+ const value = body && typeof body.cell === 'string' ? body.cell : '';
36
+ return /^[A-Za-z0-9._-]{1,32}$/.test(value) ? value : '';
37
+ };
38
+ const emit = (level, code, message, meta) => {
39
+ if (diagnostics && typeof diagnostics.record === 'function') diagnostics.record(level, 'fleet', code, message, meta);
40
+ };
32
41
 
33
42
  const guard = (fn, opts = {}) => async (req, res) => {
34
43
  try {
@@ -36,9 +45,39 @@ function fleetRoutes(fleetP, cfg = {}) {
36
45
  if (opts.mutate && readonly()) return res.status(403).json({ error: 'READONLY: mutazione fleet bloccata' });
37
46
  const fleet = await fleetP;
38
47
  if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
39
- res.json(await fn(fleet, req.body || {}));
48
+ const body = req.body || {};
49
+ if (opts.action) emit('info', 'FLEET_ACTION_STARTED', 'Fleet action started', {
50
+ action: opts.action, cell: safeCell(body), state: 'starting',
51
+ });
52
+ const result = await fn(fleet, body);
53
+ if (opts.action) emit('info', 'FLEET_ACTION_COMPLETED', 'Fleet action completed', {
54
+ action: opts.action, cell: safeCell(body), state: opts.action === 'down' ? 'stopped' : 'ready',
55
+ });
56
+ res.json(result);
40
57
  } catch (e) {
41
- res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
58
+ if (opts.action) {
59
+ const match = String(e && e.message || '').match(/cell spawn failed:\s+([A-Z][A-Z0-9_]{0,31})\s+([A-Za-z0-9._+-]{1,128})/);
60
+ if (match) emit('error', 'CELL_SPAWN_FAILED', 'Cell client spawn failed', {
61
+ action: opts.action, cell: safeCell(req.body), errno: match[1], client: match[2], status: e.status || 500,
62
+ });
63
+ else emit('warn', 'FLEET_ACTION_FAILED', 'Fleet action failed', {
64
+ action: opts.action, cell: safeCell(req.body), state: 'failed',
65
+ status: e.status || 500,
66
+ // Cause-preserving (T4): bounded enum only (lib/fleet/causes.js). The
67
+ // cause triple is {status, code, phase}; no free message, cwd/path,
68
+ // argv, env, prompt, token or credential ever enters here. Untagged /
69
+ // legacy errors degrade to the bounded UNKNOWN fallback.
70
+ code: codeOf(e.fleetCode), phase: phaseOf(e.fleetPhase),
71
+ });
72
+ }
73
+ res.status(e.status || 500).json({
74
+ error: String(e.message || e),
75
+ ...(e.data || {}),
76
+ // Structured, bounded cause on the HTTP error too: stable enum only,
77
+ // never cwd/path, argv, env, prompt, token or credentials. Untagged
78
+ // errors (no fleetCode) keep the historical response shape.
79
+ ...(e.fleetCode ? { code: codeOf(e.fleetCode), phase: phaseOf(e.fleetPhase) } : {}),
80
+ });
42
81
  }
43
82
  };
44
83
 
@@ -82,13 +121,13 @@ function fleetRoutes(fleetP, cfg = {}) {
82
121
  await f.boot(cell, b.boot === true);
83
122
  }
84
123
  return f.up(cell, { engine: b.engine, boot: !!b.boot });
85
- }, { mutate: true }));
124
+ }, { mutate: true, action: 'up' }));
86
125
  r.post('/down', guard(async (f, b) => {
87
126
  const cell = String(b.cell || '');
88
127
  if (b.boot === true && capList(f).includes('edit') && capList(f).includes('boot')) await f.boot(cell, false);
89
128
  return f.down(cell, { boot: !!b.boot });
90
- }, { mutate: true }));
91
- r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true }));
129
+ }, { mutate: true, action: 'down' }));
130
+ r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true, action: 'restart' }));
92
131
  r.post('/engine', guard((f, b) => {
93
132
  const opts = { model: typeof b.model === 'string' ? b.model : '' };
94
133
  if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
@@ -85,7 +85,7 @@ function createBuiltinRuntime(ctx) {
85
85
  const remembered = c.permissionPolicies
86
86
  && (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
87
87
  ? c.permissionPolicies[c.engine] : null;
88
- const effectivePolicy = engineDef?.managed?.client === 'pi'
88
+ const effectivePolicy = ['pi', 'shell'].includes(engineDef?.managed?.client)
89
89
  ? 'standard'
90
90
  : (remembered || engineDefault || '');
91
91
  return {
@@ -149,16 +149,19 @@ function createBuiltinRuntime(ctx) {
149
149
  let launchEngine = engine;
150
150
  if (engine.managed) {
151
151
  const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
152
- if (!resolved.ok) throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`);
152
+ if (!resolved.ok) {
153
+ const code = engine.managed.client === 'shell' ? 'SHELL_NOT_AVAILABLE' : 'ENGINE_UNCONFIGURED';
154
+ throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
155
+ }
153
156
  launchEngine = resolved.engine;
154
157
  }
155
158
 
156
159
  // (2) trust del command PRIMA di lanciare
157
160
  const trust = validateCommandTrust(launchEngine.command);
158
- if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`);
161
+ if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`, null, { phase: 'preflight', code: 'COMMAND_UNTRUSTED' });
159
162
  // (3) cwd reale sotto la home
160
163
  const realCwd = resolveCwd(cell.cwd, home);
161
- if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`);
164
+ if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`, null, { phase: 'preflight', code: 'CWD_INVALID' });
162
165
 
163
166
  // (4)+(5) argv diretto (no shell). Every cell goes through the private
164
167
  // broker-backed supervisor: credentials never enter tmux state/argv and a
@@ -168,32 +171,46 @@ function createBuiltinRuntime(ctx) {
168
171
  // #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
169
172
  const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
170
173
  const child = composeClientInvocation(launchEngine, cell);
171
- const ticket = await launchBroker.issue({
172
- command: child.command,
173
- args: child.args,
174
- env: {
175
- ...minimalEnv(),
176
- ...launchEngine.env,
177
- NEXUSCREW_MCP_SESSION: cell.tmuxSession,
178
- },
179
- supervise: {
180
- enabled: true,
181
- initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
182
- restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
183
- maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
184
- resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
185
- rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
186
- maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
187
- },
188
- ...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
189
- restartPrompt: {
190
- tmuxBin,
191
- tmuxSession: cell.tmuxSession,
192
- prompt: cell.prompt,
193
- readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
174
+ let ticket;
175
+ try {
176
+ ticket = await launchBroker.issue({
177
+ command: child.command,
178
+ args: child.args,
179
+ env: {
180
+ ...minimalEnv(),
181
+ ...launchEngine.env,
182
+ NEXUSCREW_MCP_SESSION: cell.tmuxSession,
194
183
  },
195
- } : {}),
196
- });
184
+ supervise: {
185
+ enabled: !launchEngine.shellOneShot,
186
+ initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
187
+ restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
188
+ maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
189
+ resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
190
+ rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
191
+ maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
192
+ },
193
+ ...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
194
+ restartPrompt: {
195
+ tmuxBin,
196
+ tmuxSession: cell.tmuxSession,
197
+ prompt: cell.prompt,
198
+ readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
199
+ },
200
+ } : {}),
201
+ });
202
+ } catch (brokerErr) {
203
+ const bmsg = String((brokerErr && brokerErr.message) || brokerErr);
204
+ const bcode = /too large/i.test(bmsg) ? 'LAUNCH_BROKER_PAYLOAD'
205
+ : /closed/i.test(bmsg) ? 'LAUNCH_BROKER_CLOSED'
206
+ : /unsafe launch broker/i.test(bmsg) ? 'LAUNCH_BROKER_UNSAFE'
207
+ : 'LAUNCH_BROKER_FAILED';
208
+ const bpublic = bcode === 'LAUNCH_BROKER_PAYLOAD' ? 'launch broker payload rifiutato'
209
+ : bcode === 'LAUNCH_BROKER_CLOSED' ? 'launch broker non disponibile'
210
+ : bcode === 'LAUNCH_BROKER_UNSAFE' ? 'launch broker non sicuro'
211
+ : 'launch broker failed';
212
+ throw httpError(500, bpublic, null, { phase: 'launch-broker', code: bcode });
213
+ }
197
214
  const tmuxLaunchEngine = {
198
215
  command: process.execPath,
199
216
  args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
@@ -204,14 +221,27 @@ function createBuiltinRuntime(ctx) {
204
221
  // Mantieni il pane morto solo durante la finestra di readiness: permette di
205
222
  // catturare un errore reale del client senza lasciare una sessione fantasma.
206
223
  // Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
207
- argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
224
+ if (!launchEngine.shellOneShot) {
225
+ argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
226
+ }
208
227
  const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
209
228
  if (launch.err) {
210
229
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
211
- const why = /duplicate session/i.test(launch.stderr)
230
+ const dup = /duplicate session/i.test(launch.stderr);
231
+ const why = dup
212
232
  ? 'sessione già in esecuzione'
213
233
  : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
214
- throw httpError(/duplicate/i.test(launch.stderr) ? 409 : 500, why);
234
+ throw httpError(dup ? 409 : 500, why, null,
235
+ { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
236
+ }
237
+
238
+ // Un comando Shell valorizzato e' intenzionalmente one-shot: l'uscita del
239
+ // processo, anche immediata o non-zero, e' il risultato del comando e non
240
+ // un crash del client Fleet. Non si mantiene il pane e non si abilita il
241
+ // supervisore; la cella torna inattiva quando la shell termina.
242
+ if (launchEngine.shellOneShot) {
243
+ cache = { ...cache, at: 0 };
244
+ return { ok: true, cell: cellId, session: cell.tmuxSession, prompt: null, oneShot: true };
215
245
  }
216
246
 
217
247
  // `tmux new-session -d` can return 0 even when the launched CLI exits a
@@ -235,7 +265,13 @@ function createBuiltinRuntime(ctx) {
235
265
  cache = { ...cache, at: 0 };
236
266
  const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
237
267
  const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
238
- throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`);
268
+ // Cause-preserving (T4): distinguish a cell-client spawn failure (the
269
+ // captured pane carries the stable 'cell spawn failed:' marker produced by
270
+ // cell-exec.js) from a generic early exit. Both stay on the readiness
271
+ // surface; the spawn branch keeps CELL_SPAWN_FAILED sanitized downstream.
272
+ const isSpawn = /cell spawn failed:/.test(diagnostic);
273
+ throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`, null,
274
+ isSpawn ? { phase: 'spawn-client', code: 'SPAWN_CLIENT_FAILED' } : { phase: 'readiness', code: 'CLIENT_EARLY_EXIT' });
239
275
  }
240
276
  if (readiness.target) {
241
277
  await tmuxExec(tmuxBin,
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+ // Alias locali del viewer per nodi routed. Questo store non partecipa mai a
3
+ // topology, routing, ACL o federation: associa soltanto uno stable instanceId a
4
+ // un'etichetta di display scelta sul dispositivo che ospita la PWA.
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const crypto = require('node:crypto');
9
+
10
+ const SCHEMA_VERSION = 1;
11
+ const INSTANCE_ID_RE = /^[a-f0-9]{16,64}$/;
12
+ const ALIAS_MAX = 64;
13
+ const MAX_ALIASES = 128;
14
+ const MAX_FILE_BYTES = 16 * 1024;
15
+
16
+ function defaultAliasesPath(home = os.homedir()) {
17
+ return path.join(home, '.nexuscrew', 'node-aliases.json');
18
+ }
19
+
20
+ function normalizeAlias(value) {
21
+ if (typeof value !== 'string') return null;
22
+ const alias = value.normalize('NFC').trim();
23
+ if (!alias || alias.length > ALIAS_MAX) return null;
24
+ // Cc/Cf includes newlines, NUL, bidi controls and invisible format chars.
25
+ if (/[\p{Cc}\p{Cf}]/u.test(alias)) return null;
26
+ return alias;
27
+ }
28
+
29
+ function emptyStore() {
30
+ return { version: SCHEMA_VERSION, aliasesByInstanceId: {} };
31
+ }
32
+
33
+ function parseStore(raw) {
34
+ let value;
35
+ try { value = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (_) { return null; }
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
37
+ if (Object.keys(value).some((key) => !['version', 'aliasesByInstanceId'].includes(key))) return null;
38
+ if (value.version !== SCHEMA_VERSION) return null;
39
+ const aliases = value.aliasesByInstanceId;
40
+ if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) return null;
41
+ const entries = Object.entries(aliases);
42
+ if (entries.length > MAX_ALIASES) return null;
43
+ const out = {};
44
+ for (const [instanceId, rawAlias] of entries) {
45
+ const alias = normalizeAlias(rawAlias);
46
+ if (!INSTANCE_ID_RE.test(instanceId) || alias === null || alias !== rawAlias) return null;
47
+ out[instanceId] = alias;
48
+ }
49
+ return { version: SCHEMA_VERSION, aliasesByInstanceId: out };
50
+ }
51
+
52
+ function assertSafeTarget(filePath) {
53
+ try {
54
+ const stat = fs.lstatSync(filePath);
55
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('node alias store must be a regular file');
56
+ if ((stat.mode & 0o077) !== 0) throw new Error('node alias store permissions must be 0600');
57
+ if (stat.size > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
58
+ } catch (error) {
59
+ if (error && error.code === 'ENOENT') return false;
60
+ throw error;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ function loadStore(filePath = defaultAliasesPath()) {
66
+ if (!assertSafeTarget(filePath)) return emptyStore();
67
+ const raw = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' });
68
+ if (Buffer.byteLength(raw) > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
69
+ const parsed = parseStore(raw);
70
+ if (!parsed) throw new Error('invalid node alias store');
71
+ return parsed;
72
+ }
73
+
74
+ function atomicWriteStore(filePath, store) {
75
+ const parsed = parseStore(store);
76
+ if (!parsed) throw new Error('invalid node alias store');
77
+ const payload = `${JSON.stringify(parsed, null, 2)}\n`;
78
+ if (Buffer.byteLength(payload) > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
79
+ assertSafeTarget(filePath);
80
+ const dir = path.dirname(filePath);
81
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ const dirStat = fs.lstatSync(dir);
83
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) throw new Error('node alias directory must be a regular directory');
84
+ fs.chmodSync(dir, 0o700);
85
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(8).toString('hex')}.tmp`);
86
+ try {
87
+ fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
88
+ fs.chmodSync(tmp, 0o600);
89
+ fs.renameSync(tmp, filePath);
90
+ fs.chmodSync(filePath, 0o600);
91
+ } catch (error) {
92
+ try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
93
+ throw error;
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ function setAlias(store, instanceId, value) {
99
+ if (!INSTANCE_ID_RE.test(String(instanceId || ''))) throw new Error('instanceId non valido');
100
+ const alias = normalizeAlias(value);
101
+ if (alias === null) throw new Error('alias non valido (max 64 char, niente controlli)');
102
+ const current = parseStore(store);
103
+ if (!current) throw new Error('invalid node alias store');
104
+ const aliasesByInstanceId = { ...current.aliasesByInstanceId, [instanceId]: alias };
105
+ if (Object.keys(aliasesByInstanceId).length > MAX_ALIASES) throw new Error('troppi alias nodo');
106
+ return { version: SCHEMA_VERSION, aliasesByInstanceId };
107
+ }
108
+
109
+ function deleteAlias(store, instanceId) {
110
+ if (!INSTANCE_ID_RE.test(String(instanceId || ''))) throw new Error('instanceId non valido');
111
+ const current = parseStore(store);
112
+ if (!current) throw new Error('invalid node alias store');
113
+ const aliasesByInstanceId = { ...current.aliasesByInstanceId };
114
+ delete aliasesByInstanceId[instanceId];
115
+ return { version: SCHEMA_VERSION, aliasesByInstanceId };
116
+ }
117
+
118
+ module.exports = {
119
+ SCHEMA_VERSION, INSTANCE_ID_RE, ALIAS_MAX, MAX_ALIASES, MAX_FILE_BYTES,
120
+ defaultAliasesPath, normalizeAlias, emptyStore, parseStore, loadStore,
121
+ atomicWriteStore, setAlias, deleteAlias,
122
+ };
@@ -63,6 +63,9 @@ function knownResource(resource) {
63
63
  || resource === '/decks'
64
64
  || /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
65
65
  || resource === '/topology'
66
+ || resource === '/diagnostics/status'
67
+ || resource === '/diagnostics/logs'
68
+ || resource === '/diagnostics/verbose'
66
69
  // A connected client may ask its hub to mint a hub-owned, one-time
67
70
  // pairing invite. This is the only settings mutation exposed through
68
71
  // Hydra: the rest of /settings stays unreachable.
@@ -87,6 +90,9 @@ function allowedResource(resource, method = 'GET') {
87
90
  return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
88
91
  }
89
92
  if (resource === '/topology') return method === 'GET';
93
+ if (resource === '/diagnostics/status') return method === 'GET';
94
+ if (resource === '/diagnostics/logs') return method === 'GET' || method === 'DELETE';
95
+ if (resource === '/diagnostics/verbose') return method === 'PATCH';
90
96
  if (resource === '/settings/peering/invite') return method === 'POST';
91
97
  if (resource === '/ws') return method === 'GET';
92
98
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
@@ -94,6 +100,23 @@ function allowedResource(resource, method = 'GET') {
94
100
  return false;
95
101
  }
96
102
 
103
+ function allowedQuery(resource, method, rawUrl) {
104
+ if (!resource.startsWith('/diagnostics/')) return true;
105
+ const index = String(rawUrl || '').indexOf('?');
106
+ if (index < 0) return true;
107
+ const raw = String(rawUrl).slice(index + 1);
108
+ if (!raw) return true;
109
+ if (resource !== '/diagnostics/logs' || method !== 'GET') return false;
110
+ const params = new URLSearchParams(raw);
111
+ const keys = [...params.keys()];
112
+ if (keys.some((key) => !['after', 'limit'].includes(key))) return false;
113
+ if (params.getAll('after').length > 1 || params.getAll('limit').length > 1) return false;
114
+ const after = params.get('after'); const limit = params.get('limit');
115
+ if (after !== null && (!/^\d{1,16}$/.test(after) || !Number.isSafeInteger(Number(after)))) return false;
116
+ if (limit !== null && (!/^\d{1,3}$/.test(limit) || Number(limit) < 1 || Number(limit) > 200)) return false;
117
+ return true;
118
+ }
119
+
97
120
  function cleanHeaders(headers, credential, visited = null) {
98
121
  const out = sanitizeRequestHeaders(headers, credential);
99
122
  for (const key of Object.keys(out)) {
@@ -115,7 +138,8 @@ function proxyHttp(req, res, { port, path, credential, visited = null }) {
115
138
  function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
116
139
  return (req, res) => {
117
140
  const parsed = parseRoute(req.url);
118
- if (!parsed || !allowedResource(parsed.resource, req.method)) return res.status(404).json({ error: 'not found' });
141
+ if (!parsed || !allowedResource(parsed.resource, req.method)
142
+ || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
119
143
  if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
120
144
  const st = store.loadStore(nodesPath);
121
145
  if (!st) return res.status(503).json({ error: 'node store unavailable' });
@@ -539,7 +563,7 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
539
563
 
540
564
  module.exports = {
541
565
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
542
- peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
566
+ peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
543
567
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
544
568
  probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
545
569
  };