@mmmbuto/nexuscrew 0.9.8 → 0.9.9

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-C2A08iwn.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-CM1TECdp.css">
14
+ <script type="module" crossorigin src="/assets/index-DlWa7GOd.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-CN02GYPg.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.8"}
1
+ {"version":"0.9.9"}
@@ -28,6 +28,20 @@ function emptyGroups() { return { schemaVersion: SCHEMA_VERSION, groups: {} }; }
28
28
 
29
29
  function validName(name) { return typeof name === 'string' && GROUP_NAME_RE.test(name); }
30
30
 
31
+ // R31-A4: gli errori di VALIDAZIONE si distinguono per contratto (status 400 +
32
+ // code chiuso), non per testo del messaggio. Prima erano Error generici
33
+ // indistinguibili da quelli di I/O di atomicWrite (EACCES/ENOSPC/...), e la
34
+ // route poteva separarli solo indovinando con un match sulla stringa — il
35
+ // modo sbagliato, vietato qui. Gli errori fs restano nativi: portano il loro
36
+ // errno in .code e NON hanno .status, così una scrittura fallita non può mai
37
+ // essere scambiata per una validazione.
38
+ function validationError(message, code) {
39
+ const err = new Error(message);
40
+ err.status = 400;
41
+ err.code = code;
42
+ return err;
43
+ }
44
+
31
45
  function normalizeSpec(spec) {
32
46
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return null;
33
47
  if (Object.keys(spec).some((key) => !['targets', 'mode'].includes(key))) return null;
@@ -96,9 +110,9 @@ function getGroup(cfg = {}, name, home) {
96
110
  }
97
111
 
98
112
  function saveGroup(cfg = {}, name, spec, home) {
99
- if (!validName(name)) throw new Error('nome gruppo audio non valido');
113
+ if (!validName(name)) throw validationError('nome gruppo audio non valido', 'AUDIO_GROUP_NAME_INVALID');
100
114
  const normalized = normalizeSpec(spec);
101
- if (!normalized) throw new Error('gruppo audio non valido');
115
+ if (!normalized) throw validationError('gruppo audio non valido', 'AUDIO_GROUP_SPEC_INVALID');
102
116
  const store = readGroups(cfg, home);
103
117
  store.groups[name] = normalized;
104
118
  atomicWrite(groupsPath(cfg, home), store);
@@ -106,7 +120,7 @@ function saveGroup(cfg = {}, name, spec, home) {
106
120
  }
107
121
 
108
122
  function removeGroup(cfg = {}, name, home) {
109
- if (!validName(name)) throw new Error('nome gruppo audio non valido');
123
+ if (!validName(name)) throw validationError('nome gruppo audio non valido', 'AUDIO_GROUP_NAME_INVALID');
110
124
  const store = readGroups(cfg, home);
111
125
  if (!Object.prototype.hasOwnProperty.call(store.groups, name)) return false;
112
126
  delete store.groups[name];
@@ -72,7 +72,7 @@ function readProcessStart(pid) {
72
72
  }
73
73
 
74
74
  // Campi che SOLO writePidfile puo' scrivere: mai dal chiamante via `extra`.
75
- // Misurato (Dev, 2026-08-17): con lo spread di extra per ultimo,
75
+ // Misurato (2026-08-17): con lo spread di extra per ultimo,
76
76
  // writePidfile(f, pid, cmd, {processStart:'FINTO', attestation:'unsupported'})
77
77
  // vinceva sul valore vero calcolato da probeProcessStart — un chiamante
78
78
  // poteva scrivere un'attestazione INVENTATA. safeExtra la filtra qui, e i
@@ -534,7 +534,7 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
534
534
  // scrivere qui, verificato nell'istante in cui
535
535
  // conta, non dedotto da uno passato. Si rifiuta.
536
536
  //
537
- // MISURATO (Dev, 2026-08-17): trattare OGNI meta senza attestazione come
537
+ // MISURATO (2026-08-17): trattare OGNI meta senza attestazione come
538
538
  // indeterminate rompe l'aggiornamento automatico per ogni nodo il cui
539
539
  // runtime e' ancora precedente a quando processStart e' nato (8fe514f,
540
540
  // v0.9.0) — npm install sovrascrive pidfile.js PRIMA che il runner lo
@@ -6,6 +6,7 @@ const express = require('express');
6
6
  const { Router } = require('express');
7
7
  const multer = require('multer');
8
8
  const store = require('./store.js');
9
+ const { pasteArgs } = require('../tmux/actions.js');
9
10
 
10
11
  // Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
11
12
  // notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
@@ -32,7 +33,40 @@ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => fal
32
33
  // paste=false (tasto allegati del composer): il client appende il path al
33
34
  // testo del composer — niente scrittura PTY. Default = incolla (FilesPanel).
34
35
  const wantPaste = String((req.body && req.body.paste) || '') !== 'false';
35
- const pasted = wantPaste ? await paste(session, saved.path) : false;
36
+ // R31-A2: paste RICHIESTO ma non riuscito non e' un 200 con pasted:false
37
+ // tre cause collassavano in quell'unica etichetta muta (non richiesto /
38
+ // tmux non ha ricevuto / testo respinto) e nessuno, risposta o UI, poteva
39
+ // accorgersene. Il vocabolario e' quello del ramo answer degli ask
40
+ // (lib/notify/routes.js): «paste fallito» si dice non-200 + error, col
41
+ // codice che nomina il soggetto giusto (502 valle raggiunto, 500 rifiuto
42
+ // nostro). Il file resta nella risposta: e' salvato in inbox, e' la
43
+ // consegna alla PTY che non e' avvenuta — la causa vera va detta, non
44
+ // nascosta dietro un OK.
45
+ if (!wantPaste) return res.json({ ...saved, pasted: false });
46
+ // Testo respinto a monte: pasteArgs e' la FONTE UNICA del rifiuto (la usa
47
+ // pasteToSession). Il nome upload e' sanitizzato: se scatta, la causa e'
48
+ // il filesRoot di configurazione (control char) o un path oltre i 4096
49
+ // char. Rifiuto noto PRIMA di tmux: la PTY non va neanche tentata.
50
+ // 500, non 502 (rilievo audit su d6c47c6): qui il valle NON e' stato
51
+ // contattato — a rifiutare siamo noi, per configurazione nostra. 502
52
+ // affermerebbe «il servizio a monte non risponde» e sveglierebbe un
53
+ // allarme su un tmux che sta benissimo. La coppia di codici e'
54
+ // informazione: 502 = raggiunto ma non ha preso; 500 = mai tentato.
55
+ if (!pasteArgs(session, saved.path)) {
56
+ return res.status(500).json({
57
+ error: 'paste fallito: path non incollabile (control char o oltre 4096 char)',
58
+ ...saved,
59
+ pasted: false,
60
+ });
61
+ }
62
+ const pasted = await paste(session, saved.path);
63
+ if (!pasted) {
64
+ return res.status(502).json({
65
+ error: `paste fallito: sessione "${session}" non raggiungibile`,
66
+ ...saved,
67
+ pasted: false,
68
+ });
69
+ }
36
70
  res.json({ ...saved, pasted });
37
71
  });
38
72
  });
