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 +7 -0
- package/README.md +12 -3
- package/README.vi.md +11 -3
- package/agy-relay-lib.mjs +86 -0
- package/agy-relay.mjs +142 -0
- package/package.json +1 -1
- package/proxy.mjs +6 -2
- package/shim.mjs +24 -2
- package/state.mjs +32 -0
- package/switch.mjs +10 -3
- package/tests/agy-relay.test.mjs +316 -0
- package/tests/agy.e2e.test.mjs +14 -0
- package/tests/agy.unit.test.mjs +3 -0
- package/tests/cdp.mjs +12 -3
- package/tests/gateway-secret.test.mjs +124 -0
- package/tests/shim.test.mjs +5 -2
- package/tests/switch.test.mjs +19 -0
- package/tests/update.e2e.test.mjs +2 -1
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
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
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
|
-
|
|
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 =
|
|
236
|
-
const ownUnit =
|
|
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 =
|
|
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
|
+
});
|
package/tests/agy.e2e.test.mjs
CHANGED
|
@@ -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 }) });
|
package/tests/agy.unit.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/tests/shim.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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=\[\]/);
|
package/tests/switch.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|