@mmmbuto/nexuscrew 0.8.29 → 0.8.31

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.
@@ -23,6 +23,7 @@ const path = require('node:path');
23
23
  const { execFile } = require('node:child_process');
24
24
  const { minimalRuntimeEnv } = require('../runtime/env.js');
25
25
  const { codeOf, phaseOf } = require('./causes.js');
26
+ const { isTmuxSafeName, tmuxSessionForCell } = require('./definitions.js');
26
27
 
27
28
  // Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
28
29
  // non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
@@ -129,6 +130,128 @@ function tmuxExec(tmuxBin, args, { env, timeoutMs = 10000 } = {}) {
129
130
 
130
131
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
131
132
 
133
+ function tmuxMigrationError(code, message) {
134
+ const error = new Error(message);
135
+ error.code = code;
136
+ return error;
137
+ }
138
+
139
+ function boundedTmuxFailure(result) {
140
+ return String(result?.stderr || result?.err?.message || 'errore tmux')
141
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 240) || 'errore tmux';
142
+ }
143
+
144
+ // Migrazione identita' tmux (design §3.2). Prima costruisce e valida TUTTO il
145
+ // piano: nessun rename parte se una sessione legacy normalizzata e' rivendicata
146
+ // da piu celle (es. a.b vs a_b), se legacy+target safe coesistono o se il target
147
+ // non e' tmux-safe. Solo dopo il preflight rinomina via `$N`; qualunque errore e'
148
+ // propagato con causa bounded e impedisce la persistenza di fleet.json. I rename
149
+ // gia riusciti prima di un errore operativo restano comunque idempotenti al boot
150
+ // successivo, mentre lo store precedente rimane intatto.
151
+ async function migrateLegacyTmuxSessions(tmuxBin, defs, { readonly = false } = {}) {
152
+ const cells = defs && Array.isArray(defs.cells) ? defs.cells : [];
153
+ const legacyMap = defs?.legacyTmuxSessions instanceof Map ? defs.legacyTmuxSessions : new Map();
154
+ const candidates = [];
155
+ for (const cell of cells) {
156
+ if (!cell || typeof cell.id !== 'string') continue;
157
+ const legacyRaw = legacyMap.get(cell.id) || (cell.id.includes('.') ? `cloud-${cell.id}` : '');
158
+ if (!legacyRaw) continue;
159
+ const safe = cell.tmuxSession || tmuxSessionForCell(cell.id);
160
+ if (!isTmuxSafeName(safe)) {
161
+ throw tmuxMigrationError('TMUX_MIGRATION_INVALID_TARGET',
162
+ `migrazione tmux bloccata per ${cell.id}: target safe non valido`);
163
+ }
164
+ candidates.push({ id: cell.id, legacyRaw, legacyNorm: legacyRaw.replace(/\./g, '_'), safe });
165
+ }
166
+ if (!candidates.length || readonly) {
167
+ return {
168
+ migrated: [], reason: readonly ? 'readonly' : 'none',
169
+ needsPersistence: legacyMap.size > 0,
170
+ };
171
+ }
172
+ const listing = await tmuxExec(tmuxBin,
173
+ ['list-sessions', '-F', '#{session_id}\t#{session_name}'], { env: minimalEnv() });
174
+ if (listing.err) {
175
+ const detail = boundedTmuxFailure(listing);
176
+ if (/no server running|failed to connect|connection refused|no such file.*tmux/i.test(detail)) {
177
+ return { migrated: [], reason: 'no-tmux-server', needsPersistence: legacyMap.size > 0 };
178
+ }
179
+ throw tmuxMigrationError('TMUX_MIGRATION_LIST_FAILED',
180
+ `migrazione tmux: elenco sessioni non disponibile (${detail})`);
181
+ }
182
+ const realByName = new Map(); // session_name -> session_id ($N)
183
+ for (const line of listing.stdout.split('\n')) {
184
+ const [sid, sname] = line.split('\t');
185
+ if (sid && sname) realByName.set(String(sname).trim(), String(sid).trim());
186
+ }
187
+
188
+ const ownersBySafe = new Map(cells.map((cell) => [cell.tmuxSession, cell.id]));
189
+ const claimsByReal = new Map();
190
+ for (const candidate of candidates) {
191
+ for (const name of new Set([candidate.legacyRaw, candidate.legacyNorm])) {
192
+ if (!realByName.has(name)) continue;
193
+ const claims = claimsByReal.get(name) || [];
194
+ claims.push(candidate);
195
+ claimsByReal.set(name, claims);
196
+ }
197
+ }
198
+
199
+ const activeNamesByCell = new Map();
200
+ for (const [realName, claims] of claimsByReal) {
201
+ for (const claim of claims) {
202
+ const names = activeNamesByCell.get(claim.id) || [];
203
+ names.push(realName);
204
+ activeNamesByCell.set(claim.id, names);
205
+ }
206
+ }
207
+ for (const [cellId, names] of activeNamesByCell) {
208
+ if (names.length > 1) {
209
+ throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
210
+ `migrazione tmux ambigua per ${cellId}: piu sessioni legacy (${names.join(', ')})`);
211
+ }
212
+ }
213
+
214
+ const operations = [];
215
+ for (const [realName, claims] of claimsByReal) {
216
+ if (claims.length !== 1) {
217
+ throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
218
+ `migrazione tmux ambigua: ${realName} corrisponde a ${claims.map((c) => c.id).join(', ')}`);
219
+ }
220
+ const candidate = claims[0];
221
+ const owner = ownersBySafe.get(realName);
222
+ if (owner && owner !== candidate.id) {
223
+ throw tmuxMigrationError('TMUX_MIGRATION_AMBIGUOUS',
224
+ `migrazione tmux ambigua: ${realName} e gia assegnata a ${owner}`);
225
+ }
226
+ const legacyId = realByName.get(realName);
227
+ if (!/^\$[0-9]+$/.test(legacyId || '')) {
228
+ throw tmuxMigrationError('TMUX_MIGRATION_INVALID_ID',
229
+ `migrazione tmux bloccata per ${candidate.id}: session ID non valido`);
230
+ }
231
+ const safeId = realByName.get(candidate.safe);
232
+ if (safeId && safeId !== legacyId) {
233
+ throw tmuxMigrationError('TMUX_MIGRATION_TARGET_EXISTS',
234
+ `migrazione tmux bloccata per ${candidate.id}: target ${candidate.safe} gia esistente`);
235
+ }
236
+ operations.push({ ...candidate, legacyId, realName });
237
+ }
238
+
239
+ const migrated = [];
240
+ for (const operation of operations) {
241
+ const rn = await tmuxExec(tmuxBin,
242
+ ['rename-session', '-t', operation.legacyId, operation.safe], { env: minimalEnv() });
243
+ if (rn.err) {
244
+ throw tmuxMigrationError('TMUX_MIGRATION_RENAME_FAILED',
245
+ `migrazione tmux fallita per ${operation.id} (${boundedTmuxFailure(rn)})`);
246
+ }
247
+ migrated.push({ id: operation.id, from: operation.realName, to: operation.safe });
248
+ }
249
+ return {
250
+ migrated, reason: 'ok',
251
+ needsPersistence: legacyMap.size > 0 || migrated.length > 0,
252
+ };
253
+ }
254
+
132
255
  // Policy caratteri del prompt send-keys (§9e): ammette stampabili + \t \n \r;
133
256
  // rifiuta ESC(0x1b) e gli altri byte di controllo (niente marker bracketed-paste
134
257
  // iniettabili). parseDefinitions caps solo la lunghezza: questo e' defense-in-depth.
@@ -243,6 +366,7 @@ module.exports = {
243
366
  promptCharsOk,
244
367
  composeClientInvocation,
245
368
  composeLaunchArgv,
369
+ migrateLegacyTmuxSessions,
246
370
  waitAlive,
247
371
  waitStablePane,
248
372
  injectPrompt,
@@ -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', shell: 'Shell' });
64
+ const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', shell: 'Shell' });
65
65
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
66
66
 
67
67
  function validBaseUrl(value) {
@@ -128,6 +128,16 @@ const CATALOG = Object.freeze([
128
128
  { id: 'pi.ollama', client: 'pi', provider: 'ollama', label: 'Ollama local', auth: 'none', protocol: 'openai-completions', piProvider: 'ollama', requiresModel: true, core: true, piExtension: { baseUrl: 'http://127.0.0.1:11434/v1', apiKey: 'ollama' } },
129
129
  { id: 'pi.zai', client: 'pi', provider: 'zai', label: 'Z.AI', auth: 'ZAI_API_KEY', protocol: 'pi_native', piProvider: 'zai', core: true },
130
130
  { id: 'pi.custom', client: 'pi', provider: 'custom', label: 'Custom provider', auth: 'dynamic', protocol: 'openai-responses', protocols: ['openai-responses', 'anthropic-messages', 'openai-completions', 'google-generative-ai'], custom: true, core: true },
131
+
132
+ // Agy: client gestito primario (Linux/macOS non-Termux). Auth delegata al
133
+ // login locale del client (nessuno store letto/copiato). Nessun remote-control
134
+ // NexusCrew; permission default 'standard' (unsafe aggiunge --dangerously-skip-
135
+ // permissions, allineato agli altri client). NON e' un default seed (non va nel
136
+ // fleet.json fresco su piattaforme non supportate): viene aggiunto solo da un
137
+ // backfill platform-aware (builtin.js) su Linux/macOS non-Termux. Qui figura
138
+ // (core) per la UI; describeManaged lo dichiara non configurato altrove.
139
+ // Termux/Windows restano fuori dal primary: l'utente usa agy via shell.local.
140
+ { id: 'agy.native', client: 'agy', provider: 'native', label: 'Agy', auth: 'login', protocol: 'agy_native', core: true },
131
141
  ]);
132
142
 
133
143
  function profileFor(client, provider, credentialProfile) {
@@ -202,6 +212,18 @@ function defaultShellEngine() {
202
212
  };
203
213
  }
204
214
 
215
+ // Engine Agy per il backfill platform-aware (builtin.js): standard di default,
216
+ // auth delegata al login locale, niente remote-control. Non e' un default seed.
217
+ function defaultAgyEngine() {
218
+ const profile = CATALOG.find((entry) => entry.id === 'agy.native');
219
+ return {
220
+ id: profile.id,
221
+ label: CLIENT_LABELS.agy,
222
+ rc: false,
223
+ managed: { client: 'agy', provider: 'native', model: '', permissionPolicy: 'standard' },
224
+ };
225
+ }
226
+
205
227
  function parseAssignments(raw) {
206
228
  const out = {};
207
229
  for (const line of raw.split(/\r?\n/)) {
@@ -312,6 +334,17 @@ function shellLoginArgs(command) {
312
334
  return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || ''))) ? ['-l'] : [];
313
335
  }
314
336
 
337
+ // Un command configurato deve vedere lo stesso ambiente della shell interattiva
338
+ // che l'utente ottiene lasciando vuoto il campo. Con `-lc`, zsh non carica
339
+ // `.zshrc`: alias e PATH user-locali (per esempio ~/.local/bin) spariscono e il
340
+ // command esce 127 pur essendo disponibile nel terminale normale. I quattro
341
+ // shell POSIX-like supportati vengono quindi avviati login+interactive+command;
342
+ // shell custom conservano il contratto storico -lc.
343
+ function shellConfiguredCommandArgs(command, raw) {
344
+ return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || '')))
345
+ ? ['-lic', raw] : ['-lc', raw];
346
+ }
347
+
315
348
  // Termux reports process.platform === 'android' and deliberately has no
