llm-switcher 1.3.2 → 1.3.3

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,12 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.3.3
4
+
5
+ - **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.
6
+ - **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.
7
+ - **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.
8
+ - **`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.
9
+
3
10
  ## Release 1.3.2
4
11
 
5
12
  - **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
@@ -337,9 +337,18 @@ gateway for its identity proof before it gives agy the variable. If the port doe
337
337
  the proof of this install within 3 seconds, the shim removes the variable, prints one line, and agy
338
338
  uses its official endpoint.
339
339
 
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.
340
+ The gateway can also stop while agy runs (`switch off`, a crash, an update). Its port is then free,
341
+ and another program can take it. So a routed agy never talks to the gateway port directly. The shim
342
+ starts agy behind a relay (`agy-relay.mjs`) that holds its own loopback port for as long as agy
343
+ runs. For each connection from agy, the relay asks the gateway for its identity proof on the same
344
+ socket, and sends the request on that socket only after the proof holds. If the proof fails, agy
345
+ gets a 502, and its token stays in the relay.
346
+
347
+ 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
350
+ gateway is older than the relay, agy gets a 502 that says to restart it (`switch off`, then
351
+ `switch on`).
343
352
 
344
353
  ### Blindfold mode (optional)
345
354
 
package/README.vi.md CHANGED
@@ -337,9 +337,17 @@ agy gửi token này dạng rõ tới `CLOUD_CODE_URL`. Vì vậy shim `agy` h
337
337
  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
338
  shim gỡ biến, in một dòng thông báo, và agy dùng endpoint chính thức.
339
339
 
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.
340
+ 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ó
341
+ 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
342
+ 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
343
+ 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
344
+ 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
345
+ vẫn nằm lại trong relay.
346
+
347
+ 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
350
+ 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
351
 
344
352
  ### Chế độ blindfold (tùy chọn)
345
353
 
@@ -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.3",
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
@@ -1315,7 +1317,9 @@ async function route(req, res) {
1315
1317
  proxy: 'llm-switcher',
1316
1318
  ...(challenge ? {
1317
1319
  pid: process.pid,
1318
- proof: identityProof(challenge, { role: 'gateway', port: PORT, pid: process.pid }, ADMIN_TOKEN.toString())
1320
+ proof: identityProof(challenge, { role: 'gateway', port: PORT, pid: process.pid }, ADMIN_TOKEN.toString()),
1321
+ // The only proof agy-relay.mjs accepts: its key is not on the dashboard page.
1322
+ relayProof: relayProof(challenge, { port: PORT, pid: process.pid })
1319
1323
  } : {}),
1320
1324
  port: PORT,
1321
1325
  configLoaded: Boolean(loadConfig()),
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);
@@ -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
  }
@@ -0,0 +1,316 @@
1
+ // Tests for agy-relay.mjs: agy's Google token reaches a gateway only on a connection where that
2
+ // gateway proved itself with the key of gateway.secret, also when the gateway stops in the middle
3
+ // of an agy session.
4
+ import { test, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import net from 'node:net';
10
+ import http from 'node:http';
11
+ import crypto from 'node:crypto';
12
+ import { spawn } from 'node:child_process';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const RELAY = path.join(ROOT, 'agy-relay.mjs');
17
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'agyrelay-'));
18
+ after(() => fs.rmSync(TMP, { recursive: true, force: true }));
19
+
20
+ // The directory of a relay run: admin.token, and gateway.secret unless `secret` is false.
21
+ const TOKEN = 'b'.repeat(64);
22
+ function stateDir(name, { secret = true } = {}) {
23
+ const dir = path.join(TMP, name);
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ fs.writeFileSync(path.join(dir, 'admin.token'), TOKEN);
26
+ if (secret) fs.writeFileSync(path.join(dir, 'gateway.secret'), crypto.randomBytes(32).toString('hex'));
27
+ return dir;
28
+ }
29
+ const MAIN = stateDir('main');
30
+ const { identityProof } = await import(pathToFileURL(path.join(ROOT, 'state.mjs')).href);
31
+
32
+ // Stands in for agy: FAKE_CALLS requests with its Google token, then one more once `signal` exists.
33
+ const FAKE_AGY = path.join(TMP, 'fake-agy.mjs');
34
+ fs.writeFileSync(FAKE_AGY, `
35
+ import fs from 'node:fs';
36
+ import http from 'node:http';
37
+ const [, , code, signal] = process.argv;
38
+ const call = () => new Promise((resolve) => {
39
+ const req = http.request(process.env.CLOUD_CODE_URL + '/v1internal:loadCodeAssist', {
40
+ method: 'POST', agent: false, headers: { authorization: 'Bearer google-secret', 'content-type': 'application/json' }
41
+ }, (res) => { let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { console.log('STATUS ' + res.statusCode + ' ' + b); resolve(); }); res.on('error', () => resolve()); });
42
+ req.on('error', (e) => { console.log('ERROR ' + e.message); resolve(); });
43
+ req.end('{}');
44
+ });
45
+ console.log('CCU ' + (process.env.CLOUD_CODE_URL || ''));
46
+ if (process.env.CLOUD_CODE_URL) for (let i = 0; i < Number(process.env.FAKE_CALLS || 1); i++) await call();
47
+ if (signal) { while (!fs.existsSync(signal)) await new Promise(r => setTimeout(r, 50)); await call(); }
48
+ process.exit(Number(code) || 0);
49
+ `);
50
+
51
+ const relayHmac = (key, port, pid, nonce) => crypto.createHmac('sha256', key).update(['relay', port, pid, nonce].join('|')).digest('hex');
52
+
53
+ // The answer of a gateway fixture to the identity probe. 'real' signs with the raw bytes of the
54
+ // gateway.secret file; every other kind is one way to be wrong.
55
+ function probeAnswer(kind, challenge, port, dir) {
56
+ const pid = 4242;
57
+ const secretFile = path.join(dir, 'gateway.secret');
58
+ const secret = fs.existsSync(secretFile) ? fs.readFileSync(secretFile) : Buffer.from('no-secret');
59
+ let status = 200;
60
+ let body = { status: 'ok', proxy: 'llm-switcher', port, pid, proof: identityProof(challenge, { role: 'gateway', port, pid }, TOKEN) };
61
+ if (kind === 'real' || kind === 'extra') body.relayProof = relayHmac(secret, port, pid, challenge);
62
+ if (kind === 'squatter') body.relayProof = relayHmac(TOKEN, port, pid, challenge);
63
+ if (kind === 'replay') Object.assign(body, { relayProof: relayHmac(secret, port, pid, 'stale-nonce'), nonce: 'stale-nonce', challenge: 'stale-nonce' });
64
+ if (kind === 'status500') { status = 500; body.relayProof = relayHmac(secret, port, pid, challenge); }
65
+ if (kind === 'oversize') Object.assign(body, { relayProof: relayHmac(secret, port, pid, challenge), pad: 'x'.repeat(70 * 1024) });
66
+ if (kind === 'empty') body.relayProof = '';
67
+ if (kind === 'null') body = null;
68
+ if (kind === 'number') body = 7;
69
+ if (kind === 'array') body = [];
70
+ const text = JSON.stringify(body);
71
+ let out = `HTTP/1.1 ${status} X\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(text)}\r\nConnection: keep-alive\r\n\r\n${text}`;
72
+ if (kind === 'extra') out += 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 13\r\n\r\nINJECTED-BODY';
73
+ return out;
74
+ }
75
+
76
+ // A raw gateway fixture on `port`. `kinds` is one kind, or one kind per connection in order (the
77
+ // last repeats). It records every challenge and every byte that arrives after its probe answer.
78
+ function gateway(kinds, { port = 0, dir = MAIN } = {}) {
79
+ const list = [].concat(kinds);
80
+ const seen = { challenges: [], bytes: '' };
81
+ let conn = 0;
82
+ const server = net.createServer((sock) => {
83
+ const kind = list[Math.min(conn++, list.length - 1)];
84
+ sock.on('error', () => {});
85
+ let buf = '';
86
+ let probed = false;
87
+ sock.on('data', (d) => {
88
+ if (kind === 'silent') { seen.bytes += d; return; }
89
+ if (probed) {
90
+ seen.bytes += d;
91
+ buf += d;
92
+ // 'real' answers each forwarded request once its 2-byte body is in.
93
+ const end = buf.indexOf('\r\n\r\n');
94
+ if (kind === 'real' && end >= 0 && buf.length >= end + 6) {
95
+ buf = '';
96
+ const answer = '{"answeredBy":"real"}';
97
+ sock.write(`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${answer.length}\r\nConnection: keep-alive\r\n\r\n${answer}`);
98
+ }
99
+ return;
100
+ }
101
+ buf += d;
102
+ const end = buf.indexOf('\r\n\r\n');
103
+ if (end < 0) return;
104
+ const head = buf.slice(0, end);
105
+ const rest = buf.slice(end + 4);
106
+ buf = '';
107
+ probed = true;
108
+ seen.challenges.push((head.match(/challenge=([0-9a-f]+)/) || [])[1] || '');
109
+ sock.write(probeAnswer(kind, seen.challenges.at(-1), server.address().port, dir));
110
+ if (rest) seen.bytes += rest;
111
+ });
112
+ });
113
+ return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve({ server, seen, port: server.address().port })));
114
+ }
115
+
116
+ function runRelay(gatewayUrl, args = [], { env = {}, dir = MAIN } = {}) {
117
+ const child = spawn(process.execPath, [RELAY, gatewayUrl, process.execPath, FAKE_AGY, ...args], {
118
+ env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(dir, 'config.json'), CLOUD_CODE_URL: gatewayUrl, ...env }
119
+ });
120
+ let out = '', err = '';
121
+ child.stdout.on('data', d => { out += d; });
122
+ child.stderr.on('data', d => { err += d; });
123
+ const done = new Promise((resolve) => {
124
+ const timer = setTimeout(() => child.kill(), 30000);
125
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }); });
126
+ });
127
+ return { child, done, out: () => out };
128
+ }
129
+
130
+ const statuses = (out) => out.split('\n').filter(l => l.startsWith('STATUS')).map(l => Number(l.split(' ')[1]));
131
+ const ccuPort = (out) => Number((out.match(/CCU http:\/\/127\.0\.0\.1:(\d+)/) || [])[1]);
132
+
133
+ test('agy talks to its own relay port, and the relay forwards to a gateway that proved itself with gateway.secret', async (t) => {
134
+ const g = await gateway('real');
135
+ t.after(() => g.server.close());
136
+ const { code, out } = await runRelay(`http://127.0.0.1:${g.port}`).done;
137
+ assert.equal(code, 0);
138
+ assert.ok(ccuPort(out) > 0 && ccuPort(out) !== g.port, `agy gets the relay port, not the gateway port: ${out}`);
139
+ assert.match(out, /STATUS 200 .*"answeredBy":"real"/);
140
+ assert.match(g.seen.bytes, /Bearer google-secret/);
141
+ });
142
+
143
+ // Every way a listener on the gateway port can be wrong: agy gets a 502, and its token never leaves.
144
+ for (const kind of ['squatter', 'replay', 'extra', 'status500', 'oversize', 'old']) {
145
+ test(`a gateway that answers the probe as '${kind}' gets a 502 and never receives the token`, async (t) => {
146
+ const g = await gateway(kind);
147
+ t.after(() => g.server.close());
148
+ const { code, out } = await runRelay(`http://127.0.0.1:${g.port}`).done;
149
+ assert.equal(code, 0);
150
+ assert.deepEqual(statuses(out), [502], out);
151
+ assert.doesNotMatch(g.seen.bytes, /google-secret/);
152
+ assert.doesNotMatch(out, /INJECTED-BODY/, 'bytes after the probe answer never reach agy');
153
+ if (kind === 'old') assert.match(out, /switch off.*switch on/, 'an old gateway gets the restart hint');
154
+ });
155
+ }
156
+
157
+ test('a silent listener only ever sees the probe, never the token', async (t) => {
158
+ const g = await gateway('silent');
159
+ t.after(() => g.server.close());
160
+ const { out } = await runRelay(`http://127.0.0.1:${g.port}`).done;
161
+ assert.deepEqual(statuses(out), [502]);
162
+ assert.match(g.seen.bytes, /^GET \/health\?challenge=/);
163
+ assert.doesNotMatch(g.seen.bytes, /google-secret/);
164
+ });
165
+
166
+ test('one relay sends a fresh challenge on every connection', async (t) => {
167
+ const g = await gateway('replay');
168
+ t.after(() => g.server.close());
169
+ await runRelay(`http://127.0.0.1:${g.port}`, [], { env: { FAKE_CALLS: '2' } }).done;
170
+ assert.equal(g.seen.challenges.length, 2);
171
+ assert.notEqual(g.seen.challenges[0], g.seen.challenges[1]);
172
+ assert.match(g.seen.challenges[0], /^[0-9a-f]{32}$/);
173
+ });
174
+
175
+ test('without gateway.secret the relay trusts no proof, not even one keyed by admin.token', async (t) => {
176
+ const dir = stateDir('nosecret', { secret: false });
177
+ for (const kind of ['empty', 'squatter']) {
178
+ const g = await gateway(kind, { dir });
179
+ t.after(() => g.server.close());
180
+ const { out } = await runRelay(`http://127.0.0.1:${g.port}`, [], { dir }).done;
181
+ assert.deepEqual(statuses(out), [502], `${kind}: ${out}`);
182
+ assert.doesNotMatch(g.seen.bytes, /google-secret/, kind);
183
+ }
184
+ });
185
+
186
+ test('an answer that is not an object gets a 502, and the relay keeps serving agy', async (t) => {
187
+ const g = await gateway(['null', 'number', 'array', 'real']);
188
+ t.after(() => g.server.close());
189
+ const { code, out } = await runRelay(`http://127.0.0.1:${g.port}`, [], { env: { FAKE_CALLS: '4' } }).done;
190
+ assert.equal(code, 0);
191
+ assert.deepEqual(statuses(out), [502, 502, 502, 200], out);
192
+ });
193
+
194
+ test('a gateway replaced in the middle of an agy session never receives the token', async (t) => {
195
+ const real = await gateway('real');
196
+ const port = real.port;
197
+ const signal = path.join(TMP, 'go-swap');
198
+ const run = runRelay(`http://127.0.0.1:${port}`, ['0', signal]);
199
+ for (let i = 0; i < 200 && !/STATUS/.test(run.out()); i++) await new Promise(r => setTimeout(r, 50));
200
+ assert.match(run.out(), /STATUS 200 /, 'the first request went through the real gateway');
201
+ // The gateway stops, and a process that holds admin.token takes its port.
202
+ await new Promise(r => real.server.close(r));
203
+ const squatter = await gateway('squatter', { port });
204
+ t.after(() => squatter.server.close());
205
+ fs.writeFileSync(signal, '');
206
+ const { out } = await run.done;
207
+ assert.deepEqual(statuses(out), [200, 502]);
208
+ assert.doesNotMatch(squatter.seen.bytes, /google-secret/);
209
+ });
210
+
211
+ test('the relay works against the real proxy.mjs and the proof it computes', async (t) => {
212
+ const dir = stateDir('realproxy', { secret: false });
213
+ fs.rmSync(path.join(dir, 'admin.token'));
214
+ const mock = http.createServer((req, res) => { req.resume(); res.end('{"answeredBy":"code-assist-mock"}'); });
215
+ await new Promise(r => mock.listen(0, '127.0.0.1', r));
216
+ t.after(() => mock.close());
217
+ const port = await new Promise(r => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
218
+ fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ port, activeProfiles: { claude: null, codex: null, agy: null }, profiles: {} }));
219
+ const proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], {
220
+ env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(dir, 'config.json'), LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: path.join(dir, 'claude'), LLM_SWITCHER_PORT: '', LLM_SWITCHER_CODE_ASSIST_URL: `http://127.0.0.1:${mock.address().port}` },
221
+ stdio: 'ignore'
222
+ });
223
+ t.after(() => proxy.kill());
224
+ for (let i = 0; i < 50; i++) {
225
+ try { if ((await fetch(`http://127.0.0.1:${port}/health`)).ok) break; } catch {}
226
+ await new Promise(r => setTimeout(r, 100));
227
+ }
228
+ assert.ok(fs.existsSync(path.join(dir, 'gateway.secret')), 'the gateway made its secret at startup');
229
+ const { out } = await runRelay(`http://127.0.0.1:${port}`, [], { dir }).done;
230
+ assert.match(out, /STATUS 200 .*code-assist-mock/, out);
231
+ });
232
+
233
+ test('the relay exits with the exit code of agy', async (t) => {
234
+ const g = await gateway('real');
235
+ t.after(() => g.server.close());
236
+ const { code } = await runRelay(`http://127.0.0.1:${g.port}`, ['7']).done;
237
+ assert.equal(code, 7);
238
+ });
239
+
240
+ test('a gateway address that is not 127.0.0.1 with a port leaves agy on its official endpoint', async () => {
241
+ for (const bad of ['http://localhost:3456', 'http://127.0.0.1', 'https://127.0.0.1:3456', 'not a url']) {
242
+ const { code, out, err } = await runRelay(bad).done;
243
+ assert.equal(code, 0, bad);
244
+ assert.match(out, /^CCU $/m, `${bad}: agy gets no CLOUD_CODE_URL`);
245
+ assert.match(err, /official endpoint/, bad);
246
+ }
247
+ });
248
+
249
+ // An argv string cannot hold NUL on any platform: Node refuses it before a process exists, so it
250
+ // can reach neither the relay nor agy. The relay's own NUL guard only backs this up.
251
+ test('an argument with a NUL character cannot start a process at all', () => {
252
+ assert.throws(() => spawn(process.execPath, [RELAY, 'http://127.0.0.1:9', process.execPath, FAKE_AGY, 'a\0b']), { code: 'ERR_INVALID_ARG_VALUE' });
253
+ });
254
+
255
+ // ---- A .cmd agy on Windows: every argument arrives byte-identical through cmd.exe ----------------
256
+
257
+ const WIN = process.platform === 'win32';
258
+ function cmdFixture(name) {
259
+ const dir = path.join(TMP, name);
260
+ fs.mkdirSync(dir, { recursive: true });
261
+ fs.writeFileSync(path.join(dir, 'argv.js'), `require('fs').writeFileSync(require('path').join(process.cwd(), 'argv.json'), JSON.stringify(process.argv.slice(2)));\n`);
262
+ fs.writeFileSync(path.join(dir, 'agy.cmd'), '@node "%~dp0argv.js" %*\r\n');
263
+ return dir;
264
+ }
265
+ function runCmdRelay(dir, args) {
266
+ const child = spawn(process.execPath, [RELAY, 'http://127.0.0.1', path.join(dir, 'agy.cmd'), ...args], {
267
+ cwd: dir, env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(MAIN, 'config.json') }
268
+ });
269
+ let err = '';
270
+ child.stderr.on('data', d => { err += d; });
271
+ return new Promise((resolve) => child.on('close', (code) => resolve({ code, err })));
272
+ }
273
+
274
+ test('a .cmd agy receives every argument byte-identical, and cmd.exe runs nothing else', { skip: !WIN && 'cmd.exe only' }, async () => {
275
+ const dir = cmdFixture('cmdargs');
276
+ const args = ['%PATH%', '%PATH:a=%', '%CMDCMDLINE:~-1%&echo PWNED>marker', 'C:\\temp\\', 'a"b', 'a\\"b', 'say "hi there"',
277
+ '50% off', 'x&y', 'a|b', 'a^b', '<x>', '(x)', '!PATH!', 'héllo wörld', ''];
278
+ const { code, err } = await runCmdRelay(dir, args);
279
+ assert.equal(code, 0, err);
280
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), args);
281
+ assert.equal(fs.existsSync(path.join(dir, 'marker')), false, 'no second command ran');
282
+ });
283
+
284
+ test('a .cmd agy in a directory with % in its name still starts', { skip: !WIN && 'cmd.exe only' }, async () => {
285
+ const dir = cmdFixture('50%off');
286
+ const { code, err } = await runCmdRelay(dir, ['ok']);
287
+ assert.equal(code, 0, err);
288
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), ['ok']);
289
+ });
290
+
291
+ test('a .cmd agy gets no line break and no command line over the cmd.exe limit', { skip: !WIN && 'cmd.exe only' }, async () => {
292
+ for (const [name, args, hint] of [['crlf-n', ['a\nb'], /line break/], ['crlf-r', ['a\rb'], /line break/], ['toolong', ['x'.repeat(8200)], /8191/]]) {
293
+ const dir = cmdFixture(name);
294
+ const { code, err } = await runCmdRelay(dir, args);
295
+ assert.notEqual(code, 0, name);
296
+ assert.match(err, hint, name);
297
+ assert.match(err, /stdin/, name);
298
+ assert.equal(fs.existsSync(path.join(dir, 'argv.json')), false, `${name}: the fixture never ran`);
299
+ }
300
+ const dir = cmdFixture('long-ok');
301
+ const long = 'y'.repeat(4000);
302
+ const { code, err } = await runCmdRelay(dir, [long]);
303
+ assert.equal(code, 0, err);
304
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), [long]);
305
+ });
306
+
307
+ test('an .exe agy gets a line break in an argument unchanged', async (t) => {
308
+ const dir = path.join(TMP, 'exe-newline');
309
+ fs.mkdirSync(dir, { recursive: true });
310
+ const script = path.join(dir, 'argv.mjs');
311
+ fs.writeFileSync(script, `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(path.join(dir, 'argv.json'))}, JSON.stringify(process.argv.slice(2)));\n`);
312
+ const child = spawn(process.execPath, [RELAY, 'http://127.0.0.1', process.execPath, script, 'a\nb'], { env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(MAIN, 'config.json') } });
313
+ const code = await new Promise(r => child.on('close', r));
314
+ assert.equal(code, 0);
315
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), ['a\nb']);
316
+ });
@@ -623,6 +623,20 @@ test('a Codex WS Bifrost stream cut before its end still ends the turn with resp
623
623
  }
