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/switch.mjs CHANGED
@@ -232,8 +232,8 @@ async function stopProxy(port) {
232
232
  };
233
233
  // Our own unit needs no identity proof: its manager stops it. That also covers a unit that is
234
234
  // still starting (the port is free) and a hung gateway under the unit (the probe is silent).
235
- const svc = installedService();
236
- const ownUnit = svc && [port, null].includes(servicePort(svc));
235
+ const svc = serviceForPort(port);
236
+ const ownUnit = Boolean(svc);
237
237
  if (state === 'free') {
238
238
  if (!ownUnit) return 'not-running';
239
239
  serviceStop(svc);
@@ -271,7 +271,7 @@ async function changePort(newPortStr) {
271
271
  // Refuse before anything stops: the old gateway keeps running when the new port is taken.
272
272
  const target = await probeGateway(p);
273
273
  if (isHeld(target)) refuseForeignPort(p, 'this switcher', target);
274
- const svc = installedService();
274
+ const svc = serviceForPort(oldPort);
275
275
  // The unit is rewritten below. It keeps the choice that `service install --no-autoupdate` made.
276
276
  const svcText = svc ? serviceText(svc) : null;
277
277
  const autoupdate = svcText === null || autoupdateFromServiceText(svcText);
@@ -688,6 +688,13 @@ function servicePort(kind) {
688
688
  return text === null ? null : portFromServiceText(text);
689
689
  }
690
690
 
691
+ // The installed service runs the gateway of the port its definition names. A second instance
692
+ // (LLM_SWITCHER_PORT) must never stop or rewrite it. A definition with no readable port counts.
693
+ function serviceForPort(port) {
694
+ const svc = installedService();
695
+ return svc && [port, null].includes(servicePort(svc)) ? svc : null;
696
+ }
697
+
691
698
  function reportBackup(backup) {
692
699
  if (backup) console.log(`[Service] The previous definition differed and is kept as ${backup}.`);
693
700
  }
@@ -0,0 +1,316 @@
1
+ // Tests for agy-relay.mjs: agy's Google token reaches a gateway only on a connection where that
2
+ // gateway proved itself with the key of gateway.secret, also when the gateway stops in the middle
3
+ // of an agy session.
4
+ import { test, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import net from 'node:net';
10
+ import http from 'node:http';
11
+ import crypto from 'node:crypto';
12
+ import { spawn } from 'node:child_process';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const RELAY = path.join(ROOT, 'agy-relay.mjs');
17
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'agyrelay-'));
18
+ after(() => fs.rmSync(TMP, { recursive: true, force: true }));
19
+
20
+ // The directory of a relay run: admin.token, and gateway.secret unless `secret` is false.
21
+ const TOKEN = 'b'.repeat(64);
22
+ function stateDir(name, { secret = true } = {}) {
23
+ const dir = path.join(TMP, name);
24
+ fs.mkdirSync(dir, { recursive: true });
25
+ fs.writeFileSync(path.join(dir, 'admin.token'), TOKEN);
26
+ if (secret) fs.writeFileSync(path.join(dir, 'gateway.secret'), crypto.randomBytes(32).toString('hex'));
27
+ return dir;
28
+ }
29
+ const MAIN = stateDir('main');
30
+ const { identityProof } = await import(pathToFileURL(path.join(ROOT, 'state.mjs')).href);
31
+
32
+ // Stands in for agy: FAKE_CALLS requests with its Google token, then one more once `signal` exists.
33
+ const FAKE_AGY = path.join(TMP, 'fake-agy.mjs');
34
+ fs.writeFileSync(FAKE_AGY, `
35
+ import fs from 'node:fs';
36
+ import http from 'node:http';
37
+ const [, , code, signal] = process.argv;
38
+ const call = () => new Promise((resolve) => {
39
+ const req = http.request(process.env.CLOUD_CODE_URL + '/v1internal:loadCodeAssist', {
40
+ method: 'POST', agent: false, headers: { authorization: 'Bearer google-secret', 'content-type': 'application/json' }
41
+ }, (res) => { let b = ''; res.on('data', d => { b += d; }); res.on('end', () => { console.log('STATUS ' + res.statusCode + ' ' + b); resolve(); }); res.on('error', () => resolve()); });
42
+ req.on('error', (e) => { console.log('ERROR ' + e.message); resolve(); });
43
+ req.end('{}');
44
+ });
45
+ console.log('CCU ' + (process.env.CLOUD_CODE_URL || ''));
46
+ if (process.env.CLOUD_CODE_URL) for (let i = 0; i < Number(process.env.FAKE_CALLS || 1); i++) await call();
47
+ if (signal) { while (!fs.existsSync(signal)) await new Promise(r => setTimeout(r, 50)); await call(); }
48
+ process.exit(Number(code) || 0);
49
+ `);
50
+
51
+ const relayHmac = (key, port, pid, nonce) => crypto.createHmac('sha256', key).update(['relay', port, pid, nonce].join('|')).digest('hex');
52
+
53
+ // The answer of a gateway fixture to the identity probe. 'real' signs with the raw bytes of the
54
+ // gateway.secret file; every other kind is one way to be wrong.
55
+ function probeAnswer(kind, challenge, port, dir) {
56
+ const pid = 4242;
57
+ const secretFile = path.join(dir, 'gateway.secret');
58
+ const secret = fs.existsSync(secretFile) ? fs.readFileSync(secretFile) : Buffer.from('no-secret');
59
+ let status = 200;
60
+ let body = { status: 'ok', proxy: 'llm-switcher', port, pid, proof: identityProof(challenge, { role: 'gateway', port, pid }, TOKEN) };
61
+ if (kind === 'real' || kind === 'extra') body.relayProof = relayHmac(secret, port, pid, challenge);
62
+ if (kind === 'squatter') body.relayProof = relayHmac(TOKEN, port, pid, challenge);
63
+ if (kind === 'replay') Object.assign(body, { relayProof: relayHmac(secret, port, pid, 'stale-nonce'), nonce: 'stale-nonce', challenge: 'stale-nonce' });
64
+ if (kind === 'status500') { status = 500; body.relayProof = relayHmac(secret, port, pid, challenge); }
65
+ if (kind === 'oversize') Object.assign(body, { relayProof: relayHmac(secret, port, pid, challenge), pad: 'x'.repeat(70 * 1024) });
66
+ if (kind === 'empty') body.relayProof = '';
67
+ if (kind === 'null') body = null;
68
+ if (kind === 'number') body = 7;
69
+ if (kind === 'array') body = [];
70
+ const text = JSON.stringify(body);
71
+ let out = `HTTP/1.1 ${status} X\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(text)}\r\nConnection: keep-alive\r\n\r\n${text}`;
72
+ if (kind === 'extra') out += 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 13\r\n\r\nINJECTED-BODY';
73
+ return out;
74
+ }
75
+
76
+ // A raw gateway fixture on `port`. `kinds` is one kind, or one kind per connection in order (the
77
+ // last repeats). It records every challenge and every byte that arrives after its probe answer.
78
+ function gateway(kinds, { port = 0, dir = MAIN } = {}) {
79
+ const list = [].concat(kinds);
80
+ const seen = { challenges: [], bytes: '' };
81
+ let conn = 0;
82
+ const server = net.createServer((sock) => {
83
+ const kind = list[Math.min(conn++, list.length - 1)];
84
+ sock.on('error', () => {});
85
+ let buf = '';
86
+ let probed = false;
87
+ sock.on('data', (d) => {
88
+ if (kind === 'silent') { seen.bytes += d; return; }
89
+ if (probed) {
90
+ seen.bytes += d;
91
+ buf += d;
92
+ // 'real' answers each forwarded request once its 2-byte body is in.
93
+ const end = buf.indexOf('\r\n\r\n');
94
+ if (kind === 'real' && end >= 0 && buf.length >= end + 6) {
95
+ buf = '';
96
+ const answer = '{"answeredBy":"real"}';
97
+ sock.write(`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${answer.length}\r\nConnection: keep-alive\r\n\r\n${answer}`);
98
+ }
99
+ return;
100
+ }
101
+ buf += d;
102
+ const end = buf.indexOf('\r\n\r\n');
103
+ if (end < 0) return;
104
+ const head = buf.slice(0, end);
105
+ const rest = buf.slice(end + 4);
106
+ buf = '';
107
+ probed = true;
108
+ seen.challenges.push((head.match(/challenge=([0-9a-f]+)/) || [])[1] || '');
109
+ sock.write(probeAnswer(kind, seen.challenges.at(-1), server.address().port, dir));
110
+ if (rest) seen.bytes += rest;
111
+ });
112
+ });
113
+ return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve({ server, seen, port: server.address().port })));
114
+ }
115
+
116
+ function runRelay(gatewayUrl, args = [], { env = {}, dir = MAIN } = {}) {
117
+ const child = spawn(process.execPath, [RELAY, gatewayUrl, process.execPath, FAKE_AGY, ...args], {
118
+ env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(dir, 'config.json'), CLOUD_CODE_URL: gatewayUrl, ...env }
119
+ });
120
+ let out = '', err = '';
121
+ child.stdout.on('data', d => { out += d; });
122
+ child.stderr.on('data', d => { err += d; });
123
+ const done = new Promise((resolve) => {
124
+ const timer = setTimeout(() => child.kill(), 30000);
125
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }); });
126
+ });
127
+ return { child, done, out: () => out };
128
+ }
129
+
130
+ const statuses = (out) => out.split('\n').filter(l => l.startsWith('STATUS')).map(l => Number(l.split(' ')[1]));
131
+ const ccuPort = (out) => Number((out.match(/CCU http:\/\/127\.0\.0\.1:(\d+)/) || [])[1]);
132
+
133
+ test('agy talks to its own relay port, and the relay forwards to a gateway that proved itself with gateway.secret', async (t) => {
134
+ const g = await gateway('real');
135
+ t.after(() => g.server.close());
136
+ const { code, out } = await runRelay(`http://127.0.0.1:${g.port}`).done;
137
+ assert.equal(code, 0);
138
+ assert.ok(ccuPort(out) > 0 && ccuPort(out) !== g.port, `agy gets the relay port, not the gateway port: ${out}`);
139
+ assert.match(out, /STATUS 200 .*"answeredBy":"real"/);
140
+ assert.match(g.seen.bytes, /Bearer google-secret/);
141
+ });
142
+
143
+ // Every way a listener on the gateway port can be wrong: agy gets a 502, and its token never leaves.
144
+ for (const kind of ['squatter', 'replay', 'extra', 'status500', 'oversize', 'old']) {
145
+ test(`a gateway that answers the probe as '${kind}' gets a 502 and never receives the token`, async (t) => {
146
+ const g = await gateway(kind);
147
+ t.after(() => g.server.close());
148
+ const { code, out } = await runRelay(`http://127.0.0.1:${g.port}`).done;
149
+ assert.equal(code, 0);
150
+ assert.deepEqual(statuses(out), [502], out);
151
+ assert.doesNotMatch(g.seen.bytes, /google-secret/);
152
+ assert.doesNotMatch(out, /INJECTED-BODY/, 'bytes after the probe answer never reach agy');
153
+ if (kind === 'old') assert.match(out, /switch off.*switch on/, 'an old gateway gets the restart hint');
154
+ });
155
+ }
156
+
157
+ test('a silent listener only ever sees the probe, never the token', async (t) => {
158
+ const g = await gateway('silent');
159
+ t.after(() => g.server.close());
160
+ const { out } = await runRelay(`http://127.0.0.1:${g.port}`).done;
161
+ assert.deepEqual(statuses(out), [502]);
162
+ assert.match(g.seen.bytes, /^GET \/health\?challenge=/);
163
+ assert.doesNotMatch(g.seen.bytes, /google-secret/);
164
+ });
165
+
166
+ test('one relay sends a fresh challenge on every connection', async (t) => {
167
+ const g = await gateway('replay');
168
+ t.after(() => g.server.close());
169
+ await runRelay(`http://127.0.0.1:${g.port}`, [], { env: { FAKE_CALLS: '2' } }).done;
170
+ assert.equal(g.seen.challenges.length, 2);
171
+ assert.notEqual(g.seen.challenges[0], g.seen.challenges[1]);
172
+ assert.match(g.seen.challenges[0], /^[0-9a-f]{32}$/);
173
+ });
174
+
175
+ test('without gateway.secret the relay trusts no proof, not even one keyed by admin.token', async (t) => {
176
+ const dir = stateDir('nosecret', { secret: false });
177
+ for (const kind of ['empty', 'squatter']) {
178
+ const g = await gateway(kind, { dir });
179
+ t.after(() => g.server.close());
180
+ const { out } = await runRelay(`http://127.0.0.1:${g.port}`, [], { dir }).done;
181
+ assert.deepEqual(statuses(out), [502], `${kind}: ${out}`);
182
+ assert.doesNotMatch(g.seen.bytes, /google-secret/, kind);
183
+ }
184
+ });
185
+
186
+ test('an answer that is not an object gets a 502, and the relay keeps serving agy', async (t) => {
187
+ const g = await gateway(['null', 'number', 'array', 'real']);
188
+ t.after(() => g.server.close());
189
+ const { code, out } = await runRelay(`http://127.0.0.1:${g.port}`, [], { env: { FAKE_CALLS: '4' } }).done;
190
+ assert.equal(code, 0);
191
+ assert.deepEqual(statuses(out), [502, 502, 502, 200], out);
192
+ });
193
+
194
+ test('a gateway replaced in the middle of an agy session never receives the token', async (t) => {
195
+ const real = await gateway('real');
196
+ const port = real.port;
197
+ const signal = path.join(TMP, 'go-swap');
198
+ const run = runRelay(`http://127.0.0.1:${port}`, ['0', signal]);
199
+ for (let i = 0; i < 200 && !/STATUS/.test(run.out()); i++) await new Promise(r => setTimeout(r, 50));
200
+ assert.match(run.out(), /STATUS 200 /, 'the first request went through the real gateway');
201
+ // The gateway stops, and a process that holds admin.token takes its port.
202
+ await new Promise(r => real.server.close(r));
203
+ const squatter = await gateway('squatter', { port });
204
+ t.after(() => squatter.server.close());
205
+ fs.writeFileSync(signal, '');
206
+ const { out } = await run.done;
207
+ assert.deepEqual(statuses(out), [200, 502]);
208
+ assert.doesNotMatch(squatter.seen.bytes, /google-secret/);
209
+ });
210
+
211
+ test('the relay works against the real proxy.mjs and the proof it computes', async (t) => {
212
+ const dir = stateDir('realproxy', { secret: false });
213
+ fs.rmSync(path.join(dir, 'admin.token'));
214
+ const mock = http.createServer((req, res) => { req.resume(); res.end('{"answeredBy":"code-assist-mock"}'); });
215
+ await new Promise(r => mock.listen(0, '127.0.0.1', r));
216
+ t.after(() => mock.close());
217
+ const port = await new Promise(r => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
218
+ fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ port, activeProfiles: { claude: null, codex: null, agy: null }, profiles: {} }));
219
+ const proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], {
220
+ env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(dir, 'config.json'), LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: path.join(dir, 'claude'), LLM_SWITCHER_PORT: '', LLM_SWITCHER_CODE_ASSIST_URL: `http://127.0.0.1:${mock.address().port}` },
221
+ stdio: 'ignore'
222
+ });
223
+ t.after(() => proxy.kill());
224
+ for (let i = 0; i < 50; i++) {
225
+ try { if ((await fetch(`http://127.0.0.1:${port}/health`)).ok) break; } catch {}
226
+ await new Promise(r => setTimeout(r, 100));
227
+ }
228
+ assert.ok(fs.existsSync(path.join(dir, 'gateway.secret')), 'the gateway made its secret at startup');
229
+ const { out } = await runRelay(`http://127.0.0.1:${port}`, [], { dir }).done;
230
+ assert.match(out, /STATUS 200 .*code-assist-mock/, out);
231
+ });
232
+
233
+ test('the relay exits with the exit code of agy', async (t) => {
234
+ const g = await gateway('real');
235
+ t.after(() => g.server.close());
236
+ const { code } = await runRelay(`http://127.0.0.1:${g.port}`, ['7']).done;
237
+ assert.equal(code, 7);
238
+ });
239
+
240
+ test('a gateway address that is not 127.0.0.1 with a port leaves agy on its official endpoint', async () => {
241
+ for (const bad of ['http://localhost:3456', 'http://127.0.0.1', 'https://127.0.0.1:3456', 'not a url']) {
242
+ const { code, out, err } = await runRelay(bad).done;
243
+ assert.equal(code, 0, bad);
244
+ assert.match(out, /^CCU $/m, `${bad}: agy gets no CLOUD_CODE_URL`);
245
+ assert.match(err, /official endpoint/, bad);
246
+ }
247
+ });
248
+
249
+ // An argv string cannot hold NUL on any platform: Node refuses it before a process exists, so it
250
+ // can reach neither the relay nor agy. The relay's own NUL guard only backs this up.
251
+ test('an argument with a NUL character cannot start a process at all', () => {
252
+ assert.throws(() => spawn(process.execPath, [RELAY, 'http://127.0.0.1:9', process.execPath, FAKE_AGY, 'a\0b']), { code: 'ERR_INVALID_ARG_VALUE' });
253
+ });
254
+
255
+ // ---- A .cmd agy on Windows: every argument arrives byte-identical through cmd.exe ----------------
256
+
257
+ const WIN = process.platform === 'win32';
258
+ function cmdFixture(name) {
259
+ const dir = path.join(TMP, name);
260
+ fs.mkdirSync(dir, { recursive: true });
261
+ fs.writeFileSync(path.join(dir, 'argv.js'), `require('fs').writeFileSync(require('path').join(process.cwd(), 'argv.json'), JSON.stringify(process.argv.slice(2)));\n`);
262
+ fs.writeFileSync(path.join(dir, 'agy.cmd'), '@node "%~dp0argv.js" %*\r\n');
263
+ return dir;
264
+ }
265
+ function runCmdRelay(dir, args) {
266
+ const child = spawn(process.execPath, [RELAY, 'http://127.0.0.1', path.join(dir, 'agy.cmd'), ...args], {
267
+ cwd: dir, env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(MAIN, 'config.json') }
268
+ });
269
+ let err = '';
270
+ child.stderr.on('data', d => { err += d; });
271
+ return new Promise((resolve) => child.on('close', (code) => resolve({ code, err })));
272
+ }
273
+
274
+ test('a .cmd agy receives every argument byte-identical, and cmd.exe runs nothing else', { skip: !WIN && 'cmd.exe only' }, async () => {
275
+ const dir = cmdFixture('cmdargs');
276
+ const args = ['%PATH%', '%PATH:a=%', '%CMDCMDLINE:~-1%&echo PWNED>marker', 'C:\\temp\\', 'a"b', 'a\\"b', 'say "hi there"',
277
+ '50% off', 'x&y', 'a|b', 'a^b', '<x>', '(x)', '!PATH!', 'héllo wörld', ''];
278
+ const { code, err } = await runCmdRelay(dir, args);
279
+ assert.equal(code, 0, err);
280
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), args);
281
+ assert.equal(fs.existsSync(path.join(dir, 'marker')), false, 'no second command ran');
282
+ });
283
+
284
+ test('a .cmd agy in a directory with % in its name still starts', { skip: !WIN && 'cmd.exe only' }, async () => {
285
+ const dir = cmdFixture('50%off');
286
+ const { code, err } = await runCmdRelay(dir, ['ok']);
287
+ assert.equal(code, 0, err);
288
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), ['ok']);
289
+ });
290
+
291
+ test('a .cmd agy gets no line break and no command line over the cmd.exe limit', { skip: !WIN && 'cmd.exe only' }, async () => {
292
+ for (const [name, args, hint] of [['crlf-n', ['a\nb'], /line break/], ['crlf-r', ['a\rb'], /line break/], ['toolong', ['x'.repeat(8200)], /8191/]]) {
293
+ const dir = cmdFixture(name);
294
+ const { code, err } = await runCmdRelay(dir, args);
295
+ assert.notEqual(code, 0, name);
296
+ assert.match(err, hint, name);
297
+ assert.match(err, /stdin/, name);
298
+ assert.equal(fs.existsSync(path.join(dir, 'argv.json')), false, `${name}: the fixture never ran`);
299
+ }
300
+ const dir = cmdFixture('long-ok');
301
+ const long = 'y'.repeat(4000);
302
+ const { code, err } = await runCmdRelay(dir, [long]);
303
+ assert.equal(code, 0, err);
304
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), [long]);
305
+ });
306
+
307
+ test('an .exe agy gets a line break in an argument unchanged', async (t) => {
308
+ const dir = path.join(TMP, 'exe-newline');
309
+ fs.mkdirSync(dir, { recursive: true });
310
+ const script = path.join(dir, 'argv.mjs');
311
+ fs.writeFileSync(script, `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(path.join(dir, 'argv.json'))}, JSON.stringify(process.argv.slice(2)));\n`);
312
+ const child = spawn(process.execPath, [RELAY, 'http://127.0.0.1', process.execPath, script, 'a\nb'], { env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(MAIN, 'config.json') } });
313
+ const code = await new Promise(r => child.on('close', r));
314
+ assert.equal(code, 0);
315
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'argv.json'), 'utf8')), ['a\nb']);
316
+ });
@@ -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: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { id: 'r1', usage: { input_tokens: 5, output_tokens: 1 } } })}\n\n`);
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 fetch(url('/v1internal:breakMidway'), { method: 'POST', headers: agyHeaders(), body: '{}' });
387
- await assert.rejects(r.text());
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 fetch(`http://127.0.0.1:${port}/v1internal:stallMidway`, { method: 'POST', headers: agyHeaders(), body: '{}' });
395
- await assert.rejects(r.text());
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 fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agyg' }), body: JSON.stringify(envelope()) });
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
- await assert.rejects(r.text());
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) => {
@@ -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
- ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
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
- const child = spawn(findChromium(), args, { stdio: 'ignore', env: { ...process.env, HOME: dir } });
50
+ // POSIX: a process group of its own, so close() can stop the renderers and helpers too.
51
+ const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir }, detached: process.platform !== 'win32' });
52
+ const stop = (signal) => {
53
+ try {
54
+ if (process.platform !== 'win32') process.kill(-child.pid, signal);
55
+ else child.kill(signal);
56
+ } catch { /* already gone */ }
57
+ };
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 < 150 && !port; 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) { child.kill(); throw new Error('Chromium did not open a debugging 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
- child.kill();
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
- fs.rmSync(dir, { recursive: true, force: true });
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
- await browser?.close();
27
- await fx?.stop();
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 () => {