316
349
  // /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
317
350
  // `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
@@ -474,17 +507,30 @@ function describeManaged(spec, cfg = {}) {
474
507
  const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
475
508
  && profile.delegatePiAuth !== false;
476
509
  const authConfigured = delegatedPiAuth || profile.auth === 'login' || profile.auth === 'none' || !!cred.value;
510
+ let configured = !!binary && authConfigured;
511
+ let reason = !binary ? `client ${profile.client} not found` : (!authConfigured
512
+ ? `credential ${cred.envKey} missing — set it on this device`
513
+ : 'ready');
514
+ // Agy e' un client primario supportato solo su Linux/macOS non-Termux.
515
+ // Rilevazione Termux via termuxRuntimePaths (non solo process.platform): un
516
+ // Node che riporta 'linux' sotto proot/Termux viene comunque intercettato.
517
+ if (normalized.client === 'agy') {
518
+ const platform = cfg.platform || process.platform;
519
+ const termux = platform === 'android' || termuxRuntimePaths(cfg.env || process.env, { platform, home }) !== null;
520
+ if (termux || (platform !== 'linux' && platform !== 'darwin')) {
521
+ configured = false;
522
+ reason = 'agy non supportato su questa piattaforma (usa shell.local con command agy)';
523
+ }
524
+ }
477
525
  return {
478
526
  client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
479
527
  credentialProfile: normalized.credentialProfile || '', model: normalized.model,
480
528
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
481
529
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
482
530
  credentialSource: authConfigured ? cred.source : 'missing',
483
- configured: !!binary && authConfigured, models: [...(profile.models || [])], defaultModel: profile.model || '',
531
+ configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
484
532
  binary: binary || '', displayName: normalized.displayName || profile.label,
485
- reason: !binary ? `client ${profile.client} not found` : (!authConfigured
486
- ? `credential ${cred.envKey} missing — set it on this device`
487
- : 'ready'),
533
+ reason,
488
534
  };
489
535
  }
490
536
 
@@ -633,7 +679,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
633
679
  if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
634
680
  info.permissionPolicy = effectivePolicy;
635
681
  if (effectivePolicy === 'unsafe') {
636
- if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
682
+ if (spec.client === 'claude' || spec.client === 'agy') args.push('--dangerously-skip-permissions');
637
683
  if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
638
684
  }
639
685
  let shellOneShot = false;
@@ -641,7 +687,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
641
687
  const raw = cell?.commands && typeof cell.commands[engineId] === 'string'
642
688
  ? cell.commands[engineId] : '';
643
689
  shellOneShot = raw.trim().length > 0;
644
- if (shellOneShot) args.push('-lc', raw);
690
+ if (shellOneShot) args.push(...shellConfiguredCommandArgs(info.binary, raw));
645
691
  else args.push(...shellLoginArgs(info.binary));
646
692
  } else if (spec.client === 'claude') {
647
693
  if (spec.provider === 'native') {
@@ -759,6 +805,13 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
759
805
  if (spec.provider !== 'native') args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
760
806
  if (model) args.push('--model', model);
761
807
  if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
808
+ } else if (spec.client === 'agy') {
809
+ // Agy delega l'autenticazione al proprio login locale (auth: 'login'): niente
810
+ // env provider, niente credenziali copiate. Argv diretto: --model prima del
811
+ // prompt; --prompt-interactive e' l'ultima coppia (il prompt value e' accodato
812
+ // dal push generico qui sotto). Senza prompt parte il TUI interattivo `agy`.
813
+ if (model) args.push('--model', model);
814
+ if (cell?.prompt) args.push('--prompt-interactive');
762
815
  }
763
816
  if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
764
817
  let command = info.binary;
@@ -789,8 +842,9 @@ module.exports = {
789
842
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
790
843
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
791
844
  CLIENT_LABELS, normalizeManagedSpec,
792
- defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
845
+ defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
793
846
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
794
847
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
795
- ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell, shellLoginArgs, ENV_KEY_RE,
848
+ ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
849
+ shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,
796
850
  };
@@ -16,7 +16,7 @@ async function selectProvider(cfg = {}) {
16
16
  if (cfg.fleetEnabled === false) return disabled('fleet disabilitata (fleetEnabled=false)');
17
17
  if (cfg.builtinEnabled === false) return disabled('fleet builtin disabilitata (builtinEnabled=false)');
18
18
  const fleet = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'NexusCrew builtin fleet' });
19
- if (!fleet.available) return disabled('fleet.json mancante o invalido (fail-closed)');
19
+ if (!fleet.available) return disabled(fleet.reason || 'fleet.json mancante o invalido (fail-closed)');
20
20
  return { mode: 'builtin', reason: 'NexusCrew builtin fleet', fleet };
21
21
  }
22
22
 
@@ -21,7 +21,7 @@ const {
21
21
  } = require('./managed.js');
22
22
  const {
23
23
  httpError, minimalEnv, tmuxExec,
24
- composeClientInvocation, composeLaunchArgv,
24
+ composeClientInvocation,
25
25
  waitAlive, waitStablePane, injectPrompt,
26
26
  redactSecrets, sanitizeEarlyDiagnostic,
27
27
  } = require('./launch.js');
@@ -216,39 +216,105 @@ function createBuiltinRuntime(ctx) {
216
216
  args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
217
217
  env: {}, promptMode: 'managed-argv',
218
218
  };
