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.
Files changed (61) hide show
  1. package/CHANGELOG.md +63 -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/classifier.mjs +238 -0
  8. package/config.example.json +12 -34
  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/ensure-ca-bundle.mjs +28 -0
  24. package/formats.mjs +43 -156
  25. package/icons/antigravity.png +0 -0
  26. package/icons/claude.png +0 -0
  27. package/icons/codex.png +0 -0
  28. package/icons/deepseek.png +0 -0
  29. package/icons/gemini.png +0 -0
  30. package/icons/github.png +0 -0
  31. package/icons/groq.png +0 -0
  32. package/icons/intact.svg +1 -0
  33. package/icons/ollama.png +0 -0
  34. package/icons/openai.png +0 -0
  35. package/icons/openrouter.png +0 -0
  36. package/icons/qwen.png +0 -0
  37. package/icons/vertex.png +0 -0
  38. package/mcp.mjs +39 -11
  39. package/package.json +1 -1
  40. package/proxy.mjs +114 -37
  41. package/shim.mjs +200 -57
  42. package/skills/llm-switcher/SKILL.md +15 -10
  43. package/state.mjs +1100 -191
  44. package/switch.cmd +2 -2
  45. package/switch.mjs +228 -53
  46. package/tests/blindfold-e2e.test.mjs +380 -0
  47. package/tests/blindfold-task5.test.mjs +429 -0
  48. package/tests/blindfold.task3.test.mjs +700 -0
  49. package/tests/blindfold.test.mjs +10 -5
  50. package/tests/catalog.test.mjs +147 -0
  51. package/tests/classifier.test.mjs +210 -0
  52. package/tests/contract-lab.test.mjs +22 -7
  53. package/tests/formats.test.mjs +63 -46
  54. package/tests/gateway.e2e.test.mjs +136 -36
  55. package/tests/lifecycle.test.mjs +16 -10
  56. package/tests/mcp.test.mjs +78 -2
  57. package/tests/real-user-sim.test.mjs +464 -0
  58. package/tests/shim.test.mjs +159 -66
  59. package/tests/state.test.mjs +975 -193
  60. package/tests/switch.test.mjs +446 -2
  61. package/ui.html +1710 -1726
@@ -0,0 +1,429 @@
1
+ // Task 5 (interceptor half) — R5, R6, R7b and the clause the gate names: an in-place tool-set
2
+ // update must keep the port open and leave a request that is already on the wire to finish.
3
+ //
4
+ // The invariant again: the CLI never becomes a proxy. It writes config.json, then asks the
5
+ // interceptor to re-derive its own tool set over one authenticated loopback POST — the body is
6
+ // ignored, because the interceptor owns the config (Finding 4, option (a)).
7
+ //
8
+ // state.mjs binds its paths at import time, so this file has one state dir for everything it does
9
+ // and every CLI child is pointed at the same one.
10
+
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import net from 'node:net';
17
+ import tls from 'node:tls';
18
+ import http from 'node:http';
19
+ import { spawn, spawnSync } from 'node:child_process';
20
+ import { once } from 'node:events';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-task5i-'));
25
+ process.env.LLM_SWITCHER_HOME = TMP;
26
+ process.env.LLM_SWITCHER_STATE_DIR = TMP;
27
+ process.env.LLM_SWITCHER_CONFIG = path.join(TMP, 'config.json');
28
+ delete process.env.LLM_SWITCHER_BLINDFOLD_CERTS;
29
+
30
+ const s = await import('../state.mjs');
31
+
32
+ const INTERCEPTOR = path.join(ROOT, 'blindfold', 'blindfold.mjs');
33
+ const CERT_SCRIPT = path.join(ROOT, 'blindfold', 'make-certs.sh');
34
+ const CA_PEM = path.join(TMP, 'blindfold', 'certs', 'ca.pem');
35
+
36
+ // Windows has no `bash` on PATH, but Git for Windows ships one and it carries openssl — which is
37
+ // all make-certs.sh needs. Without either, these tests say so instead of passing unasserted.
38
+ function findBash() {
39
+ for (const candidate of ['bash', 'C:\\Program Files\\Git\\bin\\bash.exe', '/usr/bin/bash', '/bin/bash']) {
40
+ try {
41
+ const probe = spawnSync(candidate, ['--version'], { stdio: 'ignore' });
42
+ if (!probe.error && probe.status === 0) return candidate;
43
+ } catch { /* try the next one */ }
44
+ }
45
+ return null;
46
+ }
47
+ const BASH = findBash();
48
+ const toBashPath = (p) => p.replace(/\\/g, '/');
49
+
50
+ let certProblem = null;
51
+ try {
52
+ if (!BASH) throw new Error('no bash (needs Git for Windows on this machine)');
53
+ const r = spawnSync(BASH, [CERT_SCRIPT, 'chatgpt.com', toBashPath(path.join(TMP, 'blindfold', 'certs'))], { encoding: 'utf8' });
54
+ if (r.status !== 0) throw new Error(`make-certs.sh failed: ${r.stderr || r.stdout}`);
55
+ } catch (err) {
56
+ certProblem = err.message;
57
+ }
58
+ const noCerts = certProblem ? `needs certificates: ${certProblem}` : false;
59
+
60
+ const TOKEN = s.ensureAdminToken();
61
+ const STATE = s.blindfoldStatePath;
62
+
63
+ const freePort = async () => {
64
+ const srv = net.createServer();
65
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
66
+ const { port } = srv.address();
67
+ await new Promise((r) => srv.close(r));
68
+ return port;
69
+ };
70
+
71
+ // ---------- an interceptor process, and the gateway it relays to ----------
72
+
73
+ // The gateway stands in for proxy.mjs: the interceptor only has to reach something that answers,
74
+ // and `delayMs` is what puts a request on the wire across a `switch off`.
75
+ function fakeGateway({ delayMs = 0 } = {}) {
76
+ const seen = [];
77
+ const server = http.createServer((req, res) => {
78
+ const chunks = [];
79
+ req.on('data', (c) => chunks.push(c));
80
+ req.on('end', () => {
81
+ seen.push({ method: req.method, url: req.url });
82
+ const body = JSON.stringify({ ok: true });
83
+ const send = () => {
84
+ if (res.writableEnded || res.destroyed) return;
85
+ res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
86
+ res.end(body);
87
+ };
88
+ if (delayMs) setTimeout(send, delayMs).unref?.();
89
+ else send();
90
+ });
91
+ });
92
+ return new Promise((resolve) => {
93
+ server.listen(0, '127.0.0.1', () => resolve({
94
+ port: server.address().port,
95
+ seen,
96
+ close: () => new Promise((r) => server.close(r))
97
+ }));
98
+ });
99
+ }
100
+
101
+ async function startInterceptor({ port, gatewayPort, config, activeTools = 'claude,codex' }) {
102
+ const child = spawn(process.execPath, [
103
+ INTERCEPTOR,
104
+ '--port', String(port),
105
+ '--gateway-port', String(gatewayPort),
106
+ '--config', config,
107
+ '--token-file', s.adminTokenPath,
108
+ '--certs', path.join(TMP, 'blindfold', 'certs'),
109
+ '--active-tools', activeTools
110
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
111
+ const output = [];
112
+ child.stdout.on('data', (d) => output.push(String(d)));
113
+ child.stderr.on('data', (d) => output.push(String(d)));
114
+
115
+ const stop = async () => {
116
+ if (child.exitCode !== null) return;
117
+ child.kill();
118
+ await once(child, 'exit').catch(() => {});
119
+ };
120
+
121
+ const deadline = Date.now() + 8000;
122
+ let probe = { state: 'free' };
123
+ while (Date.now() < deadline && probe.state !== 'ours') {
124
+ if (child.exitCode !== null) throw new Error(`interceptor exited with ${child.exitCode}:\n${output.join('')}`);
125
+ probe = await s.probeBlindfold(port);
126
+ if (probe.state !== 'ours') await new Promise((r) => setTimeout(r, 100));
127
+ }
128
+ if (probe.state !== 'ours') {
129
+ await stop();
130
+ throw new Error(`interceptor never answered as ours (state=${probe.state}):\n${output.join('')}`);
131
+ }
132
+ return { child, stop, port, pid: probe.pid };
133
+ }
134
+
135
+ // ---------- a CONNECT tunnel, then TLS and an HTTP request inside it ----------
136
+
137
+ async function raceTimeout(promise, ms, what) {
138
+ let timer;
139
+ try {
140
+ return await Promise.race([
141
+ promise,
142
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} did not finish within ${ms}ms`)), ms); })
143
+ ]);
144
+ } finally {
145
+ clearTimeout(timer);
146
+ }
147
+ }
148
+
149
+ const readUntilHead = (socket) => new Promise((resolve, reject) => {
150
+ let buf = '';
151
+ const onData = (c) => {
152
+ buf += String(c);
153
+ if (buf.includes('\r\n\r\n')) { socket.off('data', onData); socket.off('error', onError); resolve(buf); }
154
+ };
155
+ const onError = (e) => reject(e);
156
+ socket.on('data', onData);
157
+ socket.on('error', onError);
158
+ const timer = setTimeout(() => {
159
+ socket.off('data', onData);
160
+ reject(new Error(`no response head within 8s: ${JSON.stringify(buf.slice(0, 400))}`));
161
+ }, 8000);
162
+ socket.once('close', () => clearTimeout(timer));
163
+ });
164
+
165
+ async function openTls(proxyPort, host) {
166
+ const socket = net.connect(proxyPort, '127.0.0.1');
167
+ await raceTimeout(once(socket, 'connect'), 5000, `CONNECT to 127.0.0.1:${proxyPort}`);
168
+ socket.write(`CONNECT ${host}:443 HTTP/1.1\r\nHost: ${host}:443\r\n\r\n`);
169
+ const head = await readUntilHead(socket);
170
+ assert.match(head, /^HTTP\/1\.1 200 /, `CONNECT refused: ${head}`);
171
+ const secure = tls.connect({ socket, servername: host, ca: fs.readFileSync(CA_PEM), rejectUnauthorized: true });
172
+ await raceTimeout(once(secure, 'secureConnect'), 8000, 'TLS handshake');
173
+ return secure;
174
+ }
175
+
176
+ function tryParse(buf, ended) {
177
+ const text = buf.toString('latin1');
178
+ const i = text.indexOf('\r\n\r\n');
179
+ if (i === -1) return null;
180
+ const head = text.slice(0, i);
181
+ const m = /^HTTP\/1\.[01] (\d{3})/.exec(head);
182
+ if (!m) return null;
183
+ const status = Number(m[1]);
184
+ const len = /content-length:\s*(\d+)/i.exec(head);
185
+ if (len) {
186
+ const need = Number(len[1]);
187
+ if (buf.length < i + 4 + need) return null;
188
+ return { status, head, body: buf.slice(i + 4, i + 4 + need).toString('utf8') };
189
+ }
190
+ if (ended || status === 421 || status === 502 || status === 400 || status === 404) {
191
+ return { status, head, body: buf.slice(i + 4).toString('utf8') };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function readResponse(stream, { timeoutMs = 8000 } = {}) {
197
+ return new Promise((resolve) => {
198
+ const chunks = [];
199
+ let settled = false;
200
+ const finish = () => {
201
+ if (settled) return;
202
+ settled = true;
203
+ clearTimeout(timer);
204
+ resolve(tryParse(Buffer.concat(chunks), true) ?? { status: 0, head: Buffer.concat(chunks).toString('latin1'), body: '' });
205
+ };
206
+ const timer = setTimeout(finish, timeoutMs);
207
+ stream.on('data', (c) => {
208
+ chunks.push(c);
209
+ const done = tryParse(Buffer.concat(chunks), false);
210
+ if (done) { settled = true; clearTimeout(timer); resolve(done); }
211
+ });
212
+ stream.on('end', finish);
213
+ stream.on('close', finish);
214
+ stream.on('error', finish);
215
+ });
216
+ }
217
+
218
+ function post(secure, { url, host, body = '', headers = {} }) {
219
+ const extra = Object.entries(headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
220
+ secure.write(`POST ${url} HTTP/1.1\r\nHost: ${host}\r\n`
221
+ + `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${extra}\r\n${body}`);
222
+ return readResponse(secure);
223
+ }
224
+
225
+ // ---------- the CLI, pointed at this file's state dir ----------
226
+
227
+ function runCli(args, extraEnv = {}) {
228
+ return new Promise((resolve) => {
229
+ const child = spawn(process.execPath, [path.join(ROOT, 'switch.mjs'), ...args], {
230
+ env: {
231
+ ...process.env, HOME: TMP, USERPROFILE: TMP, CLAUDE_CONFIG_DIR: path.join(TMP, 'claude'),
232
+ LLM_SWITCHER_STATE_DIR: TMP, LLM_SWITCHER_PORT: '', PORT: '', ...extraEnv
233
+ },
234
+ stdio: ['ignore', 'pipe', 'pipe']
235
+ });
236
+ let stdout = '';
237
+ let stderr = '';
238
+ child.stdout.on('data', (d) => { stdout += d; });
239
+ child.stderr.on('data', (d) => { stderr += d; });
240
+ const timer = setTimeout(() => child.kill('SIGKILL'), 30000);
241
+ child.on('close', (code) => { clearTimeout(timer); resolve({ status: code, stdout, stderr }); });
242
+ });
243
+ }
244
+
245
+ // A config already in the new shape: no legacy pointer, so loading it writes nothing. Anything the
246
+ // CLI changes below is this command's own doing.
247
+ function writeConfig(file, port, bfPort, { claude = 'a', codex = 'b' } = {}) {
248
+ fs.writeFileSync(file, JSON.stringify({
249
+ port,
250
+ blindfold: { port: bfPort },
251
+ activeProfiles: { claude, codex },
252
+ profiles: {
253
+ a: { name: 'A', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x' } },
254
+ b: { name: 'B', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'y' } }
255
+ }
256
+ }, null, 2), { mode: 0o600 });
257
+ return file;
258
+ }
259
+
260
+ const envFile = (name) => path.join(TMP, `env-${name}.sh`);
261
+ const seedEnv = () => {
262
+ fs.writeFileSync(envFile('claude'), "export HTTPS_PROXY='claude'\n");
263
+ fs.writeFileSync(envFile('codex'), "export HTTPS_PROXY='codex'\n");
264
+ };
265
+
266
+ // ==================================================================
267
+ // R6 — an in-place tool-set update keeps the port open
268
+ // ==================================================================
269
+
270
+ test('R6: switch off claude empties env-claude.sh, keeps codex active, and an in-flight request through the interceptor completes without the port restarting', { skip: noCerts }, async (t) => {
271
+ const gateway = await fakeGateway({ delayMs: 900 });
272
+ t.after(() => gateway.close());
273
+ const bfPort = await freePort();
274
+ const cfg = path.join(TMP, 'cfg-inflight.json');
275
+ writeConfig(cfg, gateway.port, bfPort);
276
+ seedEnv();
277
+
278
+ const it = await startInterceptor({ port: bfPort, gatewayPort: gateway.port, config: cfg, activeTools: 'claude,codex' });
279
+ t.after(() => it.stop());
280
+
281
+ // A request on the wire: it is written, not awaited, so the switch below runs across it.
282
+ const secure = await openTls(bfPort, 'api.anthropic.com');
283
+ t.after(() => secure.destroy());
284
+ const inflight = post(secure, {
285
+ url: '/v1/messages',
286
+ host: 'api.anthropic.com',
287
+ body: JSON.stringify({ model: 'x' }),
288
+ headers: { 'content-type': 'application/json' }
289
+ });
290
+ // Give the interceptor time to have the request in hand before anything is changed.
291
+ await new Promise((r) => setTimeout(r, 150));
292
+
293
+ const sw = await runCli(['off', 'claude'], { LLM_SWITCHER_CONFIG: cfg });
294
+ assert.equal(sw.status, 0, sw.stdout + sw.stderr);
295
+
296
+ const resp = await inflight;
297
+ assert.equal(resp.status, 200, `the in-flight request must finish, got ${resp.status}: ${resp.head}`);
298
+ assert.deepEqual(JSON.parse(resp.body), { ok: true }, 'and carry the gateway answer through');
299
+
300
+ // Same process, same port: an update is not a restart.
301
+ const after = await s.probeBlindfold(bfPort);
302
+ assert.equal(after.state, 'ours', 'the interceptor still answers');
303
+ assert.equal(after.pid, it.pid, 'the same interceptor process: the port was never rebound');
304
+ assert.equal(after.activeTools, 'codex', 'it re-derived the set from config.json on its own');
305
+
306
+ assert.equal(fs.readFileSync(envFile('claude'), 'utf8'), '', 'env-claude.sh emptied');
307
+ assert.match(fs.readFileSync(envFile('codex'), 'utf8'), new RegExp(`export HTTPS_PROXY='http://127.0.0.1:${bfPort}'`), 'codex still has the interceptor behind it');
308
+ assert.equal(JSON.parse(fs.readFileSync(cfg, 'utf8')).activeProfiles.claude, null, 'claude pointer cleared');
309
+ assert.equal(JSON.parse(fs.readFileSync(cfg, 'utf8')).activeProfiles.codex, 'b', 'codex still active');
310
+ });
311
+
312
+ // ==================================================================
313
+ // R7b — collision mode: turn one tool off, leave the other running
314
+ // ==================================================================
315
+
316
+ test('R7b: during collision, switch off claude exits 0, leaves env-codex.sh untouched, and the Codex interceptor still answers', { skip: noCerts }, async (t) => {
317
+ const gateway = await fakeGateway({ delayMs: 0 });
318
+ t.after(() => gateway.close());
319
+ const bfPort = await freePort();
320
+ const cfg = path.join(TMP, 'cfg-collision.json');
321
+ // `p` serves both tools, so migration would split it into p-claude and p-codex — and p-codex
322
+ // already exists. The file this build refuses to rewrite. anthropic/responses stay absent: seeded
323
+ // to null they would say both tools are off, and there would be no "other tool" left to keep.
324
+ fs.writeFileSync(cfg, JSON.stringify({
325
+ port: gateway.port,
326
+ activeProfile: 'p',
327
+ blindfold: { port: bfPort },
328
+ activeProfiles: { 'openai-chat': null, vertex: null },
329
+ profiles: {
330
+ p: { name: 'P', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x', main: 'y' } },
331
+ 'p-codex': { name: 'P codex', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'y' } }
332
+ }
333
+ }, null, 2), { mode: 0o600 });
334
+ const before = fs.readFileSync(cfg, 'utf8');
335
+ seedEnv();
336
+
337
+ const it = await startInterceptor({ port: bfPort, gatewayPort: gateway.port, config: cfg, activeTools: 'claude,codex' });
338
+ t.after(() => it.stop());
339
+
340
+ const sw = await runCli(['off', 'claude'], { LLM_SWITCHER_CONFIG: cfg });
341
+ assert.equal(sw.status, 0, `a refused save must not make "switch off" unusable:\n${sw.stdout}${sw.stderr}`);
342
+
343
+ const after = await s.probeBlindfold(bfPort);
344
+ assert.equal(after.state, 'ours', 'the Codex interceptor still answers');
345
+ assert.equal(after.activeTools, 'codex', 'claude is off, codex is not');
346
+
347
+ assert.equal(fs.readFileSync(envFile('claude'), 'utf8'), '', 'env-claude.sh emptied');
348
+ assert.equal(fs.readFileSync(envFile('codex'), 'utf8'), "export HTTPS_PROXY='codex'\n", 'env-codex.sh untouched');
349
+
350
+ const now = fs.readFileSync(cfg, 'utf8');
351
+ const parsed = JSON.parse(now);
352
+ assert.equal(parsed.activeProfiles.claude, null, 'claude pointer cleared');
353
+ assert.equal(parsed.activeProfile, 'p', 'the legacy pointer this build will not rewrite stays');
354
+ assert.ok(now.includes('"inFormat"'), 'the colliding file was not migrated behind the user\'s back');
355
+ assert.ok(now !== before, 'only the one pointer changed');
356
+ });
357
+
358
+ // ==================================================================
359
+ // R5 — the interceptor re-derives its own set; the body carries nothing
360
+ // ==================================================================
361
+
362
+ test('R5: with no gateway answering, switch off codex posts to the interceptor and it re-derives the tool set from config.json', { skip: noCerts }, async (t) => {
363
+ const gateway = await fakeGateway({ delayMs: 0 });
364
+ t.after(() => gateway.close());
365
+ const bfPort = await freePort();
366
+ const cfg = path.join(TMP, 'cfg-rederive.json');
367
+ writeConfig(cfg, gateway.port, bfPort);
368
+ seedEnv();
369
+
370
+ const it = await startInterceptor({ port: bfPort, gatewayPort: gateway.port, config: cfg, activeTools: 'claude,codex' });
371
+ t.after(() => it.stop());
372
+
373
+ // No gateway of ours is running, so the CLI has to talk to the interceptor itself.
374
+ const sw = await runCli(['off', 'codex'], { LLM_SWITCHER_CONFIG: cfg });
375
+ assert.equal(sw.status, 0, sw.stdout + sw.stderr);
376
+
377
+ const after = await s.probeBlindfold(bfPort);
378
+ assert.equal(after.state, 'ours', 'the port stayed open through the update');
379
+ assert.equal(after.activeTools, 'claude', 'codex is off, claude is not');
380
+ assert.equal(fs.readFileSync(envFile('codex'), 'utf8'), '', 'env-codex.sh emptied');
381
+ assert.match(fs.readFileSync(envFile('claude'), 'utf8'), new RegExp(`export HTTPS_PROXY='http://127.0.0.1:${bfPort}'`), 'claude still has the interceptor behind it');
382
+ });
383
+
384
+ // ==================================================================
385
+ // the recorded state may not outlive the process it names
386
+ // ==================================================================
387
+
388
+ test('stopRecordedBlindfold deletes blindfold.json only after the interceptor port no longer answers ours', { skip: noCerts }, async (t) => {
389
+ const gwPort = await freePort();
390
+ const bfPort = await freePort();
391
+ const cfg = path.join(TMP, 'cfg-recorded.json');
392
+ writeConfig(cfg, gwPort, bfPort);
393
+
394
+ const gateway = await fakeGateway({ delayMs: 0 });
395
+ t.after(() => gateway.close());
396
+ const it = await startInterceptor({ port: bfPort, gatewayPort: gwPort, config: cfg, activeTools: 'claude,codex' });
397
+ t.after(() => it.stop());
398
+
399
+ fs.writeFileSync(STATE, JSON.stringify({ port: bfPort, pid: it.pid }), { mode: 0o600 });
400
+ const live = await s.probeBlindfold(bfPort);
401
+ assert.equal(live.state, 'ours', 'sanity: it answers as ours before anything is stopped');
402
+
403
+ const r = await s.stopRecordedBlindfold();
404
+ assert.equal(r.ok, true, r.error);
405
+
406
+ const now = await s.probeBlindfold(bfPort);
407
+ assert.notEqual(now.state, 'ours', 'the port no longer answers as ours');
408
+ assert.notEqual(now.state, 'legacy-ours', 'nor as an older build of ours');
409
+ assert.equal(fs.existsSync(STATE), false, 'the record is gone once the process it names is gone');
410
+ });
411
+
412
+ test('stopRecordedBlindfold never signals a foreign process holding the recorded port', { skip: noCerts }, async (t) => {
413
+ const port = await freePort();
414
+ // A listener that answers with a body this switcher does not recognise: not ours, not killable.
415
+ const stranger = http.createServer((req, res) => res.end('{"hello":true}'));
416
+ await new Promise((r) => stranger.listen(port, '127.0.0.1', r));
417
+ t.after(() => new Promise((r) => stranger.close(r)));
418
+
419
+ fs.writeFileSync(STATE, JSON.stringify({ port, pid: process.pid }), { mode: 0o600 });
420
+ const r = await s.stopRecordedBlindfold();
421
+
422
+ assert.equal(r.ok, true, 'a port that is not ours is nothing to stop');
423
+ let alive = false;
424
+ try { alive = (await fetch(`http://127.0.0.1:${port}/`)).ok; } catch { /* gone */ }
425
+ assert.equal(alive, true, 'a foreign listener must survive: this switcher never signals a pid it did not prove');
426
+ assert.equal(fs.existsSync(STATE), false, 'and the stale record is cleared');
427
+ });
428
+
429
+ process.on('exit', () => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ } });