llm-switcher 1.3.3 → 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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.3.4
4
+
5
+ - **The dashboard page carries no token:** `/ui` sent `admin.token` in the page, and any program on the machine can load `/ui`. The page now gets the token only from the `#token=` of the private launcher that `switch ui` opens (a file with mode 0600). The tab keeps it in `sessionStorage`, so it does not stay in the browser after the tab closes. A tab opened at `/ui` directly says to use `switch ui`. `switch status` and the gateway start line say the same.
6
+ - **Tests:** The agy shim tests start a real relay and send a real request through it. The request must reach the gateway with the Google token, and a gateway without a valid proof must get nothing. The Update now test waits longer than the page polls, so a failure shows what the page saw.
7
+
3
8
  ## Release 1.3.3
4
9
 
5
10
  - **agy runs behind a relay:** A routed agy no longer gets the gateway port. The shim starts it behind `agy-relay.mjs`, which holds its own loopback port for as long as agy runs. For each connection, the relay asks the gateway for its identity proof on the same socket and sends agy's request only after the proof holds. Before, the shim checked the gateway only at launch, so a gateway that stopped during an agy session left its port free for another program to take, with agy's Google token sent to it. If the proof fails now, agy gets a 502 and the token stays in the relay.
package/README.md CHANGED
@@ -211,7 +211,8 @@ node "$(npm root -g)/llm-switcher/proxy.mjs"
211
211
 
212
212
  `switch <command>` works the same on Linux, macOS and Windows. A checkout has its own launchers: `switch` for Linux and macOS, `switch.cmd` for Windows. Platform differences, and the two features that are not available everywhere, are in [📖 `docs/cross-platform.md`](docs/cross-platform.md).
213
213
 
