@mmmbuto/nexuscrew 0.8.4 → 0.8.6
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/README.md +55 -24
- package/frontend/dist/assets/index-CEFh0hI4.js +90 -0
- package/frontend/dist/assets/{index-COsoJxXQ.css → index-kX5IUxt4.css} +2 -2
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/fleet-service.js +8 -3
- package/lib/fleet/builtin.js +146 -23
- package/lib/fleet/definitions.js +20 -0
- package/lib/fleet/index.js +42 -1
- package/lib/fleet/managed.js +63 -30
- package/lib/fleet/provider.js +12 -11
- package/lib/fleet/routes.js +20 -4
- package/lib/nodes/peering.js +59 -7
- package/lib/nodes/store.js +2 -2
- package/lib/settings/routes.js +22 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-C1AFIaRR.js +0 -90
package/lib/fleet/managed.js
CHANGED
|
@@ -34,41 +34,50 @@ function validBaseUrl(value) {
|
|
|
34
34
|
|
|
35
35
|
const CATALOG = Object.freeze([
|
|
36
36
|
// Claude Code
|
|
37
|
-
{ id: 'claude.native', client: 'claude', provider: 'native', label: 'Claude
|
|
38
|
-
{ id: 'claude.
|
|
39
|
-
{ id: 'claude.
|
|
40
|
-
{ id: 'claude.
|
|
41
|
-
{ id: 'claude.
|
|
42
|
-
{ id: 'claude.
|
|
37
|
+
{ id: 'claude.native', client: 'claude', provider: 'native', label: 'Anthropic / Claude account', auth: 'login', endpoint: 'Anthropic account', protocol: 'anthropic_messages', rc: true, default: true, core: true },
|
|
38
|
+
{ id: 'claude.bedrock', client: 'claude', provider: 'bedrock', label: 'Amazon Bedrock', auth: 'login', endpoint: 'AWS Bedrock', protocol: 'anthropic_messages', core: true, providerEnv: { CLAUDE_CODE_USE_BEDROCK: '1' } },
|
|
39
|
+
{ id: 'claude.vertex', client: 'claude', provider: 'vertex', label: 'Google Vertex AI', auth: 'login', endpoint: 'Google Vertex AI', protocol: 'anthropic_messages', core: true, providerEnv: { CLAUDE_CODE_USE_VERTEX: '1' } },
|
|
40
|
+
{ id: 'claude.foundry', client: 'claude', provider: 'foundry', label: 'Microsoft Foundry', auth: 'login', endpoint: 'Microsoft Foundry', protocol: 'anthropic_messages', core: true, providerEnv: { CLAUDE_CODE_USE_FOUNDRY: '1' } },
|
|
41
|
+
{ id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', protocol: 'anthropic_messages', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
|
|
42
|
+
{ id: 'claude.ollama', client: 'claude', provider: 'ollama', label: 'Ollama local', auth: 'none', endpoint: 'http://127.0.0.1:11434', protocol: 'anthropic_messages', core: true },
|
|
43
|
+
{ id: 'claude.zai', client: 'claude', provider: 'zai', label: 'Z.AI', auth: 'dynamic', credentialEnv: true, defaultEnvKey: 'ZAI_API_KEY', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], core: true },
|
|
44
|
+
// Compatibilità sola lettura/launch per configurazioni 0.8.0: mai nel catalogo UI.
|
|
45
|
+
{ id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-a', legacy: true },
|
|
46
|
+
{ id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Z.AI legacy profile', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-p', legacy: true },
|
|
47
|
+
{ id: 'claude.custom', client: 'claude', provider: 'custom', label: 'Custom Anthropic-compatible', auth: 'dynamic', protocol: 'anthropic_messages', protocols: ['anthropic_messages'], custom: true, core: true },
|
|
43
48
|
|
|
44
49
|
// Codex family. OpenAI Responses is the only remote custom wire API.
|
|
45
|
-
{ id: 'codex.native', client: 'codex', provider: 'native', label: '
|
|
46
|
-
{ id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: '
|
|
47
|
-
{ id: 'codex.
|
|
48
|
-
{ id: 'codex-vl.
|
|
49
|
-
{ id: 'codex.
|
|
50
|
-
{ id: 'codex-vl.
|
|
51
|
-
{ id: 'codex.
|
|
52
|
-
{ id: 'codex-vl.
|
|
53
|
-
{ id: 'codex.
|
|
54
|
-
{ id: 'codex-vl.
|
|
50
|
+
{ id: 'codex.native', client: 'codex', provider: 'native', label: 'OpenAI / ChatGPT account', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true, core: true },
|
|
51
|
+
{ id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'OpenAI / ChatGPT account', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true, core: true },
|
|
52
|
+
{ id: 'codex.openai-api', client: 'codex', provider: 'openai-api', label: 'OpenAI API', auth: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1', protocol: 'openai_responses', core: true },
|
|
53
|
+
{ id: 'codex-vl.openai-api', client: 'codex-vl', provider: 'openai-api', label: 'OpenAI API', auth: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1', protocol: 'openai_responses', core: true },
|
|
54
|
+
{ id: 'codex.ollama', client: 'codex', provider: 'ollama', label: 'Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama', core: true },
|
|
55
|
+
{ id: 'codex-vl.ollama', client: 'codex-vl', provider: 'ollama', label: 'Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama', core: true },
|
|
56
|
+
{ id: 'codex.lmstudio', client: 'codex', provider: 'lmstudio', label: 'LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio', core: true },
|
|
57
|
+
{ id: 'codex-vl.lmstudio', client: 'codex-vl', provider: 'lmstudio', label: 'LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio', core: true },
|
|
58
|
+
{ id: 'codex.ollama-cloud', client: 'codex', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
|
|
59
|
+
{ id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true, core: true },
|
|
60
|
+
{ id: 'codex.custom', client: 'codex', provider: 'custom', label: 'Custom Responses endpoint', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true, core: true },
|
|
61
|
+
{ id: 'codex-vl.custom', client: 'codex-vl', provider: 'custom', label: 'Custom Responses endpoint', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true, core: true },
|
|
55
62
|
|
|
56
63
|
// Pi uses its real provider IDs directly. OAuth providers do not need env keys.
|
|
57
|
-
{ id: 'pi.
|
|
58
|
-
{ id: 'pi.
|
|
59
|
-
{ id: 'pi.
|
|
60
|
-
{ id: 'pi.
|
|
61
|
-
{ id: 'pi.
|
|
64
|
+
{ id: 'pi.native', client: 'pi', provider: 'native', label: 'Pi configured default', auth: 'login', protocol: 'pi_native', default: true, core: true },
|
|
65
|
+
{ id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic', core: true },
|
|
66
|
+
{ id: 'pi.openai', client: 'pi', provider: 'openai', label: 'OpenAI API', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai', core: true },
|
|
67
|
+
{ id: 'pi.openai-codex', client: 'pi', provider: 'openai-codex', label: 'OpenAI Codex OAuth', auth: 'login', protocol: 'pi_native', piProvider: 'openai-codex', core: true },
|
|
68
|
+
{ id: 'pi.google', client: 'pi', provider: 'google', label: 'Google Gemini', auth: 'GEMINI_API_KEY', protocol: 'pi_native', piProvider: 'google', core: true },
|
|
69
|
+
{ id: 'pi.openrouter', client: 'pi', provider: 'openrouter', label: 'OpenRouter', auth: 'OPENROUTER_API_KEY', protocol: 'pi_native', piProvider: 'openrouter', core: true },
|
|
70
|
+
{ id: 'pi.github-copilot', client: 'pi', provider: 'github-copilot', label: 'GitHub Copilot', auth: 'login', protocol: 'pi_native', piProvider: 'github-copilot', core: true },
|
|
62
71
|
{ id: 'pi.fireworks', client: 'pi', provider: 'fireworks', label: 'Pi · Fireworks AI', auth: 'FIREWORKS_API_KEY', protocol: 'pi_native', piProvider: 'fireworks' },
|
|
63
72
|
{ id: 'pi.huggingface', client: 'pi', provider: 'huggingface', label: 'Pi · Hugging Face', auth: 'HF_TOKEN', protocol: 'pi_native', piProvider: 'huggingface' },
|
|
64
73
|
{ id: 'pi.minimax', client: 'pi', provider: 'minimax', label: 'Pi · MiniMax', auth: 'MINIMAX_API_KEY', protocol: 'pi_native', piProvider: 'minimax' },
|
|
65
|
-
{ id: 'pi.deepseek', client: 'pi', provider: 'deepseek', label: '
|
|
74
|
+
{ id: 'pi.deepseek', client: 'pi', provider: 'deepseek', label: 'DeepSeek', auth: 'DEEPSEEK_API_KEY', protocol: 'pi_native', piProvider: 'deepseek', core: true },
|
|
66
75
|
{ id: 'pi.kimi-coding', client: 'pi', provider: 'kimi-coding', label: 'Pi · Kimi For Coding', auth: 'KIMI_API_KEY', protocol: 'pi_native', piProvider: 'kimi-coding' },
|
|
67
76
|
{ id: 'pi.mistral', client: 'pi', provider: 'mistral', label: 'Pi · Mistral', auth: 'MISTRAL_API_KEY', protocol: 'pi_native', piProvider: 'mistral' },
|
|
68
77
|
{ id: 'pi.together', client: 'pi', provider: 'together', label: 'Pi · Together AI', auth: 'TOGETHER_API_KEY', protocol: 'pi_native', piProvider: 'together' },
|
|
69
|
-
{ id: 'pi.ollama', client: 'pi', provider: 'ollama', label: '
|
|
70
|
-
{ id: 'pi.zai', client: 'pi', provider: 'zai', label: '
|
|
71
|
-
{ id: 'pi.custom', client: 'pi', provider: 'custom', label: '
|
|
78
|
+
{ id: 'pi.ollama', client: 'pi', provider: 'ollama', label: 'Ollama local', auth: 'none', protocol: 'openai-completions', piProvider: 'ollama', requiresModel: true, core: true, piExtension: { baseUrl: 'http://127.0.0.1:11434/v1', apiKey: 'ollama' } },
|
|
79
|
+
{ id: 'pi.zai', client: 'pi', provider: 'zai', label: 'Z.AI', auth: 'ZAI_API_KEY', protocol: 'pi_native', piProvider: 'zai', core: true },
|
|
80
|
+
{ id: 'pi.custom', client: 'pi', provider: 'custom', label: 'Custom provider', auth: 'dynamic', protocol: 'openai-responses', protocols: ['openai-responses', 'anthropic-messages', 'openai-completions', 'google-generative-ai'], custom: true, core: true },
|
|
72
81
|
]);
|
|
73
82
|
|
|
74
83
|
function profileFor(client, provider, credentialProfile) {
|
|
@@ -98,6 +107,13 @@ function normalizeManagedSpec(value) {
|
|
|
98
107
|
if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
|
|
99
108
|
const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
|
|
100
109
|
if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
|
|
110
|
+
if (profile.credentialEnv) {
|
|
111
|
+
const envKey = typeof value.envKey === 'string' && value.envKey.trim()
|
|
112
|
+
? value.envKey.trim()
|
|
113
|
+
: profile.defaultEnvKey;
|
|
114
|
+
if (!ENV_KEY_RE.test(envKey || '')) return null;
|
|
115
|
+
out.envKey = envKey;
|
|
116
|
+
}
|
|
101
117
|
if (profile.custom) {
|
|
102
118
|
const displayName = typeof value.displayName === 'string' ? value.displayName.trim() : '';
|
|
103
119
|
const baseUrl = typeof value.baseUrl === 'string' ? value.baseUrl.trim() : '';
|
|
@@ -117,7 +133,7 @@ function defaultDefinitions() {
|
|
|
117
133
|
return {
|
|
118
134
|
schemaVersion: 1,
|
|
119
135
|
engines: CATALOG.filter((p) => p.default).map((p) => ({
|
|
120
|
-
id: p.id, label: p.
|
|
136
|
+
id: p.id, label: CLIENT_LABELS[p.client], rc: !!p.rc,
|
|
121
137
|
managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: p.client === 'claude' ? 'unsafe' : 'standard' },
|
|
122
138
|
})),
|
|
123
139
|
cells: [],
|
|
@@ -317,27 +333,43 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
317
333
|
const profile = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
|
|
318
334
|
const cred = credential(profile, spec, cfg, home);
|
|
319
335
|
const env = {}; const args = []; const model = cell?.model || spec.model;
|
|
320
|
-
|
|
336
|
+
// Effective permission policy: override PER-CELL PER-ENGINE (remembered) vince sul
|
|
337
|
+
// default dell'engine. Mai si mutationa engine.managed.permissionPolicy (globale).
|
|
338
|
+
// Pi resta sempre 'standard' (lo spec normalized rifiuta gia' unsafe per pi).
|
|
339
|
+
const engineId = engine && typeof engine.id === 'string' ? engine.id : '';
|
|
340
|
+
const override = cell && cell.permissionPolicies && Object.prototype.hasOwnProperty.call(cell.permissionPolicies, engineId)
|
|
341
|
+
? cell.permissionPolicies[engineId] : null;
|
|
342
|
+
let effectivePolicy = (override === 'standard' || override === 'unsafe') ? override : spec.permissionPolicy;
|
|
343
|
+
// Pi resta sempre 'standard': normalizeManagedSpec rifiuta gia' unsafe nello spec
|
|
344
|
+
// dell'engine, ma l'override PER-CELL bypasserebbe quel check -> clamp esplicito.
|
|
345
|
+
if (spec.client === 'pi') effectivePolicy = 'standard';
|
|
346
|
+
info.permissionPolicy = effectivePolicy;
|
|
347
|
+
if (effectivePolicy === 'unsafe') {
|
|
321
348
|
if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
|
|
322
349
|
if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
323
350
|
}
|
|
324
351
|
if (spec.client === 'claude') {
|
|
325
352
|
if (spec.provider === 'native') {
|
|
326
353
|
if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
|
|
354
|
+
} else if (profile.providerEnv) {
|
|
355
|
+
Object.assign(env, profile.providerEnv);
|
|
327
356
|
} else {
|
|
328
357
|
const endpoint = spec.baseUrl || profile.endpoint;
|
|
329
358
|
const token = profile.auth === 'none' ? 'ollama' : cred.value;
|
|
359
|
+
const contextWindow = OLLAMA_CONTEXT[model] || (spec.provider === 'zai' ? 1000000 : 200000);
|
|
330
360
|
Object.assign(env, {
|
|
331
361
|
ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: token, ANTHROPIC_API_KEY: '',
|
|
332
362
|
ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
333
363
|
ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
334
364
|
CLAUDE_CODE_SUBAGENT_MODEL: model, API_TIMEOUT_MS: '3000000',
|
|
335
|
-
|
|
365
|
+
CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextWindow),
|
|
366
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(contextWindow),
|
|
336
367
|
});
|
|
337
368
|
}
|
|
338
369
|
if (model) args.push('--model', model);
|
|
339
370
|
} else if (spec.client === 'codex' || spec.client === 'codex-vl') {
|
|
340
371
|
if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
|
|
372
|
+
else if (spec.provider === 'openai-api') env.OPENAI_API_KEY = cred.value;
|
|
341
373
|
else if (spec.provider === 'ollama-cloud') {
|
|
342
374
|
env.OPENAI_API_KEY = cred.value;
|
|
343
375
|
args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
|
|
@@ -357,7 +389,7 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
357
389
|
baseUrl: profile.piExtension.baseUrl, apiKey: profile.piExtension.apiKey,
|
|
358
390
|
protocol: profile.protocol, model,
|
|
359
391
|
}, home));
|
|
360
|
-
args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
|
|
392
|
+
if (spec.provider !== 'native') args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
|
|
361
393
|
if (model) args.push('--model', model);
|
|
362
394
|
}
|
|
363
395
|
if (cell?.prompt) args.push(cell.prompt);
|
|
@@ -365,13 +397,14 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
|
365
397
|
}
|
|
366
398
|
|
|
367
399
|
function publicCatalog() {
|
|
368
|
-
return CATALOG.map((p) => ({
|
|
400
|
+
return CATALOG.filter((p) => !p.legacy && (p.core || p.default || p.custom)).map((p) => ({
|
|
369
401
|
id: p.id, client: p.client, clientLabel: CLIENT_LABELS[p.client], provider: p.provider,
|
|
370
402
|
credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
|
|
371
403
|
auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
|
|
372
404
|
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: p.client !== 'pi', requiresModel: !!p.requiresModel || !!p.custom,
|
|
373
405
|
permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
|
|
374
406
|
rc: !!p.rc, custom: !!p.custom, default: !!p.default,
|
|
407
|
+
credentialEnv: !!p.credentialEnv, defaultEnvKey: p.defaultEnvKey || '',
|
|
375
408
|
}));
|
|
376
409
|
}
|
|
377
410
|
|
package/lib/fleet/provider.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Il drift a runtime NON si risolve qui: e' una scelta one-shot. Se l'external
|
|
15
15
|
// diventa invalido DOPO lo startup, lo status risultante sara' degraded/unavailable
|
|
16
16
|
// (mai fall-through silenzioso al built-in) — gestito dal layer di status.
|
|
17
|
-
const {
|
|
17
|
+
const { binTrusted, externalFleetCandidates, resolveExternalFleet } = require('./index.js');
|
|
18
18
|
const { createBuiltinFleet } = require('./builtin.js');
|
|
19
19
|
|
|
20
20
|
const DISABLED_FLEET = Object.freeze({
|
|
@@ -37,19 +37,20 @@ async function selectProvider(cfg = {}) {
|
|
|
37
37
|
|
|
38
38
|
const forced = (cfg.fleetProvider || process.env.NEXUSCREW_FLEET_PROVIDER || '').toLowerCase() || null;
|
|
39
39
|
|
|
40
|
-
// External:
|
|
40
|
+
// External: scopre il binario fleet legacy cross-platform (cfg.fleetBin,
|
|
41
|
+
// $PREFIX/bin/fleet su Termux, ~/.local/bin/fleet) e accetta il primo fidato
|
|
42
|
+
// (binTrusted) che risponde al contratto (status --json con schema valido).
|
|
41
43
|
let extFleet = null;
|
|
42
44
|
let extReason = null;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
else extReason = 'binario fidato ma non risponde al contratto (status invalido)';
|
|
48
|
-
} else {
|
|
49
|
-
extReason = 'binario non fidato (symlink/world-writable/non exec)';
|
|
50
|
-
}
|
|
45
|
+
const resolvedExternal = await resolveExternalFleet(cfg);
|
|
46
|
+
if (resolvedExternal) {
|
|
47
|
+
extFleet = resolvedExternal.fleet;
|
|
48
|
+
extReason = resolvedExternal.reason;
|
|
51
49
|
} else {
|
|
52
|
-
|
|
50
|
+
// Diagnosi: perché nessun candidato external è valido (fidato + contratto).
|
|
51
|
+
const cands = externalFleetCandidates(cfg);
|
|
52
|
+
if (!cands.length) extReason = 'nessun fleetBin esterno configurato';
|
|
53
|
+
else extReason = `nessun candidato external valido: ${cands.map((b) => (binTrusted(b) ? `${b} (non risponde al contratto)` : `${b} (non fidato)`)).join('; ')}`;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
// Builtin: abilitato + fleet.json valido.
|
package/lib/fleet/routes.js
CHANGED
|
@@ -75,16 +75,29 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
75
75
|
// un engine lo persiste sulla cella (f.engine) PRIMA di up, perché l'engine
|
|
76
76
|
// dichiarato vince sull'override runtime (ignored dal builtin.up).
|
|
77
77
|
// External legacy: passthrough up(cell,{engine,boot}) invariato.
|
|
78
|
-
r.post('/up', guard((f, b) => {
|
|
78
|
+
r.post('/up', guard(async (f, b) => {
|
|
79
79
|
const cell = String(b.cell || '');
|
|
80
80
|
if (b.engine && capList(f).includes('edit')) {
|
|
81
|
-
|
|
81
|
+
const opts = { model: typeof b.model === 'string' ? b.model : '' };
|
|
82
|
+
if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
|
|
83
|
+
await f.engine(cell, String(b.engine), opts);
|
|
84
|
+
}
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(b, 'boot') && capList(f).includes('edit') && capList(f).includes('boot')) {
|
|
86
|
+
await f.boot(cell, b.boot === true);
|
|
82
87
|
}
|
|
83
88
|
return f.up(cell, { engine: b.engine, boot: !!b.boot });
|
|
84
89
|
}, { mutate: true }));
|
|
85
|
-
r.post('/down', guard((f, b) =>
|
|
90
|
+
r.post('/down', guard(async (f, b) => {
|
|
91
|
+
const cell = String(b.cell || '');
|
|
92
|
+
if (b.boot === true && capList(f).includes('edit') && capList(f).includes('boot')) await f.boot(cell, false);
|
|
93
|
+
return f.down(cell, { boot: !!b.boot });
|
|
94
|
+
}, { mutate: true }));
|
|
86
95
|
r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true }));
|
|
87
|
-
r.post('/engine', guard((f, b) =>
|
|
96
|
+
r.post('/engine', guard((f, b) => {
|
|
97
|
+
const opts = { model: typeof b.model === 'string' ? b.model : '' };
|
|
98
|
+
if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
|
|
99
|
+
return f.engine(String(b.cell || ''), String(b.engine || ''), opts);
|
|
100
|
+
}, { mutate: true }));
|
|
88
101
|
r.post('/boot', guard((f, b) => f.boot(String(b.cell || ''), b.enabled === true), { mutate: true }));
|
|
89
102
|
|
|
90
103
|
// --- Estensione B4.2: schema + define/edit/remove (engine e cell) ---
|
|
@@ -97,6 +110,9 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
97
110
|
r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
|
|
98
111
|
r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
|
|
99
112
|
r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
|
|
113
|
+
// Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
|
|
114
|
+
// gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
|
|
115
|
+
r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
|
|
100
116
|
|
|
101
117
|
return r;
|
|
102
118
|
}
|
package/lib/nodes/peering.js
CHANGED
|
@@ -40,13 +40,44 @@ function encodePairing(data) {
|
|
|
40
40
|
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Allowlist rigorosa per versione (caps). v1: solo {v,instanceId,port,label,invite}.
|
|
44
|
+
// v2 aggiunge name (slug suggerito), ssh (target/alias), sshPort?: routing non
|
|
45
|
+
// segreto. NESSUN campo segreto è ammesso oltre l'invite one-time: niente
|
|
46
|
+
// identityFile, chiave privata, API key, bearer UI. Unknown field -> null.
|
|
47
|
+
const PAIRING_V1_KEYS = new Set(['v', 'instanceId', 'port', 'label', 'invite']);
|
|
48
|
+
const PAIRING_V2_KEYS = new Set(['v', 'instanceId', 'port', 'label', 'invite', 'name', 'ssh', 'sshPort']);
|
|
49
|
+
|
|
43
50
|
function decodePairing(value) {
|
|
51
|
+
let x;
|
|
44
52
|
try {
|
|
45
|
-
|
|
46
|
-
if (!x || x.v !== 1 || !store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
|
|
47
|
-
|| !store.validToken(x.invite) || typeof x.label !== 'string') return null;
|
|
48
|
-
return x;
|
|
53
|
+
x = JSON.parse(Buffer.from(String(value || ''), 'base64url').toString('utf8'));
|
|
49
54
|
} catch (_) { return null; }
|
|
55
|
+
if (!x || typeof x !== 'object' || Array.isArray(x)) return null;
|
|
56
|
+
const allowed = x.v === 1 ? PAIRING_V1_KEYS : x.v === 2 ? PAIRING_V2_KEYS : null;
|
|
57
|
+
if (!allowed) return null;
|
|
58
|
+
for (const k of Object.keys(x)) if (!allowed.has(k)) return null; // strict: unknown -> null
|
|
59
|
+
// core obbligatorio (entrambe le versioni)
|
|
60
|
+
if (!store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
|
|
61
|
+
|| !store.validToken(x.invite) || typeof x.label !== 'string') return null;
|
|
62
|
+
const out = { v: x.v, instanceId: x.instanceId, port: x.port, label: x.label, invite: x.invite };
|
|
63
|
+
if (x.v === 2) {
|
|
64
|
+
if (x.name !== undefined) {
|
|
65
|
+
if (typeof x.name !== 'string' || !store.NODE_NAME_RE.test(x.name)) return null;
|
|
66
|
+
out.name = x.name;
|
|
67
|
+
}
|
|
68
|
+
if (x.ssh !== undefined) {
|
|
69
|
+
// target/alias SSH (user@host o Host alias); MAI secret. parseSshTarget
|
|
70
|
+
// rifiuta whitespace/control/leading '-' (argv-safe, non diventa flag ssh).
|
|
71
|
+
const ssh = typeof x.ssh === 'string' ? store.parseSshTarget(x.ssh) : null;
|
|
72
|
+
if (!ssh) return null;
|
|
73
|
+
out.ssh = ssh.value;
|
|
74
|
+
}
|
|
75
|
+
if (x.sshPort !== undefined) {
|
|
76
|
+
if (!store.isPort(x.sshPort)) return null;
|
|
77
|
+
out.sshPort = x.sshPort;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
50
81
|
}
|
|
51
82
|
|
|
52
83
|
function parsePairingUrl(value) {
|
|
@@ -57,14 +88,35 @@ function parsePairingUrl(value) {
|
|
|
57
88
|
} catch (_) { return null; }
|
|
58
89
|
}
|
|
59
90
|
|
|
60
|
-
function createInvite({
|
|
91
|
+
function createInvite({
|
|
92
|
+
invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
|
|
93
|
+
ssh, sshPort, name,
|
|
94
|
+
} = {}) {
|
|
61
95
|
const invite = randomBytes(32).toString('base64url');
|
|
62
96
|
const expiresAt = now + INVITE_TTL_MS;
|
|
63
97
|
const rows = readInvites(invitesPath, now);
|
|
64
98
|
rows.push({ hash: crypto.createHash('sha256').update(invite).digest('hex'), expiresAt, usedAt: null });
|
|
65
99
|
writeInvites(invitesPath, rows.slice(-32));
|
|
66
|
-
const
|
|
67
|
-
|
|
100
|
+
const payload = { v: 1, instanceId, port, label: String(label).slice(0, 64), invite };
|
|
101
|
+
// v2: include Host/alias SSH + slug quando forniti (routing non segreto). Così
|
|
102
|
+
// il ricevente incolla/scansiona UN solo link e ha tutto per precompilare il
|
|
103
|
+
// form. Senza ssh/name resta v1 (backward-compat con link 0.8.x esistenti).
|
|
104
|
+
const sshVal = typeof ssh === 'string' && ssh.trim() ? store.parseSshTarget(ssh.trim()) : null;
|
|
105
|
+
const requestedName = typeof name === 'string' && name.trim() && store.NODE_NAME_RE.test(name.trim())
|
|
106
|
+
? name.trim()
|
|
107
|
+
: '';
|
|
108
|
+
// Un link con routing SSH deve sempre portare anche lo slug. Se il chiamante
|
|
109
|
+
// non lo specifica, lo ricaviamo dalla label del dispositivo: il ricevente non
|
|
110
|
+
// deve compilare altri campi per arrivare a "testa e collega".
|
|
111
|
+
const linkName = requestedName || (sshVal ? store.toSlug(payload.label) : '');
|
|
112
|
+
if (sshVal || linkName) {
|
|
113
|
+
payload.v = 2;
|
|
114
|
+
if (linkName) payload.name = linkName;
|
|
115
|
+
if (sshVal) payload.ssh = sshVal.value;
|
|
116
|
+
if (sshVal && store.isPort(sshPort)) payload.sshPort = sshPort;
|
|
117
|
+
}
|
|
118
|
+
const pair = encodePairing(payload);
|
|
119
|
+
return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt, version: payload.v };
|
|
68
120
|
}
|
|
69
121
|
|
|
70
122
|
function consumeInvite({ invitesPath, invite, now = Date.now() }) {
|
package/lib/nodes/store.js
CHANGED
|
@@ -471,8 +471,8 @@ function toSlug(input) {
|
|
|
471
471
|
|
|
472
472
|
// suggestNodeName: slug univoco dato un input libero e l'elenco dei name gia'
|
|
473
473
|
// usati. Disambigua con suffisso -2/-3/... Rende la creazione di un nodo non
|
|
474
|
-
// fallibile per collisione di slug (l'utente scrive "
|
|
475
|
-
// oppure "
|
|
474
|
+
// fallibile per collisione di slug (l'utente scrive "Home Relay" e ottiene
|
|
475
|
+
// "home-relay", oppure "home-relay-2" se esiste gia').
|
|
476
476
|
function suggestNodeName(input, existing = []) {
|
|
477
477
|
const used = new Set(Array.isArray(existing) ? existing : []);
|
|
478
478
|
const base = toSlug(input);
|
package/lib/settings/routes.js
CHANGED
|
@@ -289,12 +289,32 @@ function settingsRoutes(deps = {}) {
|
|
|
289
289
|
// persisted hashed, 0600, for ten minutes only. Il `label` nel payload e'
|
|
290
290
|
// l'etichetta umana con cui il peer vedra' questo dispositivo: default sensato
|
|
291
291
|
// (mai 'localhost' crudo), sovrascrivibile dal form.
|
|
292
|
+
// v2: il creatore puo' includere l'Host/alias SSH raggiungibile (+ slug + porta
|
|
293
|
+
// SSH opzionale) cosicché il ricevente incolla/scansiona UN solo link e precompila
|
|
294
|
+
// tutto. NIENTE segreti: solo routing. L'invite one-time resta l'unica credenziale.
|
|
292
295
|
r.post('/peering/invite', mutGate, (req, res) => {
|
|
293
296
|
try {
|
|
294
|
-
const
|
|
297
|
+
const b = req.body || {};
|
|
298
|
+
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
299
|
+
const extra = {};
|
|
300
|
+
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
301
|
+
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
302
|
+
if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
|
|
303
|
+
extra.ssh = ssh.value;
|
|
304
|
+
}
|
|
305
|
+
if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
|
|
306
|
+
if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
|
|
307
|
+
extra.sshPort = Number(b.sshPort);
|
|
308
|
+
}
|
|
309
|
+
if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
|
|
310
|
+
if (typeof b.name === 'string' && b.name.trim()) {
|
|
311
|
+
if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
|
|
312
|
+
extra.name = b.name.trim();
|
|
313
|
+
}
|
|
314
|
+
if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
|
|
295
315
|
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
296
316
|
send(res, 200, peering.createInvite({
|
|
297
|
-
invitesPath, instanceId: st.nodeId, port: cfg.port, label,
|
|
317
|
+
invitesPath, instanceId: st.nodeId, port: cfg.port, label, ...extra,
|
|
298
318
|
}));
|
|
299
319
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
300
320
|
});
|