@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.
@@ -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 · Anthropic', auth: 'login', endpoint: 'Anthropic account', protocol: 'anthropic_messages', rc: true, default: true },
38
- { id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', protocol: 'anthropic_messages', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
39
- { id: 'claude.ollama', client: 'claude', provider: 'ollama', label: 'Claude · Ollama local', auth: 'none', endpoint: 'http://127.0.0.1:11434', protocol: 'anthropic_messages' },
40
- { id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Claude · Z.AI · profile A', 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' },
41
- { id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Claude · Z.AI · profile P', 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' },
42
- { id: 'claude.custom', client: 'claude', provider: 'custom', label: 'Claude · Custom', auth: 'dynamic', protocol: 'anthropic_messages', protocols: ['anthropic_messages'], custom: true },
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: 'Codex · OpenAI', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true },
46
- { id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'Codex-VL · OpenAI', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true },
47
- { id: 'codex.ollama', client: 'codex', provider: 'ollama', label: 'Codex · Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama' },
48
- { id: 'codex-vl.ollama', client: 'codex-vl', provider: 'ollama', label: 'Codex-VL · Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama' },
49
- { id: 'codex.lmstudio', client: 'codex', provider: 'lmstudio', label: 'Codex · LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio' },
50
- { id: 'codex-vl.lmstudio', client: 'codex-vl', provider: 'lmstudio', label: 'Codex-VL · LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio' },
51
- { id: 'codex.ollama-cloud', client: 'codex', provider: 'ollama-cloud', label: 'Codex · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
52
- { id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Codex-VL · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
53
- { id: 'codex.custom', client: 'codex', provider: 'custom', label: 'Codex · Custom Responses', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true },
54
- { id: 'codex-vl.custom', client: 'codex-vl', provider: 'custom', label: 'Codex-VL · Custom Responses', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true },
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.anthropic', client: 'pi', provider: 'anthropic', label: 'Pi · Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic' },
58
- { id: 'pi.openai', client: 'pi', provider: 'openai', label: 'Pi · OpenAI', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai' },
59
- { id: 'pi.google', client: 'pi', provider: 'google', label: 'Pi · Google Gemini', auth: 'GEMINI_API_KEY', protocol: 'pi_native', piProvider: 'google' },
60
- { id: 'pi.openrouter', client: 'pi', provider: 'openrouter', label: 'Pi · OpenRouter', auth: 'OPENROUTER_API_KEY', protocol: 'pi_native', piProvider: 'openrouter' },
61
- { id: 'pi.github-copilot', client: 'pi', provider: 'github-copilot', label: 'Pi · GitHub Copilot', auth: 'login', protocol: 'pi_native', piProvider: 'github-copilot' },
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: 'Pi · DeepSeek', auth: 'DEEPSEEK_API_KEY', protocol: 'pi_native', piProvider: 'deepseek' },
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: 'Pi · Ollama local', auth: 'none', protocol: 'openai-completions', piProvider: 'ollama', requiresModel: true, piExtension: { baseUrl: 'http://127.0.0.1:11434/v1', apiKey: 'ollama' } },
70
- { id: 'pi.zai', client: 'pi', provider: 'zai', label: 'Pi · Z.AI', auth: 'ZAI_API_KEY', protocol: 'pi_native', piProvider: 'zai' },
71
- { id: 'pi.custom', client: 'pi', provider: 'custom', label: 'Pi · Custom provider', auth: 'dynamic', protocol: 'openai-responses', protocols: ['openai-responses', 'anthropic-messages', 'openai-completions', 'google-generative-ai'], custom: true },
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.label, rc: !!p.rc,
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
- if (spec.permissionPolicy === 'unsafe') {
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
- CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || (spec.provider === 'zai' ? 1000000 : 200000)),
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
 
@@ -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 { createFleet, binTrusted } = require('./index.js');
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: fidato (regular file, exec, no symlink, no world-writable) E contratto ok.
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
- if (cfg.fleetBin) {
44
- if (binTrusted(cfg.fleetBin)) {
45
- const f = await createFleet(cfg); // fa status --json al boot
46
- if (f.available) { extFleet = f; extReason = 'fidato + risponde al contratto'; }
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
- extReason = 'nessun fleetBin esterno configurato';
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.
@@ -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
- return f.engine(cell, String(b.engine), { model: typeof b.model === 'string' ? b.model : '' }).then(() => f.up(cell));
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) => f.down(String(b.cell || ''), { boot: !!b.boot }), { mutate: true }));
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) => f.engine(String(b.cell || ''), String(b.engine || ''), { model: typeof b.model === 'string' ? b.model : '' }), { mutate: true }));
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
  }
@@ -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
- const x = JSON.parse(Buffer.from(String(value || ''), 'base64url').toString('utf8'));
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({ invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes }) {
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 pair = encodePairing({ v: 1, instanceId, port, label: String(label).slice(0, 64), invite });
67
- return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt };
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() }) {
@@ -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 "VPS3" e ottiene "vps3",
475
- // oppure "vps3-2" se esiste gia').
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);
@@ -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 label = nodesStore.sanitizeLabel(req.body && req.body.label, defaultDeviceName());
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {