@mmmbuto/nexuscrew 0.9.4 → 0.9.5

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.
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
  // Pidfile con verified kill: metadata {pid, cmd, startTs}; kill verifica cmd+pid
3
- // prima di signalare (no PID reuse, no broad match by name). [R1]
3
+ // prima di signalare (no broad match by name). Il controllo pid+nascita riduce
4
+ // il PID reuse a una finestra residua fra verifica e segnale — non lo elimina;
5
+ // dettaglio e motivo nel commento sopra il kill in killPidfile. [R1]
4
6
  // Primario su Termux (serve --pidfile); opzionale --manual su linux/mac.
5
7
  const fs = require('node:fs');
6
8
  const os = require('node:os');
@@ -26,33 +28,265 @@ function currentUid() {
26
28
  // `/proc/<pid>/stat` field 22 is the kernel start tick. Unlike a PID or an
27
29
  // argv it cannot be recreated by a later process. macOS has no /proc, so a
28
30
  // conservative `ps lstart` fallback still combines with UID, argv and runId.
29
- function readProcessStart(pid) {
31
+ //
32
+ // probeProcessStart espone anche la CAUSA del fallimento (non solo il suo
33
+ // esito): serve a writePidfile per distinguere, alla creazione, «questa
34
+ // piattaforma non sa attestare le nascite» (fatto stabile) da «il tentativo
35
+ // e' fallito su QUESTO pid per un motivo che non sappiamo escludere
36
+ // transitorio» (non un fatto sulla piattaforma). Guardando solo se il
37
+ // risultato e' null le due cose sono indistinguibili — ed e' esattamente
38
+ // l'errore di categoria che questa funzione evita: unsupported si dichiara
39
+ // SOLO quando ENTRAMBI i meccanismi (proc e ps) sono strutturalmente
40
+ // impossibili qui — /proc assente o negato con EACCES/EPERM (una policy di
41
+ // visibilita' di sistema, non un fatto su un pid), E il binario `ps` stesso
42
+ // non si trova (ENOENT sullo spawn, non un suo rifiuto degli argomenti, che
43
+ // potrebbe voler dire tante cose diverse e non le distinguiamo in modo
44
+ // affidabile). Ogni altro fallimento resta indeterminate: onesto, non
45
+ // generoso.
46
+ function probeProcessStart(pid) {
47
+ let procStructurallyBlocked = false;
30
48
  try {
31
49
  const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim();
32
50
  const match = raw.match(/^\d+\s+\([^)]*\)\s+(.+)$/);
33
51
  const fields = match && match[1].trim().split(/\s+/);
34
52
  const ticks = fields && fields[19]; // field 22, after state=field 3
35
- if (/^\d+$/.test(String(ticks || ''))) return `linux:${ticks}`;
36
- } catch (_) {}
53
+ if (/^\d+$/.test(String(ticks || ''))) return { value: `linux:${ticks}`, cause: null };
54
+ } catch (e) {
55
+ const denied = e && (e.code === 'EACCES' || e.code === 'EPERM');
56
+ const procMissing = e && e.code === 'ENOENT' && !fs.existsSync('/proc');
57
+ procStructurallyBlocked = Boolean(denied || procMissing);
58
+ }
59
+ let psMissing = false;
37
60
  try {
38
61
  const text = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' }).trim();
39
- return text ? `ps:${text}` : null;
40
- } catch (_) { return null; }
62
+ if (text) return { value: `ps:${text}`, cause: null };
63
+ } catch (e) {
64
+ psMissing = Boolean(e && e.code === 'ENOENT');
65
+ }
66
+ const unsupported = procStructurallyBlocked && psMissing;
67
+ return { value: null, cause: unsupported ? 'unsupported' : 'indeterminate' };
68
+ }
69
+
70
+ function readProcessStart(pid) {
71
+ return probeProcessStart(pid).value;
72
+ }
73
+
74
+ // Campi che SOLO writePidfile puo' scrivere: mai dal chiamante via `extra`.
75
+ // Misurato (Dev, 2026-08-17): con lo spread di extra per ultimo,
76
+ // writePidfile(f, pid, cmd, {processStart:'FINTO', attestation:'unsupported'})
77
+ // vinceva sul valore vero calcolato da probeProcessStart — un chiamante
78
+ // poteva scrivere un'attestazione INVENTATA. safeExtra la filtra qui, e i
79
+ // campi veri sono comunque scritti DOPO nello spread finale: due barriere,
80
+ // non una, perche' un bug nel filtro da solo basterebbe a riaprire il buco.
81
+ const RESERVED_META_FIELDS = new Set(['pid', 'cmd', 'startTs', 'uid', 'processStart', 'attestation']);
82
+
83
+ // Il marker di schema vive ACCANTO al pidfile (stessa directory, es.
84
+ // ~/.nexuscrew/), MAI dentro di esso: il pidfile e' il file sospetto, non e'
85
+ // lui a poter certificare se stesso. La sua presenza e' un evento ONE-WAY per
86
+ // installazione: "codice v2 ha scritto qui almeno una volta", non
87
+ // "l'attestazione e' riuscita" — writePidfile lo crea al primo successo,
88
+ // qualunque sia l'esito della sonda su quello specifico pid (se lo legassi
89
+ // al successo dell'attestazione, su una piattaforma davvero unsupported il
90
+ // marker non arriverebbe MAI e il bypass resterebbe aperto per sempre —
91
+ // proprio dove serve di piu' che si chiuda). Da quel momento in poi
92
+ // (killPidfile, sotto) un pidfile senza nessun campo di attestazione smette
93
+ // di essere spiegabile come "legittimo, pre-migrazione": diventa sospetto
94
+ // (downgrade, restore da backup, corruzione) e si rifiuta.
95
+ //
96
+ // TRE CONFINI DICHIARATI, non dedotti — chi indaga un incidente fra sei mesi
97
+ // deve trovarli qui, non scoprirli:
98
+ // 1. Un restore che riporta indietro l'INTERA directory (~/.nexuscrew/)
99
+ // riporta indietro anche il marker: la finestra di bypass si RIAPRE. Non
100
+ // e' un difetto di questa scelta — non esiste un posto migliore: accanto
101
+ // al binario e' read-only, e l'update lo sovrascrive comunque.
102
+ // 2. Con NEXUSCREW_PIDFILE puntato a una directory nuova il marker non c'e',
103
+ // quindi il bypass e' concesso li'. Nei test e' voluto (isolamento senza
104
+ // un parametro home in piu' da cablare); in produzione vuol dire che chi
105
+ // controlla quella variabile d'ambiente controlla anche la finestra — non
106
+ // un confine di sicurezza reale (chi setta l'ambiente del processo ha gia'
107
+ // vinto), ma va detto: il marker non e' una garanzia piu' forte di questa.
108
+ // 3. R-pidfile-5: claimSchemaMarker (sotto) crea il marker al KILL, non solo
109
+ // al write — quindi un pidfile che sta per essere RIMOSSO puo' lasciare
110
+ // dietro un marker in una directory che magari non ospitera' mai piu' un
111
+ // runtime. E' INNOCUO: il marker non ha altro effetto che rendere PIU'
112
+ // caute le decisioni FUTURE su quella stessa directory (fail-closed per
113
+ // meta senza attestazione, come se la migrazione fosse gia' avvenuta li'
114
+ // — che e' vero: e' avvenuta, anche se il pidfile che l'ha innescata non
115
+ // c'e' piu'). Il verso del residuo e' sempre quello sicuro: mai una
116
+ // concessione in piu', al massimo un rifiuto in un caso limite di riuso
117
+ // di un percorso abbandonato — coerente col confine 1 sopra.
118
+ function schemaMarkerPath(pidfilePath) {
119
+ return path.join(path.dirname(pidfilePath), '.pidfile-schema-v2');
120
+ }
121
+
122
+ // R-pidfile-4 (2026-08-17, audit su develop@437d29f): misurato dall'auditor
123
+ // sul frozen — se .pidfile-schema-v2 esiste come DIRECTORY (o symlink, o
124
+ // qualunque cosa non sia un file regolare), il vecchio hasSchemaMarker
125
+ // tornava `false` per costruzione (isFile() falso), e ensureSchemaMarker
126
+ // ingoiava OGNI errore della creazione (EEXIST incluso) senza distinguere.
127
+ // Risultato: il marker non nasceva MAI, ogni writePidfile successiva
128
+ // ripeteva lo stesso fallimento silenzioso, e "nessun marker" veniva letto
129
+ // come "pre-migrazione" — concedendo ambiguous-compat PER SEMPRE, non un
130
+ // giro in piu'. L'errore di fondo: la scrittura e' best-effort per buoni
131
+ // motivi (non deve mai far fallire writePidfile), ma un fallimento della
132
+ // scrittura si traduceva in "stato assente", cioe' nella risposta
133
+ // PERMISSIVA. Un meccanismo che non riesce a determinare il proprio stato
134
+ // non deve concedere: deve chiudere. L'incertezza va nella direzione
135
+ // sicura, sempre — lo stesso principio di isAlive/isAttributable in questo
136
+ // file, applicato qui al marker invece che al pid.
137
+ //
138
+ // La decisione al KILL non LEGGE se il marker esiste: PROVA A CREARLO adesso
139
+ // (claimSchemaMarker). Leggerlo soltanto lasciava fuori il caso in cui la
140
+ // scrittura era fallita senza lasciare traccia sul percorso — disco pieno,
141
+ // sola lettura, permesso transitorio: il percorso resta libero, la lettura
142
+ // dice 'assente', e si concedeva per sempre. Una prova fatta ORA ha una
143
+ // risposta verificabile; un evento passato che nessuno ha osservato no.
144
+ // 'created' — non c'era e la creazione riesce adesso: pre-migrazione
145
+ // vera -> ambiguous-compat concesso UNA volta, e da qui
146
+ // in poi il marker esiste per davvero.
147
+ // 'present' — EEXIST su file regolare (mai symlink: lstat, non stat)
148
+ // -> post-migrazione, fail-closed.
149
+ // 'undeterminable' — EEXIST su un tipo non atteso, oppure la creazione
150
+ // fallisce ADESSO per qualunque motivo (ostacolo,
151
+ // ENOSPC, EROFS, permessi, race) -> fail-closed, con il
152
+ // motivo che nomina la causa. Il fallimento della
153
+ // scrittura non e' un effetto collaterale da temere: e'
154
+ // il segnale che produce la risposta sicura.
155
+ // `checkSchemaMarker` resta come lettura pura per diagnostica e test, ma non
156
+ // guida piu' nessuna decisione di sicurezza.
157
+ //
158
+ // COSTO DICHIARATO: su un'installazione nuova con un ostacolo gia' presente
159
+ // su quel percorso, la PRIMA fermata dopo l'aggiornamento fallisce invece di
160
+ // essere concessa (nessun modo di distinguere "ostacolo innocente" da
161
+ // "ostacolo messo li' apposta" — e non e' compito di questa funzione
162
+ // indovinarlo). E' il verso giusto: un errore che dice cosa succede e' meglio
163
+ // di un bypass che non lo dice — ma va scritto qui, non scoperto da chi
164
+ // indaga un incidente.
165
+ function checkSchemaMarker(pidfilePath) {
166
+ const markerPath = schemaMarkerPath(pidfilePath);
167
+ let st;
168
+ try {
169
+ st = fs.lstatSync(markerPath);
170
+ } catch (e) {
171
+ if (e && e.code === 'ENOENT') return { state: 'absent' };
172
+ return { state: 'undeterminable', reason: `marker path not readable (${(e && e.code) || 'unknown error'})` };
173
+ }
174
+ if (st.isSymbolicLink()) return { state: 'undeterminable', reason: 'marker path is a symlink, not a regular file' };
175
+ if (!st.isFile()) return { state: 'undeterminable', reason: 'marker path occupied by a non-regular-file' };
176
+ return { state: 'present' };
177
+ }
178
+
179
+ // Wrapper booleano SOLO per lettura passiva (test, diagnostica esterna) —
180
+ // non guida piu' la decisione di sicurezza: vedi claimSchemaMarker sotto,
181
+ // che e' cosa killPidfile usa davvero. SOLO 'present' e' true.
182
+ // 'undeterminable' NON e' 'absent' — tornerebbe a leggere l'incertezza come
183
+ // permissiva, l'errore del giro precedente.
184
+ function hasSchemaMarker(pidfilePath) {
185
+ return checkSchemaMarker(pidfilePath).state === 'present';
186
+ }
187
+
188
+ // Best-effort per costruzione: EEXIST (un altro processo ha appena scritto
189
+ // lo stesso marker, avvii concorrenti allo start) e' il caso NORMALE di un
190
+ // evento one-way, non un errore. Qualunque ALTRO fallimento (un ostacolo al
191
+ // path, permessi, disco pieno) non deve MAI far fallire writePidfile — il
192
+ // pidfile e' gia' stato scritto quando arriviamo qui, ed e' lui
193
+ // l'informazione che conta.
194
+ //
195
+ // R-pidfile-5 (2026-08-17, audit su develop@fa8bd90): questa funzione NON
196
+ // garantisce piu' nulla sul futuro — prima il commento qui diceva "il
197
+ // prossimo killPidfile lo scoprira' da solo", ed era FALSO: se questa
198
+ // scrittura fallisce con ENOSPC/EROFS/quota/permesso transitorio, non resta
199
+ // NESSUNA traccia sul filesystem (il path resta ENOENT, esattamente come se
200
+ // non si fosse mai tentato) — una garanzia dichiarata che il codice non
201
+ // aveva. La chiusura vera non dipende da questa funzione: killPidfile (vedi
202
+ // claimSchemaMarker) non si fida di un evento passato non osservabile,
203
+ // riprova la creazione ADESSO, nell'istante in cui la decisione conta. Se
204
+ // funziona ancora qui (il caso comune: nessun ostacolo, disco con spazio) e'
205
+ // solo un'ottimizzazione — anticipa la migrazione al primo write invece di
206
+ // aspettare il primo kill su un meta ambiguo — non la fonte della garanzia.
207
+ function ensureSchemaMarker(pidfilePath) {
208
+ try {
209
+ fs.writeFileSync(schemaMarkerPath(pidfilePath), `${JSON.stringify({ since: Date.now() })}\n`,
210
+ { flag: 'wx', mode: 0o600 });
211
+ } catch (e) {
212
+ if (e && e.code === 'EEXIST') return; // normale: gia' scritto da un altro avvio
213
+ // Qualunque altro fallimento: mai propagare. Non promettiamo che qualcun
214
+ // altro se ne accorgera' — vedi claimSchemaMarker per la vera garanzia.
215
+ }
216
+ }
217
+
218
+ // R-pidfile-5: LA decisione di sicurezza. checkSchemaMarker (sopra) guarda
219
+ // un EVENTO PASSATO — "il marker e' stato scritto" — che puo' fallire in
220
+ // silenzio senza lasciare traccia (vedi ensureSchemaMarker): il percorso
221
+ // resta ENOENT, indistinguibile da "mai tentato". claimSchemaMarker sposta
222
+ // la domanda da "e' successo in passato?" a "posso ADESSO?" — verificabile
223
+ // nell'istante in cui la decisione conta, non dedotta da una scrittura che
224
+ // potrebbe essere fallita senza lasciare traccia:
225
+ // 'created' — non c'era, la creazione ORA riesce: il percorso era
226
+ // davvero libero, non solo "sembrava" (perche' una
227
+ // scrittura precedente era fallita in silenzio).
228
+ // Pre-migrazione per davvero — e ORA il marker esiste
229
+ // per davvero: i prossimi kill lo vedranno 'present'.
230
+ // 'present' — EEXIST su un file regolare: gia' migrato (invariato).
231
+ // 'undeterminable' — EEXIST su un simlink/non-file, O la creazione
232
+ // fallisce ADESSO per qualunque altro motivo (ENOSPC,
233
+ // EROFS, EACCES/EPERM, EISDIR, una race): non sappiamo
234
+ // perche' in generale, ma sappiamo che ORA non si puo'
235
+ // scrivere qui — ed e' esattamente la domanda che
236
+ // conta, verificata nel momento giusto invece che
237
+ // dedotta da un momento passato che potrebbe mentire.
238
+ //
239
+ // killPidfile diventa, in questo solo ramo, una funzione che SCRIVE (crea un
240
+ // file nuovo), non solo rimuove: valutato deliberatamente, non per svista.
241
+ // Ogni chiamante che puo' leggere un pidfile in questa directory ha gia',
242
+ // nella prassi di questo prodotto, permesso di scrittura sulla STESSA
243
+ // directory (e' li' che pidfile.js scrive di suo — ~/.nexuscrew/ o
244
+ // l'override NEXUSCREW_PIDFILE): non attraversa un confine di permessi che
245
+ // non fosse gia' presupposto per arrivare a chiamare killPidfile affatto.
246
+ // Se la scrittura genuinamente non e' permessa o e' pericolosa li' (EROFS,
247
+ // policy), il ramo 'undeterminable' rifiuta — la scrittura fallita e'
248
+ // proprio il segnale che produce la risposta sicura, non un effetto
249
+ // collaterale da temere.
250
+ function claimSchemaMarker(pidfilePath) {
251
+ const markerPath = schemaMarkerPath(pidfilePath);
252
+ try {
253
+ fs.writeFileSync(markerPath, `${JSON.stringify({ since: Date.now() })}\n`, { flag: 'wx', mode: 0o600 });
254
+ return { state: 'created' };
255
+ } catch (e) {
256
+ if (e && e.code === 'EEXIST') {
257
+ let st;
258
+ try { st = fs.lstatSync(markerPath); }
259
+ catch (e2) { return { state: 'undeterminable', reason: `marker path not readable after EEXIST (${(e2 && e2.code) || 'unknown error'})` }; }
260
+ if (st.isSymbolicLink()) return { state: 'undeterminable', reason: 'marker path is a symlink, not a regular file' };
261
+ if (!st.isFile()) return { state: 'undeterminable', reason: 'marker path occupied by a non-regular-file' };
262
+ return { state: 'present' };
263
+ }
264
+ return { state: 'undeterminable', reason: `marker not creatable now (${(e && e.code) || 'unknown error'})` };
265
+ }
41
266
  }
42
267
 
43
268
  // Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
269
+ // La classificazione (attested/unsupported/indeterminate) si decide QUI, alla
270
+ // creazione, non al kill: e' un fatto su questo tentativo di attestazione,
271
+ // non qualcosa da ricalcolare piu' tardi con lo stesso rischio di confondere
272
+ // "non so attestare" con "non ci sono riuscito adesso" (vedi killPidfile).
44
273
  function writePidfile(p, pid, cmd, extra = {}) {
45
274
  fs.mkdirSync(path.dirname(p), { recursive: true });
46
- const safeExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
47
- const processStart = readProcessStart(pid);
275
+ const rawExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
276
+ const safeExtra = {};
277
+ for (const key of Object.keys(rawExtra)) {
278
+ if (!RESERVED_META_FIELDS.has(key)) safeExtra[key] = rawExtra[key];
279
+ }
280
+ const { value: processStart, cause } = probeProcessStart(pid);
48
281
  const uid = currentUid();
49
282
  const meta = JSON.stringify({
283
+ ...safeExtra,
50
284
  pid, cmd: cmd || '', startTs: Date.now(),
51
285
  ...(uid === null ? {} : { uid }),
52
- ...(processStart ? { processStart } : {}),
53
- ...safeExtra,
286
+ ...(processStart ? { processStart } : { attestation: cause }),
54
287
  });
55
288
  fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
289
+ ensureSchemaMarker(p);
56
290
  }
57
291
 
58
292
  // La rimozione NON e' un unlink nudo. Il pidfile e' la prova che un processo
@@ -186,24 +420,162 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
186
420
  }
187
421
  }
188
422
  // PID reuse che il cmd NON vede: due processi con lo stesso comando (o un
189
- // comando che lo contiene). La nascita li distingue: se il numero è nato in
190
- // un istante diverso da quello attestato, il segnale andrebbe al processo
191
- // SBAGLIATO — vivo, nostro, magari legittimo. Non si segnala; il pidfile è
192
- // stale e si toglie (allowLive: garanzia verificata QUI, come dopo il kill).
423
+ // comando che lo contiene). La nascita li distingue ma solo AL MOMENTO DI
424
+ // QUESTO CONTROLLO. Se la nascita letta ORA non corrisponde a quella
425
+ // attestata, il proprietario è già morto e il numero è di un altro: qui la
426
+ // garanzia regge, il pidfile è stale e si toglie senza segnalare.
427
+ //
428
+ // Quello che questa verifica NON copre: fra la lettura di liveStart qui e
429
+ // killImpl() poco sotto non c'è atomicità. Se il proprietario muore in
430
+ // quell'istante e il kernel riassegna il pid prima del kill, il segnale
431
+ // parte verso il sostituto — identità confermata un momento fa, non nel
432
+ // momento in cui conta. Questa finestra è intrinseca al modello pid+segnale
433
+ // di POSIX (kill(2) non lega il segnale all'identità appena controllata) e
434
+ // non si chiude qui: la verifica sopra riduce l'esposizione da "sempre" a
435
+ // "l'istante fra questo controllo e il kill", non la elimina. La cura vera
436
+ // è pidfd_open + pidfd_send_signal (il segnale è legato a un file
437
+ // descriptor, non a un numero riassegnabile); Node non la espone senza un
438
+ // addon nativo, e un addon per un prodotto che gira anche su Termux e
439
+ // macOS costa più di quanto renda. Una rilettura dopo il kill non chiude
440
+ // la finestra — è best-effort, non rilevazione certa: il riusato può
441
+ // essere già uscito, e la rilettura corre a sua volta la stessa corsa. Non
442
+ // aggiungerne una come se fosse una guardia.
443
+ //
444
+ // Prima di questo controllo (nascita, aggiunta dopo cmd) il segnale
445
+ // partiva sul solo cmd, senza verifica di identità, per QUALUNQUE pidfile
446
+ // senza processStart — vecchio o nuovo, piattaforma capace o no: era
447
+ // esattamente il buco piu' largo trovato dall'audit (2026-08-17), perche'
448
+ // "nessuna nascita nel meta" copriva sia "questa macchina non sa attestare"
449
+ // sia "l'attestazione e' fallita alla creazione per un motivo che non
450
+ // conosciamo" con lo STESSO comportamento permissivo. Il ramo sotto separa
451
+ // i due casi guardando cosa writePidfile ha DICHIARATO al momento in cui
452
+ // contava (probeProcessStart, sopra), non ricalcolando una sonda adesso —
453
+ // rifarla qui confonderebbe di nuovo "non so attestare" con "non ci sono
454
+ // riuscito in QUESTO istante", l'errore di categoria che l'audit ha
455
+ // contestato nel primo disegno di questo fix.
456
+ let unverifiedBirth = false;
457
+ let schemaMarker = null; // calcolato pigro: solo se serve davvero (vedi sotto)
193
458
  if (meta.processStart) {
194
459
  const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
195
460
  if (liveStart && liveStart !== meta.processStart) {
196
461
  removePidfile(p, { allowLive: true });
197
462
  return { killed: false, reason: 'pid reuse (start mismatch)' };
198
463
  }
464
+ // Nascita ATTESTATA ma non rileggibile ora (/proc nascosto, ps assente,
465
+ // permessi): l'identita' non e' verificabile, e questa e' un'operazione
466
+ // DISTRUTTIVA. Il cmd da solo non discrimina — matcha per inclusioni, e
467
+ // due processi con lo stesso comando si somigliano. Si rinuncia: un
468
+ // segnale mancato costa un pidfile stale, un segnale sbagliato uccide il
469
+ // processo di qualcun altro. Il pidfile NON si tocca: non sappiamo se sia
470
+ // stale.
471
+ if (!liveStart) {
472
+ return { killed: false, reason: 'start unverifiable (refusing to signal)' };
473
+ }
474
+ } else if (meta.attestation === 'unsupported') {
475
+ // DICHIARATO alla creazione: ne' /proc ne' `ps` esistevano affatto su
476
+ // questa macchina (probeProcessStart, causa strutturale — non un pid
477
+ // specifico che non rispondeva). Nessun pidfile su questa macchina potra'
478
+ // MAI avere una nascita: qui e' dove un fail-closed spegnerebbe il
479
+ // prodotto proprio dove serve (Termux, primario — vedi testa del file).
480
+ // Si ricade sul criterio storico pid+cmd, ma DICHIARATO nel risultato
481
+ // (unverifiedBirth sotto), non in silenzio.
482
+ //
483
+ // ONESTO NON VUOL DIRE SICURO: su una piattaforma davvero unsupported la
484
+ // proprieta' "mai segnalare un PID solo ereditato" NON E' GARANTITA — un
485
+ // pid morto qui e riassegnato a un processo nostro con cmd compatibile
486
+ // riceve comunque il segnale, perche' senza nascita non c'e' modo di
487
+ // vedere il riassegno. Dichiarare il degrado lo rende TRACCIABILE, non
488
+ // lo chiude. Se un giorno serve la garanzia anche qui, la via non e' un
489
+ // altro probe su PID/cmd (la stessa prova debole con un altro nome): serve
490
+ // una prova ALTERNATIVA del proprietario — per esempio un runId verificato
491
+ // contro un endpoint locale autenticato. Non e' implementata: e' un lavoro
492
+ // a se', con un suo giro di audit (registrato qui, non eseguito).
493
+ unverifiedBirth = 'unsupported';
494
+ } else if (meta.attestation === 'indeterminate') {
495
+ // Il codice NUOVO (questo, o una versione successiva a questo fix) ha
496
+ // PROVATO ad attestare alla creazione e non ci e' riuscito per un motivo
497
+ // che non sa classificare come proprieta' della piattaforma (vedi
498
+ // probeProcessStart sopra) — sa che avrebbe potuto, su questo pid,
499
+ // adesso, e non ce l'ha fatta. Trattarlo come "la piattaforma non sa
500
+ // attestare" sarebbe l'errore di categoria contestato dall'audit:
501
+ // un'assenza non e' una proprieta' stabile solo perche' e' comoda da
502
+ // leggere cosi'. Si rifiuta: il cmd da solo matcha per inclusioni, e non
503
+ // basta a chi non sa nemmeno perche' la nascita manca QUI. Il pidfile
504
+ // NON si tocca, per lo stesso motivo del ramo attestato-ma-illeggibile
505
+ // sopra: non sappiamo se sia stale.
506
+ return { killed: false, reason: 'unattested pidfile (attestation indeterminate: a fresh attempt failed): refusing to signal a pid identified only by cmd' };
507
+ } else {
508
+ // NE' processStart NE' attestation nel meta: non "il codice nuovo ha
509
+ // provato e fallito" (quello e' il ramo indeterminate sopra) — e' un
510
+ // pidfile scritto da un codice che l'attestazione non la conosceva
511
+ // affatto, O un file scritto fuori da writePidfile del tutto (la stessa
512
+ // forma di meta la puo' produrre anche un chiamante non nostro): "nessun
513
+ // campo" da solo non prova la provenienza.
514
+ //
515
+ // R-pidfile-5: la domanda che decide non e' piu' "il marker e' gia'
516
+ // stato scritto?" (un evento passato che puo' fallire in silenzio —
517
+ // vedi ensureSchemaMarker/claimSchemaMarker sopra) ma "posso scriverlo
518
+ // ADESSO?" — claimSchemaMarker prova a crearlo in questo esatto istante:
519
+ // 'created' — non c'era davvero: QUESTA installazione non ha
520
+ // ancora mai completato una scrittura v2. Pre-
521
+ // migrazione per davvero, non solo "sembrava" —
522
+ // compatibilita' ambigua concessa UNA volta, e ORA
523
+ // il marker esiste per davvero: il prossimo kill
524
+ // su questa directory lo vedra' 'present'.
525
+ // 'present' — codice v2 ha gia' scritto qui (o un kill
526
+ // precedente l'ha appena creato): non e' piu'
527
+ // spiegabile come pre-migrazione — sospetto
528
+ // (downgrade, restore parziale del solo pidfile,
529
+ // corruzione). Si rifiuta.
530
+ // 'undeterminable' — la creazione fallisce ADESSO (ostacolo strutturale
531
+ // — directory, symlink — o ENOSPC/EROFS/permesso
532
+ // transitorio/una race): non sappiamo perche' in
533
+ // generale, ma sappiamo che ORA non si puo'
534
+ // scrivere qui, verificato nell'istante in cui
535
+ // conta, non dedotto da uno passato. Si rifiuta.
536
+ //
537
+ // MISURATO (Dev, 2026-08-17): trattare OGNI meta senza attestazione come
538
+ // indeterminate rompe l'aggiornamento automatico per ogni nodo il cui
539
+ // runtime e' ancora precedente a quando processStart e' nato (8fe514f,
540
+ // v0.9.0) — npm install sovrascrive pidfile.js PRIMA che il runner lo
541
+ // richieda, il runner legge il pidfile VECCHIO del runtime in esecuzione,
542
+ // killPidfile rifiuta, stopPortableRuntime torna killed:false,
543
+ // restartRuntime lancia, l'update muore (lib/update/runner.js). Il nodo
544
+ // resta indietro finche' qualcuno non interviene a mano — su un telefono
545
+ // Termux spesso vuol dire mai. Il rischio del ramo pid+cmd (concesso solo
546
+ // su 'created') e' quello che il prodotto ha GIA' in produzione oggi, ne'
547
+ // piu' ne' meno: non lo peggioriamo. Il PID reuse che questo ramo non
548
+ // vede richiede una coincidenza (numero riassegnato E cmd compatibile);
549
+ // il blocco dell'aggiornamento sarebbe invece CERTO per ogni nodo in
550
+ // questo stato, ogni volta. Fra un rischio raro gia' presente e un
551
+ // guasto sicuro introdotto da noi, si sceglie di non introdurre il
552
+ // guasto — decisione di prodotto, non mia.
553
+ //
554
+ // Alternative valutate e scartate: far degradare il runner invece di
555
+ // morire (il difetto gia' visto sul campo che l'ingresso unico di
556
+ // riavvio doveva chiudere); un ramo "legacy" permanente basato sulla sola
557
+ // FORMA del meta (lasciava aperti per sempre restore da backup,
558
+ // corruzione e downgrade — l'auditor l'ha contestato); un marker
559
+ // controllato ma mai riprovato al kill (questo giro — R-pidfile-5:
560
+ // l'auditor ha dimostrato che una scrittura fallita in silenzio riapre
561
+ // lo stesso buco senza lasciare traccia).
562
+ schemaMarker = (impl.claimSchemaMarkerImpl || claimSchemaMarker)(p);
563
+ if (schemaMarker.state === 'created') {
564
+ unverifiedBirth = 'ambiguous-compat';
565
+ } else {
566
+ const causa = schemaMarker.state === 'present' ? 'schema migration' : `marker state undeterminable: ${schemaMarker.reason}`;
567
+ return { killed: false, reason: `unattested pidfile after ${causa}: refusing to signal a pid identified only by cmd` };
568
+ }
199
569
  }
200
570
  try {
571
+ // Qui si chiude la verifica e si entra nella finestra residua dichiarata
572
+ // sopra: nessuna riverifica fra questo punto e il segnale, per scelta.
201
573
  killImpl(meta.pid, signal);
202
574
  // allowLive: il segnale e' partito verso il pid VERIFICATO del file (cmd
203
575
  // matchato sopra): la rimozione e' giusta anche se il processo non e' ancora
204
576
  // sparito da /proc quando unlink gira.
205
577
  removePidfile(p, { allowLive: true });
206
- return { killed: true, pid: meta.pid };
578
+ return { killed: true, pid: meta.pid, ...(unverifiedBirth ? { unverifiedBirth } : {}) };
207
579
  } catch (e) {
208
580
  return { killed: false, reason: e.message };
209
581
  }
@@ -211,6 +583,7 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
211
583
 
212
584
  module.exports = {
213
585
  defaultPidfilePath, readPidfile, writePidfile, removePidfile,
214
- currentUid, readProcessStart, pidOwnership, pidExists, readCmdline,
586
+ currentUid, readProcessStart, probeProcessStart, pidOwnership, pidExists, readCmdline,
215
587
  isAlive, isAttributable, cleanStale, killPidfile,
588
+ schemaMarkerPath, hasSchemaMarker, checkSchemaMarker, claimSchemaMarker,
216
589
  };
@@ -208,8 +208,19 @@ function waitForPidExit(pid, timeoutMs = 2000) {
208
208
 
209
209
  function stopPortableRuntime(opts = {}) {
210
210
  const home = opts.home || require('node:os').homedir();
211
+ const log = opts.log || console.log;
211
212
  const pidPath = pidf.defaultPidfilePath(home);
212
213
  const result = pidf.killPidfile(pidPath);
214
+ // killPidfile puo' fermarsi su un'identita' non completamente verificata
215
+ // (unverifiedBirth: 'unsupported' | 'ambiguous-compat') e tornare comunque
216
+ // killed:true — il valore di ritorno da solo non basta: runner.js guarda
217
+ // solo `.killed` quando e' true e ignora il resto, quindi senza questa riga
218
+ // nessun operatore vedrebbe MAI che uno stop e' passato per il ramo
219
+ // permissivo. Qui, non nel chiamante: e' l'unico posto attraversato sia dal
220
+ // percorso manuale (commands.js stop/restart) sia dall'update automatico.
221
+ if (result.killed && result.unverifiedBirth) {
222
+ log(`stop portatile: pid ${result.pid} fermato con identita' NON completamente verificata (${result.unverifiedBirth}) — confermata solo per cmd, non per nascita`);
223
+ }
213
224
  if (result.killed && !waitForPidExit(result.pid, opts.stopWaitMs || 2000)) {
214
225
  return { ...result, killed: false, reason: `pid ${result.pid} did not exit after SIGTERM` };
215
226
  }
@@ -204,8 +204,14 @@ function nodesAdd(opts) {
204
204
 
205
205
  log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
206
206
  log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
207
- // permitopen vincola i -L alla SOLA porta nexus remota; command=/bin/false + restrict.
208
- if (pub) log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
207
+ // R19: permitopen con le destinazioni EXPLICITE dei -L. Qui si conosce solo
208
+ // la porta nexus: la porta pannello del peer la annuncia il JOIN, non
209
+ // esiste ancora — quando apparirà, la riga completa (due destinazioni) la
210
+ // emette il pairing. Mai un permesso generico: è ciò che impedisce a questa
211
+ // chiave di aprire canali arbitrari. command=/bin/false + restrict.
212
+ const panelRemotePort = opts.panelRemotePort !== undefined ? Number(opts.panelRemotePort) : undefined;
213
+ const riga = tunnel.authorizedKeysLine({ remotePort, panelRemotePort, pub });
214
+ if (riga) log(riga);
209
215
  return { code: 0, name, sshPort, localPort, remotePort, transport: entry.transport };
210
216
  }
211
217
 
@@ -102,6 +102,7 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
102
102
  transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
103
103
  detail: diagnostic.detail, code: diagnostic.code, stage: diagnostic.stage,
104
104
  ...(diagnostic.hint ? { hint: diagnostic.hint } : {}),
105
+ ...(diagnostic.authorizedKeys ? { authorizedKeys: diagnostic.authorizedKeys } : {}),
105
106
  transportEngine: ts.transport || 'ssh', managed: ts.managed !== false, at: now,
106
107
  };
107
108
  } else if (!node.token) {
@@ -65,10 +65,16 @@ function recordPeerTransition(nodeName, health, diagnostics) {
65
65
  `${nodeName}: tunnel ${previous.tunnel} -> ${tunnel}`,
66
66
  { node: nodeName, state: tunnel });
67
67
  }
