llm-switcher 1.3.1 → 1.3.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.
@@ -0,0 +1,124 @@
1
+ // The key of the relay proof: random, independent of admin.token, served by no route, and the only
2
+ // key the relay accepts. Each case runs state.mjs in a child process, so it reads its own directory.
3
+ import { test, after } 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
+ import crypto from 'node:crypto';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const STATE = pathToFileURL(path.join(ROOT, 'state.mjs')).href;
14
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gwsecret-'));
15
+ after(() => fs.rmSync(TMP, { recursive: true, force: true }));
16
+ const TOKEN = 'c'.repeat(64);
17
+
18
+ function dir(name) {
19
+ const d = path.join(TMP, name);
20
+ fs.mkdirSync(d, { recursive: true });
21
+ fs.writeFileSync(path.join(d, 'admin.token'), TOKEN);
22
+ return d;
23
+ }
24
+ // Runs `code` (an async function body that gets `s`, the state module) against directory `d`.
25
+ function inState(d, code) {
26
+ const r = spawnSync(process.execPath, ['--input-type=module', '-e', `const s = await import(${JSON.stringify(STATE)}); const out = await (async () => { ${code} })(); process.stdout.write(JSON.stringify(out ?? null));`], {
27
+ env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(d, 'config.json'), LLM_SWITCHER_STATE_DIR: d }, encoding: 'utf8'
28
+ });
29
+ if (r.status !== 0) throw new Error(r.stderr);
30
+ return JSON.parse(r.stdout);
31
+ }
32
+ const secretOf = (d) => fs.readFileSync(path.join(d, 'gateway.secret'), 'utf8');
33
+
34
+ test('ensureGatewaySecret replaces a missing, empty or blank file and keeps a good one', () => {
35
+ const d = dir('ensure');
36
+ inState(d, 's.ensureGatewaySecret()');
37
+ const first = secretOf(d);
38
+ assert.match(first, /^[0-9a-f]{64}$/, 'hex only, no trailing newline');
39
+ inState(d, 's.ensureGatewaySecret()');
40
+ assert.equal(secretOf(d), first, 'an existing secret is kept');
41
+ for (const blank of ['', ' \n\t']) {
42
+ fs.writeFileSync(path.join(d, 'gateway.secret'), blank);
43
+ inState(d, 's.ensureGatewaySecret()');
44
+ assert.match(secretOf(d), /^[0-9a-f]{64}$/, `a file of ${JSON.stringify(blank)} is replaced`);
45
+ }
46
+ assert.deepEqual(fs.readdirSync(d).filter(f => f.endsWith('.tmp')), [], 'no temporary file is left');
47
+ });
48
+
49
+ test('the secret is random: never derived from admin.token or from the directory', () => {
50
+ const a = dir('indep-a');
51
+ const b = dir('indep-b');
52
+ inState(a, 's.ensureGatewaySecret()');
53
+ inState(b, 's.ensureGatewaySecret()');
54
+ const first = secretOf(a);
55
+ assert.notEqual(first, secretOf(b), 'two directories with the same admin.token');
56
+ assert.notEqual(first, TOKEN);
57
+ fs.rmSync(path.join(a, 'gateway.secret'));
58
+ inState(a, 's.ensureGatewaySecret()');
59
+ assert.notEqual(secretOf(a), first, 'the same directory and token again');
60
+ });
61
+
62
+ test('relayProof is an HMAC keyed by the raw bytes of gateway.secret', () => {
63
+ const d = dir('binding');
64
+ const raw = 'f'.repeat(64);
65
+ fs.writeFileSync(path.join(d, 'gateway.secret'), raw);
66
+ const out = inState(d, `return { secret: s.readGatewaySecret(), proof: s.relayProof('n1', { port: 3456, pid: 77 }) };`);
67
+ assert.equal(out.secret, raw);
68
+ assert.equal(out.proof, crypto.createHmac('sha256', raw).update('relay|3456|77|n1').digest('hex'));
69
+ });
70
+
71
+ test('without gateway.secret there is no proof at all, and no fallback to admin.token', () => {
72
+ const d = dir('nofallback');
73
+ const out = inState(d, `return { secret: s.readGatewaySecret(), proof: s.relayProof('n1', { port: 3456, pid: 77 }) };`);
74
+ assert.deepEqual(out, { secret: null, proof: '' });
75
+ });
76
+
77
+ test('ensureGatewaySecret throws when it cannot write, and leaves no temporary file', () => {
78
+ const d = dir('throws');
79
+ fs.mkdirSync(path.join(d, 'gateway.secret'));
80
+ assert.throws(() => inState(d, 's.ensureGatewaySecret()'));
81
+ assert.deepEqual(fs.readdirSync(d).filter(f => f.endsWith('.tmp')), []);
82
+ });
83
+
84
+ test('the blindfold proof keeps its exact formula', async () => {
85
+ const s = await import(STATE);
86
+ const proof = s.identityProof('n2', { role: 'blindfold', port: 3457, pid: 9, gatewayPort: 3456, activeTools: 'codex' }, TOKEN);
87
+ assert.equal(proof, crypto.createHmac('sha256', TOKEN).update('blindfold|3457|9|3456|codex|n2').digest('hex'));
88
+ });
89
+
90
+ test('only state.mjs touches gateway.secret: no other module of the package can read or serve it', () => {
91
+ const files = [...fs.readdirSync(ROOT).filter(f => f.endsWith('.mjs')).map(f => path.join(ROOT, f)),
92
+ ...fs.readdirSync(path.join(ROOT, 'blindfold')).filter(f => f.endsWith('.mjs')).map(f => path.join(ROOT, 'blindfold', f))];
93
+ assert.ok(files.length > 10, 'the scan found the package modules');
94
+ for (const f of files) {
95
+ if (path.basename(f) === 'state.mjs') continue;
96
+ const src = fs.readFileSync(f, 'utf8');
97
+ for (const name of ['gateway.secret', 'readGatewaySecret', 'gatewaySecretPath']) {
98
+ assert.equal(src.includes(name), false, `${path.relative(ROOT, f)} mentions ${name}`);
99
+ }
100
+ }
101
+ const proxy = fs.readFileSync(path.join(ROOT, 'proxy.mjs'), 'utf8');
102
+ assert.match(proxy, /ensureGatewaySecret\(\);/, 'the gateway makes its secret at startup and keeps no copy');
103
+ assert.match(proxy, /relayProof\(/);
104
+ });
105
+
106
+ test('gateway.secret stays out of git and out of the npm package', () => {
107
+ assert.ok(fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8').split(/\r?\n/).includes('gateway.secret'));
108
+ });
109
+
110
+ test('the .cmd command line: cmd.exe switches, quoting and the 8191 limit', async () => {
111
+ const { batCommandLine, CMD_LIMIT } = await import(pathToFileURL(path.join(ROOT, 'agy-relay-lib.mjs')).href);
112
+ const ok = batCommandLine('C:\\a\\agy.cmd', ['x']);
113
+ assert.deepEqual(ok.args.slice(0, 4), ['/e:ON', '/v:OFF', '/d', '/c']);
114
+ assert.equal(batCommandLine('C:\\a\\agy.cmd', ['50%']).args[4].includes('50%%cd:~,%'), true);
115
+ assert.equal(CMD_LIMIT, 8191);
116
+ // The /c string is `"` + `"C:\a\agy.cmd"` + ` ` + the argument + `"`: 17 code units around it.
117
+ const room = CMD_LIMIT - '""C:\\a\\agy.cmd" "'.length;
118
+ assert.equal(batCommandLine('C:\\a\\agy.cmd', ['z'.repeat(room)]).args[4].length, CMD_LIMIT);
119
+ assert.ok(batCommandLine('C:\\a\\agy.cmd', ['z'.repeat(room + 1)]).error);
120
+ // Counted in UTF-16 code units, not bytes: é is one unit and two UTF-8 bytes.
121
+ assert.ok(batCommandLine('C:\\a\\agy.cmd', ['é'.repeat(room - 2)]).args, 'non-ASCII is not refused early');
122
+ assert.ok(batCommandLine('C:\\a\\agy".cmd', []).error);
123
+ assert.ok(batCommandLine('C:\\a\\', []).error);
124
+ });
@@ -1149,6 +1149,8 @@ test('Direct passthrough waits for a slow client instead of buffering the whole
1149
1149
  req.on('error', reject);
1150
1150
  req.end(JSON.stringify({ model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'DIRECT_BIG' }] }));
1151
1151
  });
