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
|
@@ -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
|
+
});
|
|
@@ -265,6 +265,12 @@ after(() => {
|
|
|
265
265
|
fakeInterceptor?.kill();
|
|
266
266
|
proxy?.kill();
|
|
267
267
|
upstream?.close();
|
|
268
|
+
// A test that changes the tool set makes the gateway start a real interceptor. It is detached on
|
|
269
|
+
// purpose, so it outlives the gateway; stop the one the gateway recorded before its state goes.
|
|
270
|
+
try {
|
|
271
|
+
const { pid } = JSON.parse(fs.readFileSync(path.join(tmpDir, 'blindfold.json'), 'utf8'));
|
|
272
|
+
if (Number.isInteger(pid) && pid !== fakeInterceptor?.pid) process.kill(pid);
|
|
273
|
+
} catch {}
|
|
268
274
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
269
275
|
});
|
|
270
276
|
|
|
@@ -659,6 +665,36 @@ test('Security: the admin API refuses a caller without the token and changes not
|
|
|
659
665
|
}
|
|
660
666
|
});
|
|
661
667
|
|
|
668
|
+
// The model list carries each model's token limits, as intact and OpenRouter give them, so the
|
|
669
|
+
// 1M context box can be locked for a model known to be smaller.
|
|
670
|
+
test('Browse Models returns the token limits the gateway lists', async () => {
|
|
671
|
+
const sink = http.createServer((req, res) => {
|
|
672
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
673
|
+
res.end(JSON.stringify({ data: [
|
|
674
|
+
{ id: 'big', context_length: 1048576, max_output_tokens: 65536 },
|
|
675
|
+
{ id: 'small', context_length: 200000 },
|
|
676
|
+
{ id: 'or', top_provider: { context_length: 262144, max_completion_tokens: 32768 } },
|
|
677
|
+
{ id: 'in', max_input_tokens: 1000000 },
|
|
678
|
+
{ id: 'bare' }
|
|
679
|
+
] }));
|
|
680
|
+
});
|
|
681
|
+
await new Promise(r => sink.listen(0, '127.0.0.1', r));
|
|
682
|
+
try {
|
|
683
|
+
const r = await post('/api/fetch-models', { baseURL: `http://127.0.0.1:${sink.address().port}` });
|
|
684
|
+
const data = await r.json();
|
|
685
|
+
assert.equal(data.ok, true);
|
|
686
|
+
assert.deepEqual(data.models, ['bare', 'big', 'in', 'or', 'small']);
|
|
687
|
+
assert.deepEqual(data.limits, {
|
|
688
|
+
big: { context: 1048576, output: 65536 },
|
|
689
|
+
small: { context: 200000 },
|
|
690
|
+
or: { context: 262144, output: 32768 },
|
|
691
|
+
in: { input: 1000000 }
|
|
692
|
+
});
|
|
693
|
+
} finally {
|
|
694
|
+
sink.close();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
662
698
|
test('Admin API: keys are redacted and invalid switch input is rejected', async () => {
|
|
663
699
|
const status = await (await fetch(url('/api/status'), { headers: withToken('/api/status', {}) })).json();
|
|
664
700
|
assert.ok(!JSON.stringify(status).includes('sk-secret'));
|
package/tests/lifecycle.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
+
blindfold: { port: bfPort },
|
|
63
|
+
activeProfiles: { claude: null, codex: activeCodex },
|
|
61
64
|
profiles: {
|
|
62
|
-
plain: { name: 'Plain', mode: 'convert',
|
|
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, '', '', '
|
|
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');
|
|
@@ -167,40 +167,53 @@ test('switch on refuses a foreign gateway port and changes nothing', { skip: !PO
|
|
|
167
167
|
}
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
// A missing or outdated certificate set is built before anything else is checked, so turning a
|
|
171
|
+
// Codex profile on never stops at the certificates. A squatter on the interceptor port keeps the
|
|
172
|
+
// switch refused here, which shows the build ran first and that nothing else changed.
|
|
173
|
+
test('switch codex <blindfold profile> builds missing certificates and a missing leaf.key, and refuses a foreign interceptor port, changing nothing else', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
171
174
|
const cases = [];
|
|
175
|
+
const squatter = net.createServer(s => s.end('HTTP/1.1 200 OK\r\n\r\nnot the switcher'));
|
|
176
|
+
const bfPort = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
|
|
172
177
|
try {
|
|
173
178
|
// 1. no certificates at all
|
|
174
179
|
const a = makeWorkspace(); cases.push(a);
|
|
175
|
-
writeConfig(a, await freePort(), await freePort());
|
|
176
180
|
// 2. leaf.key missing
|
|
177
181
|
const b = makeWorkspace({ certs: true }); cases.push(b);
|
|
178
182
|
fs.rmSync(path.join(b.certDir, 'leaf.key'));
|
|
179
|
-
|
|
180
|
-
// 3. valid certificates
|
|
183
|
+
const caBefore = fs.readFileSync(path.join(b.certDir, 'ca.pem'), 'utf8');
|
|
184
|
+
// 3. valid certificates
|
|
181
185
|
const c = makeWorkspace({ certs: true }); cases.push(c);
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
assert.deepEqual(snapshotLaunchFiles(ws.dir), launch, 'no launcher file changed');
|
|
195
|
-
}
|
|
196
|
-
} finally {
|
|
197
|
-
squatter.close();
|
|
186
|
+
for (const ws of cases) writeConfig(ws, await freePort(), bfPort);
|
|
187
|
+
for (const ws of cases) {
|
|
188
|
+
const before = fs.readFileSync(ws.cfgPath);
|
|
189
|
+
const launch = snapshotLaunchFiles(ws.dir);
|
|
190
|
+
const r = await runSwitch(ws, ['codex', 'bf']);
|
|
191
|
+
assert.notEqual(r.status, 0, r.stdout);
|
|
192
|
+
assert.match(r.stderr, /held by another process/);
|
|
193
|
+
assert.doesNotMatch(r.stderr, /missing/);
|
|
194
|
+
assert.equal(await probe(ws, `s.blindfoldPreflight({ ca: ${JSON.stringify(path.join(ws.certDir, 'ca.pem'))} })`), null, 'the certificates are usable now');
|
|
195
|
+
assert.deepEqual(fs.readFileSync(ws.cfgPath), before, 'config.json is byte-identical');
|
|
196
|
+
assert.ok(!fs.existsSync(path.join(ws.dir, 'blindfold.json')));
|
|
197
|
+
assert.deepEqual(snapshotLaunchFiles(ws.dir), launch, 'no launcher file changed');
|
|
198
198
|
}
|
|
199
|
+
assert.equal(fs.readFileSync(path.join(b.certDir, 'ca.pem'), 'utf8'), caBefore, 'a working CA is kept: Codex already trusts it');
|
|
199
200
|
} finally {
|
|
201
|
+
squatter.close();
|
|
200
202
|
for (const ws of cases) fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
201
203
|
}
|
|
202
204
|
});
|
|
203
205
|
|
|
206
|
+
test('the gateway check builds a missing certificate set instead of refusing the change', { skip: !POSIX && 'posix' }, async () => {
|
|
207
|
+
const ws = makeWorkspace();
|
|
208
|
+
try {
|
|
209
|
+
writeConfig(ws, await freePort(), await freePort(), 'bf');
|
|
210
|
+
assert.equal(await probe(ws, `s.checkBlindfoldTarget(s.loadConfig(), s.loadConfig().port)`), null);
|
|
211
|
+
for (const f of ['ca.pem', 'ca.key', 'leaf.pem', 'leaf.key']) assert.ok(fs.existsSync(path.join(ws.certDir, f)), f);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
204
217
|
test('the gateway owns the interceptor: dashboard changes, a lost interceptor and a gateway restart are reconciled', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
205
218
|
const ws = makeWorkspace({ certs: true });
|
|
206
219
|
const gwPort = await freePort();
|
|
@@ -210,18 +223,21 @@ test('the gateway owns the interceptor: dashboard changes, a lost interceptor an
|
|
|
210
223
|
let gw = await startGateway(ws, gwPort);
|
|
211
224
|
const bfState = () => probe(ws, `s.probeBlindfold(${bfPort})`);
|
|
212
225
|
try {
|
|
213
|
-
// 1. the dashboard turns
|
|
214
|
-
const on = await api(ws, gwPort, '/api/switch', { target: '
|
|
226
|
+
// 1. the dashboard turns Codex on
|
|
227
|
+
const on = await api(ws, gwPort, '/api/switch', { target: 'codex', profile: 'bf' });
|
|
215
228
|
assert.equal(on.success, true, JSON.stringify(on));
|
|
216
229
|
let st = await waitFor(async () => { const s = await bfState(); return s.state === 'ours' && s; });
|
|
217
230
|
assert.equal(st.gatewayPort, gwPort);
|
|
218
|
-
assert.equal(st.
|
|
231
|
+
assert.equal(st.activeTools, 'codex');
|
|
219
232
|
|
|
220
|
-
// 2. the dashboard
|
|
221
|
-
const
|
|
222
|
-
assert.notEqual(
|
|
223
|
-
st = await waitFor(async () => { const s = await bfState(); return s.
|
|
224
|
-
assert.equal(st.
|
|
233
|
+
// 2. the dashboard turns Claude on as well: the running interceptor takes the new tool set in place
|
|
234
|
+
const both = await api(ws, gwPort, '/api/switch', { target: 'claude', profile: 'plain' });
|
|
235
|
+
assert.notEqual(both.success, false, JSON.stringify(both));
|
|
236
|
+
st = await waitFor(async () => { const s = await bfState(); return s.activeTools === 'claude,codex' && s; });
|
|
237
|
+
assert.equal(st.activeTools, 'claude,codex');
|
|
238
|
+
await api(ws, gwPort, '/api/switch', { target: 'claude', profile: null });
|
|
239
|
+
st = await waitFor(async () => { const s = await bfState(); return s.activeTools === 'codex' && s; });
|
|
240
|
+
assert.equal(st.activeTools, 'codex');
|
|
225
241
|
|
|
226
242
|
// 3. the interceptor dies; a sync brings it back
|
|
227
243
|
process.kill(st.pid, 'SIGTERM');
|
|
@@ -238,31 +254,30 @@ test('the gateway owns the interceptor: dashboard changes, a lost interceptor an
|
|
|
238
254
|
st = await waitFor(async () => { const s = await bfState(); return s.state === 'ours' && s; });
|
|
239
255
|
assert.equal(st.gatewayPort, gwPort, 'the gateway starts the interceptor at boot');
|
|
240
256
|
|
|
241
|
-
// 5.
|
|
257
|
+
// 5. config.json names an interceptor port that a foreign process holds: a dashboard change fails
|
|
242
258
|
const squatter = net.createServer(s => s.end());
|
|
243
259
|
const squatPort = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
|
|
260
|
+
const good = fs.readFileSync(ws.cfgPath);
|
|
244
261
|
try {
|
|
262
|
+
fs.writeFileSync(ws.cfgPath, JSON.stringify({ ...JSON.parse(good), blindfold: { port: squatPort } }, null, 2), { mode: 0o600 });
|
|
263
|
+
const edited = fs.readFileSync(ws.cfgPath);
|
|
245
264
|
const badRes = await fetch(`http://127.0.0.1:${gwPort}/api/save-profile`, {
|
|
246
265
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) },
|
|
247
|
-
body: JSON.stringify({ key: 'bf', profile: {
|
|
266
|
+
body: JSON.stringify({ key: 'bf', profile: { name: 'Renamed' } })
|
|
248
267
|
});
|
|
249
268
|
assert.equal(badRes.status, 502, 'a failure is not answered with 200');
|
|
250
269
|
const bad = await badRes.json();
|
|
251
270
|
assert.equal(bad.success, false);
|
|
252
271
|
assert.match(bad.error, /held by another process/);
|
|
253
|
-
|
|
254
|
-
assert.equal(onDisk, bfPort, 'a refused change is not saved, so HTTPS_PROXY never points at the squatter');
|
|
272
|
+
assert.deepEqual(fs.readFileSync(ws.cfgPath), edited, 'a refused change is not saved');
|
|
255
273
|
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
274
|
} finally {
|
|
259
275
|
squatter.close();
|
|
276
|
+
fs.writeFileSync(ws.cfgPath, good, { mode: 0o600 });
|
|
260
277
|
}
|
|
261
278
|
|
|
262
279
|
// 6. turning the Codex target off stops the interceptor
|
|
263
|
-
await api(ws, gwPort, '/api/
|
|
264
|
-
await waitFor(async () => (await bfState()).state === 'ours');
|
|
265
|
-
await api(ws, gwPort, '/api/switch', { target: 'responses', profile: null });
|
|
280
|
+
await api(ws, gwPort, '/api/switch', { target: 'codex', profile: null });
|
|
266
281
|
assert.equal(await waitFor(async () => (await bfState()).state === 'free'), true);
|
|
267
282
|
} finally {
|
|
268
283
|
try { const s = await bfState(); if (s.state === 'ours') process.kill(s.pid, 'SIGTERM'); } catch {}
|
|
@@ -276,32 +291,50 @@ test('the gateway owns the interceptor: dashboard changes, a lost interceptor an
|
|
|
276
291
|
test('concurrent admin changes: a refused change never reaches disk, and no accepted change is lost', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
277
292
|
const ws = makeWorkspace({ certs: true });
|
|
278
293
|
const gwPort = await freePort();
|
|
279
|
-
|
|
280
|
-
writeConfig(ws, gwPort, bfPort, 'bf');
|
|
281
|
-
const launch = snapshotLaunchFiles(ws.dir);
|
|
282
|
-
const gw = await startGateway(ws, gwPort);
|
|
294
|
+
let bfPort;
|
|
283
295
|
// A squatter that accepts and never answers keeps the identity probe waiting.
|
|
284
296
|
const silent = net.createServer(() => {});
|
|
285
297
|
const silentPort = await new Promise(r => silent.listen(0, '127.0.0.1', () => r(silent.address().port)));
|
|
298
|
+
writeConfig(ws, gwPort, silentPort);
|
|
299
|
+
const launch = snapshotLaunchFiles(ws.dir);
|
|
300
|
+
const gw = await startGateway(ws, gwPort);
|
|
286
301
|
try {
|
|
287
|
-
|
|
288
|
-
const post = (body) => fetch(`http://127.0.0.1:${gwPort}/api/save-profile`, {
|
|
302
|
+
const post = (p, body) => fetch(`http://127.0.0.1:${gwPort}${p}`, {
|
|
289
303
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) }, body: JSON.stringify(body)
|
|
290
304
|
});
|
|
291
|
-
|
|
305
|
+
// Turning Codex on waits for the silent port and is refused; the rename that arrives meanwhile is kept.
|
|
306
|
+
const refused = post('/api/switch', { target: 'codex', profile: 'bf' });
|
|
292
307
|
await new Promise(r => setTimeout(r, 300));
|
|
293
|
-
const other = post({ key: 'plain', profile: { name: 'Renamed' } });
|
|
308
|
+
const other = post('/api/save-profile', { key: 'plain', profile: { name: 'Renamed' } });
|
|
294
309
|
const [a, b] = await Promise.all([refused, other]);
|
|
295
310
|
assert.equal(a.status, 502);
|
|
296
311
|
assert.equal(b.status, 200);
|
|
297
|
-
|
|
298
|
-
assert.equal(onDisk.
|
|
312
|
+
let onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
313
|
+
assert.equal(onDisk.activeProfiles.codex, null, 'the refused switch is not saved by the other request');
|
|
299
314
|
assert.equal(onDisk.profiles.plain.name, 'Renamed');
|
|
300
315
|
|
|
316
|
+
// A usable interceptor port, and Codex on, for the second half. Other test files run at the
|
|
317
|
+
// same time and can bind a port between freePort() and the gateway's check; the gateway then
|
|
318
|
+
// rightly refuses it, so a refusal for a taken port picks another one.
|
|
319
|
+
let on;
|
|
320
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
321
|
+
bfPort = await freePort();
|
|
322
|
+
onDisk.blindfold = { port: bfPort };
|
|
323
|
+
fs.writeFileSync(ws.cfgPath, JSON.stringify(onDisk, null, 2), { mode: 0o600 });
|
|
324
|
+
on = await post('/api/switch', { target: 'codex', profile: 'bf' });
|
|
325
|
+
if (on.status === 200) break;
|
|
326
|
+
const why = await on.clone().text();
|
|
327
|
+
if (!/held by another process|does not answer/.test(why)) break;
|
|
328
|
+
onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
329
|
+
}
|
|
330
|
+
assert.equal(on.status, 200, await on.text());
|
|
331
|
+
await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours');
|
|
332
|
+
const postProfile = (body) => post('/api/save-profile', body);
|
|
333
|
+
|
|
301
334
|
// Two accepted changes to the active profile: the one that saves last keeps the other.
|
|
302
335
|
const [c, d] = await Promise.all([
|
|
303
|
-
|
|
304
|
-
|
|
336
|
+
postProfile({ key: 'bf', profile: { name: 'First' } }),
|
|
337
|
+
postProfile({ key: 'bf', profile: { defaultModels: { main: 'm2' } } })
|
|
305
338
|
]);
|
|
306
339
|
assert.equal(c.status, 200);
|
|
307
340
|
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
|
},
|
package/tests/shim.test.mjs
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
160
|
-
|
|
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');
|
package/tests/state.test.mjs
CHANGED
|
@@ -653,13 +653,18 @@ test('isSafeModelName refuses TOML date-times with T and Z', () => {
|
|
|
653
653
|
test('an empty launch lock is held, not taken over', async (t) => {
|
|
654
654
|
const { dir, env } = tmpDirs(t);
|
|
655
655
|
const lock = path.join(dir, '.launch.lock');
|
|
656
|
+
const started = path.join(dir, 'writer.started');
|
|
656
657
|
fs.writeFileSync(lock, '');
|
|
657
|
-
|
|
658
|
-
const waited = await new Promise((resolve, reject) => {
|
|
658
|
+
const done = new Promise((resolve, reject) => {
|
|
659
659
|
execFileCb(process.execPath, ['--input-type=module', '-e',
|
|
660
|
-
`const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
660
|
+
`const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); (await import('node:fs')).writeFileSync(${JSON.stringify(started)}, ''); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
661
661
|
{ env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
|
|
662
662
|
});
|
|
663
|
+
// The 400 ms count from the writer's start: a slow start under a loaded suite must not find the
|
|
664
|
+
// lock already gone.
|
|
665
|
+
for (let i = 0; i < 1000 && !fs.existsSync(started); i++) await new Promise(r => setTimeout(r, 10));
|
|
666
|
+
setTimeout(() => fs.rmSync(lock, { force: true }), 400);
|
|
667
|
+
const waited = await done;
|
|
663
668
|
// Taken over at once, the write would take a few ms.
|
|
664
669
|
assert.ok(waited >= 100 && waited < 4000, `the writer waited ${waited} ms`);
|
|
665
670
|
assert.equal(fs.existsSync(lock), false);
|