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.
@@ -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 /health listener: 'real' signs like this gateway, 'forged' sends a wrong proof, 'silent' never answers.
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 nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
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
- res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port, pid: 4242, proof }));
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'), '@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\n');
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
- assert.match(out, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
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'), '#!/usr/bin/env bash\necho "CCU=[${CLOUD_CODE_URL:-}]"\n');
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
- assert.match(out.stdout, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
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
  });
@@ -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.dashboardUrl);
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
- await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 30000);
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
- err.message += `\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
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
- const servedToken = document.querySelector('meta[name="llm-switcher-token"]')?.content;
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 (servedToken || hashTokenMatch) {
1253
- sessionStorage.setItem(ADMIN_TOKEN_KEY, servedToken || hashTokenMatch[1]);
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) || document.querySelector('meta[name="llm-switcher-token"]')?.content || '';
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('Unauthorized: check admin.token in server directory', true);
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');