@@ -264,7 +264,7 @@ function createLeaseManager(cfg = {}, seams = {}) {
264
264
  socket.on('data', onData);
265
265
  socket.once('close', onEOF);
266
266
  socket.once('end', onEOF);
267
- // Correzione (audit 2a, segnalazione precisata da Dev): questo E' il socket
267
+ // Correzione (audit 2a, segnalazione precisata in revisione): questo E' il socket
268
268
  // su cui il server SCRIVE (l'ack di refresh — proprio dove l'EPIPE nasce se
269
269
  // il peer muore mentre la write e' in volo). Un EventEmitter che emette
270
270
  // 'error' senza listener fa un throw che termina l'INTERO processo — non
@@ -325,7 +325,7 @@ const CATALOG = Object.freeze([
325
325
  // default 'standard'; unsafe -> --always-approve (flag reale di `grok`).
326
326
  { id: 'grok.native', client: 'grok', provider: 'native', label: 'Grok account (CLI login)', auth: 'login', protocol: 'grok_native', core: true },
327
327
 
328
- // VL/Vivling (~/Dev/20_ai-labs/vl): runtime TUI locale. Auth propria del
328
+ // VL/Vivling (repository `vl`): runtime TUI locale. Auth propria del
329
329
  // runtime (default local Ollama, o VL_API_KEY/--profile in config.toml):
330
330
  // NexusCrew non legge ne' copia credenziali (auth 'none'). Il CLI non ha flag
331
331
  // prompt, --model ne' di approvazione (`vl [OPTIONS]`): lancia la TUI senza
@@ -71,7 +71,7 @@ const CLIENT_NAME = 'nexuscrew-live-bridge';
71
71
  // ponte, che e' gia' stata data.
72
72
  const ORPHAN_GRACE_MS = 1500;
73
73
 
74
- // —— Prompt per-cella (rev4 LC2, nome fisso confermato da Dev 2026-08-15) ——
74
+ // —— Prompt per-cella (rev4 LC2, nome fisso confermato il 2026-08-15) ——
75
75
  // Collocazione: filesRoot/<tmuxSession>/LIVE_PROMPT.md — la sessione tmux
76
76
  // ESATTA che il roster dichiara per la cella designata, la stessa fonte gia'
77
77
  // usata per l'intestazione R2 (identityHeader). NON un prefisso ricostruito a
@@ -126,13 +126,39 @@ function readCellPrompt(filesRoot, tmuxSession) {
126
126
  // di identità — è esattamente il difetto visto sul campo (la voce andava a
127
127
  // leggere tmux per capire dove si trovava).
128
128
  //
129
+ // R30 (v2, 2026-08-19): l'intestazione dice anche COME raggiungere i tool
130
+ // NexusCrew. Il daemon app-server espone UN solo insieme di server MCP a
131
+ // tutte le Live, con l'ambiente del daemon: nexuscrew è l'unico che prende
132
+ // l'identità dall'ambiente ereditato, quindi i suoi tool con sessione
133
+ // resterebbero fail-closed (nc_identity: MISSING). La via d'uscita ce l'ha
134
+ // il ponte: il nome esatto della sessione, che il server MCP accetta da
135
+ // NEXUSCREW_MCP_SESSION via stdio. Il valore NON si mette in systemd
136
+ // Environment= (una identità statica condivisa = impersonare una cella
137
+ // fissa, demolito in audit v1): lo dice l'intestazione, per-cella.
138
+ //
129
139
  // Il fatto, niente di più: quale cella (id Fleet) e, se il roster la dichiara,
130
140
  // 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.
141
+ // cella in ~/NexusFiles/<tmuxSession>/ e la via ai tool per quella sessione.
142
+ // Restano qui FUORI le istruzioni di lavoro: quelle vivono nel prompt
143
+ // per-cella, che questa intestazione PRECEDE sempre.
144
+ //
145
+ // Senza tmuxSession dichiarata non c'è identità possibile: il testo lo DICE,
146
+ // non suggerisce un comando che fallirebbe comunque (e una sessione indovinata
147
+ // sarebbe l'identità di un'altra cella).
133
148
  function identityHeader(cellId, tmuxSession) {
134
- const sessione = tmuxSession ? ` (sessione tmux ${tmuxSession})` : '';
135
- return `Live NexusCrew agganciata alla cella ${cellId}${sessione}.`;
149
+ if (!tmuxSession) {
150
+ return `Live NexusCrew agganciata alla cella ${cellId}. `
151
+ + 'Il roster non dichiara una sessione tmux per questa cella: senza sessione '
152
+ + 'non c\'è identità, quindi i tool NexusCrew che la richiedono non sono '
153
+ + 'raggiungibili da questa Live.';
154
+ }
155
+ return `Live NexusCrew agganciata alla cella ${cellId} (sessione tmux ${tmuxSession}). `
156
+ + 'Questa Live eredita l\'ambiente del daemon, condiviso fra tutte le Live e senza identità: '
157
+ + 'i tool NexusCrew che richiedono la sessione restano chiusi finché non li chiami con la tua. '
158
+ + 'Per usarli avvia il server MCP NexusCrew via stdio con la sessione di questa cella '
159
+ + `nell'ambiente — NEXUSCREW_MCP_SESSION=${tmuxSession} nexuscrew mcp — e parlagli `
160
+ + 'JSON-RPC su stdin (initialize, notifications/initialized, tools/call). '
161
+ + `Il valore esatto per questa conversazione è ${tmuxSession}: mai un'altra sessione.`;
136
162
  }
137
163
 
138
164
  // —— Client on-demand del socket di controllo (sezione protocollo sopra) ——
@@ -39,7 +39,7 @@ function isActive(cell) {
39
39
  return !!(cell && cell.active === true && cell.tmux !== false);
40
40
  }
41
41
 
42
- // --- Seam lease↔designazione (2026-08-15, decisione di Dev: grace = false) -----
42
+ // --- Seam lease↔designazione (2026-08-15, decisione presa in revisione: grace = false) -----
43
43
  //
44
44
  // L'idoneita' dell'host designato non e' piu' solo «sessione tmux viva»: con
45
45
  // remain-on-exit la sessione sopravvive alla morte del supervisore, e la
@@ -186,7 +186,14 @@ function notifyRoutes({
186
186
  originNode: resolved.origin.node,
187
187
  originCell: resolved.origin.cell,
188
188
  });
189
- return res.json({ status: 'delivered', delivered });
189
+ // R31-A3: lo status e' DERIVATO dai conteggi, non dichiarato a parte.
190
+ // `emit` e' best-effort — push fallito → 0, `ui` conta i write SSE
191
+ // riusciti — e il dispatcher propaga SOLO l'etichetta (i conteggi
192
+ // muoiono in forward(), rilievo R1/rc.14): per la cella mittente e'
193
+ // tutta l'informazione. Non puo' affermare una consegna che i conteggi
194
+ // smentiscono: zero canali raggiunti → 'no-delivery'.
195
+ const status = delivered.ui + delivered.push > 0 ? 'delivered' : 'no-delivery';
196
+ return res.json({ status, delivered });
190
197
  }
191
198
 
192
199
  // --- target remoto: instrada, non consegnare qui -----------------------
package/lib/server.js CHANGED
@@ -864,7 +864,11 @@ function createServer(opts = {}) {
864
864
  peers: federatedPeers,
865
865
  localPort: () => (server && server.address() ? server.address().port : cfg.port),
866
866
  localToken: () => tokenHolder.value,
867
- statuses: new Set(['delivered', 'refused', 'unreachable', 'unknown']),
867
+ // 'no-delivery' (R31-A3): esito legittimo del target — richiesta accettata
868
+ // ma nessun canale raggiunto (0 UI, 0 push). Senza questa voce forward()
869
+ // lo degraderebbe a 'unknown/unreadable-endpoint-result', nascondendo
870
+ // proprio il silenzio che l'esito esiste per rivelare.
871
+ statuses: new Set(['delivered', 'no-delivery', 'refused', 'unreachable', 'unknown']),
868
872
  }),
869
873
  federatedRate: createSpeakRateLimiter(),
870
874
  }));
