llm-switcher 1.1.0

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.
Files changed (51) hide show
  1. package/.gitattributes +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +587 -0
  4. package/README.vi.md +585 -0
  5. package/blindfold/blindfold.mjs +633 -0
  6. package/blindfold/make-certs.sh +88 -0
  7. package/blindfold/wsframe.mjs +176 -0
  8. package/codex-catalog-template.json +1 -0
  9. package/config.example.json +84 -0
  10. package/contract-exclusions.json +41 -0
  11. package/contract.mjs +561 -0
  12. package/docs/LLM-RESPONSE-MATRIX.md +165 -0
  13. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
  14. package/docs/codex-blindfold.md +214 -0
  15. package/docs/cross-platform.md +136 -0
  16. package/docs/diagrams/blindfold-request-routing.html +14972 -0
  17. package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
  18. package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
  19. package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
  20. package/docs/diagrams/codex-model-name-resolution.html +15005 -0
  21. package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
  22. package/docs/response-matrix.json +1131 -0
  23. package/formats.mjs +2308 -0
  24. package/mcp.mjs +340 -0
  25. package/package.json +36 -0
  26. package/proxy.mjs +1743 -0
  27. package/service.mjs +132 -0
  28. package/shim.mjs +292 -0
  29. package/skills/llm-switcher/SKILL.md +88 -0
  30. package/state.mjs +978 -0
  31. package/switch +5 -0
  32. package/switch.cmd +2 -0
  33. package/switch.mjs +930 -0
  34. package/tests/blindfold.test.mjs +307 -0
  35. package/tests/blindfold.wire.test.mjs +170 -0
  36. package/tests/contract/run.test.mjs +214 -0
  37. package/tests/contract-check.test.mjs +458 -0
  38. package/tests/contract-lab.test.mjs +755 -0
  39. package/tests/datadir.test.mjs +37 -0
  40. package/tests/formats.test.mjs +794 -0
  41. package/tests/gateway.e2e.test.mjs +999 -0
  42. package/tests/helpers.mjs +24 -0
  43. package/tests/lifecycle.test.mjs +416 -0
  44. package/tests/live-optimizer-interop.mjs +205 -0
  45. package/tests/mcp.test.mjs +91 -0
  46. package/tests/service.test.mjs +69 -0
  47. package/tests/shim.test.mjs +228 -0
  48. package/tests/state.test.mjs +675 -0
  49. package/tests/switch.test.mjs +156 -0
  50. package/tests/wsframe.test.mjs +154 -0
  51. package/ui.html +2234 -0
@@ -0,0 +1,307 @@
1
+ // Tests for blindfold/blindfold.mjs — the routing decision of a TLS-intercepting proxy.
2
+ //
3
+ // Real-world failure (2026-09-20, adversarial review): the first version decided with a
4
+ // bare `startsWith(API_PREFIX)` on the raw request target. Node does not normalize a
5
+ // request target, but the gateway does (`new URL(...).pathname`). So
6
+ // `/backend-api/codex/%2e%2e/api/logs` passed the prefix test, was forwarded as
7
+ // `/v1/%2e%2e/api/logs`, and the gateway resolved it to `/api/logs`. The forwarded
8
+ // headers also forge a loopback Host, which satisfies the admin API's only guard.
9
+ // One request through the proxy could read the provider API key.
10
+
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import zlib from 'node:zlib';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import http from 'node:http';
20
+ import net from 'node:net';
21
+ import {
22
+ isGatewayPath, toGatewayPath, isInterceptedHost, isLocalAddress, checkDestination,
23
+ API_PREFIX, GATEWAY_PREFIX, redactHeaders, captureName, decodeBody, writeCaptureFile,
24
+ relayToGateway, relayUpgradeToGateway
25
+ } from '../blindfold/blindfold.mjs';
26
+
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+
29
+ test('a dot-segment escape never reaches the gateway', () => {
30
+ for (const attack of [
31
+ '/backend-api/codex/%2e%2e/api/logs',
32
+ '/backend-api/codex/../api/logs',
33
+ '/backend-api/codex/%2E%2E/%2e%2e/api/fetch-models',
34
+ '/backend-api/codex/a/../../api/save-profile'
35
+ ]) {
36
+ assert.equal(isGatewayPath(attack), false, `must not route: ${attack}`);
37
+ }
38
+ });
39
+
40
+ test('a path that only shares the prefix string is passed through', () => {
41
+ assert.equal(isGatewayPath('/backend-api/codex-usage'), false);
42
+ assert.equal(isGatewayPath('/backend-api/codexfoo'), false);
43
+ assert.equal(isGatewayPath('/backend-api/codex_settings'), false);
44
+ });
45
+
46
+ test('a real Codex API path is routed, and its query string survives', () => {
47
+ assert.equal(isGatewayPath('/backend-api/codex/responses'), true);
48
+ assert.equal(isGatewayPath(API_PREFIX), true);
49
+ assert.equal(toGatewayPath('/backend-api/codex/x?y=1&z=2'), `${GATEWAY_PREFIX}/x?y=1&z=2`);
50
+ assert.equal(toGatewayPath('/backend-api/codex/responses'), `${GATEWAY_PREFIX}/responses`);
51
+ });
52
+
53
+ // A CONNECT to any other host must be tunneled, not intercepted: the process then
54
+ // only copies bytes and never holds that host's plaintext.
55
+ test('only the target host is intercepted; every other public host is tunneled', () => {
56
+ assert.equal(isInterceptedHost('chatgpt.com'), true);
57
+ for (const other of ['api.openai.com', 'auth.openai.com', 'example.com', 'chatgpt.com.evil.test']) {
58
+ assert.equal(isInterceptedHost(other), false, `must not intercept ${other}`);
59
+ }
60
+ });
61
+
62
+ // The listener is a proxy on loopback, so every local process can ask it for a
63
+ // destination. A local destination would turn it into a way to reach a service that
64
+ // listens only on this machine. The test runs on the resolved address, never on a spelling.
65
+ test('a local or private address is refused', () => {
66
+ for (const local of [
67
+ '127.0.0.1', '127.255.0.9', '0.0.0.0', '::1', '::', '::ffff:127.0.0.1', '::ffff:10.1.2.3',
68
+ '10.0.0.5', '192.168.1.10', '172.16.0.1', '172.31.255.254', '169.254.169.254', 'fe80::1', 'fd00::1', 'not-an-address', ''
69
+ ]) {
70
+ assert.equal(isLocalAddress(local), true, `must refuse ${local || '(empty)'}`);
71
+ }
72
+ // Shared address space (cloud metadata at 100.100.100.200, CGNAT, Tailscale), benchmark and IETF blocks,
73
+ // and IPv4 that NAT64 or 6to4 carries inside an IPv6 address.
74
+ for (const local of ['100.100.100.200', '100.64.0.1', '198.18.0.1', '192.0.0.8', '64:ff9b::a9fe:a9fe', '64:ff9b::10.0.0.5', '2002:7f00:1::1', '2002:c0a8:0101::']) {
75
+ assert.equal(isLocalAddress(local), true, `must refuse ${local}`);
76
+ }
77
+ // A public address that only looks similar stays allowed, also inside NAT64 and 6to4.
78
+ for (const publicHost of ['172.32.0.1', '11.0.0.1', '193.168.1.10', '8.8.8.8', '2606:4700::1111', '64:ff9b::808:808', '2002:0808:0808::1', '100.128.0.1']) {
79
+ assert.equal(isLocalAddress(publicHost), false, `must not refuse ${publicHost}`);
80
+ }
81
+ });
82
+
83
+ test('checkDestination refuses a name when any answer is local, and returns the checked address', async () => {
84
+ const lookupOf = (answers) => async () => answers.map(address => ({ address, family: address.includes(':') ? 6 : 4 }));
85
+ assert.deepEqual(await checkDestination('api.example.test', { lookup: lookupOf(['93.184.216.34']) }), { address: '93.184.216.34' });
86
+ assert.match((await checkDestination('rebind.example.test', { lookup: lookupOf(['93.184.216.34', '127.0.0.1']) })).refused, /local address 127\.0\.0\.1/);
87
+ assert.match((await checkDestination('[::1]', { lookup: lookupOf(['::1']) })).refused, /local/);
88
+ assert.match((await checkDestination('', {})).refused, /no host/);
89
+ const failed = await checkDestination('nx.example.test', { lookup: async () => { throw Object.assign(new Error('x'), { code: 'ENOTFOUND' }); } });
90
+ assert.equal(failed.status, 502);
91
+ // Real resolver, numeric spellings: no network needed.
92
+ for (const spelling of ['2130706433', '0x7f000001', '0', '127.1', 'localhost']) {
93
+ assert.ok((await checkDestination(spelling)).refused, `must refuse ${spelling}`);
94
+ }
95
+ });
96
+
97
+ test('a malformed target is refused rather than guessed', () => {
98
+ for (const bad of ['', null, undefined, 'http://evil.example/backend-api/codex/responses']) {
99
+ assert.equal(isGatewayPath(bad), false, `must not route: ${String(bad)}`);
100
+ }
101
+ });
102
+
103
+ // A capture records what a real client sends, so it must never record how that
104
+ // client authenticates. The header name stays so the request shape is still
105
+ // readable; only the value goes.
106
+ test('capture redaction removes credential values and keeps everything else', () => {
107
+ const out = redactHeaders({
108
+ 'Authorization': 'Bearer secret-token',
109
+ 'COOKIE': 'session=abc',
110
+ 'X-Api-Key': 'sk-123',
111
+ 'proxy-authorization': 'Basic zzz',
112
+ 'Content-Type': 'application/json',
113
+ 'user-agent': 'codex_cli_rs/0.154.0'
114
+ });
115
+ assert.equal(out['Authorization'], '<redacted>');
116
+ assert.equal(out['COOKIE'], '<redacted>', 'matching must be case-insensitive');
117
+ assert.equal(out['X-Api-Key'], '<redacted>');
118
+ assert.equal(out['proxy-authorization'], '<redacted>');
119
+ assert.equal(out['Content-Type'], 'application/json');
120
+ assert.equal(out['user-agent'], 'codex_cli_rs/0.154.0');
121
+ assert.ok(Object.keys(out).includes('Authorization'), 'the header name must survive');
122
+ });
123
+
124
+ // Real-world failure (2026-09-20): a capture of Claude Code recorded 647 bytes of
125
+ // gzip for a response that carried a full SSE stream. The client sends
126
+ // "accept-encoding: gzip", so reading the wire bytes as UTF-8 stores noise. The
127
+ // capture looked like an empty response and the defect was invisible until the
128
+ // file was parsed.
129
+ test('a compressed response body is decoded for the capture', () => {
130
+ const sse = 'event: message_start\ndata: {"type":"message_start"}\n\n';
131
+ assert.equal(decodeBody(zlib.gzipSync(Buffer.from(sse)), 'gzip'), sse);
132
+ assert.equal(decodeBody(zlib.gzipSync(Buffer.from(sse)), 'GZIP'), sse, 'must be case-insensitive');
133
+ assert.equal(decodeBody(zlib.brotliCompressSync(Buffer.from(sse)), 'br'), sse);
134
+ assert.equal(decodeBody(zlib.deflateSync(Buffer.from(sse)), 'deflate'), sse);
135
+ assert.equal(decodeBody(zlib.zstdCompressSync(Buffer.from(sse)), 'zstd'), sse);
136
+ });
137
+
138
+ test('an unencoded body is passed through untouched', () => {
139
+ const plain = '{"ok":true}';
140
+ assert.equal(decodeBody(Buffer.from(plain), undefined), plain);
141
+ assert.equal(decodeBody(Buffer.from(plain), 'identity'), plain);
142
+ assert.equal(decodeBody(Buffer.from(plain), ''), plain);
143
+ });
144
+
145
+ // A truncated stream is normal: the client can abort mid-answer. The capture must
146
+ // still be written, and it must not store bytes that read like a provider reply.
147
+ test('a body that cannot be decoded reports the reason instead of storing noise', () => {
148
+ const broken = zlib.gzipSync(Buffer.from('hello')).subarray(0, 8);
149
+ const out = decodeBody(broken, 'gzip');
150
+ assert.match(out, /^\[capture: cannot decode gzip body of 8 bytes: /);
151
+ });
152
+
153
+ test('capture filenames carry no path separator', () => {
154
+ const n = captureName('post', '/backend-api/codex/responses?stream=true', 1700000000000);
155
+ assert.ok(!/[\\/]/.test(n), `unsafe filename: ${n}`);
156
+ assert.match(n, /^1700000000000-POST-/);
157
+ assert.ok(!n.includes('?'), 'the query string must not reach the filename');
158
+ assert.match(captureName('GET', '/'), /-root\.json$/);
159
+ });
160
+
161
+ // A capture holds full prompts and answers. It must stay private to the owner, and it must
162
+ // never be written into a directory that another account created first.
163
+ test('captures are written 0600 inside a 0700 directory, never into a foreign directory', { skip: process.platform === 'win32' && 'posix modes' }, () => {
164
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cap-'));
165
+ try {
166
+ const dir = path.join(base, 'captures');
167
+ assert.equal(writeCaptureFile(dir, 'a.json', { ok: true }), true);
168
+ assert.equal((fs.statSync(dir).mode & 0o777).toString(8), '700');
169
+ assert.equal((fs.statSync(path.join(dir, 'a.json')).mode & 0o777).toString(8), '600');
170
+
171
+ // A symlink planted at the capture path must not redirect captures or re-mode its target.
172
+ const target = path.join(base, 'target');
173
+ fs.mkdirSync(target, { mode: 0o755 });
174
+ fs.chmodSync(target, 0o755);
175
+ fs.symlinkSync(target, path.join(base, 'linked'));
176
+ assert.equal(writeCaptureFile(path.join(base, 'linked'), 'c.json', { ok: true }), false);
177
+ assert.equal((fs.statSync(target).mode & 0o777).toString(8), '755', 'the symlink target keeps its mode');
178
+ assert.deepEqual(fs.readdirSync(target), []);
179
+
180
+ const foreign = path.join(base, 'foreign');
181
+ fs.mkdirSync(foreign, { mode: 0o777 });
182
+ const otherUid = (process.getuid?.() ?? 0) + 4242;
183
+ assert.equal(writeCaptureFile(foreign, 'b.json', { ok: true }, { uid: otherUid }), false);
184
+ assert.deepEqual(fs.readdirSync(foreign), [], 'nothing is written into a directory owned by another uid');
185
+ } finally {
186
+ fs.rmSync(base, { recursive: true, force: true });
187
+ }
188
+ });
189
+
190
+ // make-certs.sh writes the CA key. With a directory in /tmp (the documented recipe) another
191
+ // account can create it first or plant symlinks, so the script must refuse a directory it
192
+ // does not own and must write every file private.
193
+ test('make-certs.sh writes private files and refuses a directory it does not own', { skip: (process.platform === 'win32' || !fs.existsSync('/usr/bin/openssl')) && 'posix + openssl' }, () => {
194
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-certs-'));
195
+ const script = path.join(ROOT, 'blindfold', 'make-certs.sh');
196
+ try {
197
+ const out = path.join(base, 'certs');
198
+ execFileSync('bash', [script, 'example.test', out], { stdio: 'ignore' });
199
+ assert.equal((fs.statSync(out).mode & 0o777).toString(8), '700');
200
+ for (const f of ['ca.key', 'leaf.key', 'ca.pem', 'leaf.pem']) {
201
+ assert.equal((fs.statSync(path.join(out, f)).mode & 0o777).toString(8), '600', f);
202
+ }
203
+ // A failed rebuild (here: no openssl on PATH) must leave the working set untouched.
204
+ const caBefore = fs.readFileSync(path.join(out, 'ca.pem'));
205
+ const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-bin-'));
206
+ for (const tool of ['mkdir', 'stat', 'id', 'chmod', 'rm', 'cat', 'dirname', 'mv', 'mktemp']) {
207
+ const real = execFileSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).trim();
208
+ fs.symlinkSync(real, path.join(bin, tool));
209
+ }
210
+ let rebuildFailed = false;
211
+ try { execFileSync('/bin/bash', [script, 'example.test', out], { stdio: 'ignore', env: { ...process.env, PATH: bin } }); } catch { rebuildFailed = true; }
212
+ fs.rmSync(bin, { recursive: true, force: true });
213
+ assert.ok(rebuildFailed, 'the rebuild fails without openssl');
214
+ assert.deepEqual(fs.readFileSync(path.join(out, 'ca.pem')), caBefore, 'the previous CA survives a failed rebuild');
215
+ assert.ok(fs.existsSync(path.join(out, 'leaf.key')), 'the previous leaf key survives');
216
+ assert.deepEqual(fs.readdirSync(out).filter(f => f.startsWith('.build')), [], 'no build directory is left behind');
217
+
218
+ // /usr/share/doc exists and belongs to root: the script must stop before writing.
219
+ const rootOwned = '/usr/share/doc';
220
+ let failed = false;
221
+ try { execFileSync('bash', [script, 'example.test', rootOwned], { stdio: 'ignore' }); } catch { failed = true; }
222
+ assert.ok(failed, 'a directory owned by another account is refused with a non-zero exit');
223
+ assert.ok(!fs.existsSync(path.join(rootOwned, 'ca.key')));
224
+ } finally {
225
+ fs.rmSync(base, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ // ---- relay behaviour, through the real handlers against a stub gateway ----
230
+
231
+ const CREDENTIALS = {
232
+ authorization: 'Bearer chatgpt-oauth', 'proxy-authorization': 'Basic x', cookie: 'sid=1',
233
+ 'chatgpt-account-id': 'acct-1', 'openai-organization': 'org-1'
234
+ };
235
+
236
+ function listen(server) {
237
+ return new Promise(r => server.listen(0, '127.0.0.1', () => r(server.address().port)));
238
+ }
239
+
240
+ // The gateway port is plain HTTP on loopback. Codex's ChatGPT credentials must never travel
241
+ // there: the gateway does not read them, and another account can hold a freed port.
242
+ test('the gateway relay strips Codex credentials and keeps tracing headers', async () => {
243
+ const seen = [];
244
+ const stub = http.createServer((req, res) => { seen.push(req.headers); res.end('ok'); });
245
+ stub.on('upgrade', (req, socket) => { seen.push(req.headers); socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'); });
246
+ const stubPort = await listen(stub);
247
+ const front = http.createServer((req, res) => relayToGateway(req, res, { host: '127.0.0.1', port: stubPort }));
248
+ front.on('upgrade', (req, socket, head) => relayUpgradeToGateway(req, socket, head, { host: '127.0.0.1', port: stubPort }));
249
+ const frontPort = await listen(front);
250
+ try {
251
+ const r = await fetch(`http://127.0.0.1:${frontPort}${API_PREFIX}/responses`, { headers: { ...CREDENTIALS, 'x-request-id': 'trace-1' } });
252
+ assert.equal(await r.text(), 'ok');
253
+ await new Promise((resolve, reject) => {
254
+ const s = net.connect(frontPort, '127.0.0.1', () => {
255
+ const extra = Object.entries(CREDENTIALS).map(([k, v]) => `${k}: ${v}`).join('\r\n');
256
+ s.write(`GET ${API_PREFIX}/responses HTTP/1.1\r\nHost: chatgpt.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nx-request-id: trace-2\r\n${extra}\r\n\r\n`);
257
+ });
258
+ s.on('data', () => { s.destroy(); resolve(); });
259
+ s.on('error', reject);
260
+ });
261
+ assert.equal(seen.length, 2);
262
+ for (const h of seen) {
263
+ for (const name of Object.keys(CREDENTIALS)) assert.equal(h[name], undefined, `${name} must not reach the gateway`);
264
+ assert.match(h['x-request-id'], /^trace-/);
265
+ }
266
+ } finally {
267
+ front.close(); stub.close();
268
+ }
269
+ });
270
+
271
+ test('the gateway relay ends the client response when the gateway dies mid-body', async () => {
272
+ const stub = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.write('data: partial\n\n'); setTimeout(() => res.socket.destroy(), 50); });
273
+ const stubPort = await listen(stub);
274
+ const front = http.createServer((req, res) => relayToGateway(req, res, { host: '127.0.0.1', port: stubPort }));
275
+ const frontPort = await listen(front);
276
+ try {
277
+ const outcome = await new Promise(resolve => {
278
+ const timer = setTimeout(() => resolve('hung'), 2000);
279
+ http.get({ host: '127.0.0.1', port: frontPort, path: `${API_PREFIX}/responses` }, (res) => {
280
+ res.on('data', () => {});
281
+ res.on('close', () => { clearTimeout(timer); resolve('ended'); });
282
+ }).on('error', () => { clearTimeout(timer); resolve('ended'); });
283
+ });
284
+ assert.equal(outcome, 'ended');
285
+ } finally {
286
+ front.close(); stub.close();
287
+ }
288
+ });
289
+
290
+ test('the gateway relay closes the upstream request when the client aborts', async () => {
291
+ let upstreamClosed;
292
+ const closed = new Promise(r => { upstreamClosed = r; });
293
+ const stub = http.createServer((req, res) => { res.writeHead(200); res.write('start'); req.socket.on('close', upstreamClosed); });
294
+ const stubPort = await listen(stub);
295
+ const front = http.createServer((req, res) => relayToGateway(req, res, { host: '127.0.0.1', port: stubPort }));
296
+ const frontPort = await listen(front);
297
+ try {
298
+ const client = http.get({ host: '127.0.0.1', port: frontPort, path: `${API_PREFIX}/responses` }, (res) => {
299
+ res.once('data', () => client.destroy());
300
+ });
301
+ client.on('error', () => {});
302
+ const result = await Promise.race([closed.then(() => 'closed'), new Promise(r => setTimeout(() => r('still open'), 2000))]);
303
+ assert.equal(result, 'closed');
304
+ } finally {
305
+ front.close(); stub.close();
306
+ }
307
+ });
@@ -0,0 +1,170 @@
1
+ // Wire tests for blindfold.mjs: a real interceptor process, a real TLS session through CONNECT,
2
+ // and a fake gateway. They cover the capture path and the local-destination guard (audit F03,
3
+ // F15, F16).
4
+ import { test, before, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import net from 'node:net';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import tls from 'node:tls';
11
+ import crypto from 'node:crypto';
12
+ import { spawn, execFileSync } from 'node:child_process';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { createFrameReader } from '../blindfold/wsframe.mjs';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const HAS_OPENSSL = (() => { try { execFileSync('openssl', ['version'], { stdio: 'ignore' }); return true; } catch { return false; } })();
18
+ const SKIP = (process.platform === 'win32' || !HAS_OPENSSL) && 'posix + openssl';
19
+
20
+ let dir, certDir, captureDir, gateway, gatewayPort;
21
+
22
+ function freePort() {
23
+ return new Promise(resolve => { const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
24
+ }
25
+
26
+ function frame(opcode, text, { mask = false, length } = {}) {
27
+ const data = Buffer.from(text);
28
+ const len = length ?? data.length;
29
+ const head = len < 126 ? Buffer.from([0x80 | opcode, (mask ? 0x80 : 0) | len])
30
+ : Buffer.concat([Buffer.from([0x80 | opcode, (mask ? 0x80 : 0) | 127]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(len)); return b; })()]);
31
+ if (!mask) return Buffer.concat([head, data]);
32
+ const key = crypto.randomBytes(4);
33
+ return Buffer.concat([head, key, Buffer.from(data.map((b, i) => b ^ key[i & 3]))]);
34
+ }
35
+
36
+ // Fake gateway: answers the WS upgrade, then greets with one text frame.
37
+ function startGateway() {
38
+ gateway = net.createServer(socket => {
39
+ let head = '';
40
+ socket.on('data', chunk => {
41
+ if (head.includes('\r\n\r\n')) return;
42
+ head += chunk.toString('latin1');
43
+ if (!head.includes('\r\n\r\n')) return;
44
+ socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: x\r\n\r\n');
45
+ socket.write(frame(1, 'hello from gateway'));
46
+ });
47
+ socket.on('error', () => {});
48
+ });
49
+ return new Promise(r => gateway.listen(0, '127.0.0.1', () => { gatewayPort = gateway.address().port; r(); }));
50
+ }
51
+
52
+ async function startInterceptor() {
53
+ const port = await freePort();
54
+ const child = spawn(process.execPath, [path.join(ROOT, 'blindfold', 'blindfold.mjs'),
55
+ '--port', String(port), '--gateway-port', String(gatewayPort), '--certs', certDir,
56
+ '--capture', captureDir, '--token-file', path.join(dir, 'admin.token')], { stdio: ['ignore', 'pipe', 'pipe'] });
57
+ let out = '';
58
+ child.stdout.on('data', d => { out += d; });
59
+ child.stderr.on('data', d => { out += d; });
60
+ const exited = new Promise(r => child.on('exit', (code, signal) => r({ code, signal })));
61
+ for (let i = 0; i < 50 && !out.includes('proxy on'); i++) await new Promise(r => setTimeout(r, 100));
62
+ assert.match(out, /proxy on/, out);
63
+ return { port, child, exited, output: () => out };
64
+ }
65
+
66
+ function connectVia(port, target) {
67
+ return new Promise((resolve, reject) => {
68
+ const s = net.connect(port, '127.0.0.1', () => s.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`));
69
+ let reply = '';
70
+ const onData = d => {
71
+ reply += d.toString('latin1');
72
+ if (!reply.includes('\r\n\r\n')) return;
73
+ s.off('data', onData);
74
+ resolve({ socket: s, status: Number(reply.split(' ')[1]) });
75
+ };
76
+ s.on('data', onData);
77
+ s.on('error', reject);
78
+ });
79
+ }
80
+
81
+ // CONNECT, TLS with the private CA, then a WS upgrade on the Codex path.
82
+ async function openCodexWs(port) {
83
+ const { socket } = await connectVia(port, 'chatgpt.com:443');
84
+ const secure = tls.connect({ socket, servername: 'chatgpt.com', ca: fs.readFileSync(path.join(certDir, 'ca.pem')) });
85
+ await new Promise((r, j) => { secure.once('secureConnect', r); secure.once('error', j); });
86
+ secure.write(`GET /backend-api/codex/responses HTTP/1.1\r\nHost: chatgpt.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${crypto.randomBytes(16).toString('base64')}\r\n\r\n`);
87
+ const read = createFrameReader();
88
+ const messages = [];
89
+ let head = '';
90
+ secure.on('data', d => {
91
+ if (!head.includes('\r\n\r\n')) {
92
+ head += d.toString('latin1');
93
+ const end = head.indexOf('\r\n\r\n');
94
+ if (end < 0) return;
95
+ d = Buffer.from(head.slice(end + 4), 'latin1');
96
+ }
97
+ for (const f of read(d)) messages.push(f.payload?.toString());
98
+ });
99
+ secure.on('error', () => {});
100
+ for (let i = 0; i < 50 && !messages.length; i++) await new Promise(r => setTimeout(r, 50));
101
+ assert.deepEqual(messages, ['hello from gateway']);
102
+ return secure;
103
+ }
104
+
105
+ const captures = () => fs.readdirSync(captureDir).filter(f => f.endsWith('.json')).map(f => fs.readFileSync(path.join(captureDir, f), 'utf8'));
106
+
107
+ before(async () => {
108
+ if (SKIP) return;
109
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-wire-'));
110
+ certDir = path.join(dir, 'certs');
111
+ captureDir = path.join(dir, 'captures');
112
+ execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), 'chatgpt.com', certDir], { stdio: 'ignore' });
113
+ fs.writeFileSync(path.join(dir, 'admin.token'), 'wire-test-token', { mode: 0o600 });
114
+ await startGateway();
115
+ });
116
+
117
+ after(() => {
118
+ gateway?.close();
119
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
120
+ });
121
+
122
+ test('a SIGTERM writes the WS capture that is still waiting for its quiet period', { skip: SKIP }, async () => {
123
+ fs.rmSync(captureDir, { recursive: true, force: true });
124
+ const bf = await startInterceptor();
125
+ try {
126
+ const ws = await openCodexWs(bf.port);
127
+ ws.write(frame(1, 'hi from client', { mask: true }));
128
+ await new Promise(r => setTimeout(r, 100));
129
+ bf.child.kill('SIGTERM');
130
+ await bf.exited;
131
+ const all = captures().join('\n');
132
+ assert.match(all, /hello from gateway/);
133
+ assert.match(all, /hi from client/);
134
+ } finally {
135
+ bf.child.kill('SIGKILL');
136
+ }
137
+ });
138
+
139
+ test('an undecodable WS frame in capture mode is recorded, and the interceptor keeps running', { skip: SKIP }, async () => {
140
+ fs.rmSync(captureDir, { recursive: true, force: true });
141
+ const bf = await startInterceptor();
142
+ try {
143
+ const ws = await openCodexWs(bf.port);
144
+ ws.write(frame(1, '', { mask: true, length: 2 ** 60 }).subarray(0, 14));
145
+ await new Promise(r => setTimeout(r, 300));
146
+ assert.equal(bf.child.exitCode, null, `interceptor exited:\n${bf.output()}`);
147
+ const next = await openCodexWs(bf.port);
148
+ next.destroy();
149
+ ws.destroy();
150
+ bf.child.kill('SIGTERM');
151
+ await bf.exited;
152
+ assert.match(captures().join('\n'), /"type": "error"/);
153
+ } finally {
154
+ bf.child.kill('SIGKILL');
155
+ }
156
+ });
157
+
158
+ test('a CONNECT to a local address is refused whatever its spelling', { skip: SKIP }, async () => {
159
+ const bf = await startInterceptor();
160
+ try {
161
+ for (const target of ['2130706433:22', '0x7f000001:22', '0:22', '[::ffff:127.0.0.1]:22', '[::]:22', '127.1:22']) {
162
+ const { socket, status } = await connectVia(bf.port, target);
163
+ socket.destroy();
164
+ assert.equal(status, 403, target);
165
+ }
166
+ assert.match(bf.output(), /refused CONNECT/);
167
+ } finally {
168
+ bf.child.kill('SIGKILL');
169
+ }
170
+ });