llm-switcher 1.1.11 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// Task 3 — R3, R3a, R3b, R3c and Acceptance A4, A4b, A9, A12.
|
|
2
|
+
//
|
|
3
|
+
// The invariant this task has to hold: the interceptor stands in the middle of the network and
|
|
4
|
+
// touches nothing else. It answers on a fixed host table instead of a host it was told, it
|
|
5
|
+
// carries one certificate for those three hosts, it has no prefix to choose, it keeps the bytes
|
|
6
|
+
// of a pass-through exchange out of the capture, and the only thing that can change while it
|
|
7
|
+
// runs — the active tool set — arrives over one authenticated loopback POST.
|
|
8
|
+
//
|
|
9
|
+
// state.mjs binds its paths (config, admin token, CA) at import time, so the workspace has to
|
|
10
|
+
// exist before the import. The test runner gives each test file its own process, so this
|
|
11
|
+
// environment is this file's alone.
|
|
12
|
+
|
|
13
|
+
import test from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import net from 'node:net';
|
|
20
|
+
import tls from 'node:tls';
|
|
21
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
22
|
+
import { once } from 'node:events';
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-task3-'));
|
|
27
|
+
process.env.LLM_SWITCHER_HOME = TMP;
|
|
28
|
+
process.env.LLM_SWITCHER_CONFIG = path.join(TMP, 'config.json');
|
|
29
|
+
delete process.env.LLM_SWITCHER_BLINDFOLD_CERTS;
|
|
30
|
+
|
|
31
|
+
const s = await import('../state.mjs');
|
|
32
|
+
const bf = await import('../blindfold/blindfold.mjs');
|
|
33
|
+
|
|
34
|
+
const INTERCEPTOR = path.join(ROOT, 'blindfold', 'blindfold.mjs');
|
|
35
|
+
const CERT_SCRIPT = path.join(ROOT, 'blindfold', 'make-certs.sh');
|
|
36
|
+
const CA_PEM = path.join(TMP, 'blindfold', 'certs', 'ca.pem');
|
|
37
|
+
|
|
38
|
+
// Windows has no `bash` on PATH, but Git for Windows ships one and it carries openssl — which is
|
|
39
|
+
// all make-certs.sh needs. Without either, the certificate tests say so instead of passing
|
|
40
|
+
// with no assertion at all.
|
|
41
|
+
function findBash() {
|
|
42
|
+
for (const candidate of ['bash', 'C:\\Program Files\\Git\\bin\\bash.exe', '/usr/bin/bash', '/bin/bash']) {
|
|
43
|
+
try {
|
|
44
|
+
const probe = spawnSync(candidate, ['--version'], { stdio: 'ignore' });
|
|
45
|
+
if (!probe.error && probe.status === 0) return candidate;
|
|
46
|
+
} catch { /* try the next one */ }
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const BASH = findBash();
|
|
52
|
+
const toBashPath = (p) => p.replace(/\\/g, '/');
|
|
53
|
+
|
|
54
|
+
function makeCerts(outDir) {
|
|
55
|
+
// The host argument is from an older command line and is ignored (R3): only the directory counts.
|
|
56
|
+
const r = spawnSync(BASH, [CERT_SCRIPT, 'chatgpt.com', toBashPath(outDir)], { encoding: 'utf8' });
|
|
57
|
+
if (r.status !== 0) throw new Error(`make-certs.sh failed: ${r.stderr || r.stdout}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let certProblem = null;
|
|
61
|
+
try {
|
|
62
|
+
if (!BASH) throw new Error('no bash (needs Git for Windows on this machine)');
|
|
63
|
+
makeCerts(path.join(TMP, 'blindfold', 'certs'));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
certProblem = err.message;
|
|
66
|
+
}
|
|
67
|
+
const noCerts = certProblem ? `needs certificates: ${certProblem}` : false;
|
|
68
|
+
|
|
69
|
+
const TOKEN = s.ensureAdminToken();
|
|
70
|
+
const freePort = async () => {
|
|
71
|
+
const srv = net.createServer();
|
|
72
|
+
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
|
|
73
|
+
const { port } = srv.address();
|
|
74
|
+
await new Promise((r) => srv.close(r));
|
|
75
|
+
return port;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ---------- an interceptor process, and the gateway it relays to ----------
|
|
79
|
+
|
|
80
|
+
function fakeGateway({ delayMs = 0 } = {}) {
|
|
81
|
+
const seen = [];
|
|
82
|
+
const server = http.createServer((req, res) => {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
req.on('data', (c) => chunks.push(c));
|
|
85
|
+
req.on('end', () => {
|
|
86
|
+
seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') });
|
|
87
|
+
const body = JSON.stringify({ ok: true });
|
|
88
|
+
const send = () => {
|
|
89
|
+
if (res.writableEnded || res.destroyed) return;
|
|
90
|
+
res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
|
|
91
|
+
res.end(body);
|
|
92
|
+
};
|
|
93
|
+
if (delayMs) setTimeout(send, delayMs).unref?.();
|
|
94
|
+
else send();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
server.listen(0, '127.0.0.1', () => resolve({
|
|
99
|
+
port: server.address().port,
|
|
100
|
+
seen,
|
|
101
|
+
close: () => new Promise((r) => server.close(r))
|
|
102
|
+
}));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeConfigFile(file, value) {
|
|
107
|
+
fs.writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function startInterceptor({ port, gatewayPort, config, activeTools = 'claude,codex', capture = null }) {
|
|
111
|
+
const cfgFile = path.join(TMP, `cfg-${port}.json`);
|
|
112
|
+
writeConfigFile(cfgFile, config ?? { activeProfiles: { anthropic: 'a', codex: 'b' } });
|
|
113
|
+
const args = [
|
|
114
|
+
INTERCEPTOR,
|
|
115
|
+
'--port', String(port),
|
|
116
|
+
'--gateway-port', String(gatewayPort),
|
|
117
|
+
'--config', cfgFile,
|
|
118
|
+
'--token-file', s.adminTokenPath,
|
|
119
|
+
'--certs', path.join(TMP, 'blindfold', 'certs'),
|
|
120
|
+
'--active-tools', activeTools
|
|
121
|
+
];
|
|
122
|
+
if (capture) args.push('--capture', capture);
|
|
123
|
+
const child = spawn(process.execPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
124
|
+
const output = [];
|
|
125
|
+
child.stdout.on('data', (d) => output.push(String(d)));
|
|
126
|
+
child.stderr.on('data', (d) => output.push(String(d)));
|
|
127
|
+
|
|
128
|
+
const stop = async () => {
|
|
129
|
+
if (child.exitCode !== null) return; // already gone: waiting for 'exit' would never resolve
|
|
130
|
+
child.kill();
|
|
131
|
+
await once(child, 'exit').catch(() => {});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const deadline = Date.now() + 8000;
|
|
135
|
+
let probe = { state: 'free' };
|
|
136
|
+
while (Date.now() < deadline && probe.state !== 'ours') {
|
|
137
|
+
if (child.exitCode !== null) {
|
|
138
|
+
throw new Error(`interceptor exited with ${child.exitCode}:\n${output.join('')}`);
|
|
139
|
+
}
|
|
140
|
+
probe = await s.probeBlindfold(port);
|
|
141
|
+
if (probe.state !== 'ours') await new Promise((r) => setTimeout(r, 100));
|
|
142
|
+
}
|
|
143
|
+
if (probe.state !== 'ours') {
|
|
144
|
+
await stop();
|
|
145
|
+
throw new Error(`interceptor never answered as ours (state=${probe.state}):\n${output.join('')}`);
|
|
146
|
+
}
|
|
147
|
+
return { child, cfgFile, stop, port };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- a CONNECT tunnel, then HTTP or an upgrade inside it ----------
|
|
151
|
+
|
|
152
|
+
// A test that waits forever reports nothing, so every wait here is bounded and says which stage
|
|
153
|
+
// it was in.
|
|
154
|
+
async function raceTimeout(promise, ms, what) {
|
|
155
|
+
let timer;
|
|
156
|
+
try {
|
|
157
|
+
return await Promise.race([
|
|
158
|
+
promise,
|
|
159
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} did not finish within ${ms}ms`)), ms); })
|
|
160
|
+
]);
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const readUntilHead = (socket) => new Promise((resolve, reject) => {
|
|
167
|
+
let buf = '';
|
|
168
|
+
const onData = (c) => {
|
|
169
|
+
buf += String(c);
|
|
170
|
+
if (buf.includes('\r\n\r\n')) { socket.off('data', onData); socket.off('error', onError); resolve(buf); }
|
|
171
|
+
};
|
|
172
|
+
const onError = (e) => reject(e);
|
|
173
|
+
socket.on('data', onData);
|
|
174
|
+
socket.on('error', onError);
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
socket.off('data', onData);
|
|
177
|
+
socket.off('error', onError);
|
|
178
|
+
reject(new Error(`no response head within 8s: ${JSON.stringify(buf.slice(0, 400))}`));
|
|
179
|
+
}, 8000);
|
|
180
|
+
const clear = () => clearTimeout(timer);
|
|
181
|
+
socket.once('close', clear);
|
|
182
|
+
const origResolve = resolve;
|
|
183
|
+
resolve = (v) => { clear(); origResolve(v); };
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
async function tunnel(proxyPort, host) {
|
|
187
|
+
const socket = net.connect(proxyPort, '127.0.0.1');
|
|
188
|
+
await raceTimeout(once(socket, 'connect'), 5000, `CONNECT to 127.0.0.1:${proxyPort}`);
|
|
189
|
+
socket.write(`CONNECT ${host}:443 HTTP/1.1\r\nHost: ${host}:443\r\n\r\n`);
|
|
190
|
+
const head = await readUntilHead(socket);
|
|
191
|
+
assert.match(head, /^HTTP\/1\.1 200 /, `CONNECT refused: ${head}`);
|
|
192
|
+
return socket;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function openTls(proxyPort, host) {
|
|
196
|
+
const socket = await tunnel(proxyPort, host);
|
|
197
|
+
const secure = tls.connect({ socket, servername: host, ca: fs.readFileSync(CA_PEM), rejectUnauthorized: true });
|
|
198
|
+
// `once` rejects on 'error', so a handshake failure surfaces as itself.
|
|
199
|
+
await raceTimeout(once(secure, 'secureConnect'), 8000, 'TLS handshake');
|
|
200
|
+
return secure;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function tryParse(buf, ended) {
|
|
204
|
+
const text = buf.toString('latin1');
|
|
205
|
+
const i = text.indexOf('\r\n\r\n');
|
|
206
|
+
if (i === -1) return null;
|
|
207
|
+
const head = text.slice(0, i);
|
|
208
|
+
const m = /^HTTP\/1\.[01] (\d{3})/.exec(head);
|
|
209
|
+
if (!m) return null;
|
|
210
|
+
const status = Number(m[1]);
|
|
211
|
+
const len = /content-length:\s*(\d+)/i.exec(head);
|
|
212
|
+
if (len) {
|
|
213
|
+
const need = Number(len[1]);
|
|
214
|
+
if (buf.length < i + 4 + need) return null;
|
|
215
|
+
return { status, head, body: buf.slice(i + 4, i + 4 + need).toString('utf8') };
|
|
216
|
+
}
|
|
217
|
+
// No length to wait for: the answer is complete when the peer stops writing.
|
|
218
|
+
if (ended || status === 421 || status === 502 || status === 400 || status === 404) {
|
|
219
|
+
return { status, head, body: buf.slice(i + 4).toString('utf8') };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readResponse(stream, { timeoutMs = 8000 } = {}) {
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let settled = false;
|
|
228
|
+
const finish = () => {
|
|
229
|
+
if (settled) return;
|
|
230
|
+
settled = true;
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
resolve(tryParse(Buffer.concat(chunks), true) ?? { status: 0, head: Buffer.concat(chunks).toString('latin1'), body: '' });
|
|
233
|
+
};
|
|
234
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
235
|
+
stream.on('data', (c) => {
|
|
236
|
+
chunks.push(c);
|
|
237
|
+
const done = tryParse(Buffer.concat(chunks), false);
|
|
238
|
+
if (done) { settled = true; clearTimeout(timer); resolve(done); }
|
|
239
|
+
});
|
|
240
|
+
stream.on('end', finish);
|
|
241
|
+
stream.on('close', finish);
|
|
242
|
+
stream.on('error', finish);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function post(secure, { method = 'POST', url, host, body = '', headers = {} }) {
|
|
247
|
+
const extra = Object.entries(headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
|
|
248
|
+
secure.write(`${method} ${url} HTTP/1.1\r\nHost: ${host}\r\n`
|
|
249
|
+
+ `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${extra}\r\n${body}`);
|
|
250
|
+
return readResponse(secure);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ==================================================================
|
|
254
|
+
// a live interceptor, driven over a real CONNECT tunnel
|
|
255
|
+
// ==================================================================
|
|
256
|
+
|
|
257
|
+
// ==================================================================
|
|
258
|
+
// R3a — one leaf for the host table, and a CA that may only sign for it
|
|
259
|
+
// ==================================================================
|
|
260
|
+
|
|
261
|
+
test('A4: make-certs.sh names all three hosts, and the CA may sign for nothing else', { skip: noCerts }, () => {
|
|
262
|
+
const dir = fs.mkdtempSync(path.join(TMP, 'leaf-'));
|
|
263
|
+
makeCerts(dir);
|
|
264
|
+
|
|
265
|
+
const leaf = fs.readFileSync(path.join(dir, 'leaf.pem'), 'utf8');
|
|
266
|
+
for (const host of s.INTERCEPT_HOSTS) {
|
|
267
|
+
assert.equal(s.certCoversHost(leaf, host), true, `leaf must cover ${host}`);
|
|
268
|
+
}
|
|
269
|
+
assert.equal(s.certCoversHost(leaf, 'evil.test'), false, 'the leaf names only the table');
|
|
270
|
+
assert.equal(s.certCoversHost(leaf, 'sub.chatgpt.com'), false, 'no wildcard entry: one leaf, three names');
|
|
271
|
+
|
|
272
|
+
// The CA is the credential that matters: if it could sign for another name, its key alone
|
|
273
|
+
// would be enough to impersonate any host on this machine. OpenSSL prints the constraint as a
|
|
274
|
+
// `Permitted:` block of DNS names, so assert on that block and not on the whole certificate.
|
|
275
|
+
const ca = path.join(dir, 'ca.pem');
|
|
276
|
+
const text = spawnSync(BASH, ['-c', `openssl x509 -in "${toBashPath(ca)}" -noout -text`], { encoding: 'utf8' }).stdout;
|
|
277
|
+
const at = text.indexOf('X509v3 Name Constraints');
|
|
278
|
+
assert.ok(at !== -1, `the CA carries name constraints:\n${text}`);
|
|
279
|
+
const constraints = text.slice(at);
|
|
280
|
+
assert.match(constraints, /Permitted:/, 'these constraints permit, they do not only exclude');
|
|
281
|
+
for (const host of s.INTERCEPT_HOSTS) {
|
|
282
|
+
assert.ok(constraints.includes(`DNS:${host}`), `CA must permit ${host}:\n${constraints}`);
|
|
283
|
+
}
|
|
284
|
+
assert.equal(constraints.includes('evil.test'), false, 'the CA may not sign outside the table');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ==================================================================
|
|
288
|
+
// R3 — the host table decides; the table is exact; the tool gates it
|
|
289
|
+
// ==================================================================
|
|
290
|
+
|
|
291
|
+
test('R3: the host table routes whole path segments, per host, and passes everything else', () => {
|
|
292
|
+
const both = new Set(['claude', 'codex']);
|
|
293
|
+
const codexOnly = new Set(['codex']);
|
|
294
|
+
const claudeOnly = new Set(['claude']);
|
|
295
|
+
const pathOf = (host, url, tools = both) => bf.hostRoute(host, url, tools)?.gatewayPath ?? null;
|
|
296
|
+
|
|
297
|
+
// api.anthropic.com: unchanged path, query included.
|
|
298
|
+
assert.equal(pathOf('api.anthropic.com', '/v1/messages?beta=true'), '/v1/messages?beta=true');
|
|
299
|
+
assert.equal(pathOf('api.anthropic.com', '/v1/messages/stream'), '/v1/messages/stream');
|
|
300
|
+
assert.equal(pathOf('api.anthropic.com', '/v1/models'), null, 'that path is not anthropic');
|
|
301
|
+
|
|
302
|
+
// api.openai.com: the responses and models families, whole segments only.
|
|
303
|
+
assert.equal(pathOf('api.openai.com', '/v1/responses'), '/v1/responses');
|
|
304
|
+
assert.equal(pathOf('api.openai.com', '/v1/models/gpt-5'), '/v1/models/gpt-5');
|
|
305
|
+
assert.equal(pathOf('api.openai.com', '/v1/responses_compact'), null, 'not /v1/responses');
|
|
306
|
+
assert.equal(pathOf('api.openai.com', '/v1/responsesfoo'), null, 'a shared prefix is not a segment');
|
|
307
|
+
|
|
308
|
+
// chatgpt.com keeps the one rewrite the interceptor has always done.
|
|
309
|
+
assert.equal(pathOf('chatgpt.com', '/backend-api/codex/chat/completions?x=1'), '/v1/chat/completions?x=1');
|
|
310
|
+
assert.equal(pathOf('chatgpt.com', '/api/oauth/token'), null, 'sign-in stays on chatgpt.com');
|
|
311
|
+
assert.equal(pathOf('chatgpt.com', '/backend-api/codex-usage'), null);
|
|
312
|
+
|
|
313
|
+
// A host outside the table is never terminated: this process then only copies bytes.
|
|
314
|
+
for (const other of ['example.com', 'auth.openai.com', 'chatgpt.com.evil.test', 'API.CHATGPT.COM']) {
|
|
315
|
+
assert.equal(bf.hostRoute(other, '/v1/messages', both), null, `must not terminate ${other}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// F2: when a tool is off, its host is not in this switcher's path at all.
|
|
319
|
+
assert.equal(pathOf('api.anthropic.com', '/v1/messages', codexOnly), null, 'claude off');
|
|
320
|
+
assert.equal(pathOf('api.openai.com', '/v1/responses', claudeOnly), null, 'codex off');
|
|
321
|
+
assert.equal(pathOf('chatgpt.com', '/backend-api/codex/x', claudeOnly), null, 'codex off');
|
|
322
|
+
|
|
323
|
+
// Spelling is not identity: DNS ignores case and :443 is the default port.
|
|
324
|
+
assert.ok(bf.hostRoute('API.Anthropic.com:443', '/v1/messages', both), 'case and port are spelling');
|
|
325
|
+
assert.equal(bf.misdirected({ socket: { _connectHost: 'API.ANTHROPIC.COM:443' }, headers: { host: 'api.anthropic.com' } }), false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('A4b: a Host header that is not the CONNECT host is misdirected', () => {
|
|
329
|
+
const req = (connectHost, hostHeader) => ({ socket: { _connectHost: connectHost }, headers: { host: hostHeader } });
|
|
330
|
+
assert.equal(bf.misdirected(req('api.anthropic.com', 'evil.com')), true);
|
|
331
|
+
assert.equal(bf.misdirected(req('chatgpt.com', 'api.openai.com')), true, 'another host of the table is still another host');
|
|
332
|
+
assert.equal(bf.misdirected(req('api.anthropic.com', 'API.ANTHROPIC.COM:443')), false, 'case and :443 match');
|
|
333
|
+
assert.equal(bf.misdirected(req('api.anthropic.com', 'api.anthropic.com')), false);
|
|
334
|
+
assert.equal(bf.misdirected(req('api.anthropic.com', '')), true, 'HTTP/1.1 without a Host is a mismatch');
|
|
335
|
+
// A socket that did not arrive through CONNECT has no tunnel host to compare against.
|
|
336
|
+
assert.equal(bf.misdirected({ socket: {}, headers: { host: 'anything' } }), false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('A4b: the gateway hop carries no client credential', () => {
|
|
340
|
+
const out = bf.gatewayHeaders({
|
|
341
|
+
authorization: 'Bearer secret',
|
|
342
|
+
'x-api-key': 'sk-live',
|
|
343
|
+
cookie: 'oai-device-id=1',
|
|
344
|
+
'openai-organization': 'org_1',
|
|
345
|
+
'user-agent': 'codex-cli',
|
|
346
|
+
'content-type': 'application/json'
|
|
347
|
+
});
|
|
348
|
+
const lower = Object.fromEntries(Object.entries(out).map(([k, v]) => [k.toLowerCase(), v]));
|
|
349
|
+
assert.equal(lower.authorization, undefined, 'the gateway has its own keys');
|
|
350
|
+
assert.equal(lower['x-api-key'], undefined, 'the gateway must never see the client key');
|
|
351
|
+
assert.equal(lower.cookie, undefined, 'the cookie was already stripped');
|
|
352
|
+
assert.equal(lower['openai-organization'], undefined, 'an account identifier stays here too');
|
|
353
|
+
assert.equal(lower['user-agent'], 'codex-cli', 'a non-credential header still travels');
|
|
354
|
+
assert.equal(lower['content-type'], 'application/json');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('A4b: a dot-segment escape never reaches the gateway on any host', () => {
|
|
358
|
+
const both = new Set(['claude', 'codex']);
|
|
359
|
+
for (const [host, url] of [
|
|
360
|
+
['chatgpt.com', '/backend-api/codex/%2e%2e/api/logs'],
|
|
361
|
+
['chatgpt.com', '/backend-api/codex/a/../../api/save-profile'],
|
|
362
|
+
['api.anthropic.com', '/v1/messages/%2e%2e/../admin/token'],
|
|
363
|
+
['api.openai.com', '/v1/responses/%2E%2E/api/logs']
|
|
364
|
+
]) {
|
|
365
|
+
assert.equal(bf.hostRoute(host, url, both), null, `must not route ${host}${url}`);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ==================================================================
|
|
370
|
+
// Finding 6 — one sorted list, and the fallback pointer keys of R6
|
|
371
|
+
// ==================================================================
|
|
372
|
+
|
|
373
|
+
test('deriveActiveTools reads the fallback pointer keys', () => {
|
|
374
|
+
assert.deepEqual(s.deriveActiveTools({ activeProfiles: { anthropic: 'a', responses: 'b', codex: null } }), ['claude']);
|
|
375
|
+
assert.deepEqual(s.deriveActiveTools({ activeProfiles: { anthropic: 'a', responses: 'b' } }), ['claude', 'codex']);
|
|
376
|
+
assert.deepEqual(s.deriveActiveTools({ activeProfile: 'p', activeProfiles: { claude: null } }), ['codex'],
|
|
377
|
+
'an explicit off must not fall through to the legacy pointer');
|
|
378
|
+
assert.deepEqual(s.deriveActiveTools({}), []);
|
|
379
|
+
// Sorted, so the spawn argument and the identity proof are the same string on every run.
|
|
380
|
+
assert.deepEqual(s.deriveActiveTools({ activeProfiles: { codex: 'b', claude: 'a' } }), ['claude', 'codex']);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ==================================================================
|
|
384
|
+
// A live interceptor: identity, the control channel, and continuity
|
|
385
|
+
// ==================================================================
|
|
386
|
+
|
|
387
|
+
test('an interceptor proves the active tool set instead of a host and a prefix', { skip: noCerts }, async (t) => {
|
|
388
|
+
const gateway = await fakeGateway();
|
|
389
|
+
t.after(gateway.close);
|
|
390
|
+
const port = await freePort();
|
|
391
|
+
const it = await startInterceptor({ port, gatewayPort: gateway.port, activeTools: 'claude,codex' });
|
|
392
|
+
t.after(it.stop);
|
|
393
|
+
|
|
394
|
+
const probe = await s.probeBlindfold(port);
|
|
395
|
+
assert.equal(probe.state, 'ours');
|
|
396
|
+
assert.equal(probe.activeTools, 'claude,codex', 'the proof carries the tool set');
|
|
397
|
+
assert.equal(probe.host, undefined, 'R3 removed the host from the answer');
|
|
398
|
+
assert.equal(probe.prefix, undefined, 'R3 removed the prefix from the answer');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('A4b: a Host that is not the CONNECT host gets 421, with no upstream connection', { skip: noCerts }, async (t) => {
|
|
402
|
+
const gateway = await fakeGateway();
|
|
403
|
+
t.after(gateway.close);
|
|
404
|
+
const port = await freePort();
|
|
405
|
+
const it = await startInterceptor({ port, gatewayPort: gateway.port });
|
|
406
|
+
t.after(it.stop);
|
|
407
|
+
|
|
408
|
+
// Plain request inside the tunnel.
|
|
409
|
+
const secure = await openTls(port, 'api.anthropic.com');
|
|
410
|
+
const res = await post(secure, { url: '/v1/messages', host: 'evil.com', body: '{"x":1}' });
|
|
411
|
+
assert.equal(res.status, 421, res.head);
|
|
412
|
+
assert.match(res.head, /Misdirected/i);
|
|
413
|
+
|
|
414
|
+
// An upgrade answers on the raw socket, so it needs its own branch.
|
|
415
|
+
const up = await openTls(port, 'api.anthropic.com');
|
|
416
|
+
up.write('GET /backend-api/codex/x HTTP/1.1\r\nHost: chatgpt.com\r\nConnection: Upgrade\r\n'
|
|
417
|
+
+ 'Upgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n');
|
|
418
|
+
const upgrade = await readResponse(up);
|
|
419
|
+
assert.equal(upgrade.status, 421, upgrade.head);
|
|
420
|
+
|
|
421
|
+
assert.equal(gateway.seen.length, 0, 'neither request may reach an upstream');
|
|
422
|
+
secure.destroy();
|
|
423
|
+
up.destroy();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('A9: only the active tool set is intercepted, and the port never moves', { skip: noCerts }, async (t) => {
|
|
427
|
+
const gateway = await fakeGateway();
|
|
428
|
+
t.after(gateway.close);
|
|
429
|
+
const port = await freePort();
|
|
430
|
+
// Spawns with both tools, while config.json already says Claude is off — so the first control
|
|
431
|
+
// POST has something real to change.
|
|
432
|
+
const it = await startInterceptor({
|
|
433
|
+
port,
|
|
434
|
+
gatewayPort: gateway.port,
|
|
435
|
+
config: { activeProfile: 'p', activeProfiles: { claude: null } },
|
|
436
|
+
activeTools: 'claude,codex'
|
|
437
|
+
});
|
|
438
|
+
t.after(it.stop);
|
|
439
|
+
|
|
440
|
+
// Codex is live: its API path reaches the gateway with the path unchanged.
|
|
441
|
+
const a = await openTls(port, 'api.openai.com');
|
|
442
|
+
const routed = await post(a, { url: '/v1/responses?stream=true', host: 'api.openai.com', body: '{"model":"gpt-5"}' });
|
|
443
|
+
assert.equal(routed.status, 200, routed.head);
|
|
444
|
+
assert.equal(gateway.seen.at(-1).url, '/v1/responses?stream=true', 'the path is not rewritten for the API hosts');
|
|
445
|
+
a.destroy();
|
|
446
|
+
|
|
447
|
+
// Turn Claude off through the one channel that exists.
|
|
448
|
+
const before = process.hrtime.bigint();
|
|
449
|
+
const ack = await fetch(`http://127.0.0.1:${port}/_control/active-tools`, {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
headers: { 'x-llm-switcher-token': TOKEN, 'content-type': 'application/json' },
|
|
452
|
+
body: '{}'
|
|
453
|
+
}).then((r) => r.json());
|
|
454
|
+
const elapsedMs = Number(process.hrtime.bigint() - before) / 1e6;
|
|
455
|
+
assert.deepEqual(ack, { ok: true, activeTools: ['codex'] });
|
|
456
|
+
assert.ok(elapsedMs < 2000, `the update is in place, not a restart (took ${elapsedMs.toFixed(0)}ms)`);
|
|
457
|
+
|
|
458
|
+
const after = await s.probeBlindfold(port);
|
|
459
|
+
assert.equal(after.state, 'ours', 'the same process still answers');
|
|
460
|
+
assert.equal(after.pid, it.child.pid, 'the port was never released');
|
|
461
|
+
assert.equal(after.activeTools, 'codex');
|
|
462
|
+
|
|
463
|
+
// /v1/messages on api.anthropic.com now leaves this switcher's path: the gateway must not see
|
|
464
|
+
// it. The pass-through itself goes to the real host and may fail without network — the point
|
|
465
|
+
// is that the switcher is no longer between the tool and its endpoint.
|
|
466
|
+
const b = await openTls(port, 'api.anthropic.com');
|
|
467
|
+
const countBefore = gateway.seen.filter((r) => r.url.startsWith('/v1/messages')).length;
|
|
468
|
+
const pending = post(b, { url: '/v1/messages', host: 'api.anthropic.com', body: '{"x":1}' });
|
|
469
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
470
|
+
assert.equal(gateway.seen.filter((r) => r.url.startsWith('/v1/messages')).length, countBefore,
|
|
471
|
+
'an inactive tool must not be routed through the gateway');
|
|
472
|
+
b.destroy();
|
|
473
|
+
await pending;
|
|
474
|
+
|
|
475
|
+
// Codex is still intercepted on the same port.
|
|
476
|
+
const c = await openTls(port, 'api.openai.com');
|
|
477
|
+
const still = await post(c, { url: '/v1/responses', host: 'api.openai.com', body: '{}' });
|
|
478
|
+
assert.equal(still.status, 200, still.head);
|
|
479
|
+
assert.equal(gateway.seen.at(-1).url, '/v1/responses');
|
|
480
|
+
c.destroy();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('POST /_control/active-tools re-derives the list from config.json', { skip: noCerts }, async (t) => {
|
|
484
|
+
const gateway = await fakeGateway();
|
|
485
|
+
t.after(gateway.close);
|
|
486
|
+
const port = await freePort();
|
|
487
|
+
const it = await startInterceptor({
|
|
488
|
+
port,
|
|
489
|
+
gatewayPort: gateway.port,
|
|
490
|
+
config: { activeProfile: 'p', activeProfiles: { claude: null } },
|
|
491
|
+
activeTools: 'claude,codex'
|
|
492
|
+
});
|
|
493
|
+
t.after(it.stop);
|
|
494
|
+
|
|
495
|
+
const res = await fetch(`http://127.0.0.1:${port}/_control/active-tools`, {
|
|
496
|
+
method: 'POST', headers: { 'x-llm-switcher-token': TOKEN }, body: '{}'
|
|
497
|
+
});
|
|
498
|
+
assert.equal(res.status, 200);
|
|
499
|
+
assert.deepEqual((await res.json()).activeTools, ['codex'], 'the pointer chain, read from the file');
|
|
500
|
+
assert.equal((await s.probeBlindfold(port)).activeTools, 'codex');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('POST /_control/active-tools refuses a missing token and a config it cannot read', { skip: noCerts }, async (t) => {
|
|
504
|
+
const gateway = await fakeGateway();
|
|
505
|
+
t.after(gateway.close);
|
|
506
|
+
const port = await freePort();
|
|
507
|
+
const it = await startInterceptor({ port, gatewayPort: gateway.port, activeTools: 'claude,codex' });
|
|
508
|
+
t.after(it.stop);
|
|
509
|
+
const post_ = (headers) => fetch(`http://127.0.0.1:${port}/_control/active-tools`, { method: 'POST', headers, body: '{}' });
|
|
510
|
+
|
|
511
|
+
// Loopback is not an identity: any process on this machine can reach the port.
|
|
512
|
+
assert.equal((await post_({})).status, 403);
|
|
513
|
+
assert.equal((await post_({ 'x-llm-switcher-token': 'not-the-token' })).status, 403);
|
|
514
|
+
assert.equal((await s.probeBlindfold(port)).activeTools, 'claude,codex', 'a refused call changes nothing');
|
|
515
|
+
|
|
516
|
+
// A config the interceptor cannot read must not empty the table: non-2xx, set unchanged.
|
|
517
|
+
writeConfigFile(it.cfgFile, '{ this is not json');
|
|
518
|
+
const broken = await post_({ 'x-llm-switcher-token': TOKEN });
|
|
519
|
+
assert.ok(broken.status >= 500, `expected a non-2xx answer, got ${broken.status}`);
|
|
520
|
+
const body = await broken.json();
|
|
521
|
+
assert.equal(body.ok, false);
|
|
522
|
+
assert.ok(!Array.isArray(body.activeTools), 'it must not claim a new list it could not derive');
|
|
523
|
+
assert.equal((await s.probeBlindfold(port)).activeTools, 'claude,codex', 'the running set is kept');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('R6: an in-flight request completes while the control endpoint updates the tool set', { skip: noCerts }, async (t) => {
|
|
527
|
+
const gateway = await fakeGateway({ delayMs: 1500 });
|
|
528
|
+
t.after(gateway.close);
|
|
529
|
+
const port = await freePort();
|
|
530
|
+
// The file says Claude is off, so the control endpoint has a real change to make mid-flight.
|
|
531
|
+
const it = await startInterceptor({
|
|
532
|
+
port,
|
|
533
|
+
gatewayPort: gateway.port,
|
|
534
|
+
config: { activeProfile: 'p', activeProfiles: { claude: null } },
|
|
535
|
+
activeTools: 'claude,codex'
|
|
536
|
+
});
|
|
537
|
+
t.after(it.stop);
|
|
538
|
+
|
|
539
|
+
const secure = await openTls(port, 'api.openai.com');
|
|
540
|
+
const inFlight = post(secure, { url: '/v1/responses', host: 'api.openai.com', body: '{"stream":true}' });
|
|
541
|
+
|
|
542
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
543
|
+
const ack = await fetch(`http://127.0.0.1:${port}/_control/active-tools`, {
|
|
544
|
+
method: 'POST', headers: { 'x-llm-switcher-token': TOKEN }, body: '{}'
|
|
545
|
+
}).then((r) => r.json());
|
|
546
|
+
assert.deepEqual(ack, { ok: true, activeTools: ['codex'] }, 'the update lands while the request is open');
|
|
547
|
+
|
|
548
|
+
const res = await inFlight;
|
|
549
|
+
assert.equal(res.status, 200, `the open stream must not be reset: ${res.head}`);
|
|
550
|
+
assert.equal(gateway.seen.length, 1, 'one request, relayed once');
|
|
551
|
+
secure.destroy();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test('A12: a pass-through exchange writes method, path, status and headers — and no body', async () => {
|
|
555
|
+
const dir = fs.mkdtempSync(path.join(TMP, 'capture-'));
|
|
556
|
+
const { EventEmitter } = await import('node:events');
|
|
557
|
+
|
|
558
|
+
const req = new EventEmitter();
|
|
559
|
+
req.method = 'POST';
|
|
560
|
+
req.url = '/api/oauth/token';
|
|
561
|
+
req.headers = { host: 'chatgpt.com', authorization: 'Bearer xyz', 'content-type': 'application/json' };
|
|
562
|
+
|
|
563
|
+
const record = bf.recordExchange(req, { includeBody: false, captureDir: dir });
|
|
564
|
+
assert.ok(record, 'a pass-through exchange is still worth recording');
|
|
565
|
+
|
|
566
|
+
const up = new EventEmitter();
|
|
567
|
+
up.statusCode = 200;
|
|
568
|
+
up.headers = { 'content-type': 'text/plain', 'content-length': '2' };
|
|
569
|
+
record(up);
|
|
570
|
+
|
|
571
|
+
// The bodies arrive and are simply never collected.
|
|
572
|
+
req.emit('data', Buffer.from('{"refresh_token":"rt-secret"}'));
|
|
573
|
+
up.emit('data', Buffer.from('ok'));
|
|
574
|
+
up.emit('end');
|
|
575
|
+
|
|
576
|
+
const deadline = Date.now() + 3000;
|
|
577
|
+
while (fs.readdirSync(dir).length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25));
|
|
578
|
+
const files = fs.readdirSync(dir);
|
|
579
|
+
assert.equal(files.length, 1, 'one capture file');
|
|
580
|
+
const written = JSON.parse(fs.readFileSync(path.join(dir, files[0]), 'utf8'));
|
|
581
|
+
|
|
582
|
+
assert.equal(written.method, 'POST');
|
|
583
|
+
assert.equal(written.url, '/api/oauth/token');
|
|
584
|
+
assert.equal(written.status, 200);
|
|
585
|
+
assert.equal(written.responseHeaders['content-type'], 'text/plain');
|
|
586
|
+
assert.ok(!('requestBody' in written), 'no request body may be stored');
|
|
587
|
+
assert.ok(!('responseBody' in written), 'no response body may be stored');
|
|
588
|
+
const dump = JSON.stringify(written);
|
|
589
|
+
assert.ok(!dump.includes('rt-secret'), 'the refresh token must not reach the disk');
|
|
590
|
+
assert.ok(!dump.includes('Bearer xyz'), 'the credential must not reach the disk');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ==================================================================
|
|
594
|
+
// R3b — reconcile: keep, replace a legacy build, confirm the ACK
|
|
595
|
+
// ==================================================================
|
|
596
|
+
|
|
597
|
+
const baseCfg = (port) => ({
|
|
598
|
+
blindfold: { port },
|
|
599
|
+
activeProfiles: { anthropic: 'a', codex: 'b' },
|
|
600
|
+
profiles: {
|
|
601
|
+
a: { tool: 'claude', baseURL: 'http://example.test', apiKey: 'k' },
|
|
602
|
+
b: { tool: 'codex', baseURL: 'http://example.test', apiKey: 'k' }
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test('R3b: a second reconcile over an unchanged config keeps the running interceptor', { skip: noCerts }, async (t) => {
|
|
607
|
+
const gateway = await fakeGateway();
|
|
608
|
+
t.after(gateway.close);
|
|
609
|
+
const port = await freePort();
|
|
610
|
+
const gatewayPort = await freePort();
|
|
611
|
+
const first = await s.reconcileBlindfold(baseCfg(port), gatewayPort);
|
|
612
|
+
t.after(async () => { await s.stopRecordedBlindfold(); });
|
|
613
|
+
assert.equal(first.ok, true, JSON.stringify(first));
|
|
614
|
+
assert.equal(first.action, 'started');
|
|
615
|
+
|
|
616
|
+
const running = await s.probeBlindfold(port);
|
|
617
|
+
assert.equal(running.state, 'ours');
|
|
618
|
+
assert.equal(running.activeTools, 'claude,codex');
|
|
619
|
+
|
|
620
|
+
const second = await s.reconcileBlindfold(baseCfg(port), gatewayPort);
|
|
621
|
+
assert.equal(second.ok, true, JSON.stringify(second));
|
|
622
|
+
assert.equal(second.action, 'kept', 'nothing changed, so nothing is restarted');
|
|
623
|
+
assert.equal((await s.probeBlindfold(port)).pid, running.pid, 'the same process is still serving');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test('R3b: an interceptor from 1.1.11 proves the old way and is replaced, not treated as foreign', { skip: noCerts }, async (t) => {
|
|
627
|
+
const gateway = await fakeGateway();
|
|
628
|
+
t.after(gateway.close);
|
|
629
|
+
const gatewayPort = await freePort();
|
|
630
|
+
const port = await freePort();
|
|
631
|
+
|
|
632
|
+
// Exactly the 1.1.11 answer: HMAC over role, port, pid, gateway port, HOST and PREFIX. It runs
|
|
633
|
+
// in its own process, because reconcile is about to kill whatever answers that proof.
|
|
634
|
+
const legacySrc = `
|
|
635
|
+
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
636
|
+
const http = await import('node:http');
|
|
637
|
+
const fs = await import('node:fs');
|
|
638
|
+
const port = ${port};
|
|
639
|
+
const gatewayPort = ${gatewayPort};
|
|
640
|
+
http.createServer((req, res) => {
|
|
641
|
+
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
642
|
+
const f = { role: 'blindfold', port, pid: process.pid, gatewayPort, host: 'chatgpt.com', prefix: '/backend-api/codex' };
|
|
643
|
+
const token = fs.readFileSync(${JSON.stringify(s.adminTokenPath)}, 'utf8').trim();
|
|
644
|
+
res.setHeader('content-type', 'application/json');
|
|
645
|
+
res.end(JSON.stringify({
|
|
646
|
+
proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort,
|
|
647
|
+
host: f.host, prefix: f.prefix, proof: s.legacyIdentityProof(nonce, f, token)
|
|
648
|
+
}));
|
|
649
|
+
}).listen(port, '127.0.0.1');
|
|
650
|
+
`;
|
|
651
|
+
const legacy = spawn(process.execPath, ['--input-type=module', '-e', legacySrc], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
652
|
+
t.after(async () => {
|
|
653
|
+
if (legacy.exitCode !== null) return;
|
|
654
|
+
legacy.kill();
|
|
655
|
+
await once(legacy, 'exit').catch(() => {});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const deadline = Date.now() + 8000;
|
|
659
|
+
let before = { state: 'free' };
|
|
660
|
+
while (Date.now() < deadline && before.state !== 'legacy-ours') {
|
|
661
|
+
before = await s.probeBlindfold(port);
|
|
662
|
+
if (before.state !== 'legacy-ours') await new Promise((r) => setTimeout(r, 100));
|
|
663
|
+
}
|
|
664
|
+
assert.equal(before.state, 'legacy-ours', 'the old proof still verifies: it is ours to stop');
|
|
665
|
+
assert.equal(before.host, 'chatgpt.com');
|
|
666
|
+
|
|
667
|
+
fs.writeFileSync(s.blindfoldStatePath, JSON.stringify({ pid: legacy.pid, port, gatewayPort }));
|
|
668
|
+
t.after(async () => { await s.stopRecordedBlindfold(); });
|
|
669
|
+
|
|
670
|
+
const r = await s.reconcileBlindfold(baseCfg(port), gatewayPort);
|
|
671
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
672
|
+
assert.equal(r.action, 'started', 'the legacy build is replaced by the multi-host one');
|
|
673
|
+
const now = await s.probeBlindfold(port);
|
|
674
|
+
assert.equal(now.state, 'ours', 'the new answer is the new proof');
|
|
675
|
+
assert.equal(now.activeTools, 'claude,codex');
|
|
676
|
+
assert.ok(now.pid !== legacy.pid, 'the fake that held the port is gone');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test('A9: reconcile confirms the list the interceptor reports, not the snapshot it was called with', { skip: noCerts }, async (t) => {
|
|
680
|
+
const gateway = await fakeGateway();
|
|
681
|
+
t.after(gateway.close);
|
|
682
|
+
const port = await freePort();
|
|
683
|
+
// The gateway port has to be the one the interceptor was spawned with: reconcile compares the
|
|
684
|
+
// probe against it, and a mismatch means "a different gateway owns this port".
|
|
685
|
+
const it = await startInterceptor({ port, gatewayPort: gateway.port, activeTools: 'claude,codex' });
|
|
686
|
+
t.after(it.stop);
|
|
687
|
+
|
|
688
|
+
// The snapshot this call was built from says "codex only"...
|
|
689
|
+
const snapshot = { ...baseCfg(port), activeProfiles: { anthropic: null, codex: 'b' } };
|
|
690
|
+
// ...while the file on disk — which the interceptor re-reads — still has both. The running
|
|
691
|
+
// process is right and the stale snapshot is wrong; reconcile must agree with the ACK.
|
|
692
|
+
writeConfigFile(it.cfgFile, baseCfg(port));
|
|
693
|
+
fs.writeFileSync(path.join(TMP, 'blindfold.json'), JSON.stringify({ pid: it.child.pid, port, gatewayPort: gateway.port }));
|
|
694
|
+
|
|
695
|
+
const r = await s.reconcileBlindfold(snapshot, gateway.port);
|
|
696
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
697
|
+
assert.equal(r.action, 'updated', 'the port stayed open and only the tool set moved');
|
|
698
|
+
assert.equal((await s.probeBlindfold(port)).pid, it.child.pid, 'the same process answered the POST');
|
|
699
|
+
assert.equal((await s.probeBlindfold(port)).activeTools, 'claude,codex', 'confirmed against the acknowledgement');
|
|
700
|
+
});
|