624
624
  });
625
625
 
626
+ test('no page or answer of the gateway carries the relay key', async () => {
627
+ const secret = fs.readFileSync(path.join(tmpDir, 'gateway.secret'), 'utf8').trim();
628
+ assert.match(secret, /^[0-9a-f]{64}$/, 'the gateway made its key at startup');
629
+ const token = fs.readFileSync(path.join(tmpDir, 'admin.token'), 'utf8').trim();
630
+ for (const p of ['/', '/ui', '/health?challenge=x', '/api/status']) {
631
+ const r = await fetch(url(p), { headers: { 'x-llm-switcher-token': token } });
632
+ assert.equal(r.status, 200, p);
633
+ const all = JSON.stringify([...r.headers]) + await r.text();
634
+ assert.equal(all.includes(secret), false, `${p} carries the relay key`);
635
+ }
636
+ const health = await (await fetch(url('/health?challenge=n9'))).json();
637
+ assert.match(health.relayProof, /^[0-9a-f]{64}$/, 'the gateway answers a relay proof');
638
+ });
639
+
626
640
  test('with agy switched off, its agent turns go back to Google', async (t) => {
627
641
  const token = fs.readFileSync(path.join(tmpDir, 'admin.token'), 'utf8').trim();
628
642
  const switchAgy = (profile) => fetch(url('/api/switch'), { method: 'POST', headers: { 'content-type': 'application/json', 'x-llm-switcher-token': token }, body: JSON.stringify({ target: 'agy', profile }) });
@@ -68,6 +68,9 @@ test('the agy shim reads only its own files, takes no CA, and scrubs a stale gat
68
68
  assert.match(posix, /if \[ "\$TOOL_ACTIVE" != "1" \] && \[ -n "\$_u" \]; then/);
69
69
  assert.match(shim.renderShim('claude', 'win32'), /NODE_EXTRA_CA_CERTS/, 'Claude keeps its CA block');
70
70
  assert.equal(shim.routeEvidence('agy', ' CLOUD_CODE_URL=http://127.0.0.1:3456 PATH=x'), true);
71
+ assert.match(win, /agy-relay\.mjs" "%CLOUD_CODE_URL%"/, 'a routed agy runs behind its relay');
72
+ assert.match(posix, /exec node "[^"]*agy-relay\.mjs" "\$GATEWAY_URL" "\$REAL" "\$@"/);
73
+ assert.doesNotMatch(shim.renderShim('codex', 'win32'), /agy-relay/);
71
74
  });
72
75
 
73
76
  test('the agy hook is merged into hooks.json, unquoted, and removed without touching other hooks', () => {
package/tests/cdp.mjs CHANGED
@@ -47,7 +47,14 @@ export async function launchBrowser() {
47
47
  ...(process.platform === 'linux' || process.getuid?.() === 0 ? ['--no-sandbox'] : []),
48
48
  'about:blank'
49
49
  ];
50
- const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir } });
50
+ // POSIX: a process group of its own, so close() can stop the renderers and helpers too.
51
+ const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir }, detached: process.platform !== 'win32' });
52
+ const stop = (signal) => {
53
+ try {
54
+ if (process.platform !== 'win32') process.kill(-child.pid, signal);
55
+ else child.kill(signal);
56
+ } catch { /* already gone */ }
57
+ };
51
58
  // The tail of what Chromium printed: the only clue when it starts on a machine no one can watch.
