@mmmbuto/nexuscrew 0.8.27 → 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.
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // Cause-preserving diagnostics for Fleet up() failures (T4).
4
+ //
5
+ // The up() path of the built-in fleet crosses five boundaries:
6
+ // 1. preflight gates (command trust, cwd, managed-engine config)
7
+ // 2. the secure launch broker (private runtime dir, payload, lifecycle)
8
+ // 3. tmux new-session (session creation / duplicate)
9
+ // 4. pane/client early-exit (readiness / liveness gate)
10
+ // 5. the cell client spawn (ENOENT/EACCES/... surfaced via cell-exec)
11
+ //
12
+ // When one of them failed, the cause used to be buried in a free-text error
13
+ // message. These bounded enums travel on structured HTTP errors instead, so
14
+ // FLEET_ACTION_FAILED can report a closed {status, code, phase} triple that
15
+ // NEVER embeds cwd/path, argv, env, prompt, token or credential data — only
16
+ // stable enum strings, which are safe to persist and to show.
17
+ //
18
+ // Add a value here ONLY when a new reachable boundary needs a stable cause.
19
+ // Anything not in the enum degrades to UNKNOWN (bounded) at the coercion gate,
20
+ // so an untagged/legacy/unexpected error can never leak an unbounded string.
21
+
22
+ const UNKNOWN = 'UNKNOWN';
23
+
24
+ // Lifecycle phase of up() where a boundary failed. Closed enum.
25
+ // preflight -> pre-launch validation gates
26
+ // launch-broker -> secure launch broker
27
+ // new-session -> tmux new-session creation
28
+ // readiness -> pane/client early-exit liveness gate
29
+ // spawn-client -> cell client spawn error surfaced via cell-exec
30
+ const PHASES = ['preflight', 'launch-broker', 'new-session', 'readiness', 'spawn-client'];
31
+ const PHASE_SET = new Set(PHASES);
32
+
33
+ // Stable failure code per boundary. Closed enum; free text never enters here.
34
+ const CODES = [
35
+ // preflight (pre-launch gates)
36
+ 'COMMAND_UNTRUSTED',
37
+ 'CWD_INVALID',
38
+ 'ENGINE_UNCONFIGURED',
39
+ 'SHELL_NOT_AVAILABLE',
40
+ // launch-broker
41
+ 'LAUNCH_BROKER_UNSAFE',
42
+ 'LAUNCH_BROKER_PAYLOAD',
43
+ 'LAUNCH_BROKER_CLOSED',
44
+ 'LAUNCH_BROKER_FAILED',
45
+ // new-session
46
+ 'NEW_SESSION_FAILED',
47
+ 'SESSION_DUPLICATE',
48
+ // readiness (client started but exited immediately)
49
+ 'CLIENT_EARLY_EXIT',
50
+ // spawn-client (cell client spawn error: ENOENT/EACCES/...)
51
+ 'SPAWN_CLIENT_FAILED',
52
+ // bounded fallback for any untagged / legacy / unexpected error
53
+ UNKNOWN,
54
+ ];
55
+ const CODE_SET = new Set(CODES);
56
+
57
+ function coerce(value, allowed, fallback) {
58
+ return typeof value === 'string' && allowed.has(value) ? value : fallback;
59
+ }
60
+
61
+ // Coerce a raw code/phase to the bounded enum, degrading to UNKNOWN.
62
+ // Pure + without dependencies: testable directly.
63
+ function codeOf(value) { return coerce(value, CODE_SET, UNKNOWN); }
64
+ function phaseOf(value) { return coerce(value, PHASE_SET, UNKNOWN); }
65
+
66
+ module.exports = {
67
+ UNKNOWN, PHASES, CODES, codeOf, phaseOf,
68
+ };
@@ -29,6 +29,7 @@ const MAX_MODEL_FLAG_LEN = 32;
29
29
  const MAX_MODEL_VAL_LEN = 128;
