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.
- package/.github/workflows/test.yml +31 -0
- package/CHANGELOG.md +14 -0
- package/README.md +22 -3
- package/README.vi.md +21 -3
- package/agy-relay-lib.mjs +86 -0
- package/agy-relay.mjs +142 -0
- package/blindfold/make-certs.sh +2 -1
- package/package.json +1 -1
- package/proxy.mjs +100 -3
- package/shim.mjs +24 -2
- package/state.mjs +32 -0
- package/switch.mjs +10 -3
- package/tests/agy-relay.test.mjs +316 -0
- package/tests/agy.e2e.test.mjs +124 -7
- package/tests/agy.unit.test.mjs +3 -0
- package/tests/cdp.mjs +31 -6
- package/tests/dashboard.e2e.test.mjs +6 -2
- package/tests/gateway-secret.test.mjs +124 -0
- package/tests/gateway.e2e.test.mjs +2 -0
- package/tests/make-certs.test.mjs +14 -0
- package/tests/shim.test.mjs +5 -2
- package/tests/switch.test.mjs +19 -0
- package/tests/update.e2e.test.mjs +8 -1
- package/tests/ws-client.mjs +46 -0
|
@@ -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
|
+
});
|
package/tests/shim.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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=\[\]/);
|
package/tests/switch.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|