llm-switcher 1.1.11 → 1.2.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/CHANGELOG.md +51 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/config.example.json +12 -34
  8. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/docs/response-matrix.json +1130 -1130
  24. package/ensure-ca-bundle.mjs +28 -0
  25. package/formats.mjs +13 -155
  26. package/mcp.mjs +39 -11
  27. package/package.json +1 -1
  28. package/proxy.mjs +92 -27
  29. package/shim.mjs +200 -57
  30. package/skills/llm-switcher/SKILL.md +93 -88
  31. package/state.mjs +1100 -191
  32. package/switch +0 -0
  33. package/switch.cmd +2 -2
  34. package/switch.mjs +228 -53
  35. package/tests/blindfold-e2e.test.mjs +380 -0
  36. package/tests/blindfold-task5.test.mjs +429 -0
  37. package/tests/blindfold.task3.test.mjs +700 -0
  38. package/tests/blindfold.test.mjs +10 -5
  39. package/tests/catalog.test.mjs +147 -0
  40. package/tests/contract-lab.test.mjs +22 -7
  41. package/tests/formats.test.mjs +33 -46
  42. package/tests/gateway.e2e.test.mjs +136 -36
  43. package/tests/helpers.mjs +24 -24
  44. package/tests/lifecycle.test.mjs +16 -10
  45. package/tests/live-optimizer-interop.mjs +205 -205
  46. package/tests/mcp.test.mjs +78 -2
  47. package/tests/real-user-sim.test.mjs +464 -0
  48. package/tests/shim.test.mjs +159 -66
  49. package/tests/state.test.mjs +975 -193
  50. package/tests/switch.test.mjs +446 -2
  51. package/ui.html +61 -154
@@ -19,9 +19,10 @@
19
19
  // this process restores normal behaviour with nothing left behind.
20
20
  //
21
21
  // SCOPE OF THE INTERCEPT
22
- // Only requests to the target host whose path starts with the Codex API prefix go to
23
- // the gateway. Every other path on that host, and every other host, is passed through
24
- // untouched — sign-in, token refresh and usage pages keep working.
22
+ // The host table names every host this process terminates TLS for, which path prefixes of
23
+ // each belong to the gateway, and which tool must be active for them to be taken. Everything
24
+ // else — every other path on those hosts, and every other host — is passed through untouched,
25
+ // so sign-in, token refresh and usage pages keep working exactly as they do without it.
25
26
  // ============================================================
26
27
 
27
28
  import fs from 'node:fs';
@@ -33,7 +34,10 @@ import tls from 'node:tls';
33
34
  import zlib from 'node:zlib';
34
35
  import crypto from 'node:crypto';
35
36
  import dns from 'node:dns/promises';
36
- import { fileURLToPath, pathToFileURL } from 'node:url';
37
+ import { fileURLToPath, pathToFileURL } from 'node:url'
38
+ // Pure: reads no file, migrates nothing. The interceptor imports it to re-derive the active tool
39
+ // set from config.json on every control request instead of trusting what it was spawned with.
40
+ import { deriveActiveTools } from '../state.mjs';
37
41
  import { createFrameReader, negotiatesDeflate } from './wsframe.mjs';
38
42
 