@@ -928,13 +928,22 @@ function settingsRoutes(deps = {}) {
928
928
  return send(res, 400, { error: 'body non valido: attesi targets e mode' });
929
929
  }
930
930
  try { return send(res, 200, { group: audioGroups.saveGroup(audioCfg, String(req.params.name || ''), body, home) }); }
931
- catch (_) { return send(res, 400, { error: 'gruppo audio non valido' }); }
931
+ // R31-A4: il catch nudo raccontava QUALUNQUE errore come «gruppo non
932
+ // valido» (400): un EACCES/ENOSPC sulla scrittura di audio-groups.json
933
+ // finiva letto come colpa del nome. La validazione arriva dal modulo con
934
+ // status 400 + code chiuso; tutto il resto — scrittura fallita compresa —
935
+ // è 500 con la causa vera (scrubError ripulisce i path). Stessa formula
936
+ // delle route settings sopra.
937
+ catch (e) { return send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
932
938
  });
933
939
  r.delete('/audio/groups/:name', mutGate, (req, res) => {
934
940
  try {
935
941
  const removed = audioGroups.removeGroup(audioCfg, String(req.params.name || ''), home);
936
942
  return removed ? send(res, 200, { removed: true }) : send(res, 404, { error: 'gruppo audio non trovato' });
937
- } catch (_) { return send(res, 400, { error: 'nome gruppo audio non valido' }); }
943
+ } catch (e) {
944
+ // R31-A4: come PUT sopra — vedi il commento lì.
945
+ return send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) });
946
+ }
938
947
  });
939
948
 
940
949
  // Viewer-local aliases for routed nodes. These routes are local-only and do
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
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": {