@mmmbuto/nexuscrew 0.9.2 → 0.9.4

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.
@@ -30,7 +30,8 @@ const fs = require('node:fs');
30
30
  const os = require('node:os');
31
31
  const path = require('node:path');
32
32
  const {
33
- loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
33
+ loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName, validateCommandTrust,
34
+ aggiornaDefinizioni,
34
35
  cellIdFromTmuxSession,
35
36
  resolveCwd, normalizeCwdRel, deriveCwdRel,
36
37
  } = require('./definitions.js');
@@ -87,13 +88,20 @@ function draftFrom(defs) {
87
88
  // ricevono l'engine standard Shell senza riscrivere celle o sostituire un id
88
89
  // scelto dall'utente. Se lo store e' pieno o la scrittura non e' possibile, il
89
90
  // bootstrap resta utilizzabile con le definizioni precedenti.
90
- function backfillShellEngine(defsPath, defs) {
91
- if (!defs || defs.engines.some((engine) => engine.managed?.client === 'shell')) return defs;
92
- if (defs.engines.some((engine) => engine.id === 'shell.local')) return defs;
93
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
94
- const draft = draftFrom(defs);
95
- draft.engines.push(defaultShellEngine());
96
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
91
+ function backfillShellEngine(defsPath, defs, log) {
92
+ if (!defs) return defs;
93
+ // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
94
+ // sullo stato che avevamo in mano: e' la differenza fra decidere sul
95
+ // presente e decidere su una fotografia.
96
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
97
+ if (dentro.engines.some((engine) => engine.managed?.client === 'shell')) return null;
98
+ if (dentro.engines.some((engine) => engine.id === 'shell.local')) return null;
99
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
100
+ const draft = draftFrom(dentro);
101
+ draft.engines.push(defaultShellEngine());
102
+ return draft;
103
+ }, { log });
104
+ return esito || defs;
97
105
  }
98
106
 
99
107
  // Backfill platform-aware dell'engine Agy primario (design §4.2): installazioni
@@ -111,12 +119,17 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
111
119
  const termux = platform === 'android'
112
120
  || termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
113
121
  if (termux || (platform !== 'linux' && platform !== 'darwin')) return defs;
114
- if (defs.engines.some((engine) => engine.managed?.client === 'agy')) return defs;
115
- if (defs.engines.some((engine) => engine.id === 'agy.native')) return defs;
116
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
117
- const draft = draftFrom(defs);
118
- draft.engines.push(defaultAgyEngine());
119
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
122
+ // Il gate di piattaforma non dipende dallo store e resta fuori dal lock:
123
+ // non si tiene un lock per rispondere a una domanda sul sistema.
124
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
125
+ if (dentro.engines.some((engine) => engine.managed?.client === 'agy')) return null;
126
+ if (dentro.engines.some((engine) => engine.id === 'agy.native')) return null;
127
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
128
+ const draft = draftFrom(dentro);
129
+ draft.engines.push(defaultAgyEngine());
130
+ return draft;
131
+ }, { log: cfg.log });
132
+ return esito || defs;
120
133
  }
121
134
 
122
135
  // Engine desktop.local: NON managed (nessun client/provider AI, solo command+args
@@ -137,15 +150,47 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
137
150
  function risolviEseguibile(nome) {
138
151
  const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
139
152
  for (const dir of dirs) {
140
- try {
141
- const reale = fs.realpathSync(path.join(dir, nome));
142
- const st = fs.statSync(reale);
143
- if (st.isFile() && (st.mode & 0o100)) return reale;
144
- } catch (_) { /* voce del PATH inservibile: si prova la prossima */ }
153
+ let reale;
154
+ try { reale = fs.realpathSync(path.join(dir, nome)); } catch (_) { continue; }
155
+ // La decisione la prende la STESSA funzione che deciderà al salvataggio.
156
+ // Riscriverne una copia qui significava poter divergere, ed era divergente:
157
+ // un `docker` 0777 passava il controllo locale (file + eseguibile) e veniva
158
+ // poi rifiutato come world-writable — cioè il default proposto non superava
159
+ // la validazione che lo attendeva.
160
+ if (!validateCommandTrust(reale).ok) continue;
161
+ // In più, una condizione che la validazione NON esprime: un binario
162
+ // ineccepibile dentro una directory scrivibile da chiunque è sostituibile
163
+ // da chiunque. Il salvataggio lo accetterebbe; noi non lo PROPONIAMO —
164
+ // scegliere il default è nostro, e su una scelta nostra si può essere più
165
+ // prudenti del minimo richiesto.
166
+ if (dirScrivibileDaTutti(reale)) continue;
167
+ return reale;
145
168
  }
146
169
  return null;
147
170
  }
148
171
 
172
+ // Guarda la directory che CONTIENE l'eseguibile, non l'eseguibile: è lì che si
173
+ // decide chi può sostituirlo. Sticky bit escluso (come /tmp: lì il rename
174
+ // altrui è già impedito dal kernel).
175
+ function dirScrivibileDaTutti(file) {
176
+ // Tutta la CATENA, non il solo genitore: con `/a` scrivibile da chiunque,
177
+ // `/a/b/docker` resta sostituibile rinominando `b` — il binario e la sua
178
+ // directory immediata possono essere ineccepibili e il percorso no. Un audit
179
+ // ha riprodotto esattamente questo caso su un controllo fermo al genitore.
180
+ //
181
+ // Lo sticky bit interrompe la risalita per quel livello: li' il kernel
182
+ // impedisce gia' di rinominare o rimuovere roba altrui (e' il caso di /tmp).
183
+ let dir = path.dirname(file);
184
+ for (;;) {
185
+ let st;
186
+ try { st = fs.statSync(dir); } catch (_) { return true; } // non ispezionabile: scarta
187
+ if ((st.mode & 0o002) && !(st.mode & 0o1000)) return true;
188
+ const su = path.dirname(dir);
189
+ if (su === dir) return false; // radice raggiunta: catena pulita
190
+ dir = su;
191
+ }
192
+ }
193
+
149
194
  // Il command DEVE essere un path assoluto: `validateCommandTrust` e' la trust
150
195
  // boundary degli engine, e un nome relativo si risolverebbe via PATH — cioe'
151
196
  // via qualcosa che l'ambiente puo' cambiare sotto di noi. Dichiarare 'docker'
@@ -191,16 +236,28 @@ function defaultDesktopEngine() {
191
236
  // bootstrap: una dipendenza nuova sul percorso di avvio per una comodita'.
192
237
  // Resta MANUALE: chi ha il container se lo aggiunge nella propria procedura
193
238
  // — e' il posto dove qualcuno sa gia' che il container esiste.
194
- function backfillDesktopEngine(defsPath, defs) {
239
+ function backfillDesktopEngine(defsPath, defs, log) {
195
240
  if (!defs) return defs;
196
- const esistente = defs.engines.find((engine) => engine.id === 'desktop.local');
197
- if (esistente) return riparaDesktopEngine(defsPath, defs, esistente);
198
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
199
- const draft = draftFrom(defs);
200
- draft.engines.push(defaultDesktopEngine());
201
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
241
+ if (defs.engines.some((engine) => engine.id === 'desktop.local')) {
242
+ return riparaDesktopEngine(defsPath, defs, log);
243
+ }
244
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
245
+ if (dentro.engines.some((engine) => engine.id === 'desktop.local')) return null;
246
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
247
+ const draft = draftFrom(dentro);
248
+ draft.engines.push(defaultDesktopEngine());
249
+ return draft;
250
+ }, { log });
251
+ return esito || defs;
202
252
  }
203
253
 
254
+ // A DIFFERENZA del backfill qui sopra, questa e' agganciata al bootstrap, e
255
+ // non contraddice la decisione del 2026-08-14: non AGGIUNGE l'engine a chi non
256
+ // ce l'ha e non presuppone alcun container: tocca soltanto un `desktop.local`
257
+ // gia' presente, il cui comando e' rotto per costruzione. Senza quell'aggancio
258
+ // la funzione non aveva chiamanti — scritta, testata e mai eseguita, con un
259
+ // test verde perche' la invocava direttamente.
260
+ //
204
261
  // Le installazioni che hanno gia' ricevuto il backfill portano in
205
262
  // configurazione un `command` RELATIVO, che la trust boundary rifiuta: per
206
263
  // loro l'aggiornamento del default non cambia nulla, perche' il backfill salta
@@ -210,17 +267,51 @@ function backfillDesktopEngine(defsPath, defs) {
210
267
  // Prudente per costruzione: interviene SOLO se il comando non e' assoluto e si
211
268
  // chiama ancora `docker`. Un path assoluto — o un comando che l'utente ha
212
269
  // cambiato in altro — non viene toccato: quella e' una scelta, non un residuo.
213
- function riparaDesktopEngine(defsPath, defs, esistente) {
214
- const cmd = typeof esistente.command === 'string' ? esistente.command : '';
215
- if (!cmd || path.isAbsolute(cmd)) return defs;
216
- if (path.basename(cmd) !== 'docker') return defs;
270
+ function riparaDesktopEngine(defsPath, defs, log = () => {}) {
271
+ if (!defs || !Array.isArray(defs.engines)) return defs;
272
+ if (!eDaRiparare(defs)) return defs; // scarto rapido, senza lock
217
273
  const risolto = risolviEseguibile('docker');
218
- if (!risolto) return defs; // niente docker qui: meglio invariato che finto
219
- const draft = draftFrom(defs);
220
- const voce = draft.engines.find((engine) => engine.id === 'desktop.local');
221
- if (!voce) return defs;
222
- voce.command = risolto;
223
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
274
+ if (!risolto) return defs; // niente docker fidato: invariato
275
+
276
+ // La condizione si rivaluta DENTRO il lock, sullo stato appena riletto: fra
277
+ // il nostro scarto rapido e la scrittura qualcuno puo' aver gia' corretto
278
+ // quella voce, o averla cambiata in altro. Rinunciare costa un altro avvio;
279
+ // sovrascrivere costa un dato.
280
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
281
+ if (!eDaRiparare(dentro)) return null;
282
+ const draft = draftFrom(dentro);
283
+ const voce = draft.engines.find((engine) => engine.id === 'desktop.local');
284
+ if (!voce) return null;
285
+ voce.command = risolto;
286
+ return draft;
287
+ }, {
288
+ log: (m) => log(m),
289
+ });
290
+ if (!esito) {
291
+ log('desktop.local: riparazione del comando non persistita (definizioni non leggibili)');
292
+ return defs;
293
+ }
294
+ if (esito.engines.find((e) => e.id === 'desktop.local')?.command === 'docker') {
295
+ // Il lock c'era ma la scrittura non ha attecchito: dirlo, invece di
296
+ // lasciare l'avvio convinto di aver fatto il suo lavoro.
297
+ log('desktop.local: riparazione del comando non persistita');
298
+ }
299
+ return esito;
300
+ }
301
+
302
+ // La condizione di riparabilità, in un posto solo perché va valutata due volte:
303
+ // sullo stato in mano e su quello riletto un istante prima di scrivere.
304
+ //
305
+ // `docker` NUDO, non un basename qualsiasi: `vendor/docker` è un percorso
306
+ // relativo che qualcuno ha scritto apposta per il proprio binario, e
307
+ // sostituirlo con il Docker di sistema è distruggere una scelta, non riparare
308
+ // un residuo. Il residuo che stiamo correggendo è esattamente la stringa che il
309
+ // nostro engine dichiarava.
310
+ function eDaRiparare(defs) {
311
+ if (!defs || !Array.isArray(defs.engines)) return false;
312
+ const voce = defs.engines.find((engine) => engine.id === 'desktop.local');
313
+ if (!voce) return false;
314
+ return voce.command === 'docker';
224
315
  }
225
316
 
226
317
  // Backfill dell'engine Kimi Code CLI nativo: installazioni esistenti ricevono
@@ -229,13 +320,20 @@ function riparaDesktopEngine(defsPath, defs, esistente) {
229
320
  // shebang. Idempotente (gia' presente -> skip), NON sovrascrive un id
230
321
  // 'kimi.native' gia' scelto dall'utente per altro (collisione -> skip, store
231
322
  // invariato), rispetta il cap MAX_ENGINES. Non tocca CELLE.
232
- function backfillKimiEngine(defsPath, defs) {
233
- if (!defs || defs.engines.some((engine) => engine.managed?.client === 'kimi')) return defs;
234
- if (defs.engines.some((engine) => engine.id === 'kimi.native')) return defs;
235
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
236
- const draft = draftFrom(defs);
237
- draft.engines.push(defaultKimiEngine());
238
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
323
+ function backfillKimiEngine(defsPath, defs, log) {
324
+ if (!defs) return defs;
325
+ // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
326
+ // sullo stato che avevamo in mano: e' la differenza fra decidere sul
327
+ // presente e decidere su una fotografia.
328
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
329
+ if (dentro.engines.some((engine) => engine.managed?.client === 'kimi')) return null;
330
+ if (dentro.engines.some((engine) => engine.id === 'kimi.native')) return null;
331
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
332
+ const draft = draftFrom(dentro);
333
+ draft.engines.push(defaultKimiEngine());
334
+ return draft;
335
+ }, { log });
336
+ return esito || defs;
239
337
  }
240
338
 
241
339
  // Backfill platform-aware dell'engine Grok Build (grok.native): come Agy,
@@ -251,12 +349,17 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
251
349
  const termux = platform === 'android'
252
350
  || termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
253
351
  if (termux || (platform !== 'linux' && platform !== 'darwin')) return defs;