30
30
  const MAX_PROMPTFLAG_LEN = 32;
31
31
  const MAX_PROMPT_LEN = 8192; // 8 KB
32
+ const MAX_CELL_COMMAND_LEN = 4096;
32
33
  const MAX_TMUXSESSION_LEN = 64;
33
34
 
34
35
  const ENGINE_ID_RE = /^[a-z0-9._-]{1,32}$/; // engine id: lowercase (design 4a/9f)
@@ -97,12 +98,14 @@ function parseDefinitions(raw) {
97
98
  if (d.cells.length > MAX_CELLS) return null;
98
99
 
99
100
  const engineIds = new Set();
101
+ const engineMap = new Map();
100
102
  const engines = [];
101
103
  for (const e of d.engines) {
102
104
  const eng = parseEngine(e);
103
105
  if (!eng) return null;
104
106
  if (engineIds.has(eng.id)) return null; // id engine univoco
105
107
  engineIds.add(eng.id);
108
+ engineMap.set(eng.id, eng);
106
109
  engines.push(eng);
107
110
  }
108
111
 
@@ -110,7 +113,7 @@ function parseDefinitions(raw) {
110
113
  const cellIds = new Set();
111
114
  const cells = [];
112
115
  for (const c of d.cells) {
113
- const cell = parseCell(c, engineIds);
116
+ const cell = parseCell(c, engineIds, engineMap);
114
117
  if (!cell) return null;
115
118
  if (cellIds.has(cell.id)) return null; // id cell univoco
116
119
  cellIds.add(cell.id);
@@ -215,7 +218,7 @@ function parseEngine(e) {
215
218
  return out;
216
219
  }
217
220
 
218
- function parseCell(c, engineIds) {
221
+ function parseCell(c, engineIds, engineMap = new Map()) {
219
222
  if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
220
223
 
221
224
  // id
@@ -224,6 +227,18 @@ function parseCell(c, engineIds) {
224
227
  // cwd (obbligatorio; la risoluzione/confinamento avviene via resolveCwd a runtime)
225
228
  if (typeof c.cwd !== 'string' || !c.cwd || c.cwd.length > MAX_CWD_LEN) return null;
226
229
 
230
+ // cwdRel (opzionale canonico, design §4.3): forma portatile home-relative.
231
+ // Qui si valida solo il FORMATO (stringa canonica): la coerenza cwd<->cwdRel
232
+ // e' un invariante di SCRITTURA (define/edit/restore), non di lettura — così
233
+ // le definizioni legacy (solo cwd) e quelle nuove (cwd+cwdRel) caricano senza
234
+ // riscrittura on-read e senza rendere il file illeggibile per disallineamenti.
235
+ let cwdRel;
236
+ if (c.cwdRel !== undefined) {
237
+ if (typeof c.cwdRel !== 'string') return null;
238
+ cwdRel = normalizeCwdRel(c.cwdRel);
239
+ if (cwdRel === null) return null;
240
+ }
241
+
227
242
  // engine = riferimento a engines[].id esistente (dangling -> null)
228
243
  if (typeof c.engine !== 'string' || !engineIds.has(c.engine)) return null;
229
244
 
@@ -268,10 +283,27 @@ function parseCell(c, engineIds) {
268
283
  for (const [engineId, value] of entries) {
269
284
  if (!engineIds.has(engineId)) return null;
270
285
  if (value !== 'standard' && value !== 'unsafe') return null;
286
+ if (engineMap.get(engineId)?.managed?.client === 'shell' && value !== 'standard') return null;
271
287
  permissionPolicies[engineId] = value;
272
288
  }
273
289
  }
274
290
 
291
+ // commands: comando Shell PER-CELL PER-ENGINE. La stringa resta opaca e
292
+ // viene interpretata solo dalla shell target con `-lc`; qui si applicano
293
+ // limiti e forma chiusa. Sono ammesse soltanto chiavi di engine Shell.
294
+ let commands;
295
+ if (c.commands !== undefined) {
296
+ if (!c.commands || typeof c.commands !== 'object' || Array.isArray(c.commands)) return null;
297
+ const entries = Object.entries(c.commands);
298
+ if (entries.length > MAX_ENGINES) return null;
299
+ commands = {};
300
+ for (const [engineId, value] of entries) {
301
+ if (!engineIds.has(engineId) || engineMap.get(engineId)?.managed?.client !== 'shell') return null;
302
+ if (typeof value !== 'string' || value.length > MAX_CELL_COMMAND_LEN || /[\x00-\x1f\x7f]/.test(value)) return null;
303
+ commands[engineId] = value;
304
+ }
305
+ }
306
+
275
307
  // prompt (opzionale, cap)
276
308
  let prompt;
277
309
  if (c.prompt !== undefined) {
@@ -296,9 +328,11 @@ function parseCell(c, engineIds) {
296
328
  }
297
329
 
298
330
  const out = { id: c.id, cwd: c.cwd, engine: c.engine, boot, tmuxSession };
331
+ if (cwdRel !== undefined) out.cwdRel = cwdRel;
299
332
  if (model !== undefined) out.model = model;
300
333
  if (Object.keys(models).length) out.models = models;
301
334
  if (permissionPolicies) out.permissionPolicies = permissionPolicies;
335
+ if (commands && Object.keys(commands).length) out.commands = commands;
302
336
  if (prompt !== undefined) out.prompt = prompt;
303
337
  return out;
304
338
  }
@@ -345,6 +379,52 @@ function resolveCwd(cwd, home) {
345
379
  } catch (_) { return null; }
346
380
  }
347
381
 
382
+ // ---------------------------------------------------------------------------
383
+ // cwdRel — cwd home-relative PORTATILE (design §4.3 / backup v3).
384
+ // Rappresentazione canonica di una cwd come percorso relativo alla home del
385
+ // device target: '' == la home stessa; 'personal' == <home>/personal.
386
+ // Helper PURI (nessun fs): la normalizzazione e' string-only e fail-closed.
387
+ // La risoluzione/confinamento finale resta demandata a resolveCwd (realpath su
388
+ // entrambi i lati), INVARIATO: cwdRel aggiunge un vincolo in scrittura, non lo
389
+ // indebolisce in lettura. Nessun '..'/assoluto/control/backslash/drive letter.
390
+ // ---------------------------------------------------------------------------
391
+ // Restituisce la forma canonica ('' = home) oppure null (input non portabile).
392
+ // Normalizza (collassa '.' e segmenti vuoti, scosta lo slash finale) RIFIUTANDO
393
+ // traversal, path assoluti, drive letter (Win), NUL/C0/DEL e backslash.
394
+ function normalizeCwdRel(rel, maxLen = MAX_CWD_LEN) {
395
+ if (typeof rel !== 'string') return null;
396
+ if (rel.length > maxLen) return null;
397
+ for (let i = 0; i < rel.length; i += 1) {
398
+ const c = rel.charCodeAt(i);
399
+ if (c <= 0x1f || c === 0x7f || c === 0x5c) return null; // C0, DEL, backslash
400
+ }
401
+ if (rel === '') return ''; // la home stessa
402
+ if (rel.charAt(0) === '/') return null; // path assoluto (leading sep)
403
+ if (/^[A-Za-z]:/.test(rel)) return null; // drive letter (Win-like)
404
+ const out = [];
405
+ for (const seg of rel.split('/')) {
406
+ if (seg === '' || seg === '.') continue; // collassa vuoti/dot
407
+ if (seg === '..') return null; // traversal
408
+ out.push(seg);
409
+ }
410
+ return out.join('/');
411
+ }
412
+
413
+ // Deriva il cwdRel canonico da una cwd ASSOLUTA rispetto a una home (entrambe
414
+ // gia' realpath: il caller passa realpath). Restituisce '' (== home), un rel
415
+ // normalizzato, oppure null se la cwd non e' esprimibile sotto la home.
416
+ // Pura: nessun fs. Usa path.relative sulle stringhe (sicuro perche' entrambi
417
+ // realpath e cwd confinato sotto home).
418
+ function deriveCwdRel(absCwd, home) {
419
+ if (typeof absCwd !== 'string' || !absCwd || typeof home !== 'string' || !home) return null;
420
+ if (absCwd.includes('\0') || home.includes('\0')) return null;
421
+ const rel = path.relative(home, absCwd);
422
+ if (rel === '') return ''; // cwd == home
423
+ if (path.isAbsolute(rel)) return null; // drive diverso (Win)
424
+ if (rel === '..' || rel.startsWith('..' + path.sep)) return null; // fuori home
425
+ return normalizeCwdRel(rel);
426
+ }
427
+
348
428
  // ---------------------------------------------------------------------------
349
429
  // loadDefinitions(p) -> parsed | null
350
430
  // Legge il file rifiutando i symlink; parse strict. Mai throw.
@@ -413,18 +493,20 @@ const CAPS = {
413
493
  SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
414
494
  MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_LABEL_LEN,
415
495
  MAX_COMMAND_LEN, MAX_CWD_LEN, MAX_MODEL_FLAG_LEN, MAX_MODEL_VAL_LEN,
416
- MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_TMUXSESSION_LEN,
496
+ MAX_PROMPTFLAG_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN, MAX_TMUXSESSION_LEN,
417
497
  };
418
498
 
419
499
  module.exports = {
420
500
  parseDefinitions,
421
501
  validateCommandTrust,
422
502
  resolveCwd,
503
+ normalizeCwdRel,
504
+ deriveCwdRel,
423
505
  loadDefinitions,
424
506
  atomicWrite,
425
507
  validTmuxName,
426
508
  CAPS,
427
509
  // Costanti esposte anche piatte (comode per la UI/schema e i test)
428
510
  SCHEMA_VERSION, MAX_ENGINES, MAX_CELLS, MAX_ARGS, MAX_ARG_LEN,
429
- MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN,
511
+ MAX_ENV_KEYS, MAX_ENV_KEY_LEN, MAX_ENV_VAL_LEN, MAX_PROMPT_LEN, MAX_CELL_COMMAND_LEN,
430
512
  };
@@ -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.
@@ -61,7 +61,7 @@ const ALIBABA_PI_MODELS = Object.freeze([
61
61
 
62
62
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
63
63
  const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
64
- 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' });
65
65
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
66
66
 
67
67
  function validBaseUrl(value) {
@@ -109,6 +109,9 @@ const CATALOG = Object.freeze([
109
109
  // Pi uses its real provider IDs directly. OAuth providers do not need env keys.
110
110
  { id: 'pi.native', client: 'pi', provider: 'native', label: 'Pi configured default', auth: 'login', protocol: 'pi_native', default: true, core: true },
111
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 },
112
115
  { id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic', core: true },
113
116
  { id: 'pi.openai', client: 'pi', provider: 'openai', label: 'OpenAI API', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai', core: true },
114
117
  { id: 'pi.openai-codex', client: 'pi', provider: 'openai-codex', label: 'OpenAI Codex OAuth', auth: 'login', protocol: 'pi_native', piProvider: 'openai-codex', core: true },
@@ -148,11 +151,12 @@ function normalizeManagedSpec(value) {
148
151
  if (!profile) return null;
149
152
  const model = value.model === undefined ? (profile.model || '') : value.model;
150
153
  if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
154
+ if (value.client === 'shell' && model) return null;
151
155
  if (profile.requiresModel && !model) return null;
152
156
  if (profile.strictModels && !(profile.models || []).includes(model)) return null;
153
157
  const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
154
158
  if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
155
- if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
159
+ if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
156
160
  const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
157
161
  if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
158
162
  if (profile.credentialEnv) {
@@ -188,6 +192,16 @@ function defaultDefinitions() {
188
192
  };
189
193
  }
190
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
+
191
205
  function parseAssignments(raw) {
192
206
  const out = {};
193
207
  for (const line of raw.split(/\r?\n/)) {
@@ -269,6 +283,35 @@ function findBinary(client, home) {
269
283
  return null;
270
284
  }
271
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
+
272
315
  // Termux reports process.platform === 'android' and deliberately has no
273
316
  // /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
274
317
  // `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
@@ -422,7 +465,9 @@ function describeManaged(spec, cfg = {}) {
422
465
  if (!normalized) return { configured: false, reason: 'invalid managed profile' };
423
466
  const home = cfg.home || require('node:os').homedir();
424
467
  const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
425
- const binary = findBinary(normalized.client, home);
468
+ const binary = normalized.client === 'shell'
469
+ ? resolveInteractiveShell({ ...cfg, home })
470
+ : findBinary(normalized.client, home);
426
471
  const cred = credential(profile, normalized, cfg, home);
427
472
  // Pi can resolve credentials from its own documented /login auth store. Do
428
473
  // not inspect or copy that store; delegate native-provider auth to Pi.
@@ -585,13 +630,20 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
585
630
  let effectivePolicy = (override === 'standard' || override === 'unsafe') ? override : spec.permissionPolicy;
586
631
  // Pi resta sempre 'standard': normalizeManagedSpec rifiuta gia' unsafe nello spec
587
632
  // dell'engine, ma l'override PER-CELL bypasserebbe quel check -> clamp esplicito.
588
- if (spec.client === 'pi') effectivePolicy = 'standard';
633
+ if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
589
634
  info.permissionPolicy = effectivePolicy;
590
635
  if (effectivePolicy === 'unsafe') {
591
636
  if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
592
637
  if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
593
638
  }
594
- 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') {
595
647
  if (spec.provider === 'native') {
596
648
  if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
597
649
  } else if (profile.providerEnv) {
@@ -708,7 +760,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
708
760
  if (model) args.push('--model', model);
709
761
  if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
710
762
  }
711
- if (cell?.prompt) args.push(cell.prompt);
763
+ if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
712
764
  let command = info.binary;
713
765
  if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
714
766
  command = cfg.nodeExecPath || process.execPath;
@@ -716,6 +768,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
716
768
  }
717
769
  return { ok: true, info, engine: {
718
770
  ...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
771
+ ...(spec.client === 'shell' ? { shellOneShot } : {}),
719
772
  } };
720
773
  }
721
774
 
@@ -724,7 +777,7 @@ function publicCatalog() {
724
777
  id: p.id, client: p.client, clientLabel: CLIENT_LABELS[p.client], provider: p.provider,
725
778
  credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
726
779
  auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
727
- 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,
728
781
  permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
729
782
  rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
730
783
  credentialEnv: p.auth === 'dynamic' ? !!p.credentialEnv : (ENV_KEY_RE.test(p.auth || '') ? p.auth : false),
@@ -736,8 +789,8 @@ module.exports = {
736
789
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
737
790
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
738
791
  CLIENT_LABELS, normalizeManagedSpec,
739
- defaultDefinitions, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
792
+ defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
740
793
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
741
794
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
742
- ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, ENV_KEY_RE,
795
+ ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell, shellLoginArgs, ENV_KEY_RE,
743
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.
@@ -60,10 +61,23 @@ function fleetRoutes(fleetP, cfg = {}) {
60
61
  action: opts.action, cell: safeCell(req.body), errno: match[1], client: match[2], status: e.status || 500,
61
62
  });
62
63
  else emit('warn', 'FLEET_ACTION_FAILED', 'Fleet action failed', {
63
- action: opts.action, cell: safeCell(req.body), state: 'failed', status: e.status || 500,
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),
64
71
  });
65
72
  }
66
- res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
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
+ });
67
81
  }
68
82
  };
69
83
 
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.27",
3
+ "version": "0.8.28",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {