@mmmbuto/nexuscrew 0.8.30 → 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/)) {
@@ -485,17 +507,30 @@ function describeManaged(spec, cfg = {}) {
485
507
  const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom'
486
508
  && profile.delegatePiAuth !== false;
487
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
+ }
488
525
  return {
489
526
  client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
490
527
  credentialProfile: normalized.credentialProfile || '', model: normalized.model,
491
528
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
492
529
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
493
530
  credentialSource: authConfigured ? cred.source : 'missing',
494
- configured: !!binary && authConfigured, models: [...(profile.models || [])], defaultModel: profile.model || '',
531
+ configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
495
532
  binary: binary || '', displayName: normalized.displayName || profile.label,
496
- reason: !binary ? `client ${profile.client} not found` : (!authConfigured
497
- ? `credential ${cred.envKey} missing — set it on this device`
498
- : 'ready'),
533
+ reason,
499
534
  };
500
535
  }
501
536
 
@@ -644,7 +679,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
644
679
  if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
645
680
  info.permissionPolicy = effectivePolicy;
646
681
  if (effectivePolicy === 'unsafe') {
647
- if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
682
+ if (spec.client === 'claude' || spec.client === 'agy') args.push('--dangerously-skip-permissions');
648
683
  if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
649
684
  }
650
685
  let shellOneShot = false;
@@ -770,6 +805,13 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
770
805
  if (spec.provider !== 'native') args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
771
806
  if (model) args.push('--model', model);
772
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');
773
815
  }
774
816
  if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
775
817
  let command = info.binary;
@@ -800,7 +842,7 @@ module.exports = {
800
842
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
801
843
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
802
844
  CLIENT_LABELS, normalizeManagedSpec,
803
- defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
845
+ defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
804
846
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
805
847
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
806
848
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
@@ -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,29 +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 sia un errore reale del client sia l'exit immediato di un command
223
- // Shell. Il separatore e' interpretato da tmux (execFile argv diretto), non
224
- // dalla shell che esegue il command configurato.
225
- argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
226
- const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
227
- 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 */ }
228
241
  // Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
229
- const dup = /duplicate session/i.test(launch.stderr);
242
+ const dup = /duplicate session/i.test(create.stderr);
230
243
  const why = dup
231
244
  ? 'sessione già in esecuzione'
232
- : `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)}`;
233
246
  throw httpError(dup ? 409 : 500, why, null,
234
247
  { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
235
248
  }
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' });
311
+ }
236
312
 
237
313
  // `tmux new-session -d` can return 0 even when the launched CLI exits a
238
314
  // moment later (missing login, bad model, incompatible provider). Without
239
315
  // this readiness gate the PWA reported success and then showed nothing.
240
316
  // Always verify liveness, including cells without a system prompt.
241
- const paneId = launch.stdout.trim().split('\n')[0] || '';
317
+
242
318
  const readiness = paneId.startsWith('%')
243
319
  ? await waitStablePane(tmuxBin, paneId, { env: minimalEnv(), readyMs })
244
320
  : { alive: await waitAlive(tmuxBin, cell.tmuxSession, { env: minimalEnv(), readyMs }), status: null, target: null };
@@ -251,7 +327,7 @@ function createBuiltinRuntime(ctx) {
251
327
  }
252
328
  // remain-on-exit era soltanto diagnostico: nessun pane morto deve restare
253
329
  // nella Fleet o nella lista tmux dopo aver raccolto l'errore.
254
- await tmuxExec(tmuxBin, ['kill-session', '-t', `=${cell.tmuxSession}`], { env: minimalEnv(), timeoutMs: 2000 });
330
+ await cleanupLaunch();
255
331
  cache = { ...cache, at: 0 };
256
332
  // Un command Shell completato rapidamente con exit 0 e' un one-shot
257
333
  // riuscito. Qualunque altro exit immediato e' invece osservabile come
package/lib/mcp/tools.js CHANGED
@@ -295,7 +295,8 @@ const TOOLS = [
295
295
  const members = orderedDeckMembers(deck).map((member) => ({
296
296
  ...member,
297
297
  ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
298
- }));
298
+ })).filter((member) => member.ownerId === localNodeId
299
+ || (member.ownerId && viewerById.has(member.ownerId)));
299
300
  if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
300
301
  decks.push({
301
302
  id: `${source.owner.instanceId}:${deck.name}`,
@@ -310,7 +310,7 @@ async function reconcilePeerShare(opts = {}) {
310
310
  throw new Error((health && health.detail) || 'hub non raggiungibile per riconciliare Share');
311
311
  }
312
312
  const attempts = Number.isInteger(opts.notifyAttempts)
313
- ? Math.max(1, Math.min(opts.notifyAttempts, 6)) : (shared ? 3 : 1);
313
+ ? Math.max(1, Math.min(opts.notifyAttempts, 6)) : 3;
314
314
  let lastError = null;
315
315
  for (let attempt = 0; attempt < attempts; attempt += 1) {
316
316
  try {
@@ -324,6 +324,67 @@ async function reconcilePeerShare(opts = {}) {
324
324
  throw lastError || new Error('riconciliazione Share fallita');
325
325
  }
326
326
 
327
+ // Runner di riconciliazione Share OFF al boot (design piano §3.2.8). Per-peer,
328
+ // no-overlap (un solo runner attivo per nome), al massimo tre round per processo,
329
+ // backoff nominato 0/1000/5000 ms (iniettabile). Re-read dello stato desiderato
330
+ // (nodes.json) prima di ogni round: abort se non e' piu' shared:false (es. il peer
331
+ // e' stato ri-condiviso o rimosso). Emette SHARE_REVOKE_PENDING/RECOVERED/EXHAUSTED
332
+ // tramite il callback diagnostico (una transizione ciascuno, no spam). Nessun
333
+ // timer infinito, nessuna retry storm process-wide, nessun retry di ON sotto la
334
+ // policy OFF. Il backoff e' un delay nominato usato solo fra round di fallimento.
335
+ const SHARE_REVOKE_BACKOFF_MS = Object.freeze([0, 1000, 5000]);
336
+ const shareRevokeRunning = new Set(); // per-peer no-overlap (scope modulo)
337
+ function runShareRevokeBoot({
338
+ node, nodesPath, fetchImpl = fetch, diagnostics,
339
+ backoff = SHARE_REVOKE_BACKOFF_MS, delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
340
+ runningSet = shareRevokeRunning, reconcileImpl = reconcilePeerShare,
341
+ healthAttempts = 3, notifyAttempts = 3, delayMs = 200, timeoutMs = 5000,
342
+ }) {
343
+ const name = node && typeof node.name === 'string' ? node.name : '';
344
+ if (!store.NODE_NAME_RE.test(name) || !store.NODE_ID_RE.test(String(node && node.nodeId || ''))
345
+ || !store.validToken(node && node.token) || typeof nodesPath !== 'string' || !nodesPath
346
+ || typeof reconcileImpl !== 'function') {
347
+ return Promise.resolve({ status: 'skipped', reason: 'invalid-input' });
348
+ }
349
+ if (runningSet.has(name)) return Promise.resolve({ status: 'already-running' });
350
+ runningSet.add(name);
351
+ const emit = (level, code, message, state) => {
352
+ if (diagnostics && typeof diagnostics.record === 'function') {
353
+ try { diagnostics.record(level, 'share', code, message, { node: name, state }); } catch (_) { /* best-effort */ }
354
+ }
355
+ };
356
+ return (async () => {
357
+ emit('warn', 'SHARE_REVOKE_PENDING', 'Share OFF reconciliation pending', 'pending');
358
+ const slots = Array.from({ length: 3 }, (_, index) => {
359
+ const candidate = Array.isArray(backoff) ? backoff[index] : SHARE_REVOKE_BACKOFF_MS[index];
360
+ return Number.isFinite(candidate) ? Math.max(0, Math.min(candidate, 30000)) : SHARE_REVOKE_BACKOFF_MS[index];
361
+ });
362
+ for (let round = 0; round < slots.length; round += 1) {
363
+ try {
364
+ if (slots[round] > 0) await delay(slots[round]);
365
+ // Re-read dello stato desiderato immediatamente prima di ogni round.
366
+ // Un peer rimosso o nuovamente condiviso annulla la policy OFF in coda.
367
+ const st = store.loadStoreStrict(nodesPath);
368
+ const fresh = store.getNode(st, name);
369
+ if (!fresh || fresh.shared !== false) return { status: 'aborted', reason: 'desired-state-changed' };
370
+ await reconcileImpl({
371
+ node: fresh, shared: false, fetchImpl,
372
+ healthAttempts: Math.max(3, Math.min(Number(healthAttempts) || 3, 12)),
373
+ notifyAttempts: Math.max(3, Math.min(Number(notifyAttempts) || 3, 6)),
374
+ delayMs: Math.max(0, Math.min(Number(delayMs) || 0, 2000)), timeoutMs,
375
+ });
376
+ emit('warn', 'SHARE_REVOKE_RECOVERED', 'Share OFF reconciliation recovered', 'recovered');
377
+ return { status: 'recovered', rounds: round + 1 };
378
+ } catch (_) {
379
+ // Error text is intentionally not recorded: it can contain transport
380
+ // details. The stable transition code is sufficient for Diagnostics.
381
+ }
382
+ }
383
+ emit('error', 'SHARE_REVOKE_EXHAUSTED', 'Share OFF reconciliation exhausted', 'exhausted');
384
+ return { status: 'exhausted', rounds: 3 };
385
+ })().finally(() => { runningSet.delete(name); });
386
+ }
387
+
327
388
  function controlledVisited(req, ingress, instanceId) {
328
389
  const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
329
390
  const seen = raw ? raw.split(',').filter(Boolean) : [];
@@ -565,5 +626,5 @@ module.exports = {
565
626
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
566
627
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
567
628
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
568
- probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
629
+ probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
569
630
  };
package/lib/server.js CHANGED
@@ -182,12 +182,28 @@ function createServer(opts = {}) {
182
182
  // asincrona chiude la finestra di crash tra write locale e ACK hub
183
183
  // senza ritardare il listen del server.
184
184
  const reconcileShare = cfg.reconcilePeerShareImpl || federation.reconcilePeerShare;
185
- Promise.resolve(reconcileShare({
186
- node, shared: node.shared === true, fetchImpl: healthFetch,
187
- healthAttempts: 3, notifyAttempts: node.shared === true ? 3 : 1, delayMs: 200,
188
- })).catch((e) => {
189
- process.stderr.write(`[nexuscrew] peer ${node.name} Share reconcile pending: ${String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***')}\n`);
190
- });
185
+ if (node.shared === true) {
186
+ Promise.resolve(reconcileShare({
187
+ node, shared: true, fetchImpl: healthFetch,
188
+ healthAttempts: 3, notifyAttempts: 3, delayMs: 200,
189
+ })).catch((e) => {
190
+ process.stderr.write(`[nexuscrew] peer ${node.name} Share reconcile pending: ${String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***')}\n`);
191
+ });
192
+ } else {
193
+ const runRevoke = cfg.runShareRevokeBootImpl || federation.runShareRevokeBoot;
194
+ Promise.resolve(runRevoke({
195
+ node, nodesPath, fetchImpl: healthFetch, diagnostics,
196
+ reconcileImpl: reconcileShare,
197
+ healthAttempts: 3, notifyAttempts: 3, delayMs: 200,
198
+ ...(Array.isArray(cfg.shareRevokeBackoff) ? { backoff: cfg.shareRevokeBackoff } : {}),
199
+ ...(typeof cfg.shareRevokeDelay === 'function' ? { delay: cfg.shareRevokeDelay } : {}),
200
+ })).catch(() => {
201
+ // The production runner contains failures and records the stable
202
+ // transitions itself. This protects only an injected/custom runner.
203
+ diagnostics.record('error', 'share', 'SHARE_REVOKE_EXHAUSTED',
204
+ 'Share OFF reconciliation exhausted', { node: node.name, state: 'exhausted' });
205
+ });
206
+ }
191
207
  }
192
208
  }
193
209
  }
@@ -48,7 +48,7 @@ const nodeAliases = require('../nodes/aliases.js');
48
48
  const nodesCmds = require('../nodes/commands.js');
49
49
  const nodesTunnel = require('../nodes/tunnel.js');
50
50
  const peering = require('../nodes/peering.js');
51
- const { waitForHealthyPeer, notifyHubShare } = require('../proxy/federation.js');
51
+ const { waitForHealthyPeer, notifyHubShare, reconcilePeerShare } = require('../proxy/federation.js');
52
52
  const { rotateToken } = require('../auth/token.js');
53
53
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
54
54
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -573,8 +573,10 @@ function settingsRoutes(deps = {}) {
573
573
  const fetchImpl = seams.fetchImpl || fetch;
574
574
  const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
575
575
  const ensureLocal = async ({ restart = false } = {}) => {
576
- if (restart) nodesTunnel.stopTunnel({ home, name });
577
- const started = nodesTunnel.startForward({
576
+ const stopForward = seams.stopTunnelImpl || nodesTunnel.stopTunnel;
577
+ const startForward = seams.startForwardImpl || nodesTunnel.startForward;
578
+ if (restart) stopForward({ home, name });
579
+ const started = startForward({
578
580
  home, node, localAppPort: runtimePort(),
579
581
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
580
582
  sshBin: seams.sshBin, logFd: seams.logFd,
@@ -606,27 +608,71 @@ function settingsRoutes(deps = {}) {
606
608
  node = nodesStore.getNode(st, name);
607
609
  await ensureLocal({ restart: true });
608
610
  };
611
+ const revokeHub = () => reconcilePeerShare({
612
+ node, shared: false, fetchImpl,
613
+ healthAttempts: 6, delayMs: 200, notifyAttempts: 3, timeoutMs: 5000,
614
+ ...(typeof seams.pairDelay === 'function' ? { delay: seams.pairDelay } : {}),
615
+ });
616
+ const reconcileOff = async ({ restart = false, unchanged = false } = {}) => {
617
+ try {
618
+ // The authenticated hub endpoint is reached through the existing -L.
619
+ // Revoke before any spec-aware replacement can tear that transport down.
620
+ await revokeHub();
621
+ } catch (revokeErr) {
622
+ return send(res, 502, {
623
+ error: 'Share disattivato localmente; hub non riconciliato',
624
+ shared: false, revoked: false, reconcilePending: true,
625
+ detail: scrubError(revokeErr),
626
+ });
627
+ }
628
+ try {
629
+ // A changed ON->OFF spec is restarted explicitly; same-state OFF uses
630
+ // the idempotent spec-aware path, which only replaces a stale -R.
631
+ await ensureLocal({ restart });
632
+ } catch (restartErr) {
633
+ return send(res, 502, {
634
+ error: 'Share revocato sul hub; riavvio locale non riconciliato',
635
+ shared: false, revoked: true, localReconcilePending: true,
636
+ detail: scrubError(restartErr),
637
+ });
638
+ }
639
+ return send(res, 200, {
640
+ name, shared: false, revoked: true,
641
+ ...(unchanged ? { unchanged: true, reconciled: true } : {}),
642
+ });
643
+ };
609
644
 
610
645
  try {
611
646
  if (node.shared === body.shared) {
612
647
  // Persisted intent is not proof of the detached process mode. A stale
613
648
  // supervisor can survive an npm upgrade and keep retrying -R even when
614
- // the store/UI says private. Re-enter the spec-aware start path before
615
- // reconciling the hub, without rewriting nodes.json.
616
- await ensureLocal();
617
- await notifyHub(body.shared);
618
- return send(res, 200, { name, shared: body.shared, unchanged: true, reconciled: true });
649
+ // the store/UI says private. Same-state OFF revokes first, then enters
650
+ // the spec-aware start path without rewriting nodes.json.
651
+ if (body.shared) {
652
+ await ensureLocal();
653
+ await notifyHub(true);
654
+ return send(res, 200, { name, shared: true, unchanged: true, reconciled: true });
655
+ }
656
+ return reconcileOff({ unchanged: true });
619
657
  }
620
658
  if (body.shared) {
621
659
  await applyLocal(true);
622
660
  await notifyHub(true);
623
- } else {
624
- // Persist OFF first. If the process dies before the hub ACK, the next
625
- // boot starts only -L and reconciles the stored false state.
626
- await applyLocal(false);
627
- await notifyHub(false);
661
+ return send(res, 200, { name, shared: true });
628
662
  }
629
- return send(res, 200, { name, shared: body.shared });
663
+ // OFF e' fail-safe e crash-durevole (design piano §3.2): persist desired
664
+ // false -> revoke autenticata sul canale -L ancora vivo -> restart locale
665
+ // senza -R. MAI restart-before-revoke: il -L e' il canale stesso della
666
+ // revoca, e un crash prima dell'ACK non puo' mai ripubblicare da intent
667
+ // stale (il file dice gia' false; il boot riparte -L only + reconcile).
668
+ // 1) persist desired false (snapshot dei soli campi gia' validati).
669
+ st = nodesStore.loadStoreStrict(nodesPath);
670
+ st = nodesStore.updateNode(st, name, { shared: false });
671
+ nodesStore.atomicWriteStore(nodesPath, st);
672
+ persistedDesired = false;
673
+ node = nodesStore.getNode(st, name);
674
+ // 2-3) revoke sul -L vivo, poi restart locale senza -R.
675
+ return reconcileOff({ restart: true });
630
676
  } catch (e) {
631
677
  // Share-on is transactional: a failed hub acknowledgement returns to the
632
678
  // safe private -L-only state. Never include remote response bodies/tokens.
@@ -43,6 +43,7 @@ function presetArgv(preset, extra) {
43
43
  // Pure: argomenti tmux per la create, o null se input invalido.
44
44
  function buildCreateArgs(name, realCwd, preset, extraPresets) {
45
45
  if (!validSessionName(name) || typeof realCwd !== 'string' || !realCwd) return null;
46
+ if (name.includes('.')) return null; // tmux normalizza '.' in '_': rifiuta in scrittura
46
47
  const argv = presetArgv(String(preset || 'shell'), extraPresets);
47
48
  if (argv === undefined) return null;
48
49
  const base = ['new-session', '-d', '-s', name, '-c', realCwd];