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 CHANGED
@@ -1,5 +1,17 @@
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
+
8
+ ## Release 1.3.3
9
+
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.
11
+ - **A relay key that no page serves:** The relay accepts only `relayProof`, an HMAC keyed by `gateway.secret`, a random file next to `admin.token`. The gateway makes it at startup and keeps no copy. The dashboard page carries `admin.token`, so a proof keyed by it could be made by any program that loads the page. A gateway older than the relay gets a 502 that says to restart it.
12
+ - **A `.cmd` agy gets its arguments unchanged:** The relay starts a `.cmd` or `.bat` agy through `%SystemRoot%\System32\cmd.exe /e:ON /v:OFF /d /c` with the quoting of the Rust standard library since CVE-2024-24576. `%` cannot expand a variable, and `&` or `|` cannot start a second command. A line break, or a command line over 8191 characters, stops the relay before agy starts, with a hint to pipe the prompt on stdin.
13
+ - **`switch off` and `switch port` leave another instance alone:** They stopped or rewrote the installed service even when it ran the gateway of another port. A second instance (`LLM_SWITCHER_PORT`) then stopped the first one. The service now counts only for the port its definition names. The test suite triggered the same fault and stopped the real gateway of the machine.
14
+
3
15
  ## Release 1.3.2
4
16
 
5
17
  - **Codex over WebSocket crosses Bifrost:** Codex uses its WebSocket transport by default. The gateway now sends each WebSocket turn to intact `/v1/responses` as one HTTP request with the whole conversation, and sends each event back as one frame. Before, these turns took the convert route. A stream that stops before `response.completed` ends the turn with `response.failed`.
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
 
@@ -337,9 +338,18 @@ gateway for its identity proof before it gives agy the variable. If the port doe
337
338
  the proof of this install within 3 seconds, the shim removes the variable, prints one line, and agy
338
339
  uses its official endpoint.
339
340
 
340
- The shim does this check only when agy starts. If the gateway stops while agy runs (`switch off`, a
341
- crash, an update), the port is free. Another account on this computer can then take the port and
342
- receive the token. To close this gap, close agy before you stop the gateway.
341
+ The gateway can also stop while agy runs (`switch off`, a crash, an update). Its port is then free,
342
+ and another program can take it. So a routed agy never talks to the gateway port directly. The shim
343
+ starts agy behind a relay (`agy-relay.mjs`) that holds its own loopback port for as long as agy
344
+ runs. For each connection from agy, the relay asks the gateway for its identity proof on the same
345
+ socket, and sends the request on that socket only after the proof holds. If the proof fails, agy
346
+ gets a 502, and its token stays in the relay.
347
+
348
+ The relay accepts only a proof keyed by `gateway.secret`, a random key next to `admin.token`. No
349
+ page and no route of the gateway serves this key, and no program can derive it from `admin.token`.
350
+ If the running
351
+ gateway is older than the relay, agy gets a 502 that says to restart it (`switch off`, then
352
+ `switch on`).
343
353
 
344
354
  ### Blindfold mode (optional)
345
355
 
@@ -773,7 +783,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
773
783
  ## Security Model
774
784
 
775
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).
776
- - 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.
777
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.
778
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.
779
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
 
@@ -337,9 +338,17 @@ agy gửi token này dạng rõ tới `CLOUD_CODE_URL`. Vì vậy shim `agy` h
337
338
  trước khi giao biến này cho agy. Nếu cổng không trả lời đúng bằng chứng của bản cài này trong 3 giây,
338
339
  shim gỡ biến, in một dòng thông báo, và agy dùng endpoint chính thức.
339
340
 
