llm-switcher 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +15 -5
- package/README.vi.md +14 -5
- package/agy-relay-lib.mjs +86 -0
- package/agy-relay.mjs +142 -0
- package/package.json +1 -1
- package/proxy.mjs +10 -7
- package/shim.mjs +24 -2
- package/state.mjs +32 -0
- package/switch.mjs +11 -4
- 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/dashboard-fixture.mjs +2 -0
- package/tests/dashboard.e2e.test.mjs +16 -0
- package/tests/gateway-secret.test.mjs +124 -0
- package/tests/gateway.e2e.test.mjs +8 -5
- package/tests/shim.test.mjs +45 -8
- package/tests/switch.test.mjs +40 -0
- package/tests/update.e2e.test.mjs +7 -3
- package/ui.html +8 -5
|
@@ -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
|
}
|
|
@@ -134,6 +134,8 @@ export async function startDashboardFixture() {
|
|
|
134
134
|
upstream,
|
|
135
135
|
upstreamBase,
|
|
136
136
|
dashboardUrl: `http://127.0.0.1:${port}/ui`,
|
|
137
|
+
// How `switch ui` opens it: the token comes in the fragment, never in the page.
|
|
138
|
+
tokenUrl: () => `http://127.0.0.1:${port}/ui#token=${adminToken()}`,
|
|
137
139
|
// What is on disk, which the dashboard has to agree with after every action.
|
|
138
140
|
config: () => JSON.parse(fs.readFileSync(cfgPath, 'utf8')),
|
|
139
141
|
// A change made outside the dashboard, as `switch` in a terminal or an editor would make it.
|
|
@@ -20,6 +20,22 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
20
20
|
fx = await startDashboardFixture();
|
|
21
21
|
browser = await launchBrowser();
|
|
22
22
|
page = await browser.newPage();
|
|
23
|
+
// Once per tab, as the launcher of `switch ui` does; the tab keeps the token from then on.
|
|
24
|
+
await page.goto(fx.tokenUrl());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('a tab opened without the launcher token says to open the dashboard with switch ui', async () => {
|
|
28
|
+
const fresh = await browser.newPage();
|
|
29
|
+
try {
|
|
30
|
+
await fresh.goto(fx.dashboardUrl);
|
|
31
|
+
await fresh.waitFor(`/switch ui/.test(document.getElementById('toast')?.textContent || '')`, 'the switch ui hint');
|
|
32
|
+
} finally {
|
|
33
|
+
// Close the tab itself, then raise the main one: a tab left in the background gets throttled
|
|
34
|
+
// timers and no animation frames, which stalls every later test.
|
|
35
|
+
await fresh.send('Page.close').catch(() => {});
|
|
36
|
+
fresh.close();
|
|
37
|
+
await page.send('Page.bringToFront');
|
|
38
|
+
}
|
|
23
39
|
});
|
|
24
40
|
|
|
25
41
|
after(async () => {
|
|
@@ -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
|
+
});
|
|
@@ -679,12 +679,15 @@ test('Security: foreign Host / Origin are rejected (DNS rebinding & CSRF)', asyn
|
|
|
679
679
|
|
|
680
680
|
// Any local process can reach loopback. Without a token it must get nothing from /api/*,
|
|
681
681
|
// and a masked key must never be resolved for a baseURL the profile does not have.
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
test('Dashboard: /ui
|
|
682
|
+
// Any process on the machine can GET /ui, another account's included, so the page carries no token.
|
|
683
|
+
// The dashboard gets it from the #token of the private launcher that `switch ui` opens.
|
|
684
|
+
test('Dashboard: /ui and / never carry the admin token, for this machine or another site', async () => {
|
|
685
685
|
for (const p of ['/ui', '/']) {
|
|
686
|
-
const
|
|
687
|
-
assert.
|
|
686
|
+
const r = await fetch(url(p));
|
|
687
|
+
assert.equal(r.status, 200, p);
|
|
688
|
+
const html = await r.text();
|
|
689
|
+
assert.ok(html.includes('<title>'), `${p} serves the dashboard`);
|
|
690
|
+
assert.ok(!html.includes(adminToken()), `${p} carries the admin token`);
|
|
688
691
|
}
|
|
689
692
|
const foreign = await fetch(url('/ui'), { headers: { origin: 'https://evil.example' } });
|
|
690
693
|
assert.equal(foreign.status, 403);
|