52
59
  let stderr = '';
53
60
  child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
@@ -61,7 +68,7 @@ export async function launchBrowser() {
61
68
  if (!port) {
62
69
  // SIGKILL and a wait: a Chromium left running holds this test process open.
63
70
  if (exited === null) {
64
- child.kill('SIGKILL');
71
+ stop('SIGKILL');
65
72
  await new Promise(r => { child.once('exit', r); setTimeout(r, 3000); });
66
73
  }
67
74
  throw new Error(`Chromium did not open a debugging port (exit: ${exited ?? 'killed'})\n${stderr}`);
@@ -78,8 +85,10 @@ export async function launchBrowser() {
78
85
  },
79
86
  async close() {
80
87
  for (const p of pages) p.close();
81
- child.kill();
88
+ // The whole group: a helper that outlives the main process keeps writing into the profile.
89
+ stop('SIGTERM');
82
90
  await new Promise(r => { child.once('exit', r); setTimeout(r, 2000); });
91
+ stop('SIGKILL');
83
92
  // On Windows a Chromium child process can hold a cache file for a moment after the exit (EBUSY).
84
93
  fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
85
94
  }
@@ -0,0 +1,124 @@
1
+ // The key of the relay proof: random, independent of admin.token, served by no route, and the only
2
+ // key the relay accepts. Each case runs state.mjs in a child process, so it reads its own directory.
3
+ import { test, after } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const STATE = pathToFileURL(path.join(ROOT, 'state.mjs')).href;
14
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gwsecret-'));
15
+ after(() => fs.rmSync(TMP, { recursive: true, force: true }));
16
+ const TOKEN = 'c'.repeat(64);
17
+
18
+ function dir(name) {
19
+ const d = path.join(TMP, name);
20
+ fs.mkdirSync(d, { recursive: true });
21
+ fs.writeFileSync(path.join(d, 'admin.token'), TOKEN);
22
+ return d;
23
+ }
24
+ // Runs `code` (an async function body that gets `s`, the state module) against directory `d`.
25
+ function inState(d, code) {
26
+ const r = spawnSync(process.execPath, ['--input-type=module', '-e', `const s = await import(${JSON.stringify(STATE)}); const out = await (async () => { ${code} })(); process.stdout.write(JSON.stringify(out ?? null));`], {
27
+ env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(d, 'config.json'), LLM_SWITCHER_STATE_DIR: d }, encoding: 'utf8'
28
+ });
29
+ if (r.status !== 0) throw new Error(r.stderr);
30
+ return JSON.parse(r.stdout);
31
+ }
32
+ const secretOf = (d) => fs.readFileSync(path.join(d, 'gateway.secret'), 'utf8');
33
+
34
+ test('ensureGatewaySecret replaces a missing, empty or blank file and keeps a good one', () => {
35
+ const d = dir('ensure');
36
+ inState(d, 's.ensureGatewaySecret()');
37
+ const first = secretOf(d);
38
+ assert.match(first, /^[0-9a-f]{64}$/, 'hex only, no trailing newline');
39
+ inState(d, 's.ensureGatewaySecret()');
40
+ assert.equal(secretOf(d), first, 'an existing secret is kept');
41
+ for (const blank of ['', ' \n\t']) {
42
+ fs.writeFileSync(path.join(d, 'gateway.secret'), blank);
43
+ inState(d, 's.ensureGatewaySecret()');
44
+ assert.match(secretOf(d), /^[0-9a-f]{64}$/, `a file of ${JSON.stringify(blank)} is replaced`);
45
+ }
46
+ assert.deepEqual(fs.readdirSync(d).filter(f => f.endsWith('.tmp')), [], 'no temporary file is left');
47
+ });
48
+
49
+ test('the secret is random: never derived from admin.token or from the directory', () => {
50
+ const a = dir('indep-a');
51
+ const b = dir('indep-b');
52
+ inState(a, 's.ensureGatewaySecret()');
53
+ inState(b, 's.ensureGatewaySecret()');
54
+ const first = secretOf(a);
55
+ assert.notEqual(first, secretOf(b), 'two directories with the same admin.token');
56
+ assert.notEqual(first, TOKEN);
57
+ fs.rmSync(path.join(a, 'gateway.secret'));
58
+ inState(a, 's.ensureGatewaySecret()');
59
+ assert.notEqual(secretOf(a), first, 'the same directory and token again');
60
+ });
61
+
62
+ test('relayProof is an HMAC keyed by the raw bytes of gateway.secret', () => {
63
+ const d = dir('binding');
64
+ const raw = 'f'.repeat(64);
65
+ fs.writeFileSync(path.join(d, 'gateway.secret'), raw);
66
+ const out = inState(d, `return { secret: s.readGatewaySecret(), proof: s.relayProof('n1', { port: 3456, pid: 77 }) };`);
67
+ assert.equal(out.secret, raw);
68
+ assert.equal(out.proof, crypto.createHmac('sha256', raw).update('relay|3456|77|n1').digest('hex'));
69
+ });
70
+
71
+ test('without gateway.secret there is no proof at all, and no fallback to admin.token', () => {
72
+ const d = dir('nofallback');
73
+ const out = inState(d, `return { secret: s.readGatewaySecret(), proof: s.relayProof('n1', { port: 3456, pid: 77 }) };`);
74
+ assert.deepEqual(out, { secret: null, proof: '' });
75
+ });
76
+
77
+ test('ensureGatewaySecret throws when it cannot write, and leaves no temporary file', () => {
78
+ const d = dir('throws');
79
+ fs.mkdirSync(path.join(d, 'gateway.secret'));
80
+ assert.throws(() => inState(d, 's.ensureGatewaySecret()'));
81
+ assert.deepEqual(fs.readdirSync(d).filter(f => f.endsWith('.tmp')), []);
82
+ });
83
+
84
+ test('the blindfold proof keeps its exact formula', async () => {
85
+ const s = await import(STATE);
86
+ const proof = s.identityProof('n2', { role: 'blindfold', port: 3457, pid: 9, gatewayPort: 3456, activeTools: 'codex' }, TOKEN);
87
+ assert.equal(proof, crypto.createHmac('sha256', TOKEN).update('blindfold|3457|9|3456|codex|n2').digest('hex'));
88
+ });
89
+
90
+ test('only state.mjs touches gateway.secret: no other module of the package can read or serve it', () => {
91
+ const files = [...fs.readdirSync(ROOT).filter(f => f.endsWith('.mjs')).map(f => path.join(ROOT, f)),
92
+ ...fs.readdirSync(path.join(ROOT, 'blindfold')).filter(f => f.endsWith('.mjs')).map(f => path.join(ROOT, 'blindfold', f))];
93
+ assert.ok(files.length > 10, 'the scan found the package modules');
94
+ for (const f of files) {
95
+ if (path.basename(f) === 'state.mjs') continue;
96
+ const src = fs.readFileSync(f, 'utf8');
97
+ for (const name of ['gateway.secret', 'readGatewaySecret', 'gatewaySecretPath']) {
98
+ assert.equal(src.includes(name), false, `${path.relative(ROOT, f)} mentions ${name}`);
99
+ }
100
+ }
101
+ const proxy = fs.readFileSync(path.join(ROOT, 'proxy.mjs'), 'utf8');
102
+ assert.match(proxy, /ensureGatewaySecret\(\);/, 'the gateway makes its secret at startup and keeps no copy');
103
+ assert.match(proxy, /relayProof\(/);
104
+ });
105
+
106
+ test('gateway.secret stays out of git and out of the npm package', () => {
107
+ assert.ok(fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8').split(/\r?\n/).includes('gateway.secret'));
108
+ });
109
+
110
+ test('the .cmd command line: cmd.exe switches, quoting and the 8191 limit', async () => {
111
+ const { batCommandLine, CMD_LIMIT } = await import(pathToFileURL(path.join(ROOT, 'agy-relay-lib.mjs')).href);
112
+ const ok = batCommandLine('C:\\a\\agy.cmd', ['x']);
113
+ assert.deepEqual(ok.args.slice(0, 4), ['/e:ON', '/v:OFF', '/d', '/c']);
114
+ assert.equal(batCommandLine('C:\\a\\agy.cmd', ['50%']).args[4].includes('50%%cd:~,%'), true);
115
+ assert.equal(CMD_LIMIT, 8191);
116
+ // The /c string is `"` + `"C:\a\agy.cmd"` + ` ` + the argument + `"`: 17 code units around it.
117
+ const room = CMD_LIMIT - '""C:\\a\\agy.cmd" "'.length;
118
+ assert.equal(batCommandLine('C:\\a\\agy.cmd', ['z'.repeat(room)]).args[4].length, CMD_LIMIT);
119
+ assert.ok(batCommandLine('C:\\a\\agy.cmd', ['z'.repeat(room + 1)]).error);
120
+ // Counted in UTF-16 code units, not bytes: é is one unit and two UTF-8 bytes.
121
+ assert.ok(batCommandLine('C:\\a\\agy.cmd', ['é'.repeat(room - 2)]).args, 'non-ASCII is not refused early');
122
+ assert.ok(batCommandLine('C:\\a\\agy".cmd', []).error);
123
+ assert.ok(batCommandLine('C:\\a\\', []).error);
124
+ });
@@ -534,7 +534,9 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
534
534
  const port = server.address().port;
535
535
  const { out, err, ms } = await runAgyWindowsShim(port);
536
536
  if (routed) {
537
- assert.match(out, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
537
+ // agy gets the port of its own relay, which proves the gateway on every connection.
538
+ const relayPort = Number((out.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
539
+ assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out}`);
538
540
  assert.doesNotMatch(err, NOTICE);
539
541
  } else {
540
542
  assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
@@ -562,7 +564,8 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
562
564
  });
563
565
  fs.rmSync(dir, { recursive: true, force: true });
564
566
  if (routed) {
565
- assert.match(out.stdout, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
567
+ const relayPort = Number((out.stdout.match(/CCU=\[http:\/\/127\.0\.0\.1:(\d+)\]/) || [])[1]);
568
+ assert.ok(relayPort > 0 && relayPort !== port, `agy must get the relay port, not ${port}: ${out.stdout}`);
566
569
  assert.doesNotMatch(out.stderr, NOTICE);
567
570
  } else {
568
571
  assert.match(out.stdout, /CCU=\[\]/);
@@ -621,3 +621,22 @@ 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
+ });
@@ -196,7 +196,8 @@ test('the dashboard Update now button installs the release and reloads on the ne
196
196
  await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 30000);
197
197
  } catch (err) {
198
198
  // 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)}`;
199
+ const stage = await page.evaluate(`document.getElementById('update-status')?.textContent || ''`).catch(() => '(page gone)');
200
+ err.message += `\nupdate status on the page: ${stage}\ngateway log:\n${fx.log()}\npage problems: ${JSON.stringify(page.problems)}`;
200
201
  throw err;
201
202
  }
202
203
  assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);