llm-switcher 1.2.2 → 1.2.4

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/state.mjs CHANGED
@@ -12,6 +12,7 @@ import crypto from 'node:crypto';
12
12
  import http from 'node:http';
13
13
  import { spawn, execFileSync } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { buildCerts, writeLeaf } from './blindfold/certs.mjs';
15
16
 
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
 
@@ -1441,26 +1442,78 @@ function writeToolFiles(pairs, shPath, cmdPath) {
1441
1442
  // launcher files are never opened (A13: emptied, never deleted).
1442
1443
  export function emptyToolEnvFiles(tool) {
1443
1444
  const codex = tool === 'codex';
1444
- withLaunchLock(() => {
1445
+ const codexRouteChanged = withLaunchLock(() => {
1446
+ const before = codex ? readOrEmpty(paths.envCodexSh) : '';
1445
1447
  writeAtomic(codex ? paths.envCodexCmd : paths.envClaudeCmd, '');
1446
1448
  writeAtomic(codex ? paths.envCodexSh : paths.envClaudeSh, '');
1449
+ return before !== '';
1447
1450
  });
1451
+ return { codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
1448
1452
  }
1449
1453
 
1450
1454
  // Write the per-tool env files from activeProfiles. settings.json is never touched here: it
1451
1455
  // belongs to the coding tool (R1), and model-catalog.json is no longer written (R9).
1452
1456
  export function applyLaunchState(cfg, port, opts = {}) {
1453
- return withLaunchLock(() => writeLaunchState(cfg, port, opts));
1457
+ const st = withLaunchLock(() => writeLaunchState(cfg, port, opts));
1458
+ if (st.codexRouteChanged) st.codexDaemon = restartCodexDaemon(st.envCodex);
1459
+ return st;
1460
+ }
1461
+
1462
+ const LOOPBACK_URL = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/?$/i;
1463
+
1464
+ /**
1465
+ * The interactive Codex TUI talks to a shared `codex app-server` daemon, and the daemon keeps the
1466
+ * environment it started with. A new route reaches it only through a restart. Only the launch files
1467
+ * that the installed codex shim reads can route that daemon, so any other state dir (tests) is a no-op.
1468
+ */
1469
+ export function restartCodexDaemon(pairs, {
1470
+ home = os.homedir(),
1471
+ codexHome = process.env.CODEX_HOME || path.join(home, '.codex'),
1472
+ stateDir = STATE_DIR,
1473
+ baseEnv = process.env,
1474
+ ca = paths.blindfoldCA,
1475
+ spawnFn = spawn
1476
+ } = {}) {
1477
+ let shim = '';
1478
+ try { shim = fs.readFileSync(path.join(home, '.llm-switcher', 'bin', 'codex'), 'utf8'); } catch {}
1479
+ if (!shim.includes(`SWITCHER_DIR="${stateDir}"`)) return { restarted: false, reason: 'the codex shim does not read this state dir' };
1480
+ const bin = path.join(codexHome, 'packages', 'app-server-daemon', 'current', 'bin', 'codex');
1481
+ // The socket is a link to the running daemon; it no longer resolves once the daemon stopped.
1482
+ if (!fs.existsSync(bin) || !fs.existsSync(path.join(codexHome, 'app-server-control', 'app-server-control.sock'))) {
1483
+ return { restarted: false, reason: 'no Codex daemon runs' };
1484
+ }
1485
+
1486
+ // Remove only the values this switcher writes (R8); a proxy or CA the user set stays.
1487
+ const env = { ...baseEnv };
1488
+ for (const k of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) if (LOOPBACK_URL.test(env[k] || '')) delete env[k];
1489
+ for (const k of ['NO_PROXY', 'no_proxy']) if (env[k] === '127.0.0.1,localhost') delete env[k];
1490
+ if (env.CODEX_CA_CERTIFICATE === ca) delete env.CODEX_CA_CERTIFICATE;
1491
+ for (const [k, v] of pairs) env[k] = v;
1492
+
1493
+ try {
1494
+ const child = spawnFn(bin, ['app-server', 'daemon', 'restart'], { env, stdio: 'ignore', detached: true });
1495
+ child.on?.('error', () => {});
1496
+ child.unref?.();
1497
+ return { restarted: true };
1498
+ } catch (err) {
1499
+ return { restarted: false, reason: err.message };
1500
+ }
1501
+ }
1502
+
1503
+ function readOrEmpty(file) {
1504
+ try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
1454
1505
  }
1455
1506
 
1456
1507
  function writeLaunchState(cfg, port) {
1457
1508
  const st = computeLaunchState(cfg, port);
1509
+ const codexBefore = readOrEmpty(paths.envCodexSh);
1458
1510
 
1459
1511
  // Env files first, the flag last: a flag that says "active" while a file is missing or stale
1460
1512
  // makes the launcher route with the wrong variables. A failed write leaves the flag as it was.
1461
1513
  try {
1462
1514
  writeToolFiles(st.envClaude, paths.envClaudeSh, paths.envClaudeCmd);
1463
1515
  writeToolFiles(st.envCodex, paths.envCodexSh, paths.envCodexCmd);
1516
+ st.codexRouteChanged = readOrEmpty(paths.envCodexSh) !== codexBefore;
1464
1517
  // Recorded on switch on so a shell opened while the gateway ran can still recognize its own
1465
1518
  // stale loopback URL later, after the port changed (R8).
1466
1519
  if (st.active) writeAtomic(paths.gatewayPort, `${port}\n`);
@@ -1476,7 +1529,8 @@ function writeLaunchState(cfg, port) {
1476
1529
  }
1477
1530
 
1478
1531
  export function clearLaunchState(port) {
1479
- withLaunchLock(() => {
1532
+ const codexRouteChanged = withLaunchLock(() => {
1533
+ const codexBefore = readOrEmpty(paths.envCodexSh);
1480
1534
  writeOrRemove(paths.activeFlag, null);
1481
1535
  // Stubs rather than deletions, and the tool files emptied rather than removed (R8, A13):
1482
1536
  // `switch off claude` must leave an empty claude file behind, and bare `switch off` must
@@ -1486,9 +1540,10 @@ export function clearLaunchState(port) {
1486
1540
  for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh]) {
1487
1541
  writeAtomic(f, '');
1488
1542
  }
1543
+ return codexBefore !== '';
1489
1544
  });
1490
1545
  // settings.json belongs to the coding tool: the switcher neither reads nor writes it (R1).
1491
- return { changed: false, removed: [] };
1546
+ return { changed: false, removed: [], codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
1492
1547
  }
1493
1548
 
1494
1549
  export function readLaunchFlags() {
@@ -1689,6 +1744,32 @@ export async function stopRecordedBlindfold() {
1689
1744
  return { ok: stopped, ...(stopped ? {} : { error: `the interceptor on port ${prev.port} did not stop` }) };
1690
1745
  }
1691
1746
 
1747
+ // A CA that still works is kept when the leaf is rebuilt: Codex already trusts it.
1748
+ function caUsable(dir) {
1749
+ try {
1750
+ const ca = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'ca.pem')));
1751
+ return ca.ca && Date.parse(ca.validTo) > Date.now() + 86400000
1752
+ && ca.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(path.join(dir, 'ca.key'))));
1753
+ } catch {
1754
+ return false;
1755
+ }
1756
+ }
1757
+
1758
+ /** Builds the certificates when they are missing or unusable, then returns what the preflight
1759
+ * says of them: null when the interceptor can start. */
1760
+ export function ensureBlindfoldCerts(desired) {
1761
+ const problem = blindfoldPreflight(desired);
1762
+ if (!problem) return null;
1763
+ const dir = path.dirname(desired.ca);
1764
+ try {
1765
+ if (caUsable(dir)) writeLeaf(dir, INTERCEPT_HOSTS);
1766
+ else buildCerts(dir, INTERCEPT_HOSTS);
1767
+ } catch (err) {
1768
+ return `${problem} Building them failed: ${err.message}`;
1769
+ }
1770
+ return blindfoldPreflight(desired);
1771
+ }
1772
+
1692
1773
  /** null when the interceptor can start, otherwise the reason and the command that fixes it. */
1693
1774
  export function blindfoldPreflight(desired) {
1694
1775
  const certDir = path.dirname(desired.ca);
@@ -1756,11 +1837,12 @@ function spawnBlindfold(desired, gatewayPort) {
1756
1837
  return child.pid;
1757
1838
  }
1758
1839
 
1759
- /** null when the interceptor that cfg asks for can run, otherwise the reason. No side effects. */
1840
+ /** null when the interceptor that cfg asks for can run, otherwise the reason. Builds missing or
1841
+ * outdated certificates on the way; changes nothing else. */
1760
1842
  export async function checkBlindfoldTarget(cfg, gatewayPort) {
1761
1843
  const desired = computeLaunchState(cfg, gatewayPort).blindfold;
1762
1844
  if (!desired) return null;
1763
- const problem = blindfoldPreflight(desired);
1845
+ const problem = ensureBlindfoldCerts(desired);
1764
1846
  if (problem) return problem;
1765
1847
  const held = (await probeBlindfold(desired.port)).state;
1766
1848
  if (held === 'foreign') return `Port ${desired.port} is held by another process, not by this switcher's interceptor.`;
package/switch.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  resolvePort, parsePort, findProfileKey, getActiveMap, setTargetProfile, activateProfile, deactivateAll,
9
9
  applyLaunchState, clearLaunchState, computeLaunchState,
10
10
  modelSlotsForProfile, modelForSlot, model1MForSlot, readAdminToken, adminTokenPath, openLog,
11
- probeGateway, probeBlindfold, blindfoldPreflight, stopRecordedBlindfold, writeDashboardLauncher,
11
+ probeGateway, probeBlindfold, ensureBlindfoldCerts, stopRecordedBlindfold, writeDashboardLauncher,
12
12
  contractLabSettings, codexPublicModelsWarning,
13
13
  getMigrationError, getMigrationCollision, getLastLoadError,
14
14
  casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
@@ -26,6 +26,12 @@ const proxyScript = path.join(ROOT_DIR, 'proxy.mjs');
26
26
  const proxyLogPath = paths.proxyLog;
27
27
  const userProfile = os.homedir();
28
28
 
29
+ // The version needs no config.json: it must also answer before the first setup.
30
+ if (['version', '--version', '-v'].includes(String(process.argv[2] || '').toLowerCase())) {
31
+ await showVersion();
32
+ process.exit(0);
33
+ }
34
+
29
35
  const config = loadConfig();
30
36
  if (!config) {
31
37
  const err = getConfigLoadError();
@@ -356,10 +362,11 @@ function planSwitch(base, key, cliTarget) {
356
362
 
357
363
  function refuseBlindfoldProblem(planned, port) {
358
364
  const bf = computeLaunchState(planned, port).blindfold;
359
- const problem = bf && blindfoldPreflight(bf);
365
+ // A missing or outdated certificate is built here; only a set that cannot be built stops the switch.
366
+ const problem = bf && ensureBlindfoldCerts(bf);
360
367
  if (!problem) return bf;
361
368
  console.error(`[Error] ${problem}`);
362
- console.error(' Fix the certificate with: bash blindfold/make-certs.sh - it names all three hosts this switcher routes. Nothing was changed.');
369
+ console.error(' Nothing was changed.');
363
370
  process.exit(1);
364
371
  }
365
372
 
@@ -415,6 +422,7 @@ async function turnOn(profileName, cliTarget) {
415
422
  const savedBytes = fs.readFileSync(configPath);
416
423
  const st = applyLaunchState(planned, port);
417
424
  reportSettings(st.settings);
425
+ reportCodexDaemon(st);
418
426
  const bf = await requestBlindfoldSync(port);
419
427
  if (!bf.ok) {
420
428
  console.error(`[Error] ${bf.error}`);
@@ -520,7 +528,7 @@ async function turnOff(targetArg) {
520
528
  // The order is fixed by R7b: the CAS write, then this tool's env files, then the active-tools
521
529
  // update. If the CAS write cannot land, the env file and the interceptor stay untouched.
522
530
  const saved = casOff([target]);
523
- emptyToolEnvFiles(target);
531
+ reportCodexDaemon(emptyToolEnvFiles(target));
524
532
  const bf = await syncOrStopBlindfold(port, config);
525
533
  if (!bf.ok) {
526
534
  console.error(`[Error] ${target} is switched back, but the interceptor is not in line: ${bf.error}`);
@@ -534,6 +542,7 @@ async function turnOff(targetArg) {
534
542
  saveConfig(config);
535
543
  const st = applyLaunchState(config, port);
536
544
  reportSettings(st.settings);
545
+ reportCodexDaemon(st);
537
546
  const bf = await syncOrStopBlindfold(port, config);
538
547
  if (!bf.ok) {
539
548
  console.error(`[Error] ${target} is switched back, but the blindfold interceptor is not in line: ${bf.error}`);
@@ -581,6 +590,12 @@ function auditActiveClis() {
581
590
  function reportSettings(result) {
582
591
  if (result?.removed?.length) console.log(`[settings.json] Removed switcher-written values: ${result.removed.join(', ')}`);
583
592
  if (result?.error) console.warn(`[settings.json] Not cleaned: ${result.error}`);
593
+ reportCodexDaemon(result);
594
+ }
595
+
596
+ // The interactive Codex TUI keeps using its shared daemon, so a new route needs that daemon restarted.
597
+ function reportCodexDaemon(st) {
598
+ if (st?.codexDaemon?.restarted) console.log('[Codex] Restarted the codex app-server daemon: open Codex sessions reconnect with the new route.');
584
599
  }
585
600
 
586
601
  async function showStatus() {
@@ -848,7 +863,7 @@ async function runDoctor() {
848
863
  const p = config.profiles[key];
849
864
  if (!p) warn(`[WARN] Target ${t} points to missing profile "${show(key)}".`);
850
865
  else if (!p.baseURL || /YOUR-|REPLACE-ME/i.test(`${p.baseURL} ${p.apiKey}`)) warn(`[WARN] Profile "${show(key)}" (${t}) still has placeholder baseURL/apiKey.`);
851
- if (p && t === 'responses') {
866
+ if (p && t === 'codex') {
852
867
  const codexWarning = codexPublicModelsWarning(show(key), p);
853
868
  if (codexWarning) warn(`[WARN] ${codexWarning}`);
854
869
  }
@@ -945,14 +960,14 @@ async function runDoctor() {
945
960
  const leafPath = path.join(path.dirname(paths.blindfoldCA), 'leaf.pem');
946
961
  if (!fs.existsSync(leafPath)) {
947
962
  warn(`[WARN] No interceptor certificate at ${leafPath}: neither tool can be intercepted.`);
948
- console.log(' Fix: bash blindfold/make-certs.sh');
963
+ console.log(' Fix: switch a tool on; the switcher builds the certificate. Or run bash blindfold/make-certs.sh');
949
964
  } else {
950
965
  try {
951
966
  const pem = fs.readFileSync(leafPath, 'utf8');
952
967
  const missing = ['api.anthropic.com', 'api.openai.com', 'chatgpt.com'].filter(h => !certCoversHost(pem, h));
953
968
  if (missing.length) {
954
969
  warn(`[WARN] The interceptor certificate does not cover ${missing.join(', ')}.`);
955
- console.log(' Fix: bash blindfold/make-certs.sh (it names all three hosts this switcher routes)');
970
+ console.log(' Fix: switch a tool on; the switcher rebuilds the certificate on the same CA. Or run bash blindfold/make-certs.sh');
956
971
  } else {
957
972
  console.log('[PASS] Interceptor certificate covers api.anthropic.com, api.openai.com and chatgpt.com.');
958
973
  }
@@ -1036,13 +1051,22 @@ async function runContractCheck() {
1036
1051
  }
1037
1052
  }
1038
1053
 
1054
+ async function showVersion() {
1055
+ const { checkForUpdate } = await import('./version.mjs');
1056
+ const v = await checkForUpdate({ stateDir: STATE_DIR });
1057
+ console.log(`llm-switcher ${v.current}`);
1058
+ if (v.updateAvailable) console.log(`A new version is available: ${v.latest}. Update with: ${v.updateCommand}`);
1059
+ else if (!v.latest) console.log('Could not check the npm registry for a newer version.');
1060
+ else console.log('This is the latest version.');
1061
+ }
1062
+
1039
1063
  async function showModels(target) {
1040
- const { loadCatalogCache, refreshCatalog } = await import('./catalog.mjs');
1064
+ const { syncLocalCatalog, refreshCatalog } = await import('./catalog.mjs');
1041
1065
  if (optionValue('--refresh') || process.argv.includes('--refresh')) {
1042
1066
  console.log('Refreshing models catalog from official endpoints...');
1043
1067
  await refreshCatalog(STATE_DIR);
1044
1068
  }
1045
- const cache = loadCatalogCache(STATE_DIR);
1069
+ const cache = syncLocalCatalog(STATE_DIR);
1046
1070
  console.log('=== LLM Switcher Discovered Models Catalog ===\n');
1047
1071
  const t = target ? target.toLowerCase() : '';
1048
1072
  if (!t || t === 'claude') {
@@ -1105,6 +1129,7 @@ if (cmd === 'off' || cmd === 'stop') {
1105
1129
  console.log('Usage:');
1106
1130
  console.log(' switch ui # Open Web UI dashboard');
1107
1131
  console.log(' switch status # Show multi-CLI active status');
1132
+ console.log(' switch version # Show the version and check for a newer one');
1108
1133
  console.log(' switch doctor # Audit environment, settings & routing');
1109
1134
  console.log(' switch on [profile] # Start gateway & activate profile for all compatible targets');
1110
1135
  console.log(' switch <profile> # Activate profile for all compatible targets');
@@ -150,7 +150,7 @@ async function startInterceptor({ port, gatewayPort, config, upstreamPort }) {
150
150
  child.stdout.on('data', (d) => output.push(String(d)));
151
151
  child.stderr.on('data', (d) => output.push(String(d)));
152
152
  const stop = async () => {
153
- if (child.exitCode !== null) return;
153
+ if (child.exitCode !== null || child.signalCode !== null) return;
154
154
  child.kill();
155
155
  await once(child, 'exit').catch(() => {});
156
156
  };
@@ -181,7 +181,7 @@ async function startGateway(t, port, cfgPath) {
181
181
  child.stdout.on('data', (d) => { log += d; });
182
182
  child.stderr.on('data', (d) => { log += d; });
183
183
  const stop = async () => {
184
- if (child.exitCode !== null) return;
184
+ if (child.exitCode !== null || child.signalCode !== null) return;
185
185
  child.kill();
186
186
  await once(child, 'exit').catch(() => {});
187
187
  };
@@ -113,7 +113,7 @@ async function startInterceptor({ port, gatewayPort, config, activeTools = 'clau
113
113
  child.stderr.on('data', (d) => output.push(String(d)));
114
114
 
115
115
  const stop = async () => {
116
- if (child.exitCode !== null) return;
116
+ if (child.exitCode !== null || child.signalCode !== null) return;
117
117
  child.kill();
118
118
  await once(child, 'exit').catch(() => {});
119
119
  };
@@ -126,7 +126,7 @@ async function startInterceptor({ port, gatewayPort, config, activeTools = 'clau
126
126
  child.stderr.on('data', (d) => output.push(String(d)));
127
127
 
128
128
  const stop = async () => {
129
- if (child.exitCode !== null) return; // already gone: waiting for 'exit' would never resolve
129
+ if (child.exitCode !== null || child.signalCode !== null) return; // already gone: waiting for 'exit' would never resolve
130
130
  child.kill();
131
131
  await once(child, 'exit').catch(() => {});
132
132
  };
@@ -650,7 +650,7 @@ test('R3b: an interceptor from 1.1.11 proves the old way and is replaced, not tr
650
650
  `;
651
651
  const legacy = spawn(process.execPath, ['--input-type=module', '-e', legacySrc], { stdio: ['ignore', 'pipe', 'pipe'] });
652
652
  t.after(async () => {
653
- if (legacy.exitCode !== null) return;
653
+ if (legacy.exitCode !== null || legacy.signalCode !== null) return;
654
654
  legacy.kill();
655
655
  await once(legacy, 'exit').catch(() => {});
656
656
  });
@@ -53,7 +53,9 @@ async function startInterceptor() {
53
53
  const port = await freePort();
54
54
  const child = spawn(process.execPath, [path.join(ROOT, 'blindfold', 'blindfold.mjs'),
55
55
  '--port', String(port), '--gateway-port', String(gatewayPort), '--certs', certDir,
56
- '--capture', captureDir, '--token-file', path.join(dir, 'admin.token')], { stdio: ['ignore', 'pipe', 'pipe'] });
56
+ '--capture', captureDir, '--token-file', path.join(dir, 'admin.token'),
57
+ // Since R3 only the paths of an active tool reach the gateway; everything else passes through.
58
+ '--active-tools', 'codex'], { stdio: ['ignore', 'pipe', 'pipe'] });
57
59
  let out = '';
58
60
  child.stdout.on('data', d => { out += d; });
59
61
  child.stderr.on('data', d => { out += d; });
@@ -14,6 +14,8 @@ import {
14
14
  refreshCatalog,
15
15
  detectToolVersion,
16
16
  checkVersionAndRefresh,
17
+ readLocalToolModels,
18
+ syncLocalCatalog,
17
19
  BASELINE_MODELS
18
20
  } from '../catalog.mjs';
19
21
 
@@ -124,24 +126,120 @@ test('detectToolVersion extracts semantic version from various User-Agent string
124
126
  assert.equal(detectToolVersion({}), '');
125
127
  });
126
128
 
127
- test('checkVersionAndRefresh records new version and triggers update only on version bumps', async () => {
128
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-bump-test-'));
129
- try {
130
- // Initial request from version 1.1.0
131
- checkVersionAndRefresh('claude', { 'user-agent': 'claude-cli/1.1.0' }, tmp);
132
- const cat1 = loadCatalogCache(tmp);
133
- assert.equal(cat1.claude.lastSeenVersion, '1.1.0');
134
-
135
- // Repeated request from the same version 1.1.0 -> no change, no duplicate trigger
136
- checkVersionAndRefresh('claude', { 'user-agent': 'claude-cli/1.1.0' }, tmp);
137
- const cat2 = loadCatalogCache(tmp);
138
- assert.equal(cat2.claude.lastSeenVersion, '1.1.0');
139
-
140
- // Tool updates to version 1.2.0 -> version bump detected and saved
141
- checkVersionAndRefresh('claude', { 'user-agent': 'claude-cli/1.2.0' }, tmp);
142
- const cat3 = loadCatalogCache(tmp);
143
- assert.equal(cat3.claude.lastSeenVersion, '1.2.0');
144
- } finally {
145
- fs.rmSync(tmp, { recursive: true, force: true });
129
+ // Local model lists that the tools write themselves, in the formats of Codex and Claude Code.
130
+ function writeToolCaches(root, { codex = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'], claude = ['claude-opus-5-5', 'claude-sonnet-5-5'] } = {}) {
131
+ const codexHome = path.join(root, 'codex');
132
+ const claudeDir = path.join(root, 'claude');
133
+ fs.mkdirSync(codexHome, { recursive: true });
134
+ fs.mkdirSync(path.join(claudeDir, 'cache', 'model-catalog'), { recursive: true });
135
+ if (codex) {
136
+ fs.writeFileSync(path.join(codexHome, 'models_cache.json'), JSON.stringify({
137
+ fetched_at: '2026-09-29T01:00:00Z', client_version: '0.158.0',
138
+ models: codex.map(slug => ({ slug, display_name: slug, context_window: 1000000, visibility: 'list' }))
139
+ }));
140
+ }
141
+ if (claude) {
142
+ // An older file with another list: the newest fetchedAt wins.
143
+ fs.writeFileSync(path.join(claudeDir, 'cache', 'model-catalog', 'old.json'), JSON.stringify({
144
+ version: 2, fetchedAt: 1000, catalog: { surface: 'cc', config: { models: [{ id: 'claude-opus-4-6', name: 'Opus 4.6' }] } }
145
+ }));
146
+ fs.writeFileSync(path.join(claudeDir, 'cache', 'model-catalog', 'new.json'), JSON.stringify({
147
+ version: 2, fetchedAt: 2000, catalog: { surface: 'cc', config: { models: claude.map(id => ({ id, name: id })) } }
148
+ }));
146
149
  }
150
+ return { codexHome, claudeDir };
151
+ }
152
+
153
+ function failingFetch(t) {
154
+ const realFetch = globalThis.fetch;
155
+ let calls = 0;
156
+ globalThis.fetch = async () => { calls++; return new Response('{}', { status: 401 }); };
157
+ t.after(() => { globalThis.fetch = realFetch; });
158
+ return () => calls;
159
+ }
160
+
161
+ test('readLocalToolModels reads the model lists that Codex and Claude Code keep on disk', (t) => {
162
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-local-models-'));
163
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
164
+ const sources = writeToolCaches(root);
165
+
166
+ const codex = readLocalToolModels('codex', sources);
167
+ assert.deepEqual(codex.models.map(m => [m.id, m.role]), [['gpt-5.6-sol', 'main'], ['gpt-5.6-terra', 'review'], ['gpt-5.6-luna', 'subagent']]);
168
+ assert.equal(codex.version, '0.158.0');
169
+
170
+ const claude = readLocalToolModels('claude', sources);
171
+ assert.deepEqual(claude.models.map(m => [m.id, m.tier]), [['claude-opus-5-5', 'opus'], ['claude-sonnet-5-5', 'sonnet']]);
172
+
173
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-no-models-'));
174
+ t.after(() => fs.rmSync(empty, { recursive: true, force: true }));
175
+ assert.equal(readLocalToolModels('codex', { codexHome: empty, claudeDir: empty }), null);
176
+ assert.equal(readLocalToolModels('claude', { codexHome: empty, claudeDir: empty }), null);
177
+ });
178
+
179
+ test('refreshCatalog takes the local lists and drops the built-in names', async (t) => {
180
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-refresh-local-'));
181
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
182
+ const sources = writeToolCaches(root);
183
+ const calls = failingFetch(t);
184
+
185
+ const cache = await refreshCatalog(root, { sources });
186
+ assert.deepEqual(cache.codex.models.map(m => m.id), ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']);
187
+ assert.deepEqual(cache.claude.models.map(m => m.id), ['claude-opus-5-5', 'claude-sonnet-5-5']);
188
+ assert.equal(calls(), 0, 'a local list needs no network call');
189
+ });
190
+
191
+ test('checkVersionAndRefresh records a version only after the catalog for it is refreshed', async (t) => {
192
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-bump-test-'));
193
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
194
+ failingFetch(t);
195
+
196
+ // No local list and a failing official list: nothing is refreshed, so the version stays unseen.
197
+ const none = { codexHome: path.join(root, 'none'), claudeDir: path.join(root, 'none') };
198
+ checkVersionAndRefresh('codex', { 'user-agent': 'codex_cli_rs/0.158.0 (Mac OS)' }, root, undefined, none);
199
+ await new Promise(r => setTimeout(r, 50));
200
+ assert.equal(loadCatalogCache(root).codex.lastSeenVersion, undefined, 'a failed refresh is tried again');
201
+
202
+ // Codex wrote its list: the next request refreshes and records the version.
203
+ const sources = writeToolCaches(root, { codex: ['gpt-5.7-sol'] });
204
+ checkVersionAndRefresh('codex', { 'user-agent': 'codex_cli_rs/0.158.0 (Mac OS)' }, root, undefined, sources);
205
+ await new Promise(r => setTimeout(r, 50));
206
+ const after = loadCatalogCache(root);
207
+ assert.equal(after.codex.lastSeenVersion, '0.158.0');
208
+ assert.deepEqual(after.codex.models.map(m => m.id), ['gpt-5.7-sol']);
209
+ });
210
+
211
+ // A profile key belongs to that profile's baseURL. The official model lists may see it only when
212
+ // the profile itself points at the official host.
213
+ test('refreshCatalog sends a profile key only to the host of that profile', async (t) => {
214
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-catalog-keys-'));
215
+ const realFetch = globalThis.fetch;
216
+ const sent = [];
217
+ globalThis.fetch = async (url, init = {}) => {
218
+ const h = init.headers || {};
219
+ sent.push({ host: new URL(url).host, auth: h.authorization || h['x-api-key'] || '' });
220
+ return new Response('{}', { status: 401 });
221
+ };
222
+ t.after(() => { globalThis.fetch = realFetch; fs.rmSync(stateDir, { recursive: true, force: true }); });
223
+
224
+ await refreshCatalog(stateDir, {
225
+ claudeProfile: { baseURL: 'https://gateway.example/v1', apiKey: 'sk-third-party' },
226
+ codexProfile: { baseURL: 'https://api.openai.com/v1', apiKey: 'sk-openai' },
227
+ sources: { codexHome: stateDir, claudeDir: stateDir }
228
+ });
229
+ assert.deepEqual(sent.find(s => s.host === 'api.anthropic.com').auth, '', 'a third-party key never reaches Anthropic');
230
+ assert.deepEqual(sent.find(s => s.host === 'api.openai.com').auth, 'Bearer sk-openai', 'an OpenAI profile key reaches OpenAI');
231
+ });
232
+
233
+ // Codex can update and rewrite its list without one request through the gateway.
234
+ test('syncLocalCatalog picks up a list that a tool rewrote, with no network call', (t) => {
235
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-sync-local-'));
236
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
237
+ const calls = failingFetch(t);
238
+ assert.deepEqual(loadCatalogCache(root).codex.models.map(m => m.id).includes('gpt-6-sol'), true, 'starts from the built-in list');
239
+
240
+ const sources = writeToolCaches(root, { codex: ['gpt-5.7-sol', 'gpt-5.7-luna'] });
241
+ const synced = syncLocalCatalog(root, sources);
242
+ assert.deepEqual(synced.codex.models.map(m => m.id), ['gpt-5.7-sol', 'gpt-5.7-luna']);
243
+ assert.deepEqual(loadCatalogCache(root).codex.models.map(m => m.id), ['gpt-5.7-sol', 'gpt-5.7-luna'], 'saved to disk');
244
+ assert.equal(calls(), 0);
147
245
  });
@@ -0,0 +1,86 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import tls from 'node:tls';
8
+ import { execFileSync } from 'node:child_process';
9
+ import { buildCerts, signLeaf } from '../blindfold/certs.mjs';
10
+ import { blindfoldPreflight, ensureBlindfoldCerts, INTERCEPT_HOSTS } from '../state.mjs';
11
+
12
+ const HAS_OPENSSL = (() => { try { execFileSync('openssl', ['version'], { stdio: 'ignore' }); return true; } catch { return false; } })();
13
+
14
+ function tmp(t) {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-certs-'));
16
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
17
+ return path.join(dir, 'certs');
18
+ }
19
+
20
+ const desiredFor = (dir) => ({ ca: path.join(dir, 'ca.pem') });
21
+
22
+ test('buildCerts writes a CA and a leaf that the preflight accepts, private to the owner', (t) => {
23
+ const dir = tmp(t);
24
+ buildCerts(dir, INTERCEPT_HOSTS);
25
+ for (const f of ['ca.pem', 'ca.key', 'leaf.pem', 'leaf.key']) {
26
+ assert.ok(fs.existsSync(path.join(dir, f)), f);
27
+ if (process.platform !== 'win32') assert.equal(fs.statSync(path.join(dir, f)).mode & 0o777, 0o600, f);
28
+ }
29
+ if (process.platform !== 'win32') assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
30
+ assert.equal(blindfoldPreflight(desiredFor(dir)), null);
31
+ const ca = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'ca.pem')));
32
+ assert.equal(ca.ca, true);
33
+ const leaf = new crypto.X509Certificate(fs.readFileSync(path.join(dir, 'leaf.pem')));
34
+ assert.equal(leaf.ca, false);
35
+ for (const h of INTERCEPT_HOSTS) assert.equal(leaf.checkHost(h), h);
36
+ });
37
+
38
+ test('a TLS client that trusts only the CA completes a handshake for every host', async (t) => {
39
+ const dir = tmp(t);
40
+ buildCerts(dir, INTERCEPT_HOSTS);
41
+ const server = tls.createServer({ key: fs.readFileSync(path.join(dir, 'leaf.key')), cert: fs.readFileSync(path.join(dir, 'leaf.pem')) }, s => s.end('ok'));
42
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
43
+ t.after(() => server.close());
44
+ for (const host of INTERCEPT_HOSTS) {
45
+ const got = await new Promise((resolve, reject) => {
46
+ const s = tls.connect({ port: server.address().port, host: '127.0.0.1', servername: host, ca: fs.readFileSync(path.join(dir, 'ca.pem')) }, () => {
47
+ s.once('data', d => { resolve(String(d)); s.end(); });
48
+ });
49
+ s.on('error', reject);
50
+ });
51
+ assert.equal(got, 'ok', host);
52
+ }
53
+ });
54
+
55
+ test('openssl verifies the leaf, and the name constraints refuse a leaf for another host', { skip: !HAS_OPENSSL && 'openssl' }, (t) => {
56
+ const dir = tmp(t);
57
+ buildCerts(dir, INTERCEPT_HOSTS);
58
+ const out = execFileSync('openssl', ['verify', '-CAfile', path.join(dir, 'ca.pem'), path.join(dir, 'leaf.pem')], { encoding: 'utf8' });
59
+ assert.match(out, /OK/);
60
+ // A leaked ca.key signs for any name; a client that obeys the constraints must refuse it.
61
+ const evil = path.join(dir, 'evil.pem');
62
+ fs.writeFileSync(evil, signLeaf(dir, ['evil.example']).cert);
63
+ assert.throws(() => execFileSync('openssl', ['verify', '-CAfile', path.join(dir, 'ca.pem'), evil], { stdio: 'pipe' }), /permitted subtree violation/);
64
+ });
65
+
66
+ test('ensureBlindfoldCerts builds a missing set, rebuilds a missing leaf on the same CA, and leaves a good set alone', (t) => {
67
+ const dir = tmp(t);
68
+ assert.match(blindfoldPreflight(desiredFor(dir)), /ca\.pem is missing/);
69
+ assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
70
+ const ca = fs.readFileSync(path.join(dir, 'ca.pem'), 'utf8');
71
+ const leaf = fs.readFileSync(path.join(dir, 'leaf.pem'), 'utf8');
72
+
73
+ assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
74
+ assert.equal(fs.readFileSync(path.join(dir, 'leaf.pem'), 'utf8'), leaf, 'a good set is not rebuilt');
75
+
76
+ fs.rmSync(path.join(dir, 'leaf.pem'));
77
+ assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
78
+ assert.equal(fs.readFileSync(path.join(dir, 'ca.pem'), 'utf8'), ca, 'the CA Codex already trusts is kept');
79
+ assert.notEqual(fs.readFileSync(path.join(dir, 'leaf.pem'), 'utf8'), leaf);
80
+
81
+ // A leaf from an older version covers one host only.
82
+ fs.writeFileSync(path.join(dir, 'leaf.pem'), signLeaf(dir, ['api.openai.com']).cert);
83
+ assert.match(blindfoldPreflight(desiredFor(dir)), /does not cover/);
84
+ assert.equal(ensureBlindfoldCerts(desiredFor(dir)), null);
85
+ assert.equal(fs.readFileSync(path.join(dir, 'ca.pem'), 'utf8'), ca);
86
+ });