214
- Open the Web Dashboard at: **[http://127.0.0.1:3456/ui](http://127.0.0.1:3456/ui)**
214
+ Open the Web Dashboard with `switch ui`. It opens `http://127.0.0.1:3456/ui` with the access token of
215
+ this install, and the browser tab keeps the token while it stays open.
215
216
 
216
217
  ---
217
218
 
@@ -345,8 +346,8 @@ socket, and sends the request on that socket only after the proof holds. If the
345
346
  gets a 502, and its token stays in the relay.
346
347
 
347
348
  The relay accepts only a proof keyed by `gateway.secret`, a random key next to `admin.token`. No
348
- page and no route of the gateway serves this key. The dashboard page carries `admin.token`, so a
349
- proof keyed by `admin.token` could be made by any program that loads the page. If the running
349
+ page and no route of the gateway serves this key, and no program can derive it from `admin.token`.
350
+ If the running
350
351
  gateway is older than the relay, agy gets a 502 that says to restart it (`switch off`, then
351
352
  `switch on`).
352
353
 
@@ -782,7 +783,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
782
783
  ## Security Model
783
784
 
784
785
  - The gateway binds to `127.0.0.1` only and rejects requests whose `Host` is not a loopback name (DNS-rebinding protection) or whose `Origin` is not the dashboard itself (CSRF protection).
785
- - The admin API (`/api/*`) requires the `x-llm-switcher-token` header. The gateway creates the token in `admin.token`, next to `config.json`, with mode 0600. The gateway puts this token in the dashboard page, so `http://127.0.0.1:3456/ui` works when you open it directly. The Host and Origin checks keep the page and the token from other sites. The MCP server reads the file. `/v1/*` and `/health` need no token.
786
+ - The admin API (`/api/*`) requires the `x-llm-switcher-token` header. The gateway creates the token in `admin.token`, next to `config.json`, with mode 0600. The dashboard page does not carry this token, because any program on the computer can load the page. `switch ui` opens the dashboard through a private file (mode 0600) that gives the token to the browser tab in the URL fragment, and the page removes it from the address bar. A tab opened at `http://127.0.0.1:3456/ui` without this file shows a message that says to use `switch ui`. The MCP server reads the file. `/v1/*` and `/health` need no token.
786
787
  - API keys are never sent to the browser: `/api/status` returns redacted profiles and the dashboard keeps the stored key unless you type a new one. The stored key goes only to the stored `baseURL` and `endpoints` of that profile. A save that changes either one must carry the key again.
787
788
  - Every dashboard change carries the config revision that the page loaded. If another tab, the CLI or the MCP server saved in the meantime, the gateway answers 409 and the page reloads instead of overwriting that change.
788
789
  - Client credentials such as `x-api-key`, `authorization` or `x-goog-api-key` are **not** forwarded to upstreams. Other `x-*` headers, `traceparent` and `tracestate` pass through. The gateway drops its own control headers (`x-profile`, `x-llm-profile`) and the network identity headers (`x-forwarded-*`, `x-real-ip`).
package/README.vi.md CHANGED
@@ -213,7 +213,8 @@ node "$(npm root -g)/llm-switcher/proxy.mjs"
213
213
  Khác biệt giữa các nền tảng, và hai tính năng không chạy ở mọi nơi, nằm trong
214
214
  [📖 `docs/cross-platform.md`](docs/cross-platform.md).
215
215
 
216
- Mở Bảng điều khiển Web Dashboard tại: **[http://127.0.0.1:3456/ui](http://127.0.0.1:3456/ui)**
216
+ Mở Bảng điều khiển Web Dashboard bằng `switch ui`. Lệnh này mở `http://127.0.0.1:3456/ui` kèm token truy
217
+ cập của bản cài này, và tab trình duyệt giữ token trong lúc tab còn mở.
217
218
 
218
219
  ---
219
220
 
@@ -345,8 +346,8 @@ socket đó, và chỉ gửi request trên socket ấy khi bằng chứng đúng
345
346
  vẫn nằm lại trong relay.
346
347
 
347
348
  Relay chỉ chấp nhận bằng chứng ký bằng `gateway.secret`, một khóa ngẫu nhiên nằm cạnh `admin.token`.
348
- Không trang và không route nào của gateway trả về khóa này. Trang dashboard chứa `admin.token`, nên
349
- bằng chứng ký bằng `admin.token` thì chương trình nào tải trang cũng làm giả được. Nếu gateway đang
349
+ Không trang và không route nào của gateway trả về khóa này, và không chương trình nào suy ra được nó từ
350
+ `admin.token`. Nếu gateway đang
350
351
  chạy cũ hơn relay, agy nhận 502 kèm hướng dẫn khởi động lại (`switch off`, rồi `switch on`).
351
352
 
352
353
  ### Chế độ blindfold (tùy chọn)
@@ -776,7 +777,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
776
777
  ## Mô hình Bảo mật
777
778
 
778
779
  - Gateway chỉ lắng nghe `127.0.0.1` và từ chối request có `Host` không phải loopback (chống DNS rebinding) hoặc `Origin` không phải chính dashboard (chống CSRF).
779
- - Admin API (`/api/*`) bắt buộc header `x-llm-switcher-token`. Gateway tạo token trong file `admin.token`, cạnh `config.json`, với mode 0600. Gateway đặt token này vào trang dashboard, nên mở thẳng `http://127.0.0.1:3456/ui` là dùng được. Lớp kiểm tra Host và Origin ngăn trang web khác đọc trang và token. MCP server đọc token từ file. `/v1/*` và `/health` không cần token.
780
+ - Admin API (`/api/*`) bắt buộc header `x-llm-switcher-token`. Gateway tạo token trong file `admin.token`, cạnh `config.json`, với mode 0600. Trang dashboard không chứa token này, vì chương trình nào trên máy cũng tải được trang. `switch ui` mở dashboard qua một file riêng tư (mode 0600), file này đưa token cho tab trình duyệt qua phần fragment của URL, rồi trang xóa nó khỏi thanh địa chỉ. Tab mở thẳng `http://127.0.0.1:3456/ui` mà không qua file này sẽ hiện thông báo bảo dùng `switch ui`. MCP server đọc token từ file. `/v1/*` và `/health` không cần token.
780
781
  - API key không bao giờ gửi xuống trình duyệt: `/api/status` trả profile đã che key, dashboard giữ nguyên key đã lưu nếu bạn không nhập key mới. Key đã lưu chỉ được gửi tới `baseURL` và `endpoints` đã lưu của chính profile đó. Lần lưu nào đổi một trong hai thì phải nhập lại key.
781
782
  - Mỗi thay đổi từ dashboard mang theo revision của config mà trang đã tải. Nếu tab khác, CLI hoặc MCP server đã lưu trước đó, gateway trả 409 và trang tải lại thay vì ghi đè thay đổi kia.
782
783
  - Credential của client (`x-api-key`, `authorization`, `x-goog-api-key`) **không** được chuyển tiếp lên upstream. Các header `x-*` khác, `traceparent` và `tracestate` được chuyển tiếp. Gateway bỏ header điều khiển của chính nó (`x-profile`, `x-llm-profile`) và header định danh mạng (`x-forwarded-*`, `x-real-ip`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -1300,10 +1300,9 @@ async function route(req, res) {
1300
1300
  'X-Frame-Options': 'DENY',
1301
1301
  'X-Content-Type-Options': 'nosniff'
1302
1302
  });
1303
- // One gateway serves one user, so the page opened at /ui carries its own token. The Host and Origin
1304
- // guard above keeps it from other sites; the token is hex, so it needs no escaping.
1305
- const meta = `<meta name="llm-switcher-token" content="${ADMIN_TOKEN.toString()}">`;
1306
- return res.end(fs.readFileSync(uiHtmlPath, 'utf8').replace('<head>', `<head>\n ${meta}`));
1303
+ // No token in the page: any process on the machine can GET /ui. The dashboard gets it from the
1304
+ // #token of the private launcher that `switch ui` opens.
1305
+ return res.end(fs.readFileSync(uiHtmlPath, 'utf8'));
1307
1306
  }
1308
1307
  }
