@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.
- package/CHANGELOG.md +43 -0
- package/README.md +48 -5
- package/frontend/dist/assets/index-CWKyv6KS.js +93 -0
- package/frontend/dist/assets/{index-DqG-mDnV.css → index-xxfypte7.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +113 -0
- package/lib/cli/service.js +3 -1
- package/lib/diagnostics/routes.js +43 -0
- package/lib/diagnostics/store.js +139 -0
- package/lib/fleet/cell-exec.js +17 -2
- package/lib/fleet/managed.js +11 -3
- package/lib/fleet/routes.js +29 -4
- package/lib/mcp/server.js +80 -19
- package/lib/mcp/tools.js +57 -8
- package/lib/nodes/aliases.js +122 -0
- package/lib/proxy/federation.js +26 -2
- package/lib/runtime/env.js +60 -1
- package/lib/server.js +28 -5
- package/lib/settings/routes.js +32 -0
- package/lib/update/manager.js +8 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-Dov6E_p3.js +0 -91
package/lib/fleet/managed.js
CHANGED
|
@@ -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
|
-
|
|
278
|
-
|
|
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
|
}
|
package/lib/fleet/routes.js
CHANGED
|
@@ -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
|
-
|
|
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'`
|
|
43
|
-
// sessione reale); fallback env NEXUSCREW_MCP_SESSION;
|
|
44
|
-
// che RICHIEDONO la sessione
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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)
|
|
56
|
-
|
|
57
|
-
|
|
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 (_) {
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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)
|
|
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
|
};
|
package/lib/runtime/env.js
CHANGED
|
@@ -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
|
};
|