@mmmbuto/nexuscrew 0.8.39 → 0.8.41

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +2 -1
  3. package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
  4. package/frontend/dist/assets/index-CwZySkm2.js +93 -0
  5. package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/version.json +1 -1
  8. package/lib/audio/acl.js +65 -0
  9. package/lib/audio/adapters.js +233 -0
  10. package/lib/audio/bridge-auth.js +176 -0
  11. package/lib/audio/capability.js +40 -0
  12. package/lib/audio/consent.js +64 -0
  13. package/lib/audio/dispatch.js +154 -0
  14. package/lib/audio/group-receipt.js +119 -0
  15. package/lib/audio/group-speak.js +146 -0
  16. package/lib/audio/groups.js +121 -0
  17. package/lib/audio/origin.js +128 -0
  18. package/lib/audio/queue.js +191 -0
  19. package/lib/audio/rate-limit.js +71 -0
  20. package/lib/audio/receipt.js +146 -0
  21. package/lib/audio/routes.js +374 -0
  22. package/lib/audio/speak.js +125 -0
  23. package/lib/cli/doctor.js +37 -0
  24. package/lib/config.js +7 -0
  25. package/lib/fleet/builtin.js +2 -1
  26. package/lib/fleet/launch.js +16 -0
  27. package/lib/fleet/managed.js +53 -2
  28. package/lib/fleet/provider.js +6 -0
  29. package/lib/fleet/runtime.js +14 -1
  30. package/lib/mcp/server.js +27 -3
  31. package/lib/mcp/tools.js +142 -0
  32. package/lib/nodes/tunnel-supervisor.js +33 -7
  33. package/lib/nodes/tunnel.js +21 -0
  34. package/lib/proxy/federation.js +41 -11
  35. package/lib/proxy/hop-proof.js +50 -0
  36. package/lib/server.js +97 -3
  37. package/lib/settings/routes.js +150 -4
  38. package/lib/tmux/lifecycle.js +24 -3
  39. package/package.json +1 -1
  40. package/skills/nexuscrew-agent/SKILL.md +30 -1
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+ // lib/audio/routes.js — route audio locali: /speak, /stop, /capability, /status.
3
+ //
4
+ // Due invarianti reggono tutto il resto.
5
+ //
6
+ // 1) L'origine e' verificata, non dichiarata. `originResolver` e' obbligatorio e
7
+ // autoritativo: nessun header, campo del body o parametro di query concorre a
8
+ // stabilire chi sta parlando. Senza un'origine verificabile la richiesta
9
+ // muore con 401, non con un default permissivo.
10
+ //
11
+ // 2) Ogni decisione di suonare la prende il nodo che possiede l'altoparlante. Se
12
+ // il target e' remoto questo server non decide per lui: instrada e riporta
13
+ // l'esito per endpoint cosi' com'e', incluso `unknown`.
14
+ //
15
+ // Il corpo grezzo viene conservato (`req.rawBody`) perche' la firma del bridge
16
+ // copre i BYTE trasmessi: ri-serializzare il JSON per verificarlo produrrebbe
17
+ // una stringa diversa a parita' di significato.
18
+ const express = require('express');
19
+ const { handleSpeak, handleStop, isValidInstance } = require('./speak.js');
20
+ const { describeCapability } = require('./capability.js');
21
+ const { createReceiptStore } = require('./receipt.js');
22
+ const { createSpeakRateLimiter } = require('./rate-limit.js');
23
+ const { createGroupReceiptStore } = require('./group-receipt.js');
24
+ const { createGroupSpeaker } = require('./group-speak.js');
25
+
26
+ const UTTERANCE_ID_RE = /^[A-Za-z0-9._:-]{8,128}$/;
27
+ const LANG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,34})$/;
28
+ const VOICE_RE = /^[^\u0000-\u001f\u007f]{1,64}$/;
29
+ const URGENCIES = new Set(['normal', 'high']);
30
+
31
+ function plainObject(value) {
32
+ return !!value && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function onlyKeys(value, allowed) {
36
+ return plainObject(value) && Object.keys(value).every((key) => allowed.has(key));
37
+ }
38
+
39
+ function optionalBounded(value, re) {
40
+ return value === undefined || (typeof value === 'string' && re.test(value));
41
+ }
42
+
43
+ // Il server non deve diventare tollerante verso campi futuri/non documentati:
44
+ // testo, route o identita' inattesi non vengono mai ignorati in silenzio.
45
+ // `originCell`/`originNode` sono gli unici campi aggiunti dal dispatcher sul
46
+ // percorso federato; il target li ricontrolla nel resolver, non li usa mai
47
+ // come fonte primaria dell'identita'.
48
+ function parseSpeakBody(body, { federated = false } = {}) {
49
+ const allowed = new Set(['target', 'text', 'lang', 'voice', 'urgency', 'utteranceId']);
50
+ if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
51
+ if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
52
+ const target = typeof body.target === 'string' ? body.target : '';
53
+ if (!isValidInstance(target)) return { error: 'invalid-target' };
54
+ if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
55
+ if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
56
+ if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
57
+ if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
58
+ return { error: 'invalid-utterance' };
59
+ }
60
+ return {
61
+ target, text: body.text,
62
+ ...(body.lang !== undefined ? { lang: body.lang } : {}),
63
+ ...(body.voice !== undefined ? { voice: body.voice } : {}),
64
+ urgency: body.urgency || 'normal',
65
+ ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
66
+ };
67
+ }
68
+
69
+ function parseStopBody(body, { federated = false } = {}) {
70
+ const allowed = new Set(['target', 'utteranceId']);
71
+ if (federated) { allowed.add('originCell'); allowed.add('originNode'); }
72
+ if (!onlyKeys(body, allowed)) return { error: 'invalid-request' };
73
+ const target = typeof body.target === 'string' ? body.target : '';
74
+ if (!isValidInstance(target)) return { error: 'invalid-target' };
75
+ if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
76
+ return { error: 'invalid-utterance' };
77
+ }
78
+ return { target, ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}) };
79
+ }
80
+
81
+ function parseRemoteStatusBody(body) {
82
+ if (!onlyKeys(body, new Set(['target', 'utteranceId', 'originCell', 'originNode']))) return { error: 'invalid-request' };
83
+ if (!isValidInstance(body.target)) return { error: 'invalid-target' };
84
+ if (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId)) return { error: 'invalid-utterance' };
85
+ return { target: body.target, utteranceId: body.utteranceId };
86
+ }
87
+
88
+ function parseGroupSpeakBody(body) {
89
+ if (!onlyKeys(body, new Set(['group', 'text', 'lang', 'voice', 'urgency', 'utteranceId']))) return { error: 'invalid-request' };
90
+ if (typeof body.group !== 'string') return { error: 'invalid-group' };
91
+ if (typeof body.text !== 'string' || body.text.length < 1 || body.text.length > 320) return { error: 'invalid-text' };
92
+ if (!optionalBounded(body.lang, LANG_RE) || !optionalBounded(body.voice, VOICE_RE)) return { error: 'invalid-request' };
93
+ if (body.urgency !== undefined && !URGENCIES.has(body.urgency)) return { error: 'invalid-request' };
94
+ if (body.utteranceId !== undefined && (typeof body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(body.utteranceId))) {
95
+ return { error: 'invalid-utterance' };
96
+ }
97
+ return {
98
+ group: body.group, text: body.text, urgency: body.urgency || 'normal',
99
+ ...(body.lang !== undefined ? { lang: body.lang } : {}),
100
+ ...(body.voice !== undefined ? { voice: body.voice } : {}),
101
+ ...(body.utteranceId ? { utteranceId: body.utteranceId } : {}),
102
+ };
103
+ }
104
+
105
+ function audioRoutes(deps = {}) {
106
+ if (!deps.originResolver || typeof deps.originResolver.resolve !== 'function') {
107
+ throw new Error('audioRoutes: originResolver e obbligatorio (origine verificata)');
108
+ }
109
+ const r = express.Router();
110
+ r.use(express.json({
111
+ limit: '16kb',
112
+ verify: (req, _res, buf) => { req.rawBody = Buffer.from(buf); },
113
+ }));
114
+
115
+ const originResolver = deps.originResolver;
116
+ const receiptStore = deps.receiptStore || createReceiptStore();
117
+ const rateLimiter = deps.rateLimiter || createSpeakRateLimiter();
118
+ const readonly = deps.readonly || (() => false);
119
+ const localNodeId = deps.localNodeId || (() => null);
120
+ const consent = deps.consent || (() => false);
121
+ const adapter = deps.adapter || null;
122
+ const queue = deps.queue || null;
123
+ const acl = deps.acl || null;
124
+ const dispatcher = deps.dispatcher || null;
125
+ const getGroup = deps.getGroup || (() => null);
126
+ const groupReceipts = deps.groupReceipts || createGroupReceiptStore();
127
+ const now = deps.now || Date.now;
128
+
129
+ const send = (res, code, body) => res.status(code).json(body);
130
+
131
+ // Un solo messaggio per ogni fallimento di origine. Distinguere "firma
132
+ // scaduta" da "nonce riusato" da "cella non attiva" darebbe a chi prova a
133
+ // forgiare un oracolo gratuito su cosa manca per riuscire.
134
+ async function originOr401(req, res, opts = {}) {
135
+ const resolved = await originResolver.resolve(req, opts);
136
+ if (!resolved || resolved.ok !== true) {
137
+ send(res, 401, { error: 'origine non verificabile' });
138
+ return null;
139
+ }
140
+ return resolved;
141
+ }
142
+
143
+ function gateDeps(origin, attested) {
144
+ return {
145
+ readonly, rateLimiter, queue, localNodeId, consent, now,
146
+ acl: acl ? acl.allows : null,
147
+ receipt: (status, o) => receiptStore.record({
148
+ origin: o.origin, target: o.target, status, reason: o.reason,
149
+ utteranceId: o.utteranceId, attested,
150
+ }),
151
+ updateReceipt: (id, status, reason) => receiptStore.update(id, status, reason),
152
+ readReceipt: (id, o) => receiptStore.get(o, id),
153
+ findReceipt: (id, o, target) => receiptStore.find(id, o, target),
154
+ };
155
+ }
156
+
157
+ function localCapability(target) {
158
+ const self = localNodeId();
159
+ if (!self || target !== self) return { status: 'refused', reason: 'invalid-target' };
160
+ const described = describeCapability({ adapter, consent: consent() === true, nodeId: self });
161
+ if (described.consent !== true) return { status: 'refused', reason: 'consent' };
162
+ if (described.installed !== true) return { status: 'refused', reason: 'no-adapter' };
163
+ if (described.liveness !== 'ready') return { status: 'unknown', reason: 'liveness-unknown' };
164
+ return { status: 'ready', capability: described };
165
+ }
166
+
167
+ async function capabilityForGroup({ target }) {
168
+ if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
169
+ if (target === localNodeId()) return localCapability(target);
170
+ if (!dispatcher || typeof dispatcher.probeCapability !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
171
+ return dispatcher.probeCapability(target);
172
+ }
173
+
174
+ async function speakForGroup({ target, origin, utteranceId, text, lang, voice, urgency }) {
175
+ if (target === localNodeId()) {
176
+ return handleSpeak({
177
+ target, text, lang, voice, urgency, utteranceId, origin, trust: 'local-bridge', visited: [],
178
+ }, gateDeps(origin, false));
179
+ }
180
+ if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unreachable', reason: 'dispatch-unavailable' };
181
+ // Un gruppo non e' una scorciatoia attorno al budget `nc_speak`: prima del
182
+ // dispatch remoto consuma gli stessi tre bucket sul nodo origine. Il target
183
+ // li applichera' di nuovo in modo indipendente, perche' resta l'autorita'
184
+ // finale sul proprio altoparlante.
185
+ const rl = rateLimiter.check({ origin, target, urgency });
186
+ if (!rl.allowed) return { status: 'refused', reason: 'rate-limit' };
187
+ return dispatcher.dispatch({
188
+ resource: '/audio/speak', target, origin,
189
+ payload: { text, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), urgency, utteranceId },
190
+ });
191
+ }
192
+
193
+ async function statusForGroup({ target, origin, utteranceId }) {
194
+ if (target === localNodeId()) return receiptStore.get(origin, utteranceId) || { status: 'unknown', reason: 'not-found' };
195
+ if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
196
+ return dispatcher.dispatch({ resource: '/audio/speak/status', target, origin, payload: { utteranceId } });
197
+ }
198
+
199
+ async function stopForGroup({ target, origin, utteranceId }) {
200
+ if (target === localNodeId()) return handleStop({ target, utteranceId, origin }, gateDeps(origin, false));
201
+ if (!dispatcher || typeof dispatcher.dispatch !== 'function') return { status: 'unknown', reason: 'dispatch-unavailable' };
202
+ return dispatcher.dispatch({ resource: '/audio/stop', target, origin, payload: { utteranceId } });
203
+ }
204
+
205
+ const groupSpeaker = deps.groupSpeaker || createGroupSpeaker({
206
+ getGroup, receipts: groupReceipts,
207
+ capability: capabilityForGroup, speak: speakForGroup,
208
+ status: statusForGroup, stop: stopForGroup,
209
+ });
210
+
211
+ r.post('/speak', async (req, res) => {
212
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
213
+ const { origin, trust, visited } = resolved;
214
+ const parsed = parseSpeakBody(req.body, { federated: trust === 'federated' });
215
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
216
+ const { target, utteranceId } = parsed;
217
+ // Gruppo e speak singolo condividono il namespace dell'utteranceId per
218
+ // origine. Senza questo gate un id riusato potrebbe far sembrare `spoken`
219
+ // un endpoint che aveva pronunciato una richiesta precedente, diversa.
220
+ if (utteranceId && groupReceipts.get(origin, utteranceId)) {
221
+ return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
222
+ }
223
+ // Target remoto: questo nodo non decide e non parla. Instrada sulla
224
+ // topologia autorizzata e restituisce l'esito del target senza addolcirlo.
225
+ if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
226
+ if (readonly()) return send(res, 403, { status: 'refused', reason: 'readonly' });
227
+ const rl = rateLimiter.check({ origin, target, urgency: parsed.urgency });
228
+ if (!rl.allowed) return send(res, 429, { status: 'refused', reason: 'rate-limit' });
229
+ const local = receiptStore.record({
230
+ origin, target, status: 'accepted', utteranceId, attested: trust === 'federated',
231
+ });
232
+ const out = await dispatcher.dispatch({
233
+ resource: '/audio/speak', target, origin,
234
+ payload: {
235
+ text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
236
+ utteranceId: local.utteranceId,
237
+ },
238
+ });
239
+ const merged = receiptStore.update(local.utteranceId, out.status, out.reason) || {
240
+ ...local, status: out.status, ...(out.reason ? { reason: out.reason } : {}),
241
+ };
242
+ const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
243
+ : out.status === 'unreachable' ? 503 : 422;
244
+ return send(res, code, merged);
245
+ }
246
+
247
+ const out = handleSpeak({
248
+ target, text: parsed.text, lang: parsed.lang, voice: parsed.voice, urgency: parsed.urgency,
249
+ utteranceId, origin, trust, visited,
250
+ }, gateDeps(origin, trust === 'federated'));
251
+ const code = out.status === 'spoken' || out.status === 'accepted' || out.status === 'unknown' ? 200
252
+ : (out.reason === 'invalid-text' || out.reason === 'invalid-target' || out.reason === 'not-local-target') ? 400
253
+ : out.status === 'unreachable' ? 503 : 422;
254
+ return send(res, code, out);
255
+ });
256
+
257
+ r.post('/stop', async (req, res) => {
258
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
259
+ const { origin } = resolved;
260
+ const parsed = parseStopBody(req.body, { federated: resolved.trust === 'federated' });
261
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
262
+ const { target } = parsed;
263
+ if (dispatcher && isValidInstance(target) && !dispatcher.isLocal(target)) {
264
+ const out = await dispatcher.dispatch({
265
+ resource: '/audio/stop', target, origin,
266
+ payload: { ...(parsed.utteranceId ? { utteranceId: parsed.utteranceId } : {}) },
267
+ });
268
+ return send(res, out.status === 'accepted' ? 200 : out.status === 'unreachable' ? 503 : 422, out);
269
+ }
270
+ const out = handleStop({ target, utteranceId: parsed.utteranceId, origin }, gateDeps(origin, false));
271
+ return send(res, out.status === 'accepted' || out.status === 'unknown' ? 200 : 422, out);
272
+ });
273
+
274
+ // Capability del SOLO nodo locale. Non e' una directory: un chiamante non puo'
275
+ // usare questo endpoint per sondare la capability di un terzo nodo, che va
276
+ // interrogato sulla propria route federata con la credenziale giusta.
277
+ r.get('/capability', async (req, res) => {
278
+ const resolved = await originOr401(req, res, { requireCell: false }); if (!resolved) return undefined;
279
+ if (resolved.trust === 'federated' && acl && typeof acl.allows === 'function') {
280
+ const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
281
+ if (!verdict || verdict.allowed !== true) return send(res, 403, { error: 'capability non autorizzata' });
282
+ }
283
+ if (Object.keys(req.query || {}).some((key) => key !== 'target') || Array.isArray(req.query && req.query.target)) {
284
+ return send(res, 400, { error: 'query non valida' });
285
+ }
286
+ const self = localNodeId();
287
+ const target = String(req.query.target || self || '');
288
+ if (!isValidInstance(target)) return send(res, 400, { error: 'target non valido (instanceId 32 hex)' });
289
+ if (!self || target !== self) return send(res, 403, { error: 'capability descrive solo il nodo locale' });
290
+ return send(res, 200, describeCapability({ adapter, consent: consent() === true, nodeId: self }));
291
+ });
292
+
293
+ // Status per utteranceId, leggibile SOLO dalla stessa origine (nodo+cella).
294
+ // Per chiunque altro l'enunciato non esiste: distinguere 404 da 403
295
+ // permetterebbe di enumerare gli id altrui.
296
+ r.get('/speak/status/:utteranceId', async (req, res) => {
297
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
298
+ const rc = receiptStore.get(resolved.origin, String(req.params.utteranceId || ''));
299
+ if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
300
+ return send(res, 200, rc);
301
+ });
302
+
303
+ // Status federato: POST e non GET perche' la cella attestata del chiamante
304
+ // viaggia nel body costruito dal dispatcher. Non e' una API per browser: la
305
+ // whitelist Hydra e la prova hop rendono il percorso raggiungibile solo da un
306
+ // origin node gia' autenticato. Il target riapplica l'ACL prima di rivelare
307
+ // anche il receipt redatto.
308
+ r.post('/speak/status', async (req, res) => {
309
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
310
+ if (resolved.trust !== 'federated') return send(res, 403, { status: 'refused', reason: 'federated-only' });
311
+ const parsed = parseRemoteStatusBody(req.body);
312
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
313
+ if (parsed.target !== localNodeId()) return send(res, 403, { status: 'refused', reason: 'not-local-target' });
314
+ if (acl && typeof acl.allows === 'function') {
315
+ const verdict = acl.allows({ trust: resolved.trust, origin: resolved.origin, visited: resolved.visited });
316
+ if (!verdict || verdict.allowed !== true) return send(res, 422, { status: 'refused', reason: 'acl' });
317
+ }
318
+ const rc = receiptStore.get(resolved.origin, parsed.utteranceId);
319
+ if (!rc) return send(res, 404, { status: 'unknown', reason: 'not-found' });
320
+ return send(res, 200, rc);
321
+ });
322
+
323
+ // I gruppi sono locali all'origine e non fanno parte della whitelist
324
+ // federata: la loro espansione avviene qui, quindi ogni endpoint riceve un
325
+ // comando /audio/speak esatto e applica autonomamente consenso/ACL/READONLY.
326
+ r.post('/groups/speak', async (req, res) => {
327
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
328
+ if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
329
+ const parsed = parseGroupSpeakBody(req.body);
330
+ if (parsed.error) return send(res, 400, { status: 'refused', reason: parsed.error });
331
+ if (readonly()) return send(res, 422, { status: 'refused', reason: 'readonly' });
332
+ if (parsed.utteranceId && receiptStore.get(resolved.origin, parsed.utteranceId)) {
333
+ return send(res, 422, { status: 'refused', reason: 'utterance-collision' });
334
+ }
335
+ const out = await groupSpeaker.speakGroup({ origin: resolved.origin, ...parsed });
336
+ const code = out.status === 'refused' ? 422 : 200;
337
+ return send(res, code, out);
338
+ });
339
+
340
+ r.get('/groups/status/:utteranceId', async (req, res) => {
341
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
342
+ if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
343
+ const utteranceId = String(req.params.utteranceId || '');
344
+ if (!UTTERANCE_ID_RE.test(utteranceId)) return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
345
+ const receipt = groupSpeaker.getStatus({ origin: resolved.origin, utteranceId });
346
+ if (!receipt) return send(res, 404, { status: 'unknown', reason: 'not-found' });
347
+ return send(res, 200, receipt);
348
+ });
349
+
350
+ r.post('/groups/stop', async (req, res) => {
351
+ const resolved = await originOr401(req, res); if (!resolved) return undefined;
352
+ if (resolved.trust !== 'local-bridge') return send(res, 403, { status: 'refused', reason: 'local-bridge-required' });
353
+ if (!onlyKeys(req.body, new Set(['utteranceId'])) || typeof req.body.utteranceId !== 'string' || !UTTERANCE_ID_RE.test(req.body.utteranceId)) {
354
+ return send(res, 400, { status: 'refused', reason: 'invalid-utterance' });
355
+ }
356
+ const out = await groupSpeaker.stopGroup({ origin: resolved.origin, utteranceId: req.body.utteranceId });
357
+ return send(res, out.status === 'refused' ? 422 : 200, out);
358
+ });
359
+
360
+ r.use((err, _req, res, _next) => {
361
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
362
+ return res.status(413).json({ error: 'body troppo grande' });
363
+ }
364
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
365
+ return res.status(err.status || 400).json({ error: 'richiesta audio non valida' });
366
+ });
367
+
368
+ return r;
369
+ }
370
+
371
+ module.exports = {
372
+ audioRoutes, UTTERANCE_ID_RE, LANG_RE, VOICE_RE,
373
+ parseSpeakBody, parseStopBody, parseGroupSpeakBody,
374
+ };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+ // lib/audio/speak.js — catena di gate LATO TARGET: la decisione di emettere
3
+ // suono la prende il nodo che possiede l'altoparlante, sempre, anche quando la
4
+ // richiesta e' gia' passata da una route federata autorizzata.
5
+ //
6
+ // Ordine dei gate, deliberato:
7
+ // 0. testo valido (1..320) — nessun effetto collaterale
8
+ // 1. target esatto e uguale a QUESTO nodo — un nodo parla solo per se'
9
+ // 2. READONLY — `speak` e' una mutazione: ha
10
+ // un effetto fisico osservabile, quindi in sola lettura si rifiuta
11
+ // 3. ACL sull'origine
12
+ // 4. consenso audio locale (default OFF)
13
+ // 5. idempotenza per utteranceId — PRIMA del rate limit, cosi' un
14
+ // retry identico non consuma budget e non produce una seconda voce
15
+ // 6. rate limit dedicato
16
+ // 7. adapter/coda presenti — senza adapter si dice no, non
17
+ // si finge un `accepted`
18
+ // 8. accodamento
19
+ //
20
+ // I gate 3 e 4 stanno prima dell'idempotenza di proposito: un rifiuto per ACL o
21
+ // consenso deve restare un rifiuto anche al secondo tentativo, non trasformarsi
22
+ // in un receipt riusato.
23
+ const INSTANCE_ID_RE = /^[a-f0-9]{32}$/i;
24
+ const TEXT_MAX = 320;
25
+
26
+ function isValidInstance(v) {
27
+ return typeof v === 'string' && INSTANCE_ID_RE.test(v);
28
+ }
29
+
30
+ // handleSpeak(): ritorna SEMPRE uno stato per endpoint, mai un booleano.
31
+ // `deps.receipt(status, opts)` registra e ritorna il receipt redatto.
32
+ function handleSpeak(input, deps) {
33
+ const opts = input || {};
34
+ const d = deps || {};
35
+ const { target, origin } = opts;
36
+
37
+ if (typeof opts.text !== 'string' || opts.text.length < 1 || opts.text.length > TEXT_MAX) {
38
+ return { status: 'refused', reason: 'invalid-text' };
39
+ }
40
+ if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
41
+ // Un target diverso da questo nodo non e' affar suo: il nodo non ritrasmette
42
+ // e non fa da relay applicativo per la voce di un altro.
43
+ if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
44
+ return { status: 'refused', reason: 'not-local-target' };
45
+ }
46
+ if (typeof d.readonly === 'function' && d.readonly()) {
47
+ return d.receipt('refused', { reason: 'readonly', target, origin, utteranceId: opts.utteranceId });
48
+ }
49
+ if (typeof d.acl === 'function') {
50
+ const verdict = d.acl({ trust: opts.trust, origin, visited: opts.visited });
51
+ if (!verdict || verdict.allowed !== true) {
52
+ // La reason resta generica verso l'esterno: la ragione precisa del rifiuto
53
+ // descriverebbe la topologia del nodo a chi non e' autorizzato a vederla.
54
+ return d.receipt('refused', { reason: 'acl', target, origin, utteranceId: opts.utteranceId });
55
+ }
56
+ }
57
+ if (typeof d.consent === 'function' && d.consent() !== true) {
58
+ return d.receipt('refused', { reason: 'consent', target, origin, utteranceId: opts.utteranceId });
59
+ }
60
+ if (opts.utteranceId && typeof d.findReceipt === 'function') {
61
+ const found = d.findReceipt(opts.utteranceId, origin, target);
62
+ if (found === 'collision') {
63
+ const ts = typeof d.now === 'function' ? d.now() : Date.now();
64
+ return {
65
+ status: 'refused', reason: 'utterance-collision', utteranceId: opts.utteranceId,
66
+ origin: { node: origin && origin.node, cell: origin && origin.cell }, target, timestamp: ts,
67
+ };
68
+ }
69
+ if (found) return found;
70
+ }
71
+ if (d.rateLimiter) {
72
+ const rl = d.rateLimiter.check({ origin, target, urgency: opts.urgency });
73
+ if (!rl.allowed) {
74
+ return d.receipt('refused', { reason: 'rate-limit', target, origin, utteranceId: opts.utteranceId });
75
+ }
76
+ }
77
+ if (!d.queue || typeof d.queue.enqueue !== 'function') {
78
+ return d.receipt('refused', { reason: 'no-adapter', target, origin, utteranceId: opts.utteranceId });
79
+ }
80
+ // Il receipt nasce PRIMA dell'accodamento: la coda aggiornera' questo stesso
81
+ // utteranceId quando l'adapter conferma l'avvio, fallisce o va in timeout.
82
+ const receipt = d.receipt('accepted', { target, origin, utteranceId: opts.utteranceId });
83
+ const admitted = d.queue.enqueue({
84
+ utteranceId: receipt.utteranceId,
85
+ text: opts.text, lang: opts.lang, voice: opts.voice, urgency: opts.urgency,
86
+ });
87
+ if (admitted.status !== 'accepted') {
88
+ return (d.updateReceipt && d.updateReceipt(receipt.utteranceId, admitted.status, admitted.reason))
89
+ || { ...receipt, status: admitted.status, ...(admitted.reason ? { reason: admitted.reason } : {}) };
90
+ }
91
+ // La coda puo' aver gia' concluso in modo sincrono (adapter fake nei test,
92
+ // fallimento immediato di spawn): si rilegge il receipt invece di riportare
93
+ // uno stato ormai superato.
94
+ return (d.readReceipt && d.readReceipt(receipt.utteranceId, origin)) || receipt;
95
+ }
96
+
97
+ // handleStop(): comando di controllo, non sintesi. Lo stop LOCALE e' sovrano e
98
+ // non dipende dalla rete: un endpoint deve poter tacere anche offline. Uno stop
99
+ // remoto per utteranceId vale solo per gli enunciati del chiamante.
100
+ function handleStop(input, deps) {
101
+ const opts = input || {};
102
+ const d = deps || {};
103
+ const { target, origin } = opts;
104
+ if (!isValidInstance(target)) return { status: 'refused', reason: 'invalid-target' };
105
+ if (typeof d.localNodeId === 'function' && target !== d.localNodeId()) {
106
+ return { status: 'refused', reason: 'not-local-target' };
107
+ }
108
+ // READONLY non blocca lo stop: fermare una voce e' sempre permesso. Impedirlo
109
+ // significherebbe che un nodo in sola lettura non puo' zittirsi.
110
+ if (opts.utteranceId) {
111
+ if (typeof d.findReceipt === 'function') {
112
+ const found = d.findReceipt(opts.utteranceId, origin, target);
113
+ if (!found || found === 'collision') {
114
+ return { status: 'unknown', reason: 'utterance-not-found', utteranceId: opts.utteranceId };
115
+ }
116
+ }
117
+ const acted = d.queue && typeof d.queue.stop === 'function' ? d.queue.stop(opts.utteranceId) : false;
118
+ if (d.updateReceipt && acted) d.updateReceipt(opts.utteranceId, 'refused', 'stopped');
119
+ return { status: 'accepted', utteranceId: opts.utteranceId, stopped: acted === true };
120
+ }
121
+ const acted = d.queue && typeof d.queue.stopAll === 'function' ? d.queue.stopAll() : false;
122
+ return { status: 'accepted', stopped: acted === true };
123
+ }
124
+
125
+ module.exports = { handleSpeak, handleStop, isValidInstance, INSTANCE_ID_RE, TEXT_MAX };
package/lib/cli/doctor.js CHANGED
@@ -15,6 +15,7 @@ const { fleetInstallPath } = require('./fleet-service.js');
15
15
  const { resolvePaths } = require('./url.js');
