@mmmbuto/nexuscrew 0.8.26 → 0.8.28
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 +46 -0
- package/README.md +44 -9
- package/frontend/dist/assets/{index-DqG-mDnV.css → index-BAzlX2nP.css} +1 -1
- package/frontend/dist/assets/index-DiKMLCvW.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +19 -2
- package/lib/cli/doctor.js +166 -1
- package/lib/cli/service.js +10 -5
- package/lib/diagnostics/routes.js +43 -0
- package/lib/diagnostics/store.js +139 -0
- package/lib/fleet/builtin.js +201 -16
- package/lib/fleet/causes.js +68 -0
- package/lib/fleet/cell-exec.js +17 -2
- package/lib/fleet/definitions.js +86 -4
- package/lib/fleet/launch.js +19 -1
- package/lib/fleet/managed.js +73 -12
- package/lib/fleet/routes.js +44 -5
- package/lib/fleet/runtime.js +69 -33
- 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-DkIPrmX6.js +0 -91
package/lib/fleet/launch.js
CHANGED
|
@@ -22,6 +22,7 @@ const os = require('node:os');
|
|
|
22
22
|
const path = require('node:path');
|
|
23
23
|
const { execFile } = require('node:child_process');
|
|
24
24
|
const { minimalRuntimeEnv } = require('../runtime/env.js');
|
|
25
|
+
const { codeOf, phaseOf } = require('./causes.js');
|
|
25
26
|
|
|
26
27
|
// Env minimale controllato dal service (design §9a). Allowlist DURA: le definizioni
|
|
27
28
|
// non possono toccare PATH/loader-key (parseDefinitions le rifiuta gia' in env);
|
|
@@ -34,7 +35,24 @@ function minimalEnv() {
|
|
|
34
35
|
return minimalRuntimeEnv(process.env, { home: os.homedir() });
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
// httpError(status, msg, data?, cause?) — structured HTTP error. `data` carries
|
|
39
|
+
// arbitrary API detail for the response body; `cause` (T4) is the OPTIONAL
|
|
40
|
+
// bounded failure triple {phase, code} of the up() boundary that failed. The
|
|
41
|
+
// cause is coerced through the closed enum in causes.js (anything not
|
|
42
|
+
// allowlisted degrades to UNKNOWN) and attached as e.fleetCode / e.fleetPhase,
|
|
43
|
+
// so the fleet router can surface {status, code, phase} WITHOUT ever embedding
|
|
44
|
+
// cwd/path, argv, env, prompt, token or credentials. The two channels are kept
|
|
45
|
+
// distinct: `data` is free API detail, `cause` is the bounded failure triple.
|
|
46
|
+
function httpError(status, msg, data = null, cause = null) {
|
|
47
|
+
const e = new Error(msg);
|
|
48
|
+
e.status = status;
|
|
49
|
+
if (data) e.data = data;
|
|
50
|
+
if (cause) {
|
|
51
|
+
e.fleetCode = codeOf(cause.code);
|
|
52
|
+
e.fleetPhase = phaseOf(cause.phase);
|
|
53
|
+
}
|
|
54
|
+
return e;
|
|
55
|
+
}
|
|
38
56
|
|
|
39
57
|
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
40
58
|
// NON devono mai ecoare i segreti delle definizioni.
|
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([
|
|
@@ -60,7 +61,7 @@ const ALIBABA_PI_MODELS = Object.freeze([
|
|
|
60
61
|
|
|
61
62
|
const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
|
|
62
63
|
const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
|
|
63
|
-
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi' });
|
|
64
|
+
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi', shell: 'Shell' });
|
|
64
65
|
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
65
66
|
|
|
66
67
|
function validBaseUrl(value) {
|
|
@@ -108,6 +109,9 @@ const CATALOG = Object.freeze([
|
|
|
108
109
|
// Pi uses its real provider IDs directly. OAuth providers do not need env keys.
|
|
109
110
|
{ id: 'pi.native', client: 'pi', provider: 'native', label: 'Pi configured default', auth: 'login', protocol: 'pi_native', default: true, core: true },
|
|
110
111
|
{ id: 'pi.alibaba-token-plan', client: 'pi', provider: 'alibaba-token-plan', label: 'Alibaba Token Plan Personal', auth: 'ALIBABA_CODE_API_KEY', endpoint: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', protocol: 'openai-completions', model: 'qwen3.8-max-preview', models: ALIBABA_TOKEN_PLAN_MODELS, strictModels: true, piProvider: 'alibaba-token-plan', piExtension: { baseUrl: 'https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1', models: ALIBABA_PI_MODELS }, delegatePiAuth: false, core: true, notice: 'alibaba-token-plan' },
|
|
112
|
+
// Device-local shell. Kept after the established default clients so adding
|
|
113
|
+
// it does not change the preselected engine for a newly-created cell.
|
|
114
|
+
{ id: 'shell.local', client: 'shell', provider: 'local', label: 'Shell', auth: 'none', protocol: 'shell', default: true, core: true },
|
|
111
115
|
{ id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic', core: true },
|
|
112
116
|
{ id: 'pi.openai', client: 'pi', provider: 'openai', label: 'OpenAI API', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai', core: true },
|
|
113
117
|
{ id: 'pi.openai-codex', client: 'pi', provider: 'openai-codex', label: 'OpenAI Codex OAuth', auth: 'login', protocol: 'pi_native', piProvider: 'openai-codex', core: true },
|
|
@@ -147,11 +151,12 @@ function normalizeManagedSpec(value) {
|
|
|
147
151
|
if (!profile) return null;
|
|
148
152
|
const model = value.model === undefined ? (profile.model || '') : value.model;
|
|
149
153
|
if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
|
|
154
|
+
if (value.client === 'shell' && model) return null;
|
|
150
155
|
if (profile.requiresModel && !model) return null;
|
|
151
156
|
if (profile.strictModels && !(profile.models || []).includes(model)) return null;
|
|
152
157
|
const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
|
|
153
158
|
if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
|
|
154
|
-
if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
|
|
159
|
+
if ((value.client === 'pi' || value.client === 'shell') && permissionPolicy !== 'standard') return null;
|
|
155
160
|
const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
|
|
156
161
|
if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
|
|
157
162
|
if (profile.credentialEnv) {
|
|
@@ -187,6 +192,16 @@ function defaultDefinitions() {
|
|
|
187
192
|
};
|
|
188
193
|
}
|
|
189
194
|
|
|
195
|
+
function defaultShellEngine() {
|
|
196
|
+
const profile = CATALOG.find((entry) => entry.id === 'shell.local');
|
|
197
|
+
return {
|
|
198
|
+
id: profile.id,
|
|
199
|
+
label: CLIENT_LABELS.shell,
|
|
200
|
+
rc: false,
|
|
201
|
+
managed: { client: 'shell', provider: 'local', model: '', permissionPolicy: 'standard' },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
190
205
|
function parseAssignments(raw) {
|
|
191
206
|
const out = {};
|
|
192
207
|
for (const line of raw.split(/\r?\n/)) {
|
|
@@ -268,14 +283,50 @@ function findBinary(client, home) {
|
|
|
268
283
|
return null;
|
|
269
284
|
}
|
|
270
285
|
|
|
286
|
+
// Resolve a device-local interactive shell without persisting a path in
|
|
287
|
+
// fleet.json. Candidates are ordered and fail closed. Symlinks are resolved
|
|
288
|
+
// first, then the existing command trust policy is applied to the real file.
|
|
289
|
+
function resolveInteractiveShell(cfg = {}) {
|
|
290
|
+
const env = cfg.env || process.env;
|
|
291
|
+
const platform = cfg.platform || process.platform;
|
|
292
|
+
const termux = termuxRuntimePaths(env, { platform, home: cfg.home });
|
|
293
|
+
const candidates = [];
|
|
294
|
+
if (typeof env.SHELL === 'string' && path.isAbsolute(env.SHELL)) candidates.push(env.SHELL);
|
|
295
|
+
if (termux?.prefix) {
|
|
296
|
+
candidates.push(path.join(termux.prefix, 'bin', 'bash'));
|
|
297
|
+
candidates.push(path.join(termux.prefix, 'bin', 'sh'));
|
|
298
|
+
}
|
|
299
|
+
candidates.push('/bin/bash', '/bin/sh');
|
|
300
|
+
const validate = cfg.validateCommandTrust
|
|
301
|
+
|| ((command) => require('./definitions.js').validateCommandTrust(command));
|
|
302
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
303
|
+
try {
|
|
304
|
+
const real = fs.realpathSync(candidate);
|
|
305
|
+
if (validate(real).ok) return real;
|
|
306
|
+
} catch (_) { /* next candidate */ }
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function shellLoginArgs(command) {
|
|
312
|
+
return ['bash', 'zsh', 'sh', 'dash'].includes(path.basename(String(command || ''))) ? ['-l'] : [];
|
|
313
|
+
}
|
|
314
|
+
|
|
271
315
|
// Termux reports process.platform === 'android' and deliberately has no
|
|
272
316
|
// /usr/bin/env. npm CLI shims commonly resolve to a JavaScript file with
|
|
273
317
|
// `#!/usr/bin/env node`; direct tmux exec then fails in the kernel before the
|
|
274
318
|
// client starts. Detect only that explicit Node shebang and invoke it through
|
|
275
319
|
// the already-running trusted Node executable. Native and shell binaries keep
|
|
276
320
|
// their original direct-exec path.
|
|
277
|
-
|
|
278
|
-
|
|
321
|
+
//
|
|
322
|
+
// Detection uses both process.platform AND the runtime Termux layout (PREFIX /
|
|
323
|
+
// files/home) so that a Node build that reports `linux` while actually running
|
|
324
|
+
// under Termux (proot / custom build) still gets the shebang workaround. The
|
|
325
|
+
// optional `env` argument lets tests inject a synthetic environment; the public
|
|
326
|
+
// two-argument call form is unchanged.
|
|
327
|
+
function needsExplicitNode(binary, platform = process.platform, env = process.env) {
|
|
328
|
+
const termux = platform === 'android' || termuxRuntimePaths(env, { platform }) !== null;
|
|
329
|
+
if (!termux) return false;
|
|
279
330
|
try {
|
|
280
331
|
const fd = fs.openSync(binary, 'r');
|
|
281
332
|
try {
|
|
@@ -414,7 +465,9 @@ function describeManaged(spec, cfg = {}) {
|
|
|
414
465
|
if (!normalized) return { configured: false, reason: 'invalid managed profile' };
|
|
415
466
|
const home = cfg.home || require('node:os').homedir();
|
|
416
467
|
const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
|
|
417
|
-
const binary =
|
|
468
|
+
const binary = normalized.client === 'shell'
|
|
469
|
+
? resolveInteractiveShell({ ...cfg, home })
|
|
470
|
+
: findBinary(normalized.client, home);
|
|
418
471
|
const cred = credential(profile, normalized, cfg, home);
|
|
419
472
|
// Pi can resolve credentials from its own documented /login auth store. Do
|
|
420
473
|
// not inspect or copy that store; delegate native-provider auth to Pi.
|
|
@@ -577,13 +630,20 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
577
630
|
let effectivePolicy = (override === 'standard' || override === 'unsafe') ? override : spec.permissionPolicy;
|
|
578
631
|
// Pi resta sempre 'standard': normalizeManagedSpec rifiuta gia' unsafe nello spec
|
|
579
632
|
// dell'engine, ma l'override PER-CELL bypasserebbe quel check -> clamp esplicito.
|
|
580
|
-
if (spec.client === 'pi') effectivePolicy = 'standard';
|
|
633
|
+
if (spec.client === 'pi' || spec.client === 'shell') effectivePolicy = 'standard';
|
|
581
634
|
info.permissionPolicy = effectivePolicy;
|
|
582
635
|
if (effectivePolicy === 'unsafe') {
|
|
583
636
|
if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
|
|
584
637
|
if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
585
638
|
}
|
|
586
|
-
|
|
639
|
+
let shellOneShot = false;
|
|
640
|
+
if (spec.client === 'shell') {
|
|
641
|
+
const raw = cell?.commands && typeof cell.commands[engineId] === 'string'
|
|
642
|
+
? cell.commands[engineId] : '';
|
|
643
|
+
shellOneShot = raw.trim().length > 0;
|
|
644
|
+
if (shellOneShot) args.push('-lc', raw);
|
|
645
|
+
else args.push(...shellLoginArgs(info.binary));
|
|
646
|
+
} else if (spec.client === 'claude') {
|
|
587
647
|
if (spec.provider === 'native') {
|
|
588
648
|
if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
|
|
589
649
|
} else if (profile.providerEnv) {
|
|
@@ -700,14 +760,15 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
700
760
|
if (model) args.push('--model', model);
|
|
701
761
|
if (spec.provider === 'alibaba-token-plan' && model === 'qwen3.8-max-preview') args.push('--thinking', 'xhigh');
|
|
702
762
|
}
|
|
703
|
-
if (cell?.prompt) args.push(cell.prompt);
|
|
763
|
+
if (spec.client !== 'shell' && cell?.prompt) args.push(cell.prompt);
|
|
704
764
|
let command = info.binary;
|
|
705
|
-
if (needsExplicitNode(info.binary, cfg.platform || process.platform)) {
|
|
765
|
+
if (needsExplicitNode(info.binary, cfg.platform || process.platform, cfg.env || process.env)) {
|
|
706
766
|
command = cfg.nodeExecPath || process.execPath;
|
|
707
767
|
args.unshift(info.binary);
|
|
708
768
|
}
|
|
709
769
|
return { ok: true, info, engine: {
|
|
710
770
|
...engine, command, args, env, promptMode: 'managed-argv', clientBinary: info.binary,
|
|
771
|
+
...(spec.client === 'shell' ? { shellOneShot } : {}),
|
|
711
772
|
} };
|
|
712
773
|
}
|
|
713
774
|
|
|
@@ -716,7 +777,7 @@ function publicCatalog() {
|
|
|
716
777
|
id: p.id, client: p.client, clientLabel: CLIENT_LABELS[p.client], provider: p.provider,
|
|
717
778
|
credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
|
|
718
779
|
auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
|
|
719
|
-
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe:
|
|
780
|
+
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: !['pi', 'shell'].includes(p.client), requiresModel: !!p.requiresModel || !!p.custom,
|
|
720
781
|
permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
|
|
721
782
|
rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
|
|
722
783
|
credentialEnv: p.auth === 'dynamic' ? !!p.credentialEnv : (ENV_KEY_RE.test(p.auth || '') ? p.auth : false),
|
|
@@ -728,8 +789,8 @@ module.exports = {
|
|
|
728
789
|
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, ALIBABA_TOKEN_PLAN_MODELS,
|
|
729
790
|
ALIBABA_CODEX_MODELS, ALIBABA_TOKEN_PLAN_CONTEXT, ALIBABA_PI_MODELS,
|
|
730
791
|
CLIENT_LABELS, normalizeManagedSpec,
|
|
731
|
-
defaultDefinitions, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
792
|
+
defaultDefinitions, defaultShellEngine, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
|
|
732
793
|
discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
733
794
|
providerKeyPaths, parseProviderKeyFiles, credentialSources, credential,
|
|
734
|
-
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, ENV_KEY_RE,
|
|
795
|
+
ensureKimiClaudeConfig, ensureAlibabaClaudeConfig, resolveInteractiveShell, shellLoginArgs, ENV_KEY_RE,
|
|
735
796
|
};
|
package/lib/fleet/routes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const express = require('express');
|
|
3
|
+
const { codeOf, phaseOf } = require('./causes.js');
|
|
3
4
|
|
|
4
5
|
// Fallback difensivo per un adapter builtin incompleto nei test. Il provider di
|
|
5
6
|
// prodotto dichiara sempre la propria capability list.
|
|
@@ -29,6 +30,14 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
29
30
|
r.use((req, res, next) => (req.path === '/restore-cells' || req.path === '/restore-engines' ? restoreJson : smallJson)(req, res, next));
|
|
30
31
|
|
|
31
32
|
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
33
|
+
const diagnostics = cfg.diagnostics || null;
|
|
34
|
+
const safeCell = (body) => {
|
|
35
|
+
const value = body && typeof body.cell === 'string' ? body.cell : '';
|
|
36
|
+
return /^[A-Za-z0-9._-]{1,32}$/.test(value) ? value : '';
|
|
37
|
+
};
|
|
38
|
+
const emit = (level, code, message, meta) => {
|
|
39
|
+
if (diagnostics && typeof diagnostics.record === 'function') diagnostics.record(level, 'fleet', code, message, meta);
|
|
40
|
+
};
|
|
32
41
|
|
|
33
42
|
const guard = (fn, opts = {}) => async (req, res) => {
|
|
34
43
|
try {
|
|
@@ -36,9 +45,39 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
36
45
|
if (opts.mutate && readonly()) return res.status(403).json({ error: 'READONLY: mutazione fleet bloccata' });
|
|
37
46
|
const fleet = await fleetP;
|
|
38
47
|
if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
|
|
39
|
-
|
|
48
|
+
const body = req.body || {};
|
|
49
|
+
if (opts.action) emit('info', 'FLEET_ACTION_STARTED', 'Fleet action started', {
|
|
50
|
+
action: opts.action, cell: safeCell(body), state: 'starting',
|
|
51
|
+
});
|
|
52
|
+
const result = await fn(fleet, body);
|
|
53
|
+
if (opts.action) emit('info', 'FLEET_ACTION_COMPLETED', 'Fleet action completed', {
|
|
54
|
+
action: opts.action, cell: safeCell(body), state: opts.action === 'down' ? 'stopped' : 'ready',
|
|
55
|
+
});
|
|
56
|
+
res.json(result);
|
|
40
57
|
} catch (e) {
|
|
41
|
-
|
|
58
|
+
if (opts.action) {
|
|
59
|
+
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})/);
|
|
60
|
+
if (match) emit('error', 'CELL_SPAWN_FAILED', 'Cell client spawn failed', {
|
|
61
|
+
action: opts.action, cell: safeCell(req.body), errno: match[1], client: match[2], status: e.status || 500,
|
|
62
|
+
});
|
|
63
|
+
else emit('warn', 'FLEET_ACTION_FAILED', 'Fleet action failed', {
|
|
64
|
+
action: opts.action, cell: safeCell(req.body), state: 'failed',
|
|
65
|
+
status: e.status || 500,
|
|
66
|
+
// Cause-preserving (T4): bounded enum only (lib/fleet/causes.js). The
|
|
67
|
+
// cause triple is {status, code, phase}; no free message, cwd/path,
|
|
68
|
+
// argv, env, prompt, token or credential ever enters here. Untagged /
|
|
69
|
+
// legacy errors degrade to the bounded UNKNOWN fallback.
|
|
70
|
+
code: codeOf(e.fleetCode), phase: phaseOf(e.fleetPhase),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
res.status(e.status || 500).json({
|
|
74
|
+
error: String(e.message || e),
|
|
75
|
+
...(e.data || {}),
|
|
76
|
+
// Structured, bounded cause on the HTTP error too: stable enum only,
|
|
77
|
+
// never cwd/path, argv, env, prompt, token or credentials. Untagged
|
|
78
|
+
// errors (no fleetCode) keep the historical response shape.
|
|
79
|
+
...(e.fleetCode ? { code: codeOf(e.fleetCode), phase: phaseOf(e.fleetPhase) } : {}),
|
|
80
|
+
});
|
|
42
81
|
}
|
|
43
82
|
};
|
|
44
83
|
|
|
@@ -82,13 +121,13 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
82
121
|
await f.boot(cell, b.boot === true);
|
|
83
122
|
}
|
|
84
123
|
return f.up(cell, { engine: b.engine, boot: !!b.boot });
|
|
85
|
-
}, { mutate: true }));
|
|
124
|
+
}, { mutate: true, action: 'up' }));
|
|
86
125
|
r.post('/down', guard(async (f, b) => {
|
|
87
126
|
const cell = String(b.cell || '');
|
|
88
127
|
if (b.boot === true && capList(f).includes('edit') && capList(f).includes('boot')) await f.boot(cell, false);
|
|
89
128
|
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 }));
|
|
129
|
+
}, { mutate: true, action: 'down' }));
|
|
130
|
+
r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true, action: 'restart' }));
|
|
92
131
|
r.post('/engine', guard((f, b) => {
|
|
93
132
|
const opts = { model: typeof b.model === 'string' ? b.model : '' };
|
|
94
133
|
if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -85,7 +85,7 @@ function createBuiltinRuntime(ctx) {
|
|
|
85
85
|
const remembered = c.permissionPolicies
|
|
86
86
|
&& (c.permissionPolicies[c.engine] === 'standard' || c.permissionPolicies[c.engine] === 'unsafe')
|
|
87
87
|
? c.permissionPolicies[c.engine] : null;
|
|
88
|
-
const effectivePolicy = engineDef?.managed?.client
|
|
88
|
+
const effectivePolicy = ['pi', 'shell'].includes(engineDef?.managed?.client)
|
|
89
89
|
? 'standard'
|
|
90
90
|
: (remembered || engineDefault || '');
|
|
91
91
|
return {
|
|
@@ -149,16 +149,19 @@ function createBuiltinRuntime(ctx) {
|
|
|
149
149
|
let launchEngine = engine;
|
|
150
150
|
if (engine.managed) {
|
|
151
151
|
const resolved = resolveManagedEngine(engine, cell, { ...cfg, home });
|
|
152
|
-
if (!resolved.ok)
|
|
152
|
+
if (!resolved.ok) {
|
|
153
|
+
const code = engine.managed.client === 'shell' ? 'SHELL_NOT_AVAILABLE' : 'ENGINE_UNCONFIGURED';
|
|
154
|
+
throw httpError(400, `engine managed non configurato (${engine.id}): ${resolved.reason}`, null, { phase: 'preflight', code });
|
|
155
|
+
}
|
|
153
156
|
launchEngine = resolved.engine;
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
// (2) trust del command PRIMA di lanciare
|
|
157
160
|
const trust = validateCommandTrust(launchEngine.command);
|
|
158
|
-
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}
|
|
161
|
+
if (!trust.ok) throw httpError(400, `command non trusted (${launchEngine.command}): ${trust.reason}`, null, { phase: 'preflight', code: 'COMMAND_UNTRUSTED' });
|
|
159
162
|
// (3) cwd reale sotto la home
|
|
160
163
|
const realCwd = resolveCwd(cell.cwd, home);
|
|
161
|
-
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}
|
|
164
|
+
if (!realCwd) throw httpError(400, `cwd non valida (deve esistere sotto la home): ${cell.cwd}`, null, { phase: 'preflight', code: 'CWD_INVALID' });
|
|
162
165
|
|
|
163
166
|
// (4)+(5) argv diretto (no shell). Every cell goes through the private
|
|
164
167
|
// broker-backed supervisor: credentials never enter tmux state/argv and a
|
|
@@ -168,32 +171,46 @@ function createBuiltinRuntime(ctx) {
|
|
|
168
171
|
// #5: elimina la race di riuso del nome sessione tra waitAlive e paste).
|
|
169
172
|
const readyMs = cfg.launchReadyMs != null ? cfg.launchReadyMs : 500;
|
|
170
173
|
const child = composeClientInvocation(launchEngine, cell);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
enabled: true,
|
|
181
|
-
initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
|
|
182
|
-
restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
|
|
183
|
-
maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
|
|
184
|
-
resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
|
|
185
|
-
rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
|
|
186
|
-
maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
|
|
187
|
-
},
|
|
188
|
-
...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
|
|
189
|
-
restartPrompt: {
|
|
190
|
-
tmuxBin,
|
|
191
|
-
tmuxSession: cell.tmuxSession,
|
|
192
|
-
prompt: cell.prompt,
|
|
193
|
-
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
174
|
+
let ticket;
|
|
175
|
+
try {
|
|
176
|
+
ticket = await launchBroker.issue({
|
|
177
|
+
command: child.command,
|
|
178
|
+
args: child.args,
|
|
179
|
+
env: {
|
|
180
|
+
...minimalEnv(),
|
|
181
|
+
...launchEngine.env,
|
|
182
|
+
NEXUSCREW_MCP_SESSION: cell.tmuxSession,
|
|
194
183
|
},
|
|
195
|
-
|
|
196
|
-
|
|
184
|
+
supervise: {
|
|
185
|
+
enabled: !launchEngine.shellOneShot,
|
|
186
|
+
initialReadyMs: Math.max(50, Math.min(30000, Number(readyMs) || 500)),
|
|
187
|
+
restartDelayMs: Math.max(50, Math.min(60000, Number(cfg.cellRestartDelayMs) || 1000)),
|
|
188
|
+
maxRestartDelayMs: Math.max(100, Math.min(300000, Number(cfg.cellMaxRestartDelayMs) || 60000)),
|
|
189
|
+
resetAfterMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRestartResetMs) || 30000)),
|
|
190
|
+
rapidWindowMs: Math.max(1000, Math.min(3600000, Number(cfg.cellRapidWindowMs) || 60000)),
|
|
191
|
+
maxRapidRestarts: Math.max(1, Math.min(100, Number(cfg.cellMaxRapidRestarts) || 8)),
|
|
192
|
+
},
|
|
193
|
+
...(launchEngine.promptMode === 'send-keys' && cell.prompt ? {
|
|
194
|
+
restartPrompt: {
|
|
195
|
+
tmuxBin,
|
|
196
|
+
tmuxSession: cell.tmuxSession,
|
|
197
|
+
prompt: cell.prompt,
|
|
198
|
+
readyMs: Math.max(0, Math.min(30000, Number(cfg.sendKeysReadyMs) || readyMs)),
|
|
199
|
+
},
|
|
200
|
+
} : {}),
|
|
201
|
+
});
|
|
202
|
+
} catch (brokerErr) {
|
|
203
|
+
const bmsg = String((brokerErr && brokerErr.message) || brokerErr);
|
|
204
|
+
const bcode = /too large/i.test(bmsg) ? 'LAUNCH_BROKER_PAYLOAD'
|
|
205
|
+
: /closed/i.test(bmsg) ? 'LAUNCH_BROKER_CLOSED'
|
|
206
|
+
: /unsafe launch broker/i.test(bmsg) ? 'LAUNCH_BROKER_UNSAFE'
|
|
207
|
+
: 'LAUNCH_BROKER_FAILED';
|
|
208
|
+
const bpublic = bcode === 'LAUNCH_BROKER_PAYLOAD' ? 'launch broker payload rifiutato'
|
|
209
|
+
: bcode === 'LAUNCH_BROKER_CLOSED' ? 'launch broker non disponibile'
|
|
210
|
+
: bcode === 'LAUNCH_BROKER_UNSAFE' ? 'launch broker non sicuro'
|
|
211
|
+
: 'launch broker failed';
|
|
212
|
+
throw httpError(500, bpublic, null, { phase: 'launch-broker', code: bcode });
|
|
213
|
+
}
|
|
197
214
|
const tmuxLaunchEngine = {
|
|
198
215
|
command: process.execPath,
|
|
199
216
|
args: [path.join(__dirname, 'cell-exec.js'), '--socket', ticket.socketPath, '--nonce', ticket.nonce],
|
|
@@ -204,14 +221,27 @@ function createBuiltinRuntime(ctx) {
|
|
|
204
221
|
// Mantieni il pane morto solo durante la finestra di readiness: permette di
|
|
205
222
|
// catturare un errore reale del client senza lasciare una sessione fantasma.
|
|
206
223
|
// Il separatore e' interpretato da tmux (execFile argv diretto), non da shell.
|
|
207
|
-
|
|
224
|
+
if (!launchEngine.shellOneShot) {
|
|
225
|
+
argv.push(';', 'set-option', '-w', '-t', `=${cell.tmuxSession}:`, 'remain-on-exit', 'on');
|
|
226
|
+
}
|
|
208
227
|
const launch = await tmuxExec(tmuxBin, argv, { env: minimalEnv() });
|
|
209
228
|
if (launch.err) {
|
|
210
229
|
// Redazione (§9h): lo stderr di tmux puo' ecoare argv/env del comando lanciato.
|
|
211
|
-
const
|
|
230
|
+
const dup = /duplicate session/i.test(launch.stderr);
|
|
231
|
+
const why = dup
|
|
212
232
|
? 'sessione già in esecuzione'
|
|
213
233
|
: `tmux new-session failed: ${redactSecrets(launch.stderr.trim() || launch.err.message, launchEngine, cell)}`;
|
|
214
|
-
throw httpError(
|
|
234
|
+
throw httpError(dup ? 409 : 500, why, null,
|
|
235
|
+
{ phase: 'new-session', code: dup ? 'SESSION_DUPLICATE' : 'NEW_SESSION_FAILED' });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Un comando Shell valorizzato e' intenzionalmente one-shot: l'uscita del
|
|
239
|
+
// processo, anche immediata o non-zero, e' il risultato del comando e non
|
|
240
|
+
// un crash del client Fleet. Non si mantiene il pane e non si abilita il
|
|
241
|
+
// supervisore; la cella torna inattiva quando la shell termina.
|
|
242
|
+
if (launchEngine.shellOneShot) {
|
|
243
|
+
cache = { ...cache, at: 0 };
|
|
244
|
+
return { ok: true, cell: cellId, session: cell.tmuxSession, prompt: null, oneShot: true };
|
|
215
245
|
}
|
|
216
246
|
|
|
217
247
|
// `tmux new-session -d` can return 0 even when the launched CLI exits a
|
|
@@ -235,7 +265,13 @@ function createBuiltinRuntime(ctx) {
|
|
|
235
265
|
cache = { ...cache, at: 0 };
|
|
236
266
|
const client = path.basename(launchEngine.clientBinary || launchEngine.command || 'client');
|
|
237
267
|
const status = Number.isInteger(readiness.status) ? ` (exit ${readiness.status})` : '';
|
|
238
|
-
|
|
268
|
+
// Cause-preserving (T4): distinguish a cell-client spawn failure (the
|
|
269
|
+
// captured pane carries the stable 'cell spawn failed:' marker produced by
|
|
270
|
+
// cell-exec.js) from a generic early exit. Both stay on the readiness
|
|
271
|
+
// surface; the spawn branch keeps CELL_SPAWN_FAILED sanitized downstream.
|
|
272
|
+
const isSpawn = /cell spawn failed:/.test(diagnostic);
|
|
273
|
+
throw httpError(500, `client ${client} terminato subito${status}: ${diagnostic || 'verifica login, provider, modello e argomenti dell\'engine'}`, null,
|
|
274
|
+
isSpawn ? { phase: 'spawn-client', code: 'SPAWN_CLIENT_FAILED' } : { phase: 'readiness', code: 'CLIENT_EARLY_EXIT' });
|
|
239
275
|
}
|
|
240
276
|
if (readiness.target) {
|
|
241
277
|
await tmuxExec(tmuxBin,
|
|
@@ -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
|
};
|