340
- Shim chỉ kiểm tra việc này lúc agy khởi động. Nếu gateway dừng trong lúc agy đang chạy (`switch off`,
341
- crash, cập nhật), cổng bị bỏ trống. Khi đó một tài khoản khác trên máy có thể chiếm cổng và nhận token.
342
- Để không có khoảng hở này, hãy đóng agy trước khi dừng gateway.
341
+ Gateway cũng có thể dừng trong lúc agy đang chạy (`switch off`, crash, cập nhật). Khi đó cổng của nó
342
+ bỏ trống, và một chương trình khác có thể chiếm cổng. Vì vậy agy đã route không bao giờ nói chuyện
343
+ trực tiếp với cổng gateway. Shim chạy agy sau một relay (`agy-relay.mjs`) giữ cổng loopback riêng
344
+ suốt thời gian agy chạy. Với mỗi kết nối của agy, relay hỏi gateway bằng chứng danh tính trên chính
345
+ socket đó, và chỉ gửi request trên socket ấy khi bằng chứng đúng. Nếu sai, agy nhận 502, và token
346
+ vẫn nằm lại trong relay.
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`.
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
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`).
343
352
 
344
353
  ### Chế độ blindfold (tùy chọn)
345
354
 
@@ -768,7 +777,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
768
777
  ## Mô hình Bảo mật
769
778
 
