@mmmbuto/nexuscrew 0.8.25 → 0.8.27

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.
@@ -7,6 +7,7 @@ const path = require('node:path');
7
7
  const crypto = require('node:crypto');
8
8
  const { execFile } = require('node:child_process');
9
9
  const { ENV_KEY_RE } = require('./env-key.js');
10
+ const { termuxRuntimePaths } = require('../runtime/env.js');
10
11
  const { readCredentialStore, safePrivateDir } = require('./credentials.js');
11
12
 
12
13
  const OLLAMA_CLOUD_MODELS = Object.freeze([
@@ -274,8 +275,15 @@ function findBinary(client, home) {
274
275
  // client starts. Detect only that explicit Node shebang and invoke it through
275
276
  // the already-running trusted Node executable. Native and shell binaries keep
276
277
  // their original direct-exec path.
277
- function needsExplicitNode(binary, platform = process.platform) {
278
- if (platform !== 'android') return false;
278
+ //
279
+ // Detection uses both process.platform AND the runtime Termux layout (PREFIX /
280
+ // files/home) so that a Node build that reports `linux` while actually running
281
+ // under Termux (proot / custom build) still gets the shebang workaround. The
282
+ // optional `env` argument lets tests inject a synthetic environment; the public
283
+ // two-argument call form is unchanged.
284
+ function needsExplicitNode(binary, platform = process.platform, env = process.env) {
285
+ const termux = platform === 'android' || termuxRuntimePaths(env, { platform }) !== null;
286
+ if (!termux) return false;
279
287
  try {
280
288
  const fd = fs.openSync(binary, 'r');
281
289
  try {
@@ -702,7 +710,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
702
710
  }
703
711
  if (cell?.prompt) args.push(cell.prompt);
704
712
  let command = info.binary;
705
- if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
713
+ if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
706
714
  command = cfg.nodeExecPath || process.execPath;
707
715
  args.unshift(info.binary);
708
716
  }
@@ -29,6 +29,14 @@ function fleetRoutes(fleetP, cfg = {}) {
29
29
  r.use((req, res, next) => (req.path === '/restore-cells' || req.path === '/restore-engines' ? restoreJson : smallJson)(req, res, next));
30
30
 
31
31
  const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
32
+ const diagnostics = cfg.diagnostics || null;
33
+ const safeCell = (body) => {
34
+ const value = body && typeof body.cell === 'string' ? body.cell : '';
35
+ return /^[A-Za-z0-9._-]{1,32}$/.test(value) ? value : '';
36
+ };
37
+ const emit = (level, code, message, meta) => {
38
+ if (diagnostics && typeof diagnostics.record === 'function') diagnostics.record(level, 'fleet', code, message, meta);
39
+ };
32
40
 
33
41
  const guard = (fn, opts = {}) => async (req, res) => {
34
42
  try {
@@ -36,8 +44,25 @@ function fleetRoutes(fleetP, cfg = {}) {
36
44
  if (opts.mutate && readonly()) return res.status(403).json({ error: 'READONLY: mutazione fleet bloccata' });
37
45
  const fleet = await fleetP;
38
46
  if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
39
- res.json(await fn(fleet, req.body || {}));
47
+ const body = req.body || {};
48
+ if (opts.action) emit('info', 'FLEET_ACTION_STARTED', 'Fleet action started', {
49
+ action: opts.action, cell: safeCell(body), state: 'starting',
50
+ });
51
+ const result = await fn(fleet, body);
52
+ if (opts.action) emit('info', 'FLEET_ACTION_COMPLETED', 'Fleet action completed', {
53
+ action: opts.action, cell: safeCell(body), state: opts.action === 'down' ? 'stopped' : 'ready',
54
+ });
55
+ res.json(result);
40
56
  } catch (e) {
57
+ if (opts.action) {
58
+ const match = String(e && e.message || '').match(/cell spawn failed:\s+([A-Z][A-Z0-9_]{0,31})\s+([A-Za-z0-9._+-]{1,128})/);
59
+ if (match) emit('error', 'CELL_SPAWN_FAILED', 'Cell client spawn failed', {
60
+ action: opts.action, cell: safeCell(req.body), errno: match[1], client: match[2], status: e.status || 500,
61
+ });
62
+ else emit('warn', 'FLEET_ACTION_FAILED', 'Fleet action failed', {
63
+ action: opts.action, cell: safeCell(req.body), state: 'failed', status: e.status || 500,
64
+ });
65
+ }
41
66
  res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
42
67
  }
43
68
  };
@@ -82,13 +107,13 @@ function fleetRoutes(fleetP, cfg = {}) {
82
107
  await f.boot(cell, b.boot === true);
83
108
  }
84
109
  return f.up(cell, { engine: b.engine, boot: !!b.boot });
85
- }, { mutate: true }));
110
+ }, { mutate: true, action: 'up' }));
86
111
  r.post('/down', guard(async (f, b) => {
87
112
  const cell = String(b.cell || '');
88
113
  if (b.boot === true && capList(f).includes('edit') && capList(f).includes('boot')) await f.boot(cell, false);
89
114
  return f.down(cell, { boot: !!b.boot });
90
- }, { mutate: true }));
91
- r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true }));
115
+ }, { mutate: true, action: 'down' }));
116
+ r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true, action: 'restart' }));
92
117
  r.post('/engine', guard((f, b) => {
93
118
  const opts = { model: typeof b.model === 'string' ? b.model : '' };
94
119
  if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
package/lib/mcp/server.js CHANGED
@@ -25,7 +25,7 @@ const { loadConfig } = require('../config.js');
25
25
  const { readTokenSafe } = require('../auth/token.js');
26
26
  const { isValidSession } = require('../files/store.js');
27
27
  const VERSION = require('../../package.json').version;
28
- const { TOOLS } = require('./tools.js');
28
+ const { TOOLS, IDENTITY_CODE, IDENTITY_REMEDIATION } = require('./tools.js');
29
29
  const cells = require('./cells.js');
30
30
 
31
31
  // Versione protocollo di fallback se il client non ne dichiara una valida.
@@ -39,27 +39,83 @@ const METHOD_NOT_FOUND = -32601;
39
39
  const INVALID_PARAMS = -32602;
40
40
 
41
41
  // --- identita' cella mittente ------------------------------------------------
42
- // Ordine (design §1): $TMUX presente -> `tmux display-message -p '#S'` (nome
43
- // sessione reale); fallback env NEXUSCREW_MCP_SESSION; altrimenti null (i tool
44
- // che RICHIEDONO la sessione falliscono con errore chiaro, nc_notify degrada
45
- // a sender sconosciuto). execFile argv diretto: mai shell.
46
- function resolveSession({ env, tmuxBin, execFileImpl }) {
47
- const fallback = () => {
48
- const s = env.NEXUSCREW_MCP_SESSION;
49
- return (typeof s === 'string' && isValidSession(s.trim())) ? s.trim() : null;
42
+ // Ordine (design §1, INVARIATO): $TMUX presente -> `tmux display-message -p '#S'`
43
+ // (nome sessione reale); se fallisce/invalida -> fallback env NEXUSCREW_MCP_SESSION;
44
+ // altrimenti null. I tool che RICHIEDONO la sessione restano fail-closed.
45
+ // execFile argv diretto: mai shell.
46
+ //
47
+ // `resolveIdentity` rende OSSERVABILE la sorgente della risoluzione (P0):
48
+ // ritorna { session, source, code, envPresence, requiredEnvVars, remediation }
49
+ // senza cambiare la precedenza e senza esporre valori/segreti. `resolveSession`
50
+ // resta il wrapper pubblico Promise<string|null> invariato (compatibilita').
51
+ const IDENTITY_REQUIRED_ENV_VARS = Object.freeze(['TMUX', 'TMUX_PANE', 'NEXUSCREW_MCP_SESSION']);
52
+
53
+ function envPresenceOf(env) {
54
+ return {
55
+ TMUX: !!env.TMUX,
56
+ TMUX_PANE: !!env.TMUX_PANE,
57
+ NEXUSCREW_MCP_SESSION: !!(typeof env.NEXUSCREW_MCP_SESSION === 'string' && env.NEXUSCREW_MCP_SESSION.trim()),
58
+ };
59
+ }
60
+
61
+ function resolveIdentity({ env, tmuxBin, execFileImpl }) {
62
+ const e = env || {};
63
+ const envPresence = envPresenceOf(e);
64
+ const fallbackRaw = e.NEXUSCREW_MCP_SESSION;
65
+ const fallbackPresent = typeof fallbackRaw === 'string' && fallbackRaw.trim().length > 0;
66
+ const tmuxPresent = !!e.TMUX;
67
+
68
+ // Prova il fallback NEXUSCREW_MCP_SESSION: ritorna la sessione normalizzata se
69
+ // valida, `false` se presente ma invalida, `null` se assente.
70
+ const tryFallback = () => {
71
+ if (!fallbackPresent) return null;
72
+ const s = fallbackRaw.trim();
73
+ return isValidSession(s) ? s : false;
50
74
  };
51
- if (!env.TMUX) return Promise.resolve(fallback());
75
+
76
+ // code quando NON identificati: INVALID se c'e' un segnale di identita'
77
+ // (TMUX o NEXUSCREW_MCP_SESSION presente), MISSING altrimenti.
78
+ const codeWhenMissing = () => ((tmuxPresent || fallbackPresent)
79
+ ? IDENTITY_CODE.INVALID : IDENTITY_CODE.MISSING);
80
+
81
+ const ok = (session, source) => ({
82
+ session, source, code: IDENTITY_CODE.OK,
83
+ envPresence, requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
84
+ });
85
+ const missing = () => ({
86
+ session: null, source: 'missing', code: codeWhenMissing(),
87
+ envPresence, requiredEnvVars: IDENTITY_REQUIRED_ENV_VARS, remediation: IDENTITY_REMEDIATION,
88
+ });
89
+
52
90
  return new Promise((resolve) => {
91
+ if (!tmuxPresent) {
92
+ const fb = tryFallback();
93
+ return resolve(typeof fb === 'string' ? ok(fb, 'NEXUSCREW_MCP_SESSION') : missing());
94
+ }
53
95
  try {
54
96
  execFileImpl(tmuxBin, ['display-message', '-p', '#S'], { timeout: 3000 }, (err, stdout) => {
55
- if (err) return resolve(fallback());
56
- const name = String(stdout || '').trim();
57
- resolve(isValidSession(name) ? name : fallback());
97
+ if (!err) {
98
+ const name = String(stdout || '').trim();
99
+ if (isValidSession(name)) return resolve(ok(name, 'tmux'));
100
+ }
101
+ // tmux fallito/invalido -> precedenza preservata: fallback env.
102
+ const fb = tryFallback();
103
+ resolve(typeof fb === 'string' ? ok(fb, 'NEXUSCREW_MCP_SESSION') : missing());
58
104
  });
59
- } catch (_) { resolve(fallback()); }
105
+ } catch (_) {
106
+ const fb = tryFallback();
107
+ resolve(typeof fb === 'string' ? ok(fb, 'NEXUSCREW_MCP_SESSION') : missing());
108
+ }
60
109
  });
61
110
  }
62
111
 
112
+ // Wrapper pubblico STORICO: stessi parametri, stesso return Promise<string|null>.
113
+ // Mantiene i test esistenti e ogni chiamante esterno che dipende solo dal nome
114
+ // della sessione (o null). La diagnostica source/code vive in resolveIdentity.
115
+ function resolveSession(opts) {
116
+ return resolveIdentity(opts).then((i) => i.session);
117
+ }
118
+
63
119
  // --- server --------------------------------------------------------------------
64
120
  function createMcpServer(opts = {}) {
65
121
  const input = opts.input || process.stdin;
@@ -75,11 +131,15 @@ function createMcpServer(opts = {}) {
75
131
  const baseUrl = `http://127.0.0.1:${cfg.port}`;
76
132
 
77
133
  // Identita' risolta una volta e cacheata (la sessione tmux non cambia a runtime).
78
- let sessionP = null;
79
- const session = () => {
80
- if (!sessionP) sessionP = resolveSession({ env, tmuxBin: cfg.tmuxBin || 'tmux', execFileImpl });
81
- return sessionP;
134
+ // Una sola risoluzione condivisa: `identity()` per la diagnostica completa
135
+ // (source/code/presence), `session()` estrae solo il nome per gli handler
136
+ // storici (compatibilita'). Nessuna API/token coinvolta qui.
137
+ let identityP = null;
138
+ const identity = () => {
139
+ if (!identityP) identityP = resolveIdentity({ env, tmuxBin: cfg.tmuxBin || 'tmux', execFileImpl });
140
+ return identityP;
82
141
  };
142
+ const session = () => identity().then((i) => i.session);
83
143
 
84
144
  // Token letto ad OGNI chiamata (rotazione-friendly), MAI incluso negli errori.
85
145
  function readToken() {
@@ -113,6 +173,7 @@ function createMcpServer(opts = {}) {
113
173
 
114
174
  const ctx = {
115
175
  session,
176
+ identity,
116
177
  api,
117
178
  home: () => env.HOME || os.homedir(),
118
179
  fileExists: (p) => { try { return require('node:fs').statSync(p).isFile(); } catch (_) { return false; } },
@@ -252,7 +313,7 @@ function startMcp(opts = {}) {
252
313
  }
253
314
 
254
315
  module.exports = {
255
- createMcpServer, startMcp, resolveSession, TOOLS,
316
+ createMcpServer, startMcp, resolveSession, resolveIdentity, TOOLS,
256
317
  parseCellTarget: cells.parseCellTarget,
257
318
  normalizeCellPayload: cells.normalizeCellPayload,
258
319
  readCellDirectory: cells.readCellDirectory,
package/lib/mcp/tools.js CHANGED
@@ -27,10 +27,33 @@ function argString(args, key, { required = false, max = 4096 } = {}) {
27
27
  return v;
28
28
  }
29
29
 
30
- function requireSession(session, tool) {
30
+ // Codici stabili di identita' MCP (contratto P0). Valori EXACT, non sensibili,
31
+ // usati sia nella diagnostica read-only (`nc_identity`) sia nel messaggio umano
32
+ // dei tool identity-gated (isError=true preservato dal server). Non espongono
33
+ // valori/env/sessioni: solo la categoria del problema.
34
+ const IDENTITY_CODE = Object.freeze({
35
+ OK: 'OK',
36
+ MISSING: 'NEXUSCREW_MCP_IDENTITY_MISSING',
37
+ INVALID: 'NEXUSCREW_MCP_IDENTITY_INVALID',
38
+ });
39
+
40
+ // Remediation senza segreti/valori: nomi soltanto, compatibile con
41
+ // `codex-vl mcp add --env-var` (allowlist di nomi, nessun valore persistito).
42
+ const IDENTITY_REMEDIATION =
43
+ 'Nei client che ripuliscono l\'ambiente, allowlista i nomi delle variabili di identita nel server MCP stdio '
44
+ + '(codex-vl mcp add ... --env-var NEXUSCREW_MCP_SESSION --env-var TMUX --env-var TMUX_PANE) '
45
+ + 'oppure assicurati che il client MCP inoltri il contesto tmux al processo child: nessun valore '
46
+ + 'viene copiato nella CLI o nel file di configurazione.';
47
+
48
+ function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
31
49
  if (session) return session;
50
+ const stableCode = code === IDENTITY_CODE.INVALID ? IDENTITY_CODE.INVALID : IDENTITY_CODE.MISSING;
51
+ // Errore umano per il modello: messaggio chiaro + codice stabile fra parentesi
52
+ // quadre. isError=true e' impostato dal server (toolsCall); qui si propaga
53
+ // solo il testo. La regex storica /NEXUSCREW_MCP_SESSION/ resta soddisfatta.
32
54
  throw new Error(
33
- `${tool}: sessione tmux non identificata — serve $TMUX (dentro tmux) o NEXUSCREW_MCP_SESSION`,
55
+ `${tool}: sessione tmux non identificata — serve $TMUX (dentro tmux) o `
56
+ + `NEXUSCREW_MCP_SESSION [${stableCode}]`,
34
57
  );
35
58
  }
36
59
 
@@ -79,7 +102,8 @@ const TOOLS = [
79
102
  if (args.options !== undefined && !Array.isArray(args.options)) {
80
103
  throw new Error('options deve essere un array di stringhe');
81
104
  }
82
- const session = requireSession(await ctx.session(), 'nc_ask');
105
+ const identity = await ctx.identity();
106
+ const session = requireSession(identity.session, 'nc_ask', identity.code);
83
107
  const j = await ctx.api('POST', '/api/asks', {
84
108
  question, ...(args.options ? { options: args.options } : {}), session,
85
109
  });
@@ -109,7 +133,8 @@ const TOOLS = [
109
133
  if (!ctx.fileExists(p)) throw new Error(`file inesistente: ${p}`);
110
134
  const home = ctx.home();
111
135
  if (p !== home && !p.startsWith(home + path.sep)) throw new Error('path fuori da HOME');
112
- const session = requireSession(await ctx.session(), 'nc_send_file');
136
+ const identity = await ctx.identity();
137
+ const session = requireSession(identity.session, 'nc_send_file', identity.code);
113
138
  const j = await ctx.api('POST', '/api/files/outbox', {
114
139
  session, path: p, ...(caption ? { caption } : {}),
115
140
  });
@@ -145,7 +170,8 @@ const TOOLS = [
145
170
  inputSchema: { type: 'object', properties: {} },
146
171
  annotations: { readOnlyHint: true },
147
172
  async handler(_args, ctx) {
148
- const tmuxSession = requireSession(await ctx.session(), 'nc_deck');
173
+ const identity = await ctx.identity();
174
+ const tmuxSession = requireSession(identity.session, 'nc_deck', identity.code);
149
175
  const [config, topology, localDecks] = await Promise.all([
150
176
  ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'), ctx.api('GET', '/api/decks'),
151
177
  ]);
@@ -257,7 +283,8 @@ const TOOLS = [
257
283
  if (code === 9 || code === 10 || code === 13) continue;
258
284
  if (code < 32 || code === 127) throw new Error('message contiene caratteri di controllo non ammessi');
259
285
  }
260
- const callerSession = requireSession(await ctx.session(), 'nc_send_cell');
286
+ const identity = await ctx.identity();
287
+ const callerSession = requireSession(identity.session, 'nc_send_cell', identity.code);
261
288
  const directory = await readCellDirectory(ctx, callerSession);
262
289
  const sender = directory.cells.find((cell) => cell.self && cell.active);
263
290
  if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
@@ -290,11 +317,33 @@ const TOOLS = [
290
317
  inputSchema: { type: 'object', properties: {} },
291
318
  annotations: { readOnlyHint: true },
292
319
  async handler(_args, ctx) {
293
- const session = requireSession(await ctx.session(), 'nc_inbox');
320
+ const identity = await ctx.identity();
321
+ const session = requireSession(identity.session, 'nc_inbox', identity.code);
294
322
  const j = await ctx.api('GET', `/api/files?session=${encodeURIComponent(session)}`);
295
323
  return { inbox: Array.isArray(j.inbox) ? j.inbox : [] };
296
324
  },
297
325
  },
326
+ {
327
+ name: 'nc_identity',
328
+ description: 'Diagnostica read-only dell\'identita\' del chiamante MCP. Utilizzabile anche senza sessione tmux e senza token: restituisce SOLO dati non sensibili (presence delle env var, sorgente della risoluzione, codice stabile). Non chiama API HTTP e non legge il token. Usa questo tool quando gli altri tool nc_* falliscono con NEXUSCREW_MCP_IDENTITY_* per capire cosa manca.',
329
+ inputSchema: { type: 'object', properties: {} },
330
+ annotations: { readOnlyHint: true },
331
+ async handler(_args, ctx) {
332
+ const id = await ctx.identity();
333
+ // Output bounded e non sensibile: nessun valore/env, solo presence e codice.
334
+ // `session` solo se validata; `source` sempre fra i tre valori ammessi.
335
+ const out = {
336
+ identified: !!id.session,
337
+ source: id.source,
338
+ envPresence: id.envPresence,
339
+ requiredEnvVars: id.requiredEnvVars,
340
+ code: id.code,
341
+ remediation: id.remediation,
342
+ };
343
+ if (id.session) out.session = id.session;
344
+ return out;
345
+ },
346
+ },
298
347
  ];
299
348
 
300
- module.exports = { TOOLS, argString, requireSession };
349
+ module.exports = { TOOLS, argString, requireSession, IDENTITY_CODE, IDENTITY_REMEDIATION };
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+ // Alias locali del viewer per nodi routed. Questo store non partecipa mai a
3
+ // topology, routing, ACL o federation: associa soltanto uno stable instanceId a
4
+ // un'etichetta di display scelta sul dispositivo che ospita la PWA.
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const crypto = require('node:crypto');
9
+
10
+ const SCHEMA_VERSION = 1;
11
+ const INSTANCE_ID_RE = /^[a-f0-9]{16,64}$/;
12
+ const ALIAS_MAX = 64;
13
+ const MAX_ALIASES = 128;
14
+ const MAX_FILE_BYTES = 16 * 1024;
15
+
16
+ function defaultAliasesPath(home = os.homedir()) {
17
+ return path.join(home, '.nexuscrew', 'node-aliases.json');
18
+ }
19
+
20
+ function normalizeAlias(value) {
21
+ if (typeof value !== 'string') return null;
22
+ const alias = value.normalize('NFC').trim();
23
+ if (!alias || alias.length > ALIAS_MAX) return null;
24
+ // Cc/Cf includes newlines, NUL, bidi controls and invisible format chars.
25
+ if (/[\p{Cc}\p{Cf}]/u.test(alias)) return null;
26
+ return alias;
27
+ }
28
+
29
+ function emptyStore() {
30
+ return { version: SCHEMA_VERSION, aliasesByInstanceId: {} };
31
+ }
32
+
33
+ function parseStore(raw) {
34
+ let value;
35
+ try { value = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (_) { return null; }
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
37
+ if (Object.keys(value).some((key) => !['version', 'aliasesByInstanceId'].includes(key))) return null;
38
+ if (value.version !== SCHEMA_VERSION) return null;
39
+ const aliases = value.aliasesByInstanceId;
40
+ if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) return null;
41
+ const entries = Object.entries(aliases);
42
+ if (entries.length > MAX_ALIASES) return null;
43
+ const out = {};
44
+ for (const [instanceId, rawAlias] of entries) {
45
+ const alias = normalizeAlias(rawAlias);
46
+ if (!INSTANCE_ID_RE.test(instanceId) || alias === null || alias !== rawAlias) return null;
47
+ out[instanceId] = alias;
48
+ }
49
+ return { version: SCHEMA_VERSION, aliasesByInstanceId: out };
50
+ }
51
+
52
+ function assertSafeTarget(filePath) {
53
+ try {
54
+ const stat = fs.lstatSync(filePath);
55
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('node alias store must be a regular file');
56
+ if ((stat.mode & 0o077) !== 0) throw new Error('node alias store permissions must be 0600');
57
+ if (stat.size > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
58
+ } catch (error) {
59
+ if (error && error.code === 'ENOENT') return false;
60
+ throw error;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ function loadStore(filePath = defaultAliasesPath()) {
66
+ if (!assertSafeTarget(filePath)) return emptyStore();
67
+ const raw = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' });
68
+ if (Buffer.byteLength(raw) > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
69
+ const parsed = parseStore(raw);
70
+ if (!parsed) throw new Error('invalid node alias store');
71
+ return parsed;
72
+ }
73
+
74
+ function atomicWriteStore(filePath, store) {
75
+ const parsed = parseStore(store);
76
+ if (!parsed) throw new Error('invalid node alias store');
77
+ const payload = `${JSON.stringify(parsed, null, 2)}\n`;
78
+ if (Buffer.byteLength(payload) > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
79
+ assertSafeTarget(filePath);
80
+ const dir = path.dirname(filePath);
81
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ const dirStat = fs.lstatSync(dir);
83
+ if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) throw new Error('node alias directory must be a regular directory');
84
+ fs.chmodSync(dir, 0o700);
85
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(8).toString('hex')}.tmp`);
86
+ try {
87
+ fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
88
+ fs.chmodSync(tmp, 0o600);
89
+ fs.renameSync(tmp, filePath);
90
+ fs.chmodSync(filePath, 0o600);
91
+ } catch (error) {
92
+ try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
93
+ throw error;
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ function setAlias(store, instanceId, value) {
99
+ if (!INSTANCE_ID_RE.test(String(instanceId || ''))) throw new Error('instanceId non valido');
100
+ const alias = normalizeAlias(value);
101
+ if (alias === null) throw new Error('alias non valido (max 64 char, niente controlli)');
102
+ const current = parseStore(store);
103
+ if (!current) throw new Error('invalid node alias store');
104
+ const aliasesByInstanceId = { ...current.aliasesByInstanceId, [instanceId]: alias };
105
+ if (Object.keys(aliasesByInstanceId).length > MAX_ALIASES) throw new Error('troppi alias nodo');
106
+ return { version: SCHEMA_VERSION, aliasesByInstanceId };
107
+ }
108
+
109
+ function deleteAlias(store, instanceId) {
110
+ if (!INSTANCE_ID_RE.test(String(instanceId || ''))) throw new Error('instanceId non valido');
111
+ const current = parseStore(store);
112
+ if (!current) throw new Error('invalid node alias store');
113
+ const aliasesByInstanceId = { ...current.aliasesByInstanceId };
114
+ delete aliasesByInstanceId[instanceId];
115
+ return { version: SCHEMA_VERSION, aliasesByInstanceId };
116
+ }
117
+
118
+ module.exports = {
119
+ SCHEMA_VERSION, INSTANCE_ID_RE, ALIAS_MAX, MAX_ALIASES, MAX_FILE_BYTES,
120
+ defaultAliasesPath, normalizeAlias, emptyStore, parseStore, loadStore,
121
+ atomicWriteStore, setAlias, deleteAlias,
122
+ };
@@ -63,6 +63,9 @@ function knownResource(resource) {
63
63
  || resource === '/decks'
64
64
  || /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
65
65
  || resource === '/topology'
66
+ || resource === '/diagnostics/status'
67
+ || resource === '/diagnostics/logs'
68
+ || resource === '/diagnostics/verbose'
66
69
  // A connected client may ask its hub to mint a hub-owned, one-time
67
70
  // pairing invite. This is the only settings mutation exposed through
68
71
  // Hydra: the rest of /settings stays unreachable.
@@ -87,6 +90,9 @@ function allowedResource(resource, method = 'GET') {
87
90
  return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
88
91
  }
89
92
  if (resource === '/topology') return method === 'GET';
93
+ if (resource === '/diagnostics/status') return method === 'GET';
94
+ if (resource === '/diagnostics/logs') return method === 'GET' || method === 'DELETE';
95
+ if (resource === '/diagnostics/verbose') return method === 'PATCH';
90
96
  if (resource === '/settings/peering/invite') return method === 'POST';
91
97
  if (resource === '/ws') return method === 'GET';
92
98
  if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
@@ -94,6 +100,23 @@ function allowedResource(resource, method = 'GET') {
94
100
  return false;
95
101
  }
96
102
 
103
+ function allowedQuery(resource, method, rawUrl) {
104
+ if (!resource.startsWith('/diagnostics/')) return true;
105
+ const index = String(rawUrl || '').indexOf('?');
106
+ if (index < 0) return true;
107
+ const raw = String(rawUrl).slice(index + 1);
108
+ if (!raw) return true;
109
+ if (resource !== '/diagnostics/logs' || method !== 'GET') return false;
110
+ const params = new URLSearchParams(raw);
111
+ const keys = [...params.keys()];
112
+ if (keys.some((key) => !['after', 'limit'].includes(key))) return false;
113
+ if (params.getAll('after').length > 1 || params.getAll('limit').length > 1) return false;
114
+ const after = params.get('after'); const limit = params.get('limit');
115
+ if (after !== null && (!/^\d{1,16}$/.test(after) || !Number.isSafeInteger(Number(after)))) return false;
116
+ if (limit !== null && (!/^\d{1,3}$/.test(limit) || Number(limit) < 1 || Number(limit) > 200)) return false;
117
+ return true;
118
+ }
119
+
97
120
  function cleanHeaders(headers, credential, visited = null) {
98
121
  const out = sanitizeRequestHeaders(headers, credential);
99
122
  for (const key of Object.keys(out)) {
@@ -115,7 +138,8 @@ function proxyHttp(req, res, { port, path, credential, visited = null }) {
115
138
  function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
116
139
  return (req, res) => {
117
140
  const parsed = parseRoute(req.url);
118
- if (!parsed || !allowedResource(parsed.resource, req.method)) return res.status(404).json({ error: 'not found' });
141
+ if (!parsed || !allowedResource(parsed.resource, req.method)
142
+ || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
119
143
  if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
120
144
  const st = store.loadStore(nodesPath);
121
145
  if (!st) return res.status(503).json({ error: 'node store unavailable' });
@@ -539,7 +563,7 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
539
563
 
540
564
  module.exports = {
541
565
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
542
- peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
566
+ peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
543
567
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
544
568
  probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
545
569
  };
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const os = require('node:os');
4
5
  const path = require('node:path');
5
6
 
@@ -77,11 +78,69 @@ function minimalRuntimeEnv(source = process.env, opts = {}) {
77
78
  if (!selected.PREFIX) selected.PREFIX = termux.prefix;
78
79
  if (!selected.TMPDIR) selected.TMPDIR = termux.tmpdir;
79
80
  if (!selected.TMUX_TMPDIR) selected.TMUX_TMPDIR = termux.tmuxTmpdir;
81
+ // Termux Google Play (targetSdk >= 29) runs under the SELinux `untrusted_app`
82
+ // domain, which forbids execve() of files in the app's data directory unless
83
+ // libtermux-exec is preloaded (it redirects those execs to the system linker).
84
+ // The shared tmux server and every pane are launched with this minimal env,
85
+ // so without the preload every command pane dies at execve. Preserve ONLY the
86
+ // validated trusted preload; every other LD_*/provider/credential value stays
87
+ // dropped as before. Fail-closed: any doubt -> drop, never pass through.
88
+ const trusted = trustedTermuxPreload(source, opts);
89
+ if (trusted) selected.LD_PRELOAD = trusted;
80
90
  }
81
91
  return withUtf8Locale(selected, opts);
82
92
  }
83
93
 
94
+ // Basenames of the trusted termux-exec preload library shipped by Termux.
95
+ // Modern builds ship `libtermux-exec-ld-preload.so`; older ones shipped
96
+ // `libtermux-exec.so`. The optional numeric segment accepts a versioned
97
+ // variant; every other name is rejected. This IS the allowlist: adding a new
98
+ // trusted entry requires extending this regex (after a device-specific proof).
99
+ const TERMUX_EXEC_BASENAME_RE = /^libtermux-exec(?:-ld-preload)?(?:-\d+(?:\.\d+)*)?\.so$/;
100
+
101
+ // Pure validator for the single Termux LD_PRELOAD entry minimalRuntimeEnv may
102
+ // preserve. Returns the canonical realpath of the trusted library, or null when
103
+ // the value is absent, non-Termux, relative, a list/mixed value, outside the
104
+ // active Termux PREFIX/lib, missing, non-regular, world/group-writable, or not
105
+ // owned by the running user (or root). Never throws, never logs.
106
+ //
107
+ // Security model: the preload must come from the already-trusted service env
108
+ // (the attacker who controls that env is already inside the trust domain). We
109
+ // only make sure it cannot escape that domain: a relative path, a foreign
110
+ // library, or a path planted outside PREFIX/lib is dropped as if absent.
111
+ function trustedTermuxPreload(source = process.env, opts = {}) {
112
+ const raw = source && source.LD_PRELOAD;
113
+ if (typeof raw !== 'string' || raw === '') return null;
114
+ // Reject list-shaped / ambiguous / mixed values: LD_PRELOAD accepts a
115
+ // colon-(or space-)separated list; we only ever preserve a single entry.
116
+ if (/[ \t\r\n:]/.test(raw)) return null;
117
+ if (raw.includes('\0')) return null;
118
+ if (!path.isAbsolute(raw)) return null;
119
+ // Only when the runtime is genuinely Termux (PREFIX or files/home layout).
120
+ const termux = termuxRuntimePaths(source, opts);
121
+ if (!termux || !termux.prefix) return null;
122
+ let realPrefix;
123
+ let realRaw;
124
+ try {
125
+ realPrefix = fs.realpathSync(termux.prefix);
126
+ realRaw = fs.realpathSync(raw);
127
+ } catch (_) { return null; }
128
+ // Resolve the canonical trusted directory and require the library to live in
129
+ // it directly (no nested paths, no elsewhere). Both sides are realpath'd so a
130
+ // symlinked PREFIX or a planted symlink cannot escape.
131
+ const trustedDir = path.join(realPrefix, 'lib');
132
+ if (path.dirname(realRaw) !== trustedDir) return null;
133
+ const base = path.basename(realRaw);
134
+ if (!TERMUX_EXEC_BASENAME_RE.test(base)) return null;
135
+ let st;
136
+ try { st = fs.lstatSync(realRaw); } catch (_) { return null; }
137
+ if (!st.isFile()) return null; // regular file only (realpath already resolved symlinks)
138
+ if (st.mode & 0o022) return null; // not group/world-writable
139
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) return null;
140
+ return realRaw;
141
+ }
142
+
84
143
  module.exports = {
85
144
  MINIMAL_ENV_KEYS, UTF8_RE, localeDefaults, withUtf8Locale,
86
- termuxRuntimePaths, minimalRuntimeEnv,
145
+ termuxRuntimePaths, minimalRuntimeEnv, trustedTermuxPreload, TERMUX_EXEC_BASENAME_RE,
87
146
  };