219
- const argv = composeLaunchArgv({ tmuxSession: cell.tmuxSession, realCwd, engine: tmuxLaunchEngine, cell });
220
- argv.splice(2, 0, '-P', '-F', '#{pane_id}');
221
- // Mantieni il pane morto solo durante la finestra di readiness: permette di
222
- // catturare un errore reale del client senza lasciare una sessione fantasma.
223
- // Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
224
- if (!launchEngine.shellOneShot) {
225
- argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
226
- }
227
- const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
228
- if (launch.err) {
219
+ const tmuxChild = composeClientInvocation(tmuxLaunchEngine, cell);
220
+
221
+ // Avvio staged (design §3.3): crea il pane con un placeholder inerte trusted
222
+ // (cell-hold.js), arma remain-on-exit window-local sul @N, poi respawn-pane -k
223
+ // verso cell-exec sul %N esatto. Cosi' remain-on-exit e' gia' ON quando il vero
224
+ // child puo' terminare: nessuna finestra scomparsa, nessun NEW_SESSION_FAILED
225
+ // che maschera l'exit reale. Il nome sessione e' gia' validato tmux-safe (v2
226
+ // per id puntati, definitions.js); gli step critici usano gli ID restituiti
227
+ // da tmux ($N/@N/%N). respawn-pane -k preserva il pane ID: il %N catturato
228
+ // qui resta valido per readiness e prompt. Nessuna shell string, nessun
229
+ // command/env/prompt del child nell'argv tmux (solo cell-hold, poi cell-exec
230
+ // via broker). Nessun sleep come sincronizzazione.
231
+ const CELL_HOLD = path.join(__dirname, 'cell-hold.js');
232
+ const create = await tmuxExec(tmuxBin,
233
+ ['new-session', '-d', '-s', cell.tmuxSession, '-c', realCwd,
234
+ '-P', '-F', '#{session_id}\t#{window_id}\t#{pane_id}',
235
+ process.execPath, CELL_HOLD],
236
+ { env: minimalEnv() });
237
+ if (create.err) {
238
+ // new-session fallita: nessuna sessione creata. Revoca il ticket (il child
239
+ // non partira' mai) prima di propagare l'errore.
240
+ try { await launchBroker.revoke?.(ticket.nonce); } catch (_) { /* best-effort */ }
229
241
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
230
- const dup = /duplicate session/i.test(launch.stderr);
242
+ const dup = /duplicate session/i.test(create.stderr);
231
243
  const why = dup
232
244
  ? 'sessione già in esecuzione'
233
- : `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
245
+ : `tmux new-session failed: ${redactSecrets(create.stderr.trim() || create.err.message, launchEngine, cell)}`;
234
246
  throw httpError(dup ? 409 : 500, why, null,
235
247
  { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
236
248
  }
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 };
249
+ const createdIds = create.stdout.trim().split('\n')[0].split('\t');
250
+ const sessionId = /^\$[0-9]+$/.test(createdIds[0] || '') ? createdIds[0] : '';
251
+ const windowId = /^@[0-9]+$/.test(createdIds[1] || '') ? createdIds[1] : '';
252
+ const paneId = /^%[0-9]+$/.test(createdIds[2] || '') ? createdIds[2] : '';
253
+ const resolveSessionIdForCleanup = async () => {
254
+ if (sessionId) return sessionId;
255
+ // Output parziale anomalo: risali al $N da un @N/%N gia restituito. Come
256
+ // ultima recovery enumera id+nomi e usa il nome safe solo per SELEZIONARE
257
+ // l'id; kill-session non torna mai al target nominale richiesto.
258
+ for (const target of [paneId, windowId]) {
259
+ if (!target) continue;
260
+ const shown = await tmuxExec(tmuxBin,
261
+ ['display-message', '-p', '-t', target, '#{session_id}'],
262
+ { env: minimalEnv(), timeoutMs: 2000 });
263
+ const resolved = shown.stdout.trim();
264
+ if (!shown.err && /^\$[0-9]+$/.test(resolved)) return resolved;
265
+ }
266
+ const listed = await tmuxExec(tmuxBin,
267
+ ['list-sessions', '-F', '#{session_id}\t#{session_name}'],
268
+ { env: minimalEnv(), timeoutMs: 2000 });
269
+ if (listed.err) return '';
270
+ for (const line of listed.stdout.split('\n')) {
271
+ const [sid, name] = line.split('\t');
272
+ if (name === cell.tmuxSession && /^\$[0-9]+$/.test(sid || '')) return sid;
273
+ }
274
+ return '';
275
+ };
276
+ const cleanupLaunch = async () => {
277
+ const stableSessionId = await resolveSessionIdForCleanup();
278
+ if (stableSessionId) {
279
+ await tmuxExec(tmuxBin, ['kill-session', '-t', stableSessionId],
280
+ { env: minimalEnv(), timeoutMs: 2000 });
281
+ }
282
+ try { await launchBroker.revoke?.(ticket.nonce); } catch (_) { /* best-effort */ }
283
+ };
284
+ if (!sessionId || !windowId || !paneId) {
285
+ await cleanupLaunch();
286
+ throw httpError(500, 'tmux new-session: ID sessione/finestra/pane non restituito', null,
287
+ { phase: 'new-session', code: 'NEW_SESSION_FAILED' });
288
+ }
289
+ // Arma remain-on-exit window-local sul @N: la finestra esiste (il placeholder
290
+ // la tiene viva), quindi niente race con un child che termini nel frattempo.
291
+ const arm = await tmuxExec(tmuxBin,
292
+ ['set-option', '-w', '-t', windowId, 'remain-on-exit', 'on'],
293
+ { env: minimalEnv() });
294
+ if (arm.err) {
295
+ await cleanupLaunch();
296
+ throw httpError(500,
297
+ `tmux set-option remain-on-exit failed: ${redactSecrets(arm.stderr.trim() || arm.err.message, launchEngine, cell)}`,
298
+ null, { phase: 'new-session', code: 'NEW_SESSION_FAILED' });
299
+ }
300
+ // Sostituisci il placeholder con il vero child (cell-exec via broker) sul %N.
301
+ const respawn = await tmuxExec(tmuxBin,
302
+ ['respawn-pane', '-k', '-c', realCwd, '-t', paneId, tmuxChild.command, ...tmuxChild.args],
303
+ { env: minimalEnv() });
304
+ if (respawn.err) {
305
+ // respawn fallito dopo issue(): il nonce va revocato/consumato prima
306
+ // dell'errore (design §3.3), poi cleanup della sessione-pannello.
307
+ await cleanupLaunch();
308
+ throw httpError(500,
309
+ `tmux respawn-pane failed: ${redactSecrets(respawn.stderr.trim() || respawn.err.message, launchEngine, cell)}`,
310
+ null, { phase: 'new-session', code: 'NEW_SESSION_FAILED' });
245
311
  }
246
312
 
247
313
  // `tmux new-session -d` can return 0 even when the launched CLI exits a
248
314
  // moment later (missing login, bad model, incompatible provider). Without
249
315
  // this readiness gate the PWA reported success and then showed nothing.
250
316
  // Always verify liveness, including cells without a system prompt.
251
- const paneId = launch.stdout.trim().split('\n')[0] || '';
317
+
252
318
  const readiness = paneId.startsWith('%')
253
319
  ? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
254
320
  : { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
@@ -261,10 +327,25 @@ function createBuiltinRuntime(ctx) {
261
327
  }
262
328
  // remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
263
329
  // nella Fleet o nella lista tmux dopo aver raccolto l'errore.
264
- await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
330
+ await cleanupLaunch();
265
331
  cache = { ...cache, at: 0 };
332
+ // Un command Shell completato rapidamente con exit 0 e' un one-shot
333
+ // riuscito. Qualunque altro exit immediato e' invece osservabile come
334
+ // errore strutturato: prima il runtime restituiva un falso successo e
335
+ // scartava proprio l'exit 127/diagnostica che servivano all'operatore.
336
+ if (launchEngine.shellOneShot && readiness.status === 0) {
337
+ return {
338
+ ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
339
+ oneShot: true, active: false, completed: true, exitCode: 0,
340
+ };
341
+ }
266
342
  const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
267
343
  const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
344
+ if (launchEngine.shellOneShot) {
345
+ throw httpError(500,
346
+ `comando Shell terminato subito${status}: ${diagnostic || 'verifica command, PATH e configurazione della shell'}`,
347
+ null, { phase: 'readiness', code: 'SHELL_COMMAND_FAILED' });
348
+ }
268
349
  // Cause-preserving (T4): distinguish a cell-client spawn failure (the
269
350
  // captured pane carries the stable 'cell spawn failed:' marker produced by
270
351
  // cell-exec.js) from a generic early exit. Both stay on the readiness
@@ -278,6 +359,17 @@ function createBuiltinRuntime(ctx) {
278
359
  ['set-option', '-w', '-t', readiness.target, 'remain-on-exit', 'off'], { env: minimalEnv(), timeoutMs: 2000 });
279
360
  }
280
361
 
362
+ // Il command Shell e' partito ed e' ancora vivo dopo la finestra di
363
+ // readiness: la cella deve risultare attiva (per CLI interattive come agy),
364
+ // poi tornera' inattiva quando il processo terminera' naturalmente.
365
+ if (launchEngine.shellOneShot) {
366
+ cache = { ...cache, at: 0 };
367
+ return {
368
+ ok: true, cell: cellId, session: cell.tmuxSession, prompt: null,
369
+ oneShot: true, active: true, completed: false,
370
+ };
371
+ }
372
+
281
373
  // (6) prompt send-keys: bracketed-paste best-effort, target = pane id esatto
282
374
  // (fallback al nome sessione con match esatto '=' se tmux non ha stampato l'id).
283
375
  let prompt = null;
package/lib/mcp/tools.js CHANGED
@@ -9,9 +9,11 @@
9
9
  // argomenti e orchestrazione delle chiamate via ctx.api. Ordine, nomi, schemi,
10
10
  // handler, identity gate e semantica read/write sono preservati EXACT.
11
11
  const path = require('node:path');
12
+ const { codeOf, phaseOf } = require('../fleet/causes.js');
12
13
  const {
13
14
  NODE_ID_RE, orderedDeckMembers, fleetStatusPath, fleetCellsBySession,
14
- routePath, topologyOwners, memberOwnerId, parseCellTarget, readCellDirectory,
15
+ routePath, topologyOwners, memberOwnerId, parseCellTarget, normalizeCellPayload,
16
+ readCellDirectory,
15
17
  } = require('./cells.js');
16
18
 
17
19
  // --- helper input tool (fail-closed) ------------------------------------------
@@ -57,6 +59,93 @@ function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
57
59
  );
58
60
  }
59
61
 
62
+ // Il command Shell e' utile per diagnosticare una cella locale, ma puo'
63
+ // contenere credenziali scritte per errore. Manteniamo la struttura operativa
64
+ // e redigiamo flag/assegnazioni credential-shaped prima che il valore lasci il
65
+ // bridge MCP. Nessun command entra mai nella directory federata nc_cells.
66
+ function commandForDiagnostics(raw) {
67
+ if (typeof raw !== 'string' || !raw.trim()) {
68
+ return { configured: false, value: null, redacted: false, truncated: false };
69
+ }
70
+ let value = raw.normalize('NFC').replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
71
+ const before = value;
72
+ value = value
73
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
74
+ .replace(/((?:--)?(?:api[-_]?key|token|secret|password|credential))(\s*(?:=|:)\s*|\s+)(?:"[^"]*"|'[^']*'|\S+)/gi,
75
+ '$1$2[redacted]')
76
+ .replace(/\b([A-Za-z][A-Za-z0-9_]*(?:_API_KEY|_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIAL|_AUTH)[A-Za-z0-9_]*)\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/gi,
77
+ '$1=[redacted]')
78
+ // env maiuscole generiche senza separatore prima del suffisso credenziale
79
+ // (ZAIKEY=, PASSWD=, MYPASS=, DATABASE_URL=...): coerente con
80
+ // lib/diagnostics/store.js, ma consuma anche valori quotati per intero.
81
+ .replace(/\b([A-Z][A-Z0-9_]{2,})\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '$1=[redacted]')
82
+ .replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)[_-][A-Za-z0-9_-]{8,}\b/g, '[redacted]');
83
+ const truncated = value.length > 4096;
84
+ if (truncated) value = value.slice(0, 4096);
85
+ return { configured: true, value, redacted: value !== before || truncated, truncated };
86
+ }
87
+
88
+ // Accetta soltanto i due eventi failure prodotti dal router Fleet e ricostruisce
89
+ // una causa chiusa. Free text e meta ignoti vengono sempre scartati, anche se
90
+ // arrivano da un runtime precedente o compromesso.
91
+ function failureForDiagnostics(record, cell) {
92
+ if (!record || record.component !== 'fleet' || !record.meta || record.meta.cell !== cell) return null;
93
+ const at = typeof record.ts === 'string' && record.ts.length <= 64 ? record.ts : null;
94
+ const status = Number.isSafeInteger(record.meta.status) && record.meta.status >= 100 && record.meta.status <= 599
95
+ ? record.meta.status : null;
96
+ if (record.code === 'CELL_SPAWN_FAILED') {
97
+ return {
98
+ event: 'CELL_SPAWN_FAILED', at, status,
99
+ code: 'SPAWN_CLIENT_FAILED', phase: 'spawn-client',
100
+ errno: /^[A-Z][A-Z0-9_]{0,31}$/.test(String(record.meta.errno || '')) ? record.meta.errno : null,
101
+ client: /^[A-Za-z0-9._+-]{1,128}$/.test(String(record.meta.client || '')) ? record.meta.client : null,
102
+ };
103
+ }
104
+ if (record.code !== 'FLEET_ACTION_FAILED') return null;
105
+ return {
106
+ event: 'FLEET_ACTION_FAILED', at, status,
107
+ code: codeOf(record.meta.code), phase: phaseOf(record.meta.phase),
108
+ };
109
+ }
110
+
111
+ function lastFailureForCell(records, cell) {
112
+ if (!Array.isArray(records)) return null;
113
+ for (let i = records.length - 1; i >= 0; i -= 1) {
114
+ const failure = failureForDiagnostics(records[i], cell);
115
+ if (failure) return failure;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ async function recentDiagnostics(ctx) {
121
+ const out = [];
122
+ let after = 0;
123
+ // Store bounded a 500 record: tre pagine da 200 coprono l'intero buffer senza
124
+ // una query illimitata e senza dipendere dalla modalita' verbose.
125
+ for (let page = 0; page < 3; page += 1) {
126
+ const payload = await ctx.api('GET', `/api/diagnostics/logs?after=${after}&limit=200`);
127
+ const records = Array.isArray(payload && payload.records) ? payload.records : [];
128
+ out.push(...records);
129
+ const cursor = Number.isSafeInteger(payload && payload.cursor) ? payload.cursor : after;
130
+ if (records.length < 200 || cursor <= after) break;
131
+ after = cursor;
132
+ }
133
+ return out.slice(-500);
134
+ }
135
+
136
+ // La diagnostica command/cause e' intenzionalmente locale: non deve neppure
137
+ // interrogare la topologia o i proxy remoti. Questo helper costruisce la stessa
138
+ // forma normalizzata di nc_cells usando soltanto config + /api/cells locali.
139
+ async function readLocalCellDirectory(ctx, callerSession) {
140
+ const [config, payload] = await Promise.all([
141
+ ctx.api('GET', '/api/config'), ctx.api('GET', '/api/cells'),
142
+ ]);
143
+ const nodeId = String(config && config.instanceId || '');
144
+ if (!NODE_ID_RE.test(nodeId)) throw new Error('instanceId locale non disponibile');
145
+ const owner = { instanceId: nodeId, route: [], label: 'Local', stale: false };
146
+ return { nodeId, cells: normalizeCellPayload(payload, owner, callerSession) };
147
+ }
148
+
60
149
  // --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
61
150
  // annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
62
151
  const TOOLS = [
@@ -206,7 +295,8 @@ const TOOLS = [
206
295
  const members = orderedDeckMembers(deck).map((member) => ({
207
296
  ...member,
208
297
  ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
209
- }));
298
+ })).filter((member) => member.ownerId === localNodeId
299
+ || (member.ownerId && viewerById.has(member.ownerId)));
210
300
  if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
211
301
  decks.push({
212
302
  id: `${source.owner.instanceId}:${deck.name}`,
@@ -263,6 +353,53 @@ const TOOLS = [
263
353
  return readCellDirectory(ctx, callerSession);
264
354
  },
265
355
  },
356
+ {
357
+ name: 'nc_cell_diagnostics',
358
+ description: 'Diagnostica read-only target-specifica di una cella Fleet locale: command Shell redatto e ultima causa spawn/start bounded. Richiede un caller Fleet locale attivo; non attraversa la federazione.',
359
+ inputSchema: {
360
+ type: 'object',
361
+ properties: {
362
+ target: { type: 'string', description: 'id owner-qualified locale restituito da nc_cells: <instanceId>:<cell>' },
363
+ },
364
+ required: ['target'],
365
+ },
366
+ annotations: { readOnlyHint: true },
367
+ async handler(args, ctx) {
368
+ const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
369
+ if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
370
+ const identity = await ctx.identity();
371
+ const callerSession = requireSession(identity.session, 'nc_cell_diagnostics', identity.code);
372
+ const directory = await readLocalCellDirectory(ctx, callerSession);
373
+ const sender = directory.cells.find((cell) => cell.self && cell.active);
374
+ if (!sender) throw new Error('nc_cell_diagnostics: la sessione chiamante non e\' una cella Fleet attiva locale');
375
+ if (targetRef.instanceId !== directory.nodeId) {
376
+ throw new Error('nc_cell_diagnostics: target remoto rifiutato; i command non attraversano la federazione');
377
+ }
378
+ const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
379
+ && cell.cell === targetRef.cell);
380
+ if (!target) throw new Error('nc_cell_diagnostics: cella locale non trovata');
381
+
382
+ const [definitions, diagnostics] = await Promise.all([
383
+ ctx.api('GET', '/api/fleet/definitions'), recentDiagnostics(ctx),
384
+ ]);
385
+ const definition = Array.isArray(definitions && definitions.cells)
386
+ ? definitions.cells.find((cell) => cell && cell.id === target.cell) : null;
387
+ if (!definition || definition.tmuxSession !== target.tmuxSession || definition.engine !== target.engine) {
388
+ throw new Error('nc_cell_diagnostics: definizione locale incoerente o non disponibile');
389
+ }
390
+ const rawCommand = definition.commands && typeof definition.commands === 'object'
391
+ ? definition.commands[definition.engine] : '';
392
+ return {
393
+ target: target.id,
394
+ cell: target.cell,
395
+ tmuxSession: target.tmuxSession,
396
+ engine: target.engine,
397
+ active: target.active,
398
+ command: commandForDiagnostics(rawCommand),
399
+ lastFailure: lastFailureForCell(diagnostics, target.cell),
400
+ };
401
+ },
402
+ },
266
403
  {
267
404
  name: 'nc_send_cell',
268
405
  description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
@@ -346,4 +483,7 @@ const TOOLS = [
346
483
  },
347
484
  ];
348
485
 
349
- module.exports = { TOOLS, argString, requireSession, IDENTITY_CODE, IDENTITY_REMEDIATION };
486
+ module.exports = {
487
+ TOOLS, argString, requireSession, commandForDiagnostics, failureForDiagnostics,
488
+ lastFailureForCell, readLocalCellDirectory, IDENTITY_CODE, IDENTITY_REMEDIATION,
489
+ };