1152
+ // The end callback of the mock can run after the client's 'end' under load: 0 means not recorded yet.
1153
+ assert.ok(await until(() => bigState.finishedAt, 2000), 'the upstream never finished');
1152
1154
  assert.ok(bigState.finishedAt >= resumedAt, `upstream finished ${resumedAt - bigState.finishedAt} ms before the client read anything`);
1153
1155
  });
1154
1156
 
@@ -20,3 +20,17 @@ test('make-certs.sh builds the certificates under a path that contains a dot', {
20
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
21
  assert.deepEqual(fs.readdirSync(dir), ['first.last'], 'no serial file outside the output directory');
22
22
  });
23
+
24
+ // A Windows path has no forward slash. The script took `C:\...\certs` for the host of the old
25
+ // two-argument form, ignored it, and rebuilt the default certificates of the checkout instead.
26
+ test('make-certs.sh writes to a Windows path given as its only argument', { skip: process.platform !== 'win32' && 'a Windows path' }, (t) => {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmswcerts-win-'));
28
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
29
+ const out = path.join(dir, 'certs');
30
+ // Where the script falls back to: a decoy, so a failing run never touches the real certificates.
31
+ const decoy = path.join(dir, 'decoy');
32
+ execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), out], { stdio: 'pipe', env: { ...process.env, LLM_SWITCHER_BLINDFOLD_CERTS: decoy } });
33
+ assert.match(out, /^[A-Za-z]:\\/, 'the argument is a native Windows path');
34
+ for (const f of ['ca.pem', 'ca.key', 'leaf.pem', 'leaf.key']) assert.ok(fs.existsSync(path.join(out, f)), `${f} is missing in ${out}`);
35
+ assert.equal(fs.existsSync(decoy), false, 'nothing is written to the default directory');
36
+ });
@@ -534,7 +534,9 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
534
534
  const port = server.address().port;
535
535
  const { out, err, ms } = await runAgyWindowsShim(port);
536
536
  if (routed) {
537
- assert.match(out, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
537
+ // agy gets the port of its own relay, which proves the gateway on every connection.
538
+ const relayPort = Number((out.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
539
+ assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out}`);
538
540
  assert.doesNotMatch(err, NOTICE);
539
541
  } else {
540
542
  assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
@@ -562,7 +564,8 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
562
564
  });
563
565
  fs.rmSync(dir, { recursive: true, force: true });
564
566
  if (routed) {
565
- assert.match(out.stdout, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
567
+ const relayPort = Number((out.stdout.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
568
+ assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out.stdout}`);
566
569
  assert.doesNotMatch(out.stderr, NOTICE);
567
570
  } else {
568
571
  assert.match(out.stdout, /CCU=\[\]/);
@@ -621,3 +621,22 @@ test('R7e: saves work again after the file is fixed', async (t) => {
621
621
  assert.equal(fixed.status, 0, fixed.stdout + fixed.stderr);
622
622
  assert.ok(readCfg(ws).profiles.plain, 'the file writes again');
623
623
  });
624
+
625
+ // A second instance (LLM_SWITCHER_PORT) must never stop the service of the first one: the unit
626
+ // runs the gateway of another port. This test once stopped the real gateway of the machine.
627
+ test('switch off stops the installed service only when that service owns the port', { skip: process.platform !== 'linux' && 'a systemd fake' }, async (t) => {
628
+ const ws = await workspace(t, await freePort());
629
+ const unitDir = path.join(ws.home, '.config', 'systemd', 'user');
630
+ fs.mkdirSync(unitDir, { recursive: true });
631
+ fs.writeFileSync(path.join(unitDir, 'llm-switcher.service'), '[Service]\nExecStart=/usr/bin/node /opt/llm-switcher/proxy.mjs --port 3456\n');
632
+ // A systemctl that only records what it was told.
633
+ const bin = path.join(ws.dir, 'fakebin');
634
+ fs.mkdirSync(bin);
635
+ const calls = path.join(ws.dir, 'systemctl.log');
636
+ fs.writeFileSync(path.join(bin, 'systemctl'), `#!/bin/sh\necho "$@" >> "${calls}"\nexit 0\n`, { mode: 0o755 });
637
+ const env = { PATH: `${bin}${path.delimiter}${process.env.PATH}` };
638
+ assert.equal((await run(ws, ['on', 'plain'], { env })).status, 0);
639
+ assert.equal((await run(ws, ['off'], { env })).status, 0);
640
+ const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
641
+ assert.doesNotMatch(log, /\b(stop|restart)\b/, `systemctl was told to stop the instance on port 3456:\n${log}`);
642
+ });
@@ -192,6 +192,13 @@ test('the dashboard Update now button installs the release and reloads on the ne
192
192
  await page.goto(fx.dashboardUrl);
193
193
  await page.waitFor(`!document.getElementById('update-notice').hidden`, 'the update notice');
194
194
  await page.click('#update-now');
195
- await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 30000);
195
+ try {
196
+ await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 30000);
197
+ } catch (err) {
198
+ // A CI runner cannot be watched: put what the gateway and the page saw into the failure.
199
+ const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
200
+ err.message += `\nupdate status on the page: ${stage}\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
201
+ throw err;
202
+ }
196
203
  assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
197
204
  });
@@ -0,0 +1,46 @@
1
+ // A raw WebSocket client for the gateway tests. It sends the handshake headers Codex sends (a
2
+ // User-Agent, a profile header), which the WebSocket of Node cannot set.
3
+ import net from 'node:net';
4
+ import crypto from 'node:crypto';
5
+ import { createFrameReader } from '../blindfold/wsframe.mjs';
6
+
7
+ // One masked client frame, as RFC 6455 requires from a client.
8
+ export function clientFrame(opcode, payload) {
9
+ const data = Buffer.from(payload);
10
+ const len = data.length;
11
+ const head = len < 126 ? Buffer.from([0x80 | opcode, 0x80 | len])
12
+ : len < 65536 ? Buffer.from([0x80 | opcode, 0x80 | 126, len >> 8, len & 0xff])
13
+ : Buffer.concat([Buffer.from([0x80 | opcode, 0x80 | 127]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(len)); return b; })()]);
14
+ const mask = crypto.randomBytes(4);
15
+ return Buffer.concat([head, mask, Buffer.from(data.map((b, i) => b ^ mask[i & 3]))]);
16
+ }
17
+
18
+ // Opens /v1/responses on the gateway. `messages` collects every JSON text frame the gateway sends.
19
+ export function openResponsesWs(port, headers = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ const socket = net.connect(port, '127.0.0.1');
22
+ const extra = Object.entries(headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
23
+ socket.write(`GET /v1/responses HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${crypto.randomBytes(16).toString('base64')}\r\n${extra}\r\n`);
24
+ let head = Buffer.alloc(0);
25
+ const read = createFrameReader();
26
+ const messages = [];
27
+ const onData = (chunk) => {
28
+ head = Buffer.concat([head, chunk]);
29
+ const end = head.indexOf('\r\n\r\n');
30
+ if (end < 0) return;
31
+ socket.off('data', onData);
32
+ const collect = (c) => { for (const f of read(c)) if (f.type === 'text') messages.push(JSON.parse(f.payload.toString())); };
33
+ socket.on('data', collect);
34
+ const rest = head.subarray(end + 4);
35
+ if (rest.length) collect(rest);
36
+ resolve({
37
+ reply: head.subarray(0, end).toString(),
38
+ messages,
39
+ send: (obj) => socket.write(clientFrame(1, JSON.stringify(obj))),
40
+ close: () => socket.destroy()
41
+ });
42
+ };
43
+ socket.on('data', onData);
44
+ socket.on('error', reject);
45
+ });
46
+ }