16
16
  const { commandExists } = require('./path.js');
17
17
  const { loadDefinitions } = require('../fleet/definitions.js');
18
+ const { loadConfig } = require('../config.js');
18
19
  const {
19
20
  termuxRuntimePaths, trustedTermuxPreload, TERMUX_EXEC_BASENAME_RE,
20
21
  } = require('../runtime/env.js');
@@ -426,6 +427,39 @@ function checkFleetDefinitions(home, fleetDefsPath, enabled = true) {
426
427
  };
427
428
  }
428
429
 
430
+ // Read-only check: alternate-screen is applied per managed session, while the
431
+ // history limit remains an operator-owned tmux setting. Do not write ~/.tmux.conf
432
+ // or change a live server from doctor; only recommend a sufficient value.
433
+ function checkAlternateScreenHistory(alternateScreen, execImpl, tmuxBin) {
434
+ if (alternateScreen === true) {
435
+ return { name: 'Fleet alternate-screen history', ok: true, detail: 'alternate-screen standard attivo' };
436
+ }
437
+ let raw = '';
438
+ try {
439
+ raw = String(execImpl(tmuxBin || 'tmux', ['show-options', '-g', 'history-limit'], { encoding: 'utf8' }) || '');
440
+ } catch (_) {
441
+ return {
442
+ name: 'Fleet alternate-screen history', ok: true, warn: true,
443
+ detail: 'history-limit non verificabile; con alternateScreen off configura set -g history-limit 100000 in ~/.tmux.conf',
444
+ };
445
+ }
446
+ const match = raw.match(/(?:^|\n)history-limit\s+(\d+)\b/);
447
+ if (!match) {
448
+ return {
449
+ name: 'Fleet alternate-screen history', ok: true, warn: true,
450
+ detail: 'history-limit non leggibile; con alternateScreen off configura set -g history-limit 100000 in ~/.tmux.conf',
451
+ };
452
+ }
453
+ const limit = Number(match[1]);
454
+ if (limit < 10000) {
455
+ return {
456
+ name: 'Fleet alternate-screen history', ok: true, warn: true,
457
+ detail: `history-limit ${limit} (<10000) con alternateScreen off; configura set -g history-limit 100000 in ~/.tmux.conf`,
458
+ };
459
+ }
460
+ return { name: 'Fleet alternate-screen history', ok: true, detail: `history-limit ${limit}` };
461
+ }
462
+
429
463
  // Esegue tutti i check. Seam iniettabili per test (platform, home, execImpl, ptyLoad).
