llm-switcher 1.3.2 → 1.3.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 +12 -0
- package/README.md +15 -5
- package/README.vi.md +14 -5
- package/agy-relay-lib.mjs +86 -0
- package/agy-relay.mjs +142 -0
- package/package.json +1 -1
- package/proxy.mjs +10 -7
- package/shim.mjs +24 -2
- package/state.mjs +32 -0
- package/switch.mjs +11 -4
- package/tests/agy-relay.test.mjs +316 -0
- package/tests/agy.e2e.test.mjs +14 -0
- package/tests/agy.unit.test.mjs +3 -0
- package/tests/cdp.mjs +12 -3
- package/tests/dashboard-fixture.mjs +2 -0
- package/tests/dashboard.e2e.test.mjs +16 -0
- package/tests/gateway-secret.test.mjs +124 -0
- package/tests/gateway.e2e.test.mjs +8 -5
- package/tests/shim.test.mjs +45 -8
- package/tests/switch.test.mjs +40 -0
- package/tests/update.e2e.test.mjs +7 -3
- package/ui.html +8 -5
package/tests/shim.test.mjs
CHANGED
|
@@ -476,6 +476,21 @@ const AGY_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'agyconfig-'));
|
|
|
476
476
|
const AGY_CONFIG = path.join(AGY_CONFIG_DIR, 'config.json');
|
|
477
477
|
test.after(() => fs.rmSync(AGY_CONFIG_DIR, { recursive: true, force: true }));
|
|
478
478
|
fs.writeFileSync(path.join(path.dirname(AGY_CONFIG), 'admin.token'), AGY_TOKEN);
|
|
479
|
+
// The relay key of this test install: the relay of a routed agy checks every connection with it.
|
|
480
|
+
const AGY_SECRET = 'd'.repeat(64);
|
|
481
|
+
fs.writeFileSync(path.join(path.dirname(AGY_CONFIG), 'gateway.secret'), AGY_SECRET);
|
|
482
|
+
// Stands in for agy: sends one request with its Google token to CLOUD_CODE_URL, when it has one.
|
|
483
|
+
const AGY_REQUEST = path.join(AGY_CONFIG_DIR, 'agy-request.mjs');
|
|
484
|
+
fs.writeFileSync(AGY_REQUEST, `
|
|
485
|
+
import http from 'node:http';
|
|
486
|
+
const url = process.env.CLOUD_CODE_URL;
|
|
487
|
+
if (url) await new Promise((resolve) => {
|
|
488
|
+
const req = http.request(url + '/v1internal:loadCodeAssist', { method: 'POST', agent: false, headers: { authorization: 'Bearer google-secret' } },
|
|
489
|
+
(res) => { res.resume(); res.on('end', () => { console.log('STATUS ' + res.statusCode); resolve(); }); });
|
|
490
|
+
req.on('error', (e) => { console.log('ERROR ' + e.message); resolve(); });
|
|
491
|
+
req.end('{}');
|
|
492
|
+
});
|
|
493
|
+
`);
|
|
479
494
|
const VERIFY = path.join(ROOT, 'verify-gateway.mjs');
|
|
480
495
|
// Async on purpose: the /health listener lives in this process, and a spawnSync would block it.
|
|
481
496
|
const { spawn: spawnAsync } = await import('node:child_process');
|
|
@@ -491,19 +506,31 @@ function runAsync(cmd, args, opts) {
|
|
|
491
506
|
}
|
|
492
507
|
const NOTICE = /did not prove it is this switcher/;
|
|
493
508
|
|
|
494
|
-
// A
|
|
509
|
+
// A gateway listener: 'real' signs like this gateway (both proofs), 'forged' sends wrong proofs,
|
|
510
|
+
// 'silent' never answers. `server.seen` holds the Authorization of every request that is not a probe.
|
|
495
511
|
async function agyListener(kind) {
|
|
496
512
|
const http = await import('node:http');
|
|
497
513
|
const net = await import('node:net');
|
|
514
|
+
const crypto = await import('node:crypto');
|
|
515
|
+
const seen = [];
|
|
498
516
|
const server = kind === 'silent'
|
|
499
517
|
? net.createServer((sock) => sock.on('error', () => {}))
|
|
500
518
|
: http.createServer((req, res) => {
|
|
501
519
|
const port = server.address().port;
|
|
502
|
-
const
|
|
503
|
-
const proof = kind === 'real' ? identityProof(nonce, { role: 'gateway', port, pid: 4242 }, AGY_TOKEN) : 'forged';
|
|
520
|
+
const u = new URL(req.url, 'http://x');
|
|
504
521
|
res.setHeader('content-type', 'application/json');
|
|
505
|
-
|
|
522
|
+
if (u.pathname !== '/health') {
|
|
523
|
+
seen.push(req.headers.authorization || '');
|
|
524
|
+
req.resume();
|
|
525
|
+
return res.end('{"answeredBy":"gateway"}');
|
|
526
|
+
}
|
|
527
|
+
const nonce = u.searchParams.get('challenge');
|
|
528
|
+
const real = kind === 'real';
|
|
529
|
+
const proof = real ? identityProof(nonce, { role: 'gateway', port, pid: 4242 }, AGY_TOKEN) : 'forged';
|
|
530
|
+
const relayProof = real ? crypto.createHmac('sha256', AGY_SECRET).update(['relay', port, 4242, nonce].join('|')).digest('hex') : 'forged';
|
|
531
|
+
res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port, pid: 4242, proof, relayProof }));
|
|
506
532
|
});
|
|
533
|
+
server.seen = seen;
|
|
507
534
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
508
535
|
return server;
|
|
509
536
|
}
|
|
@@ -511,7 +538,7 @@ async function agyListener(kind) {
|
|
|
511
538
|
async function runAgyWindowsShim(port) {
|
|
512
539
|
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyshim-'));
|
|
513
540
|
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyfake-'));
|
|
514
|
-
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'),
|
|
541
|
+
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), `@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\nnode "${AGY_REQUEST}"\r\n`);
|
|
515
542
|
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
516
543
|
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
517
544
|
fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
|
|
@@ -534,11 +561,17 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
534
561
|
const port = server.address().port;
|
|
535
562
|
const { out, err, ms } = await runAgyWindowsShim(port);
|
|
536
563
|
if (routed) {
|
|
537
|
-
|
|
564
|
+
// agy gets the port of its own relay, which proves the gateway on every connection.
|
|
565
|
+
const relayPort = Number((out.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
|
|
566
|
+
assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out}`);
|
|
567
|
+
// The request really crossed the relay and reached this gateway, token included.
|
|
568
|
+
assert.match(out, /STATUS 200/, out);
|
|
569
|
+
assert.deepEqual(server.seen, ['Bearer google-secret']);
|
|
538
570
|
assert.doesNotMatch(err, NOTICE);
|
|
539
571
|
} else {
|
|
540
572
|
assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
|
|
541
573
|
assert.match(err, NOTICE);
|
|
574
|
+
assert.deepEqual(server.seen ?? [], [], 'the listener never received the token');
|
|
542
575
|
}
|
|
543
576
|
assert.ok(ms < 8000, `the shim took ${ms} ms`);
|
|
544
577
|
});
|
|
@@ -551,7 +584,7 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
551
584
|
t.after(() => server.close());
|
|
552
585
|
const port = server.address().port;
|
|
553
586
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyposix-'));
|
|
554
|
-
fs.writeFileSync(path.join(dir, 'agy'),
|
|
587
|
+
fs.writeFileSync(path.join(dir, 'agy'), `#!/usr/bin/env bash\necho "CCU=[\${CLOUD_CODE_URL:-}]"\nnode "${AGY_REQUEST}"\n`);
|
|
555
588
|
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
556
589
|
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
557
590
|
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
@@ -562,11 +595,15 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
562
595
|
});
|
|
563
596
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
564
597
|
if (routed) {
|
|
565
|
-
|
|
598
|
+
const relayPort = Number((out.stdout.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
|
|
599
|
+
assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out.stdout}`);
|
|
600
|
+
assert.match(out.stdout, /STATUS 200/, out.stdout);
|
|
601
|
+
assert.deepEqual(server.seen, ['Bearer google-secret']);
|
|
566
602
|
assert.doesNotMatch(out.stderr, NOTICE);
|
|
567
603
|
} else {
|
|
568
604
|
assert.match(out.stdout, /CCU=\[\]/);
|
|
569
605
|
assert.match(out.stderr, NOTICE);
|
|
606
|
+
assert.deepEqual(server.seen ?? [], [], 'the listener never received the token');
|
|
570
607
|
}
|
|
571
608
|
assert.ok(Date.now() - started < 8000, `the shim took ${Date.now() - started} ms`);
|
|
572
609
|
});
|
package/tests/switch.test.mjs
CHANGED
|
@@ -621,3 +621,43 @@ 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
|
+
});
|
|
643
|
+
|
|
644
|
+
// The same rule for `switch port`: the unit of another instance keeps its port and is not restarted.
|
|
645
|
+
test('switch port leaves the installed service of another port unchanged', { skip: process.platform !== 'linux' && 'a systemd fake' }, async (t) => {
|
|
646
|
+
const ws = await workspace(t, await freePort());
|
|
647
|
+
const unitDir = path.join(ws.home, '.config', 'systemd', 'user');
|
|
648
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
649
|
+
const unit = path.join(unitDir, 'llm-switcher.service');
|
|
650
|
+
const original = '[Service]\nExecStart=/usr/bin/node /opt/llm-switcher/proxy.mjs --port 3456\n';
|
|
651
|
+
fs.writeFileSync(unit, original);
|
|
652
|
+
const bin = path.join(ws.dir, 'fakebin');
|
|
653
|
+
fs.mkdirSync(bin);
|
|
654
|
+
const calls = path.join(ws.dir, 'systemctl.log');
|
|
655
|
+
fs.writeFileSync(path.join(bin, 'systemctl'), `#!/bin/sh\necho "$@" >> "${calls}"\nexit 0\n`, { mode: 0o755 });
|
|
656
|
+
const env = { PATH: `${bin}${path.delimiter}${process.env.PATH}` };
|
|
657
|
+
assert.equal((await run(ws, ['on', 'plain'], { env })).status, 0);
|
|
658
|
+
const r = await run(ws, ['port', String(await freePort())], { env });
|
|
659
|
+
assert.equal(r.status, 0, r.out);
|
|
660
|
+
assert.equal(fs.readFileSync(unit, 'utf8'), original, 'the unit of port 3456 was rewritten');
|
|
661
|
+
const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
|
|
662
|
+
assert.doesNotMatch(log, /\b(stop|restart|start|enable)\b/, `systemctl was told to act on the other instance:\n${log}`);
|
|
663
|
+
});
|
|
@@ -115,6 +115,8 @@ async function fixture(t, { registryUrl = 'http://127.0.0.1:9/' } = {}) {
|
|
|
115
115
|
c.on('close', code => resolve({ code, out }));
|
|
116
116
|
}),
|
|
117
117
|
dashboardUrl: `http://127.0.0.1:${port}/ui`,
|
|
118
|
+
// How `switch ui` opens it: the token comes in the fragment, never in the page.
|
|
119
|
+
tokenUrl: () => `http://127.0.0.1:${port}/ui#token=${fs.readFileSync(path.join(data, 'admin.token'), 'utf8').trim()}`,
|
|
118
120
|
api: (p, opts = {}) => fetch(`http://127.0.0.1:${port}${p}`, {
|
|
119
121
|
...opts,
|
|
120
122
|
headers: { 'x-llm-switcher-token': fs.readFileSync(path.join(data, 'admin.token'), 'utf8').trim(), 'Content-Type': 'application/json' }
|
|
@@ -189,14 +191,16 @@ test('the dashboard Update now button installs the release and reloads on the ne
|
|
|
189
191
|
const browser = await launchBrowser();
|
|
190
192
|
t.after(() => browser.close());
|
|
191
193
|
const page = await browser.newPage();
|
|
192
|
-
await page.goto(fx.
|
|
194
|
+
await page.goto(fx.tokenUrl());
|
|
193
195
|
await page.waitFor(`!document.getElementById('update-notice').hidden`, 'the update notice');
|
|
194
196
|
await page.click('#update-now');
|
|
195
197
|
try {
|
|
196
|
-
|
|
198
|
+
// Longer than the 30 s the page polls for the new gateway, so a failure shows the page's own verdict.
|
|
199
|
+
await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 40000);
|
|
197
200
|
} catch (err) {
|
|
198
201
|
// A CI runner cannot be watched: put what the gateway and the page saw into the failure.
|
|
199
|
-
|
|
202
|
+
const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
|
|
203
|
+
err.message += `\nupdate status on the page: ${stage}\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
|
|
200
204
|
throw err;
|
|
201
205
|
}
|
|
202
206
|
assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
|
package/ui.html
CHANGED
|
@@ -1247,10 +1247,12 @@
|
|
|
1247
1247
|
<script>
|
|
1248
1248
|
// State & Globals
|
|
1249
1249
|
const ADMIN_TOKEN_KEY = 'llm-switcher-admin-token';
|
|
1250
|
-
|
|
1250
|
+
// The token comes only in the #token of the private launcher that `switch ui` opens, and stays in
|
|
1251
|
+
// this tab. The page itself never carries it: any process on the machine can load /ui.
|
|
1252
|
+
const NO_TOKEN_HINT = 'This tab has no access token. Open the dashboard with `switch ui`.';
|
|
1251
1253
|
const hashTokenMatch = location.hash.match(/(?:^#|&)token=([0-9a-f]+)/);
|
|
1252
|
-
if (
|
|
1253
|
-
sessionStorage.setItem(ADMIN_TOKEN_KEY,
|
|
1254
|
+
if (hashTokenMatch) {
|
|
1255
|
+
sessionStorage.setItem(ADMIN_TOKEN_KEY, hashTokenMatch[1]);
|
|
1254
1256
|
}
|
|
1255
1257
|
if (hashTokenMatch) {
|
|
1256
1258
|
history.replaceState(null, '', location.pathname + location.search + '#/routes');
|
|
@@ -1278,13 +1280,13 @@
|
|
|
1278
1280
|
|
|
1279
1281
|
// API Helper
|
|
1280
1282
|
async function api(path, opts = {}) {
|
|
1281
|
-
const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) ||
|
|
1283
|
+
const token = sessionStorage.getItem(ADMIN_TOKEN_KEY) || '';
|
|
1282
1284
|
const headers = {
|
|
1283
1285
|
...opts.headers,
|
|
1284
1286
|
'x-llm-switcher-token': token
|
|
1285
1287
|
};
|
|
1286
1288
|
const res = await fetch(path, { ...opts, headers });
|
|
1287
|
-
if (res.status === 401) showToast(
|
|
1289
|
+
if (res.status === 401) showToast(NO_TOKEN_HINT, true);
|
|
1288
1290
|
if (res.status === 409) setTimeout(loadStatus, 0);
|
|
1289
1291
|
return res;
|
|
1290
1292
|
}
|
|
@@ -2792,6 +2794,7 @@
|
|
|
2792
2794
|
}
|
|
2793
2795
|
|
|
2794
2796
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2797
|
+
if (!sessionStorage.getItem(ADMIN_TOKEN_KEY)) showToast(NO_TOKEN_HINT, true);
|
|
2795
2798
|
// Setup initial theme buttons state
|
|
2796
2799
|
const currentTheme = document.documentElement.dataset.theme || 'dark';
|
|
2797
2800
|
document.getElementById('btn-theme-dark')?.setAttribute('aria-pressed', currentTheme === 'dark');
|