llm-switcher 1.2.2 → 1.2.3

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.
@@ -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,92 @@
1
+ // The interactive Codex TUI talks to a shared app-server daemon that keeps the environment it started
2
+ // with. A change of the Codex route must restart it, or the TUI keeps calling the old destination.
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ // Temporary HOME and state dir before the import, so nothing here can reach the real Codex daemon.
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-codex-daemon-'));
11
+ process.env.HOME = path.join(root, 'home');
12
+ process.env.LLM_SWITCHER_STATE_DIR = path.join(root, 'state');
13
+ delete process.env.CODEX_HOME;
14
+ fs.mkdirSync(process.env.LLM_SWITCHER_STATE_DIR, { recursive: true });
15
+ const { restartCodexDaemon, applyLaunchState, clearLaunchState, emptyToolEnvFiles, STATE_DIR } = await import('../state.mjs');
16
+ test.after(() => fs.rmSync(root, { recursive: true, force: true }));
17
+
18
+ // A machine where the codex shim routes through `stateDir` and a daemon is running.
19
+ function machine(stateDir) {
20
+ const home = fs.mkdtempSync(path.join(root, 'm-'));
21
+ const codexHome = path.join(home, '.codex');
22
+ const bin = path.join(codexHome, 'packages', 'app-server-daemon', 'current', 'bin', 'codex');
23
+ fs.mkdirSync(path.dirname(bin), { recursive: true });
24
+ fs.writeFileSync(bin, '');
25
+ fs.mkdirSync(path.join(codexHome, 'app-server-control'), { recursive: true });
26
+ fs.writeFileSync(path.join(codexHome, 'app-server-control', 'app-server-control.sock'), '');
27
+ fs.mkdirSync(path.join(home, '.llm-switcher', 'bin'), { recursive: true });
28
+ fs.writeFileSync(path.join(home, '.llm-switcher', 'bin', 'codex'), `#!/usr/bin/env bash\nSWITCHER_DIR="${stateDir}"\n`);
29
+ const calls = [];
30
+ const spawnFn = (cmd, args, opts) => { calls.push({ cmd, args, env: opts.env }); return { unref() {}, on() {} }; };
31
+ return { home, codexHome, bin, calls, spawnFn };
32
+ }
33
+
34
+ const ROUTE = [['HTTPS_PROXY', 'http://127.0.0.1:3457'], ['https_proxy', 'http://127.0.0.1:3457'],
35
+ ['NO_PROXY', '127.0.0.1,localhost'], ['no_proxy', '127.0.0.1,localhost'], ['CODEX_CA_CERTIFICATE', '/ca.pem']];
36
+
37
+ test('a route change restarts the running daemon with the new Codex variables', () => {
38
+ const m = machine('/switcher/state');
39
+ const r = restartCodexDaemon(ROUTE, { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv: { PATH: '/bin' } });
40
+ assert.equal(r.restarted, true);
41
+ assert.equal(m.calls.length, 1);
42
+ assert.equal(m.calls[0].cmd, m.bin);
43
+ assert.deepEqual(m.calls[0].args, ['app-server', 'daemon', 'restart']);
44
+ assert.equal(m.calls[0].env.HTTPS_PROXY, 'http://127.0.0.1:3457');
45
+ assert.equal(m.calls[0].env.CODEX_CA_CERTIFICATE, '/ca.pem');
46
+ assert.equal(m.calls[0].env.PATH, '/bin');
47
+ });
48
+
49
+ test('switching Codex off restarts the daemon without the switcher variables, and keeps the user\'s own proxy', () => {
50
+ const m = machine('/switcher/state');
51
+ const baseEnv = { HTTPS_PROXY: 'http://127.0.0.1:3457', https_proxy: 'http://proxy.corp:8080', CODEX_CA_CERTIFICATE: '/ca.pem', NO_PROXY: '127.0.0.1,localhost' };
52
+ restartCodexDaemon([], { home: m.home, codexHome: m.codexHome, stateDir: '/switcher/state', spawnFn: m.spawnFn, baseEnv, ca: '/ca.pem' });
53
+ const env = m.calls[0].env;
54
+ assert.equal(env.HTTPS_PROXY, undefined, 'the loopback interceptor is removed');
55
+ assert.equal(env.CODEX_CA_CERTIFICATE, undefined, 'the switcher CA is removed');
56
+ assert.equal(env.https_proxy, 'http://proxy.corp:8080', 'a proxy the user set is kept');
57
+ });
58
+
59
+ test('no restart when the codex shim routes through another state dir, or no daemon runs', () => {
60
+ const other = machine('/another/install');
61
+ assert.equal(restartCodexDaemon(ROUTE, { home: other.home, codexHome: other.codexHome, stateDir: '/switcher/state', spawnFn: other.spawnFn }).restarted, false);
62
+ assert.equal(other.calls.length, 0);
63
+
64
+ const idle = machine('/switcher/state');
65
+ fs.rmSync(path.join(idle.codexHome, 'app-server-control', 'app-server-control.sock'));
66
+ assert.equal(restartCodexDaemon(ROUTE, { home: idle.home, codexHome: idle.codexHome, stateDir: '/switcher/state', spawnFn: idle.spawnFn }).restarted, false);
67
+ assert.equal(idle.calls.length, 0, 'a daemon that does not run is not started');
68
+ });
69
+
70
+ test('applyLaunchState reports a Codex route change only when env-codex changes', () => {
71
+ assert.ok(STATE_DIR.startsWith(root), 'the test writes only its own launch files');
72
+ const cfg = {
73
+ port: 3456, blindfold: { port: 3457 }, activeProfiles: { claude: null, codex: 'cx' },
74
+ profiles: { cx: { name: 'Cx', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'm' } } }
75
+ };
76
+ assert.equal(applyLaunchState(cfg, 3456).codexRouteChanged, true, 'first activation');
77
+ assert.equal(applyLaunchState(cfg, 3456).codexRouteChanged, false, 'same route again');
78
+ assert.equal(applyLaunchState({ ...cfg, activeProfiles: { claude: null, codex: null } }, 3456).codexRouteChanged, true, 'switched off');
79
+ });
80
+
81
+ test('switch off, for all tools or for Codex alone, also reports the Codex route change', () => {
82
+ const cfg = {
83
+ port: 3456, blindfold: { port: 3457 }, activeProfiles: { claude: null, codex: 'cx' },
84
+ profiles: { cx: { name: 'Cx', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'm' } } }
85
+ };
86
+ applyLaunchState(cfg, 3456);
87
+ assert.equal(clearLaunchState(3456).codexRouteChanged, true);
88
+ assert.equal(clearLaunchState(3456).codexRouteChanged, false, 'already off');
89
+ applyLaunchState(cfg, 3456);
90
+ assert.equal(emptyToolEnvFiles('codex').codexRouteChanged, true);
91
+ assert.equal(emptyToolEnvFiles('claude').codexRouteChanged, false);
92
+ });
@@ -54,16 +54,16 @@ function envFor(ws) {
54
54
  return { ...process.env, HOME: path.join(ws.dir, 'home'), USERPROFILE: path.join(ws.dir, 'home'), LLM_SWITCHER_CONFIG: ws.cfgPath, LLM_SWITCHER_STATE_DIR: ws.dir, LLM_SWITCHER_BLINDFOLD_CERTS: ws.certDir, CLAUDE_CONFIG_DIR: ws.claudeDir, LLM_SWITCHER_PORT: '', PORT: '' };
55
55
  }
