@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
@@ -60,7 +60,11 @@ const ALIBABA_PI_MODELS = Object.freeze([
60
60
  ]);
61
61
 
62
62
  const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
63
- const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
63
+ const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', 'credentialSourcePolicy', ...CUSTOM_KEYS]);
64
+ // Explicit credential source policy. Default 'auto' preserves the legacy
65
+ // resolution order (runtime -> store -> shell -> key files -> legacy) so a
66
+ // pre-WP1 fleet.json migrates no-op: no existing cell changes resolution.
67
+ const CREDENTIAL_SOURCES = Object.freeze(['environment', 'nexuscrew-store', 'auto']);
64
68
  const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', agy: 'Agy', shell: 'Shell' });
65
69
  const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
66
70
 
@@ -167,7 +171,13 @@ function normalizeManagedSpec(value) {
167
171
  const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
168
172
  if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
169
173
  if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
174
+ const credentialSourcePolicy = value.credentialSourcePolicy === undefined ? 'auto' : value.credentialSourcePolicy;
175
+ if (!CREDENTIAL_SOURCES.includes(credentialSourcePolicy)) return null;
170
176
  const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
177
+ // 'auto' is the default and stays ABSENT from the normalized spec, so a legacy
178
+ // fleet.json (no credentialSourcePolicy) migrates truly no-op: no added field,
179
+ // no change in resolution. Explicit environment|nexuscrew-store are recorded.
180
+ if (credentialSourcePolicy !== 'auto') out.credentialSourcePolicy = credentialSourcePolicy;
171
181
  if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
172
182
  if (profile.credentialEnv) {
173
183
  const envKey = typeof value.envKey === 'string' && value.envKey.trim()
@@ -416,10 +426,20 @@ function credentialSources(cfg, home) {
416
426
  function credential(profile, spec, cfg, home) {
417
427
  if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '', source: profile.auth };
418
428
  const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
429
+ const policy = spec && CREDENTIAL_SOURCES.includes(spec.credentialSourcePolicy) ? spec.credentialSourcePolicy : 'auto';
419
430
  const sources = credentialSources(cfg, home);
420
431
  // The fixed shell file is already the user's environment source. Values are
421
432
  // consumed only in memory and passed to the selected child; never persisted
422
433
  // in fleet.json, service files, API responses or logs.
434
+ if (policy === 'environment') {
435
+ if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
436
+ return { envKey, value: '', source: 'missing' };
437
+ }
438
+ if (policy === 'nexuscrew-store') {
439
+ if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'nexuscrew-store' };
440
+ return { envKey, value: '', source: 'missing' };
441
+ }
442
+ // auto: legacy resolution order (runtime -> store -> shell -> keys -> legacy).
423
443
  if (sources.runtime[envKey]) return { envKey, value: sources.runtime[envKey], source: 'environment' };
424
444
  if (sources.local[envKey]) return { envKey, value: sources.local[envKey], source: 'local' };
425
445
  if (sources.shell[envKey]) return { envKey, value: sources.shell[envKey], source: 'compatibility' };
@@ -430,6 +450,31 @@ function credential(profile, spec, cfg, home) {
430
450
  return { envKey, value: '', source: 'missing' };
431
451
  }
432
452
 
453
+ // The profile's "owned" env set. When the credential source is the local store,
454
+ // the child environment must not inherit any of these from the runtime: the
455
+ // entire set is enumerated and neutralized (unset), never just envKey. For
456
+ // Anthropic-compatible (claude.*) clients the set is the ANTHROPIC_* triple;
457
+ // every other client keeps the single profile envKey.
458
+ function credentialEnvNeutralizeSet(profile) {
459
+ if (!profile) return [];
460
+ if (profile.client === 'claude') return ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
461
+ const auth = profile.auth;
462
+ const envKey = auth && auth !== 'dynamic' && auth !== 'login' && auth !== 'none'
463
+ ? auth : (profile.defaultEnvKey || '');
464
+ return envKey ? [envKey] : [];
465
+ }
466
+
467
+ // Apply nexuscrew-store neutralization to a composed child env: keys of the
468
+ // profile set that carry no NexusCrew-injected value are UNSET (deleted), never
469
+ // left as an empty string. 'auto'/'environment' are unchanged (legacy behavior).
470
+ function applyStoreNeutralization(env, spec, profile) {
471
+ if (!env || !spec || spec.credentialSourcePolicy !== 'nexuscrew-store') return env;
472
+ for (const k of credentialEnvNeutralizeSet(profile)) {
473
+ if (env[k] === undefined || env[k] === '') delete env[k];
474
+ }
475
+ return env;
476
+ }
477
+
433
478
  let ollamaCache = { at: 0, models: [] };
434
479
  async function discoverOllamaModels(opts = {}) {
435
480
  const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
@@ -527,6 +572,7 @@ function describeManaged(spec, cfg = {}) {
527
572
  credentialProfile: normalized.credentialProfile || '', model: normalized.model,
528
573
  permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
529
574
  endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
575
+ credentialSourcePolicy: normalized.credentialSourcePolicy || 'auto',
530
576
  credentialSource: authConfigured ? cred.source : 'missing',
531
577
  configured, models: [...(profile.models || [])], defaultModel: profile.model || '',
532
578
  binary: binary || '', displayName: normalized.displayName || profile.label,
@@ -814,6 +860,10 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
814
860
  if (cell?.prompt) args.push('--prompt-interactive');
815
861
  }
816
862
  if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
863
+ // nexuscrew-store source: neutralize the profile's env set in the composed
864
+ // child env (unset, never empty), so the runtime cannot leak credentials that
865
+ // the local store is meant to own.
866
+ applyStoreNeutralization(env, spec, profile);
817
867
  let command = info.binary;
818
868
  if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
819
869
  command = cfg.nodeExecPath || process.execPath;
@@ -841,10 +891,11 @@ function publicCatalog() {
841
891
  module.exports = {
842
892
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
843
893
  ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
844
- CLIENT_LABELS, normalizeManagedSpec,
894
+ CLIENT_LABELS, normalizeManagedSpec, profileFor,
845
895
  defaultDefinitions, defaultShellEngine, defaultAgyEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
846
896
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
847
897
  providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
898
+ credentialEnvNeutralizeSet, applyStoreNeutralization,
848
899
  ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell,
849
900
  shellLoginArgs, shellConfiguredCommandArgs, ENV_KEY_RE,
850
901
  };
@@ -13,6 +13,12 @@ function disabled(reason) {
13
13
  }
14
14
 
15
15
  async function selectProvider(cfg = {}) {
16
+ // Seam esplicito per i test: permette di far girare il server REALE con uno
17
+ // stato Fleet controllato, senza tmux ne' processi. Esiste perche' i confini
18
+ // che dipendono dalle celle attive (l'origine di Audio Share) vanno provati
19
+ // sul server vero: un test che inietta le dipendenze nel router non si
20
+ // accorgerebbe mai di un cablaggio sbagliato.
21
+ if (cfg.fleetSeam) return { mode: 'seam', reason: 'fleet iniettata (seam di test)', fleet: cfg.fleetSeam };
16
22
  if (cfg.fleetEnabled === false) return disabled('fleet disabilitata (fleetEnabled=false)');
17
23
  if (cfg.builtinEnabled === false) return disabled('fleet builtin disabilitata (builtinEnabled=false)');
18
24
  const fleet = await createBuiltinFleet({ ...cfg, fleetProviderReason: 'NexusCrew builtin fleet' });
@@ -21,7 +21,7 @@ const {
21
21
  } = require('./managed.js');
22
22
  const {
23
23
  httpError, minimalEnv, tmuxExec,
24
- composeClientInvocation,
24
+ composeClientInvocation, alternateScreenArgs,
25
25
  waitAlive, waitStablePane, injectPrompt,
26
26
  redactSecrets, sanitizeEarlyDiagnostic,
27
27
  } = require('./launch.js');
@@ -246,6 +246,19 @@ function createBuiltinRuntime(ctx) {
246
246
  throw httpError(dup ? 409 : 500, why, null,
247
247
  { phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
248
248
  }
249
+ // Best effort per la sola sessione appena creata: non tocchiamo mai
250
+ // ~/.tmux.conf, opzioni globali o sessioni preesistenti. Un tmux che non
251
+ // accetta una delle opzioni non deve impedire l'avvio della cella.
252
+ const alternateSteps = alternateScreenArgs(cell.tmuxSession, cfg.alternateScreen);
253
+ if (alternateSteps) {
254
+ for (const args of alternateSteps) {
255
+ const configured = await tmuxExec(tmuxBin, args, { env: minimalEnv(), timeoutMs: 2000 });
256
+ if (configured.err) {
257
+ const log = typeof cfg.log === 'function' ? cfg.log : console.warn;
258
+ log(`fleet alternate-screen setup failed for ${cell.id}; continuing`);
259
+ }
260
+ }
261
+ }
249
262
  const createdIds = create.stdout.trim().split('\n')[0].split('\t');
250
263
  const sessionId = /^\$[0-9]+$/.test(createdIds[0] || '') ? createdIds[0] : '';
251
264
  const windowId = /^@[0-9]+$/.test(createdIds[1] || '') ? createdIds[1] : '';
package/lib/mcp/server.js CHANGED
@@ -20,10 +20,12 @@
20
20
  const readline = require('node:readline');
21
21
  const crypto = require('node:crypto');
22
22
  const os = require('node:os');
23
+ const path = require('node:path');
23
24
  const { execFile } = require('node:child_process');
24
25
  const { loadConfig } = require('../config.js');
25
26
  const { readTokenSafe } = require('../auth/token.js');
26
27
  const { isValidSession } = require('../files/store.js');
28
+ const { loadOrCreateBridgeSecret, signedHeaders } = require('../audio/bridge-auth.js');
27
29
  const VERSION = require('../../package.json').version;
28
30
  const MCP_COMPANIONS = require('../../mcp-companions.json');
29
31
  const { TOOLS, IDENTITY_CODE, IDENTITY_REMEDIATION } = require('./tools.js');
@@ -162,17 +164,39 @@ function createMcpServer(opts = {}) {
162
164
  throw new Error('token NexusCrew non leggibile: il server e\' inizializzato? (nexuscrew init)');
163
165
  }
164
166
 
165
- async function api(method, apiPath, body) {
167
+ // Segreto del bridge: file 0600 accanto al token, distinto dal token stesso.
168
+ // Serve dove il Bearer non basta — Audio Share — perche' il Bearer prova solo
169
+ // "qualcuno in loopback ce l'ha", non "questa e' la cella X".
170
+ const bridgeKeyPath = () => cfg.audioBridgeSecretPath || path.join(path.dirname(cfg.tokenPath), 'audio-bridge.key');
171
+
172
+ // `opts.signedSession` attiva la firma HMAC del bridge: copre metodo, path,
173
+ // sessione, timestamp, nonce e i BYTE del body effettivamente inviati. Per
174
+ // questo il payload viene serializzato UNA volta sola e riusato: firmare un
175
+ // JSON e spedirne un altro, anche solo con un ordine di chiavi diverso,
176
+ // produrrebbe una firma valida per un corpo che il server non vede mai.
177
+ async function api(method, apiPath, body, opts = {}) {
166
178
  const token = readToken();
179
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
180
+ let signed = {};
181
+ if (opts.signedSession) {
182
+ try {
183
+ signed = signedHeaders(loadOrCreateBridgeSecret(bridgeKeyPath()), {
184
+ method, path: apiPath, session: opts.signedSession, rawBody: payload === undefined ? '' : payload,
185
+ });
186
+ } catch (_) {
187
+ throw new Error('segreto bridge audio non leggibile: il server e\' inizializzato? (nexuscrew init)');
188
+ }
189
+ }
167
190
  let r;
168
191
  try {
169
192
  r = await fetchImpl(`${baseUrl}${apiPath}`, {
170
193
  method,
171
194
  headers: {
172
195
  authorization: `Bearer ${token}`,
173
- ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
196
+ ...signed,
197
+ ...(payload !== undefined ? { 'content-type': 'application/json' } : {}),
174
198
  },
175
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
199
+ ...(payload !== undefined ? { body: payload } : {}),
176
200
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
177
201
  });
178
202
  } catch (e) {
package/lib/mcp/tools.js CHANGED
@@ -496,6 +496,148 @@ const TOOLS = [
496
496
  return out;
497
497
  },
498
498
  },
499
+ {
500
+ name: 'nc_speak',
501
+ description: 'Speak un utterance audio verso un nodo target (exact instanceId). Ritorna un receipt caller-scoped con status per-endpoint (refused|unreachable|accepted|spoken|unknown) e attribution; MAI text/lang/voice o aggregate success. L\'origin genera un utteranceId immutabile se omesso.',
502
+ inputSchema: {
503
+ type: 'object',
504
+ properties: {
505
+ target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
506
+ text: { type: 'string', description: 'utterance text (bounded)' },
507
+ lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
508
+ voice: { type: 'string', description: 'voice id hint (optional)' },
509
+ urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
510
+ utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
511
+ },
512
+ required: ['target', 'text'],
513
+ },
514
+ async handler(args, ctx) {
515
+ const target = argString(args, 'target', { required: true, max: 32 });
516
+ const text = argString(args, 'text', { required: true, max: 320 });
517
+ const lang = argString(args, 'lang', { max: 35 });
518
+ const voice = argString(args, 'voice', { max: 64 });
519
+ if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
520
+ throw new Error('urgency deve essere "normal" o "high"');
521
+ }
522
+ const urgency = args.urgency === 'high' ? 'high' : 'normal';
523
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
524
+ const identity = await ctx.identity();
525
+ const session = requireSession(identity.session, 'nc_speak', identity.code);
526
+ // La sessione NON viaggia nel body: sarebbe una dichiarazione, e una
527
+ // dichiarazione e' falsificabile da chiunque abbia il token della UI.
528
+ // Viaggia dentro la firma HMAC del bridge, che il server verifica prima di
529
+ // attribuire l'enunciato a una cella.
530
+ const payload = { target, text, urgency, ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}) };
531
+ const receipt = await ctx.api('POST', '/api/audio/speak', payload, { signedSession: session });
532
+ return { receipt };
533
+ },
534
+ },
535
+ {
536
+ name: 'nc_speak_status',
537
+ description: 'Interroga lo status di uno speak per utteranceId (read-only). Ritorna il receipt caller-scoped redacted (status/reason/attribution); MAI text/lang/voice/secret.',
538
+ inputSchema: {
539
+ type: 'object',
540
+ properties: { utteranceId: { type: 'string', description: 'utterance id da interrogare' } },
541
+ required: ['utteranceId'],
542
+ },
543
+ annotations: { readOnlyHint: true },
544
+ async handler(args, ctx) {
545
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
546
+ const identity = await ctx.identity();
547
+ const session = requireSession(identity.session, 'nc_speak_status', identity.code);
548
+ const receipt = await ctx.api('GET', `/api/audio/speak/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
549
+ return { receipt };
550
+ },
551
+ },
552
+ {
553
+ name: 'nc_speak_stop',
554
+ description: 'Ferma un enunciato su un nodo target. Con utteranceId ferma quello specifico (solo se e\' del chiamante), senza utteranceId ferma tutto sul target. Lo stop locale del nodo resta sovrano e non dipende da questa chiamata.',
555
+ inputSchema: {
556
+ type: 'object',
557
+ properties: {
558
+ target: { type: 'string', description: 'exact target node instanceId (32 hex)' },
559
+ utteranceId: { type: 'string', description: 'enunciato da fermare; omesso = tutti gli enunciati del target' },
560
+ },
561
+ required: ['target'],
562
+ },
563
+ async handler(args, ctx) {
564
+ const target = argString(args, 'target', { required: true, max: 32 });
565
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
566
+ const identity = await ctx.identity();
567
+ const session = requireSession(identity.session, 'nc_speak_stop', identity.code);
568
+ const receipt = await ctx.api('POST', '/api/audio/stop', {
569
+ target, ...(utteranceId ? { utteranceId } : {}),
570
+ }, { signedSession: session });
571
+ return { receipt };
572
+ },
573
+ },
574
+ {
575
+ name: 'nc_speak_group',
576
+ description: 'Speak verso un gruppo audio locale nominato. Il gruppo espande solo instanceId esatti: primary-failover prova un endpoint alla volta, fanout e esplicito. Ritorna subito un receipt per endpoint; usa nc_speak_group_status per gli aggiornamenti. Un gruppo non concede consenso e non aggira liveness/ACL del target.',
577
+ inputSchema: {
578
+ type: 'object',
579
+ properties: {
580
+ group: { type: 'string', description: 'nome del gruppo audio locale (a-z, 0-9, trattini)' },
581
+ text: { type: 'string', description: 'utterance text (bounded)' },
582
+ lang: { type: 'string', description: 'BCP-47 lang hint (optional)' },
583
+ voice: { type: 'string', description: 'voice id hint (optional)' },
584
+ urgency: { type: 'string', enum: ['normal', 'high'], description: 'high urgency non bypassa i rate limit' },
585
+ utteranceId: { type: 'string', description: 'immutable id (generato se omesso)' },
586
+ },
587
+ required: ['group', 'text'],
588
+ },
589
+ async handler(args, ctx) {
590
+ const group = argString(args, 'group', { required: true, max: 32 });
591
+ const text = argString(args, 'text', { required: true, max: 320 });
592
+ const lang = argString(args, 'lang', { max: 35 });
593
+ const voice = argString(args, 'voice', { max: 64 });
594
+ if (args.urgency !== undefined && args.urgency !== 'normal' && args.urgency !== 'high') {
595
+ throw new Error('urgency deve essere "normal" o "high"');
596
+ }
597
+ const urgency = args.urgency === 'high' ? 'high' : 'normal';
598
+ const utteranceId = argString(args, 'utteranceId', { max: 128 });
599
+ const identity = await ctx.identity();
600
+ const session = requireSession(identity.session, 'nc_speak_group', identity.code);
601
+ const receipt = await ctx.api('POST', '/api/audio/groups/speak', {
602
+ group, text, urgency,
603
+ ...(lang ? { lang } : {}), ...(voice ? { voice } : {}), ...(utteranceId ? { utteranceId } : {}),
604
+ }, { signedSession: session });
605
+ return { receipt };
606
+ },
607
+ },
608
+ {
609
+ name: 'nc_speak_group_status',
610
+ description: 'Interroga lo status per-endpoint di un gruppo audio (read-only). Il receipt e caller-scoped e non contiene testo, lingua, voce o successi aggregati.',
611
+ inputSchema: {
612
+ type: 'object',
613
+ properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo' } },
614
+ required: ['utteranceId'],
615
+ },
616
+ annotations: { readOnlyHint: true },
617
+ async handler(args, ctx) {
618
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
619
+ const identity = await ctx.identity();
620
+ const session = requireSession(identity.session, 'nc_speak_group_status', identity.code);
621
+ const receipt = await ctx.api('GET', `/api/audio/groups/status/${encodeURIComponent(utteranceId)}`, undefined, { signedSession: session });
622
+ return { receipt };
623
+ },
624
+ },
625
+ {
626
+ name: 'nc_speak_group_stop',
627
+ description: 'Ferma un gruppo audio per utteranceId: blocca i candidati non ancora tentati e invia Stop a ogni endpoint gia ammesso. Lo Stop locale del nodo resta sovrano anche offline.',
628
+ inputSchema: {
629
+ type: 'object',
630
+ properties: { utteranceId: { type: 'string', description: 'utterance id del gruppo da fermare' } },
631
+ required: ['utteranceId'],
632
+ },
633
+ async handler(args, ctx) {
634
+ const utteranceId = argString(args, 'utteranceId', { required: true, max: 128 });
635
+ const identity = await ctx.identity();
636
+ const session = requireSession(identity.session, 'nc_speak_group_stop', identity.code);
637
+ const receipt = await ctx.api('POST', '/api/audio/groups/stop', { utteranceId }, { signedSession: session });
638
+ return { receipt };
639
+ },
640
+ },
499
641
  ];
500
642
 
501
643
  module.exports = {
@@ -21,6 +21,17 @@ const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw
21
21
  const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
22
22
  const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
23
23
  ? Math.min(reverseFailureMaxRaw, 32) : 8;
24
+ // Once the initial reverse-forward budget is exhausted the supervisor does NOT
25
+ // die: it stays "degraded" and retries at the fixed production cadence of 60s,
26
+ // so a transient reverse listener on the hub (e.g. a mobile reconnect) heals on
27
+ // its own without an OFF/ON. A short cadence is a test-only seam, never a
28
+ // production runtime override: otherwise a bad environment value could recreate
29
+ // the retry storm that this breaker is meant to prevent.
30
+ const steadyRetryTestMode = process.env.NEXUSCREW_TUNNEL_TEST_MODE === '1';
31
+ const steadyRetryMsRaw = Number(steadyRetryTestMode ? (process.env.NEXUSCREW_TUNNEL_STEADY_RETRY_MS || 60000) : 60000);
32
+ const steadyRetryMinMs = steadyRetryTestMode ? 1 : 60000;
33
+ const steadyRetryMs = Number.isFinite(steadyRetryMsRaw) && steadyRetryMsRaw >= steadyRetryMinMs
34
+ ? Math.min(steadyRetryMsRaw, 120000) : 60000;
24
35
  if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
25
36
 
26
37
  let child = null;
@@ -92,6 +103,7 @@ function probeForward(expectedChild) {
92
103
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
93
104
  if (ready) {
94
105
  attempt = 0;
106
+ reverseFailures = 0;
95
107
  logEvent(`forward ready stableMs=${stableMs}`);
96
108
  if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
97
109
  return;
@@ -137,14 +149,28 @@ function scheduleRetry(detail) {
137
149
  retryTimer = setTimeout(run, delayMs);
138
150
  }
139
151
 
140
- function terminalFailure(diagnosis) {
152
+ // Bounded, attributable degraded state. The supervisor stays alive, advertises
153
+ // the reverse failure class plus the negotiated reversePort and the honest
154
+ // remote-listener attribution (unknown without hub privilege), then retries on
155
+ // a fixed cadence. It never exits on a reverse-forward failure: the tunnel
156
+ // self-heals when the channel frees up.
157
+ function enterDegraded(diagnosis) {
158
+ if (stopping) return finish();
141
159
  const safe = diagnosis || { code: 'reverse-forward-failed', detail: 'canale inverso non disponibile' };
142
- logEvent(`ssh terminal failure code=${safe.code} attempts=${reverseFailures}`);
143
- writeState('failed', {
160
+ if (reverseFailures > reverseFailureMax) reverseFailures = reverseFailureMax;
161
+ // `ownsGeneration()` only proves that THIS local supervisor owns its pidfile;
162
+ // it cannot identify the process holding a listener on the hub. Never turn
163
+ // local pidfile ownership into a false remote-listener attribution.
164
+ const ownership = 'unknown';
165
+ logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${reverseFailures} steadyRetryMs=${steadyRetryMs}`);
166
+ if (!writeState('degraded', {
144
167
  code: safe.code, detail: safe.detail,
145
- ...(safe.hint ? { hint: safe.hint } : {}), terminal: true,
146
- });
147
- process.exit(0);
168
+ ...(safe.hint ? { hint: safe.hint } : {}),
169
+ reversePort, ownership, steadyRetryMs, terminal: false,
170
+ })) return stop();
171
+ // Keep the backoff counter honest for observers; the degraded retry is fixed.
172
+ if (attempt < reverseFailureMax) attempt = reverseFailureMax;
173
+ retryTimer = setTimeout(run, steadyRetryMs);
148
174
  }
149
175
 
150
176
  function run() {
@@ -176,7 +202,7 @@ function run() {
176
202
  const reverseFailure = diagnosis && ['reverse-forward-bind', 'reverse-forward-failed'].includes(diagnosis.code);
177
203
  if (reverseFailure) {
178
204
  reverseFailures += 1;
179
- if (reverseFailures >= reverseFailureMax) return terminalFailure(diagnosis);
205
+ if (reverseFailures >= reverseFailureMax) return enterDegraded(diagnosis);
180
206
  } else {
181
207
  reverseFailures = 0;
182
208
  }
@@ -290,6 +290,19 @@ function readTunnelState(home, name) {
290
290
  const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
291
291
  if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
292
292
  if (state.status === 'failed' && state.terminal === true) return terminalState();
293
+ if (state.status === 'degraded') {
294
+ return {
295
+ status: 'down', phase: 'degraded', reason: 'degraded', pid: meta.pid, since: meta.startTs || null,
296
+ ...(typeof state.code === 'string' ? { code: state.code } : {}),
297
+ ...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
298
+ ...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
299
+ ...(Number.isInteger(state.reversePort) ? { reversePort: state.reversePort } : {}),
300
+ ...(typeof state.ownership === 'string' ? { ownership: state.ownership } : {}),
301
+ ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
302
+ ...(Number.isFinite(state.steadyRetryMs) ? { retryInMs: state.steadyRetryMs } : {}),
303
+ ...(transport ? { transport } : {}),
304
+ };
305
+ }
293
306
  if (state.status === 'transport-ready' || state.status === 'up') {
294
307
  return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
295
308
  ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
@@ -330,6 +343,14 @@ function diagnoseTunnel(home, node, state = null) {
330
343
  detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
331
344
  hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
332
345
  }
346
+ if (current.reason === 'degraded' || current.phase === 'degraded') {
347
+ return {
348
+ stage: 'ssh', code: current.code || 'reverse-forward-failed', status: 'down', phase: 'degraded',
349
+ detail: current.detail || 'canale inverso non disponibile, retry fisso in corso',
350
+ hint: current.hint || 'il tunnel resta attivo e riprovera automaticamente; verifica il listener reverse sul hub',
351
+ ...(Number.isInteger(current.reversePort) ? { reversePort: current.reversePort } : {}),
352
+ };
353
+ }
333
354
  if (current.reason === 'failed' || current.phase === 'failed') {
334
355
  return {
335
356
  stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
@@ -10,6 +10,7 @@ const { safeEqual } = require('../nodes/peering.js');
10
10
  const {
11
11
  sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
12
12
  } = require('./node-proxy.js');
13
+ const { signHop, HOP_HEADER } = require('./hop-proof.js');
13
14
 
14
15
  const MAX_HOPS = 4;
15
16
  const ROUTE_DELIMITER = '_';
@@ -71,6 +72,10 @@ function knownResource(resource) {
71
72
  // Hydra: the rest of /settings stays unreachable.
72
73
  || resource === '/settings/peering/invite'
73
74
  || resource === '/ws'
75
+ || resource === '/audio/capability'
76
+ || resource === '/audio/speak'
77
+ || resource === '/audio/speak/status'
78
+ || resource === '/audio/stop'
74
79
  || /^\/fleet\/(status|schema|definitions|credentials\/status|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);
75
80
  }
76
81
 
@@ -97,9 +102,25 @@ function allowedResource(resource, method = 'GET') {
97
102
  if (resource === '/ws') return method === 'GET';
98
103
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
99
104
  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';
105
+ // Audio: only capability read, speak and stop are exposed through Hydra.
106
+ // audio.consent is a LOCAL mutation and MUST stay unreachable federated.
107
+ if (resource === '/audio/capability') return method === 'GET';
108
+ if (resource === '/audio/speak') return method === 'POST';
109
+ if (resource === '/audio/speak/status') return method === 'POST';
110
+ if (resource === '/audio/stop') return method === 'POST';
100
111
  return false;
101
112
  }
102
113
 
114
+ // READONLY blocca ogni mutazione che crea o modifica stato, con due eccezioni
115
+ // audio deliberate: interrogare lo status federato usa POST soltanto per
116
+ // trasportare l'attestazione della cella, e Stop e' un'azione di sicurezza che
117
+ // deve poter zittire un endpoint anche quando quel nodo e' READONLY. Speak
118
+ // resta naturalmente bloccato.
119
+ function readonlyBlocksFederated(resource, method) {
120
+ if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return false;
121
+ return resource !== '/audio/speak/status' && resource !== '/audio/stop';
122
+ }
123
+
103
124
  function allowedQuery(resource, method, rawUrl) {
104
125
  if (!resource.startsWith('/diagnostics/')) return true;
105
126
  const index = String(rawUrl || '').indexOf('?');
@@ -117,17 +138,20 @@ function allowedQuery(resource, method, rawUrl) {
117
138
  return true;
118
139
  }
119
140
 
120
- function cleanHeaders(headers, credential, visited = null) {
141
+ function cleanHeaders(headers, credential, visited = null, hopProof = null) {
121
142
  const out = sanitizeRequestHeaders(headers, credential);
122
143
  for (const key of Object.keys(out)) {
123
144
  if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
124
145
  }
125
146
  if (Array.isArray(visited) && visited.length) out['x-nexuscrew-visited'] = visited.join(',');
147
+ // La prova di hop viene aggiunta DOPO la cancellazione: il canale e' riservato
148
+ // al server, un valore arrivato dal client non sopravvive mai fin qui.
149
+ if (hopProof) out[HOP_HEADER] = hopProof;
126
150
  return out;
127
151
  }
128
152
 
129
- function proxyHttp(req, res, { port, path, credential, visited = null }) {
130
- const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited) }, (r) => {
153
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null }) {
154
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof) }, (r) => {
131
155
  res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
132
156
  });
133
157
  up.setTimeout(30000, () => up.destroy());
@@ -135,22 +159,28 @@ function proxyHttp(req, res, { port, path, credential, visited = null }) {
135
159
  req.pipe(up);
136
160
  }
137
161
 
138
- function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
162
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
139
163
  return (req, res) => {
140
164
  const parsed = parseRoute(req.url);
141
165
  if (!parsed || !allowedResource(parsed.resource, req.method)
142
166
  || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
143
- if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
167
+ if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
144
168
  const st = store.loadStore(nodesPath);
145
169
  if (!st) return res.status(503).json({ error: 'node store unavailable' });
146
170
  const visited = controlledVisited(req, ingress, st.nodeId);
147
171
  if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
148
172
  if (parsed.route.length === 0) {
173
+ // Ultimo hop: la richiesta rientra nell'API locale con il Bearer locale e
174
+ // da li' in poi sarebbe indistinguibile da un POST diretto. La prova di
175
+ // hop e' cio' che la distingue, ed e' legata a metodo, path e catena.
176
+ const path = `/api${parsed.resource}${queryOf(req.url)}`;
177
+ const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
149
178
  return proxyHttp(req, res, {
150
179
  port: typeof localPort === 'function' ? localPort() : localPort,
151
- path: `/api${parsed.resource}${queryOf(req.url)}`,
180
+ path,
152
181
  credential: localCredential(),
153
182
  visited,
183
+ hopProof: signHop(secret, { method: req.method, path, visited }),
154
184
  });
155
185
  }
156
186
  const next = st && store.getNode(st, parsed.route[0]);
@@ -514,7 +544,7 @@ async function collectLocalTopology({
514
544
  return { instanceId: live.instanceId, nodes };
515
545
  }
516
546
 
517
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null }) {
547
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
518
548
  const r = express.Router();
519
549
  r.use((req, res, next) => {
520
550
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -576,13 +606,13 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
576
606
  return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
577
607
  }
578
608
  });
579
- r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
609
+ r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly, hopSecret })(req, res));
580
610
  return r;
581
611
  }
582
612
 
583
- function localRouter({ nodesPath, localPort, localCredential, readonly }) {
613
+ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null }) {
584
614
  const r = express.Router();
585
- r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly })(req, res));
615
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret })(req, res));
586
616
  return r;
587
617
  }
588
618
 
@@ -624,7 +654,7 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
624
654
 
625
655
  module.exports = {
626
656
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
627
- peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
657
+ peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
628
658
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
629
659
  probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
630
660
  };