@mmmbuto/nexuscrew 0.8.19 → 0.8.21

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-DOHx885g.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-kxpt4ezv.css">
14
+ <script type="module" crossorigin src="/assets/index-DaFQbMHq.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-DQbVJLji.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.8.19"}
1
+ {"version":"0.8.21"}
package/lib/cli/init.js CHANGED
@@ -152,13 +152,21 @@ function runInit(opts = {}) {
152
152
  try {
153
153
  const nstore = require('../nodes/store.js');
154
154
  const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
155
- const st = nstore.loadOrInitStore(nodesPath);
155
+ const st = nstore.initStore(nodesPath);
156
156
  actions.push(`nodes.json ok (${nodesPath}, nodeId ${st.nodeId.slice(0, 8)}…)`);
157
157
  const mig = nstore.migrateLegacyNodes(configPath, nodesPath);
158
158
  if (mig.migrated) actions.push(`nodes: migrati ${mig.count} nodi legacy da config.json`);
159
159
  } catch (e) {
160
160
  actions.push(`WARN: nodes.json init/migrazione fallita: ${e.message} (init prosegue)`);
161
161
  }
162
+ try {
163
+ const dstore = require('../decks/store.js');
164
+ const decksPath = opts.decksPath || path.join(configDir, 'decks.json');
165
+ dstore.initStore(decksPath);
166
+ actions.push(`decks.json ok (${decksPath})`);
167
+ } catch (e) {
168
+ actions.push(`WARN: decks.json init fallita: ${e.message} (init prosegue)`);
169
+ }
162
170
  }
163
171
 
164
172
  // service generation
@@ -20,7 +20,7 @@ function decksRoutes({ cfg = {}, decksPath }) {
20
20
  const found = store.loadStore(decksPath);
21
21
  if (found) return found;
22
22
  if (readonly() && !fs.existsSync(decksPath)) return store.emptyStore();
23
- return store.loadOrCreate(decksPath);
23
+ return store.loadStoreStrict(decksPath);
24
24
  };