56
56
 
57
- function writeConfig(ws, gwPort, bfPort, activeResponses = null) {
57
+ // The 1.2 schema: one pointer per tool and one interceptor port for the whole config. A file in the
58
+ // older schema is migrated on load, which rewrites it and breaks every "changes nothing" assertion.
59
+ function writeConfig(ws, gwPort, bfPort, activeCodex = null) {
58
60
  fs.writeFileSync(ws.cfgPath, JSON.stringify({
59
61
  port: gwPort,
60
- activeProfiles: { anthropic: null, responses: activeResponses, 'openai-chat': null, vertex: null },
62
+ blindfold: { port: bfPort },
63
+ activeProfiles: { claude: null, codex: activeCodex },
61
64
  profiles: {
62
- plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } },
63
- bf: {
64
- name: 'Blindfold', mode: 'convert', inFormat: 'responses', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k',
65
- defaultModels: { main: 'm' }, blindfold: true, blindfoldPort: bfPort
66
- }
65
+ plain: { name: 'Plain', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } },
66
+ bf: { name: 'Blindfold', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'm' } }
67
67
  }
68
68
  }, null, 2), { mode: 0o600 });
69
69
  }
@@ -121,7 +121,7 @@ test('the gateway proves its identity to a fresh challenge; a replayed /health i
121
121
  const body = await (await fetch(`http://127.0.0.1:${gwPort}/health?challenge=abc`)).json();
122
122
  // The proof binds the role, the port it answers on and its pid, so it cannot be relayed or edited.
123
123
  assert.equal(body.port, gwPort);
124
- assert.equal(body.proof, crypto.createHmac('sha256', token(ws)).update(['gateway', gwPort, body.pid, '', '', '', 'abc'].join('|')).digest('hex'));
124
+ assert.equal(body.proof, crypto.createHmac('sha256', token(ws)).update(['gateway', gwPort, body.pid, '', '', 'abc'].join('|')).digest('hex'));
125
125
  assert.equal(await probe(ws, `s.probeGateway(${gwPort})`), 'ours');
126
126
  assert.equal(await probe(ws, `s.probeGateway(${replayPort})`), 'foreign');
127
127
  assert.equal(await probe(ws, `s.probeGateway(${await freePort()})`), 'free');
@@ -210,18 +210,21 @@ test('the gateway owns the interceptor: dashboard changes, a lost interceptor an
210
210
  let gw = await startGateway(ws, gwPort);
211
211
  const bfState = () => probe(ws, `s.probeBlindfold(${bfPort})`);
212
212
  try {
213
- // 1. the dashboard turns blindfold on for the Codex target
214
- const on = await api(ws, gwPort, '/api/switch', { target: 'responses', profile: 'bf' });
213
+ // 1. the dashboard turns Codex on
214
+ const on = await api(ws, gwPort, '/api/switch', { target: 'codex', profile: 'bf' });
215
215
  assert.equal(on.success, true, JSON.stringify(on));
216
216
  let st = await waitFor(async () => { const s = await bfState(); return s.state === 'ours' && s; });
217
217
  assert.equal(st.gatewayPort, gwPort);
218
- assert.equal(st.prefix, '/backend-api/codex');
218
+ assert.equal(st.activeTools, 'codex');
219
219
 
220
- // 2. the dashboard changes the prefix: the interceptor is respawned with it
221
- const saved = await api(ws, gwPort, '/api/save-profile', { key: 'bf', profile: { blindfoldPrefix: '/backend-api/codex2' } });
222
- assert.notEqual(saved.success, false, JSON.stringify(saved));
223
- st = await waitFor(async () => { const s = await bfState(); return s.prefix === '/backend-api/codex2' && s; });
224
- assert.equal(st.prefix, '/backend-api/codex2');
220
+ // 2. the dashboard turns Claude on as well: the running interceptor takes the new tool set in place
221
+ const both = await api(ws, gwPort, '/api/switch', { target: 'claude', profile: 'plain' });
222
+ assert.notEqual(both.success, false, JSON.stringify(both));
223
+ st = await waitFor(async () => { const s = await bfState(); return s.activeTools === 'claude,codex' && s; });
224
+ assert.equal(st.activeTools, 'claude,codex');
225
+ await api(ws, gwPort, '/api/switch', { target: 'claude', profile: null });
226
+ st = await waitFor(async () => { const s = await bfState(); return s.activeTools === 'codex' && s; });
227
+ assert.equal(st.activeTools, 'codex');
225
228
 
226
229
  // 3. the interceptor dies; a sync brings it back
227
230
  process.kill(st.pid, 'SIGTERM');
@@ -238,31 +241,30 @@ test('the gateway owns the interceptor: dashboard changes, a lost interceptor an
238
241
  st = await waitFor(async () => { const s = await bfState(); return s.state === 'ours' && s; });
239
242
  assert.equal(st.gatewayPort, gwPort, 'the gateway starts the interceptor at boot');
240
243
 
241
- // 5. a foreign process on the new interceptor port: the caller sees the failure
244
+ // 5. config.json names an interceptor port that a foreign process holds: a dashboard change fails
242
245
  const squatter = net.createServer(s => s.end());
243
246
  const squatPort = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
247
+ const good = fs.readFileSync(ws.cfgPath);
244
248
  try {
249
+ fs.writeFileSync(ws.cfgPath, JSON.stringify({ ...JSON.parse(good), blindfold: { port: squatPort } }, null, 2), { mode: 0o600 });
250
+ const edited = fs.readFileSync(ws.cfgPath);
245
251
  const badRes = await fetch(`http://127.0.0.1:${gwPort}/api/save-profile`, {
246
252
  method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) },
247
- body: JSON.stringify({ key: 'bf', profile: { blindfoldPort: squatPort } })
253
+ body: JSON.stringify({ key: 'bf', profile: { name: 'Renamed' } })
248
254
  });
249
255
  assert.equal(badRes.status, 502, 'a failure is not answered with 200');
250
256
  const bad = await badRes.json();
251
257
  assert.equal(bad.success, false);
252
258
  assert.match(bad.error, /held by another process/);
253
- const onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8')).profiles.bf.blindfoldPort;
254
- assert.equal(onDisk, bfPort, 'a refused change is not saved, so HTTPS_PROXY never points at the squatter');
259
+ assert.deepEqual(fs.readFileSync(ws.cfgPath), edited, 'a refused change is not saved');
255
260
  assert.equal((await bfState()).state, 'ours', 'the working interceptor stays up');
256
- const status = await fetch(`http://127.0.0.1:${gwPort}/api/status`, { headers: { 'x-llm-switcher-token': token(ws) } }).then(r => r.json());
257
- assert.equal(status.config.profiles.bf.blindfoldPort, bfPort, 'the gateway does not keep the refused change in memory');
258
261
  } finally {
259
262
  squatter.close();
263
+ fs.writeFileSync(ws.cfgPath, good, { mode: 0o600 });
260
264
  }
261
265
 
262
266
  // 6. turning the Codex target off stops the interceptor
263
- await api(ws, gwPort, '/api/save-profile', { key: 'bf', profile: { blindfoldPort: bfPort } });
264
- await waitFor(async () => (await bfState()).state === 'ours');
265
- await api(ws, gwPort, '/api/switch', { target: 'responses', profile: null });
267
+ await api(ws, gwPort, '/api/switch', { target: 'codex', profile: null });
266
268
  assert.equal(await waitFor(async () => (await bfState()).state === 'free'), true);
267
269
  } finally {
268
270
  try { const s = await bfState(); if (s.state === 'ours') process.kill(s.pid, 'SIGTERM'); } catch {}
@@ -276,32 +278,42 @@ test('the gateway owns the interceptor: dashboard changes, a lost interceptor an
276
278
  test('concurrent admin changes: a refused change never reaches disk, and no accepted change is lost', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
277
279
  const ws = makeWorkspace({ certs: true });
278
280
  const gwPort = await freePort();
279
- const bfPort = await freePort();
280
- writeConfig(ws, gwPort, bfPort, 'bf');
281
- const launch = snapshotLaunchFiles(ws.dir);
282
- const gw = await startGateway(ws, gwPort);
281
+ let bfPort;
283
282
  // A squatter that accepts and never answers keeps the identity probe waiting.
284
283
  const silent = net.createServer(() => {});
285
284
  const silentPort = await new Promise(r => silent.listen(0, '127.0.0.1', () => r(silent.address().port)));
285
+ writeConfig(ws, gwPort, silentPort);
286
+ const launch = snapshotLaunchFiles(ws.dir);
287
+ const gw = await startGateway(ws, gwPort);
286
288
  try {
287
- await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours');
288
- const post = (body) => fetch(`http://127.0.0.1:${gwPort}/api/save-profile`, {
289
+ const post = (p, body) => fetch(`http://127.0.0.1:${gwPort}${p}`, {
289
290
  method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) }, body: JSON.stringify(body)
290
291
  });
291
- const refused = post({ key: 'bf', profile: { blindfoldPort: silentPort } });
292
+ // Turning Codex on waits for the silent port and is refused; the rename that arrives meanwhile is kept.
293
+ const refused = post('/api/switch', { target: 'codex', profile: 'bf' });
292
294
  await new Promise(r => setTimeout(r, 300));
293
- const other = post({ key: 'plain', profile: { name: 'Renamed' } });
295
+ const other = post('/api/save-profile', { key: 'plain', profile: { name: 'Renamed' } });
294
296
  const [a, b] = await Promise.all([refused, other]);
295
297
  assert.equal(a.status, 502);
296
298
  assert.equal(b.status, 200);
297
- const onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
298
- assert.equal(onDisk.profiles.bf.blindfoldPort, bfPort, 'the refused port is not saved by the other request');
299
+ let onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
300
+ assert.equal(onDisk.activeProfiles.codex, null, 'the refused switch is not saved by the other request');
299
301
  assert.equal(onDisk.profiles.plain.name, 'Renamed');
300
302
 
303
+ // A usable interceptor port, and Codex on, for the second half. Chosen only now: other test files
304
+ // run at the same time, and a port picked seconds earlier can be taken in between.
305
+ bfPort = await freePort();
306
+ onDisk.blindfold = { port: bfPort };
307
+ fs.writeFileSync(ws.cfgPath, JSON.stringify(onDisk, null, 2), { mode: 0o600 });
308
+ const on = await post('/api/switch', { target: 'codex', profile: 'bf' });
309
+ assert.equal(on.status, 200, await on.text());
310
+ await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours');
311
+ const postProfile = (body) => post('/api/save-profile', body);
312
+
301
313
  // Two accepted changes to the active profile: the one that saves last keeps the other.
302
314
  const [c, d] = await Promise.all([
303
- post({ key: 'bf', profile: { name: 'First' } }),
304
- post({ key: 'bf', profile: { defaultModels: { main: 'm2' } } })
315
+ postProfile({ key: 'bf', profile: { name: 'First' } }),
316
+ postProfile({ key: 'bf', profile: { defaultModels: { main: 'm2' } } })
305
317
  ]);
306
318
  assert.equal(c.status, 200);
307
319
  assert.equal(d.status, 200);
@@ -0,0 +1,22 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const HAS_OPENSSL = process.platform !== 'win32' && fs.existsSync('/usr/bin/openssl');
11
+
12
+ // LibreSSL names the -CAcreateserial file after the CA path cut at its first dot, so a
13
+ // directory such as /Users/first.last sent the serial file to /Users/first.srl.
14
+ test('make-certs.sh builds the certificates under a path that contains a dot', { skip: !HAS_OPENSSL && 'posix + openssl' }, (t) => {
15
+ // The first dot of the whole path is in `first.last`, so a stray serial file lands in `dir` as first.srl.
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmswcerts'));
17
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
18
+ const out = path.join(dir, 'first.last', 'certs');
19
+ execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), out], { stdio: 'pipe' });
20
+ for (const f of ['ca.pem', 'ca.key', 'leaf.pem', 'leaf.key']) assert.ok(fs.existsSync(path.join(out, f)), `${f} is missing`);
21
+ assert.deepEqual(fs.readdirSync(dir), ['first.last'], 'no serial file outside the output directory');
22
+ });
@@ -130,6 +130,8 @@ before(async () => {
130
130
  proxyPort = await freePort();
131
131
  blindfoldPort = await freePort();
132
132
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-sim-'));
133
+ // Blindfold mode needs a CA. Without its own directory the gateway reads the certificates of the checkout.
134
+ execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), path.join(tmpDir, 'certs')], { stdio: 'ignore' });
133
135
 
134
136
  const base = `http://127.0.0.1:${upstreamPort}`;
135
137
  const models = { opus: 'up-opus', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' };
@@ -150,6 +152,7 @@ before(async () => {
150
152
  ...process.env,
151
153
  LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'),
152
154
  LLM_SWITCHER_STATE_DIR: tmpDir,
155
+ LLM_SWITCHER_BLINDFOLD_CERTS: path.join(tmpDir, 'certs'),
153
156
  CLAUDE_CONFIG_DIR: path.join(tmpDir, 'claude'),
154
157
  LLM_SWITCHER_PORT: ''
155
158
  },
@@ -31,7 +31,11 @@ if (process.platform !== 'win32') {
31
31
  fs.chmodSync(path.join(RENDER_DIR, name), 0o755);
32
32
  }
33
33
  }
34
+ // Only node, never the directory it lives in: an npm install puts the real claude and codex next to it.
35
+ const NODE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'shimnode-'));
36
+ if (process.platform !== 'win32') fs.symlinkSync(process.execPath, path.join(NODE_DIR, 'node'));
34
37
  test.after(() => {
38
+ fs.rmSync(NODE_DIR, { recursive: true, force: true });
35
39
  fs.rmSync(RENDER_DIR, { recursive: true, force: true });
36
40
  fs.rmSync(STATE, { recursive: true, force: true });
37
41
  });
