@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.
- package/CHANGELOG.md +56 -1
- package/README.md +2 -1
- package/frontend/dist/assets/index-CrZBnpKn.css +32 -0
- package/frontend/dist/assets/index-CwZySkm2.js +93 -0
- package/frontend/dist/assets/index-hq-2ORFQ.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/acl.js +65 -0
- package/lib/audio/adapters.js +233 -0
- package/lib/audio/bridge-auth.js +176 -0
- package/lib/audio/capability.js +40 -0
- package/lib/audio/consent.js +64 -0
- package/lib/audio/dispatch.js +154 -0
- package/lib/audio/group-receipt.js +119 -0
- package/lib/audio/group-speak.js +146 -0
- package/lib/audio/groups.js +121 -0
- package/lib/audio/origin.js +128 -0
- package/lib/audio/queue.js +191 -0
- package/lib/audio/rate-limit.js +71 -0
- package/lib/audio/receipt.js +146 -0
- package/lib/audio/routes.js +374 -0
- package/lib/audio/speak.js +125 -0
- package/lib/cli/doctor.js +37 -0
- package/lib/config.js +7 -0
- package/lib/fleet/builtin.js +2 -1
- package/lib/fleet/launch.js +16 -0
- package/lib/fleet/managed.js +53 -2
- package/lib/fleet/provider.js +6 -0
- package/lib/fleet/runtime.js +14 -1
- package/lib/mcp/server.js +27 -3
- package/lib/mcp/tools.js +142 -0
- package/lib/nodes/tunnel-supervisor.js +33 -7
- package/lib/nodes/tunnel.js +21 -0
- package/lib/proxy/federation.js +41 -11
- package/lib/proxy/hop-proof.js +50 -0
- package/lib/server.js +97 -3
- package/lib/settings/routes.js +150 -4
- package/lib/tmux/lifecycle.js +24 -3
- package/package.json +1 -1
- package/skills/nexuscrew-agent/SKILL.md +30 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/proxy/hop-proof.js — prova che una richiesta arrivata su /api e' l'ULTIMO
|
|
3
|
+
// hop di una route federata generata da QUESTO processo, non un POST diretto di
|
|
4
|
+
// un client locale che ha semplicemente scritto gli header a mano.
|
|
5
|
+
//
|
|
6
|
+
// Perche' serve. `controlledVisited()` lega gia' `x-nexuscrew-visited` al peer
|
|
7
|
+
// autenticato dal suo acceptToken, ma l'ultimo hop entra in `/api/<resource>`
|
|
8
|
+
// con il Bearer LOCALE: a quel punto l'handler non distingue piu' un inbound
|
|
9
|
+
// federato da una richiesta diretta di chiunque possieda il token della UI.
|
|
10
|
+
// `cleanHeaders()` cancella gia' `x-nexuscrew-{route,visited,hop}` da ogni
|
|
11
|
+
// header in ingresso, quindi il canale e' riservato: basta renderlo non
|
|
12
|
+
// riproducibile. La firma usa un segreto casuale per-processo, mai su disco,
|
|
13
|
+
// mai esposto da un'API: un client locale non puo' calcolarla.
|
|
14
|
+
//
|
|
15
|
+
// Il payload firmato include metodo e path oltre alla catena visited, cosi' una
|
|
16
|
+
// prova non e' trasportabile su un'altra risorsa o su un altro verbo.
|
|
17
|
+
const crypto = require('node:crypto');
|
|
18
|
+
|
|
19
|
+
const HOP_HEADER = 'x-nexuscrew-hop';
|
|
20
|
+
const PREFIX = 'NEXUSCREW-HOP-V1';
|
|
21
|
+
|
|
22
|
+
function createHopSecret() {
|
|
23
|
+
return crypto.randomBytes(32);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function canonicalHop({ method, path: reqPath, visited }) {
|
|
27
|
+
const chain = Array.isArray(visited) ? visited.join(',') : String(visited || '');
|
|
28
|
+
return `${PREFIX}\n${String(method || '').toUpperCase()}\n${String(reqPath || '')}\n${chain}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// signHop(): hex HMAC-SHA256. Ritorna null senza segreto, cosi' un server che
|
|
32
|
+
// non ha inizializzato la prova non emette un header vuoto interpretabile.
|
|
33
|
+
function signHop(secret, parts) {
|
|
34
|
+
if (!secret) return null;
|
|
35
|
+
return crypto.createHmac('sha256', secret).update(canonicalHop(parts)).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// verifyHop(): confronto timing-safe. Fail-closed su qualunque anomalia
|
|
39
|
+
// (segreto assente, header assente, lunghezza diversa, hex non valido).
|
|
40
|
+
function verifyHop(secret, parts, proof) {
|
|
41
|
+
if (!secret || typeof proof !== 'string' || !/^[a-f0-9]{64}$/i.test(proof)) return false;
|
|
42
|
+
const expected = signHop(secret, parts);
|
|
43
|
+
if (!expected) return false;
|
|
44
|
+
const a = Buffer.from(expected, 'hex');
|
|
45
|
+
const b = Buffer.from(proof.toLowerCase(), 'hex');
|
|
46
|
+
if (a.length !== b.length) return false;
|
|
47
|
+
return crypto.timingSafeEqual(a, b);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { createHopSecret, signHop, verifyHop, canonicalHop, HOP_HEADER };
|
package/lib/server.js
CHANGED
|
@@ -32,6 +32,17 @@ const nodesInventory = require('./nodes/inventory.js');
|
|
|
32
32
|
const topologyCache = require('./nodes/topology-cache.js');
|
|
33
33
|
const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
|
|
34
34
|
const federation = require('./proxy/federation.js');
|
|
35
|
+
const { audioRoutes } = require('./audio/routes.js');
|
|
36
|
+
const { isConsent: isAudioConsent } = require('./audio/consent.js');
|
|
37
|
+
const { createOriginResolver } = require('./audio/origin.js');
|
|
38
|
+
const { createAudioAcl } = require('./audio/acl.js');
|
|
39
|
+
const { createDispatcher } = require('./audio/dispatch.js');
|
|
40
|
+
const { createSpeakQueue } = require('./audio/queue.js');
|
|
41
|
+
const { createReceiptStore } = require('./audio/receipt.js');
|
|
42
|
+
const audioAdapters = require('./audio/adapters.js');
|
|
43
|
+
const audioGroups = require('./audio/groups.js');
|
|
44
|
+
const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
|
|
45
|
+
const { createHopSecret } = require('./proxy/hop-proof.js');
|
|
35
46
|
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
36
47
|
const decksStore = require('./decks/store.js');
|
|
37
48
|
const { decksRoutes } = require('./decks/routes.js');
|
|
@@ -133,6 +144,22 @@ function createServer(opts = {}) {
|
|
|
133
144
|
const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
|
|
134
145
|
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
135
146
|
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
147
|
+
const audioHome = cfg.home || os.homedir();
|
|
148
|
+
// Stato audio ancorato alla directory del token (come notifyDir): server,
|
|
149
|
+
// Settings e bridge MCP devono guardare gli stessi file anche quando un test
|
|
150
|
+
// isola l'istanza fuori dalla home reale.
|
|
151
|
+
const audioCfg = {
|
|
152
|
+
home: audioHome,
|
|
153
|
+
tokenPath: cfg.tokenPath,
|
|
154
|
+
audioConsentPath: cfg.audioConsentPath,
|
|
155
|
+
audioGroupsPath: cfg.audioGroupsPath,
|
|
156
|
+
audioBridgeSecretPath: cfg.audioBridgeSecretPath || path.join(notifyDir, 'audio-bridge.key'),
|
|
157
|
+
};
|
|
158
|
+
// Segreto per-processo della prova di hop federata: vive solo in memoria, non
|
|
159
|
+
// e' su disco e nessuna API lo espone. Serve a distinguere l'ultimo hop di una
|
|
160
|
+
// route federata da un POST diretto di chi possiede il token della UI.
|
|
161
|
+
const hopSecret = createHopSecret();
|
|
162
|
+
const audioNonceCache = createNonceCache();
|
|
136
163
|
function resolveNode(name) {
|
|
137
164
|
const st = nodesStore.loadStore(nodesPath);
|
|
138
165
|
if (!st) return null;
|
|
@@ -246,7 +273,10 @@ function createServer(opts = {}) {
|
|
|
246
273
|
try {
|
|
247
274
|
const { name, cwd, preset } = req.body || {};
|
|
248
275
|
await createSession(cfg.tmuxBin, { name, cwd, preset },
|
|
249
|
-
{
|
|
276
|
+
{
|
|
277
|
+
home: os.homedir(), presets: cfg.sessionPresets, ensureProtection: ensureTmuxProtection,
|
|
278
|
+
alternateScreen: cfg.alternateScreen, log: cfg.log,
|
|
279
|
+
});
|
|
250
280
|
res.status(201).json({ created: true, name });
|
|
251
281
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
252
282
|
});
|
|
@@ -299,6 +329,64 @@ function createServer(opts = {}) {
|
|
|
299
329
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
300
330
|
}));
|
|
301
331
|
api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
|
|
332
|
+
// Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
|
|
333
|
+
// store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
|
|
334
|
+
// asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
|
|
335
|
+
// risoluzione dell'origine sempre vuota e la feature morta senza che un test
|
|
336
|
+
// sul router se ne accorga.
|
|
337
|
+
const audioNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
338
|
+
// Seam di test per l'adapter: permette di esercitare il percorso completo
|
|
339
|
+
// (route -> gate -> coda -> receipt) sul server REALE senza emettere suono.
|
|
340
|
+
// In produzione resta il probe locale.
|
|
341
|
+
const audioAdapter = cfg.audioAdapterSeam
|
|
342
|
+
|| audioAdapters.createAdapter(audioAdapters.detectAdapter({ env: cfg.env || process.env }));
|
|
343
|
+
const audioReceipts = createReceiptStore();
|
|
344
|
+
const audioQueue = createSpeakQueue({
|
|
345
|
+
adapter: audioAdapter,
|
|
346
|
+
// La coda e' l'unica a sapere com'e' finito davvero un enunciato: e' lei ad
|
|
347
|
+
// aggiornare il receipt da `accepted` a spoken/refused/unknown.
|
|
348
|
+
onStatus: (utteranceId, status, reason) => { try { audioReceipts.update(utteranceId, status, reason); } catch (_) {} },
|
|
349
|
+
});
|
|
350
|
+
api.use('/audio', audioRoutes({
|
|
351
|
+
readonly: proxyReadonly,
|
|
352
|
+
localNodeId: audioNodeId,
|
|
353
|
+
receiptStore: audioReceipts,
|
|
354
|
+
adapter: audioAdapter,
|
|
355
|
+
queue: audioQueue,
|
|
356
|
+
acl: createAudioAcl({ nodesPath }),
|
|
357
|
+
consent: () => { try { return isAudioConsent(audioCfg, audioHome); } catch (_) { return false; } },
|
|
358
|
+
originResolver: createOriginResolver({
|
|
359
|
+
localNodeId: audioNodeId,
|
|
360
|
+
// Celle Fleet ATTIVE in questo momento, attese davvero.
|
|
361
|
+
activeCells: async () => {
|
|
362
|
+
const fleet = await fleetP;
|
|
363
|
+
if (!fleet || fleet.available !== true || typeof fleet.status !== 'function') return [];
|
|
364
|
+
const st = await fleet.status();
|
|
365
|
+
return Array.isArray(st && st.cells) ? st.cells.map((c) => ({
|
|
366
|
+
cell: c && c.cell, tmuxSession: c && c.tmuxSession,
|
|
367
|
+
active: c && c.active === true && c.tmux !== false,
|
|
368
|
+
})) : [];
|
|
369
|
+
},
|
|
370
|
+
bridgeSecret: () => { try { return loadOrCreateBridgeSecret(bridgeSecretPath(audioCfg, audioHome)); } catch (_) { return null; } },
|
|
371
|
+
hopSecret: () => hopSecret,
|
|
372
|
+
nonceCache: audioNonceCache,
|
|
373
|
+
}),
|
|
374
|
+
dispatcher: createDispatcher({
|
|
375
|
+
localNodeId: audioNodeId,
|
|
376
|
+
peers: async () => {
|
|
377
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
378
|
+
if (!st) return [];
|
|
379
|
+
const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
|
|
380
|
+
return nodesInventory.buildInventory({
|
|
381
|
+
direct: nodesStore.redactStore(st).nodes,
|
|
382
|
+
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
386
|
+
localToken: () => tokenHolder.value,
|
|
387
|
+
}),
|
|
388
|
+
getGroup: (name) => audioGroups.getGroup(audioCfg, name, audioHome),
|
|
389
|
+
}));
|
|
302
390
|
api.use('/cells', cellsRoutes({
|
|
303
391
|
fleetP,
|
|
304
392
|
instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
@@ -359,7 +447,12 @@ function createServer(opts = {}) {
|
|
|
359
447
|
// Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
|
|
360
448
|
// wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
|
|
361
449
|
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
362
|
-
api.use('/settings', settingsRoutes({
|
|
450
|
+
api.use('/settings', settingsRoutes({
|
|
451
|
+
cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort,
|
|
452
|
+
// Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
|
|
453
|
+
// locale a frase fissa e lo stop sovrano, mai una seconda sintesi parallela.
|
|
454
|
+
audio: { adapter: audioAdapter, queue: audioQueue },
|
|
455
|
+
}));
|
|
363
456
|
api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
|
|
364
457
|
api.get('/topology', async (_req, res) => {
|
|
365
458
|
try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
|
|
@@ -367,6 +460,7 @@ function createServer(opts = {}) {
|
|
|
367
460
|
});
|
|
368
461
|
api.use('/route', federation.localRouter({
|
|
369
462
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
463
|
+
hopSecret: () => hopSecret,
|
|
370
464
|
}));
|
|
371
465
|
api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
|
|
372
466
|
api.post('/voice/transcribe',
|
|
@@ -392,7 +486,7 @@ function createServer(opts = {}) {
|
|
|
392
486
|
app.use('/api', api);
|
|
393
487
|
app.use('/federation', federation.peerRouter({
|
|
394
488
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
395
|
-
fetchImpl: healthFetch,
|
|
489
|
+
fetchImpl: healthFetch, hopSecret: () => hopSecret,
|
|
396
490
|
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
397
491
|
}));
|
|
398
492
|
|
package/lib/settings/routes.js
CHANGED
|
@@ -45,6 +45,10 @@ const express = require('express');
|
|
|
45
45
|
|
|
46
46
|
const nodesStore = require('../nodes/store.js');
|
|
47
47
|
const nodeAliases = require('../nodes/aliases.js');
|
|
48
|
+
const { setConsent: setAudioConsent, isConsent: isAudioConsent } = require('../audio/consent.js');
|
|
49
|
+
const { describeCapability } = require('../audio/capability.js');
|
|
50
|
+
const audioAdapters = require('../audio/adapters.js');
|
|
51
|
+
const audioGroups = require('../audio/groups.js');
|
|
48
52
|
const nodesCmds = require('../nodes/commands.js');
|
|
49
53
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
50
54
|
const peering = require('../nodes/peering.js');
|
|
@@ -63,10 +67,11 @@ const { createPairHandler } = require('./pairing-coordinator.js');
|
|
|
63
67
|
const { publicPeeringRoutes } = require('./public-peering-routes.js');
|
|
64
68
|
|
|
65
69
|
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
66
|
-
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
|
|
70
|
+
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
|
|
67
71
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
68
72
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
69
73
|
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
|
|
74
|
+
const LOCAL_AUDIO_TEST_TEXT = 'NexusCrew audio test.';
|
|
70
75
|
|
|
71
76
|
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
|
72
77
|
// NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
|
|
@@ -165,6 +170,10 @@ function settingsRoutes(deps = {}) {
|
|
|
165
170
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
166
171
|
const platform = seams.platform || detectPlatform();
|
|
167
172
|
const runtimePort = typeof deps.runtimePort === 'function' ? deps.runtimePort : () => cfg.port;
|
|
173
|
+
// Il server passa qui l'adapter e la coda REALI. Il fallback serve solo ai
|
|
174
|
+
// test unitari di Settings costruiti senza il server: non deve creare una
|
|
175
|
+
// seconda coda che dichiara un test riuscito ma non parla sul nodo vero.
|
|
176
|
+
const audioService = deps.audio && typeof deps.audio === 'object' ? deps.audio : null;
|
|
168
177
|
|
|
169
178
|
const r = express.Router();
|
|
170
179
|
r.use(express.json({ limit: '8kb' }));
|
|
@@ -233,6 +242,9 @@ function settingsRoutes(deps = {}) {
|
|
|
233
242
|
service,
|
|
234
243
|
version: VERSION,
|
|
235
244
|
autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
|
|
245
|
+
// Valore effettivo: l'env mantiene la precedenza sul file e il runtime
|
|
246
|
+
// Fleet legge lo stesso oggetto cfg alla prossima creazione di cella.
|
|
247
|
+
alternateScreen: cfg.alternateScreen === true,
|
|
236
248
|
// Nome dispositivo proposto per i form di pairing (etichetta umana, non
|
|
237
249
|
// lo slug). La UI lo precompila e lascia editing libero.
|
|
238
250
|
deviceName,
|
|
@@ -254,10 +266,10 @@ function settingsRoutes(deps = {}) {
|
|
|
254
266
|
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
255
267
|
}
|
|
256
268
|
for (const k of Object.keys(b)) {
|
|
257
|
-
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
|
|
269
|
+
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen)` });
|
|
258
270
|
}
|
|
259
271
|
if (Object.keys(b).length === 0) {
|
|
260
|
-
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
|
|
272
|
+
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate, alternateScreen)' });
|
|
261
273
|
}
|
|
262
274
|
if (b.roles !== undefined) {
|
|
263
275
|
if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
|
|
@@ -287,6 +299,9 @@ function settingsRoutes(deps = {}) {
|
|
|
287
299
|
if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
|
|
288
300
|
return send(res, 400, { error: 'autoUpdate deve essere boolean' });
|
|
289
301
|
}
|
|
302
|
+
if (b.alternateScreen !== undefined && typeof b.alternateScreen !== 'boolean') {
|
|
303
|
+
return send(res, 400, { error: 'alternateScreen deve essere boolean' });
|
|
304
|
+
}
|
|
290
305
|
|
|
291
306
|
// merge sul config esistente (preserva le chiavi non gestite qui)
|
|
292
307
|
const { cfg: current } = readConfigFile(configPath);
|
|
@@ -298,14 +313,23 @@ function settingsRoutes(deps = {}) {
|
|
|
298
313
|
if (b.port !== undefined) next.port = b.port;
|
|
299
314
|
if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
|
|
300
315
|
if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
|
|
316
|
+
if (b.alternateScreen !== undefined) next.alternateScreen = b.alternateScreen;
|
|
301
317
|
atomicWriteConfig(configPath, next);
|
|
302
318
|
if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
|
|
303
319
|
updater.setEnabled(b.autoUpdate);
|
|
304
320
|
}
|
|
321
|
+
// Vale dalla prossima fleet up, mai retroattivamente; rispetta l'env che
|
|
322
|
+
// conserva la precedenza sul file per tutta la vita del processo.
|
|
323
|
+
if (b.alternateScreen !== undefined && process.env.NEXUSCREW_ALTERNATE_SCREEN === undefined) {
|
|
324
|
+
cfg.alternateScreen = b.alternateScreen;
|
|
325
|
+
}
|
|
305
326
|
|
|
306
327
|
const out = {
|
|
307
328
|
saved: true,
|
|
308
|
-
config: {
|
|
329
|
+
config: {
|
|
330
|
+
roles: next.roles, port: next.port, wizardDone: next.wizardDone,
|
|
331
|
+
autoUpdate: next.autoUpdate !== false, alternateScreen: next.alternateScreen === true,
|
|
332
|
+
},
|
|
309
333
|
};
|
|
310
334
|
// Il server legge la porta SOLO allo startup: il cambio vale al prossimo
|
|
311
335
|
// restart (contratto dichiarato nella risposta, la UI avvisa).
|
|
@@ -650,6 +674,23 @@ function settingsRoutes(deps = {}) {
|
|
|
650
674
|
// the store/UI says private. Same-state OFF revokes first, then enters
|
|
651
675
|
// the spec-aware start path without rewriting nodes.json.
|
|
652
676
|
if (body.shared) {
|
|
677
|
+
// A same-state Share ON may target a supervisor stuck in "degraded"
|
|
678
|
+
// (reverse channel retrying on a fixed cadence after the initial
|
|
679
|
+
// budget). Accelerate ONLY a degraded supervisor that readTunnelState
|
|
680
|
+
// confirms we own (owned live pidfile + sidecar): force a local
|
|
681
|
+
// restart so the -R is retried immediately instead of waiting for the
|
|
682
|
+
// steady retry. A healthy tunnel stays idempotent, and we never revoke
|
|
683
|
+
// on the hub nor change the persisted shared intent in this branch.
|
|
684
|
+
const liveState = nodesTunnel.readTunnelState(home, name);
|
|
685
|
+
if (liveState && liveState.phase === 'degraded') {
|
|
686
|
+
// Reuse the normal restart transaction: it fails closed on a local
|
|
687
|
+
// spawn error and waits for the authenticated -L health probe before
|
|
688
|
+
// publishing Share again. A process restart alone is never proof
|
|
689
|
+
// that the reverse channel is ready.
|
|
690
|
+
await ensureLocal({ restart: true });
|
|
691
|
+
await notifyHub(true);
|
|
692
|
+
return send(res, 200, { name, shared: true, unchanged: true, accelerated: true });
|
|
693
|
+
}
|
|
653
694
|
await ensureLocal();
|
|
654
695
|
await notifyHub(true);
|
|
655
696
|
return send(res, 200, { name, shared: true, unchanged: true, reconciled: true });
|
|
@@ -709,6 +750,111 @@ function settingsRoutes(deps = {}) {
|
|
|
709
750
|
if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
710
751
|
return applyNodeEdit(res, name, { label });
|
|
711
752
|
});
|
|
753
|
+
// Consenso audio: proprieta' del nodo LOCALE, cioe' della macchina che
|
|
754
|
+
// suonerebbe, non di un record peer. Vive in un file dedicato e si muta solo
|
|
755
|
+
// da qui: `/audio/consent` non compare nella whitelist federation, quindi
|
|
756
|
+
// nessun peer puo' concedersi da remoto il permesso di far parlare un
|
|
757
|
+
// computer altrui. E' il terzo asse, separato da `shared` (pubblicazione) e da
|
|
758
|
+
// `visibility` (routing): nessuno dei due autorizza un suono fisico.
|
|
759
|
+
const audioCfg = {
|
|
760
|
+
home,
|
|
761
|
+
tokenPath: cfg && cfg.tokenPath,
|
|
762
|
+
audioConsentPath: cfg && cfg.audioConsentPath,
|
|
763
|
+
audioGroupsPath: cfg && cfg.audioGroupsPath,
|
|
764
|
+
};
|
|
765
|
+
const localAudioAdapter = () => {
|
|
766
|
+
if (audioService) return audioService.adapter || null;
|
|
767
|
+
return audioAdapters.createAdapter(audioAdapters.detectAdapter({}));
|
|
768
|
+
};
|
|
769
|
+
const localAudioQueue = () => (audioService ? audioService.queue || null : null);
|
|
770
|
+
const localAudioCapability = () => {
|
|
771
|
+
const consent = isAudioConsent(audioCfg, home) === true;
|
|
772
|
+
return describeCapability({ adapter: localAudioAdapter(), consent });
|
|
773
|
+
};
|
|
774
|
+
const emptyBody = (body) => body === undefined
|
|
775
|
+
|| (body && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length === 0);
|
|
776
|
+
r.get('/audio', (_req, res) => {
|
|
777
|
+
try {
|
|
778
|
+
// Capability redatta: id logico dell'adapter e limiti dichiarati, mai il
|
|
779
|
+
// path del binario e mai l'elenco delle voci di sistema.
|
|
780
|
+
return send(res, 200, localAudioCapability());
|
|
781
|
+
} catch (_) {
|
|
782
|
+
return send(res, 500, { error: 'stato audio non leggibile' });
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
r.patch('/audio/consent', mutGate, (req, res) => {
|
|
786
|
+
const consent = req.body && req.body.consent;
|
|
787
|
+
if (typeof consent !== 'boolean') {
|
|
788
|
+
return send(res, 400, { error: 'body non valido: atteso {consent:boolean}' });
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
setAudioConsent(audioCfg, consent, home);
|
|
792
|
+
return send(res, 200, localAudioCapability());
|
|
793
|
+
} catch (_) {
|
|
794
|
+
return send(res, 500, { error: 'scrittura consenso audio non riuscita' });
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
// Prova esplicita dal pannello Settings. Non e' `nc_speak`: non accetta testo,
|
|
798
|
+
// target, cella o origine dal browser e non e' inoltrabile via federazione.
|
|
799
|
+
// Usa solo una frase fissa, il consenso del nodo e la sua coda reale; un 200
|
|
800
|
+
// descrive l'esito onesto dell'azione, NON prova che qualcuno abbia sentito.
|
|
801
|
+
r.post('/audio/test', mutGate, (req, res) => {
|
|
802
|
+
if (!emptyBody(req.body)) return send(res, 400, { error: 'body non valido: nessun campo ammesso' });
|
|
803
|
+
try {
|
|
804
|
+
const capability = localAudioCapability();
|
|
805
|
+
if (capability.consent !== true) return send(res, 200, { status: 'refused', reason: 'consent' });
|
|
806
|
+
const queue = localAudioQueue();
|
|
807
|
+
if (capability.installed !== true || !queue || typeof queue.enqueue !== 'function') {
|
|
808
|
+
return send(res, 200, { status: 'refused', reason: 'no-adapter' });
|
|
809
|
+
}
|
|
810
|
+
const result = queue.enqueue({
|
|
811
|
+
utteranceId: `settings-${crypto.randomUUID()}`,
|
|
812
|
+
text: LOCAL_AUDIO_TEST_TEXT,
|
|
813
|
+
urgency: 'normal',
|
|
814
|
+
});
|
|
815
|
+
return send(res, 200, {
|
|
816
|
+
status: result && result.status === 'accepted' ? 'accepted' : 'refused',
|
|
817
|
+
...(result && result.reason ? { reason: String(result.reason).slice(0, 64) } : {}),
|
|
818
|
+
});
|
|
819
|
+
} catch (_) {
|
|
820
|
+
return send(res, 500, { error: 'prova audio locale non riuscita' });
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
// Stop locale sovrano: resta disponibile anche in READONLY. Non richiede rete,
|
|
824
|
+
// origin MCP o receipt, perche' zittire il dispositivo non deve dipendere da
|
|
825
|
+
// chi l'aveva fatto parlare. Ferma tutti i test/enunciati ancora in coda.
|
|
826
|
+
r.post('/audio/stop', (req, res) => {
|
|
827
|
+
if (!emptyBody(req.body)) return send(res, 400, { error: 'body non valido: nessun campo ammesso' });
|
|
828
|
+
try {
|
|
829
|
+
const queue = localAudioQueue();
|
|
830
|
+
const stopped = !!(queue && typeof queue.stopAll === 'function' && queue.stopAll());
|
|
831
|
+
return send(res, 200, { status: 'accepted', stopped });
|
|
832
|
+
} catch (_) {
|
|
833
|
+
return send(res, 500, { error: 'stop audio locale non riuscito' });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
// Gruppi dell'ORIGINE: configurazione locale, separata dal consenso di ogni
|
|
837
|
+
// endpoint. Non e' nella whitelist federation e una lista non puo' far
|
|
838
|
+
// parlare un nodo senza il suo consenso/liveness al momento della consegna.
|
|
839
|
+
r.get('/audio/groups', (_req, res) => {
|
|
840
|
+
try { return send(res, 200, { groups: audioGroups.listGroups(audioCfg, home) }); }
|
|
841
|
+
catch (_) { return send(res, 500, { error: 'gruppi audio non leggibili' }); }
|
|
842
|
+
});
|
|
843
|
+
r.put('/audio/groups/:name', mutGate, (req, res) => {
|
|
844
|
+
const body = req.body;
|
|
845
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
846
|
+
|| Object.keys(body).some((key) => !['targets', 'mode'].includes(key))) {
|
|
847
|
+
return send(res, 400, { error: 'body non valido: attesi targets e mode' });
|
|
848
|
+
}
|
|
849
|
+
try { return send(res, 200, { group: audioGroups.saveGroup(audioCfg, String(req.params.name || ''), body, home) }); }
|
|
850
|
+
catch (_) { return send(res, 400, { error: 'gruppo audio non valido' }); }
|
|
851
|
+
});
|
|
852
|
+
r.delete('/audio/groups/:name', mutGate, (req, res) => {
|
|
853
|
+
try {
|
|
854
|
+
const removed = audioGroups.removeGroup(audioCfg, String(req.params.name || ''), home);
|
|
855
|
+
return removed ? send(res, 200, { removed: true }) : send(res, 404, { error: 'gruppo audio non trovato' });
|
|
856
|
+
} catch (_) { return send(res, 400, { error: 'nome gruppo audio non valido' }); }
|
|
857
|
+
});
|
|
712
858
|
|
|
713
859
|
// Viewer-local aliases for routed nodes. These routes are local-only and do
|
|
714
860
|
// not mutate the peer identity, route, label, or federated topology.
|
package/lib/tmux/lifecycle.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const { execFile } = require('node:child_process');
|
|
5
|
+
const { alternateScreenArgs, minimalEnv, tmuxExec } = require('../fleet/launch.js');
|
|
5
6
|
|
|
6
7
|
// Preset allowlistati (audit F1): il client sceglie un NOME, mai un comando.
|
|
7
8
|
// Estendibili da config.json `sessionPresets` (name -> array argv di stringhe).
|
|
@@ -52,9 +53,25 @@ function buildCreateArgs(name, realCwd, preset, extraPresets) {
|
|
|
52
53
|
|
|
53
54
|
function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// Best effort per la sola sessione PWA appena creata. Riusa il builder Fleet:
|
|
57
|
+
// target exact-match, opzione window-local e hook per le finestre successive.
|
|
58
|
+
// Un tmux che rifiuta l'opzione non deve mai trasformare una creazione riuscita
|
|
59
|
+
// in un errore HTTP; nessuna opzione globale viene emessa.
|
|
60
|
+
async function configureAlternateScreen(tmuxBin, name, alternateScreen, log) {
|
|
61
|
+
const steps = alternateScreenArgs(name, alternateScreen);
|
|
62
|
+
if (!steps) return;
|
|
63
|
+
const warn = typeof log === 'function' ? log : console.warn;
|
|
64
|
+
for (const args of steps) {
|
|
65
|
+
const configured = await tmuxExec(tmuxBin, args, { env: minimalEnv(), timeoutMs: 2000 });
|
|
66
|
+
if (configured.err) warn(`session alternate-screen setup failed for ${name}; continuing`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function createSession(tmuxBin, { name, cwd, preset }, {
|
|
71
|
+
home, presets, ensureProtection, alternateScreen, log,
|
|
72
|
+
} = {}) {
|
|
56
73
|
if (typeof ensureProtection === 'function') await ensureProtection();
|
|
57
|
-
|
|
74
|
+
await new Promise((resolve, reject) => {
|
|
58
75
|
if (!validSessionName(name)) return reject(httpError(400, 'nome sessione non valido'));
|
|
59
76
|
// Il namespace cloud-* e' delle celle fleet (audit finale #1): una generica
|
|
60
77
|
// con quel prefisso occuperebbe il binding per-nome e sarebbe poi 409 al kill.
|
|
@@ -71,6 +88,7 @@ async function createSession(tmuxBin, { name, cwd, preset }, { home, presets, en
|
|
|
71
88
|
resolve();
|
|
72
89
|
});
|
|
73
90
|
});
|
|
91
|
+
await configureAlternateScreen(tmuxBin, name, alternateScreen, log);
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
async function killSession(tmuxBin, name, { ensureProtection } = {}) {
|
|
@@ -86,4 +104,7 @@ async function killSession(tmuxBin, name, { ensureProtection } = {}) {
|
|
|
86
104
|
});
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
module.exports = {
|
|
107
|
+
module.exports = {
|
|
108
|
+
PRESETS, validSessionName, resolveCwd, isProtectedSession, buildCreateArgs,
|
|
109
|
+
configureAlternateScreen, createSession, killSession,
|
|
110
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nexuscrew-agent
|
|
3
|
-
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file,
|
|
3
|
+
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect runtime or deck context, list authorized Fleet cells, message an exact active cell, read its inbox, deliver a file, recover from local tmux messages that remain unsubmitted or garbled, or when a drag in the web terminal does not scroll a full-screen TUI. Prefer nc_notify, nc_ask, nc_status, nc_deck, nc_cells, nc_send_cell, nc_inbox, and nc_send_file; use bundled tmux/file helpers only as a declared compatibility fallback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NexusCrew Agent I/O
|
|
@@ -91,6 +91,33 @@ It does: `load-buffer` → `paste-buffer -p` (bracketed paste) → burst-flush (
|
|
|
91
91
|
tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
## Terminal scrolling (NexusCrew-managed tmux)
|
|
95
|
+
|
|
96
|
+
A drag or wheel in the NexusCrew web terminal browses the tmux history through
|
|
97
|
+
copy-mode. That works only while the pane is on the normal screen. A full-screen
|
|
98
|
+
TUI — Claude Code, Codex, Agy, `vim`, `less`, `htop` — renders in the terminal's
|
|
99
|
+
**alternate buffer**, which has no scrollback: copy-mode has nothing to scroll, so
|
|
100
|
+
the gesture does nothing. On a phone, where the drag is the only scroll gesture,
|
|
101
|
+
the terminal looks frozen.
|
|
102
|
+
|
|
103
|
+
NexusCrew applies `alternate-screen off` **per session** when it creates a
|
|
104
|
+
managed Fleet session or a PWA session. No global tmux option or `~/.tmux.conf` edit is needed:
|
|
105
|
+
tmux then ignores `smcup`/`rmcup` for that session, TUI output stays on the
|
|
106
|
+
normal screen, the transcript flows into tmux history, and drag/wheel scrolling
|
|
107
|
+
work. The setting is also applied to windows created later in that session.
|
|
108
|
+
|
|
109
|
+
- It applies only to future NexusCrew-created sessions. A running or unmanaged
|
|
110
|
+
pane remains unchanged; never restart a Fleet cell just to change it.
|
|
111
|
+
- To opt out, set `alternateScreen: true` in NexusCrew's local `config.json` or
|
|
112
|
+
start the service with `NEXUSCREW_ALTERNATE_SCREEN=1`.
|
|
113
|
+
- Verify on a pane that is running a TUI:
|
|
114
|
+
`tmux display-message -p -t '=<session>:' '#{alternate_on}'` must print `0`.
|
|
115
|
+
- Trade-off: after quitting `vim`/`less`/`htop` the last screen stays on display
|
|
116
|
+
instead of being restored.
|
|
117
|
+
- Full-screen redraws consume history. Keep a generous user-owned
|
|
118
|
+
`history-limit` (100000 is a good default); `nexuscrew doctor` warns below
|
|
119
|
+
10000 and suggests `set -g history-limit 100000`, but never writes it.
|
|
120
|
+
|
|
94
121
|
## Quick reference
|
|
95
122
|
|
|
96
123
|
| Goal | Do this |
|
|
@@ -106,6 +133,7 @@ tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
|
|
|
106
133
|
| Send a prompt/command to a session | `nc-send <session> "text"` |
|
|
107
134
|
| Queue text without running it | `nc-send <session> --no-submit "text"` |
|
|
108
135
|
| Confirm a send worked | `tmux capture-pane -t <session> -p | tail` |
|
|
136
|
+
| Make a new managed terminal scrollable on a full-screen TUI | NexusCrew default; verify `#{alternate_on}` is `0` |
|
|
109
137
|
|
|
110
138
|
## Common mistakes
|
|
111
139
|
|
|
@@ -116,3 +144,4 @@ tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
|
|
|
116
144
|
- **Delivering to a guessed session name** → file lands in an orphan folder with no badge. Use `nc-deliver` (it reads the real session).
|
|
117
145
|
- **Sending to an ambiguous cell name** → call `nc_cells` and use the full owner-qualified ID.
|
|
118
146
|
- **Calling `submitted` a completed task** → it is only a transport receipt; require an explicit result callback.
|
|
147
|
+
- **Treating a dead scroll gesture as a web-terminal bug** → the pane is in the alternate buffer. Check whether it predates the NexusCrew setting or opted out with `alternateScreen:true`; never send raw page keys to a TUI to work around it.
|