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/CHANGELOG.md +17 -0
- package/README.md +7 -3
- package/README.vi.md +3 -3
- package/blindfold/certs.mjs +156 -0
- package/blindfold/make-certs.sh +3 -1
- package/catalog.mjs +132 -42
- package/docs/codex-blindfold.md +6 -4
- package/mcp.mjs +2 -2
- package/package.json +1 -1
- package/proxy.mjs +32 -12
- package/scripts/run-tests.mjs +3 -1
- package/state.mjs +88 -6
- package/switch.mjs +34 -9
- package/tests/blindfold-e2e.test.mjs +2 -2
- package/tests/blindfold-task5.test.mjs +1 -1
- package/tests/blindfold.task3.test.mjs +2 -2
- package/tests/blindfold.wire.test.mjs +3 -1
- package/tests/catalog.test.mjs +117 -19
- package/tests/certs.test.mjs +86 -0
- package/tests/codex-daemon.test.mjs +92 -0
- package/tests/gateway.e2e.test.mjs +36 -0
- package/tests/lifecycle.test.mjs +90 -57
- package/tests/make-certs.test.mjs +22 -0
- package/tests/real-user-sim.test.mjs +3 -0
- package/tests/shim.test.mjs +8 -3
- package/tests/state.test.mjs +8 -3
- package/tests/switch.test.mjs +30 -24
- package/tests/ui.test.mjs +110 -0
- package/tests/version.test.mjs +78 -0
- package/ui.html +325 -82
- package/version.mjs +53 -0
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
|
-
|
|
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.
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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('
|
|
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 === '
|
|
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
|
|
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 {
|
|
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 =
|
|
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')
|
|
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; });
|
package/tests/catalog.test.mjs
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
});
|