25
25
  const expected = (body) => {
26
26
  const n = body && body.expectedRevision;
@@ -33,7 +33,7 @@ function decksRoutes({ cfg = {}, decksPath }) {
33
33
  };
34
34
 
35
35
  r.get('/', (_req, res) => {
36
- try { res.json(read()); } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
36
+ try { res.json(read()); } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
37
37
  });
38
38
  r.post('/', mutate((req, res) => {
39
39
  const name = String(req.body && req.body.name || '');
@@ -113,14 +113,43 @@ function atomicWrite(p, data) {
113
113
  return parsed;
114
114
  }
115
115
 
116
- function loadOrCreate(p) {
116
+ function storeUnavailable(code, message) {
117
+ const e = new Error(message); e.status = 503; e.code = code; return e;
118
+ }
119
+
120
+ function loadStoreStrict(p) {
121
+ let st;
122
+ try { st = fs.lstatSync(p); }
123
+ catch (e) {
124
+ if (e.code === 'ENOENT') {
125
+ throw storeUnavailable('DECKS_STORE_MISSING',
126
+ `decks.json assente (${p}): esegui \`nexuscrew init\` per inizializzare esplicitamente lo store`);
127
+ }
128
+ throw e;
129
+ }
130
+ if (st.isSymbolicLink()) throw storeUnavailable('DECKS_STORE_INVALID', 'decks.json e\' un symlink (rifiutato)');
131
+ if (!st.isFile()) throw storeUnavailable('DECKS_STORE_INVALID', 'decks.json non e\' un file regolare');
117
132
  const found = loadStore(p);
118
- if (found) return found;
119
- try { if (fs.existsSync(p)) throw new Error('decks.json presente ma invalido'); } catch (e) { throw e; }
120
- return atomicWrite(p, emptyStore());
133
+ if (!found) {
134
+ throw storeUnavailable('DECKS_STORE_INVALID',
135
+ 'decks.json presente ma invalido: ripristina un backup valido; non viene sovrascritto');
136
+ }
137
+ return found;
121
138
  }
122
139
 
140
+ function initStore(p) {
141
+ try { fs.lstatSync(p); }
142
+ catch (e) {
143
+ if (e.code === 'ENOENT') return atomicWrite(p, emptyStore());
144
+ throw e;
145
+ }
146
+ return loadStoreStrict(p);
147
+ }
148
+
149
+ // Compatibilita' interna: nessuna creazione implicita fuori da initStore().
150
+ function loadOrCreate(p) { return loadStoreStrict(p); }
151
+
123
152
  module.exports = {
124
153
  SCHEMA_VERSION, MAX_DECKS, MAX_TILES, NAME_RE, OWNER_ID_RE,
125
- defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadOrCreate, atomicWrite,
154
+ defaultDecksPath, emptyStore, parseLayout, parseStore, loadStore, loadStoreStrict, initStore, loadOrCreate, atomicWrite,
126
155
  };
@@ -33,7 +33,7 @@ const {
33
33
  loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
34
34
  } = require('./definitions.js');
35
35
  const {
36
- publicCatalog, describeManaged,
36
+ publicCatalog, describeManaged, describeCatalogCredential,
37
37
  } = require('./managed.js');
38
38
  const { validEnvKey } = require('./env-key.js');
39
39
  const { setCredential, removeCredential } = require('./credentials.js');
@@ -462,6 +462,20 @@ async function createBuiltinFleet(cfg = {}) {
462
462
  // Vista editabile ma secret-safe: gli env values restano write-only.
463
463
  function definitions() {
464
464
  const defs = reloadDefs();
465
+ const { map: credentialMap } = credentialRequirements(defs);
466
+ const managedCatalog = publicCatalog().map((profile) => {
467
+ if (typeof profile.credentialEnv !== 'string') return profile;
468
+ const status = describeCatalogCredential(
469
+ profile.client, profile.provider, profile.credentialProfile, { ...cfg, home },
470
+ );
471
+ const usedBy = credentialMap.get(profile.credentialEnv)?.engines || [];
472
+ return {
473
+ ...profile,
474
+ authConfigured: status?.authConfigured === true,
475
+ credentialSource: status?.credentialSource || 'missing',
476
+ credentialUsedBy: [...usedBy].sort(),
477
+ };
478
+ });
465
479
  return {
466
480
  schemaVersion: defs.schemaVersion,
467
481
  engines: defs.engines.map((e) => {
@@ -475,7 +489,7 @@ async function createBuiltinFleet(cfg = {}) {
475
489
  ...(c.models ? { models: { ...c.models } } : {}),
476
490
  ...(c.permissionPolicies ? { permissionPolicies: { ...c.permissionPolicies } } : {}),
477
491
  })),
478
- managedCatalog: publicCatalog(),
492
+ managedCatalog,
479
493
  managedConfig: {
480
494
  providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env'),
481
495
  providerShellPath: cfg.providerShellPath || path.join(home, '.config', 'ai-shell', 'providers.zsh'),
@@ -486,8 +500,8 @@ async function createBuiltinFleet(cfg = {}) {
486
500
  };
487
501
  }
488
502
 
489
- function credentialRequirements() {
490
- const defs = reloadDefs(); const map = new Map();
503
+ function credentialRequirements(existingDefs) {
504
+ const defs = existingDefs || reloadDefs(); const map = new Map();
491
505
  for (const engine of defs.engines) {
492
506
  if (!engine.managed) continue;
493
507
  const info = describeManaged(engine.managed, { ...cfg, home });
@@ -0,0 +1,44 @@
1
+ {
2
+ "models": [
3
+ {
4
+ "slug": "moonshotai/kimi-k3",
5
+ "display_name": "Kimi K3",
6
+ "description": "Kimi K3 through OpenRouter Responses",
7
+ "default_reasoning_level": "max",
8
+ "supported_reasoning_levels": [
9
+ {
10
+ "effort": "max",
11
+ "description": "Maximum reasoning"
12
+ }
13
+ ],
14
+ "shell_type": "default",
15
+ "visibility": "list",
16
+ "supported_in_api": true,
17
+ "priority": 50,
18
+ "availability_nux": null,
19
+ "upgrade": null,
20
+ "base_instructions": "",
21
+ "supports_reasoning_summaries": true,
22
+ "default_reasoning_summary": "none",
23
+ "support_verbosity": false,
24
+ "default_verbosity": null,
25
+ "apply_patch_tool_type": null,
26
+ "web_search_tool_type": "text",
27
+ "truncation_policy": {
28
+ "mode": "tokens",
29
+ "limit": 1048576
30
+ },
31
+ "supports_parallel_tool_calls": false,
32
+ "supports_image_detail_original": true,
33
+ "context_window": 1048576,
34
+ "max_context_window": 1048576,
35
+ "effective_context_window_percent": 95,
36
+ "experimental_supported_tools": [],
37
+ "input_modalities": [
38
+ "text",
39
+ "image"
40
+ ],
41
+ "supports_search_tool": false
42
+ }
43
+ ]
44
+ }
@@ -7,7 +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 { readCredentialStore } = require('./credentials.js');
10
+ const { readCredentialStore, safePrivateDir } = require('./credentials.js');
11
11
 
12
12
  const OLLAMA_CLOUD_MODELS = Object.freeze([
13
13
  'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
@@ -36,6 +36,8 @@ function validBaseUrl(value) {
36
36
  const CATALOG = Object.freeze([
37
37
  // Claude Code
38
38
  { 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 },
39
+ { id: 'claude.openrouter', client: 'claude', provider: 'openrouter', label: 'OpenRouter', auth: 'OPENROUTER_API_KEY', endpoint: 'https://openrouter.ai/api', protocol: 'anthropic_messages', requiresModel: true, core: true, notice: 'claude-openrouter' },
40
+ { id: 'claude.kimi-code', client: 'claude', provider: 'kimi-code', label: 'Kimi Code', auth: 'KIMI_API_KEY', endpoint: 'https://api.kimi.com/coding/', protocol: 'anthropic_messages', model: 'k3[1m]', models: ['k3', 'k3[1m]', 'kimi-for-coding', 'kimi-for-coding-highspeed'], strictModels: true, core: true, notice: 'claude-kimi-code' },
39
41
  { 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' } },
40
42
  { 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' } },
41
43
  { 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' } },
@@ -52,6 +54,7 @@ const CATALOG = Object.freeze([
52
54
  { 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 },
53
55
  { 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 },
54
56
  { 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 },
57
+ { id: 'codex-vl.openrouter', client: 'codex-vl', provider: 'openrouter', label: 'OpenRouter', auth: 'OPENROUTER_API_KEY', endpoint: 'https://openrouter.ai/api/v1', protocol: 'openai_responses', requiresModel: true, core: true, notice: 'codex-openrouter' },
55
58
  { id: 'codex.ollama', client: 'codex', provider: 'ollama', label: 'Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama', core: true },
56
59
  { id: 'codex-vl.ollama', client: 'codex-vl', provider: 'ollama', label: 'Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama', core: true },
57
60
  { id: 'codex.lmstudio', client: 'codex', provider: 'lmstudio', label: 'LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio', core: true },
@@ -103,6 +106,7 @@ function normalizeManagedSpec(value) {
103
106
  const model = value.model === undefined ? (profile.model || '') : value.model;
104
107
  if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
105
108
  if (profile.requiresModel && !model) return null;
109
+ if (profile.strictModels && !(profile.models || []).includes(model)) return null;
106
110
  const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
107
111
  if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
108
112
  if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
@@ -388,6 +392,22 @@ function describeManaged(spec, cfg = {}) {
388
392
  };
389
393
  }
390
394
 
395
+ // Target-local, value-free status for a fixed catalog credential. The caller
396
+ // supplies only a profile already present in the public catalog, so this cannot
397
+ // be used as an arbitrary environment-variable oracle.
398
+ function describeCatalogCredential(client, provider, credentialProfile = '', cfg = {}) {
399
+ const profile = profileFor(client, provider, credentialProfile);
400
+ if (!profile || profile.auth === 'dynamic' || profile.auth === 'login' || profile.auth === 'none'
401
+ || !ENV_KEY_RE.test(profile.auth || '')) return null;
402
+ const home = cfg.home || require('node:os').homedir();
403
+ const cred = credential(profile, {}, cfg, home);
404
+ return {
405
+ envKey: cred.envKey,
406
+ authConfigured: !!cred.value,
407
+ credentialSource: cred.value ? cred.source : 'missing',
408
+ };
409
+ }
410
+
391
411
  function codexProviderArgs(id, name, baseUrl, envKey) {
392
412
  return [
393
413
  '-c', `model_provider=${JSON.stringify(id)}`, '-c', `model_providers.${id}.name=${JSON.stringify(name)}`,
@@ -396,6 +416,56 @@ function codexProviderArgs(id, name, baseUrl, envKey) {
396
416
  ];
397
417
  }
398
418
 
419
+ function codexCommandAuthProviderArgs(id, name, baseUrl, command, args) {
420
+ return [
421
+ '-c', `model_provider=${JSON.stringify(id)}`, '-c', `model_providers.${id}.name=${JSON.stringify(name)}`,
422
+ '-c', `model_providers.${id}.base_url=${JSON.stringify(baseUrl)}`,
423
+ '-c', `model_providers.${id}.wire_api="responses"`,
424
+ '-c', `model_providers.${id}.auth.command=${JSON.stringify(command)}`,
425
+ '-c', `model_providers.${id}.auth.args=${JSON.stringify(args)}`,
426
+ '-c', `model_providers.${id}.auth.timeout_ms=5000`,
427
+ '-c', `model_providers.${id}.auth.refresh_interval_ms=300000`,
428
+ ];
429
+ }
430
+
431
+ function ensureKimiClaudeConfig(home) {
432
+ const nexusDir = path.join(home, '.nexuscrew');
433
+ const profilesDir = path.join(nexusDir, 'claude-profiles');
434
+ const configDir = path.join(profilesDir, 'kimi-code');
435
+ safePrivateDir(nexusDir, { create: true });
436
+ safePrivateDir(profilesDir, { create: true });
437
+ safePrivateDir(configDir, { create: true });
438
+ const file = path.join(configDir, '.claude.json');
439
+ let current = {};
440
+ try {
441
+ const st = fs.lstatSync(file);
442
+ if (st.isSymbolicLink() || !st.isFile() || (st.mode & 0o077) || st.size > 256 * 1024
443
+ || (typeof process.getuid === 'function' && st.uid !== process.getuid())) {
444
+ throw new Error('unsafe Kimi Code Claude config');
445
+ }
446
+ current = JSON.parse(fs.readFileSync(file, 'utf8'));
447
+ if (!current || typeof current !== 'object' || Array.isArray(current)) throw new Error('invalid Kimi Code Claude config');
448
+ } catch (error) {
449
+ if (error.code !== 'ENOENT') throw error;
450
+ }
451
+ const next = { ...current, hasCompletedOnboarding: true, penguinModeOrgEnabled: true };
452
+ if (current.hasCompletedOnboarding === true && current.penguinModeOrgEnabled === true) return configDir;
453
+ const tmp = path.join(configDir, `.claude.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`);
454
+ let fd;
455
+ try {
456
+ fd = fs.openSync(tmp, 'wx', 0o600);
457
+ fs.writeFileSync(fd, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
458
+ fs.fsyncSync(fd);
459
+ fs.closeSync(fd); fd = undefined;
460
+ fs.chmodSync(tmp, 0o600);
461
+ fs.renameSync(tmp, file);
462
+ } finally {
463
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
464
+ try { fs.unlinkSync(tmp); } catch (_) {}
465
+ }
466
+ return configDir;
467
+ }
468
+
399
469
  function writePiProviderExtension(spec, home) {
400
470
  const dir = path.join(home, '.nexuscrew', 'pi-providers');
401
471
  try {
@@ -465,6 +535,29 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
465
535
  if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
466
536
  } else if (profile.providerEnv) {
467
537
  Object.assign(env, profile.providerEnv);
538
+ } else if (spec.provider === 'openrouter') {
539
+ Object.assign(env, {
540
+ ANTHROPIC_BASE_URL: profile.endpoint, ANTHROPIC_AUTH_TOKEN: cred.value, ANTHROPIC_API_KEY: '',
541
+ ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model,
542
+ ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
543
+ CLAUDE_CODE_SUBAGENT_MODEL: model, API_TIMEOUT_MS: '3000000',
544
+ });
545
+ } else if (spec.provider === 'kimi-code') {
546
+ const contextWindow = model === 'k3[1m]' ? '1048576' : '262144';
547
+ Object.assign(env, {
548
+ CLAUDE_CONFIG_DIR: ensureKimiClaudeConfig(home),
549
+ ANTHROPIC_BASE_URL: profile.endpoint, ANTHROPIC_API_KEY: cred.value,
550
+ ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_FABLE_MODEL: model,
551
+ ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model,
552
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: model, CLAUDE_CODE_SUBAGENT_MODEL: model,
553
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: contextWindow,
554
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: contextWindow,
555
+ API_TIMEOUT_MS: '3000000',
556
+ });
557
+ if (model === 'k3' || model === 'k3[1m]') {
558
+ env.CLAUDE_CODE_EFFORT_LEVEL = 'max';
559
+ env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT = '1';
560
+ }
468
561
  } else {
469
562
  const endpoint = spec.baseUrl || profile.endpoint;
470
563
  const token = profile.auth === 'none' ? 'ollama' : cred.value;
@@ -488,6 +581,16 @@ function resolveManagedEngine(engine, cell, cfg = {}) {
488
581
  args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`);
489
582
  const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
490
583
  if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
584
+ } else if (spec.provider === 'openrouter') {
585
+ env.OPENROUTER_API_KEY = cred.value;
586
+ const authHelper = path.join(__dirname, 'openrouter-auth-helper.js');
587
+ args.push(...codexCommandAuthProviderArgs('openrouter', 'OpenRouter', profile.endpoint, process.execPath, [authHelper, 'OPENROUTER_API_KEY']));
588
+ args.push('-c', 'model_providers.openrouter.stream_idle_timeout_ms=600000');
589
+ if (model === 'moonshotai/kimi-k3') {
590
+ const localCatalog = path.join(__dirname, 'catalogs', 'openrouter-kimi-k3.json');
591
+ args.push('-c', `model_catalog_json=${JSON.stringify(localCatalog)}`);
592
+ args.push('-c', 'model_context_window=1048576');
593
+ }
491
594
  } else if (spec.provider === 'custom') {
492
595
  env[spec.envKey] = cred.value;
493
596
  args.push(...codexProviderArgs(spec.providerId, spec.displayName, spec.baseUrl, spec.envKey));
@@ -522,14 +625,15 @@ function publicCatalog() {
522
625
  auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
523
626
  protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: p.client !== 'pi', requiresModel: !!p.requiresModel || !!p.custom,
524
627
  permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
525
- rc: !!p.rc, custom: !!p.custom, default: !!p.default,
526
- credentialEnv: !!p.credentialEnv, defaultEnvKey: p.defaultEnvKey || '',
628
+ rc: !!p.rc, custom: !!p.custom, default: !!p.default, notice: p.notice || '',
629
+ credentialEnv: p.auth === 'dynamic' ? !!p.credentialEnv : (ENV_KEY_RE.test(p.auth || '') ? p.auth : false),
630
+ defaultEnvKey: p.defaultEnvKey || '',
527
631
  }));
528
632
  }
529
633
 
530
634
  module.exports = {
531
635
  CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
532
- defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
636
+ defaultDefinitions, describeManaged, describeCatalogCredential, discoverOllamaModels, resolveManagedEngine, needsExplicitNode,
533
637
  discoverPiModels, parseEnvFile, parseProviderShellFile, findBinary, publicCatalog, writePiProviderExtension,
534
- providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, ENV_KEY_RE,
638
+ providerKeyPaths, parseProviderKeyFiles, credentialSources, credential, ensureKimiClaudeConfig, ENV_KEY_RE,
535
639
  };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const ALLOWED_ENV_KEY = 'OPENROUTER_API_KEY';
4
+ const MAX_TOKEN_BYTES = 16 * 1024;
5
+
6
+ function readOpenRouterCredential(name, env = process.env) {
7
+ if (name !== ALLOWED_ENV_KEY) throw new Error('unsupported credential request');
8
+ const value = env[name];
9
+ if (typeof value !== 'string' || !value || Buffer.byteLength(value) > MAX_TOKEN_BYTES
10
+ || /[\x00-\x1f\x7f]/.test(value)) {
11
+ throw new Error('credential unavailable');
12
+ }
13
+ return value;
14
+ }
15
+
16
+ if (require.main === module) {
17
+ try { process.stdout.write(readOpenRouterCredential(process.argv[2])); }
18
+ catch (_) { process.exitCode = 1; }
19
+ }
20
+
21
+ module.exports = { ALLOWED_ENV_KEY, MAX_TOKEN_BYTES, readOpenRouterCredential };
@@ -16,6 +16,7 @@ const path = require('node:path');
16
16
  const { execFileSync } = require('node:child_process');
17
17
  const store = require('./store.js');
18
18
  const tunnel = require('./tunnel.js');
19
+ const federation = require('../proxy/federation.js');
19
20
  const { resolvePaths, DEFAULT_PORT } = require('../cli/url.js');
20
21
 
21
22
  const LOCAL_PORT_BASE = 43001; // porte locali stabili per i forward (design: stabili da nodes.json)
@@ -127,8 +128,11 @@ function nodesAdd(opts) {
127
128
  const sshPort = opts.sshPort === undefined ? undefined : Number(opts.sshPort);
128
129
 
129
130
  let st;
130
- try { st = store.loadOrInitStore(nodesPath); }
131
- catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'store invalido' }; }
131
+ try { st = store.loadStoreStrict(nodesPath); }
132
+ catch (e) {
133
+ log(`nodes add: ${e.message}`);
134
+ return { code: 1, reason: 'store invalido', status: e.status || 500, errorCode: e.code || null };
135
+ }
132
136
 
133
137
  const localPort = opts.localPort ? Number(opts.localPort) : assignLocalPort(st);
134
138
  const keyPath = opts.identityFile || opts.key || (typeof opts.keygen === 'function' ? defaultKeyPath(home, name) : undefined);
@@ -238,6 +242,35 @@ async function nodesTest(opts) {
238
242
  const node = st ? store.getNode(st, name) : null;
239
243
  if (!node) { log(`nodes test: nodo sconosciuto "${name}"`); return { code: 1, result: 'unknown-node' }; }
240
244
 
245
+ // An inbound peer is reached through the reverse listener owned by sshd on
246
+ // this hub; there is intentionally no local tunnel pidfile to inspect.
247
+ if (node.direction === 'inbound') {
248
+ if (node.shared !== true) {
249
+ log(`nodes test [${name}]: PASSIVO — Share disattivato, nessun canale inverso atteso`);
250
+ return { code: 0, result: 'passive' };
251
+ }
252
+ if (!node.token) {
253
+ log(`nodes test [${name}]: ASSOCIAZIONE INCOMPLETA — credenziale federation assente`);
254
+ return { code: 1, result: 'token-missing' };
255
+ }
256
+ const probe = opts.federationProbe || federation.probeHealth;
257
+ let health;
258
+ try {
259
+ health = await probe({
260
+ port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null,
261
+ fetchImpl: opts.fetchImpl, timeoutMs: opts.timeoutMs,
262
+ });
263
+ } catch (_) {
264
+ health = { status: 'down', detail: 'peer non raggiungibile' };
265
+ }
266
+ if (health && health.status === 'healthy') {
267
+ log(`nodes test [${name}]: OK — canale inverso e federation autenticati`);
268
+ return { code: 0, result: 'ok', health };
269
+ }
270
+ log(`nodes test [${name}]: FEDERATION KO — ${(health && health.detail) || 'peer non raggiungibile'}`);
271
+ return { code: 1, result: 'federation-ko', health };
272
+ }
273
+
241
274
  const state = tunnel.readTunnelState(home, name);
242
275
  if (state.status !== 'up') {
243
276
  const diagnostic = tunnel.diagnoseTunnel(home, node, state);
@@ -2,6 +2,7 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
+ const net = require('node:net');
5
6
  const path = require('node:path');
6
7
  const store = require('./store.js');
7
8
 
@@ -132,9 +133,14 @@ function consumeInvite({ invitesPath, invite, now = Date.now() }) {
132
133
  return true;
133
134
  }
134
135
 
135
- function allocateReversePort(nodes) {
136
+ function hasInvite({ invitesPath, invite, now = Date.now() }) {
137
+ const hash = crypto.createHash('sha256').update(String(invite || '')).digest('hex');
138
+ return readInvites(invitesPath, now).some((row) => safeEqual(row.hash, hash));
139
+ }
140
+
141
+ function allocateReversePort(nodes, reservations = []) {
136
142
  const used = new Set();
137
- for (const n of nodes || []) {
143
+ for (const n of [...(nodes || []), ...(reservations || [])]) {
138
144
  if (store.isPort(n.localPort)) used.add(n.localPort);
139
145
  if (store.isPort(n.reversePort)) used.add(n.reversePort);
140
146
  }
@@ -144,6 +150,41 @@ function allocateReversePort(nodes) {
144
150
  return p;
145
151
  }
146
152
 
153
+ function canBindReversePort(port, createServerImpl = net.createServer) {
154
+ return new Promise((resolve, reject) => {
155
+ const server = createServerImpl();
156
+ let settled = false;
157
+ const finish = (value, error = null) => {
158
+ if (settled) return;
159
+ settled = true;
160
+ server.removeAllListeners?.();
161
+ if (error) reject(error); else resolve(value);
162
+ };
163
+ server.once('error', (error) => {
164
+ if (error && (error.code === 'EADDRINUSE' || error.code === 'EACCES')) finish(false);
165
+ else finish(false, error);
166
+ });
167
+ server.once('listening', () => {
168
+ if (typeof server.unref === 'function') server.unref();
169
+ try { server.close(() => finish(true)); } catch (error) { finish(false, error); }
170
+ });
171
+ server.listen({ host: '127.0.0.1', port, exclusive: true });
172
+ });
173
+ }
174
+
175
+ // The reverse listener will be owned by sshd, but allocation happens on the
176
+ // hub. Probe the real loopback bind before issuing a candidate so a stale or
177
+ // unrelated listener absent from nodes.json cannot be reallocated blindly.
178
+ async function allocateAvailableReversePort(nodes, reservations = [], opts = {}) {
179
+ const rejected = [];
180
+ while (rejected.length < (65535 - REVERSE_PORT_BASE)) {
181
+ const candidate = allocateReversePort(nodes, [...reservations, ...rejected]);
182
+ if (await canBindReversePort(candidate, opts.createServerImpl || net.createServer)) return candidate;
183
+ rejected.push({ localPort: candidate });
184
+ }
185
+ throw new Error('no reverse port available');
186
+ }
187
+
147
188
  function createPending({ pendingPath, data, now = Date.now() }) {
148
189
  const rows = readInvites(pendingPath, now);
149
190
  const credential = crypto.randomBytes(32).toString('base64url');
@@ -269,6 +310,7 @@ async function probeTransportReady({
269
310
  module.exports = {
270
311
  INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
271
312
  readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
272
- createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
313
+ createInvite, consumeInvite, hasInvite, allocateReversePort, canBindReversePort,
314
+ allocateAvailableReversePort, createPending, consumePending,
273
315
  capabilityIdentity, capabilityProbeMaterial, probeTransportReady,
274
316
  };
@@ -9,7 +9,8 @@
9
9
  // - nessun token in output redatto (redactStore/redactNode)
10
10
  //
11
11
  // Modulo PURO al parse: parseStore/parseNode non toccano il filesystem.
12
- // Tutto l'I/O vive in loadStore/atomicWriteStore/loadOrInitStore/migrateLegacyNodes.
12
+ // Tutto l'I/O vive in loadStore/loadStoreStrict/initStore/atomicWriteStore/
13
+ // migrateLegacyNodes.
13
14
  // Principio fail-closed: qualunque dato malformato -> null (parse) o throw
14
15
  // esplicito (mutazioni/write), mai un default silenzioso.
15
16
  const fs = require('node:fs');
@@ -227,12 +228,15 @@ function parseStore(raw) {
227
228
 
228
229
  const names = new Set();
229
230
  const ids = new Set();
231
+ const localPorts = new Set();
230
232
  const nodes = [];
231
233
  for (const raw2 of d.nodes) {
232
234
  const node = parseNode(raw2, d.schemaVersion);
233
235
  if (!node) return null;
234
236
  if (names.has(node.name)) return null; // name univoco
235
237
  names.add(node.name);
238
+ if (localPorts.has(node.localPort)) return null; // ogni listener locale ha un solo owner
239
+ localPorts.add(node.localPort);
236
240
  if (node.nodeId) {
237
241
  if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
238
242
  if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
@@ -306,22 +310,56 @@ function emptyStore(nodeId) {
306
310
  return { schemaVersion: SCHEMA_VERSION, nodeId: nodeId || newNodeId(), nodes: [] };
307
311
  }
308
312
 
309
- // loadOrInitStore(p): store esistente, oppure ne crea uno vuoto (nodeId fresco,
310
- // STABILE una volta scritto). Se il file esiste ma e' invalido -> throw esplicito
311
- // (mai sovrascrivere/mascherare corruzione o un file altrui).
312
- function loadOrInitStore(p) {
313
+ function storeUnavailable(code, message) {
314
+ const e = new Error(message);
315
+ e.status = 503;
316
+ e.code = code;
317
+ return e;
318
+ }
319
+
320
+ // Runtime strict: nodes.json e' identita' + credential store. Se sparisce non
321
+ // deve MAI essere rigenerato implicitamente da una route/command: un nuovo
322
+ // nodeId farebbe apparire l'installazione come un altro dispositivo e
323
+ // maschererebbe una perdita di stato. Solo `nexuscrew init` usa initStore().
324
+ function loadStoreStrict(p) {
313
325
  let st;
314
326
  try { st = fs.lstatSync(p); }
315
327
  catch (e) {
316
- if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
328
+ if (e.code === 'ENOENT') {
329
+ throw storeUnavailable('NODES_STORE_MISSING',
330
+ `nodes.json assente (${p}): esegui \`nexuscrew init\` per inizializzare esplicitamente lo store`);
331
+ }
317
332
  throw e;
318
333
  }
319
- if (st.isSymbolicLink()) throw new Error('nodes.json e\' un symlink (rifiutato)');
334
+ if (st.isSymbolicLink()) {
335
+ throw storeUnavailable('NODES_STORE_INVALID', 'nodes.json e\' un symlink (rifiutato)');
336
+ }
337
+ if (!st.isFile()) {
338
+ throw storeUnavailable('NODES_STORE_INVALID', 'nodes.json non e\' un file regolare');
339
+ }
320
340
  const parsed = loadStore(p);
321
- if (!parsed) throw new Error('nodes.json presente ma invalido (schema strict): correggi o rimuovi il file');
341
+ if (!parsed) {
342
+ throw storeUnavailable('NODES_STORE_INVALID',
343
+ 'nodes.json presente ma invalido (schema strict): ripristina un backup valido; non viene sovrascritto');
344
+ }
322
345
  return parsed;
323
346
  }
324
347
 
348
+ // Init esplicito e idempotente. E' l'unico percorso autorizzato a creare uno
349
+ // store mancante; un file presente ma invalido resta fail-closed.
350
+ function initStore(p) {
351
+ try { fs.lstatSync(p); }
352
+ catch (e) {
353
+ if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
354
+ throw e;
355
+ }
356
+ return loadStoreStrict(p);
357
+ }
358
+
359
+ // Compatibilita' API interna: il vecchio nome non inizializza piu' a runtime.
360
+ // Tenerlo strict impedisce che un call-site dimenticato reintroduca F1.
361
+ function loadOrInitStore(p) { return loadStoreStrict(p); }
362
+
325
363
  // --- Mutazioni pure (ritornano un nuovo store; il caller lo scrive) ---------
326
364
 
327
365
  function getNode(store, name) {
@@ -334,6 +372,9 @@ function addNode(store, entry) {
334
372
  if (store.nodes.some((n) => n.name === node.name)) {
335
373
  throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
336
374
  }
375
+ if (store.nodes.some((n) => n.localPort === node.localPort)) {
376
+ throw new Error(`nodo duplicato: localPort ${node.localPort} gia' assegnata`);
377
+ }
337
378
  if (node.nodeId) {
338
379
  if (node.nodeId === store.nodeId) throw new Error('self-reference: il nodeId coincide con questa installazione');
339
380
  if (store.nodes.some((n) => n.nodeId === node.nodeId)) {
@@ -425,7 +466,7 @@ function migrateLegacyNodes(configPath, nodesPath) {
425
466
  if (!cfg || typeof cfg !== 'object' || !Array.isArray(cfg.nodes) || cfg.nodes.length === 0) {
426
467
  return { migrated: false, count: 0, reason: 'nessun campo nodes legacy in config.json' };
427
468
  }
428
- const store = loadOrInitStore(nodesPath);
469
+ const store = initStore(nodesPath);
429
470
  if (store.nodes.length > 0) {
430
471
  return { migrated: false, count: 0, reason: 'nodes.json gia\' popolato (no overwrite)' };
431
472
  }
@@ -498,7 +539,7 @@ module.exports = {
498
539
  // parse/validate
499
540
  parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
500
541
  // I/O
501
- defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
542
+ defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
502
543
  // mutazioni
503
544
  getNode, addNode, removeNode, setNodeToken, updateNode,
504
545
  // redazione