@mmmbuto/nexuscrew 0.8.52-rc.2 → 0.8.52-rc.21

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.
@@ -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;
@@ -111,19 +127,28 @@ function knownResource(resource) {
111
127
  //
112
128
  // These four were briefly removed on 2026-08-05 over `update_candidate`,
113
129
  // which names the URL a device fetches its own update from and accepts
114
- // `http:`. The removal was wrong on both counts. It was incoherent: a paired
115
- // peer is trusted as its owner (docs/SECURITY.md). And it was ineffective:
116
- // `/fleet/define-engine` + `/fleet/up` are federated, so a peer can already
117
- // define an arbitrary command and run it on the host — including one that
118
- // calls the local `/api/vl-nodes` endpoints. The door was shut next to an
119
- // open window.
130
+ // `http:`. The removal was wrong on two counts, one absolute and one
131
+ // conditional.
132
+ //
133
+ // Incoherent, always: a paired peer is trusted as its owner
134
+ // (docs/SECURITY.md), and singling out VL nodes made them the only
135
+ // unfederated resource in their own control plane.
136
+ //
137
+ // Ineffective, but ONLY where fleet is available: `/fleet/define-engine` +
138
+ // `/fleet/define-cell` + `/fleet/up` are federated, and a managed shell
139
+ // engine with a raw `commands[shell]` resolves to `bash -lic "<raw>"` — so
140
+ // a peer can already run anything on the host, including a call to these
141
+ // endpoints locally. VL is orthogonal to fleet: on an owner with VL enabled
142
+ // and `/fleet/*` unavailable that path does not exist, and federating these
143
+ // four DOES add capability. It stays within the trust model, so it is not a
144
+ // violation — but do not repeat "it grants nothing" without that condition.
120
145
  //
121
146
  // `update_candidate` is still a real defect. It belongs to the command, not
122
147
  // to this allowlist: bind the update channel to something the receiving
123
148
  // owner controls. Fixing it here only hid it.
124
149
  || resource === '/vl-nodes'
125
150
  || resource === '/vl-nodes/invite'
126
- || /^\/vl-nodes\/[a-f0-9]{32}(?:\/commands)?$/.test(resource)
151
+ || /^\/vl-nodes\/[a-f0-9]{32}(?:\/commands|\/events)?$/.test(resource)
127
152
  || resource === '/decks'
128
153
  || /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
129
154
  || resource === '/topology'
@@ -142,6 +167,13 @@ function knownResource(resource) {
142
167
  // ADMITS a further node, so leaving it federated would let a peer break the
143
168
  // very boundary the owner chose. Minting an invite is done on the hub.
144
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'
145
177
  || resource === '/audio/capability'
146
178
  || resource === '/audio/speak'
147
179
  || resource === '/audio/speak/status'
@@ -163,6 +195,10 @@ function allowedResource(resource, method = 'GET') {
163
195
  if (resource === '/vl-nodes') return method === 'GET';
164
196
  if (resource === '/vl-nodes/invite') return method === 'POST';
165
197
  if (/^\/vl-nodes\/[a-f0-9]{32}\/commands$/.test(resource)) return method === 'POST';
198
+ // Sola lettura del ring: la conversazione si vede da qualunque peer
199
+ // autorizzato, come ogni altra risorsa. GET e basta — questo canale non
200
+ // deve mai diventare un canale comandi implicito.
201
+ if (/^\/vl-nodes\/[a-f0-9]{32}\/events$/.test(resource)) return method === 'GET';
166
202
  if (/^\/vl-nodes\/[a-f0-9]{32}$/.test(resource)) return method === 'DELETE';
167
203
  if (resource === '/decks') return method === 'GET' || method === 'POST';
168
204
  if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
@@ -175,6 +211,10 @@ function allowedResource(resource, method = 'GET') {
175
211
  if (resource === '/ws') return method === 'GET';
176
212
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
177
213
  if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
214
+ // Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
215
+ // SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
216
+ // e' un paste nel tmux locale, quindi non attraverserebbe comunque.
217
+ if (resource === '/notify') return method === 'POST';
178
218
  // Audio: only capability read, speak and stop are exposed through Hydra.
179
219
  // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
180
220
  if (resource === '/audio/capability') return method === 'GET';
@@ -793,7 +833,7 @@ async function probeReverseOwner(peer, fetchImpl = fetch) {
793
833
  });
794
834
  }
795
835
 
796
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
836
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null }) {
797
837
  const r = express.Router();
798
838
  r.use((req, res, next) => {
799
839
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -1029,6 +1069,21 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
1029
1069
  // guasto definitivo senza interpretare una stringa. Solo il canale
1030
1070
  // non ancora salito e' ritentabile.
1031
1071
  const failure = classifyShareFailure(health);
1072
+ // L'hub sa di aver rifiutato e sa perche', e finora non lo scriveva
1073
+ // da nessuna parte: l'errore viveva solo nel toast del dispositivo,
1074
+ // che e' l'unico posto dove chi amministra l'hub non puo' guardarlo.
1075
+ // Un rifiuto ripetuto diventava cosi' un mistero — e' costato ore su
1076
+ // un caso reale, risolto solo leggendo i log di sshd con i privilegi
1077
+ // di root. Meta bounded: nome nodo, codice tipizzato, porta tentata.
1078
+ // Nessun testo remoto, nessuna credenziale.
1079
+ if (diagnostics && typeof diagnostics.record === 'function') {
1080
+ diagnostics.record('warn', 'share', 'SHARE_CHANNEL_REFUSED',
1081
+ 'Share refused: the reverse channel did not come up', {
1082
+ node: req.peer.name,
1083
+ code: failure.code,
1084
+ port: activeReversePort(req.peer),
1085
+ });
1086
+ }
1032
1087
  return res.status(409).json({
1033
1088
  error: 'canale share non raggiungibile',
1034
1089
  code: failure.code,
@@ -1060,7 +1115,7 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
1060
1115
  return r;
1061
1116
  }
1062
1117
 
1063
- function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
1118
+ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
1064
1119
  if (readonly()) return reject(socket, 403);
1065
1120
  const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
1066
1121
  if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
@@ -1079,7 +1134,18 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1079
1134
  }
1080
1135
  const up = net.connect({ host: '127.0.0.1', port });
1081
1136
  up.once('connect', () => {
1082
- const headers = cleanHeaders(req.headers, credential, parsed.route.length ? visited : null);
1137
+ // Ultimo hop: l'upgrade rientra nell'API locale col Bearer locale e da li'
1138
+ // in poi sarebbe indistinguibile da un attach diretto di chi possiede quel
1139
+ // token. La catena PIU' la prova di hop sono cio' che lo distingue —
1140
+ // esattamente come fa routeHandler per il percorso HTTP. Senza, il nodo che
1141
+ // possiede la sessione non sa quale peer stia aprendo il suo PTY, e ogni
1142
+ // permesso per-cella diventa decorativo: `/ws` attacca per NOME di
1143
+ // sessione, quindi basta indovinarlo.
1144
+ const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
1145
+ const hopProof = !parsed.route.length && secret
1146
+ ? signHop(secret, { method: req.method || 'GET', path, visited })
1147
+ : null;
1148
+ const headers = cleanHeaders(req.headers, credential, visited, hopProof);
1083
1149
  const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
1084
1150
  for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
1085
1151
  lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
@@ -1097,7 +1163,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1097
1163
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
1098
1164
 
1099
1165
  module.exports = {
1100
- MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure,
1166
+ MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
1101
1167
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
1102
1168
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
1103
1169
  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
- isValidSession: (name) => sessionExists(cfg.tmuxBin, name),
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 },
@@ -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).
@@ -39,6 +39,98 @@ function sanitizeHealth(raw) {
39
39
  };
40
40
  }
41
41
 
42
+ // --- eventi di sessione (passo 2) ------------------------------------------
43
+ // I kind ammessi sono una WHITELIST, non una blacklist: un kind sconosciuto
44
+ // cade fuori da solo. `tool_args` e `tool_result` non sono nell'elenco per una
45
+ // ragione precisa — contengono file, output di comandi e potenziali segreti, e
46
+ // viaggeranno solo su richiesta esplicita (passo 3). Non troncarli: escluderli.
47
+ const EVENT_KINDS = new Set([
48
+ 'text', 'thinking', 'tool_start', 'tool_end', 'usage',
49
+ 'turn_end', 'done', 'error', 'gap', 'truncate', 'writer_epoch',
50
+ ]);
51
+ // Questi non si perdono mai: senza di loro la UI mostra un turno che non
52
+ // finisce, indistinguibile da un nodo bloccato.
53
+ const TERMINAL_KINDS = new Set(['turn_end', 'done', 'error']);
54
+ // 16 KiB e non 64: il poll e' montato con express.json({ limit: '20kb' }) e
55
+ // oltre quel tetto la route risponde 413, cioe' il device si scollega.
56
+ const MAX_EVENTS_BYTES = 16_384;
57
+ const MAX_EVENT_TEXT = 4_096;
58
+ // Il ring vive in memoria e si perde al restart: la copia durevole e' il
59
+ // journal sul device. L'hub non accumula la conversazione nel tempo, e non la
60
+ // scrive su disco: la visibilita' e' viva, non e' un archivio.
61
+ const RING_MAX_EVENTS = 512;
62
+ const RING_MAX_BYTES = 262_144;
63
+
64
+ function sanitizeEvent(raw) {
65
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
66
+ if (!Number.isSafeInteger(raw.seq) || raw.seq < 0) return null;
67
+ const kind = typeof raw.kind === 'string' ? raw.kind : '';
68
+ if (!EVENT_KINDS.has(kind)) return null;
69
+ const event = { seq: raw.seq, kind };
70
+ const at = boundedInteger(raw.at, 0, Number.MAX_SAFE_INTEGER);
71
+ if (at) event.at = at;
72
+ const text = boundedString(raw.text, MAX_EVENT_TEXT);
73
+ if (text) event.text = text;
74
+ const name = boundedString(raw.name, 128);
75
+ if (name) event.name = name;
76
+ if (raw.isError === true) event.isError = true;
77
+ const count = boundedInteger(raw.count, 0, Number.MAX_SAFE_INTEGER);
78
+ if (count) event.count = count;
79
+ return event;
80
+ }
81
+
82
+ function sanitizeEvents(raw) {
83
+ if (!Array.isArray(raw)) return { events: [], dropped: 0 };
84
+ const clean = [];
85
+ for (const item of raw.slice(0, RING_MAX_EVENTS * 2)) {
86
+ const event = sanitizeEvent(item);
87
+ if (event) clean.push(event);
88
+ }
89
+ clean.sort((a, b) => a.seq - b.seq);
90
+
91
+ // I terminali sono messi da parte PRIMA di spendere il budget, cosi' un
92
+ // overflow di testo non puo' mangiarseli.
93
+ const terminals = clean.filter((e) => TERMINAL_KINDS.has(e.kind));
94
+ const rest = clean.filter((e) => !TERMINAL_KINDS.has(e.kind));
95
+ const reserve = JSON.stringify(terminals).length + 64;
96
+ const budget = MAX_EVENTS_BYTES - reserve;
97
+
98
+ const kept = [];
99
+ let used = 2;
100
+ let dropped = 0;
101
+ let firstDroppedSeq = 0;
102
+ for (const event of rest) {
103
+ const size = JSON.stringify(event).length + 1;
104
+ if (used + size <= budget) {
105
+ kept.push(event);
106
+ used += size;
107
+ } else {
108
+ dropped += 1;
109
+ if (!firstDroppedSeq) firstDroppedSeq = event.seq;
110
+ }
111
+ }
112
+
113
+ const events = kept.concat(terminals);
114
+ // Lo scarto e' SEMPRE dichiarato: un buco silenzioso in UI si legge come
115
+ // "non e' successo niente", che e' peggio del buco.
116
+ if (dropped > 0) events.push({ seq: firstDroppedSeq, kind: 'gap', count: dropped });
117
+ events.sort((a, b) => a.seq - b.seq);
118
+ return { events, dropped };
119
+ }
120
+
121
+ // La sessione VL dichiarata dal nodo (sidebar: «N900 · 1 sessione»). Solo
122
+ // attach + label del profilo, mai contenuto: qualunque campo extra viene
123
+ // potato, non troncato — stessa disciplina della whitelist eventi. Assente o
124
+ // invalida -> null: un binario piu' vecchio non dichiara sessioni che non ha
125
+ // e non sparisce dalla lista.
126
+ function sanitizeSession(raw) {
127
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
128
+ if (raw.attached !== true && raw.attached !== false) return null;
129
+ const profile = boundedString(raw.profile, 64);
130
+ if (!profile) return null;
131
+ return { attached: raw.attached, profile };
132
+ }
133
+
42
134
  function sanitizeHeartbeat(raw) {
43
135
  if (!raw || raw.protocol !== PROTOCOL || !SESSION_RE.test(String(raw.sessionId || ''))
44
136
  || !Number.isSafeInteger(raw.seq) || raw.seq < 0) return null;
@@ -53,6 +145,7 @@ function sanitizeHeartbeat(raw) {
53
145
  capabilities,
54
146
  health: sanitizeHealth(raw.health),
55
147
  ack: sanitizeAck(raw.ack),
148
+ session: sanitizeSession(raw.session),
56
149
  };
57
150
  }
58
151
 
@@ -76,6 +169,9 @@ function createBroker(options = {}) {
76
169
  waiter: null,
77
170
  inflight: null,
78
171
  lastAck: null,
172
+ events: [],
173
+ eventsCursor: 0,
174
+ eventsBytes: 0,
79
175
  };
80
176
  states.set(node.nodeId, state);
81
177
  }
@@ -94,6 +190,34 @@ function createBroker(options = {}) {
94
190
  return true;
95
191
  }
96
192
 
193
+ // At-least-once: il device rimanda dall'ultimo cursore confermato, quindi
194
+ // rivedere un seq gia' accettato e' normale, non un errore. Il dedup e' qui.
195
+ function ingestEvents(state, raw) {
196
+ const incoming = sanitizeEvents(raw);
197
+ if (!incoming.events.length) return;
198
+ for (const event of incoming.events) {
199
+ if (event.seq <= state.eventsCursor) continue;
200
+ state.events.push(event);
201
+ state.eventsBytes += JSON.stringify(event).length;
202
+ state.eventsCursor = event.seq;
203
+ }
204
+ while (state.events.length > RING_MAX_EVENTS || state.eventsBytes > RING_MAX_BYTES) {
205
+ const evicted = state.events.shift();
206
+ if (!evicted) break;
207
+ state.eventsBytes -= JSON.stringify(evicted).length;
208
+ }
209
+ }
210
+
211
+ function events(nodeId, { after = 0 } = {}) {
212
+ const state = states.get(nodeId);
213
+ if (!state) return { events: [], cursor: 0 };
214
+ const from = Number.isSafeInteger(after) && after > 0 ? after : 0;
215
+ return {
216
+ events: from ? state.events.filter((e) => e.seq > from) : state.events.slice(),
217
+ cursor: state.eventsCursor,
218
+ };
219
+ }
220
+
97
221
  function observe(node, raw) {
98
222
  const heartbeat = sanitizeHeartbeat(raw);
99
223
  if (!heartbeat) return { ok: false, code: 'invalid-heartbeat' };
@@ -120,6 +244,7 @@ function createBroker(options = {}) {
120
244
  }
121
245
  state.lastSeen = now();
122
246
  state.heartbeat = heartbeat;
247
+ ingestEvents(state, raw && raw.events);
123
248
  let acknowledged = false;
124
249
  if (heartbeat.ack && state.inflight && heartbeat.ack.id === state.inflight.id) {
125
250
  state.lastAck = { ...heartbeat.ack, at: state.lastSeen };
@@ -146,7 +271,9 @@ function createBroker(options = {}) {
146
271
  const state = observed.state;
147
272
  finishWaiter(state, { type: 'superseded', generation: state.generation });
148
273
  if (observed.acknowledged) {
149
- return Promise.resolve({ type: 'acknowledged', generation: state.generation });
274
+ return Promise.resolve({
275
+ type: 'acknowledged', generation: state.generation, eventsCursor: state.eventsCursor,
276
+ });
150
277
  }
151
278
  const boundedWait = Math.max(1, Math.min(MAX_WAIT_MS, Number(waitMs) || MAX_WAIT_MS));
152
279
  return new Promise((resolve) => {
@@ -167,7 +294,9 @@ function createBroker(options = {}) {
167
294
  waiter.cleanup = () => signal.removeEventListener('abort', onAbort);
168
295
  }
169
296
  state.waiter = waiter;
170
- });
297
+ // Il cursore viaggia su OGNI risposta, anche su un poll scaduto a vuoto:
298
+ // e' cosi' che il device sa quando puo' liberare il proprio buffer.
299
+ }).then((value) => ({ ...value, eventsCursor: state.eventsCursor }));
171
300
  }
172
301
 
173
302
  function dispatch(nodeId, command) {
@@ -207,13 +336,14 @@ function createBroker(options = {}) {
207
336
  version: state?.heartbeat?.version || '',
208
337
  capabilities: state?.heartbeat?.capabilities || [],
209
338
  health: state?.heartbeat?.health || null,
339
+ session: state?.heartbeat?.session ?? null,
210
340
  inflight: state?.inflight || null,
211
341
  lastAck: state?.lastAck || null,
212
342
  };
213
343
  });
214
344
  }
215
345
 
216
- return { poll, dispatch, forget, list, observe, _states: states };
346
+ return { poll, dispatch, forget, list, observe, events, _states: states };
217
347
  }
218
348
 
219
349
  module.exports = {
@@ -224,5 +354,12 @@ module.exports = {
224
354
  sanitizeHeartbeat,
225
355
  sanitizeHealth,
226
356
  sanitizeAck,
357
+ sanitizeSession,
358
+ sanitizeEvents,
359
+ EVENT_KINDS,
360
+ TERMINAL_KINDS,
361
+ MAX_EVENTS_BYTES,
362
+ RING_MAX_EVENTS,
363
+ RING_MAX_BYTES,
227
364
  createBroker,
228
365
  };
@@ -10,6 +10,10 @@ const SIMPLE_COMMANDS = new Set([
10
10
  'status', 'health', 'start', 'stop', 'restart', 'version', 'capabilities', 'unpair',
11
11
  ]);
12
12
  const MAX_CANDIDATE_BYTES = 2_621_440;
13
+ /// Stesso tetto dichiarato dal device (`MAX_PROMPT_TEXT` in protocol.rs): due
14
+ /// numeri diversi qui e la' vorrebbero dire consegnare comandi che il nodo
15
+ /// rifiuta.
16
+ const MAX_PROMPT_TEXT = 4 * 1024;
13
17
 
14
18
  function commandOf(body) {
15
19
  if (!body || typeof body.kind !== 'string') return null;
@@ -18,6 +22,18 @@ function commandOf(body) {
18
22
  || Array.isArray(body.args) || Object.keys(body.args).length > 0)) return null;
19
23
  return { kind: body.kind, args: {} };
20
24
  }
