@mmmbuto/nexuscrew 0.9.1 → 0.9.3
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.
- package/CHANGELOG.md +171 -0
- package/frontend/dist/assets/{index-Bu_-2-Uu.js → index-uRh_hSop.js} +19 -19
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/init.js +18 -3
- package/lib/files/telemetry.js +89 -0
- package/lib/fleet/builtin.js +258 -49
- package/lib/fleet/definitions.js +226 -0
- package/lib/live-host/bridge.js +82 -22
- package/lib/proxy/panel-proxy.js +30 -3
- package/lib/server.js +5 -0
- package/package.json +1 -1
- package/skills/live/SKILL.md +8 -0
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-uRh_hSop.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-0vuhL1YP.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.2"}
|
package/lib/cli/init.js
CHANGED
|
@@ -17,7 +17,7 @@ const {
|
|
|
17
17
|
generateFleetService, installFleetService, migrationGate,
|
|
18
18
|
selectProviderModeSync, fleetFileMode,
|
|
19
19
|
} = require('./fleet-service.js');
|
|
20
|
-
const {
|
|
20
|
+
const { aggiornaDefinizioni } = require('../fleet/definitions.js');
|
|
21
21
|
const { defaultDefinitions } = require('../fleet/managed.js');
|
|
22
22
|
const { commandExists, resolveCommand } = require('./path.js');
|
|
23
23
|
|
|
@@ -83,8 +83,23 @@ function ensureFleetDefaults(opts = {}) {
|
|
|
83
83
|
if (e.code !== 'ENOENT') throw e;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// Fra il controllo qui sopra e la scrittura c'e' una finestra: se un altro
|
|
87
|
+
// processo crea le definizioni nel frattempo, scrivere i default sopra le sue
|
|
88
|
+
// significa cancellare una configurazione intera — non perdere una modifica.
|
|
89
|
+
// La creazione avviene quindi sotto lock, con l'esistenza RIVERIFICATA
|
|
90
|
+
// dentro: `seMancante` viene chiamata solo se il file davvero non c'e'.
|
|
91
|
+
// `propaga` perche' `created` dica il vero: senza, una rinuncia del lock
|
|
92
|
+
// restituiva lo stato riletto — truthy — e l'init dichiarava `created: true`
|
|
93
|
+
// per un file che non aveva creato.
|
|
94
|
+
let creato = null;
|
|
95
|
+
try {
|
|
96
|
+
creato = aggiornaDefinizioni(fleetDefsPath, () => null, {
|
|
97
|
+
seMancante: () => defaultDefinitions(),
|
|
98
|
+
propaga: true,
|
|
99
|
+
log: opts.log,
|
|
100
|
+
});
|
|
101
|
+
} catch (_) { creato = null; }
|
|
102
|
+
return { path: fleetDefsPath, created: Boolean(creato), enabled: true };
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
// Migration rule (B2): se non c'è config.json, parse la porta dal service file esistente.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Telemetria per-cella: contesto LIBERO e tier 5h/7d USATI, letti dal file che
|
|
3
|
+
// la statusline di Claude Code scrive in <root>/<sessione>/telemetry.json
|
|
4
|
+
// (snippet documentato in docs/STATUSLINE_TELEMETRY.md, NON applicato: la
|
|
5
|
+
// statusline e' dell'operatore). Il verso dei due dati e' OPPOSTO e i nomi del file lo
|
|
6
|
+
// portano scritto dentro: `contextFreePct` e' quanto RESTA, `tier*UsedPct` e'
|
|
7
|
+
// quanto e' STATO CONSUMATO. Confondere i versi produce una riga che dice il
|
|
8
|
+
// contrario del vero e fa prendere la decisione opposta a quella giusta.
|
|
9
|
+
//
|
|
10
|
+
// Tre regole non opinabili, tutte implementate qui:
|
|
11
|
+
// 1. TIMESTAMP OBBLIGATORIO: oltre MASSIMA_ETA_MS il dato e' morto e viene
|
|
12
|
+
// restituito null — un numero stantio che sembra fresco e' peggio di un
|
|
13
|
+
// numero assente.
|
|
14
|
+
// 2. ASSENZA LEGITTIMA: le celle non-Claude (codex-vl, agy, grok, shell) non
|
|
15
|
+
// hanno quella statusline e non avranno mai questo file. File assente =
|
|
16
|
+
// null, e la riga della lista resta com'era: niente campo, niente
|
|
17
|
+
// trattino, niente «n/d».
|
|
18
|
+
// 3. LETTURA TOLLERANTE: file illeggibile, JSON rotto, campi mancanti o
|
|
19
|
+
// fuori contratto — si degrada a null senza mai far fallire la lista.
|
|
20
|
+
//
|
|
21
|
+
// CONTRATTO del file: valori INTERI 0..100 (gia' percentuali — la
|
|
22
|
+
// normalizzazione frazione→percentuale e' compito di chi scrive, vedi lo
|
|
23
|
+
// snippet). Il lettore accetta SOLO interi: una frazione scritta per errore
|
|
24
|
+
// (0.5 che voleva essere 50%) viene rifiutata, non arrotondata a 1% — un
|
|
25
|
+
// numero sbagliato mostrato con sicurezza e' il difetto che conta, meglio
|
|
26
|
+
// nessun numero.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
// La statusline aggiorna a ogni evento del modello: in una cella viva il file
|
|
32
|
+
// e' sempre piu' fresco di cosi'. Cinque minuti coprono una pausa pranzo senza
|
|
33
|
+
// mostrare come attuale il dato di ieri.
|
|
34
|
+
const MASSIMA_ETA_MS = 5 * 60 * 1000;
|
|
35
|
+
// La soglia guarda in ENTRAMBI i versi. Un ts nel futuro farebbe `ora - ts`
|
|
36
|
+
// negativo: la differenza non supera MAI la massima eta' e il dato resterebbe
|
|
37
|
+
// «fresco» per sempre — un orologio avanti, o uno ts scritto male, e la riga
|
|
38
|
+
// mostra un numero morto che non scadra' mai. Due minuti di skew sono il
|
|
39
|
+
// margine che un orologio legittimamente sforato puo' avere; oltre, il ts e'
|
|
40
|
+
// rotto e il dato non esiste.
|
|
41
|
+
const FUTURO_TOLLERATO_MS = 2 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
const NOME_FILE = 'telemetry.json';
|
|
44
|
+
|
|
45
|
+
// Accetta SOLO interi 0..100 gia' numeri. Tutto il resto e' fuori contratto
|
|
46
|
+
// e non viene mostrato — in particolare null e i booleani: `Number(null)` e'
|
|
47
|
+
// 0 e `Number(true)` e' 1, e un campo assente letto come «0% usato» e'
|
|
48
|
+
// esattamente il numero sbagliato-mostrato-con-sicurezza che questo modulo
|
|
49
|
+
// esiste per evitare.
|
|
50
|
+
function percentualeIntera(valore) {
|
|
51
|
+
if (typeof valore !== 'number' || !Number.isInteger(valore)) return null;
|
|
52
|
+
if (valore < 0 || valore > 100) return null;
|
|
53
|
+
return valore;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Legge la telemetria di una sessione. Ritorna `{ ts, contextFreePct?,
|
|
58
|
+
* tier5hUsedPct?, tier7dUsedPct? }` con solo i campi validi, oppure null per
|
|
59
|
+
* assenza legittima, dato stantio o qualsiasi rotture. Non lancia MAI.
|
|
60
|
+
*/
|
|
61
|
+
function leggiTelemetria(root, sessione, ora = Date.now()) {
|
|
62
|
+
try {
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = fs.readFileSync(path.join(root, String(sessione), NOME_FILE), 'utf8');
|
|
66
|
+
} catch (_) {
|
|
67
|
+
return null; // assente (o non leggibile): cella non-Claude o primo avvio
|
|
68
|
+
}
|
|
69
|
+
const dato = JSON.parse(raw);
|
|
70
|
+
if (!dato || typeof dato !== 'object' || Array.isArray(dato)) return null;
|
|
71
|
+
const ts = Number(dato.ts);
|
|
72
|
+
// Senza timestamp non c'e' freschezza da verificare: il dato non esiste.
|
|
73
|
+
if (!Number.isFinite(ts)) return null;
|
|
74
|
+
if (ora - ts > MASSIMA_ETA_MS) return null; // stantio = assente
|
|
75
|
+
if (ts - ora > FUTURO_TOLLERATO_MS) return null; // ts rotto: «fresco per sempre» non e' fresco
|
|
76
|
+
const campi = {};
|
|
77
|
+
const libero = percentualeIntera(dato.contextFreePct);
|
|
78
|
+
const t5 = percentualeIntera(dato.tier5hUsedPct);
|
|
79
|
+
const t7 = percentualeIntera(dato.tier7dUsedPct);
|
|
80
|
+
if (libero !== null) campi.contextFreePct = libero;
|
|
81
|
+
if (t5 !== null) campi.tier5hUsedPct = t5;
|
|
82
|
+
if (t7 !== null) campi.tier7dUsedPct = t7;
|
|
83
|
+
return Object.keys(campi).length ? { ts, ...campi } : null;
|
|
84
|
+
} catch (_) {
|
|
85
|
+
return null; // JSON rotto o altro: la lista non fallisce per questo
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { leggiTelemetria, MASSIMA_ETA_MS, FUTURO_TOLLERATO_MS, NOME_FILE };
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
// mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
|
|
27
27
|
// - promptMode 'send-keys' inietta via bracketed paste; se il command e' gia'
|
|
28
28
|
// uscito (sessione morta) NON digita (§9e).
|
|
29
|
+
const fs = require('node:fs');
|
|
29
30
|
const os = require('node:os');
|
|
30
31
|
const path = require('node:path');
|
|
31
32
|
const {
|
|
32
|
-
loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
|
|
33
|
+
loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName, validateCommandTrust,
|
|
34
|
+
aggiornaDefinizioni,
|
|
33
35
|
cellIdFromTmuxSession,
|
|
34
36
|
resolveCwd, normalizeCwdRel, deriveCwdRel,
|
|
35
37
|
} = require('./definitions.js');
|
|
@@ -86,13 +88,20 @@ function draftFrom(defs) {
|
|
|
86
88
|
// ricevono l'engine standard Shell senza riscrivere celle o sostituire un id
|
|
87
89
|
// scelto dall'utente. Se lo store e' pieno o la scrittura non e' possibile, il
|
|
88
90
|
// bootstrap resta utilizzabile con le definizioni precedenti.
|
|
89
|
-
function backfillShellEngine(defsPath, defs) {
|
|
90
|
-
if (!defs
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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;
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
// Backfill platform-aware dell'engine Agy primario (design §4.2): installazioni
|
|
@@ -110,12 +119,17 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
|
|
|
110
119
|
const termux = platform === 'android'
|
|
111
120
|
|| termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
|
|
112
121
|
if (termux || (platform !== 'linux' && platform !== 'darwin')) return defs;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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;
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
// Engine desktop.local: NON managed (nessun client/provider AI, solo command+args
|
|
@@ -127,11 +141,73 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
|
|
|
127
141
|
// meccanismo condiviso (validPanelUrl in definitions.js): l'engine non ha una
|
|
128
142
|
// sua strada per imporre l'URL, dichiara solo il valore, e resta sovrascrivibile
|
|
129
143
|
// per-cella come qualunque altro panelUrl (vedi parseCell).
|
|
144
|
+
// Risolve un eseguibile del PATH nel suo path assoluto REALE. Il realpath non
|
|
145
|
+
// e' pignoleria: `validateCommandTrust` usa `lstat` e rifiuta i symlink — e in
|
|
146
|
+
// molte installazioni il primo hit nel PATH e' proprio un symlink. Senza questo
|
|
147
|
+
// passaggio l'engine sarebbe rifiutato dalla stessa trust boundary del
|
|
148
|
+
// progetto, con un messaggio che parla di path assoluti mentre il problema e'
|
|
149
|
+
// un link.
|
|
150
|
+
function risolviEseguibile(nome) {
|
|
151
|
+
const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
152
|
+
for (const dir of dirs) {
|
|
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;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
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
|
+
|
|
194
|
+
// Il command DEVE essere un path assoluto: `validateCommandTrust` e' la trust
|
|
195
|
+
// boundary degli engine, e un nome relativo si risolverebbe via PATH — cioe'
|
|
196
|
+
// via qualcosa che l'ambiente puo' cambiare sotto di noi. Dichiarare 'docker'
|
|
197
|
+
// rendeva questo engine built-in NON avviabile: veniva offerto nell'elenco e
|
|
198
|
+
// rifiutato al salvataggio con «command deve essere un path assoluto», un
|
|
199
|
+
// errore che descrive la forma del valore invece di dire che il comando non
|
|
200
|
+
// era stato risolto.
|
|
201
|
+
//
|
|
202
|
+
// Se docker non e' installato si ricade sul path convenzionale: l'engine resta
|
|
203
|
+
// visibile e il rifiuto diventa «non accessibile (ENOENT)», che nomina la
|
|
204
|
+
// causa vera — docker manca — invece di sembrare un difetto di
|
|
205
|
+
// configurazione.
|
|
130
206
|
function defaultDesktopEngine() {
|
|
131
207
|
return {
|
|
132
208
|
id: 'desktop.local',
|
|
133
209
|
label: 'Desktop',
|
|
134
|
-
command: 'docker',
|
|
210
|
+
command: risolviEseguibile('docker') || '/usr/bin/docker',
|
|
135
211
|
args: ['exec', '-it', '-u', 'abc', 'ai-desktop', 'bash'],
|
|
136
212
|
promptMode: 'send-keys',
|
|
137
213
|
panelUrl: 'https://127.0.0.1:6901',
|
|
@@ -160,12 +236,82 @@ function defaultDesktopEngine() {
|
|
|
160
236
|
// bootstrap: una dipendenza nuova sul percorso di avvio per una comodita'.
|
|
161
237
|
// Resta MANUALE: chi ha il container se lo aggiunge nella propria procedura
|
|
162
238
|
// — e' il posto dove qualcuno sa gia' che il container esiste.
|
|
163
|
-
function backfillDesktopEngine(defsPath, defs) {
|
|
164
|
-
if (!defs
|
|
165
|
-
if (defs.engines.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
239
|
+
function backfillDesktopEngine(defsPath, defs, log) {
|
|
240
|
+
if (!defs) 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;
|
|
252
|
+
}
|
|
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
|
+
//
|
|
261
|
+
// Le installazioni che hanno gia' ricevuto il backfill portano in
|
|
262
|
+
// configurazione un `command` RELATIVO, che la trust boundary rifiuta: per
|
|
263
|
+
// loro l'aggiornamento del default non cambia nulla, perche' il backfill salta
|
|
264
|
+
// cio' che esiste gia'. Senza questa riparazione il difetto resterebbe
|
|
265
|
+
// esattamente dove e' stato visto — su una macchina gia' configurata.
|
|
266
|
+
//
|
|
267
|
+
// Prudente per costruzione: interviene SOLO se il comando non e' assoluto e si
|
|
268
|
+
// chiama ancora `docker`. Un path assoluto — o un comando che l'utente ha
|
|
269
|
+
// cambiato in altro — non viene toccato: quella e' una scelta, non un residuo.
|
|
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
|
|
273
|
+
const risolto = risolviEseguibile('docker');
|
|
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';
|
|
169
315
|
}
|
|
170
316
|
|
|
171
317
|
// Backfill dell'engine Kimi Code CLI nativo: installazioni esistenti ricevono
|
|
@@ -174,13 +320,20 @@ function backfillDesktopEngine(defsPath, defs) {
|
|
|
174
320
|
// shebang. Idempotente (gia' presente -> skip), NON sovrascrive un id
|
|
175
321
|
// 'kimi.native' gia' scelto dall'utente per altro (collisione -> skip, store
|
|
176
322
|
// invariato), rispetta il cap MAX_ENGINES. Non tocca CELLE.
|
|
177
|
-
function backfillKimiEngine(defsPath, defs) {
|
|
178
|
-
if (!defs
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
184
337
|
}
|
|
185
338
|
|
|
186
339
|
// Backfill platform-aware dell'engine Grok Build (grok.native): come Agy,
|
|
@@ -196,12 +349,17 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
|
|
|
196
349
|
const termux = platform === 'android'
|
|
197
350
|
|| termuxRuntimePaths(cfg.env || process.env, { platform, home: cfg.home }) !== null;
|
|
198
351
|
if (termux || (platform !== 'linux' && platform !== 'darwin')) return defs;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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;
|
|
205
363
|
}
|
|
206
364
|
|
|
207
365
|
// Backfill dell'engine VL/Vivling (vl.native): come Kimi, NESSUN platform gate
|
|
@@ -209,13 +367,20 @@ function backfillGrokEngine(defsPath, defs, cfg = {}) {
|
|
|
209
367
|
// -> skip), NON sovrascrive un id 'vl.native' gia' scelto dall'utente per altro
|
|
210
368
|
// (collisione -> skip, store invariato), rispetta il cap MAX_ENGINES. Non tocca
|
|
211
369
|
// CELLE.
|
|
212
|
-
function backfillVlEngine(defsPath, defs) {
|
|
213
|
-
if (!defs
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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;
|
|
219
384
|
}
|
|
220
385
|
|
|
221
386
|
// Applica engine + modello + policy come un'unica transizione. Ogni engine ricorda
|
|
@@ -413,6 +578,16 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
413
578
|
: off;
|
|
414
579
|
|
|
415
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);
|
|
416
591
|
// La migrazione precede QUALUNQUE backfill/scrittura: loadDefinitions normalizza
|
|
417
592
|
// i nomi legacy solo in memoria. Se il rename e' ambiguo o fallisce, fleet.json
|
|
418
593
|
// resta byte-invariato e la Fleet non puo creare una seconda sessione safe.
|
|
@@ -427,17 +602,36 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
427
602
|
return blocked(`${detail} [${code}]`, code);
|
|
428
603
|
}
|
|
429
604
|
if (migration.needsPersistence) {
|
|
430
|
-
|
|
431
|
-
|
|
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) {
|
|
432
621
|
return blocked('migrazione tmux completata ma fleet.json non e persistibile [TMUX_MIGRATION_PERSIST_FAILED]',
|
|
433
622
|
'TMUX_MIGRATION_PERSIST_FAILED');
|
|
434
623
|
}
|
|
624
|
+
boot = persistito;
|
|
435
625
|
}
|
|
436
|
-
boot = backfillShellEngine(defsPath, boot);
|
|
626
|
+
boot = backfillShellEngine(defsPath, boot, cfg.log);
|
|
437
627
|
boot = backfillAgyEngine(defsPath, boot, cfg);
|
|
438
|
-
boot = backfillKimiEngine(defsPath, boot);
|
|
628
|
+
boot = backfillKimiEngine(defsPath, boot, cfg.log);
|
|
439
629
|
boot = backfillGrokEngine(defsPath, boot, cfg);
|
|
440
|
-
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);
|
|
441
635
|
}
|
|
442
636
|
|
|
443
637
|
// Adopt or create the shared server before exposing a mutable Fleet. Reapply
|
|
@@ -500,14 +694,28 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
500
694
|
// Scrive il draft mutato; atomicWrite valida PRIMA (fail-closed). Su input
|
|
501
695
|
// invalido: backup predecessore + throw -> httpError(400) (mai garbage).
|
|
502
696
|
async function mutate(defs, mutator) {
|
|
503
|
-
|
|
504
|
-
|
|
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.
|
|
505
704
|
let parsed;
|
|
506
705
|
try {
|
|
507
|
-
parsed =
|
|
706
|
+
parsed = aggiornaDefinizioni(defsPath, (dentro) => {
|
|
707
|
+
const draft = draftFrom(dentro);
|
|
708
|
+
mutator(draft);
|
|
709
|
+
return draft;
|
|
710
|
+
}, { propaga: true });
|
|
508
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');
|
|
509
716
|
throw httpError(400, `definizioni non valide: ${e.message}`);
|
|
510
717
|
}
|
|
718
|
+
if (!parsed) throw httpError(409, 'definizioni fleet non leggibili: riprova');
|
|
511
719
|
commitDefs(parsed);
|
|
512
720
|
return parsed;
|
|
513
721
|
}
|
|
@@ -1201,6 +1409,7 @@ module.exports = {
|
|
|
1201
1409
|
// Esportate ma NON chiamate qui sopra (§bootstrap, righe ~354-358): scelta
|
|
1202
1410
|
// deliberata, non una dimenticanza — vedi il commento su backfillDesktopEngine.
|
|
1203
1411
|
backfillDesktopEngine,
|
|
1412
|
+
riparaDesktopEngine,
|
|
1204
1413
|
defaultDesktopEngine,
|
|
1205
1414
|
resolveCellCwd,
|
|
1206
1415
|
composeLaunchArgv,
|