68
- // Testabile solo quando il tunnel e' su ORA: se e' appena caduto, il
69
- // servizio e' passato a 'unknown' come artefatto del tunnel (gia' segnalato
70
- // sopra), non come un fatto nuovo quindi qui non si scrive nulla.
71
- if (tunnel === 'up' && previous.service !== service) {
68
+ // Testabile solo quando il tunnel era SU e resta SU: solo allora entrambi
69
+ // gli estremi del confronto sono misure vere. Se e' appena caduto, il
70
+ // servizio passa a 'unknown' come artefatto del tunnel (gia' segnalato
71
+ // sopra), non come un fatto nuovo. Per SIMMETRIA vale anche al contrario:
72
+ // se e' appena tornato, il servizio non e' "tornato" — e' semplicemente di
73
+ // nuovo osservabile, e 'unknown' non era una misura precedente da cui
74
+ // partire. Scrivere "unknown -> ok" alla ripresa sarebbe un fatto inventato
75
+ // tanto quanto lo sarebbe "ok -> unknown" alla caduta: un record diagnostico
76
+ // che dichiara misurato cio' che non lo e' stato e' peggio del silenzio.
77
+ if (previous.tunnel === 'up' && tunnel === 'up' && previous.service !== service) {
72
78
  diagnostics.record('warn', 'peer-health', 'SERVICE_TRANSITION',
73
79
  `${nodeName}: service ${previous.service} -> ${service}`,
74
80
  { node: nodeName, state: service });