770
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).
771
- - 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.
772
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.
773
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.
774
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`).
@@ -0,0 +1,86 @@
1
+ // Pure helpers of agy-relay.mjs, kept apart so tests can import them without starting a relay.
2
+
3
+ // The switcher writes 127.0.0.1 only. localhost can resolve to ::1, which no proof covers.
4
+ export function gatewayPort(raw) {
5
+ let u;
6
+ try { u = new URL(raw); } catch { return 0; }
7
+ if (u.protocol !== 'http:' || u.hostname !== '127.0.0.1') return 0;
8
+ const port = Number(u.port);
9
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 0;
10
+ }
11
+
12
+ // One complete HTTP/1.1 answer at the start of `buf`: { status, text, rest }, or null while it is
13
+ // still incomplete. An answer with no length cannot share its socket, so it counts as a failure.
14
+ export function readAnswer(buf) {
15
+ const end = buf.indexOf('\r\n\r\n');
16
+ if (end < 0) return null;
17
+ const lines = buf.subarray(0, end).toString('latin1').split('\r\n');
18
+ const status = Number(lines[0].split(' ')[1]);
19
+ const headers = {};
20
+ for (const line of lines.slice(1)) {
21
+ const i = line.indexOf(':');
22
+ if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
23
+ }
24
+ const rest = buf.subarray(end + 4);
25
+ if (/close/i.test(headers.connection || '')) return { status: 0, text: '', rest };
26
+ if (headers['content-length'] !== undefined) {
27
+ const n = Number(headers['content-length']);
28
+ if (!Number.isInteger(n) || n < 0) return { status: 0, text: '', rest };
29
+ if (rest.length < n) return null;
30
+ return { status, text: rest.subarray(0, n).toString('utf8'), rest: rest.subarray(n) };
31
+ }
32
+ if (/chunked/i.test(headers['transfer-encoding'] || '')) {
33
+ const parts = [];
34
+ let off = 0;
35
+ for (;;) {
36
+ const lineEnd = rest.indexOf('\r\n', off);
37
+ if (lineEnd < 0) return null;
38
+ const size = parseInt(rest.subarray(off, lineEnd).toString('latin1'), 16);
39
+ if (!Number.isInteger(size) || size < 0) return { status: 0, text: '', rest };
40
+ if (size === 0) {
41
+ if (rest.length < lineEnd + 4) return null;
42
+ return { status, text: Buffer.concat(parts).toString('utf8'), rest: rest.subarray(lineEnd + 4) };
43
+ }
44
+ if (rest.length < lineEnd + 2 + size + 2) return null;
45
+ parts.push(rest.subarray(lineEnd + 2, lineEnd + 2 + size));
46
+ off = lineEnd + 2 + size + 2;
47
+ }
48
+ }
49
+ return { status: 0, text: '', rest };
50
+ }
51
+
52
+ // The longest string cmd.exe takes after /c.
53
+ export const CMD_LIMIT = 8191;
54
+ const SAFE = /^[A-Za-z0-9#$*+\-./:?@\\_]$/;
55
+
56
+ // One argument for a batch file, as the Rust standard library builds it since CVE-2024-24576
57
+ // (make_bat_command_line): `"` becomes `""`, and `%` becomes `%%cd:~,%`, which expands to `%`, so
58
+ // cmd.exe never expands a %name% or %name:x=y% that came from the argument.
59
+ function batArg(arg) {
60
+ const quote = arg === '' || arg.endsWith('\\') || [...arg].some(c => !SAFE.test(c));
61
+ let out = quote ? '"' : '';
62
+ let backslashes = 0;
63
+ for (const c of arg) {
64
+ if (c === '\\') {
65
+ backslashes++;
66
+ } else {
67
+ if (c === '"') out += `${'\\'.repeat(backslashes)}"`;
68
+ else if (c === '%') out += '%%cd:~,';
69
+ backslashes = 0;
70
+ }
71
+ out += c;
72
+ }
73
+ if (quote) out += `${'\\'.repeat(backslashes)}"`;
74
+ return out;
75
+ }
76
+
77
+ // The cmd.exe arguments that run a .cmd/.bat with `args` unchanged: { args } or { error }.
78
+ export function batCommandLine(script, args) {
79
+ if (script.includes('"') || script.endsWith('\\')) return { error: `the path of agy (${script}) cannot hold " or end with \\` };
80
+ for (const a of args) {
81
+ if (/[\r\n]/.test(a)) return { error: 'an argument holds a line break, which a .cmd or .bat agy cannot receive. Pipe the prompt on stdin instead.' };
82
+ }
83
+ const inner = `"${[`"${script.replace(/%/g, '%%cd:~,%')}"`, ...args.map(batArg)].join(' ')}"`;
84
+ if (inner.length > CMD_LIMIT) return { error: `the command line is longer than ${CMD_LIMIT} characters, the limit of cmd.exe. Pipe the prompt on stdin instead.` };
85
+ return { args: ['/e:ON', '/v:OFF', '/d', '/c', inner] };
86
+ }
package/agy-relay.mjs ADDED
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // Runs agy behind a relay that holds its own loopback port for exactly as long as agy runs.
3
+ //
4
+ // agy sends its Google token in clear text to CLOUD_CODE_URL. The shim checks the gateway once,
5
+ // at launch. A gateway that stops later frees its port, and another program can take it. So agy
6
+ // talks only to this relay: for each connection it asks the gateway for its relay proof on the
7
+ // socket it will use, and sends agy's bytes only after that proof holds.
8
+ //
9
+ // Usage: node agy-relay.mjs <gateway-url> <agy> [args...]
10
+ import net from 'node:net';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { spawn } from 'node:child_process';
14
+ import { relayProof } from './state.mjs';
15
+ import { gatewayPort, readAnswer, batCommandLine } from './agy-relay-lib.mjs';
16
+
17
+ // The proof of a live gateway takes milliseconds; a squatter gains nothing from a longer wait.
18
+ const PROOF_MS = 3000;
19
+ const MAX_PROBE_ANSWER = 64 * 1024;
20
+
21
+ // The gateway on a new socket: { sock } when it proved itself with the relay key (state.mjs),
22
+ // otherwise { old: true } for a gateway built before relayProof, or {} for anything else. Only the
23
+ // first case gets agy's bytes, on this same socket, so no other process can answer them.
24
+ function provenSocket(port) {
25
+ return new Promise((resolve) => {
26
+ const nonce = crypto.randomBytes(16).toString('hex');
27
+ const sock = net.connect(port, '127.0.0.1');
28
+ let buf = Buffer.alloc(0);
29
+ let settled = false;
30
+ const finish = (result) => {
31
+ if (settled) return;
32
+ settled = true;
33
+ clearTimeout(timer);
34
+ sock.off('data', onData);
35
+ sock.off('error', onFail);
36
+ sock.off('close', onFail);
37
+ if (result.sock) return resolve(result);
38
+ sock.destroy();
39
+ resolve(result);
40
+ };
41
+ const onFail = () => finish({});
42
+ const onData = (chunk) => {
43
+ buf = Buffer.concat([buf, chunk]);
44
+ if (buf.length > MAX_PROBE_ANSWER) return finish({});
45
+ const answer = readAnswer(buf);
46
+ if (!answer) return;
47
+ // Nothing may follow the probe answer: those bytes would reach agy as its own answer.
48
+ if (answer.status !== 200 || answer.rest.length) return finish({});
49
+ let body;
50
+ try { body = JSON.parse(answer.text); } catch { return finish({}); }
51
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return finish({});
52
+ if (body.proxy !== 'llm-switcher' || body.port !== port) return finish({});
53
+ // An absent field, never a falsy one: relayProof '' must still fail the check below.
54
+ if (!Object.hasOwn(body, 'relayProof')) return finish({ old: true });
55
+ const expected = relayProof(nonce, { port, pid: body.pid });
56
+ finish(Boolean(expected) && body.relayProof === expected ? { sock } : {});
57
+ };
58
+ const timer = setTimeout(onFail, PROOF_MS);
59
+ sock.on('data', onData);
60
+ sock.on('error', onFail);
61
+ sock.on('close', onFail);
62
+ sock.write(`GET /health?challenge=${nonce} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: keep-alive\r\n\r\n`);
63
+ });
64
+ }
65
+
66
+ // agy shows a Code Assist error, and its request stays in this process.
67
+ function refusal(message) {
68
+ const body = JSON.stringify({ error: { code: 502, status: 'UNAVAILABLE', message } });
69
+ return `HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`;
70
+ }
71
+ const NOT_PROVED = 'LLM Switcher: the gateway did not prove its identity, so this request was not sent. Run `switch on`, or `switch off agy`.';
72
+ const OLD_GATEWAY = 'LLM Switcher: the running gateway is older than this relay. Restart it: `switch off`, then `switch on`.';
73
+
74
+ function startRelay(port) {
75
+ // pauseOnConnect: agy's bytes stay unread until the gateway on this connection has proved itself.
76
+ const server = net.createServer({ pauseOnConnect: true }, async (client) => {
77
+ client.on('error', () => client.destroy());
78
+ const { sock: up, old } = await provenSocket(port);
79
+ if (client.destroyed) return up?.destroy();
80
+ if (!up) {
81
+ client.end(refusal(old ? OLD_GATEWAY : NOT_PROVED));
82
+ client.resume(); // read and drop the request; nothing of it leaves this process
83
+ return;
84
+ }
85
+ up.on('error', () => client.destroy());
86
+ client.on('close', () => up.destroy());
87
+ up.on('close', () => client.destroy());
88
+ client.pipe(up);
89
+ up.pipe(client);
90
+ client.resume();
91
+ });
92
+ return new Promise((resolve, reject) => {
93
+ server.once('error', reject);
94
+ server.listen(0, '127.0.0.1', () => resolve(server));
95
+ });
96
+ }
97
+
98
+ // A .cmd/.bat cannot start without cmd.exe. It is always the one in System32: a bare name would be
99
+ // looked up in the current directory first.
100
+ function launch(file, args, env) {
101
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(file)) {
102
+ const line = batCommandLine(file, args);
103
+ if (line.error) {
104
+ console.error(`[llm-switcher] cannot start agy: ${line.error}`);
105
+ process.exit(2);
106
+ }
107
+ const cmd = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
108
+ return spawn(cmd, line.args, { stdio: 'inherit', env, windowsVerbatimArguments: true });
109
+ }
110
+ return spawn(file, args, { stdio: 'inherit', env });
111
+ }
112
+
113
+ const [gatewayUrl, agy, ...args] = process.argv.slice(2);
114
+ if (!agy) {
115
+ console.error('Usage: node agy-relay.mjs <gateway-url> <agy> [args...]');
116
+ process.exit(2);
117
+ }
118
+ if (args.some(a => a.includes('\0'))) {
119
+ console.error('[llm-switcher] cannot start agy: an argument holds a NUL character.');
120
+ process.exit(2);
121
+ }
122
+
123
+ const env = { ...process.env };
124
+ delete env.CLOUD_CODE_URL;
125
+ const port = gatewayPort(gatewayUrl);
126
+ if (port) {
127
+ const server = await startRelay(port);
128
+ env.CLOUD_CODE_URL = `http://127.0.0.1:${server.address().port}`;
129
+ } else {
130
+ console.error(`[llm-switcher] ${String(gatewayUrl)} is not the address of this switcher's gateway; agy uses its official endpoint.`);
131
+ }
132
+
133
+ const child = launch(agy, args, env);
134
+ // Ctrl+C reaches agy and this relay together. agy decides what it means; the relay ends with agy.
135
+ process.on('SIGINT', () => {});
136
+ if (process.platform === 'win32') process.on('SIGBREAK', () => {});
137
+ process.on('SIGTERM', () => child.kill());
138
+ child.on('error', (err) => {
139
+ console.error(`[llm-switcher] cannot start agy (${agy}): ${err.message}`);
140
+ process.exit(127);
141
+ });
142
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.3.2",
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
@@ -22,7 +22,7 @@ import {
22
22
  getActiveMap, setTargetProfile, activateProfile, deactivateProfile, deactivateAll, deleteProfile,
23
23
  isProfileActive, profileAcceptsTarget, applyLaunchState, readLaunchFlags, redactConfig, MASKED_KEY,
24
24
  modelForSlot, primaryModel, codexPublicModel, isSafeModelName, parsePort, CODEX_MODEL_SLOTS,
25
- ensureAdminToken, identityProof, reconcileBlindfold, checkBlindfoldTarget,
25
+ ensureAdminToken, ensureGatewaySecret, relayProof, identityProof, reconcileBlindfold, checkBlindfoldTarget,
26
26
  codexModelEntry, smallestWindows, publicModelWindows, model1MForSlot,
27
27
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
28
28
  } from './state.mjs';