39
43
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -55,17 +59,28 @@ function port(value, fallback) {
55
59
  const LISTEN_PORT = port(arg('port', process.env.LLM_SWITCHER_BLINDFOLD_PORT), 3457);
56
60
  const GATEWAY_HOST = arg('gateway-host', '127.0.0.1');
57
61
  const GATEWAY_PORT = port(arg('gateway-port', process.env.LLM_SWITCHER_PORT), 3456);
58
- const TARGET_HOST = arg('host', 'chatgpt.com');
59
- // Codex with ChatGPT auth calls https://chatgpt.com/backend-api/codex/<endpoint>;
60
- // the gateway serves the same endpoints under /v1.
61
- export const API_PREFIX = arg('prefix', '/backend-api/codex');
62
+ // R3: `--host` and `--prefix` are gone. Which hosts are intercepted, under which prefix, and for
63
+ // which tool is decided by the host table below — a value handed in on the command line can be
64
+ // stale while this process runs, and the config is the one copy that cannot (F6).
62
65
  export const GATEWAY_PREFIX = arg('gateway-prefix', '/v1');
63
66
  const CERT_DIR = arg('certs', path.join(HERE, 'certs'));
67
+ // Where the control endpoint re-reads the tool set from. Passed explicitly by the spawner so the
68
+ // process never guesses at a path (Finding 2 / F6).
69
+ const CONFIG_FILE = arg('config', path.join(HERE, '..', 'config.json'));
64
70
  const VERBOSE = process.argv.includes('--verbose');
65
71
  const CAPTURE_DIR = arg('capture', null);
66
- // The switcher's admin.token. The identity probe answers HMAC(token, nonce) with it.
72
+ // The switcher's admin.token: it signs the identity probe, and the control endpoint compares it
73
+ // in the x-llm-switcher-token header.
67
74
  const TOKEN_FILE = arg('token-file', path.join(HERE, '..', 'admin.token'));
68
75
 
76
+ // Finding 6: one fixed, sorted list. Sorting means the spawner's argument and this process's own
77
+ // derivation produce the same string, so `matches` and the identity proof are stable.
78
+ const parseToolList = (value) => String(value || '').split(',').map(s => s.trim()).filter(Boolean).sort();
79
+ // Spawned with the tool set of the config at that moment; the control endpoint replaces it from
80
+ // the file itself, in memory, without touching the port or any open stream.
81
+ let ACTIVE_TOOLS = parseToolList(arg('active-tools', ''));
82
+ const activeToolSet = () => new Set(ACTIVE_TOOLS);
83
+
69
84
  const log = (...args) => { if (VERBOSE) console.log('[blindfold]', ...args); };
70
85
 
71
86
  // A capture file records what a genuine client sends on the wire. It must never
@@ -150,10 +165,12 @@ export function writeCaptureFile(dir, fileName, record, { uid = process.getuid?.
150
165
  }
151
166
 
152
167
  // A capture is a diagnostic. Failing to write one must never fail the request.
153
- function writeCapture(record, fileName) {
154
- if (!CAPTURE_DIR) return;
168
+ // `dir` defaults to the --capture of this process; recordExchange passes its own so the
169
+ // pass-through rule (R3c) can be exercised against a directory of its own.
170
+ function writeCapture(record, { fileName, dir = CAPTURE_DIR } = {}) {
171
+ if (!dir) return;
155
172
  try {
156
- writeCaptureFile(CAPTURE_DIR, fileName || captureName(record.method, record.url), record);
173
+ writeCaptureFile(dir, fileName || captureName(record.method, record.url), record);
157
174
  } catch (err) {
158
175
  log('capture failed:', err.message);
159
176
  }
@@ -178,7 +195,87 @@ function normalizedTarget(url) {
178
195
  }
179
196
  }
180
197
 
198
+ // The chatgpt.com rule, named on its own because it is the only one that rewrites a prefix:
199
+ // Codex with ChatGPT auth calls /backend-api/codex/<endpoint>, the gateway serves it under /v1.
200
+ export const API_PREFIX = '/backend-api/codex';
201
+
202
+ // ---------------- the host table of R3 ----------------
203
+ // One host, the whole-path prefixes of that host that belong to the gateway, the path the gateway
204
+ // serves them under, and the tool that has to be active for them to be taken at all. A prefix only
205
+ // ever matches on a segment boundary, so `/v1/responses_compact` is not `/v1/responses`; and the
206
+ // decision is made on the NORMALIZED pathname (normalizedTarget), so a `%2e%2e` segment cannot
207
+ // carry a request into another gateway path. A host outside this table is tunneled, never
208
+ // terminated — this process then copies bytes it cannot read.
209
+ const HOST_ROUTES = {
210
+ 'api.anthropic.com': { tool: 'claude', prefixes: ['/v1/messages'], rewrite: null },
211
+ 'api.openai.com': { tool: 'codex', prefixes: ['/v1/responses', '/v1/models'], rewrite: null },
212
+ 'chatgpt.com': { tool: 'codex', prefixes: [API_PREFIX], rewrite: { from: API_PREFIX, to: '/v1' } }
213
+ };
214
+
215
+ // Only reachable if a socket reaches the TLS endpoint without a CONNECT to name its host — a test
216
+ // emitting a connection directly. A real request always carries the tunnel's host.
217
+ const API_HOST_FALLBACK = 'chatgpt.com';
218
+
219
+ // A Host header carries an optional port; the CONNECT target does not, because its port was split
220
+ // off when the tunnel was opened. Case never matters to DNS and a client may write either.
221
+ export function normalizeHost(value) {
222
+ return String(value || '').trim().toLowerCase().replace(/:443$/, '');
223
+ }
224
+
225
+ export function isInterceptedHost(host) {
226
+ return Boolean(HOST_ROUTES[normalizeHost(host)]);
227
+ }
228
+
229
+ // One CONNECT tunnel carries one host. TLS already bound the certificate this endpoint presents
230
+ // to the host the tunnel was opened for; a Host header naming a different host would ask for a
231
+ // second origin over that same binding. Refuse instead of guessing which one was meant (A4b).
232
+ /**
233
+ * Which host this TLS session belongs to.
234
+ *
235
+ * The interceptor learns a host when it accepts the CONNECT; the request handler runs on the TLS
236
+ * socket wrapped around that raw socket, so the property may not be visible from here. Try the
237
+ * raw socket first (the CONNECT host, which is what R3b binds the check to), then the socket that
238
+ * wrapped it, then SNI — but SNI only counts when it names a host of the table: a client must
239
+ * never be able to point this process at an address of its own choosing.
240
+ */
241
+ export function tunnelHostOf(req) {
242
+ const socket = req?.socket;
243
+ const fromTunnel = normalizeHost(socket?._connectHost ?? socket?._parent?._connectHost);
244
+ if (fromTunnel) return fromTunnel;
245
+ const sni = normalizeHost(socket?.servername);
246
+ return Object.hasOwn(HOST_ROUTES, sni) ? sni : '';
247
+ }
248
+
249
+ export function misdirected(req) {
250
+ const want = tunnelHostOf(req);
251
+ if (!want) return false; // not a host we terminated: there is nothing to compare against
252
+ return normalizeHost(req.headers?.host) !== want;
253
+ }
254
+
255
+ /**
256
+ * The one routing decision this process makes. null means pass-through. The gateway path is
257
+ * unchanged for the two API hosts and has the ChatGPT prefix swapped for /v1, as it always was.
258
+ * `activeTools` gates the whole host: when the tool behind a host is off, that host keeps its own
259
+ * endpoint and this switcher is no longer in its path at all (F2).
260
+ */
261
+ export function hostRoute(connectHost, url, activeTools) {
262
+ const route = HOST_ROUTES[normalizeHost(connectHost)];
263
+ if (!route) return null;
264
+ if (activeTools && !activeTools.has(route.tool)) return null;
265
+ const parsed = normalizedTarget(url);
266
+ if (!parsed) return null;
267
+ const p = parsed.pathname;
268
+ const prefix = route.prefixes.find(x => p === x || p.startsWith(`${x}/`));
269
+ if (!prefix) return null;
270
+ const gatewayPath = route.rewrite && prefix === route.rewrite.from
271
+ ? route.rewrite.to + p.slice(route.rewrite.from.length)
272
+ : p;
273
+ return { host: normalizeHost(connectHost), tool: route.tool, gatewayPath: gatewayPath + parsed.search };
274
+ }
275
+
181
276
  export function isGatewayPath(url) {
277
+ // The rule for chatgpt.com, kept as its own predicate because it is the one rule with a
278
+ // rewrite. hostRoute() above is the decision the interceptor actually makes, per CONNECT host.
182
279
  const parsed = normalizedTarget(url);
183
280
  if (!parsed) return false;
184
281
  const p = parsed.pathname;
@@ -193,8 +290,11 @@ export function toGatewayPath(url) {
193
290
  }
194
291
 
195
292
  // Codex's ChatGPT credentials. The gateway never reads them, and the gateway port is plain
196
- // HTTP that another local account can hold once it is free, so they stay on this side.
197
- const GATEWAY_STRIPPED = ['authorization', 'proxy-authorization', 'cookie', 'chatgpt-account-id', 'openai-organization'];
293
+ // HTTP that another local account can hold once it is free, so they stay on this side — and
294
+ // `authorization` and `x-api-key` join them: the gateway has its own provider keys and must
295
+ // never see the ones the client brought (R3, spec F-host-table).
296
+ const GATEWAY_STRIPPED = ['authorization', 'proxy-authorization', 'cookie', 'chatgpt-account-id',
297
+ 'openai-organization', 'x-api-key'];
198
298
 
199
299
  // The gateway answers only to a loopback Host, so rewrite it. Origin carries the
200
300
  // intercepted hostname and would fail the same check.
@@ -230,34 +330,36 @@ function armTimeout(socket, onTimeout) {
230
330
  socket.setTimeout(IDLE_TIMEOUT_MS, onTimeout);
231
331
  }
232
332
 
233
- // ---------- the TLS endpoint that pretends to be TARGET_HOST ----------
333
+ // ---------- the TLS endpoint that terminates for a host of the table ----------
234
334
 
235
335
  const mitm = https.createServer();
236
336
 
237
337
  // Collect a copy for the capture file while the bytes keep flowing. Buffering to
238
- // write the file first would hold back a streamed response. Both routes record,
239
- // because on a host with no gateway prefix every exchange is a passthrough and a
240
- // capture that skipped them would always be empty.
241
- function recordExchange(req) {
242
- if (!CAPTURE_DIR) return null;
338
+ // write the file first would hold back a streamed response.
339
+ //
340
+ // `includeBody: false` is the pass-through route (R3c): those bytes belong to the provider and
341
+ // to an OAuth exchange, and a capture exists to be read and shared. Only the shape of the
342
+ // exchange is kept — method, path, status and the redacted headers.
343
+ export function recordExchange(req, { includeBody = true, captureDir = CAPTURE_DIR } = {}) {
344
+ if (!captureDir) return null;
243
345
  const reqChunks = [];
244
- req.on('data', (c) => reqChunks.push(c));
346
+ if (includeBody) req.on('data', (c) => reqChunks.push(c));
245
347
  return (upRes) => {
246
348
  const resChunks = [];
247
- upRes.on('data', (c) => resChunks.push(c));
349
+ if (includeBody) upRes.on('data', (c) => resChunks.push(c));
248
350
  upRes.on('end', () => writeCapture({
249
351
  method: req.method,
250
352
  url: req.url,
251
353
  requestHeaders: redactHeaders(req.headers),
252
- requestBody: clip(decodeBody(Buffer.concat(reqChunks), req.headers['content-encoding'])),
354
+ ...(includeBody ? { requestBody: clip(decodeBody(Buffer.concat(reqChunks), req.headers['content-encoding'])) } : {}),
253
355
  status: upRes.statusCode,
254
356
  responseHeaders: redactHeaders(upRes.headers),
255
- responseBody: clip(decodeBody(Buffer.concat(resChunks), upRes.headers['content-encoding']))
256
- }));
357
+ ...(includeBody ? { responseBody: clip(decodeBody(Buffer.concat(resChunks), upRes.headers['content-encoding'])) } : {})
358
+ }, { dir: captureDir }));
257
359
  };
258
360
  }
259
361
 
260
- export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_PORT } = {}) {
362
+ export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_PORT, gatewayPath = null } = {}) {
261
363
  log('gateway', req.method, req.url);
262
364
  const record = recordExchange(req);
263
365
 
@@ -265,7 +367,7 @@ export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_P
265
367
  host,
266
368
  port,
267
369
  method: req.method,
268
- path: toGatewayPath(req.url),
370
+ path: gatewayPath || toGatewayPath(req.url),
269
371
  headers: gatewayHeaders(req.headers, { host, port })
270
372
  }, (upRes) => {
271
373
  res.writeHead(upRes.statusCode, upRes.headers);
@@ -280,22 +382,28 @@ export function relayToGateway(req, res, { host = GATEWAY_HOST, port = GATEWAY_P
280
382
  }
281
383
 
282
384
  mitm.on('request', (req, res) => {
283
- if (!isGatewayPath(req.url)) return passThroughRequest(req, res);
284
- relayToGateway(req, res);
385
+ // The Host header must name the host this tunnel was opened for; a mismatch is refused before
386
+ // any upstream connection exists (A4b).
387
+ if (misdirected(req)) return failExchange(res, 421, 'text/plain', 'Misdirected Request');
388
+ const route = hostRoute(tunnelHostOf(req), req.url, activeToolSet());
389
+ if (!route) return passThroughRequest(req, res);
390
+ relayToGateway(req, res, { gatewayPath: route.gatewayPath });
285
391
  });
286
392
 
287
- // Everything that is not a Codex API call goes to the real host, so sign-in and
288
- // usage pages behave exactly as they do without this process.
393
+ // Everything that is not an intercepted API call goes to the host the tunnel was opened for, so
394
+ // sign-in and usage pages behave exactly as they do without this process — including on a host
395
+ // that is in the table but whose tool is switched off (F2).
289
396
  function passThroughRequest(req, res) {
290
- log('passthrough', req.method, req.url);
291
- const record = recordExchange(req);
397
+ const connectHost = tunnelHostOf(req) || API_HOST_FALLBACK;
398
+ log('passthrough', req.method, req.url, '->', connectHost);
399
+ const record = recordExchange(req, { includeBody: false });
292
400
  const upstream = https.request({
293
- host: TARGET_HOST,
294
- servername: TARGET_HOST,
401
+ host: connectHost,
402
+ servername: connectHost,
295
403
  port: 443,
296
404
  method: req.method,
297
405
  path: req.url,
298
- headers: { ...req.headers, host: TARGET_HOST }
406
+ headers: { ...req.headers, host: connectHost }
299
407
  }, (upRes) => {
300
408
  res.writeHead(upRes.statusCode, upRes.headers);
301
409
  if (record) record(upRes);
@@ -423,15 +531,24 @@ function relayUpgrade(req, clientSocket, head, target, readyEvent, headers, requ
423
531
  clientSocket.on('error', () => target.destroy());
424
532
  }
425
533
 
426
- export function relayUpgradeToGateway(req, clientSocket, head, { host = GATEWAY_HOST, port = GATEWAY_PORT } = {}) {
534
+ export function relayUpgradeToGateway(req, clientSocket, head, { host = GATEWAY_HOST, port = GATEWAY_PORT, gatewayPath = null } = {}) {
427
535
  relayUpgrade(req, clientSocket, head, net.connect(port, host), 'connect',
428
- gatewayHeaders(req.headers, { host, port }), toGatewayPath(req.url), 'gateway');
536
+ gatewayHeaders(req.headers, { host, port }), gatewayPath || toGatewayPath(req.url), 'gateway');
429
537
  }
430
538
 
431
539
  mitm.on('upgrade', (req, clientSocket, head) => {
432
- if (isGatewayPath(req.url)) return relayUpgradeToGateway(req, clientSocket, head);
433
- relayUpgrade(req, clientSocket, head, tls.connect({ host: TARGET_HOST, port: 443, servername: TARGET_HOST }),
434
- 'secureConnect', { ...req.headers, host: TARGET_HOST }, req.url, 'passthrough');
540
+ // An upgrade answers on the raw socket: there is no response object to write 421 to.
541
+ if (misdirected(req)) {
542
+ if (clientSocket.writable) clientSocket.end('HTTP/1.1 421 Misdirected Request\r\nConnection: close\r\n\r\n');
543
+ return clientSocket.destroy();
544
+ }
545
+ const route = hostRoute(tunnelHostOf(req), req.url, activeToolSet());
546
+ if (route) return relayUpgradeToGateway(req, clientSocket, head, { gatewayPath: route.gatewayPath });
547
+ // Pass-through follows the host the tunnel was opened for, with that same name as the SNI —
548
+ // not a name this process happened to be configured with (A9).
549
+ const connectHost = tunnelHostOf(req) || API_HOST_FALLBACK;
550
+ relayUpgrade(req, clientSocket, head, tls.connect({ host: connectHost, port: 443, servername: connectHost }),
551
+ 'secureConnect', { ...req.headers, host: connectHost }, req.url, 'passthrough');
435
552
  });
436
553
 
437
554
  mitm.on('tlsClientError', (err) => log('tls client error:', err.message));
@@ -447,18 +564,51 @@ mitm.on('clientError', (err, socket) => {
447
564
  export function identityAnswer(url) {
448
565
  const challenge = new URL(url || '/', 'http://blindfold.invalid').searchParams.get('challenge');
449
566
  if (!challenge) return null;
450
- const fields = { role: 'blindfold', port: LISTEN_PORT, pid: process.pid, gatewayPort: GATEWAY_PORT, host: TARGET_HOST, prefix: API_PREFIX };
567
+ const fields = { role: 'blindfold', port: LISTEN_PORT, pid: process.pid, gatewayPort: GATEWAY_PORT, activeTools: ACTIVE_TOOLS.join(',') };
451
568
  let proof = '';
452
569
  try {
453
570
  const token = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
454
- // Same fields and order as identityProof in state.mjs.
455
- const msg = [fields.role, fields.port, fields.pid, fields.gatewayPort, fields.host, fields.prefix, challenge].join('|');
571
+ // Same fields and order as identityProof in state.mjs. `host` and `prefix` left with R3.
572
+ const msg = [fields.role, fields.port, fields.pid, fields.gatewayPort, fields.activeTools, challenge].join('|');
456
573
  if (token) proof = crypto.createHmac('sha256', token).update(msg).digest('hex');
457
574
  } catch {}
458
575
  return { proxy: 'llm-switcher-blindfold', proof, ...fields };
459
576
  }
460
577
 
578
+ // ---------------- the one control channel ----------------
579
+ // Finding 2 / Item 7: the tool set is the only thing that changes while this process runs. One
580
+ // authenticated loopback POST replaces it in memory — no signal, no restart, no socket closed and
581
+ // no in-flight stream severed. The request body is read and then ignored, on purpose: the config
582
+ // file is the one copy both ends already agree on, and a delta is one more thing to get wrong
583
+ // (Finding 4, option (a)).
584
+ const readToken = () => { try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; } };
585
+
586
+ function handleControl(req, res) {
587
+ req.resume();
588
+ const answer = (status, body) => {
589
+ res.writeHead(status, { 'Content-Type': 'application/json' });
590
+ res.end(JSON.stringify(body));
591
+ };
592
+ const token = readToken();
593
+ const given = String(req.headers['x-llm-switcher-token'] || '');
594
+ // Loopback is not an identity: every process on this machine can reach this port.
595
+ if (!token || !given || given !== token) return answer(403, { ok: false, error: 'forbidden' });
596
+ let next;
597
+ try {
598
+ next = deriveActiveTools(JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))).sort();
599
+ } catch (err) {
600
+ // A config we cannot read must not empty the table: keep serving the set we already have.
601
+ return answer(500, { ok: false, error: err.message });
602
+ }
603
+ ACTIVE_TOOLS = next;
604
+ log('active tools ->', next.join(',') || '(none)');
605
+ return answer(200, { ok: true, activeTools: next });
606
+ }
607
+
461
608
  const proxy = http.createServer((req, res) => {
609
+ let pathname = '';
610
+ try { pathname = new URL(req.url || '/', 'http://blindfold.invalid').pathname; } catch {}
611
+ if (req.method === 'POST' && pathname === '/_control/active-tools') return handleControl(req, res);
462
612
  const identity = identityAnswer(req.url);
463
613
  if (identity) {
464
614
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -484,12 +634,6 @@ for (const [prefix, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::
484
634
  LOCAL_RANGES.addSubnet(prefix, bits, 'ipv6');
485
635
  }
486
636
 
487
- export function isInterceptedHost(host) {
488
- return host === TARGET_HOST;
489
- }
490
-
491
- // Decides on an address, never on a spelling: `0`, `2130706433`, `127.1` and names such as
492
- // localtest.me all resolve to loopback. Something that is not an address counts as local.
493
637
  export function isLocalAddress(address) {
494
638
  const family = net.isIP(address);
495
639
  if (family === 0) return true;
@@ -552,6 +696,9 @@ proxy.on('connect', (req, clientSocket, head) => {
552
696
 
553
697
  if (isInterceptedHost(host)) {
554
698
  log('intercept CONNECT', target);
699
+ // The host this tunnel was opened for. Every later decision — the 421 check, the host table,
700
+ // and which origin pass-through re-originates to — reads this instead of a configured name.
701
+ clientSocket._connectHost = normalizeHost(host);
555
702
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
556
703
  if (head?.length) clientSocket.unshift(head);
557
704
  // Hand the raw socket to the TLS endpoint: it completes the handshake with our leaf.
@@ -623,9 +770,9 @@ export function start() {
623
770
  proxy.listen(LISTEN_PORT, '127.0.0.1', () => {
624
771
  listening = true;
625
772
  console.log(`[blindfold] proxy on http://127.0.0.1:${LISTEN_PORT}`);
626
- console.log(`[blindfold] ${TARGET_HOST}${API_PREFIX}/* -> http://${GATEWAY_HOST}:${GATEWAY_PORT}${GATEWAY_PREFIX}/*`);
627
- console.log(`[blindfold] every other path on ${TARGET_HOST} is re-originated to the real host`);
628
- console.log('[blindfold] every other public host is tunneled; local destinations are refused');
773
+ console.log(`[blindfold] intercepts ${Object.keys(HOST_ROUTES).join(', ')}`);
774
+ console.log(`[blindfold] api paths -> http://${GATEWAY_HOST}:${GATEWAY_PORT}${GATEWAY_PREFIX} for ${ACTIVE_TOOLS.join(',') || 'no tool'}`);
775
+ console.log('[blindfold] every other path is re-originated to its own host; local destinations are refused');
629
776
  });
630
777
  }
631
778
 
@@ -14,7 +14,18 @@
14
14
  # CODEX_CA_CERTIFICATE environment variable, which the switcher exports for you.
15
15
  set -euo pipefail
16
16
 
17
- HOST="${1:-chatgpt.com}"
17
+ # R3: the interceptor answers on a fixed host table, so one leaf has to carry all three hosts and
18
+ # this CA may only ever sign for them. A certificate an older version built covers a single host;
19
+ # state.mjs `blindfoldPreflight` detects that and names this script instead of starting an
20
+ # interceptor whose handshake would fail on two of the three hosts.
21
+ INTERCEPT_HOSTS=(api.anthropic.com api.openai.com chatgpt.com)
22
+ SAN_LIST=""
23
+ NC_LIST=""
24
+ for h in "${INTERCEPT_HOSTS[@]}"; do
25
+ SAN_LIST="${SAN_LIST:+$SAN_LIST,}DNS:$h"
26
+ NC_LIST="${NC_LIST:+$NC_LIST,}permitted;DNS:$h"
27
+ done
28
+
18
29
  # The default must match the folder the gateway reads (state.mjs paths.blindfoldCA): an npm
19
30
  # install keeps its data in ~/.llm-switcher, a git checkout next to the code.
20
31
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -22,7 +33,14 @@ if [ -n "${LLM_SWITCHER_BLINDFOLD_CERTS:-}" ]; then DEFAULT_OUT="$LLM_SWITCHER_B
22
33
  elif [ -n "${LLM_SWITCHER_HOME:-}" ]; then DEFAULT_OUT="$LLM_SWITCHER_HOME/blindfold/certs"
23
34
  elif [ -d "$ROOT/.git" ]; then DEFAULT_OUT="$ROOT/blindfold/certs"
24
35
  else DEFAULT_OUT="$HOME/.llm-switcher/blindfold/certs"; fi
25
- OUT_DIR="${2:-$DEFAULT_OUT}"
36
+ # Older commands read `make-certs.sh <host> <out-dir>`. The host list is fixed now, so the host is
37
+ # ignored; two arguments keep those commands working. A lone path-like argument is the directory.
38
+ OUT_ARG=""
39
+ if [ "$#" -ge 2 ]; then OUT_ARG="$2"
40
+ elif [ "$#" -eq 1 ]; then
41
+ case "$1" in */*|.*) OUT_ARG="$1" ;; *) : ;; esac
42
+ fi
43
+ OUT_DIR="${OUT_ARG:-$DEFAULT_OUT}"
26
44
  CA_DAYS=3650
27
45
  LEAF_DAYS=825
28
46
 
@@ -42,7 +60,7 @@ chmod 700 "$OUT_DIR"
42
60
  WORK="$(mktemp -d "$OUT_DIR/.build.XXXXXX")"
43
61
  trap 'rm -rf "$WORK"' EXIT
44
62
 
45
- echo "[blindfold] host : $HOST"
63
+ echo "[blindfold] hosts : ${INTERCEPT_HOSTS[*]}"
46
64
  echo "[blindfold] output : $OUT_DIR"
47
65
 
48
66
  cat > "$WORK/ca.cnf" <<EOF
@@ -58,8 +76,9 @@ CN = LLM Switcher Local CA
58
76
  basicConstraints = critical,CA:TRUE,pathlen:0
59
77
  keyUsage = critical,keyCertSign,cRLSign
60
78
  subjectKeyIdentifier = hash
61
- # Codex trusts this CA for every host. The constraint limits a leaked ca.key to HOST and its subdomains.
62
- nameConstraints = critical,permitted;DNS:$HOST
79
+ # Codex trusts this CA for every host. The constraint keeps a leaked ca.key to the host table:
80
+ # a client that obeys name constraints refuses every other certificate this CA could sign.
81
+ nameConstraints = critical,$NC_LIST
63
82
  EOF
64
83
 
65
84
  cat > "$WORK/leaf.cnf" <<EOF
@@ -68,14 +87,14 @@ prompt = no
68
87
  distinguished_name = dn
69
88
 
70
89
  [dn]
71
- CN = $HOST
90
+ CN = llm-switcher
72
91
  EOF
73
92
 
74
93
  cat > "$WORK/leaf.ext" <<EOF
75
94
  basicConstraints = critical,CA:FALSE
76
95
  keyUsage = critical,digitalSignature,keyEncipherment
77
96
  extendedKeyUsage = serverAuth
78
- subjectAltName = DNS:$HOST,DNS:*.$HOST
97
+ subjectAltName = $SAN_LIST
79
98
  EOF
80
99
 
81
100
  openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/ca.key"