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 +5 -0
- package/README.md +5 -4
- package/README.vi.md +5 -4
- package/package.json +1 -1
- package/proxy.mjs +4 -5
- package/switch.mjs +1 -1
- package/tests/dashboard-fixture.mjs +2 -0
- package/tests/dashboard.e2e.test.mjs +16 -0
- package/tests/gateway.e2e.test.mjs +8 -5
- package/tests/shim.test.mjs +40 -6
- package/tests/switch.test.mjs +21 -0
- package/tests/update.e2e.test.mjs +5 -2
- package/ui.html +8 -5
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
|
|
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
|
|
349
|
-
|
|
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
|
|
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
|
|
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
|
|
349
|
-
|
|
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.
|
|
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
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
|
-
//
|
|
1304
|
-
//
|
|
1305
|
-
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
test('Dashboard: /ui
|
|
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
|
|
687
|
-
assert.
|
|
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);
|
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`);
|
|
@@ -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'),
|
|
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
|
});
|
package/tests/switch.test.mjs
CHANGED
|
@@ -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.
|
|
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)');
|
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');
|