@@ -47,7 +51,7 @@ function runShim(name, args, { active, fakeDir, extraPath = '', env = {}, files
47
51
  fs.writeFileSync(path.join(STATE, `env-${name}.sh`), "export HTTPS_PROXY='http://127.0.0.1:3457'\n");
48
52
  }
49
53
  for (const [f, content] of Object.entries(files)) fs.writeFileSync(path.join(STATE, f), content);
50
- const PATH_ = [RENDER_DIR, fakeDir, extraPath || '/usr/bin:/bin', path.dirname(process.execPath)]
54
+ const PATH_ = [RENDER_DIR, fakeDir, extraPath || '/usr/bin:/bin', NODE_DIR]
51
55
  .filter(Boolean).join(path.delimiter);
52
56
  return execFileSync(path.join(RENDER_DIR, name), args, {
53
57
  encoding: 'utf8', env: { ...process.env, ...env, PATH: PATH_ }, timeout: 15000
@@ -156,8 +160,8 @@ test('A2: codex shim passes no config override', () => {
156
160
  test('codex shim wires only the interceptor proxy and passes its arguments through', (t) => {
157
161
  if (process.platform === 'win32') return t.skip('posix only');
158
162
  const out = runShim('codex', ['exec', 'hi'], {
159
- fakeDir: makeArgvBin('codex'),
160
- files: { 'active.flag': 'active' }
163
+ active: true,
164
+ fakeDir: makeArgvBin('codex')
161
165
  }).split('\n');
162
166
  assert.equal(out[0], 'HTTPS_PROXY=http://127.0.0.1:3457', 'the interceptor is the only wiring');
163
167
  assert.deepEqual(out.slice(1), ['exec', 'hi'], 'arguments must be preserved');
@@ -171,6 +175,7 @@ test('Claude shim never loads the Codex-only file or passes Codex overrides', (t
171
175
  // env-codex.sh carries a deliberately different port: if the claude shim ever sourced it,
172
176
  // that value would show up instead of the claude one.
173
177
  const out = runShim('claude', ['--resume'], {
178
+ active: true,
174
179
  fakeDir: makeArgvBin('claude'),
175
180
  files: { 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:9999'\n" }
176
181
  }).split('\n');