@mmmbuto/nexuscrew 0.9.2 → 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 +140 -0
- package/lib/cli/init.js +18 -3
- package/lib/files/telemetry.js +89 -0
- package/lib/fleet/builtin.js +218 -64
- 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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,146 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to NexusCrew are tracked here.
|
|
4
4
|
|
|
5
|
+
## 0.9.3 — 2026-08-16 — "A Receipt Is Not an Outcome"
|
|
6
|
+
|
|
7
|
+
The previous release fixed the Desktop engine and shipped the fix inert. Pulling
|
|
8
|
+
on that thread produced most of this one: a repair that was never called, a race
|
|
9
|
+
underneath it, a lock that reopened the race it closed, and — at the end — a
|
|
10
|
+
success reported for a write that never happened. A `200 OK` for a change that
|
|
11
|
+
was silently dropped is worse than a refusal, because nothing downstream has any
|
|
12
|
+
reason to look again.
|
|
13
|
+
|
|
14
|
+
- **Every write to the fleet definitions now goes through a lock, and a
|
|
15
|
+
surrendered write is no longer reported as success.** Ten code paths did
|
|
16
|
+
read-modify-write on the same file with no mutual exclusion, so a concurrent
|
|
17
|
+
update could be read, overwritten, and lost. The lock is taken by creating a
|
|
18
|
+
file exclusively — one syscall, no window between checking and acting — and
|
|
19
|
+
its owner is identified per acquisition rather than per process. Liveness is
|
|
20
|
+
asked of the kernel: a lock is only broken when it is both old *and* its owner
|
|
21
|
+
is gone, because age alone never proved a process dead. The first version of
|
|
22
|
+
this guard evicted **live** owners, which is how a cure reopens the disease
|
|
23
|
+
it was written for; the case is now pinned by tests in both directions.
|
|
24
|
+
|
|
25
|
+
Two callers want opposite behaviour and now get it: an opportunistic
|
|
26
|
+
migration gives up in silence, while a change a person asked for **fails**
|
|
27
|
+
with its own cause — `409` when the lock is busy, `400` when the data is
|
|
28
|
+
invalid. Previously both answered `200 OK`.
|
|
29
|
+
|
|
30
|
+
- **Creating the definitions for the first time also goes through the lock.**
|
|
31
|
+
`init` wrote the file directly, so a first run racing with a live service
|
|
32
|
+
could erase a configuration instead of creating one.
|
|
33
|
+
|
|
34
|
+
- **The panel's own password reaches the panel.** `Authorization` was stripped
|
|
35
|
+
wholesale on the way in, so any panel behind HTTP authentication was
|
|
36
|
+
impossible to log into by construction — not misconfigured, impossible. The
|
|
37
|
+
header now travels for the schemes a panel actually uses (`Basic`, `Digest`,
|
|
38
|
+
`NTLM`, `Negotiate`) and never for a bearer token, which belongs to the
|
|
39
|
+
control plane and must not leak into a proxied application. It is a closed
|
|
40
|
+
list, not a denylist: an unknown scheme is refused rather than forwarded.
|
|
41
|
+
|
|
42
|
+
- **A container that opens over the page instead of taking you away from it.**
|
|
43
|
+
Opening a cell's preview, or its desktop panel, used to mean leaving whatever
|
|
44
|
+
you were looking at. There is now one popup — closed by `Escape`, by clicking
|
|
45
|
+
outside it, never by clicking inside it, and it takes focus when it opens —
|
|
46
|
+
reached from the status dot beside each cell in the list and from the cell's
|
|
47
|
+
own tile in the grid. The panel was previously reachable **only** from an
|
|
48
|
+
enlarged cell, so opening a browser meant leaving the grid first. The popup
|
|
49
|
+
now holds all three sources — preview, live stream, desktop panel — and on
|
|
50
|
+
narrow screens they get real buttons instead of one small dot carrying three
|
|
51
|
+
meanings. Looking at a cell never selects it.
|
|
52
|
+
|
|
53
|
+
One defect found on the way, worth stating because it was invisible: the
|
|
54
|
+
popup held onto the **row** it was opened from — a single frame of a list
|
|
55
|
+
that refreshes every few seconds. Left open across a refresh it could show
|
|
56
|
+
another cell's preview, or a dead one's. It now holds the key and re-resolves
|
|
57
|
+
it on every render; a cell that disappears closes its own popup.
|
|
58
|
+
|
|
59
|
+
- **The panel opened from the list is served from its own origin again.** A
|
|
60
|
+
panel reached through the new popup was passed no port, and with no port the
|
|
61
|
+
frame falls back to a relative path — same-origin with the control plane,
|
|
62
|
+
which is precisely what 0.9.1 separated so that a panel's own scripts cannot
|
|
63
|
+
reach the operator's token. The new entry point had quietly reopened it, with
|
|
64
|
+
nothing to show for it: the panel opened and worked. The port is now resolved
|
|
65
|
+
per row by the same function the grid uses, and a remote cell whose node has
|
|
66
|
+
no negotiated port still gets none rather than borrowing the local one —
|
|
67
|
+
a wrong origin is not a fallback.
|
|
68
|
+
|
|
69
|
+
- **A lock is no longer held by a process that merely inherited its number.**
|
|
70
|
+
Liveness was decided by asking the kernel whether a pid existed, but pids are
|
|
71
|
+
reused: once the number was handed to an unrelated process, the lock was
|
|
72
|
+
never considered abandoned and every write to the fleet definitions gave up
|
|
73
|
+
in silence. The lock's token now also records **when** its owner started, so
|
|
74
|
+
two processes that held the same number at different times are told apart.
|
|
75
|
+
Where that cannot be read — systems without `/proc` — the lock is left alone:
|
|
76
|
+
a delayed write, never an eviction. That case is documented, not closed.
|
|
77
|
+
|
|
78
|
+
Also: a lock whose token could not be written is no longer treated as owned.
|
|
79
|
+
The write was best-effort, so a failure left an **empty** lock that named
|
|
80
|
+
nobody, and thirty seconds later a live owner could be evicted from it.
|
|
81
|
+
|
|
82
|
+
- **A migration no longer overwrites work committed while it was running.**
|
|
83
|
+
The concurrency check compared a snapshot taken *after* the migration's own
|
|
84
|
+
work, so anything written during it was already inside the snapshot: the
|
|
85
|
+
comparison passed and the older state won. The baseline is now taken at the
|
|
86
|
+
source, covering the whole window.
|
|
87
|
+
|
|
88
|
+
- **The cell list shows free context and tier usage, where the cell can report
|
|
89
|
+
them.** The numbers come from a small file written beside the cell's own
|
|
90
|
+
files; a documented snippet is included for producing it
|
|
91
|
+
(`docs/STATUSLINE_TELEMETRY.md`), and nothing is written automatically. Three
|
|
92
|
+
rules govern the display: a reading older than five minutes is dropped rather
|
|
93
|
+
than shown as current, cells that cannot report simply have no such line —
|
|
94
|
+
no dash, no "n/a" — and an unreadable or malformed file degrades to nothing
|
|
95
|
+
without failing the list. Values are accepted only as integers already in
|
|
96
|
+
percent: a `null` would otherwise have been converted to a perfectly credible
|
|
97
|
+
`0%` — and the same conversion was found in the documented snippet, on the
|
|
98
|
+
writing side, where the reader cannot defend against it: a zero written in
|
|
99
|
+
place of nothing is a valid integer and would have passed.
|
|
100
|
+
|
|
101
|
+
Freshness is checked in **both** directions. A reading stamped in the future
|
|
102
|
+
was never older than the limit, so it would have stayed "current" forever;
|
|
103
|
+
beyond a small tolerance for clock skew it is now dropped like any other
|
|
104
|
+
unusable value. And every number carries its own direction in its label —
|
|
105
|
+
`context 71% free · 5h used 33%` — because a comment claiming the labels said
|
|
106
|
+
so was not the same as the labels saying so, and next to a "free" the reader
|
|
107
|
+
supplies the missing word themselves.
|
|
108
|
+
|
|
109
|
+
- **The voice interface knows which cell it is attached to.** It received the
|
|
110
|
+
working directory and a prompt, never an identity, so a Live session had to
|
|
111
|
+
go looking through terminal sessions to guess where it was — and could
|
|
112
|
+
describe another cell's work as its own. The bridge now always prepends the
|
|
113
|
+
designated cell's identity to the instructions it sends, alone when no
|
|
114
|
+
per-cell prompt exists. The identity travels on the bridge rather than in the
|
|
115
|
+
prompt on purpose: a prompt may legitimately be absent, and if identity
|
|
116
|
+
depended on it, a missing prompt would have meant a Live with no idea who it
|
|
117
|
+
was. The exact session name is included only when the roster declares it,
|
|
118
|
+
never inferred.
|
|
119
|
+
|
|
120
|
+
**Please note, if you set `developer_instructions` in your configuration.**
|
|
121
|
+
The bridge sends that field on every Live session now, and the consumer
|
|
122
|
+
*replaces* its configured value with what it receives rather than adding to
|
|
123
|
+
it. A cell with no per-cell prompt therefore no longer receives the global
|
|
124
|
+
developer instructions it used to get. This is accepted rather than worked
|
|
125
|
+
around: changing the field's meaning would be a new contract for every
|
|
126
|
+
client, and the intended place for a cell's own instructions is its
|
|
127
|
+
`LIVE_PROMPT.md`.
|
|
128
|
+
|
|
129
|
+
- **The per-cell prompt is found on machines that name their sessions
|
|
130
|
+
anything.** Its directory was built by pinning the literal prefix `cloud-` in
|
|
131
|
+
front of the cell name, so on a host whose sessions are named otherwise the
|
|
132
|
+
file was either looked for where it does not exist, or under an invented
|
|
133
|
+
path. Both ended in "absent" — the same answer as a file that genuinely is
|
|
134
|
+
not there, so the defect hid inside the branch meant to report it. The
|
|
135
|
+
directory is now the session the roster declares, and when the roster
|
|
136
|
+
declares none there is a distinct outcome: no path is built at all, rather
|
|
137
|
+
than a prefix guessed.
|
|
138
|
+
|
|
139
|
+
- **Ready-to-copy prompt templates** in `docs/live-prompt-templates/`, in
|
|
140
|
+
English, Italian and Spanish, with `docs/LIVE_PROMPT.md` describing where the
|
|
141
|
+
file goes and the four outcomes the bridge can report. They name no cell, no
|
|
142
|
+
operator and no host: the voice takes its identity from the tools at runtime,
|
|
143
|
+
so the file works as copied.
|
|
144
|
+
|
|
5
145
|
## 0.9.2 — 2026-08-16 — "Offered and Then Refused"
|
|
6
146
|
|
|
7
147
|
Both defects in this release were found by using the product, not by a test —
|
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
|
@@ -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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
|
214
|
-
|
|
215
|
-
if (!
|
|
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;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
voce.
|
|
223
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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 =
|
|
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,
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -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,
|
package/lib/live-host/bridge.js
CHANGED
|
@@ -17,10 +17,20 @@
|
|
|
17
17
|
// fallimento, la risposta è `none` col motivo: la Live parte senza
|
|
18
18
|
// puntamento, comportamento standard. Un `none` non è un errore HTTP.
|
|
19
19
|
// - MC2: il prompt per-cella (LIVE_PROMPT.md accanto ai canonici della
|
|
20
|
-
// cella) viaggia su developerInstructions di thread/start
|
|
21
|
-
//
|
|
22
|
-
// rev5 MC2).
|
|
23
|
-
//
|
|
20
|
+
// cella) viaggia su developerInstructions di thread/start e SOSTITUISCE
|
|
21
|
+
// le developer instructions della config per quella Live (rev4 LC2
|
|
22
|
+
// emendata da rev5 MC2). La riga che decide è in codex-rs
|
|
23
|
+
// core/src/config/mod.rs: `developer_instructions.or(cfg.developer_
|
|
24
|
+
// instructions)` — l'override Some scarta il valore di config. R2
|
|
25
|
+
// (2026-08-16, verso corretto dopo audit pre-release): l'identità della
|
|
26
|
+
// cella designata viaggia SEMPRE come intestazione anteposta al campo,
|
|
27
|
+
// anche senza prompt. Il campo NON è additivo: una cella senza
|
|
28
|
+
// LIVE_PROMPT.md, che prima non passava nulla e riceveva le developer
|
|
29
|
+
// instructions della config, ora passa la sola intestazione e QUELLE
|
|
30
|
+
// NON le riceve più. Restano fuori da questa sostituzione AGENTS.md e
|
|
31
|
+
// il world state (fragment user, canale separato) e il prompt base.
|
|
32
|
+
// La via designata per le istruzioni di lavoro della Live è il
|
|
33
|
+
// LIVE_PROMPT.md della cella: viaggia nello stesso campo.
|
|
24
34
|
// - MC3: il ponte crea le proprie conversazioni con thread/start e non
|
|
25
35
|
// tocca MAI la thread di una TUI — né turn/start né thread/resume: chi
|
|
26
36
|
// guarda i metodi visti dal server deve vedere solo initialize,
|
|
@@ -62,21 +72,41 @@ const CLIENT_NAME = 'nexuscrew-live-bridge';
|
|
|
62
72
|
const ORPHAN_GRACE_MS = 1500;
|
|
63
73
|
|
|
64
74
|
// —— Prompt per-cella (rev4 LC2, nome fisso confermato da Dev 2026-08-15) ——
|
|
65
|
-
// Collocazione: filesRoot
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
75
|
+
// Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
|
|
76
|
+
// ESATTA che il roster dichiara per la cella designata, la stessa fonte gia'
|
|
77
|
+
// usata per l'intestazione R2 (identityHeader). NON un prefisso ricostruito a
|
|
78
|
+
// mano: fino al 2026-08-16 questa funzione anteponeva 'cloud-' come default
|
|
79
|
+
// universale quando il cellId non ce l'aveva gia' — su un device che chiama
|
|
80
|
+
// le proprie sessioni con un prefisso diverso il file non veniva MAI trovato,
|
|
81
|
+
// e l'esito era 'missing' ("assenza legittima"): il bug si mascherava
|
|
82
|
+
// esattamente nel ramo che avrebbe dovuto segnalarlo. Bug trovato scrivendo
|
|
83
|
+
// docs/LIVE_PROMPT.md, corretto qui.
|
|
84
|
+
//
|
|
85
|
+
// Quattro esiti DISTINTI, perché «non so nemmeno dove cercare», «ho cercato
|
|
86
|
+
// e non c'è» e «c'è ma non si può leggere» portano chi indaga in posti
|
|
87
|
+
// diversi:
|
|
88
|
+
// applied:true → il testo va su developerInstructions
|
|
89
|
+
// applied:false, reason session-unknown → il roster non dichiara la sessione
|
|
90
|
+
// tmux per questa cella: NESSUN path viene
|
|
91
|
+
// costruito (mai un prefisso indovinato),
|
|
92
|
+
// quindi non si tenta nemmeno la lettura
|
|
93
|
+
// applied:false, reason missing → ENOENT sul path dichiarato: assenza
|
|
94
|
+
// legittima (LC2.3), si procede senza
|
|
95
|
+
// PROMPT (R2: l'intestazione identità
|
|
96
|
+
// viaggia comunque). ATTENZIONE: il campo
|
|
97
|
+
// developerInstructions viene comunque
|
|
98
|
+
// inviato per via dell'intestazione, e il
|
|
99
|
+
// consumer lo SOSTITUISCE alla propria
|
|
100
|
+
// configurazione invece di sommarlo — chi
|
|
101
|
+
// non ha prompt per-cella non riceve le
|
|
102
|
+
// developer instructions globali che
|
|
103
|
+
// riceveva prima (vedi MC2, verificato
|
|
104
|
+
// sulla riga che decide)
|
|
72
105
|
// applied:false, reason unreadable|empty → presente ma inutilizzabile: va
|
|
73
106
|
// dichiarato, mai silenziato
|
|
74
|
-
function readCellPrompt(filesRoot,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// qui, una volta sola.
|
|
78
|
-
const dirName = String(cellId).startsWith('cloud-') ? String(cellId) : `cloud-${cellId}`;
|
|
79
|
-
const file = path.join(filesRoot, dirName, 'LIVE_PROMPT.md');
|
|
107
|
+
function readCellPrompt(filesRoot, tmuxSession) {
|
|
108
|
+
if (typeof tmuxSession !== 'string' || !tmuxSession) return { applied: false, reason: 'session-unknown' };
|
|
109
|
+
const file = path.join(filesRoot, tmuxSession, 'LIVE_PROMPT.md');
|
|
80
110
|
let raw;
|
|
81
111
|
try {
|
|
82
112
|
raw = fs.readFileSync(file, 'utf8');
|
|
@@ -89,6 +119,22 @@ function readCellPrompt(filesRoot, cellId) {
|
|
|
89
119
|
return { applied: true, source: 'LIVE_PROMPT.md', text };
|
|
90
120
|
}
|
|
91
121
|
|
|
122
|
+
// —— Identità della Live (R2, 2026-08-16): la porta il ponte, non il prompt ——
|
|
123
|
+
// Il ponte ha la designazione IN MANO (MC3.4: è la sua condizione di
|
|
124
|
+
// funzionamento) e un prompt può legittimamente mancare (MC2.4): se
|
|
125
|
+
// l'identità dipendesse dal prompt, l'assenza del prompt diventerebbe assenza
|
|
126
|
+
// di identità — è esattamente il difetto visto sul campo (la voce andava a
|
|
127
|
+
// leggere tmux per capire dove si trovava).
|
|
128
|
+
//
|
|
129
|
+
// Il fatto, niente di più: quale cella (id Fleet) e, se il roster la dichiara,
|
|
130
|
+
// la sessione tmux esatta — quella con cui la voce raggiunge i canonici della
|
|
131
|
+
// cella in ~/NexusFiles/<tmuxSession>/. Non un'instruzione di lavoro: quelle
|
|
132
|
+
// vivono nel prompt per-cella, che questa intestazione PRECEDE sempre.
|
|
133
|
+
function identityHeader(cellId, tmuxSession) {
|
|
134
|
+
const sessione = tmuxSession ? ` (sessione tmux ${tmuxSession})` : '';
|
|
135
|
+
return `Live NexusCrew agganciata alla cella ${cellId}${sessione}.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
92
138
|
// —— Client on-demand del socket di controllo (sezione protocollo sopra) ——
|
|
93
139
|
// Una sola richiesta per connessione: aperta, handshake, thread/start, chiusa.
|
|
94
140
|
// Le eventuali notifiche broadcast che arrivano nel frattempo vengono ignorate
|
|
@@ -308,7 +354,9 @@ function createLiveBridge({
|
|
|
308
354
|
// qualunque altro engine la Live lavora ATTRAVERSO la cella e il ponte non
|
|
309
355
|
// ha nulla da creare qui.
|
|
310
356
|
const engine = String(cell.engine || '');
|
|
311
|
-
|
|
357
|
+
// Stessa fonte dell'intestazione qui sotto: la sessione tmux che il
|
|
358
|
+
// roster dichiara, mai il cellId ricostruito con un prefisso indovinato.
|
|
359
|
+
const prompt = readCellPrompt(root, cell.tmuxSession);
|
|
312
360
|
|
|
313
361
|
if (!engine.startsWith('codex-vl')) {
|
|
314
362
|
const out = {
|
|
@@ -322,12 +370,21 @@ function createLiveBridge({
|
|
|
322
370
|
return out;
|
|
323
371
|
}
|
|
324
372
|
|
|
373
|
+
// R2: l'identità viaggia SEMPRE, anteposta al prompt quando c'è. Il campo
|
|
374
|
+
// non è mai più assente: senza LIVE_PROMPT.md porta la sola intestazione
|
|
375
|
+
// — e poiché il campo SOSTITUISCE le developer instructions della config
|
|
376
|
+
// (vedi MC2: la .or() in config/mod.rs), quella cella non le riceve più.
|
|
377
|
+
const intestazione = identityHeader(snap.hostCell, cell.tmuxSession);
|
|
378
|
+
const developerInstructions = prompt.applied
|
|
379
|
+
? `${intestazione}\n\n${prompt.text}`
|
|
380
|
+
: intestazione;
|
|
381
|
+
|
|
325
382
|
let started;
|
|
326
383
|
try {
|
|
327
384
|
started = await startThreadOnControlSocket({
|
|
328
385
|
socketPath: cfg.liveBridgeSocketPath,
|
|
329
386
|
cwd: cell.cwd,
|
|
330
|
-
developerInstructions
|
|
387
|
+
developerInstructions,
|
|
331
388
|
timeoutMs: cfg.liveBridgeTimeoutMs,
|
|
332
389
|
WebSocket,
|
|
333
390
|
log,
|
|
@@ -347,12 +404,15 @@ function createLiveBridge({
|
|
|
347
404
|
at: now(),
|
|
348
405
|
};
|
|
349
406
|
// LC1.4: il puntamento è visibile lato nostro — log con cella, thread e
|
|
350
|
-
// prompt applicato. È il "dirottamento dichiarato" del contratto.
|
|
351
|
-
|
|
407
|
+
// prompt applicato. È il "dirottamento dichiarato" del contratto. Il
|
|
408
|
+
// campo SOSTITUISCE le developer instructions della config (MC2, la
|
|
409
|
+
// .or() in config/mod.rs): il log lo dichiara, perché chi lo legge sappia
|
|
410
|
+
// cosa quella Live NON riceve.
|
|
411
|
+
log(`[live-bridge] Live puntata su ${snap.hostCell}: thread ${started.threadId} (cwd ${started.cwd}, identità nell'intestazione, prompt ${prompt.applied ? 'per-cella applicato' : `non applicato (${promptEcho.reason})`}, sostituisce le developer instructions di config)`);
|
|
352
412
|
return out;
|
|
353
413
|
}
|
|
354
414
|
|
|
355
|
-
return { resolveForLive, readCellPrompt: (
|
|
415
|
+
return { resolveForLive, readCellPrompt: (tmuxSession) => { const { text, ...rest } = readCellPrompt(root, tmuxSession); return rest; } };
|
|
356
416
|
}
|
|
357
417
|
|
|
358
418
|
let cachedVersion = null;
|
package/lib/proxy/panel-proxy.js
CHANGED
|
@@ -89,9 +89,23 @@ function stripLocalTokenQuery(search) {
|
|
|
89
89
|
// sessione del control plane, esattamente cio' che l'Authorization rimossa
|
|
90
90
|
// doveva impedire. E `x-forwarded-*` da un client sono valori che un pannello
|
|
91
91
|
// potrebbe credere veri. Rilievo di un audit indipendente.
|
|
92
|
-
function isStrippedRequestHeader(key) {
|
|
92
|
+
function isStrippedRequestHeader(key, value) {
|
|
93
93
|
if (HOP_BY_HOP.has(key)) return true;
|
|
94
|
-
if (key === '
|
|
94
|
+
if (key === 'cookie' || key === 'host') return true;
|
|
95
|
+
// `authorization` NON si strippa in blocco: lo schema dice di chi e'.
|
|
96
|
+
//
|
|
97
|
+
// `Bearer` e' il nostro token del control plane e non deve mai raggiungere il
|
|
98
|
+
// container — e' la ragione per cui questo strip esiste. Ma un pannello
|
|
99
|
+
// protetto da password risponde `401 WWW-Authenticate: Basic`, il browser
|
|
100
|
+
// chiede le credenziali all'utente e le rimanda: buttando via ANCHE quelle,
|
|
101
|
+
// il pannello ripresentava il prompt all'infinito e il login era impossibile
|
|
102
|
+
// per costruzione. Sono credenziali che l'utente ha inserito PER il pannello,
|
|
103
|
+
// in risposta a una sua richiesta: appartengono a lui.
|
|
104
|
+
//
|
|
105
|
+
// Dichiarato: se qualcuno mette una basic auth propria davanti a NexusCrew,
|
|
106
|
+
// quella credenziale raggiungerebbe il pannello. Non e' il nostro schema di
|
|
107
|
+
// autenticazione e non e' una configurazione che produciamo.
|
|
108
|
+
if (key === 'authorization') return !credenzialeDelPannello(value);
|
|
95
109
|
// `referer` porterebbe il ticket d'ingresso (viaggiava in query sulla prima
|
|
96
110
|
// richiesta dell'iframe) fino al pannello: al container non deve arrivare
|
|
97
111
|
// NESSUNA credenziale, nemmeno di seconda mano.
|
|
@@ -101,10 +115,23 @@ function isStrippedRequestHeader(key) {
|
|
|
101
115
|
return false;
|
|
102
116
|
}
|
|
103
117
|
|
|
118
|
+
// ELENCO CHIUSO di cio' che si inoltra, non di cio' che si toglie: un valore
|
|
119
|
+
// sconosciuto non deve poter passare per omissione. Sono gli schemi con cui un
|
|
120
|
+
// server web chiede le credenziali al browser — quelli che l'utente puo' aver
|
|
121
|
+
// inserito in risposta a un `401` del pannello. Tutto il resto, `Bearer`
|
|
122
|
+
// compreso, resta di qua.
|
|
123
|
+
const SCHEMI_DEL_PANNELLO = /^(basic|digest|ntlm|negotiate)\s/i;
|
|
124
|
+
|
|
125
|
+
function credenzialeDelPannello(value) {
|
|
126
|
+
const primo = Array.isArray(value) ? value[0] : value;
|
|
127
|
+
if (typeof primo !== 'string') return false;
|
|
128
|
+
return SCHEMI_DEL_PANNELLO.test(primo.trim());
|
|
129
|
+
}
|
|
130
|
+
|
|
104
131
|
function forwardHeaders(headers, targetHost) {
|
|
105
132
|
const out = {};
|
|
106
133
|
for (const [k, v] of Object.entries(headers || {})) {
|
|
107
|
-
if (isStrippedRequestHeader(k.toLowerCase())) continue;
|
|
134
|
+
if (isStrippedRequestHeader(k.toLowerCase(), v)) continue;
|
|
108
135
|
out[k] = v;
|
|
109
136
|
}
|
|
110
137
|
// L'host deve essere quello della destinazione: un pannello che genera
|
package/lib/server.js
CHANGED
|
@@ -19,6 +19,7 @@ const { loadOrCreateToken, verify } = require('./auth/token.js');
|
|
|
19
19
|
const { requireToken, bearerFrom } = require('./auth/middleware.js');
|
|
20
20
|
const { filesRoutes } = require('./files/routes.js');
|
|
21
21
|
const { createOutboxWatcher } = require('./files/watcher.js');
|
|
22
|
+
const { leggiTelemetria } = require('./files/telemetry.js');
|
|
22
23
|
const VERSION = require('../package.json').version;
|
|
23
24
|
const { transcribe } = require('./voice/transcribe.js');
|
|
24
25
|
const { selectProvider } = require('./fleet/provider.js');
|
|
@@ -753,6 +754,10 @@ function createServer(opts = {}) {
|
|
|
753
754
|
status: working ? (s.status || sample?.status || '') : '',
|
|
754
755
|
outbox: sum[s.name] || { count: 0, latest: 0 },
|
|
755
756
|
preview: sample?.preview ?? null,
|
|
757
|
+
// Contesto libero e tier usati, dove la cella li pubblica (celle
|
|
758
|
+
// Claude via statusline). Null per tutte le altre — assenza
|
|
759
|
+
// legittima, non un trattino: la riga resta com'era.
|
|
760
|
+
telemetry: leggiTelemetria(cfg.filesRoot, s.name),
|
|
756
761
|
};
|
|
757
762
|
}));
|
|
758
763
|
res.json({ sessions: enriched });
|
package/package.json
CHANGED
package/skills/live/SKILL.md
CHANGED
|
@@ -83,6 +83,14 @@ owning node, not a retry from the requester.
|
|
|
83
83
|
with nothing designated still answers `GET` with `hostCell: null` — a valid
|
|
84
84
|
state, distinct from "not permitted to ask".
|
|
85
85
|
|
|
86
|
+
## The per-cell voice prompt
|
|
87
|
+
|
|
88
|
+
Once a session lands on a host cell, a native Live (engine `codex-vl*`)
|
|
89
|
+
always sends `thread/start` an identity header naming the designated cell
|
|
90
|
+
and its exact tmux session, and appends a per-cell prompt file's text after
|
|
91
|
+
it when one exists. See [The Live per-cell prompt](../../docs/LIVE_PROMPT.md)
|
|
92
|
+
for where the file goes and the ready-to-copy IT/EN/ES templates.
|
|
93
|
+
|
|
86
94
|
## Dependencies
|
|
87
95
|
|
|
88
96
|
**Bundled:** this is a NexusCrew core feature. No external MCP companion or
|