25
+ // L'unico verbo che porta testo dell'utente fino alla sessione del device.
26
+ // Il tetto e' lo stesso che il nodo dichiara (4 KiB): rifiutare QUI cio' che
27
+ // il device rifiuterebbe comunque evita un comando in volo destinato a
28
+ // fallire, e un `submitted` che l'operatore leggerebbe come «e' partito».
29
+ if (body.kind === 'prompt') {
30
+ const args = body.args;
31
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
32
+ if (Object.keys(args).join(',') !== 'text') return null;
33
+ if (typeof args.text !== 'string') return null;
34
+ if (args.text.length < 1 || Buffer.byteLength(args.text, 'utf8') > MAX_PROMPT_TEXT) return null;
35
+ return { kind: 'prompt', args: { text: args.text } };
36
+ }
21
37
  if (body.kind === 'logs') {
22
38
  const limit = body.args && body.args.limit;
23
39
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) return null;
@@ -96,6 +112,13 @@ function publicRoutes({ storePath, broker, ownerId }) {
96
112
  signal: abort.signal,
97
113
  });
98
114
  if (res.headersSent || res.destroyed) return undefined;
115
+ // Il cursore degli eventi viaggia in un HEADER, non nel corpo: la risposta
116
+ // a un poll scaduto e' un 204 senza corpo, e senza header il device non
117
+ // saprebbe mai fin dove sono stati accettati i suoi eventi — continuerebbe
118
+ // a rimandarli. L'header sta su OGNI risposta, anche su quelle vuote.
119
+ if (Number.isSafeInteger(result.eventsCursor)) {
120
+ res.set('x-vl-events-cursor', String(result.eventsCursor));
121
+ }
99
122
  if (result.type === 'command') return res.json({ protocol: PROTOCOL, command: result.command });
100
123
  if (result.type === 'idle' || result.type === 'aborted' || result.type === 'acknowledged') {
101
124
  return res.status(204).end();
@@ -138,6 +161,21 @@ function apiRoutes({ storePath, broker, ownerId, readonly = () => false }) {
138
161
  return res.json({ instanceId: localOwner, protocol: PROTOCOL, nodes });
139
162
  });
140
163
 
164
+ // Lettura pura del ring in memoria: nessun gate READONLY, perche' non muta
165
+ // nulla. `after` e' il cursore della UI, cosi' non riscarica tutto a ogni
166
+ // giro. Cio' che non c'e' piu' nel ring non si recupera da qui: la copia
167
+ // durevole e' il journal sul device (contratto passo 2, punto 5).
168
+ r.get('/:nodeId/events', (req, res) => {
169
+ if (!store.NODE_ID_RE.test(String(req.params.nodeId || ''))
170
+ || !store.listNodes(storePath).some((node) => node.nodeId === req.params.nodeId)) {
171
+ return res.status(404).json({ error: 'node not paired' });
172
+ }
173
+ const rawAfter = req.query.after === undefined ? 0 : Number(req.query.after);
174
+ const after = Number.isSafeInteger(rawAfter) && rawAfter > 0 ? rawAfter : 0;
175
+ const out = broker.events(req.params.nodeId, { after });
176
+ return res.json({ protocol: PROTOCOL, nodeId: req.params.nodeId, ...out });
177
+ });
178
+
141
179
  r.post('/invite', express.json({ limit: '4kb' }), (req, res) => {
142
180
  if (readonly()) return res.status(403).json({ error: 'READONLY: invite blocked' });
143
181
  const localOwner = ownerId();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.52-rc.2",
3
+ "version": "0.8.52-rc.21",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {