llm-switcher 1.3.1 → 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/.github/workflows/test.yml +31 -0
- package/CHANGELOG.md +14 -0
- package/README.md +22 -3
- package/README.vi.md +21 -3
- package/agy-relay-lib.mjs +86 -0
- package/agy-relay.mjs +142 -0
- package/blindfold/make-certs.sh +2 -1
- package/package.json +1 -1
- package/proxy.mjs +100 -3
- 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 +124 -7
- package/tests/agy.unit.test.mjs +3 -0
- package/tests/cdp.mjs +31 -6
- package/tests/dashboard.e2e.test.mjs +6 -2
- package/tests/gateway-secret.test.mjs +124 -0
- package/tests/gateway.e2e.test.mjs +2 -0
- package/tests/make-certs.test.mjs +14 -0
- package/tests/shim.test.mjs +5 -2
- package/tests/switch.test.mjs +19 -0
- package/tests/update.e2e.test.mjs +8 -1
- package/tests/ws-client.mjs +46 -0
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
|
@@ -9,6 +9,7 @@ import os from 'node:os';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { openResponsesWs } from './ws-client.mjs';
|
|
12
13
|
|
|
13
14
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
15
|
const AGY_UA = 'antigravity/cli/1.2.14 (aidev_client; os_type=windows; arch=amd64; cl=990662481; auth_method=consumer)';
|
|
@@ -70,6 +71,16 @@ function startUpstream() {
|
|
|
70
71
|
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }
|
|
71
72
|
]);
|
|
72
73
|
}
|
|
74
|
+
if (req.method === 'GET' && req.url.startsWith('/cbroken/v1/models/')) {
|
|
75
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
76
|
+
return res.end(JSON.stringify({ bifrost_ua: 'codex_cli_rs/' }));
|
|
77
|
+
}
|
|
78
|
+
if (req.url.startsWith('/cbroken/v1/responses')) {
|
|
79
|
+
// The first event, then the connection dies: no response.completed ever comes.
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
81
|
+
res.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: { id: 'resp_cut', status: 'in_progress' } })}\n\n`);
|
|
82
|
+
return setTimeout(() => res.socket.destroy(), 50);
|
|
83
|
+
}
|
|
73
84
|
if (req.method === 'GET' && req.url.startsWith('/broken/v1/models/')) {
|
|
74
85
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
75
86
|
return res.end(JSON.stringify({ bifrost_ua: 'antigravity/cli/' }));
|
|
@@ -127,8 +138,19 @@ function startUpstream() {
|
|
|
127
138
|
]);
|
|
128
139
|
}
|
|
129
140
|
if (req.url.startsWith('/intact/v1/responses')) {
|
|
141
|
+
// A Responses stream as the Codex account on intact sends it; each turn gets its own id.
|
|
142
|
+
// With store:false the real backend sends response.completed with an empty output: the
|
|
143
|
+
// items come only in response.output_item.done (measured 2026-10-03).
|
|
144
|
+
const id = `resp_${received.length}`;
|
|
145
|
+
const item = { type: 'message', id: `msg_${id}`, role: 'assistant', content: [{ type: 'output_text', text: 'codex pool' }] };
|
|
146
|
+
const events = [
|
|
147
|
+
{ type: 'response.created', response: { id, status: 'in_progress' } },
|
|
148
|
+
{ type: 'response.output_text.delta', delta: 'codex pool' },
|
|
149
|
+
{ type: 'response.output_item.done', output_index: 0, item },
|
|
150
|
+
{ type: 'response.completed', response: { id, status: 'completed', output: [], usage: { input_tokens: 5, output_tokens: 1 } } }
|
|
151
|
+
];
|
|
130
152
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
131
|
-
res.write(`event:
|
|
153
|
+
for (const e of events) res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
|
|
132
154
|
return res.end();
|
|
133
155
|
}
|
|
134
156
|
if (req.url.startsWith('/chat/v1/chat/completions')) {
|
|
@@ -170,6 +192,7 @@ before(async () => {
|
|
|
170
192
|
agyf: { name: 'agy flaky lookup', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/flaky/v1`, apiKey: 'sk-flaky', defaultModels: { main: 'antigravity/*' } },
|
|
171
193
|
agynf: { name: 'agy lookup 404', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/nf/v1`, apiKey: 'sk-nf', defaultModels: { main: 'antigravity/*' } },
|
|
172
194
|
agyg: { name: 'agy broken stream', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/broken/v1`, apiKey: 'sk-broken', defaultModels: { main: 'antigravity/*' } },
|
|
195
|
+
cdxcut: { name: 'codex cut stream', mode: 'convert', tool: 'codex', outFormat: 'openai-chat', baseURL: `${base}/cbroken/v1`, apiKey: 'sk-cut', defaultModels: { main: 'codex/gpt-5.5' } },
|
|
173
196
|
cdx: { name: 'codex on intact', mode: 'convert', tool: 'codex', outFormat: 'openai-chat', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact', defaultModels: { main: 'codex/gpt-5.5' } }
|
|
174
197
|
}
|
|
175
198
|
};
|
|
@@ -237,6 +260,19 @@ const envelope = (over = {}) => ({
|
|
|
237
260
|
},
|
|
238
261
|
...over
|
|
239
262
|
});
|
|
263
|
+
// Whether the gateway ended its answer as a whole one. Read from the HTTP framing, because fetch in
|
|
264
|
+
// Node 18 takes a chunked body that a closed socket cut short as complete.
|
|
265
|
+
function answerIsComplete(target, { method = 'POST', headers = {}, body = '' } = {}) {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const req = http.request(target, { method, headers: { ...headers, 'content-length': Buffer.byteLength(body) } }, (res) => {
|
|
268
|
+
res.on('error', () => {});
|
|
269
|
+
res.on('close', () => resolve({ status: res.statusCode, complete: res.complete }));
|
|
270
|
+
res.resume();
|
|
271
|
+
});
|
|
272
|
+
req.on('error', reject);
|
|
273
|
+
req.end(body);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
240
276
|
const lastTo = (prefix) => [...received].reverse().find(r => r.url.startsWith(prefix));
|
|
241
277
|
const dataLines = (text) => text.split('\n').filter(l => l.startsWith('data:')).map(l => JSON.parse(l.slice(5)));
|
|
242
278
|
|
|
@@ -383,16 +419,16 @@ test('a Code Assist host that never answers gives a 504 after the idle timeout',
|
|
|
383
419
|
|
|
384
420
|
test('an upstream that stops in the middle of a body ends the answer to agy with an error', async () => {
|
|
385
421
|
const started = Date.now();
|
|
386
|
-
const r = await
|
|
387
|
-
|
|
422
|
+
const r = await answerIsComplete(url('/v1internal:breakMidway'), { headers: agyHeaders(), body: '{}' });
|
|
423
|
+
assert.equal(r.complete, false);
|
|
388
424
|
assert.ok(Date.now() - started < 10000);
|
|
389
425
|
});
|
|
390
426
|
|
|
391
427
|
test('an upstream that stalls after its headers is cut by the idle timeout', async () => {
|
|
392
428
|
const port = await startGateway({ LLM_SWITCHER_CODE_ASSIST_URL: `${base}/google`, LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS: '1000' });
|
|
393
429
|
const started = Date.now();
|
|
394
|
-
const r = await
|
|
395
|
-
|
|
430
|
+
const r = await answerIsComplete(`http://127.0.0.1:${port}/v1internal:stallMidway`, { headers: agyHeaders(), body: '{}' });
|
|
431
|
+
assert.equal(r.complete, false);
|
|
396
432
|
assert.ok(Date.now() - started < 5000, `took ${Date.now() - started} ms`);
|
|
397
433
|
});
|
|
398
434
|
|
|
@@ -515,9 +551,90 @@ test('an agent turn that names a profile that does not exist gets a 400, never G
|
|
|
515
551
|
});
|
|
516
552
|
|
|
517
553
|
test('a Bifrost stream that breaks ends the answer to agy with an error, not a clean end', async () => {
|
|
518
|
-
const r = await
|
|
554
|
+
const r = await answerIsComplete(url('/v1internal:streamGenerateContent?alt=sse'), { headers: agyHeaders({ 'x-llm-profile': 'agyg' }), body: JSON.stringify(envelope()) });
|
|
519
555
|
assert.equal(r.status, 200);
|
|
520
|
-
|
|
556
|
+
assert.equal(r.complete, false);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('Codex on the WebSocket transport crosses Bifrost too, and its turn history reaches intact', async () => {
|
|
560
|
+
const ws = await openResponsesWs(proxyPort, { 'User-Agent': CODEX_UA, 'x-llm-profile': 'cdx', Authorization: 'Bearer chatgpt-user-token' });
|
|
561
|
+
try {
|
|
562
|
+
assert.match(ws.reply, /^HTTP\/1\.1 101/);
|
|
563
|
+
const before = received.length;
|
|
564
|
+
const turn1 = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }];
|
|
565
|
+
ws.send({ type: 'response.create', model: 'gpt-5.5', instructions: 'x', input: turn1, store: false });
|
|
566
|
+
const done = async (n) => {
|
|
567
|
+
for (let i = 0; i < 100 && ws.messages.filter(m => m.type === 'response.completed').length < n; i++) await new Promise(r => setTimeout(r, 30));
|
|
568
|
+
return ws.messages.filter(m => m.type === 'response.completed')[n - 1];
|
|
569
|
+
};
|
|
570
|
+
const first = await done(1);
|
|
571
|
+
assert.ok(first, `no response.completed: ${JSON.stringify(ws.messages).slice(0, 300)}`);
|
|
572
|
+
assert.ok(ws.messages.some(m => m.type === 'response.output_text.delta' && m.delta === 'codex pool'), 'the events of intact reach Codex');
|
|
573
|
+
|
|
574
|
+
const up1 = received.slice(before).find(x => x.url.startsWith('/intact/v1/responses'));
|
|
575
|
+
assert.ok(up1, 'the WS turn must reach intact /responses, not the convert route');
|
|
576
|
+
assert.equal(up1.method, 'POST');
|
|
577
|
+
assert.equal(up1.headers['x-api-key'], 'sk-intact');
|
|
578
|
+
assert.equal(up1.headers.authorization, undefined, 'the ChatGPT token never reaches intact');
|
|
579
|
+
assert.equal(up1.headers['user-agent'], CODEX_UA);
|
|
580
|
+
for (const h of ['upgrade', 'sec-websocket-key', 'sec-websocket-version', 'x-llm-profile']) assert.equal(up1.headers[h], undefined, `${h} is a WebSocket or switcher header`);
|
|
581
|
+
assert.equal(up1.body.model, 'codex/gpt-5.5');
|
|
582
|
+
assert.equal(up1.body.stream, true);
|
|
583
|
+
assert.equal(up1.body.type, undefined, 'the WS frame type is not part of a Responses request');
|
|
584
|
+
assert.deepEqual(up1.body.input, turn1);
|
|
585
|
+
|
|
586
|
+
// Turn 2 names turn 1; intact has no WS state, so it gets the whole conversation.
|
|
587
|
+
const turn2 = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'again' }] }];
|
|
588
|
+
ws.send({ type: 'response.create', model: 'gpt-5.5', previous_response_id: first.response.id, input: turn2, store: false });
|
|
589
|
+
assert.ok(await done(2), 'no second response.completed');
|
|
590
|
+
const up2 = received.slice(before).filter(x => x.url.startsWith('/intact/v1/responses'))[1];
|
|
591
|
+
const answer1 = ws.messages.find(m => m.type === 'response.output_item.done').item;
|
|
592
|
+
assert.deepEqual(up2.body.input, [...turn1, answer1, ...turn2], 'the answer of turn 1 is replayed, though response.completed had no output');
|
|
593
|
+
assert.equal(up2.body.previous_response_id, undefined);
|
|
594
|
+
|
|
595
|
+
const token = fs.readFileSync(path.join(tmpDir, 'admin.token'), 'utf8').trim();
|
|
596
|
+
const { logs } = await (await fetch(url('/api/logs'), { headers: { 'x-llm-switcher-token': token } })).json();
|
|
597
|
+
const entry = logs.find(l => l.clientFormat === 'responses-ws' && l.outFormat === 'bifrost');
|
|
598
|
+
assert.ok(entry, 'the inspector names the WS Bifrost route');
|
|
599
|
+
assert.deepEqual(entry.tokens, { prompt: 5, completion: 1 });
|
|
600
|
+
} finally {
|
|
601
|
+
ws.close();
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test('codex exec (originator codex_exec) crosses Bifrost like the Codex TUI', async () => {
|
|
606
|
+
const before = received.length;
|
|
607
|
+
const body = { model: 'gpt-5.5', instructions: 'x', input: [{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] }], stream: true, store: false };
|
|
608
|
+
const r = await fetch(url('/v1/responses'), { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'codex_exec/0.160.0 (Windows 10.0.26300; x86_64) unknown', 'x-llm-profile': 'cdx' }, body: JSON.stringify(body) });
|
|
609
|
+
await r.text();
|
|
610
|
+
assert.ok(received.slice(before).some(x => x.url.startsWith('/intact/v1/responses')), 'codex_exec must reach intact /responses');
|
|
611
|
+
assert.equal(received.slice(before).some(x => x.url.startsWith('/intact/v1/chat/completions')), false, 'not the convert route');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test('a Codex WS Bifrost stream cut before its end still ends the turn with response.failed', async () => {
|
|
615
|
+
const ws = await openResponsesWs(proxyPort, { 'User-Agent': CODEX_UA, 'x-llm-profile': 'cdxcut' });
|
|
616
|
+
try {
|
|
617
|
+
ws.send({ type: 'response.create', model: 'gpt-5.5', input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }] });
|
|
618
|
+
for (let i = 0; i < 150 && !ws.messages.some(m => m.type === 'response.failed'); i++) await new Promise(r => setTimeout(r, 30));
|
|
619
|
+
assert.ok(ws.messages.some(m => m.type === 'response.created' && m.response.id === 'resp_cut'), 'the events before the cut reach Codex');
|
|
620
|
+
assert.ok(ws.messages.some(m => m.type === 'response.failed'), `no terminal event: ${JSON.stringify(ws.messages).slice(0, 300)}`);
|
|
621
|
+
} finally {
|
|
622
|
+
ws.close();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
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');
|
|
521
638
|
});
|
|
522
639
|
|
|
523
640
|
test('with agy switched off, its agent turns go back to Google', async (t) => {
|
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
|
@@ -41,16 +41,38 @@ export async function launchBrowser() {
|
|
|
41
41
|
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
|
|
42
42
|
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-gpu',
|
|
43
43
|
'--disable-background-networking', '--window-size=1280,900',
|
|
44
|
-
|
|
44
|
+
// macOS: no keychain prompt, which would block a headless run with no one to answer it.
|
|
45
|
+
'--use-mock-keychain', '--password-store=basic',
|
|
46
|
+
// Ubuntu 24.04 blocks the user namespaces the Chrome sandbox needs. The page is a local test.
|
|
47
|
+
...(process.platform === 'linux' || process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
45
48
|
'about:blank'
|
|
46
49
|
];
|
|
47
|
-
|
|
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
|
+
};
|
|
58
|
+
// The tail of what Chromium printed: the only clue when it starts on a machine no one can watch.
|
|
59
|
+
let stderr = '';
|
|
60
|
+
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
|
|
61
|
+
let exited = null;
|
|
62
|
+
child.once('exit', (code, signal) => { exited = signal || code; });
|
|
48
63
|
let port = 0;
|
|
49
|
-
for (let i = 0; i <
|
|
64
|
+
for (let i = 0; i < 300 && !port && exited === null; i++) {
|
|
50
65
|
try { port = Number(fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0]) || 0; } catch { /* not written yet */ }
|
|
51
66
|
if (!port) await sleep(100);
|
|
52
67
|
}
|
|
53
|
-
if (!port) {
|
|
68
|
+
if (!port) {
|
|
69
|
+
// SIGKILL and a wait: a Chromium left running holds this test process open.
|
|
70
|
+
if (exited === null) {
|
|
71
|
+
stop('SIGKILL');
|
|
72
|
+
await new Promise(r => { child.once('exit', r); setTimeout(r, 3000); });
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Chromium did not open a debugging port (exit: ${exited ?? 'killed'})\n${stderr}`);
|
|
75
|
+
}
|
|
54
76
|
|
|
55
77
|
const pages = [];
|
|
56
78
|
return {
|
|
@@ -63,9 +85,12 @@ export async function launchBrowser() {
|
|
|
63
85
|
},
|
|
64
86
|
async close() {
|
|
65
87
|
for (const p of pages) p.close();
|
|
66
|
-
|
|
88
|
+
// The whole group: a helper that outlives the main process keeps writing into the profile.
|
|
89
|
+
stop('SIGTERM');
|
|
67
90
|
await new Promise(r => { child.once('exit', r); setTimeout(r, 2000); });
|
|
68
|
-
|
|
91
|
+
stop('SIGKILL');
|
|
92
|
+
// On Windows a Chromium child process can hold a cache file for a moment after the exit (EBUSY).
|
|
93
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
69
94
|
}
|
|
70
95
|
};
|
|
71
96
|
}
|
|
@@ -23,8 +23,12 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
after(async () => {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// The gateway must stop even when the browser cleanup throws, or it holds this process open.
|
|
27
|
+
try {
|
|
28
|
+
await browser?.close();
|
|
29
|
+
} finally {
|
|
30
|
+
await fx?.stop();
|
|
31
|
+
}
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
beforeEach(async () => {
|