1309
1308
 
@@ -2266,7 +2265,7 @@ if (!handedOver) server.listen(PORT, '127.0.0.1', () => {
2266
2265
  serialized(() => reconcile(cfg));
2267
2266
  }
2268
2267
  console.log(`[llm-switcher] Server running on http://127.0.0.1:${PORT}`);
2269
- console.log(`[llm-switcher] Web UI available at: http://127.0.0.1:${PORT}/ui`);
2268
+ console.log(`[llm-switcher] Web UI available at: http://127.0.0.1:${PORT}/ui (open it with \`switch ui\`)`);
2270
2269
  console.log(`[llm-switcher] Endpoints: /v1/messages (Claude Code) | /v1/responses (Codex)`);
2271
2270
  // Asynchronously discover latest tool models in the background without blocking startup
2272
2271
  refreshCatalog(STATE_DIR).catch(() => {});
package/switch.mjs CHANGED
@@ -616,7 +616,7 @@ async function showStatus() {
616
616
  console.log('=== LLM Switcher Status ===');
617
617
  const held = { legacy: `OLD GATEWAY (< 1.1.1) ON PORT ${port}: stop it, then run \`switch on\``, foreign: `PORT ${port} HELD BY ANOTHER PROCESS`, silent: `PORT ${port} DOES NOT ANSWER (hung gateway or another program)` };
618
618
  console.log(`Proxy Service: ${isRunning ? `RUNNING (port ${port})` : held[gateway] || 'STOPPED'}`);
619
- console.log(`Web UI: http://127.0.0.1:${port}/ui`);
619
+ console.log(`Web UI: http://127.0.0.1:${port}/ui (open it with \`switch ui\`)`);
620
620
  console.log(`Launcher Flag: ${flagged ? 'active.flag present' : 'absent (launchers use official endpoints)'}`);
621
621
  if (flagged && !isRunning) {
622
622
  console.log(`[WARN] active.flag exists but proxy is STOPPED -> launched CLIs will fail to connect. Run 'switch on' or 'switch off'.`);
@@ -134,6 +134,8 @@ export async function startDashboardFixture() {
134
134
  upstream,
135
135
  upstreamBase,
136
136
  dashboardUrl: `http://127.0.0.1:${port}/ui`,
137
+ // How `switch ui` opens it: the token comes in the fragment, never in the page.
138
+ tokenUrl: () => `http://127.0.0.1:${port}/ui#token=${adminToken()}`,
137
139
  // What is on disk, which the dashboard has to agree with after every action.
138
140
  config: () => JSON.parse(fs.readFileSync(cfgPath, 'utf8')),
139
141
  // A change made outside the dashboard, as `switch` in a terminal or an editor would make it.
@@ -20,6 +20,22 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
20
20
  fx = await startDashboardFixture();
21
21
  browser = await launchBrowser();
22
22
  page = await browser.newPage();
23
+ // Once per tab, as the launcher of `switch ui` does; the tab keeps the token from then on.
24
+ await page.goto(fx.tokenUrl());
25
+ });
26
+
27
+ it('a tab opened without the launcher token says to open the dashboard with switch ui', async () => {
28
+ const fresh = await browser.newPage();
29
+ try {
30
+ await fresh.goto(fx.dashboardUrl);
31
+ await fresh.waitFor(`/switch ui/.test(document.getElementById('toast')?.textContent || '')`, 'the switch ui hint');
32
+ } finally {
33
+ // Close the tab itself, then raise the main one: a tab left in the background gets throttled
34
+ // timers and no animation frames, which stalls every later test.
35
+ await fresh.send('Page.close').catch(() => {});
36
+ fresh.close();
37
+ await page.send('Page.bringToFront');
38
+ }
23
39
  });
24
40
 
25
41
  after(async () => {
@@ -679,12 +679,15 @@ test('Security: foreign Host / Origin are rejected (DNS rebinding & CSRF)', asyn
679
679
 
680
680
  // Any local process can reach loopback. Without a token it must get nothing from /api/*,
681
681
  // and a masked key must never be resolved for a baseURL the profile does not have.
682
- // One gateway serves one user on one machine: the dashboard opened at /ui, with no link from `switch ui`,
683
- // carries its own token. A page of another site and a rebound Host still get nothing.
684
- test('Dashboard: /ui opened directly carries the admin token, other sites do not get it', async () => {
682
+ // Any process on the machine can GET /ui, another account's included, so the page carries no token.
683
+ // The dashboard gets it from the #token of the private launcher that `switch ui` opens.
684
+ test('Dashboard: /ui and / never carry the admin token, for this machine or another site', async () => {
685
685
  for (const p of ['/ui', '/']) {
686
- const html = await (await fetch(url(p))).text();
687
- assert.ok(html.includes(`<meta name="llm-switcher-token" content="${adminToken()}">`), `${p} has no token`);
686
+ const r = await fetch(url(p));
687
+ assert.equal(r.status, 200, p);
688
+ const html = await r.text();
689
+ assert.ok(html.includes('<title>'), `${p} serves the dashboard`);
690
+ assert.ok(!html.includes(adminToken()), `${p} carries the admin token`);
688
691
  }
689
692
  const foreign = await fetch(url('/ui'), { headers: { origin: 'https://evil.example' } });
690
693
  assert.equal(foreign.status, 403);
@@ -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`);
@@ -537,10 +564,14 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
537
564
  // agy gets the port of its own relay, which proves the gateway on every connection.
538
565
  const relayPort = Number((out.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
539
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']);
540
570
  assert.doesNotMatch(err, NOTICE);
541
571
  } else {
542
572
  assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
543
573
  assert.match(err, NOTICE);
574
+ assert.deepEqual(server.seen ?? [], [], 'the listener never received the token');
544
575
  }
545
576
  assert.ok(ms < 8000, `the shim took ${ms} ms`);
546
577
  });
@@ -553,7 +584,7 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
553
584
  t.after(() => server.close());
554
585
  const port = server.address().port;
555
586
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyposix-'));
556
- 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`);
557
588
  fs.chmodSync(path.join(dir, 'agy'), 0o755);
558
589
  for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
559
590
  fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
@@ -566,10 +597,13 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
566
597
  if (routed) {
567
598
  const relayPort = Number((out.stdout.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
568
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']);
569
602
  assert.doesNotMatch(out.stderr, NOTICE);
570
603
  } else {
571
604
  assert.match(out.stdout, /CCU=\[\]/);
572
605
  assert.match(out.stderr, NOTICE);
606
+ assert.deepEqual(server.seen ?? [], [], 'the listener never received the token');
573
607
  }
574
608
  assert.ok(Date.now() - started < 8000, `the shim took ${Date.now() - started} ms`);
575
609
  });
@@ -640,3 +640,24 @@ test('switch off stops the installed service only when that service owns the por
640
640
  const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
641
641
  assert.doesNotMatch(log, /\b(stop|restart)\b/, `systemctl was told to stop the instance on port 3456:\n${log}`);
642
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,11 +191,12 @@ 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
202
  const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
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');