430
464
  function doctor(opts = {}) {
431
465
  const platform = opts.platform || detectPlatform();
@@ -454,6 +488,8 @@ function doctor(opts = {}) {
454
488
  checkTmuxSurvival(platform, execImpl),
455
489
  checkTokenPerms(tokenPath),
456
490
  checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
491
+ checkAlternateScreenHistory(opts.alternateScreen !== undefined
492
+ ? opts.alternateScreen : loadConfig().alternateScreen, execImpl, opts.tmuxBin),
457
493
  checkTermuxExec(opts.env, { platform, home }),
458
494
  checkSshClient(existsImpl),
459
495
  checkAutossh(existsImpl),
@@ -477,6 +513,7 @@ module.exports = {
477
513
  checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
478
514
  checkMacServiceWorkingDirectory,
479
515
  checkFleetDefinitions,
516
+ checkAlternateScreenHistory,
480
517
  checkServiceWorkingDirectory,
481
518
  checkFleetServiceWorkingDirectory,
482
519
  checkTmuxSurvival, checkUserLinger,
package/lib/config.js CHANGED
@@ -22,6 +22,9 @@ function baseDefaults() {
22
22
  // Shared-server safety is operational hardening, not a same-UID security
23
23
  // boundary. Disable only when the operator deliberately owns tmux policy.
24
24
  protectSharedTmuxServer: true,
25
+ // Fleet sessions stay on the normal screen by default so their transcript
26
+ // enters tmux history and remains scrollable from the web terminal.
27
+ alternateScreen: false,
25
28
  readonlyDefault: false,
26
29
  // Etichetta neutra usata nel prefisso delle risposte ask incollate in TUI.
27
30
  replyLabel: 'human',
@@ -72,6 +75,10 @@ function envOverrides() {
72
75
  e.protectSharedTmuxServer = !['', '0', 'false', 'no', 'off']
73
76
  .includes(String(process.env.NEXUSCREW_PROTECT_SHARED_TMUX_SERVER).toLowerCase());
74
77
  }
78
+ if (process.env.NEXUSCREW_ALTERNATE_SCREEN !== undefined) {
79
+ e.alternateScreen = !['', '0', 'false', 'no', 'off']
80
+ .includes(String(process.env.NEXUSCREW_ALTERNATE_SCREEN).toLowerCase());
81
+ }
75
82
  if (process.env.NEXUSCREW_READONLY) e.readonlyDefault = process.env.NEXUSCREW_READONLY === '1';
76
83
  if (process.env.NEXUSCREW_REPLY_LABEL) e.replyLabel = process.env.NEXUSCREW_REPLY_LABEL;
77
84
  if (process.env.NEXUSCREW_FILES_ROOT) e.filesRoot = process.env.NEXUSCREW_FILES_ROOT;
@@ -47,7 +47,7 @@ const { requireSharedTmuxProtection } = require('../tmux/shared-server.js');
47
47
  // da builtin.js; httpError e' usato anche dal CRUD del facade.
48
48
  const { createBuiltinRuntime } = require('./runtime.js');
49
49
  const {
50
- composeLaunchArgv, composeClientInvocation, minimalEnv, promptCharsOk,
50
+ composeLaunchArgv, composeClientInvocation, alternateScreenArgs, minimalEnv, promptCharsOk,
51
51
  redactSecrets, sanitizeEarlyDiagnostic, waitStablePane, httpError, migrateLegacyTmuxSessions,
52
52
  } = require('./launch.js');
53
53
 
@@ -876,6 +876,7 @@ module.exports = {
876
876
  resolveCellCwd,
877
877
  composeLaunchArgv,
878
878
  composeClientInvocation,
879
+ alternateScreenArgs,
879
880
  minimalEnv,
880
881
  promptCharsOk,
881
882
  redactSecrets,
@@ -309,6 +309,21 @@ function composeLaunchArgv({ tmuxSession, realCwd, engine, cell }) {
309
309
  return ['new-session', '-d', '-s', tmuxSession, '-c', realCwd, child.command, ...child.args];
310
310
  }
311
311
 
312
+ // Pure: applica alternate-screen soltanto alla sessione Fleet appena creata.
313
+ // Il target exact-match protegge da nomi che condividono un prefisso; `-w`
314
+ // mantiene l'opzione window-local e non muta mai il server tmux globale.
315
+ // La hook e' necessaria perche' le finestre create dopo new-session non
316
+ // ereditano l'opzione della prima finestra.
317
+ function alternateScreenArgs(session, alternateScreen = false) {
318
+ if (!isTmuxSafeName(session)) return null;
319
+ if (alternateScreen !== false) return [];
320
+ const target = `=${session}:`;
321
+ return [
322
+ ['set-option', '-t', target, '-w', 'alternate-screen', 'off'],
323
+ ['set-hook', '-t', target, 'after-new-window', 'set-option -w alternate-screen off'],
324
+ ];
325
+ }
326
+
312
327
  // Poll has-session entro readyMs (no delay fisso cieco). Ritorna true se la sessione
313
328
  // e' viva entro la deadline, false altrimenti (command uscito / mai partita).
314
329
  async function waitAlive(tmuxBin, session, { env, readyMs }) {
@@ -380,6 +395,7 @@ module.exports = {
380
395
  promptCharsOk,
381
396
  composeClientInvocation,
382
397
  composeLaunchArgv,
398
+ alternateScreenArgs,
383
399
  migrateLegacyTmuxSessions,
384
400
  waitAlive,
385
401
  waitStablePane,