@mmmbuto/nexuscrew 0.8.52-rc.9 → 0.8.53
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 +340 -0
- package/frontend/dist/assets/index-BJ-_5bxw.js +93 -0
- package/frontend/dist/assets/{index-_kotgT8Z.css → index-CYi_lhCg.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +11 -0
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +6 -1
- package/lib/cells/scope-guard.js +239 -0
- package/lib/cells/scope.js +144 -0
- package/lib/cli/commands.js +279 -4
- package/lib/fleet/builtin.js +129 -4
- package/lib/fleet/catalogs/alibaba-token-plan-qwen3.8.json +3 -3
- package/lib/fleet/definitions.js +128 -7
- package/lib/fleet/managed.js +229 -20
- package/lib/fleet/model-probe.js +129 -0
- package/lib/fleet/routes.js +27 -1
- package/lib/fleet/runtime.js +3 -3
- package/lib/mcp/server.js +35 -1
- package/lib/mcp/tools.js +67 -4
- package/lib/nodes/commands.js +86 -3
- package/lib/nodes/identity.js +232 -0
- package/lib/nodes/store.js +89 -0
- package/lib/notify/notifier.js +7 -0
- package/lib/notify/routes.js +75 -3
- package/lib/proxy/federation.js +100 -8
- package/lib/server.js +100 -4
- package/lib/settings/pairing-coordinator.js +16 -0
- package/lib/settings/public-peering-routes.js +24 -1
- package/lib/settings/routes.js +1 -1
- package/lib/tmux/actions.js +33 -1
- package/package.json +1 -1
- package/frontend/dist/assets/index-Cefi613r.js +0 -93
package/lib/notify/routes.js
CHANGED
|
@@ -20,8 +20,18 @@
|
|
|
20
20
|
const express = require('express');
|
|
21
21
|
const { isValidSession } = require('../files/store.js');
|
|
22
22
|
const { normalizeNotificationLang } = require('./language.js');
|
|
23
|
+
const { HOP_HEADER } = require('../proxy/hop-proof.js');
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const TARGET_RE = /^[a-f0-9]{32}$/i;
|
|
26
|
+
|
|
27
|
+
// `url` NON e' ammesso, ne' in locale ne' federato: sw.js fa
|
|
28
|
+
// clients.openWindow(url) sul click, quindi accettarlo da un peer sarebbe un
|
|
29
|
+
// open-redirect dentro la PWA autenticata. Il solo url legittimo lo genera in
|
|
30
|
+
// casa la route degli ask (deep-link /#ask=<id>), che non passa da qui.
|
|
31
|
+
const NOTIFY_KEYS = new Set(['title', 'body', 'urgency', 'session', 'lang', 'target']);
|
|
32
|
+
// Chiavi accettate SOLO su un ingresso federato provato: le mette il
|
|
33
|
+
// dispatcher del nodo di origine, non un chiamante locale.
|
|
34
|
+
const FEDERATED_KEYS = new Set(['originCell', 'originNode']);
|
|
25
35
|
const ASK_KEYS = new Set(['question', 'options', 'session']);
|
|
26
36
|
const RATE_MAX = 6;
|
|
27
37
|
const RATE_WINDOW_MS = 60 * 1000;
|
|
@@ -91,7 +101,14 @@ function replyLabel(cfg) {
|
|
|
91
101
|
return clean || 'human';
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
function notifyRoutes({
|
|
104
|
+
function notifyRoutes({
|
|
105
|
+
cfg, notifier, push, asks, paste, sessionExists,
|
|
106
|
+
// Federazione delle notifiche. Assenti (test unitari, montaggi parziali) la
|
|
107
|
+
// route resta esattamente quella locale di prima: nessun percorso nuovo si
|
|
108
|
+
// apre per omissione.
|
|
109
|
+
localNodeId = () => null, originResolver = null, acl = null, dispatcher = null,
|
|
110
|
+
federatedRate = null,
|
|
111
|
+
}) {
|
|
95
112
|
const r = express.Router();
|
|
96
113
|
const json = express.json({ limit: '16kb' });
|
|
97
114
|
|
|
@@ -110,8 +127,14 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
|
|
|
110
127
|
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
111
128
|
return res.status(400).json({ error: 'body deve essere un oggetto JSON' });
|
|
112
129
|
}
|
|
130
|
+
// Un ingresso e' federato solo se porta la prova di hop. Si stabilisce
|
|
131
|
+
// PRIMA di guardare il body: quali chiavi sono lecite dipende da come la
|
|
132
|
+
// richiesta e' arrivata, non da cosa dichiara.
|
|
133
|
+
const federated = !!(originResolver && req.headers && req.headers[HOP_HEADER]);
|
|
113
134
|
for (const k of Object.keys(b)) {
|
|
114
|
-
if (
|
|
135
|
+
if (NOTIFY_KEYS.has(k)) continue;
|
|
136
|
+
if (federated && FEDERATED_KEYS.has(k)) continue;
|
|
137
|
+
return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: title, body?, urgency?, session?, lang?, target?)` });
|
|
115
138
|
}
|
|
116
139
|
if (typeof b.title !== 'string' || !b.title.trim()) {
|
|
117
140
|
return res.status(400).json({ error: 'title deve essere una stringa non vuota' });
|
|
@@ -130,6 +153,55 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
|
|
|
130
153
|
if (b.session !== undefined && !isValidSession(b.session)) {
|
|
131
154
|
return res.status(400).json({ error: 'session non valida' });
|
|
132
155
|
}
|
|
156
|
+
if (b.target !== undefined && !TARGET_RE.test(String(b.target))) {
|
|
157
|
+
return res.status(400).json({ error: 'target deve essere un instanceId di nodo' });
|
|
158
|
+
}
|
|
159
|
+
const self = localNodeId();
|
|
160
|
+
|
|
161
|
+
// --- ingresso FEDERATO: la notifica arriva da un altro nodo -----------
|
|
162
|
+
if (federated) {
|
|
163
|
+
const resolved = await originResolver.resolve(req, { requireCell: true });
|
|
164
|
+
if (!resolved.ok) return res.status(403).json({ status: 'refused', reason: resolved.reason });
|
|
165
|
+
// Il target e' esatto e va confermato QUI: una route puo' consegnare a
|
|
166
|
+
// un nodo diverso da quello che il mittente credeva.
|
|
167
|
+
if (!self || b.target !== self) {
|
|
168
|
+
return res.status(404).json({ status: 'refused', reason: 'wrong-target' });
|
|
169
|
+
}
|
|
170
|
+
const verdict = acl ? acl.allows(resolved) : { allowed: false, reason: 'acl-unavailable' };
|
|
171
|
+
if (!verdict.allowed) return res.status(403).json({ status: 'refused', reason: verdict.reason });
|
|
172
|
+
// Budget SEPARATO da quello locale: senza, un peer rumoroso non solo
|
|
173
|
+
// spamma ma affama le notifiche delle celle di casa, che condividono
|
|
174
|
+
// lo stesso bucket da 6/60s.
|
|
175
|
+
if (federatedRate) {
|
|
176
|
+
const quota = federatedRate.check({ origin: resolved.origin, target: self, urgency: b.urgency });
|
|
177
|
+
if (!quota.allowed) {
|
|
178
|
+
return res.status(429).json({ status: 'refused', reason: `rate-${quota.bucket}` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const delivered = await notifier.emit({
|
|
182
|
+
title: b.title.trim(), body: b.body, urgency: b.urgency, lang,
|
|
183
|
+
// Il mittente NON e' `b.session`: quel campo lo dichiara il chiamante.
|
|
184
|
+
// Qui vale solo cio' che la catena ha provato, piu' la cella che il
|
|
185
|
+
// nodo di origine attesta.
|
|
186
|
+
originNode: resolved.origin.node,
|
|
187
|
+
originCell: resolved.origin.cell,
|
|
188
|
+
});
|
|
189
|
+
return res.json({ status: 'delivered', delivered });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- target remoto: instrada, non consegnare qui -----------------------
|
|
193
|
+
if (b.target !== undefined && dispatcher && self && b.target !== self) {
|
|
194
|
+
const out = await dispatcher.dispatch({
|
|
195
|
+
resource: '/notify',
|
|
196
|
+
target: b.target,
|
|
197
|
+
// La cella di origine e' quella DICHIARATA dal chiamante locale: viene
|
|
198
|
+
// trasmessa come attestazione, e il target la trattera' come tale.
|
|
199
|
+
origin: { node: self, cell: b.session || 'unknown' },
|
|
200
|
+
payload: { title: b.title.trim(), ...(b.body ? { body: b.body } : {}), ...(b.urgency ? { urgency: b.urgency } : {}), ...(lang ? { lang } : {}) },
|
|
201
|
+
});
|
|
202
|
+
return res.json(out);
|
|
203
|
+
}
|
|
204
|
+
|
|
133
205
|
const sender = b.session || 'unknown';
|
|
134
206
|
if (!allowNotify(sender)) {
|
|
135
207
|
return res.status(429).json({ error: 'rate limit notify superato (limite globale per token + per sessione)' });
|
package/lib/proxy/federation.js
CHANGED
|
@@ -60,6 +60,22 @@ function classifyShareFailure(health) {
|
|
|
60
60
|
return { code: 'share-peer-unreachable', detail };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Porta del canale reverse realmente in gioco, vista DALL'HUB: lo slot attivo
|
|
64
|
+
// del pool quando c'e', altrimenti la `localPort` del peer — che e' il punto
|
|
65
|
+
// su cui l'hub sonda davvero (cfr. nodes test, ramo inbound). NON
|
|
66
|
+
// `reversePort`: quello e' il campo negoziato che vive sul CLIENT, e sull'hub
|
|
67
|
+
// e' assente. Il test del comportamento ha trovato questo scambio; il valore
|
|
68
|
+
// e' il dato che manca per capire un rifiuto, quindi sbagliarlo lo renderebbe
|
|
69
|
+
// peggio che inutile, mandando a cercare sulla porta sbagliata.
|
|
70
|
+
function activeReversePort(peer) {
|
|
71
|
+
const pool = peer && peer.reversePool;
|
|
72
|
+
if (pool && Array.isArray(pool.slots)) {
|
|
73
|
+
const slot = pool.slots[pool.activeSlot];
|
|
74
|
+
if (slot && Number.isInteger(slot.port)) return slot.port;
|
|
75
|
+
}
|
|
76
|
+
return peer && Number.isInteger(peer.localPort) ? peer.localPort : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function peerFromToken(nodesPath, token) {
|
|
64
80
|
const st = store.loadStore(nodesPath);
|
|
65
81
|
if (!st || !token) return null;
|
|
@@ -151,11 +167,25 @@ function knownResource(resource) {
|
|
|
151
167
|
// ADMITS a further node, so leaving it federated would let a peer break the
|
|
152
168
|
// very boundary the owner chose. Minting an invite is done on the hub.
|
|
153
169
|
|| resource === '/ws'
|
|
170
|
+
// Un avviso all'operatore attraversa la federazione con la stessa spina
|
|
171
|
+
// dorsale dell'audio: origine provata dalla catena `visited`, ACL del
|
|
172
|
+
// target, budget dedicato. NON e' la coda di NC-NEXT-07 (§5 del modello di
|
|
173
|
+
// autorita'): quella differisce un'esecuzione, questo mostra del testo e
|
|
174
|
+
// non esegue nulla. `/audio/speak` esce da un altoparlante in una stanza
|
|
175
|
+
// fisica ed e' gia' federato: una notifica e' meno invasiva di cosi'.
|
|
176
|
+
|| resource === '/notify'
|
|
154
177
|
|| resource === '/audio/capability'
|
|
155
178
|
|| resource === '/audio/speak'
|
|
156
179
|
|| resource === '/audio/speak/status'
|
|
157
180
|
|| resource === '/audio/stop'
|
|
158
|
-
|
|
181
|
+
// I modelli dichiarati seguono gli engine, perche' sono la stessa cosa
|
|
182
|
+
// vista da un'altra angolazione: un engine gestito NON parte se il suo id
|
|
183
|
+
// di modello non e' nel catalogo, e un modello dichiarato e' cio' che
|
|
184
|
+
// estende quel catalogo. `define-engine` federato + `define-model` locale
|
|
185
|
+
// significava poter creare a distanza un engine che non si puo' rendere
|
|
186
|
+
// avviabile a distanza — un'asimmetria che rompeva la modifica remota a
|
|
187
|
+
// meta' strada, senza proteggere nulla.
|
|
188
|
+
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
159
189
|
}
|
|
160
190
|
|
|
161
191
|
function allowedResource(resource, method = 'GET') {
|
|
@@ -187,7 +217,16 @@ function allowedResource(resource, method = 'GET') {
|
|
|
187
217
|
if (resource === '/diagnostics/verbose') return method === 'PATCH';
|
|
188
218
|
if (resource === '/ws') return method === 'GET';
|
|
189
219
|
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
|
|
190
|
-
|
|
220
|
+
// `model-test` e' un POST che non muta niente: interroga il CATALOGO del
|
|
221
|
+
// fornitore (`GET .../models`) con la credenziale di quel nodo e ne ricava un
|
|
222
|
+
// enum. Non genera testo, quindi non consuma token. Resta comunque una
|
|
223
|
+
// mutazione ai fini di READONLY (sotto): un nodo dichiarato di sola lettura
|
|
224
|
+
// non emette richieste autenticate su comando di un peer.
|
|
225
|
+
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
|
226
|
+
// Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
|
|
227
|
+
// SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
|
|
228
|
+
// e' un paste nel tmux locale, quindi non attraverserebbe comunque.
|
|
229
|
+
if (resource === '/notify') return method === 'POST';
|
|
191
230
|
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
192
231
|
// audio.consent is a LOCAL mutation and MUST stay unreachable federated.
|
|
193
232
|
if (resource === '/audio/capability') return method === 'GET';
|
|
@@ -312,8 +351,35 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
|
|
|
312
351
|
} catch (e) {
|
|
313
352
|
out.transport = 'down';
|
|
314
353
|
out.status = 'down';
|
|
315
|
-
|
|
316
|
-
|
|
354
|
+
// DUE GUASTI DIVERSI, e prima avevano lo stesso messaggio. Con un canale
|
|
355
|
+
// inverso SSH il listener sull'hub e' sshd: se il dispositivo non e'
|
|
356
|
+
// connesso non c'e' nessun listener e la connessione viene RIFIUTATA; se
|
|
357
|
+
// invece il tunnel regge ma NexusCrew sul dispositivo e' morto, sshd
|
|
358
|
+
// accetta, inoltra, e la connessione viene AZZERATA dall'altro capo.
|
|
359
|
+
//
|
|
360
|
+
// MISURATO il 2026-08-07 sui tre casi reali contemporaneamente presenti:
|
|
361
|
+
// tunnel su + servizio giu' -> ECONNRESET
|
|
362
|
+
// nessun listener -> ECONNREFUSED
|
|
363
|
+
// tutto su -> HTTP (401)
|
|
364
|
+
//
|
|
365
|
+
// Perche' vale la pena distinguerli: «peer non raggiungibile» ha mandato
|
|
366
|
+
// l'indagine nella federazione e nel pairing per quattro ore, mentre il
|
|
367
|
+
// difetto era un servizio che non era ripartito sul dispositivo. Sono due
|
|
368
|
+
// guasti con due rimedi diversi — uno si risolve sulla rete, l'altro
|
|
369
|
+
// andando sul dispositivo — e un messaggio unico li fa cercare nel posto
|
|
370
|
+
// sbagliato la meta' delle volte.
|
|
371
|
+
const codice = (e && (e.code || (e.cause && e.cause.code))) || '';
|
|
372
|
+
if (e && (e.name === 'AbortError' || codice === 'ETIMEDOUT')) {
|
|
373
|
+
out.detail = `peer non raggiungibile (timeout ${timeoutMs}ms)`;
|
|
374
|
+
} else if (codice === 'ECONNREFUSED') {
|
|
375
|
+
out.detail = `canale inverso non attivo sulla porta ${port}: il dispositivo non e' connesso`;
|
|
376
|
+
out.layer = 'tunnel';
|
|
377
|
+
} else if (codice === 'ECONNRESET' || codice === 'ECONNABORTED' || codice === 'EPIPE') {
|
|
378
|
+
out.detail = `canale inverso attivo sulla porta ${port}, ma NexusCrew non risponde sul dispositivo`;
|
|
379
|
+
out.layer = 'service';
|
|
380
|
+
} else {
|
|
381
|
+
out.detail = 'peer non raggiungibile (tcp refused/down)';
|
|
382
|
+
}
|
|
317
383
|
return out;
|
|
318
384
|
} finally {
|
|
319
385
|
if (timer) clearTimeout(timer);
|
|
@@ -806,7 +872,7 @@ async function probeReverseOwner(peer, fetchImpl = fetch) {
|
|
|
806
872
|
});
|
|
807
873
|
}
|
|
808
874
|
|
|
809
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
|
|
875
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null }) {
|
|
810
876
|
const r = express.Router();
|
|
811
877
|
r.use((req, res, next) => {
|
|
812
878
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
@@ -1042,6 +1108,21 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1042
1108
|
// guasto definitivo senza interpretare una stringa. Solo il canale
|
|
1043
1109
|
// non ancora salito e' ritentabile.
|
|
1044
1110
|
const failure = classifyShareFailure(health);
|
|
1111
|
+
// L'hub sa di aver rifiutato e sa perche', e finora non lo scriveva
|
|
1112
|
+
// da nessuna parte: l'errore viveva solo nel toast del dispositivo,
|
|
1113
|
+
// che e' l'unico posto dove chi amministra l'hub non puo' guardarlo.
|
|
1114
|
+
// Un rifiuto ripetuto diventava cosi' un mistero — e' costato ore su
|
|
1115
|
+
// un caso reale, risolto solo leggendo i log di sshd con i privilegi
|
|
1116
|
+
// di root. Meta bounded: nome nodo, codice tipizzato, porta tentata.
|
|
1117
|
+
// Nessun testo remoto, nessuna credenziale.
|
|
1118
|
+
if (diagnostics && typeof diagnostics.record === 'function') {
|
|
1119
|
+
diagnostics.record('warn', 'share', 'SHARE_CHANNEL_REFUSED',
|
|
1120
|
+
'Share refused: the reverse channel did not come up', {
|
|
1121
|
+
node: req.peer.name,
|
|
1122
|
+
code: failure.code,
|
|
1123
|
+
port: activeReversePort(req.peer),
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1045
1126
|
return res.status(409).json({
|
|
1046
1127
|
error: 'canale share non raggiungibile',
|
|
1047
1128
|
code: failure.code,
|
|
@@ -1073,7 +1154,7 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
|
|
|
1073
1154
|
return r;
|
|
1074
1155
|
}
|
|
1075
1156
|
|
|
1076
|
-
function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
|
|
1157
|
+
function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
|
|
1077
1158
|
if (readonly()) return reject(socket, 403);
|
|
1078
1159
|
const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
|
|
1079
1160
|
if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
|
|
@@ -1092,7 +1173,18 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1092
1173
|
}
|
|
1093
1174
|
const up = net.connect({ host: '127.0.0.1', port });
|
|
1094
1175
|
up.once('connect', () => {
|
|
1095
|
-
|
|
1176
|
+
// Ultimo hop: l'upgrade rientra nell'API locale col Bearer locale e da li'
|
|
1177
|
+
// in poi sarebbe indistinguibile da un attach diretto di chi possiede quel
|
|
1178
|
+
// token. La catena PIU' la prova di hop sono cio' che lo distingue —
|
|
1179
|
+
// esattamente come fa routeHandler per il percorso HTTP. Senza, il nodo che
|
|
1180
|
+
// possiede la sessione non sa quale peer stia aprendo il suo PTY, e ogni
|
|
1181
|
+
// permesso per-cella diventa decorativo: `/ws` attacca per NOME di
|
|
1182
|
+
// sessione, quindi basta indovinarlo.
|
|
1183
|
+
const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
|
|
1184
|
+
const hopProof = !parsed.route.length && secret
|
|
1185
|
+
? signHop(secret, { method: req.method || 'GET', path, visited })
|
|
1186
|
+
: null;
|
|
1187
|
+
const headers = cleanHeaders(req.headers, credential, visited, hopProof);
|
|
1096
1188
|
const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
|
|
1097
1189
|
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
1098
1190
|
lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
|
|
@@ -1110,7 +1202,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1110
1202
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
1111
1203
|
|
|
1112
1204
|
module.exports = {
|
|
1113
|
-
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure,
|
|
1205
|
+
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
1114
1206
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
1115
1207
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
1116
1208
|
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
package/lib/server.js
CHANGED
|
@@ -39,12 +39,20 @@ const { isConsent: isAudioConsent } = require('./audio/consent.js');
|
|
|
39
39
|
const { createOriginResolver } = require('./audio/origin.js');
|
|
40
40
|
const { createAudioAcl } = require('./audio/acl.js');
|
|
41
41
|
const { createDispatcher } = require('./audio/dispatch.js');
|
|
42
|
+
const { createCellScopeGuard } = require('./cells/scope-guard.js');
|
|
43
|
+
const { createCellScope } = require('./cells/scope.js');
|
|
44
|
+
const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
|
|
45
|
+
// Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
|
|
46
|
+
// limitare il danno di un nodo che mente sulla propria cella (il tetto per
|
|
47
|
+
// target si calcola sul NODO, che la catena visited verifica).
|
|
48
|
+
const { createSpeakRateLimiter } = require('./audio/rate-limit.js');
|
|
42
49
|
const { createSpeakQueue } = require('./audio/queue.js');
|
|
43
50
|
const { createReceiptStore } = require('./audio/receipt.js');
|
|
44
51
|
const audioAdapters = require('./audio/adapters.js');
|
|
45
52
|
const audioGroups = require('./audio/groups.js');
|
|
46
53
|
const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
|
|
47
|
-
const { createHopSecret } = require('./proxy/hop-proof.js');
|
|
54
|
+
const { createHopSecret, verifyHop, HOP_HEADER } = require('./proxy/hop-proof.js');
|
|
55
|
+
const { parseVisitedChain } = require('./audio/origin.js');
|
|
48
56
|
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
49
57
|
const decksStore = require('./decks/store.js');
|
|
50
58
|
const { decksRoutes } = require('./decks/routes.js');
|
|
@@ -105,6 +113,10 @@ function createServer(opts = {}) {
|
|
|
105
113
|
// alla porta sbagliata (bug reale: 401/"unauthorized" dal servizio su cfg.port).
|
|
106
114
|
let server = null;
|
|
107
115
|
const previews = createPreviewSampler(cfg.tmuxBin);
|
|
116
|
+
// Seam di test per l'esistenza di una sessione: senza, un test sullo scope
|
|
117
|
+
// dell'attach non discrimina, perche' ogni sessione risulta assente e il
|
|
118
|
+
// rifiuto arriverebbe comunque — un verde che non prova nulla.
|
|
119
|
+
const sessionExistsImpl = cfg.sessionExistsSeam || ((name) => sessionExists(cfg.tmuxBin, name));
|
|
108
120
|
// MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
|
|
109
121
|
// tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
|
|
110
122
|
// nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
|
|
@@ -541,6 +553,51 @@ function createServer(opts = {}) {
|
|
|
541
553
|
// ma il token chiude anche altri processi locali della stessa macchina.
|
|
542
554
|
const api = express.Router();
|
|
543
555
|
api.use(requireToken(tokenStore));
|
|
556
|
+
// Origine per lo scope celle: stessa prova di hop usata da audio e notify.
|
|
557
|
+
// requireCell:false perche' qui interessa QUALE NODO parla, non quale cella
|
|
558
|
+
// dichiari — la cella e' solo attestata e non deve decidere un permesso.
|
|
559
|
+
const cellScopeOrigin = createOriginResolver({
|
|
560
|
+
localNodeId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
561
|
+
hopSecret: () => hopSecret,
|
|
562
|
+
});
|
|
563
|
+
const cellScoper = createCellScope({
|
|
564
|
+
nodesPath,
|
|
565
|
+
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
566
|
+
});
|
|
567
|
+
// Variante SINCRONA per l'upgrade WebSocket: verifica la prova di hop e la
|
|
568
|
+
// catena senza passare dal resolver async. Un upgrade non federato — o con
|
|
569
|
+
// una prova che non regge — resta senza restrizioni, esattamente come una
|
|
570
|
+
// richiesta locale: la prova la costruisce il proxy, un peer non puo' ne'
|
|
571
|
+
// toglierla ne' falsificarla.
|
|
572
|
+
const wsCellScope = (req) => {
|
|
573
|
+
try {
|
|
574
|
+
const headers = (req && req.headers) || {};
|
|
575
|
+
const proof = headers[HOP_HEADER];
|
|
576
|
+
if (!proof) return cellScoper.resolve({ trust: 'local-bridge' });
|
|
577
|
+
const self = (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
578
|
+
const visited = parseVisitedChain(headers['x-nexuscrew-visited'], self);
|
|
579
|
+
if (!visited) return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
580
|
+
const ok = verifyHop(hopSecret, {
|
|
581
|
+
method: req.method || 'GET', path: '/ws', visited,
|
|
582
|
+
}, proof);
|
|
583
|
+
if (!ok) return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
584
|
+
return cellScoper.resolve({ trust: 'federated', visited });
|
|
585
|
+
} catch (_) {
|
|
586
|
+
// Fail-closed: se non si riesce a stabilire l'origine, nessuna sessione.
|
|
587
|
+
return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
// Scope celle (NC-E): un punto solo decide cosa un peer federato vede e puo'
|
|
591
|
+
// toccare. Sta qui, prima di ogni route, perche' i canali da cui una cella
|
|
592
|
+
// trapela sono molti e crescono: filtrarli uno per uno significa dimenticarne
|
|
593
|
+
// uno, e un permesso che vale su quasi tutti i canali non e' un permesso.
|
|
594
|
+
// Le richieste locali non lo attraversano: il proprietario della macchina non
|
|
595
|
+
// si limita da solo.
|
|
596
|
+
api.use(createCellScopeGuard({
|
|
597
|
+
nodesPath,
|
|
598
|
+
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
599
|
+
resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
|
|
600
|
+
}));
|
|
544
601
|
api.get('/sessions', async (_req, res) => {
|
|
545
602
|
try {
|
|
546
603
|
const sessions = await listSessions(cfg.tmuxBin);
|
|
@@ -615,6 +672,19 @@ function createServer(opts = {}) {
|
|
|
615
672
|
}));
|
|
616
673
|
// MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
|
|
617
674
|
// del router /api; gate READONLY sui mutanti dentro notifyRoutes.
|
|
675
|
+
// Identita' e instradamento condivisi con l'audio: stessa fonte (node store),
|
|
676
|
+
// stesso segreto di hop, stessa inventory. Due modelli di provenienza
|
|
677
|
+
// divergenti sarebbero un bug latente.
|
|
678
|
+
const federatedNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
679
|
+
const federatedPeers = async () => {
|
|
680
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
681
|
+
if (!st) return [];
|
|
682
|
+
const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
|
|
683
|
+
return nodesInventory.buildInventory({
|
|
684
|
+
direct: nodesStore.redactStore(st).nodes,
|
|
685
|
+
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
686
|
+
});
|
|
687
|
+
};
|
|
618
688
|
api.use(notifyRoutes({
|
|
619
689
|
cfg,
|
|
620
690
|
notifier,
|
|
@@ -622,6 +692,22 @@ function createServer(opts = {}) {
|
|
|
622
692
|
asks: asksStore,
|
|
623
693
|
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
624
694
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
695
|
+
localNodeId: federatedNodeId,
|
|
696
|
+
// requireCell resta true: una notifica federata porta sempre una cella
|
|
697
|
+
// attestata, che serve ad attribuirla e a calcolarne il budget.
|
|
698
|
+
originResolver: createOriginResolver({
|
|
699
|
+
localNodeId: federatedNodeId,
|
|
700
|
+
hopSecret: () => hopSecret,
|
|
701
|
+
}),
|
|
702
|
+
acl: createAudioAcl({ nodesPath }),
|
|
703
|
+
dispatcher: createDispatcher({
|
|
704
|
+
localNodeId: federatedNodeId,
|
|
705
|
+
peers: federatedPeers,
|
|
706
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
707
|
+
localToken: () => tokenHolder.value,
|
|
708
|
+
statuses: new Set(['delivered', 'refused', 'unreachable', 'unknown']),
|
|
709
|
+
}),
|
|
710
|
+
federatedRate: createSpeakRateLimiter(),
|
|
625
711
|
}));
|
|
626
712
|
api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
|
|
627
713
|
// Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
|
|
@@ -787,6 +873,7 @@ function createServer(opts = {}) {
|
|
|
787
873
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
788
874
|
fetchImpl: healthFetch, hopSecret: () => hopSecret,
|
|
789
875
|
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
876
|
+
diagnostics,
|
|
790
877
|
}));
|
|
791
878
|
|
|
792
879
|
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
@@ -868,13 +955,13 @@ function createServer(opts = {}) {
|
|
|
868
955
|
let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
|
|
869
956
|
const given = bearerFrom(req) || u.searchParams.get('token') || '';
|
|
870
957
|
if (!verify(tokenHolder.value, given)) return socket.destroy();
|
|
871
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
958
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
872
959
|
return;
|
|
873
960
|
}
|
|
874
961
|
if (pathname.startsWith('/federation/route/')) {
|
|
875
962
|
const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
|
|
876
963
|
if (!ingress) return socket.destroy();
|
|
877
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
964
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
878
965
|
return;
|
|
879
966
|
}
|
|
880
967
|
if (pathname === '/node' || pathname.startsWith('/node/')) {
|
|
@@ -900,10 +987,19 @@ function createServer(opts = {}) {
|
|
|
900
987
|
// non possono settare header sui WS -> il flusso locale resta identico
|
|
901
988
|
// (token nel frame attach, mai in URL).
|
|
902
989
|
const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
|
|
990
|
+
// Scope celle sull'ATTACH. Calcolato in modo sincrono di proposito: rendere
|
|
991
|
+
// async questo handler rischierebbe di perdere i frame che arrivano prima
|
|
992
|
+
// di bindWs. `/ws` attacca per NOME DI SESSIONE e senza questo gate ogni
|
|
993
|
+
// filtro sugli elenchi sarebbe decorativo — basterebbe indovinare
|
|
994
|
+
// `cloud-Dev`.
|
|
995
|
+
const wsScope = wsCellScope(req);
|
|
903
996
|
bindWs(ws, {
|
|
904
997
|
openAttach,
|
|
905
998
|
verifyToken: (t) => preauth || verify(tokenHolder.value, t),
|
|
906
|
-
|
|
999
|
+
// Una sessione fuori scope viene trattata come inesistente: il client
|
|
1000
|
+
// riceve 4404, lo stesso codice di una sessione che non c'e'. Dire "esiste
|
|
1001
|
+
// ma non puoi" rivelerebbe proprio cio' che lo scope nasconde.
|
|
1002
|
+
isValidSession: (name) => sessionExistsImpl(name) && wsScope.allowsSession(name),
|
|
907
1003
|
runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
|
|
908
1004
|
countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
|
|
909
1005
|
defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
|
|
@@ -32,6 +32,7 @@ const os = require('node:os');
|
|
|
32
32
|
const crypto = require('node:crypto');
|
|
33
33
|
|
|
34
34
|
const nodesStore = require('../nodes/store.js');
|
|
35
|
+
const identity = require('../nodes/identity.js');
|
|
35
36
|
const nodesCmds = require('../nodes/commands.js');
|
|
36
37
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
37
38
|
const peering = require('../nodes/peering.js');
|
|
@@ -223,6 +224,15 @@ function createPairHandler(deps) {
|
|
|
223
224
|
// Pairing establishes a private client-to-hub connection. Sharing
|
|
224
225
|
// this device back through the hub is a separate explicit action.
|
|
225
226
|
shared: false,
|
|
227
|
+
// La nostra pubblica, passo 1 del modello di autorita'. Se non si
|
|
228
|
+
// riesce a leggerla si accoppia senza: il passo 1 non ha effetti, e
|
|
229
|
+
// un guasto sulla chiave non deve togliere la capacita' di
|
|
230
|
+
// accoppiare un dispositivo.
|
|
231
|
+
...(() => {
|
|
232
|
+
try {
|
|
233
|
+
return { publicKey: identity.ensureNodeKey({ keyPath: identity.keyPathNextTo(nodesPath) }).publicKey };
|
|
234
|
+
} catch (_) { return {}; }
|
|
235
|
+
})(),
|
|
226
236
|
roles: readRoles(configPath),
|
|
227
237
|
}),
|
|
228
238
|
});
|
|
@@ -295,6 +305,12 @@ function createPairHandler(deps) {
|
|
|
295
305
|
st = nodesStore.updateNode(st, b.name, {
|
|
296
306
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
297
307
|
shared: false,
|
|
308
|
+
// L'altra meta' dello scambio. Un hub piu' vecchio non manda la sua
|
|
309
|
+
// pubblica e il pairing riesce lo stesso — nessun campo, nessun
|
|
310
|
+
// cambiamento di comportamento.
|
|
311
|
+
...(identity.isPublicKey(joined.publicKey)
|
|
312
|
+
? { publicKey: joined.publicKey, keySource: 'pairing', keyBoundAt: new Date().toISOString() }
|
|
313
|
+
: {}),
|
|
298
314
|
...(joinedPool ? { reversePool: joinedPool } : {}),
|
|
299
315
|
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
300
316
|
});
|
|
@@ -13,6 +13,7 @@ const express = require('express');
|
|
|
13
13
|
|
|
14
14
|
const nodesStore = require('../nodes/store.js');
|
|
15
15
|
const peering = require('../nodes/peering.js');
|
|
16
|
+
const identity = require('../nodes/identity.js');
|
|
16
17
|
const reversePool = require('../nodes/reverse-pool.js');
|
|
17
18
|
const { readRoles } = require('../cli/commands.js');
|
|
18
19
|
const { configJsonPath } = require('../config.js');
|
|
@@ -27,6 +28,15 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
27
28
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
28
29
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
29
30
|
const reversePoolLedgerPath = deps.reversePoolLedgerPath || cfg.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
|
|
31
|
+
// La pubblica di questa installazione, per il passo 1 del modello di
|
|
32
|
+
// autorita'. Si legge PIGRAMENTE e non all'avvio del router: un errore sul
|
|
33
|
+
// file di chiave non deve impedire di montare le route di pairing — il passo
|
|
34
|
+
// 1 non ha effetti, e «non riesco a leggere la mia chiave» non e' una buona
|
|
35
|
+
// ragione per non poter piu' accoppiare un dispositivo.
|
|
36
|
+
const localPublicKey = () => {
|
|
37
|
+
try { return identity.ensureNodeKey({ keyPath: identity.keyPathNextTo(nodesPath) }).publicKey; }
|
|
38
|
+
catch (_) { return null; }
|
|
39
|
+
};
|
|
30
40
|
const r = express.Router();
|
|
31
41
|
const attempts = new Map();
|
|
32
42
|
r.use(express.json({ limit: '8kb' }));
|
|
@@ -150,15 +160,23 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
150
160
|
name, remotePort: b.port, reversePort, reversePool: assignedPool, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
151
161
|
shared: false,
|
|
152
162
|
label: nodesStore.sanitizeLabel(b.label, name),
|
|
163
|
+
// La pubblica del peer, se la manda. Un peer piu' vecchio non la manda
|
|
164
|
+
// e si accoppia come sempre: il passo 1 osserva, non pretende.
|
|
165
|
+
...(identity.isPublicKey(b.publicKey) ? { publicKey: b.publicKey } : {}),
|
|
153
166
|
...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
|
|
154
167
|
} });
|
|
155
168
|
if (!peering.consumeInvite({ invitesPath, invite: b.invite, now })) {
|
|
156
169
|
peering.consumePending({ pendingPath, credential, now });
|
|
157
170
|
return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
158
171
|
}
|
|
172
|
+
const mia = localPublicKey();
|
|
159
173
|
res.json({ paired: true, instanceId: poolStore.nodeId, reversePort,
|
|
160
174
|
reversePool: { base: assignedPool.base, slots: assignedPool.slots.map((slot) => slot.port) },
|
|
161
|
-
credential, roles: readRoles(configPath)
|
|
175
|
+
credential, roles: readRoles(configPath),
|
|
176
|
+
// Lo scambio e' simmetrico e avviene QUI, dentro l'atto che consuma
|
|
177
|
+
// l'invito monouso: e' il solo momento in cui l'operatore ha deciso,
|
|
178
|
+
// su entrambe le macchine, che questi due nodi si conoscono.
|
|
179
|
+
...(mia ? { publicKey: mia } : {}) });
|
|
162
180
|
} catch (e) {
|
|
163
181
|
if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
|
|
164
182
|
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
|
|
@@ -188,6 +206,11 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
188
206
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
189
207
|
visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
|
|
190
208
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
209
|
+
// Legata al pairing, non asserita: e' `keySource` a dire la differenza,
|
|
210
|
+
// e al passo 3 un grant potra' pretendere questa e non l'altra.
|
|
211
|
+
...(identity.isPublicKey(pending.publicKey)
|
|
212
|
+
? { publicKey: pending.publicKey, keySource: 'pairing', keyBoundAt: new Date().toISOString() }
|
|
213
|
+
: {}),
|
|
191
214
|
...(pending.roles ? { roles: pending.roles } : {}),
|
|
192
215
|
rolesKnown: pending.rolesKnown === true,
|
|
193
216
|
...(pending.label ? { label: pending.label } : {}),
|
package/lib/settings/routes.js
CHANGED
|
@@ -72,7 +72,7 @@ const { publicPeeringRoutes } = require('./public-peering-routes.js');
|
|
|
72
72
|
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
|
|
73
73
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
74
74
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
75
|
-
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
|
|
75
|
+
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected', 'cellVisibility', 'cells']);
|
|
76
76
|
const LOCAL_AUDIO_TEST_TEXT = 'NexusCrew audio test.';
|
|
77
77
|
|
|
78
78
|
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
package/lib/tmux/actions.js
CHANGED
|
@@ -157,7 +157,39 @@ async function submitToSession(tmuxBin, session, text, opts = {}) {
|
|
|
157
157
|
// inside the composer. C-e is sent as its own tmux command after the paste,
|
|
158
158
|
// then the pane is revalidated before the separate Enter. Other clients
|
|
159
159
|
// still receive a short separation between paste and submit.
|
|
160
|
-
|
|
160
|
+
//
|
|
161
|
+
// NC-Q: L'ATTESA SCALA COL PAYLOAD, e prima era una costante. Il tempo che
|
|
162
|
+
// un TUI impiega a ingerire un bracketed paste cresce con la lunghezza —
|
|
163
|
+
// Claude Code sopra una certa taglia lo COLLASSA («paste again to expand»)
|
|
164
|
+
// — mentre l'attesa era fissa. Oltre quella soglia l'Enter arriva mentre il
|
|
165
|
+
// client sta ancora digerendo e viene mangiato: il testo resta nel
|
|
166
|
+
// composer, la cella non si muove, e il mittente riceve `submitted`. E'
|
|
167
|
+
// una perdita silenziosa, ed e' la peggiore: chi manda crede di aver
|
|
168
|
+
// consegnato.
|
|
169
|
+
//
|
|
170
|
+
// MISURATO il 2026-08-07, stesso bersaglio a 11 minuti di distanza: ~2900
|
|
171
|
+
// caratteri -> mai elaborato per NOVE ORE; ~60 caratteri -> in lavorazione
|
|
172
|
+
// dopo dodici secondi. Lo stesso era successo con `claude.zai-a` a 150 ms e
|
|
173
|
+
// con `codex-vl` a 400: la costante piu' alta non bastava, perche' il
|
|
174
|
+
// problema non e' quale costante ma che sia una costante.
|
|
175
|
+
//
|
|
176
|
+
// I numeri sono un'EURISTICA, non una misura del tempo di ingestione: 150
|
|
177
|
+
// ms ogni 500 caratteri oltre i primi 500, col vecchio valore come minimo.
|
|
178
|
+
// Il gradino usa `floor` di proposito: sotto i 500 caratteri non cambia
|
|
179
|
+
// NIENTE, perche' quei messaggi erano gia' affidabili e rallentarli sarebbe
|
|
180
|
+
// stato un costo pagato da tutti per un difetto che non li riguarda.
|
|
181
|
+
//
|
|
182
|
+
// Nessun tetto esplicito: il testo e' gia' limitato a MAX_SUBMIT, quindi
|
|
183
|
+
// l'attesa non puo' superare i ~2,5 s per costruzione. Un `Math.min` qui
|
|
184
|
+
// sarebbe un ramo che non si esegue mai — e un ramo irraggiungibile e' un
|
|
185
|
+
// pezzo di codice che promette di proteggere da qualcosa che non puo'
|
|
186
|
+
// accadere.
|
|
187
|
+
//
|
|
188
|
+
// Riduce di molto la finestra; non la chiude. `submitted` continua a
|
|
189
|
+
// significare paste+Enter, non accettazione — e resta vero che dopo un
|
|
190
|
+
// messaggio lungo conviene verificare che la cella si sia mossa.
|
|
191
|
+
const attesaBase = codexComposer ? 400 : 150;
|
|
192
|
+
await delay(attesaBase + Math.floor(text.length / 500) * 150);
|
|
161
193
|
if (!(await paneAlive())) return { submitted: false, reason: 'sessione terminata durante la consegna' };
|
|
162
194
|
if (codexComposer) {
|
|
163
195
|
const flush = await execTmux(execFileImpl, tmuxBin, ['send-keys', '-t', paneId, 'C-e']);
|
package/package.json
CHANGED