@@ -116,6 +116,8 @@ function checkRequestOrigin(req) {
116
116
  // The Host/Origin guard stops browser pages only. A local process that is not the owner must
117
117
  // also present the token from admin.token (mode 0600) to use /api/*.
118
118
  const ADMIN_TOKEN = Buffer.from(ensureAdminToken());
119
+ // The relay key. The gateway keeps no copy: relayProof() reads the file for each proof.
120
+ ensureGatewaySecret();
119
121
 
120
122
  // `switch contract-probe` names the trace id of the exchange it drives, so it can print the id
121
123
  // before intact holds it. Only a process that can read admin.token is believed, and the marker
@@ -1298,10 +1300,9 @@ async function route(req, res) {
1298
1300
  'X-Frame-Options': 'DENY',
1299
1301
  'X-Content-Type-Options': 'nosniff'
1300
1302
  });
1301
- // One gateway serves one user, so the page opened at /ui carries its own token. The Host and Origin
1302
- // guard above keeps it from other sites; the token is hex, so it needs no escaping.
1303
- const meta = `<meta name="llm-switcher-token" content="${ADMIN_TOKEN.toString()}">`;
1304
- 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'));
1305
1306
  }
1306
1307
  }
1307
1308
 
@@ -1315,7 +1316,9 @@ async function route(req, res) {
1315
1316
  proxy: 'llm-switcher',
1316
1317
  ...(challenge ? {
1317
1318
  pid: process.pid,
1318
- proof: identityProof(challenge, { role: 'gateway', port: PORT, pid: process.pid }, ADMIN_TOKEN.toString())
1319
+ proof: identityProof(challenge, { role: 'gateway', port: PORT, pid: process.pid }, ADMIN_TOKEN.toString()),
1320
+ // The only proof agy-relay.mjs accepts: its key is not on the dashboard page.
1321
+ relayProof: relayProof(challenge, { port: PORT, pid: process.pid })
1319
1322
  } : {}),
1320
1323
  port: PORT,
1321
1324
  configLoaded: Boolean(loadConfig()),
@@ -2262,7 +2265,7 @@ if (!handedOver) server.listen(PORT, '127.0.0.1', () => {
2262
2265
  serialized(() => reconcile(cfg));
2263
2266
  }
2264
2267
  console.log(`[llm-switcher] Server running on http://127.0.0.1:${PORT}`);
2265
- 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\`)`);
2266
2269
  console.log(`[llm-switcher] Endpoints: /v1/messages (Claude Code) | /v1/responses (Codex)`);
2267
2270
  // Asynchronously discover latest tool models in the background without blocking startup
2268
2271
  refreshCatalog(STATE_DIR).catch(() => {});
package/shim.mjs CHANGED
@@ -40,6 +40,9 @@ const ROUTE_NOTIFIER = path.join(path.dirname(fileURLToPath(import.meta.url)), '
40
40
  // agy sends its Google token in clear text to CLOUD_CODE_URL. Its shim asks this helper for the
41
41
  // gateway's identity proof first, so a process that took the port never receives the token.
42
42
  const GATEWAY_CHECK = path.join(path.dirname(fileURLToPath(import.meta.url)), 'verify-gateway.mjs');
43
+ // The launch check covers the start only. A routed agy then runs behind this relay, which proves
44
+ // the gateway on every connection, so a port freed later never receives the token.
45
+ const AGY_RELAY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'agy-relay.mjs');
43
46
 
44
47
  // CLIs to wrap. `claude` is the most important case (--resume); codex and agy have their own files.
45
48
  export const SHIMMED = ['claude', 'codex', 'agy'];
@@ -191,6 +194,13 @@ if [ -z "$REAL" ]; then
191
194
  exit 127
192
195
  fi
193
196
 
197
+ ${name !== 'agy' ? '' : `# A routed agy talks only to its relay, which lives as long as agy (agy-relay.mjs).
198
+ if [ "$TOOL_ACTIVE" = "1" ] && [ -n "\${CLOUD_CODE_URL:-}" ]; then
199
+ GATEWAY_URL="$CLOUD_CODE_URL"
200
+ unset CLOUD_CODE_URL
201
+ exec node "${AGY_RELAY}" "$GATEWAY_URL" "$REAL" "$@"
202
+ fi
203
+ `}
194
204
  # No override of any kind: the tool keeps its own configuration and its own model names (F3, R1).
195
205
  exec "$REAL" "$@"
196
206
  `;
@@ -266,14 +276,26 @@ for /f "tokens=1 delims==" %%V in ('set LLM_SWITCHER_CODEX_ 2^>nul ^| findstr /b
266
276
  ${caBlock}
267
277
 
268
278
  REM Find the real binary: drop this shim's own directory so it never calls itself.
269
- for /f "delims=" %%i in ('where ${name}.cmd 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
279
+ ${name === 'agy' ? `set "REAL_BIN="
280
+ for /f "delims=" %%i in ('where ${name}.cmd 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do if not defined REAL_BIN set "REAL_BIN=%%i"
281
+ if not defined REAL_BIN for /f "delims=" %%i in ('where ${name}.exe 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do if not defined REAL_BIN set "REAL_BIN=%%i"
282
+ if not defined REAL_BIN goto :NOT_FOUND
283
+ REM A routed agy talks only to its relay, which lives as long as agy (agy-relay.mjs).
284
+ if not "%TOOL_ACTIVE%"=="1" goto :RUN_PLAIN
285
+ if not defined CLOUD_CODE_URL goto :RUN_PLAIN
286
+ node "${AGY_RELAY}" "%CLOUD_CODE_URL%" "%REAL_BIN%" %*
287
+ exit /b
288
+ :RUN_PLAIN
289
+ call "%REAL_BIN%" %*
290
+ exit /b
291
+ :NOT_FOUND` : `for /f "delims=" %%i in ('where ${name}.cmd 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
270
292
  call "%%i" %*
271
293
  exit /b
272
294
  )
273
295
  for /f "delims=" %%i in ('where ${name}.exe 2^>nul ^| findstr /v /i "\\.llm-switcher\\\\bin"') do (
274
296
  call "%%i" %*
275
297
  exit /b
276
- )
298
+ )`}
277
299
  echo [llm-switcher] cannot find the real '${name}' on PATH.>&2
278
300
  exit /b 127
279
301
 
package/state.mjs CHANGED
@@ -107,6 +107,38 @@ export function ensureAdminToken() {
107
107
  return token;
108
108
  }
109
109
 
110
+ // The key of the proof that agy-relay.mjs checks. Random, never derived from admin.token: the
111
+ // dashboard page carries admin.token, so a proof keyed by it can be made by anyone who loads /ui.
112
+ export const gatewaySecretPath = path.join(path.dirname(configPath), 'gateway.secret');
113
+
114
+ export function readGatewaySecret() {
115
+ try {
116
+ return fs.readFileSync(gatewaySecretPath, 'utf8').trim() || null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ // A good secret is kept. A missing, empty or blank file is replaced through a rename, so a damaged
123
+ // file never leaves agy without a key. Throws when it cannot write; never leaves a temporary file.
124
+ export function ensureGatewaySecret() {
125
+ if (readGatewaySecret()) return;
126
+ const tmp = `${gatewaySecretPath}.${process.pid}.tmp`;
127
+ try {
128
+ fs.rmSync(tmp, { force: true });
129
+ fs.writeFileSync(tmp, crypto.randomBytes(32).toString('hex'), { mode: 0o600, flag: 'wx' });
130
+ fs.renameSync(tmp, gatewaySecretPath);
131
+ } catch (err) {
132
+ try { fs.rmSync(tmp, { force: true }); } catch {}
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ export function relayProof(nonce, { port, pid }, secret = readGatewaySecret()) {
138
+ if (!secret) return '';
139
+ return crypto.createHmac('sha256', secret).update(['relay', port, pid, nonce].join('|')).digest('hex');
140
+ }
141
+
110
142
  // `switch ui` must not put the token on a command line: /proc/<pid>/cmdline is readable by every
111
143
  // account. It opens this private file instead, which redirects to the dashboard with the token.
112
144
  export function writeDashboardLauncher(url) {
package/switch.mjs CHANGED
@@ -232,8 +232,8 @@ async function stopProxy(port) {
232
232
  };
233
233
  // Our own unit needs no identity proof: its manager stops it. That also covers a unit that is
234
234
  // still starting (the port is free) and a hung gateway under the unit (the probe is silent).
235
- const svc = installedService();
236
- const ownUnit = svc && [port, null].includes(servicePort(svc));
235
+ const svc = serviceForPort(port);
236
+ const ownUnit = Boolean(svc);
237
237
  if (state === 'free') {
238
238
  if (!ownUnit) return 'not-running';
239
239
  serviceStop(svc);
@@ -271,7 +271,7 @@ async function changePort(newPortStr) {
271
271
  // Refuse before anything stops: the old gateway keeps running when the new port is taken.
272
272
  const target = await probeGateway(p);
273
273
  if (isHeld(target)) refuseForeignPort(p, 'this switcher', target);
274
- const svc = installedService();
274
+ const svc = serviceForPort(oldPort);
275
275
  // The unit is rewritten below. It keeps the choice that `service install --no-autoupdate` made.
276
276
  const svcText = svc ? serviceText(svc) : null;
277
277
  const autoupdate = svcText === null || autoupdateFromServiceText(svcText);
@@ -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'.`);
@@ -688,6 +688,13 @@ function servicePort(kind) {
688
688
  return text === null ? null : portFromServiceText(text);
689
689
  }
690
690
 
691
+ // The installed service runs the gateway of the port its definition names. A second instance
692
+ // (LLM_SWITCHER_PORT) must never stop or rewrite it. A definition with no readable port counts.
693
+ function serviceForPort(port) {
694
+ const svc = installedService();
695
+ return svc && [port, null].includes(servicePort(svc)) ? svc : null;
696
+ }
697
+
691
698
  function reportBackup(backup) {
692
699
  if (backup) console.log(`[Service] The previous definition differed and is kept as ${backup}.`);
693
700
  }