254
- if (defs.engines.some((engine) => engine.managed?.client === 'grok')) return defs;
255
- if (defs.engines.some((engine) => engine.id === 'grok.native')) return defs;
256
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
257
- const draft = draftFrom(defs);
258
- draft.engines.push(defaultGrokEngine());
259
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
352
+ // Il gate di piattaforma non dipende dallo store e resta fuori dal lock:
353
+ // non si tiene un lock per rispondere a una domanda sul sistema.
354
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
355
+ if (dentro.engines.some((engine) => engine.managed?.client === 'grok')) return null;
356
+ if (dentro.engines.some((engine) => engine.id === 'grok.native')) return null;
357
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
358
+ const draft = draftFrom(dentro);
359
+ draft.engines.push(defaultGrokEngine());
360
+ return draft;
361
+ }, { log: cfg.log });
362
+ return esito || defs;
260
363
  }
261
364
 
262
365
  // Backfill dell'engine VL/Vivling (vl.native): come Kimi, NESSUN platform gate
@@ -264,13 +367,20 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
264
367
  // -> skip), NON sovrascrive un id 'vl.native' gia' scelto dall'utente per altro
265
368
  // (collisione -> skip, store invariato), rispetta il cap MAX_ENGINES. Non tocca
266
369
  // CELLE.
267
- function backfillVlEngine(defsPath, defs) {
268
- if (!defs || defs.engines.some((engine) => engine.managed?.client === 'vl')) return defs;
269
- if (defs.engines.some((engine) => engine.id === 'vl.native')) return defs;
270
- if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
271
- const draft = draftFrom(defs);
272
- draft.engines.push(defaultVlEngine());
273
- try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
370
+ function backfillVlEngine(defsPath, defs, log) {
371
+ if (!defs) return defs;
372
+ // Le condizioni si valutano su cio' che si legge DENTRO il lock, non
373
+ // sullo stato che avevamo in mano: e' la differenza fra decidere sul
374
+ // presente e decidere su una fotografia.
375
+ const esito = aggiornaDefinizioni(defsPath, (dentro) => {
376
+ if (dentro.engines.some((engine) => engine.managed?.client === 'vl')) return null;
377
+ if (dentro.engines.some((engine) => engine.id === 'vl.native')) return null;
378
+ if (dentro.engines.length >= CAPS.MAX_ENGINES) return null;
379
+ const draft = draftFrom(dentro);
380
+ draft.engines.push(defaultVlEngine());
381
+ return draft;
382
+ }, { log });
383
+ return esito || defs;
274
384
  }
275
385
 
276
386
  // Applica engine + modello + policy come un'unica transizione. Ogni engine ricorda
@@ -468,6 +578,16 @@ async function createBuiltinFleet(cfg = {}) {
468
578
  : off;
469
579
 
470
580
  if (!readonly()) {
581
+ // Audit 093, rilievo 3: lo snapshot della BASE si prende QUI, alla lettura
582
+ // che ha prodotto `boot` — non con una rilettura dopo la migrazione. Il
583
+ // vecchio `primaDelloScrivere` era una rilettura: una scrittura arrivata
584
+ // DURANTE migrateLegacyTmuxSessions (una catena di chiamate tmux: finestra
585
+ // larga) era già dentro la rilettura, il confronto passava e `boot`,
586
+ // costruito sullo stato pre-migrazione, cancellava il lavoro altrui. Con
587
+ // lo snapshot alla fonte, il confronto dentro il lock copre la finestra
588
+ // INTERA da questa lettura alla presa. `boot` e il futuro `dentro` sono
589
+ // entrambi normalizzati da loadDefinitions: il confronto resta coerente.
590
+ const baseAllaLettura = JSON.stringify(boot);
471
591
  // La migrazione precede QUALUNQUE backfill/scrittura: loadDefinitions normalizza
472
592
  // i nomi legacy solo in memoria. Se il rename e' ambiguo o fallisce, fleet.json
473
593
  // resta byte-invariato e la Fleet non puo creare una seconda sessione safe.
@@ -482,17 +602,36 @@ async function createBuiltinFleet(cfg = {}) {
482
602
  return blocked(`${detail} [${code}]`, code);
483
603
  }
484
604
  if (migration.needsPersistence) {
485
- try { boot = atomicWrite(defsPath, boot); }
486
- catch (_) {
605
+ // `boot` e' gia' stato mutato in memoria dalla migrazione: qui non si puo'
606
+ // ricostruire il draft dallo stato riletto, si puo' solo verificare che
607
+ // nessun altro abbia scritto nel frattempo — per TUTTA la finestra, da
608
+ // `baseAllaLettura` in poi (vedi il commento alla snapshot). Se qualcuno
609
+ // l'ha fatto si RINUNCIA: la migrazione e' idempotente e si riapplica al
610
+ // prossimo avvio, mentre sovrascrivere cancellerebbe il lavoro altrui.
611
+ let persistito = null;
612
+ try {
613
+ // `propaga` anche qui: senza, una rinuncia del lock tornava come stato
614
+ // valido e l'avvio proseguiva dichiarando implicitamente una migrazione
615
+ // che non era stata scritta.
616
+ persistito = aggiornaDefinizioni(defsPath, (dentro) => (
617
+ JSON.stringify(dentro) === baseAllaLettura ? boot : null
618
+ ), { propaga: true, log: cfg.log });
619
+ } catch (_) { persistito = null; }
620
+ if (!persistito) {
487
621
  return blocked('migrazione tmux completata ma fleet.json non e persistibile [TMUX_MIGRATION_PERSIST_FAILED]',
488
622
  'TMUX_MIGRATION_PERSIST_FAILED');
489
623
  }
624
+ boot = persistito;
490
625
  }
491
- boot = backfillShellEngine(defsPath, boot);
626
+ boot = backfillShellEngine(defsPath, boot, cfg.log);
492
627
  boot = backfillAgyEngine(defsPath, boot, cfg);
493
- boot = backfillKimiEngine(defsPath, boot);
628
+ boot = backfillKimiEngine(defsPath, boot, cfg.log);
494
629
  boot = backfillGrokEngine(defsPath, boot, cfg);
495
- boot = backfillVlEngine(defsPath, boot);
630
+ boot = backfillVlEngine(defsPath, boot, cfg.log);
631
+ // Ripara (non aggiunge) un desktop.local gia' presente col comando relativo.
632
+ // Il logger va PASSATO: senza, il messaggio di fallimento si ferma a un
633
+ // callback che nessuno fornisce — cioe' il silenzio che si voleva togliere.
634
+ boot = riparaDesktopEngine(defsPath, boot, cfg.log);
496
635
  }
497
636
 
498
637
  // Adopt or create the shared server before exposing a mutable Fleet. Reapply
@@ -555,14 +694,28 @@ async function createBuiltinFleet(cfg = {}) {
555
694
  // Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
556
695
  // invalido: backup predecessore + throw -> httpError(400) (mai garbage).
557
696
  async function mutate(defs, mutator) {
558
- const draft = draftFrom(defs);
559
- mutator(draft);
697
+ // Questo e' l'ALTRO scrittore: le mutazioni che arrivano dall'interfaccia.
698
+ // Un lock che valesse solo per l'avvio proteggerebbe il bootstrap da se'
699
+ // stesso e non da qui, cioe' da chi scrive davvero mentre il sistema gira.
700
+ //
701
+ // Il mutator lavora sullo stato riletto DENTRO il lock, non su quello che
702
+ // il chiamante aveva in mano: e' anche piu' corretto, perche' applica la
703
+ // modifica al presente invece che a una fotografia.
560
704
  let parsed;
561
705
  try {
562
- parsed = atomicWrite(defsPath, draft);
706
+ parsed = aggiornaDefinizioni(defsPath, (dentro) => {
707
+ const draft = draftFrom(dentro);
708
+ mutator(draft);
709
+ return draft;
710
+ }, { propaga: true });
563
711
  } catch (e) {
712
+ // Due fallimenti diversi, due risposte diverse: un input invalido e' 400
713
+ // e non cambiera' riprovando; un lock occupato e' 409 e riprovando puo'
714
+ // riuscire. Confonderli manda chi legge a correggere il dato sbagliato.
715
+ if (e && e.code === 'FLEET_LOCK_BUSY') throw httpError(409, 'definizioni fleet occupate: riprova');
564
716
  throw httpError(400, `definizioni non valide: ${e.message}`);
565
717
  }
718
+ if (!parsed) throw httpError(409, 'definizioni fleet non leggibili: riprova');
566
719
  commitDefs(parsed);
567
720
  return parsed;
568
721
  }
@@ -1256,6 +1409,7 @@ module.exports = {
1256
1409
  // Esportate ma NON chiamate qui sopra (§bootstrap, righe ~354-358): scelta
1257
1410
  // deliberata, non una dimenticanza — vedi il commento su backfillDesktopEngine.
1258
1411
  backfillDesktopEngine,
1412
+ riparaDesktopEngine,
1259
1413
  defaultDesktopEngine,
1260
1414
  resolveCellCwd,
1261
1415
  composeLaunchArgv,
@@ -635,6 +635,229 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
635
635
  return out;
636
636
  }
637
637
 
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // aggiornaDefinizioni(p, trasforma, opts) -> definizioni risultanti
641
+ //
642
+ // Leggi-modifica-scrivi SOTTO LOCK. `atomicWrite` garantisce che il file non
643
+ // resti a meta' — non che nessuno lo abbia cambiato mentre lo tenevi in mano:
644
+ // sono due proprieta' diverse, e la prima non implica la seconda. Un audit ha
645
+ // riprodotto la perdita su SETTE percorsi diversi (i backfill di avvio, la
646
+ // riparazione desktop e la persistenza della migrazione): ognuno salvava,
647
+ // osservava sul disco il valore scritto da un altro, e lo perdeva scrivendo un
648
+ // draft costruito su uno stato precedente.
649
+ //
650
+ // `trasforma(defs)` riceve lo stato letto DENTRO il lock e restituisce il draft
651
+ // da scrivere, oppure `null` per non scrivere nulla. Se il lock non si ottiene
652
+ // entro il tempo concesso si RINUNCIA: per una migrazione opportunistica non
653
+ // fare nulla costa un altro avvio, sovrascrivere costa un dato.
654
+ // ---------------------------------------------------------------------------
655
+ const LOCK_ATTESA_MS = 2000; // quanto si insiste prima di rinunciare
656
+ const LOCK_STALE_MS = 30000; // oltre questa eta' il lock e' di un morto
657
+
658
+ function percorsoLock(p) { return `${p}.lock`; }
659
+
660
+ // La NASCITA di un processo (starttime, /proc/<pid>/stat campo 22: tick di
661
+ // uptime a cui il processo e' partito). Un pid e' un numero RICICLATO: chiedere
662
+ // «esiste il processo 4711?» non e' la domanda «e' ancora vivo QUEL processo
663
+ // che prese il lock?» — se il proprietario muore e il sistema riassegna il
664
+ // numero, kill(pid, 0) risponde «vivo» per sempre e ogni scrittura rinuncia in
665
+ // silenzio. Due processi con lo stesso numero nascono in istanti diversi: la
666
+ // coppia pid+nascita e' l'identita'. Null quando non e' leggibile (pid assente,
667
+ // o sistema senza /proc: e' il modo in cui il codice vede macOS).
668
+ function leggiStarttimeProc(pid) {
669
+ try {
670
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
671
+ // il campo comm (2) puo' contenere spazi e parentesi: si salta tutto
672
+ // fino all'ultima ')', i campi seguenti partono dal 3°. Il campo 22
673
+ // (starttime) e' quindi l'indice 19 della coda.
674
+ const coda = stat.slice(stat.lastIndexOf(')') + 2);
675
+ const st = Number(coda.split(' ')[19]);
676
+ return Number.isFinite(st) ? st : null;
677
+ } catch (_) { return null; }
678
+ }
679
+
680
+ function prendiLock(p) {
681
+ const lock = percorsoLock(p);
682
+ // La directory puo' non esistere ancora: alla prima creazione delle
683
+ // definizioni non c'e' nulla. Prima era `atomicWrite` a crearla, e spostando
684
+ // la scrittura sotto lock quell'effetto si era perso — il lock non si apriva,
685
+ // si rinunciava, e la creazione falliva in silenzio.
686
+ try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch (_) { return null; }
687
+ const scadenza = Date.now() + LOCK_ATTESA_MS;
688
+ // Il token identifica QUESTA presa, non il processo: due prese successive
689
+ // dello stesso pid restano distinguibili, e al rilascio si puo' verificare di
690
+ // stare togliendo il proprio lock e non quello di chi e' subentrato.
691
+ // Il TERZO campo e' la nascita di chi prende: e' quello che rende
692
+ // confrontabile «e' ancora vivo QUEL processo» (pid da solo non basta: e'
693
+ // un numero riciclato). Senza /proc non c'e' nascita da attestare e il
694
+ // token resta a due campi — vedere proprietarioVivo per le conseguenze.
695
+ const nascita = leggiStarttimeProc(process.pid);
696
+ const token = nascita === null
697
+ ? `${process.pid}:${crypto.randomBytes(8).toString('hex')}`
698
+ : `${process.pid}:${crypto.randomBytes(8).toString('hex')}:${nascita}`;
699
+ for (;;) {
700
+ try {
701
+ // 'wx' fallisce se il file esiste: e' l'esclusione mutua, in una syscall.
702
+ const fd = fs.openSync(lock, 'wx', 0o600);
703
+ try {
704
+ fs.writeFileSync(fd, `${token}\n`);
705
+ } catch (_) {
706
+ // Il contenuto NON e' informativo: e' l'unica cosa che rende il lock
707
+ // attribuibile. Se non si puo' scrivere (disco pieno, quota, errore
708
+ // transitorio) il lock resterebbe VUOTO: nessun pid da interrogare, e
709
+ // dopo 30s chiunque lo esproprierebbe mentre il presunto titolare —
710
+ // vivo e al lavoro, convinto di essere protetto — scrive senza mutua
711
+ // esclusione. Nessuna titolarita' senza token: si chiude il fd e si
712
+ // toglie il file APPENA CREATO (e' nostro per costruzione: 'wx'), poi
713
+ // si rinuncia. Un lock vuoto sul disco puo' restare solo dal relitto
714
+ // di un crash fra open e write — processo che non esiste piu':
715
+ // recuperarlo dopo la scadenza e' giusto (semantica pinnata da
716
+ // fleet-lock-edges «illeggibile = abbandonato»).
717
+ try { fs.closeSync(fd); } catch (_) { /* gia' chiuso */ }
718
+ try { fs.unlinkSync(lock); } catch (_) { /* gia' rimosso */ }
719
+ return null;
720
+ }
721
+ return { fd, token };
722
+ } catch (e) {
723
+ if (e.code !== 'EEXIST') return null; // dir non scrivibile o simili: si rinuncia
724
+ // Un lock abbandonato non deve bloccare per sempre — ma l'eta' da sola non
725
+ // dice che il proprietario sia morto: un lavoro lento e' vivo e sta usando
726
+ // il lock. Espropriarlo per anzianita' ROMPE la mutua esclusione, cioe'
727
+ // riapre esattamente il difetto che il lock chiude. Si guarda prima se il
728
+ // processo esiste ancora.
729
+ try {
730
+ const eta = Date.now() - fs.statSync(lock).mtimeMs;
731
+ if (eta > LOCK_STALE_MS && !proprietarioVivo(lock)) { fs.unlinkSync(lock); continue; }
732
+ } catch (_) { /* sparito nel frattempo: si riprova */ }
733
+ if (Date.now() >= scadenza) return null;
734
+ if (!dormiSincrono(25)) return null; // non si sa attendere: meglio rinunciare che consumare CPU
735
+ }
736
+ }
737
+ }
738
+
739
+ // `kill(pid, 0)` non invia nulla: chiede al kernel se quel NUMERO esiste.
740
+ // EPERM significa che esiste e non e' nostro — vivo comunque.
741
+ function vivoPerKernel(pid) {
742
+ try { process.kill(pid, 0); return true; }
743
+ catch (e) { return e && e.code === 'EPERM'; }
744
+ }
745
+
746
+ // Il proprietario del lock e' vivo SOLO se e' ancora vivo QUEL processo.
747
+ // Tre vie, in ordine di forza:
748
+ // 1. token con nascita (pid:hex:starttime) e /proc leggibile: identita'
749
+ // CONFRONTABILE. vivo <=> il numero esiste ed e' nato nello stesso
750
+ // istante che il token attesta. Un numero riassegnato nasce in un
751
+ // istante diverso: il proprietario e' morto anche se il pid esiste.
752
+ // 2. token con nascita ma /proc non leggibile (neanche per QUESTO
753
+ // processo: e' come il codice vede un sistema senza /proc, es. macOS):
754
+ // criterio NON CALCOLABILE — nel dubbio il lock resta, decide il kernel.
755
+ // 3. token vecchio (pid:hex, nascita assente — lock scritti prima della
756
+ // correzione): identita' non confrontabile — nel dubbio il lock resta,
757
+ // decide il kernel. E' il buco dichiarato che resta per i lock gia'
758
+ // sul disco: costa una scrittura rimandata, mai un vivo espropriato.
759
+ // L'asimmetria e' la lezione della cura precedente: dichiarare morto un vivo
760
+ // ROMPE la mutua esclusione; dichiarare vivo un morto rimanda una scrittura.
761
+ // `lettore` e' iniettabile per provare la via 2 dove /proc esiste eccome.
762
+ function proprietarioVivo(lock, lettore = leggiStarttimeProc) {
763
+ let pid;
764
+ let nascita = null;
765
+ try {
766
+ const parti = String(fs.readFileSync(lock, 'utf8')).trim().split(':');
767
+ pid = Number.parseInt(parti[0], 10);
768
+ if (parti.length >= 3) {
769
+ const st = Number(parti[2]);
770
+ if (Number.isFinite(st)) nascita = st;
771
+ }
772
+ } catch (_) { return false; }
773
+ if (!Number.isInteger(pid) || pid <= 0) return false; // illeggibile: trattato come abbandonato
774
+ if (nascita !== null) {
775
+ // /proc leggibile per QUESTO processo? Se no, il criterio non e'
776
+ // calcolabile su questo sistema (non «il pid e' morto»: NON LO SO).
777
+ if (lettore(process.pid) === null) return vivoPerKernel(pid);
778
+ const sua = lettore(pid);
779
+ if (sua === null) return false; // /proc c'e' e quel pid non esiste: morto
780
+ return sua === nascita; // stesso numero, stessa nascita: e' ancora LUI
781
+ }
782
+ return vivoPerKernel(pid);
783
+ }
784
+
785
+ // Attesa sincrona senza spawnare nulla: questo percorso e' sincrono per
786
+ // costruzione (gira nel bootstrap), quindi non c'e' un event loop da cedere.
787
+ function dormiSincrono(ms) {
788
+ try {
789
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
790
+ return true;
791
+ } catch (_) {
792
+ // Senza SharedArrayBuffer non si puo' attendere senza girare a vuoto: il
793
+ // ciclo brucerebbe CPU per tutto il tempo concesso. Meglio dirlo al
794
+ // chiamante e rinunciare subito — l'aggiornamento e' opportunistico.
795
+ return false;
796
+ }
797
+ }
798
+
799
+ function rilasciaLock(p, presa) {
800
+ try { fs.closeSync(presa.fd); } catch (_) { /* gia' chiuso */ }
801
+ // Si toglie SOLO il proprio lock. Un unlink cieco, dopo che qualcun altro ha
802
+ // preso il lock, cancellerebbe il suo — e da li' in avanti nessuno sarebbe
803
+ // piu' protetto, a cascata.
804
+ try {
805
+ const dentro = String(fs.readFileSync(percorsoLock(p), 'utf8')).trim();
806
+ if (dentro !== presa.token) return; // subentrato qualcun altro: non e' roba nostra
807
+ } catch (_) { return; }
808
+ try { fs.unlinkSync(percorsoLock(p)); } catch (_) { /* gia' rimosso */ }
809
+ }
810
+
811
+ function aggiornaDefinizioni(p, trasforma, opts = {}) {
812
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
813
+ // Di default NON si propaga: questi aggiornamenti girano nel bootstrap, dove
814
+ // un'eccezione non degrada — impedisce l'avvio. Prima della conversione ogni
815
+ // backfill aveva il suo `try/catch` attorno alla scrittura, e quella rete va
816
+ // conservata. Chi invece serve una richiesta dell'utente passa
817
+ // `propaga: true`, perche' li' un errore va riportato a chi ha chiesto.
818
+ const propaga = opts.propaga === true;
819
+ const presa = prendiLock(p);
820
+ if (presa === null) {
821
+ log('definizioni fleet: lock non ottenuto, aggiornamento rimandato');
822
+ // Rinunciare NON e' come non aver avuto nulla da fare, e chi propaga deve
823
+ // poterlo distinguere: restituendo lo stato riletto — che e' truthy — una
824
+ // mutazione chiesta dall'utente rispondeva OK senza aver scritto niente.
825
+ // L'errore ha un `code` perche' il chiamante possa dire la cosa giusta
826
+ // invece di confonderlo con «definizioni non valide».
827
+ if (propaga) {
828
+ const e = new Error('definizioni fleet occupate: aggiornamento non eseguito');
829
+ e.code = 'FLEET_LOCK_BUSY';
830
+ throw e;
831
+ }
832
+ return loadDefinitions(p);
833
+ }
834
+ try {
835
+ // La lettura sta DENTRO il lock: e' l'unico modo perche' il draft nasca da
836
+ // uno stato che nessun altro puo' cambiare prima che venga scritto.
837
+ const dentro = loadDefinitions(p);
838
+ if (!dentro) {
839
+ // `loadDefinitions` restituisce null sia per «non c'e'» sia per «c'e' ma
840
+ // non si legge», e la differenza qui e' tutto: creare le definizioni di
841
+ // default sopra un file esistente ma illeggibile CANCELLA una
842
+ // configurazione. Si guarda il filesystem, non il valore di ritorno.
843
+ let assente = false;
844
+ try { fs.lstatSync(p); } catch (e) { assente = e.code === 'ENOENT'; }
845
+ if (!assente || typeof opts.seMancante !== 'function') return null;
846
+ const iniziale = opts.seMancante();
847
+ return iniziale ? atomicWrite(p, iniziale) : null;
848
+ }
849
+ const draft = trasforma(dentro);
850
+ if (!draft) return dentro; // niente da fare: si esce senza scrivere
851
+ return atomicWrite(p, draft);
852
+ } catch (e) {
853
+ if (propaga) throw e;
854
+ log(`definizioni fleet: aggiornamento non riuscito (${e && e.code ? e.code : e && e.message ? e.message : 'errore'})`);
855
+ return loadDefinitions(p);
856
+ } finally {
857
+ rilasciaLock(p, presa);
858
+ }
859
+ }
860
+
638
861
  // ---------------------------------------------------------------------------
639
862
  // validateCommandTrust(command) -> {ok, reason}
640
863
  // Path assoluto, regular file, owner-executable, NON symlink (lstat), NON
@@ -823,6 +1046,9 @@ const CAPS = {
823
1046
  module.exports = {
824
1047
  parseDefinitions,
825
1048
  validateCommandTrust,
1049
+ aggiornaDefinizioni,
1050
+ proprietarioVivo,
1051
+ leggiStarttimeProc,
826
1052
  validPanelUrl, PANELURL_LOOPBACK_HOSTS,
827
1053
  resolveCwd,
828
1054
  normalizeCwdRel,