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
package/state.mjs CHANGED
@@ -28,13 +28,23 @@ export const DATA_DIR = resolveDataDir(ROOT_DIR);
28
28
  if (DATA_DIR !== ROOT_DIR) {
29
29
  try { fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); } catch {}
30
30
  }
31
- export const TARGETS = ['anthropic', 'responses', 'openai-chat', 'vertex'];
31
+ // R5: a request has exactly two targets now — the two tools. `anthropic` and `responses`
32
+ // survive only as spellings of them; `openai-chat` and `vertex` survive only as names
33
+ // migration still has to recognise in a config this build has not rewritten yet.
34
+ const LEGACY_TARGET_TOOL = { anthropic: 'claude', responses: 'codex' };
35
+ // Migration input only: the pointer keys an unmigrated config may still carry.
36
+ const LEGACY_TARGETS = ['anthropic', 'responses', 'openai-chat', 'vertex'];
37
+ export const TOOLS = ['claude', 'codex'];
38
+ export const COMMAND_WORDS = new Set(['claude', 'codex', 'on', 'off', 'status', 'doctor', 'ui']);
32
39
  export const DEFAULT_PORT = 3456;
33
40
  export const DEFAULT_BLINDFOLD_PORT = 3457;
34
41
  // Codex with ChatGPT sign-in calls https://chatgpt.com/backend-api/codex.
35
42
  // An API-key account calls https://api.openai.com/v1 instead.
36
- export const DEFAULT_BLINDFOLD_HOST = 'chatgpt.com';
37
- export const DEFAULT_BLINDFOLD_PREFIX = '/backend-api/codex';
43
+ // R3 retired these: routing uses the host table of R3 (INTERCEPT_HOSTS) and nothing else. They
44
+ // survive only as migration input, so the migration can still tell a default value from a custom
45
+ // one before it drops the per-profile field.
46
+ const LEGACY_DEFAULT_BLINDFOLD_HOST = 'chatgpt.com';
47
+ const LEGACY_DEFAULT_BLINDFOLD_PREFIX = '/backend-api/codex';
38
48
  export const CLAUDE_MODEL_SLOTS = ['opus', 'sonnet', 'haiku', 'fable'];
39
49
  // Codex CLI model roles per OpenAI docs (config-reference):
40
50
  // - main <-> `model` (session model)
@@ -114,63 +124,799 @@ export const STATE_DIR = process.env.LLM_SWITCHER_STATE_DIR ? path.resolve(proce
114
124
 
115
125
  export const paths = {
116
126
  activeFlag: path.join(STATE_DIR, 'active.flag'),
117
- flag1M: path.join(STATE_DIR, '1m.flag'), // Claude Code launcher flag
118
- flagCodex1M: path.join(STATE_DIR, 'codex-1m.flag'), // Codex launcher flag
119
- flagOpenAI1M: path.join(STATE_DIR, 'openai-1m.flag'), // OpenAI launcher flag
127
+ // env.sh / env.cmd stay behind as neutral stubs: a shell rc that still sources them gets
128
+ // nothing new (R8). They are never unlinked, so an old rc line never turns into an error.
120
129
  envCmd: path.join(STATE_DIR, 'env.cmd'),
121
130
  envSh: path.join(STATE_DIR, 'env.sh'),
131
+ // One env file per tool: a proxy meant for one tool can never capture the other's traffic.
132
+ envClaudeCmd: path.join(STATE_DIR, 'env-claude.cmd'),
133
+ envClaudeSh: path.join(STATE_DIR, 'env-claude.sh'),
122
134
  envCodexCmd: path.join(STATE_DIR, 'env-codex.cmd'),
123
135
  envCodexSh: path.join(STATE_DIR, 'env-codex.sh'),
124
- codexCatalog: path.join(STATE_DIR, 'model-catalog.json'),
136
+ // The gateway port of the last switch on. The shim scrubs a stale loopback base URL
137
+ // against it, so a shell opened before a port change still reaches the right gateway.
138
+ gatewayPort: path.join(STATE_DIR, 'gateway.port'),
125
139
  proxyLog: path.join(STATE_DIR, 'proxy.log'),
126
140
  blindfoldLog: path.join(STATE_DIR, 'blindfold.log'),
141
+ // The template is an input, not an output: /v1/models still builds its entries from it (R9).
142
+ // Only model-catalog.json (paths.codexCatalog) is no longer written.
127
143
  codexCatalogTemplate: path.join(ROOT_DIR, 'codex-catalog-template.json'),
128
- blindfoldCA: path.join(process.env.LLM_SWITCHER_BLINDFOLD_CERTS || path.join(DATA_DIR, 'blindfold', 'certs'), 'ca.pem')
144
+ blindfoldCA: path.join(process.env.LLM_SWITCHER_BLINDFOLD_CERTS || path.join(DATA_DIR, 'blindfold', 'certs'), 'ca.pem'),
145
+ // The bundle holds the user's own CA plus the switcher CA, so Claude Code keeps trusting
146
+ // both. Keyed by the hash of the user's PEM path: two different user CAs never share a file.
147
+ claudeCaBundle: (userCaPath) => path.join(STATE_DIR,
148
+ `claude-ca-bundle-${crypto.createHash('sha256').update(path.resolve(userCaPath)).digest('hex').slice(0, 16)}.pem`),
149
+ // Remembers which PEM the bundle was built from, so a shell that inherits the bundle path
150
+ // rebuilds from the original user file instead of copying the bundle into itself.
151
+ claudeCaBundleSidecar: (userCaPath) => `${paths.claudeCaBundle(userCaPath)}.src`
129
152
  };
130
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // Claude CA bundle (R2)
156
+ //
157
+ // Claude Code appends NODE_EXTRA_CA_CERTS to the system roots. Pointing it straight at our
158
+ // ca.pem would drop a CA the user already trusts (a corporate root, for example), so the shim
159
+ // points it at a bundle holding BOTH: the user's certificates and the switcher CA.
160
+ //
161
+ // The bundle is keyed by the user's PEM path, and a `.src` sidecar records that path. A nested
162
+ // shell that inherits the bundle path therefore rebuilds from the ORIGINAL user file instead of
163
+ // copying the bundle into itself on every run.
164
+ //
165
+ // Staleness is content comparison only: mtimes lie across copies and checkouts.
166
+ //
167
+ // Every failure prints to stderr and returns ok:false so the shim can keep the inherited value.
168
+ // The shim must never export a path to a file that does not exist.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function isOwnBundlePath(candidate, stateDir) {
172
+ if (!candidate) return false;
173
+ try {
174
+ const resolved = path.resolve(candidate);
175
+ if (path.dirname(resolved) !== path.resolve(stateDir)) return false;
176
+ return /^claude-ca-bundle-[0-9a-f]{16}\.pem$/.test(path.basename(resolved));
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ // One stable rendering, so "did the source change" is a byte comparison of equal shapes.
183
+ function normalizePem(pem) {
184
+ const text = String(pem).replace(/\r\n/g, '\n').trim();
185
+ return text ? `${text}\n` : '';
186
+ }
187
+
188
+ // tmp + rename with a fixed mode: the bundle is never visible half-written, and a stale
189
+ // tmp from a killed run cannot be mistaken for either file.
190
+ function writeModeAtomic(file, content, mode) {
191
+ const tmp = `${file}.${process.pid}.tmp`;
192
+ try {
193
+ fs.writeFileSync(tmp, content, { mode });
194
+ fs.renameSync(tmp, file);
195
+ } catch (err) {
196
+ fs.rmSync(tmp, { force: true });
197
+ throw err;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Build (or refresh) the bundle of the user's CA and the switcher CA.
203
+ *
204
+ * @param {string} inheritedCa the NODE_EXTRA_CA_CERTS the shell already carried
205
+ * @param {string} switcherCaPath the switcher's own ca.pem
206
+ * @param {string} [stateDir]
207
+ * @returns {{ok: boolean, path?: string, rebuilt?: boolean, error?: string}}
208
+ */
209
+ export function ensureClaudeCaBundle(inheritedCa, switcherCaPath, stateDir = STATE_DIR) {
210
+ const fail = (message) => {
211
+ console.error(`[llm-switcher] ${message}`);
212
+ return { ok: false, error: message };
213
+ };
214
+
215
+ const inherited = String(inheritedCa || '').trim();
216
+ if (!inherited) return fail('NODE_EXTRA_CA_CERTS is empty; there is nothing to bundle.');
217
+
218
+ let userCaPath;
219
+ if (isOwnBundlePath(inherited, stateDir)) {
220
+ // The shell inherited a bundle. Its sidecar names the user's original PEM; the bundle
221
+ // itself must never be a source here, or it would append itself into itself.
222
+ const sidecar = `${inherited}.src`;
223
+ let recorded;
224
+ try {
225
+ recorded = fs.readFileSync(sidecar, 'utf8').trim();
226
+ } catch {
227
+ return fail(`missing or unreadable sidecar ${sidecar}; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
228
+ }
229
+ if (!recorded) return fail(`empty sidecar ${sidecar}; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
230
+ if (!fs.existsSync(recorded)) {
231
+ return fail(`sidecar ${sidecar} names ${recorded}, which does not exist; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
232
+ }
233
+ userCaPath = recorded;
234
+ } else {
235
+ // An external user PEM: no sidecar is expected yet, and this first build writes one.
236
+ if (!fs.existsSync(inherited)) {
237
+ return fail(`${inherited} does not exist; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
238
+ }
239
+ userCaPath = inherited;
240
+ }
241
+
242
+ let userPem;
243
+ try {
244
+ userPem = fs.readFileSync(userCaPath);
245
+ } catch (err) {
246
+ return fail(`cannot read ${userCaPath}: ${err.message}; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
247
+ }
248
+
249
+ let switcherPem;
250
+ try {
251
+ switcherPem = fs.readFileSync(switcherCaPath);
252
+ } catch (err) {
253
+ return fail(`cannot read the switcher CA ${switcherCaPath}: ${err.message}; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
254
+ }
255
+
256
+ const bundlePath = paths.claudeCaBundle(userCaPath);
257
+ const sidecarPath = paths.claudeCaBundleSidecar(userCaPath);
258
+ const desired = Buffer.from(normalizePem(userPem) + normalizePem(switcherPem), 'utf8');
259
+
260
+ let current = null;
261
+ try {
262
+ current = fs.readFileSync(bundlePath);
263
+ } catch { /* no bundle yet */ }
264
+
265
+ let sidecarOk = false;
266
+ try {
267
+ sidecarOk = fs.readFileSync(sidecarPath, 'utf8').trim() === path.resolve(userCaPath);
268
+ } catch { /* no sidecar yet */ }
269
+
270
+ // Content only: an untouched bundle is left alone, so a running tool never sees it churn.
271
+ if (current && current.equals(desired) && sidecarOk) return { ok: true, path: bundlePath, rebuilt: false };
272
+
273
+ try {
274
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
275
+ } catch (err) {
276
+ return fail(`cannot create ${stateDir}: ${err.message}; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
277
+ }
278
+
279
+ // The sidecar goes first: the bundle may only exist once its origin is already recorded.
280
+ try {
281
+ writeModeAtomic(sidecarPath, `${path.resolve(userCaPath)}\n`, 0o600);
282
+ } catch (err) {
283
+ return fail(`cannot write ${sidecarPath}: ${err.message}; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
284
+ }
285
+
286
+ try {
287
+ writeModeAtomic(bundlePath, desired, 0o600);
288
+ } catch (err) {
289
+ return fail(`cannot write ${bundlePath}: ${err.message}; keeping NODE_EXTRA_CA_CERTS at ${inherited}.`);
290
+ }
291
+
292
+ return { ok: true, path: bundlePath, rebuilt: true };
293
+ }
294
+
131
295
  // ---------------- config IO ----------------
132
296
 
133
297
  let cachedConfig = null;
134
298
  let lastSignature = '';
135
299
  let lastLoadError = null;
300
+ let migrationError = null;
301
+ let migrationCollision = null;
302
+
303
+ export function getMigrationCollision() {
304
+ return migrationCollision;
305
+ }
306
+
307
+ export function getMigrationError() {
308
+ return migrationError;
309
+ }
310
+
311
+ export function getLastLoadError() {
312
+ return lastLoadError;
313
+ }
314
+
315
+ export function getConfigLoadError() {
316
+ return migrationError || lastLoadError;
317
+ }
136
318
 
137
319
  // mtime alone misses a rewrite inside the same timestamp tick. Every save renames a new file into
138
320
  // place, so the inode changes even then.
139
321
  const fileSignature = (st) => `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}`;
140
322
 
323
+ function getClaudeSlots(p) {
324
+ const slots = {};
325
+ for (const s of CLAUDE_MODEL_SLOTS) {
326
+ if (p.defaultModels && Object.hasOwn(p.defaultModels, s)) slots[s] = p.defaultModels[s];
327
+ }
328
+ return slots;
329
+ }
330
+
331
+ function getClaude1M(p) {
332
+ const flags = {};
333
+ for (const s of CLAUDE_MODEL_SLOTS) {
334
+ if (p.model1M && Object.hasOwn(p.model1M, s)) flags[s] = p.model1M[s];
335
+ }
336
+ return flags;
337
+ }
338
+
339
+ function getCodexSlots(p) {
340
+ const slots = {};
341
+ for (const s of CODEX_MODEL_SLOTS) {
342
+ if (p.defaultModels && Object.hasOwn(p.defaultModels, s)) slots[s] = p.defaultModels[s];
343
+ }
344
+ return slots;
345
+ }
346
+
347
+ function getCodex1M(p) {
348
+ const flags = {};
349
+ for (const s of CODEX_MODEL_SLOTS) {
350
+ if (p.model1M && Object.hasOwn(p.model1M, s)) flags[s] = p.model1M[s];
351
+ }
352
+ return flags;
353
+ }
354
+
355
+ function createClaudeHalf(p) {
356
+ const half = {};
357
+ for (const [k, v] of Object.entries(p)) {
358
+ if (['inFormat', 'blindfold', 'blindfoldPort', 'blindfoldHost', 'blindfoldPrefix', 'defaultModels', 'model1M', 'publicModels', 'codexRoles'].includes(k)) {
359
+ continue;
360
+ }
361
+ half[k] = v;
362
+ }
363
+ half.tool = 'claude';
364
+
365
+ const dm = {};
366
+ if (p.defaultModels && typeof p.defaultModels === 'object') {
367
+ for (const slot of CLAUDE_MODEL_SLOTS) {
368
+ if (Object.hasOwn(p.defaultModels, slot)) dm[slot] = p.defaultModels[slot];
369
+ }
370
+ if (Object.hasOwn(p.defaultModels, 'default')) dm.default = p.defaultModels.default;
371
+ }
372
+ if (p.defaultModels && typeof p.defaultModels === 'object') half.defaultModels = dm;
373
+
374
+ const m1m = {};
375
+ if (p.model1M && typeof p.model1M === 'object') {
376
+ for (const slot of CLAUDE_MODEL_SLOTS) {
377
+ if (Object.hasOwn(p.model1M, slot)) m1m[slot] = p.model1M[slot];
378
+ }
379
+ }
380
+ if (Object.keys(m1m).length > 0) half.model1M = m1m;
381
+
382
+ return half;
383
+ }
384
+
385
+ function createCodexHalf(p) {
386
+ const half = {};
387
+ for (const [k, v] of Object.entries(p)) {
388
+ if (['inFormat', 'blindfold', 'blindfoldPort', 'blindfoldHost', 'blindfoldPrefix', 'defaultModels', 'model1M', 'publicModels', 'codexRoles'].includes(k)) {
389
+ continue;
390
+ }
391
+ half[k] = v;
392
+ }
393
+ half.tool = 'codex';
394
+ if (Array.isArray(p.publicModels)) half.publicModels = [...p.publicModels];
395
+ if (p.codexRoles && typeof p.codexRoles === 'object') half.codexRoles = { ...p.codexRoles };
396
+
397
+ const dm = {};
398
+ const srcDm = p.defaultModels && typeof p.defaultModels === 'object' ? p.defaultModels : {};
399
+ for (const slot of CODEX_MODEL_SLOTS) {
400
+ if (Object.hasOwn(srcDm, slot)) dm[slot] = srcDm[slot];
401
+ }
402
+ if (!Object.hasOwn(dm, 'main') && Object.hasOwn(srcDm, 'opus')) {
403
+ dm.main = srcDm.opus;
404
+ }
405
+ if (!Object.hasOwn(dm, 'review') && Object.hasOwn(srcDm, 'sonnet')) {
406
+ dm.review = srcDm.sonnet;
407
+ }
408
+ if (!Object.hasOwn(dm, 'subagent')) {
409
+ for (const k of ['fast', 'fallback', 'haiku', 'fable']) {
410
+ if (Object.hasOwn(srcDm, k)) {
411
+ dm.subagent = srcDm[k];
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ if (Object.hasOwn(srcDm, 'default')) dm.default = srcDm.default;
417
+ if (Object.keys(dm).length > 0) half.defaultModels = dm;
418
+
419
+ const m1m = {};
420
+ const srcM1m = p.model1M && typeof p.model1M === 'object' ? p.model1M : {};
421
+ for (const slot of CODEX_MODEL_SLOTS) {
422
+ if (Object.hasOwn(srcM1m, slot)) m1m[slot] = srcM1m[slot];
423
+ }
424
+ if (!Object.hasOwn(m1m, 'main') && Object.hasOwn(srcM1m, 'opus')) {
425
+ m1m.main = srcM1m.opus;
426
+ }
427
+ if (!Object.hasOwn(m1m, 'review') && Object.hasOwn(srcM1m, 'sonnet')) {
428
+ m1m.review = srcM1m.sonnet;
429
+ }
430
+ if (!Object.hasOwn(m1m, 'subagent')) {
431
+ for (const k of ['fast', 'fallback', 'haiku', 'fable']) {
432
+ if (Object.hasOwn(srcM1m, k)) {
433
+ m1m.subagent = srcM1m[k];
434
+ break;
435
+ }
436
+ }
437
+ }
438
+ if (Object.keys(m1m).length > 0) half.model1M = m1m;
439
+
440
+ return half;
441
+ }
442
+
443
+ export function validateProfileInput(p) {
444
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return 'Profile must be an object';
445
+ if (p.inFormat !== undefined) return 'inFormat is not supported; use "tool": "claude" or "tool": "codex"';
446
+ if (p.blindfold !== undefined) return 'blindfold per-profile setting is deprecated';
447
+ if (p.blindfoldPort !== undefined) return 'blindfoldPort per-profile setting is deprecated';
448
+ if (p.blindfoldHost !== undefined) return 'blindfoldHost per-profile setting is deprecated';
449
+ if (p.blindfoldPrefix !== undefined) return 'blindfoldPrefix per-profile setting is deprecated';
450
+ if (p.tool !== undefined && p.tool !== null && !TOOLS.includes(p.tool)) {
451
+ return `Invalid tool "${p.tool}". Valid: ${TOOLS.join(', ')}`;
452
+ }
453
+ return null;
454
+ }
455
+
456
+ export function needsMigration(cfg) {
457
+ if (!cfg || typeof cfg !== 'object') return false;
458
+ if (Object.hasOwn(cfg, 'activeProfile')) return true;
459
+ if (cfg.activeProfiles && typeof cfg.activeProfiles === 'object') {
460
+ for (const k of LEGACY_TARGETS) {
461
+ if (Object.hasOwn(cfg.activeProfiles, k)) return true;
462
+ }
463
+ }
464
+ if (cfg.profiles && typeof cfg.profiles === 'object') {
465
+ for (const [key, p] of Object.entries(cfg.profiles)) {
466
+ if (COMMAND_WORDS.has(key.toLowerCase())) return true;
467
+ if (!p || typeof p !== 'object') continue;
468
+ if (Object.hasOwn(p, 'inFormat')) return true;
469
+ if (Object.hasOwn(p, 'blindfold')) return true;
470
+ if (Object.hasOwn(p, 'blindfoldPort')) return true;
471
+ if (Object.hasOwn(p, 'blindfoldHost')) return true;
472
+ if (Object.hasOwn(p, 'blindfoldPrefix')) return true;
473
+ if (p.tool === undefined) {
474
+ if (p.tool === null && p.disabled === true) {
475
+ // already migrated retired profile
476
+ } else {
477
+ return true;
478
+ }
479
+ }
480
+ }
481
+ }
482
+ if (Object.hasOwn(cfg, 'blindfold') && (typeof cfg.blindfold !== 'object' || cfg.blindfold === null || Array.isArray(cfg.blindfold))) {
483
+ return true;
484
+ }
485
+ return false;
486
+ }
487
+
488
+ export function migrateConfigInMemory(rawConfig) {
489
+ if (!rawConfig || typeof rawConfig !== 'object') {
490
+ return { config: rawConfig, collision: null, migrated: false, warnings: [] };
491
+ }
492
+
493
+ const warnings = [];
494
+ const cfg = JSON.parse(JSON.stringify(rawConfig));
495
+ const rawProfiles = rawConfig.profiles && typeof rawConfig.profiles === 'object' ? rawConfig.profiles : {};
496
+ cfg.profiles = cfg.profiles && typeof cfg.profiles === 'object' ? cfg.profiles : {};
497
+
498
+ // Step 1: Command-word renames (R7f, R6)
499
+ const renameMap = new Map();
500
+ for (const key of Object.keys(rawProfiles)) {
501
+ if (COMMAND_WORDS.has(key.toLowerCase())) {
502
+ const newKey = `${key}-profile`;
503
+ renameMap.set(key, newKey);
504
+ cfg.profiles[newKey] = cfg.profiles[key];
505
+ delete cfg.profiles[key];
506
+ warnings.push(`Profile "${key}" renamed to "${newKey}" because "${key}" is a command word.`);
507
+ }
508
+ }
509
+
510
+ const rawActiveMap = getActiveMap(rawConfig);
511
+ const splitMap = new Map();
512
+ const clashingKeys = [];
513
+ const initialKeysLower = new Set(Object.keys(rawProfiles).map(k => k.toLowerCase()));
514
+
515
+ // Step 2: Profile classification & splitting (R7)
516
+ const profilesToProcess = Object.entries(cfg.profiles);
517
+
518
+ for (const [key, p] of profilesToProcess) {
519
+ if (!p || typeof p !== 'object') continue;
520
+
521
+ let originalKey = key;
522
+ for (const [orig, ren] of renameMap.entries()) {
523
+ if (ren === key) { originalKey = orig; break; }
524
+ }
525
+
526
+ const inFmt = p.inFormat !== undefined && p.inFormat !== null ? String(p.inFormat).trim().toLowerCase() : null;
527
+
528
+ // R7(a): a profile that already declares `tool` is already migrated. It only loses a
529
+ // leftover `inFormat`/`blindfold*` (cleanup pass below) and is never classified or split again.
530
+ if (Object.hasOwn(p, 'tool')) {
531
+ continue;
532
+ }
533
+
534
+ if (inFmt === 'anthropic') {
535
+ p.tool = 'claude';
536
+ continue;
537
+ }
538
+ if (inFmt === 'responses') {
539
+ p.tool = 'codex';
540
+ continue;
541
+ }
542
+ if (inFmt && inFmt !== 'auto' && inFmt !== '') {
543
+ p.tool = null;
544
+ p.disabled = true;
545
+ warnings.push(`Unknown or retired format "${p.inFormat}" for profile "${key}"; profile disabled.`);
546
+ continue;
547
+ }
548
+
549
+ const claudeSlots = getClaudeSlots(p);
550
+ const claude1M = getClaude1M(p);
551
+ const codexSlots = getCodexSlots(p);
552
+ const codex1M = getCodex1M(p);
553
+ const hasClaude = Object.keys(claudeSlots).length > 0 || Object.keys(claude1M).length > 0;
554
+ const hasCodex = Object.keys(codexSlots).length > 0 || Object.keys(codex1M).length > 0
555
+ || (Array.isArray(p.publicModels) && p.publicModels.length > 0)
556
+ || (p.codexRoles && Object.keys(p.codexRoles).length > 0);
557
+
558
+ const activeForResponses = (rawActiveMap.codex === originalKey);
559
+ const activeForAnthropic = (rawActiveMap.claude === originalKey);
560
+
561
+ let shouldSplit = false;
562
+ let singleTool = null;
563
+
564
+ if (hasClaude && hasCodex) {
565
+ shouldSplit = true;
566
+ } else if (hasClaude && !hasCodex) {
567
+ if (activeForResponses) {
568
+ shouldSplit = true;
569
+ } else {
570
+ singleTool = 'claude';
571
+ }
572
+ } else if (hasCodex && !hasClaude) {
573
+ if (activeForAnthropic) {
574
+ shouldSplit = true;
575
+ } else {
576
+ singleTool = 'codex';
577
+ }
578
+ } else {
579
+ if (activeForResponses) {
580
+ shouldSplit = true;
581
+ } else {
582
+ singleTool = 'claude';
583
+ }
584
+ }
585
+
586
+ if (singleTool) {
587
+ p.tool = singleTool;
588
+ } else if (shouldSplit) {
589
+ const claudeKey = `${key}-claude`;
590
+ const codexKey = `${key}-codex`;
591
+
592
+ for (const candidate of [claudeKey, codexKey]) {
593
+ const candidateLower = candidate.toLowerCase();
594
+ if (initialKeysLower.has(candidateLower) && candidateLower !== originalKey.toLowerCase()) {
595
+ clashingKeys.push(candidate);
596
+ }
597
+ }
598
+
599
+ splitMap.set(key, { claude: claudeKey, codex: codexKey });
600
+ if (originalKey !== key) {
601
+ splitMap.set(originalKey, { claude: claudeKey, codex: codexKey });
602
+ }
603
+
604
+ const claudeProfile = createClaudeHalf(p);
605
+ const codexProfile = createCodexHalf(p);
606
+
607
+ delete cfg.profiles[key];
608
+ cfg.profiles[claudeKey] = claudeProfile;
609
+ cfg.profiles[codexKey] = codexProfile;
610
+ }
611
+ }
612
+
613
+ for (const [orig, ren] of renameMap.entries()) {
614
+ const renLower = ren.toLowerCase();
615
+ if (initialKeysLower.has(renLower) && renLower !== orig.toLowerCase()) {
616
+ clashingKeys.push(ren);
617
+ }
618
+ }
619
+
620
+ if (clashingKeys.length > 0) {
621
+ return {
622
+ config: rawConfig,
623
+ collision: { clashingKeys: [...new Set(clashingKeys)], originalConfig: rawConfig },
624
+ migrated: false,
625
+ warnings
626
+ };
627
+ }
628
+
629
+ // Step 3: Pointer migration (R7b, Item 2)
630
+ const newActiveProfiles = {};
631
+ if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'claude')) {
632
+ newActiveProfiles.claude = rawConfig.activeProfiles.claude;
633
+ } else {
634
+ let oldClaudeTarget = null;
635
+ if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'anthropic')) {
636
+ oldClaudeTarget = rawConfig.activeProfiles.anthropic;
637
+ } else if (rawConfig.activeProfile) {
638
+ oldClaudeTarget = rawConfig.activeProfile;
639
+ }
640
+ if (oldClaudeTarget) {
641
+ let resolvedKey = renameMap.get(oldClaudeTarget) || oldClaudeTarget;
642
+ if (splitMap.has(resolvedKey)) {
643
+ resolvedKey = splitMap.get(resolvedKey).claude;
644
+ }
645
+ const prof = cfg.profiles[resolvedKey];
646
+ if (prof && prof.tool === 'claude') {
647
+ newActiveProfiles.claude = resolvedKey;
648
+ } else {
649
+ newActiveProfiles.claude = null;
650
+ }
651
+ } else {
652
+ newActiveProfiles.claude = null;
653
+ }
654
+ }
655
+
656
+ if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'codex')) {
657
+ newActiveProfiles.codex = rawConfig.activeProfiles.codex;
658
+ } else {
659
+ let oldCodexTarget = null;
660
+ if (rawConfig.activeProfiles && Object.hasOwn(rawConfig.activeProfiles, 'responses')) {
661
+ oldCodexTarget = rawConfig.activeProfiles.responses;
662
+ } else if (rawConfig.activeProfile) {
663
+ oldCodexTarget = rawConfig.activeProfile;
664
+ }
665
+ if (oldCodexTarget) {
666
+ let resolvedKey = renameMap.get(oldCodexTarget) || oldCodexTarget;
667
+ if (splitMap.has(resolvedKey)) {
668
+ resolvedKey = splitMap.get(resolvedKey).codex;
669
+ }
670
+ const prof = cfg.profiles[resolvedKey];
671
+ if (prof && prof.tool === 'codex') {
672
+ newActiveProfiles.codex = resolvedKey;
673
+ } else {
674
+ newActiveProfiles.codex = null;
675
+ }
676
+ } else {
677
+ newActiveProfiles.codex = null;
678
+ }
679
+ }
680
+
681
+ cfg.activeProfiles = newActiveProfiles;
682
+ delete cfg.activeProfile;
683
+
684
+ // Step 4: R3b top-level blindfold.port resolution (R3b)
685
+ let topBlindfold = null;
686
+ if (cfg.blindfold && typeof cfg.blindfold === 'object' && !Array.isArray(cfg.blindfold)) {
687
+ topBlindfold = cfg.blindfold;
688
+ }
689
+
690
+ const gatewayPort = parsePort(cfg.port) || DEFAULT_PORT;
691
+ let defaultBfPort = DEFAULT_BLINDFOLD_PORT;
692
+ if (gatewayPort === defaultBfPort) {
693
+ defaultBfPort = gatewayPort + 1;
694
+ }
695
+
696
+ if (topBlindfold && parsePort(topBlindfold.port)) {
697
+ let bfP = parsePort(topBlindfold.port);
698
+ if (bfP === gatewayPort) {
699
+ bfP = gatewayPort === DEFAULT_BLINDFOLD_PORT ? DEFAULT_BLINDFOLD_PORT + 1 : DEFAULT_BLINDFOLD_PORT;
700
+ warnings.push(`blindfold.port ${topBlindfold.port} conflicts with gateway port ${gatewayPort}; changed to ${bfP}`);
701
+ }
702
+ cfg.blindfold = { ...topBlindfold, port: bfP };
703
+ } else {
704
+ let candidatePort = null;
705
+ const activeCodexKey = cfg.activeProfiles?.codex;
706
+ let rawActiveCodexProf = null;
707
+ if (activeCodexKey) {
708
+ for (const [origKey, p] of Object.entries(rawProfiles)) {
709
+ const ren = renameMap.get(origKey) || origKey;
710
+ const split = splitMap.get(ren);
711
+ if (ren === activeCodexKey || (split && split.codex === activeCodexKey)) {
712
+ rawActiveCodexProf = p;
713
+ break;
714
+ }
715
+ }
716
+ }
717
+
718
+ if (rawActiveCodexProf && rawActiveCodexProf.blindfold && parsePort(rawActiveCodexProf.blindfoldPort)) {
719
+ candidatePort = parsePort(rawActiveCodexProf.blindfoldPort);
720
+ } else {
721
+ const ports = [];
722
+ for (const [k, p] of Object.entries(rawProfiles)) {
723
+ if (!p || p.disabled) continue;
724
+ const ren = renameMap.get(k) || k;
725
+ const split = splitMap.get(ren);
726
+ const codexResultKey = split ? split.codex : ren;
727
+ const resProf = cfg.profiles[codexResultKey];
728
+ if (resProf && resProf.tool === 'codex') {
729
+ if (p.blindfold && parsePort(p.blindfoldPort)) {
730
+ const bp = parsePort(p.blindfoldPort);
731
+ if (bp !== DEFAULT_BLINDFOLD_PORT) ports.push(bp);
732
+ }
733
+ }
734
+ }
735
+ if (ports.length > 0) {
736
+ candidatePort = Math.min(...ports);
737
+ }
738
+ }
739
+
740
+ let finalBfPort = candidatePort || defaultBfPort;
741
+ if (finalBfPort === gatewayPort) {
742
+ finalBfPort = gatewayPort === DEFAULT_BLINDFOLD_PORT ? DEFAULT_BLINDFOLD_PORT + 1 : DEFAULT_BLINDFOLD_PORT;
743
+ }
744
+ cfg.blindfold = { port: finalBfPort };
745
+ }
746
+
747
+ // Clean up all result profiles: delete inFormat and blindfold* fields
748
+ for (const [k, p] of Object.entries(cfg.profiles)) {
749
+ if (p && typeof p === 'object') {
750
+ if (p.blindfoldHost && p.blindfoldHost !== LEGACY_DEFAULT_BLINDFOLD_HOST) {
751
+ warnings.push(`Profile "${k}" has custom blindfoldHost "${p.blindfoldHost}" which is no longer supported and was dropped.`);
752
+ }
753
+ if (p.blindfoldPrefix && p.blindfoldPrefix !== LEGACY_DEFAULT_BLINDFOLD_PREFIX) {
754
+ warnings.push(`Profile "${k}" has custom blindfoldPrefix "${p.blindfoldPrefix}" which is no longer supported and was dropped.`);
755
+ }
756
+ delete p.inFormat;
757
+ delete p.blindfold;
758
+ delete p.blindfoldPort;
759
+ delete p.blindfoldHost;
760
+ delete p.blindfoldPrefix;
761
+ }
762
+ }
763
+
764
+ return { config: cfg, collision: null, migrated: true, warnings };
765
+ }
766
+
767
+ export function saveConfigAtomicCAS(migratedBytes, originalBytes, targetPath = configPath) {
768
+ let attempts = 0;
769
+ let currentOriginal = originalBytes;
770
+ let currentMigrated = migratedBytes;
771
+ const createdBackups = [];
772
+ const createdTmps = [];
773
+
774
+ while (attempts < 3) {
775
+ attempts++;
776
+ const rand = crypto.randomBytes(4).toString('hex');
777
+ const tmp = `${targetPath}.${process.pid}.${rand}.tmp`;
778
+ const bak = path.join(path.dirname(targetPath), `config.json.bak-${Date.now()}-${process.pid}-${rand}`);
779
+ let renameFinished = false;
780
+
781
+ try {
782
+ createdTmps.push(tmp);
783
+ fs.writeFileSync(tmp, currentMigrated, { mode: 0o600, flag: 'wx' });
784
+ fs.chmodSync(tmp, 0o600);
785
+
786
+ createdBackups.push(bak);
787
+ fs.writeFileSync(bak, currentOriginal, { mode: 0o600, flag: 'wx' });
788
+ fs.chmodSync(bak, 0o600);
789
+
790
+ const diskBytes = fs.readFileSync(targetPath);
791
+ if (diskBytes.equals(currentOriginal)) {
792
+ fs.renameSync(tmp, targetPath);
793
+ renameFinished = true;
794
+ try {
795
+ fs.chmodSync(targetPath, 0o600);
796
+ } catch {}
797
+ return { ok: true, config: JSON.parse(currentMigrated.toString('utf8')) };
798
+ }
799
+
800
+ // Mismatch
801
+ try { fs.rmSync(tmp, { force: true }); } catch {}
802
+ try { fs.rmSync(bak, { force: true }); } catch {}
803
+ createdTmps.pop();
804
+ createdBackups.pop();
805
+
806
+ let diskParsed;
807
+ try {
808
+ diskParsed = JSON.parse(diskBytes.toString('utf8'));
809
+ } catch {
810
+ throw new Error('config.json contains invalid JSON on disk during CAS retry');
811
+ }
812
+
813
+ if (!needsMigration(diskParsed)) {
814
+ return { ok: true, config: diskParsed, noMigrationNeeded: true };
815
+ }
816
+
817
+ const reResult = migrateConfigInMemory(diskParsed);
818
+ if (reResult.collision) {
819
+ return { collision: reResult.collision };
820
+ }
821
+
822
+ currentOriginal = diskBytes;
823
+ currentMigrated = Buffer.from(JSON.stringify(reResult.config, null, 2), 'utf8');
824
+ } catch (err) {
825
+ if (renameFinished) {
826
+ return { ok: true, config: JSON.parse(currentMigrated.toString('utf8')) };
827
+ }
828
+ try { fs.rmSync(tmp, { force: true }); } catch {}
829
+ try { fs.rmSync(bak, { force: true }); } catch {}
830
+ throw err;
831
+ }
832
+ }
833
+
834
+ for (const t of createdTmps) {
835
+ try { fs.rmSync(t, { force: true }); } catch {}
836
+ }
837
+ for (const b of createdBackups) {
838
+ try { fs.rmSync(b, { force: true }); } catch {}
839
+ }
840
+ const casErr = new Error('CAS failed after 3 attempts');
841
+ return { error: casErr };
842
+ }
843
+
141
844
  // Cached config read. If the file is half-written (invalid JSON), keep the old cached copy.
142
845
  export function loadConfig() {
143
846
  try {
144
847
  const stat = fs.statSync(configPath);
145
- if (!cachedConfig || fileSignature(stat) !== lastSignature) {
146
- const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
848
+ const currentSig = fileSignature(stat);
849
+ if (!cachedConfig || currentSig !== lastSignature) {
850
+ lastLoadError = null;
851
+ migrationError = null;
852
+ migrationCollision = null;
853
+
854
+ const rawBytes = fs.readFileSync(configPath);
855
+ const parsed = JSON.parse(rawBytes.toString('utf8'));
147
856
  if (!parsed || typeof parsed !== 'object') throw new Error('config root must be an object');
148
857
  if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {};
149
- cachedConfig = parsed;
150
- lastSignature = fileSignature(stat);
858
+
859
+ if (needsMigration(parsed)) {
860
+ const migResult = migrateConfigInMemory(parsed);
861
+ if (migResult.collision) {
862
+ migrationCollision = migResult.collision;
863
+ cachedConfig = parsed;
864
+ lastSignature = currentSig;
865
+ return cachedConfig;
866
+ }
867
+
868
+ const migratedBytes = Buffer.from(JSON.stringify(migResult.config, null, 2), 'utf8');
869
+ try {
870
+ const casRes = saveConfigAtomicCAS(migratedBytes, rawBytes, configPath);
871
+ if (casRes.ok) {
872
+ cachedConfig = casRes.config || migResult.config;
873
+ try {
874
+ const newStat = fs.statSync(configPath);
875
+ lastSignature = fileSignature(newStat);
876
+ } catch {
877
+ lastSignature = '';
878
+ }
879
+ } else if (casRes.collision) {
880
+ migrationCollision = casRes.collision;
881
+ cachedConfig = parsed;
882
+ lastSignature = currentSig;
883
+ } else if (casRes.error) {
884
+ migrationError = casRes.error;
885
+ cachedConfig = parsed;
886
+ lastSignature = currentSig;
887
+ }
888
+ } catch (casErr) {
889
+ migrationError = casErr;
890
+ cachedConfig = parsed;
891
+ lastSignature = currentSig;
892
+ }
893
+ } else {
894
+ cachedConfig = parsed;
895
+ lastSignature = currentSig;
896
+ }
151
897
  }
152
- lastLoadError = null;
153
898
  } catch (err) {
154
899
  lastLoadError = err;
155
900
  }
156
901
  return cachedConfig;
157
902
  }
158
903
 
159
- export function getConfigLoadError() {
160
- return lastLoadError;
161
- }
162
-
163
904
  // Atomic write (tmp + rename) so a running proxy never reads a half-written file.
164
905
  // The tmp file is created 0600 and renamed over config.json, so the keys are never readable by
165
906
  // another account, even when an earlier release left config.json at 0644.
166
907
  export function saveConfig(cfg) {
167
- const tmp = `${configPath}.${process.pid}.tmp`;
908
+ if (migrationCollision) {
909
+ throw new Error(`Refusing to save: configuration migration collision: ${JSON.stringify(migrationCollision.clashingKeys)}`);
910
+ }
911
+ if (migrationError) {
912
+ throw new Error(`Refusing to save: configuration migration failed: ${migrationError.message}`);
913
+ }
914
+ const tmp = `${configPath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
168
915
  fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), { encoding: 'utf8', mode: 0o600 });
169
916
  fs.chmodSync(tmp, 0o600);
170
917
  const ino = fs.statSync(tmp).ino;
171
918
  fs.renameSync(tmp, configPath);
172
919
  cachedConfig = cfg;
173
- // Another process can rename its own file in between; its signature must not be taken as ours.
174
920
  try {
175
921
  const st = fs.statSync(configPath);
176
922
  lastSignature = st.ino === ino ? fileSignature(st) : '';
@@ -179,6 +925,21 @@ export function saveConfig(cfg) {
179
925
  }
180
926
  }
181
927
 
928
+ // R7b: `switch off` has to work while saveConfig refuses — a collision means this build will not
929
+ // rewrite config.json, but turning one tool off is still a write the user asked for. Re-read the file,
930
+ // change only the pointer(s) that name a tool, and hand both byte buffers to the CAS writer, so the
931
+ // top-level legacy pointers and the retired keys inside activeProfiles come back exactly as they were.
932
+ // Throws when config.json cannot be read or parsed; otherwise returns what saveConfigAtomicCAS returns.
933
+ export function casClearToolPointers(tools) {
934
+ const original = fs.readFileSync(configPath);
935
+ const next = JSON.parse(original.toString('utf8'));
936
+ const active = (next.activeProfiles && typeof next.activeProfiles === 'object')
937
+ ? next.activeProfiles
938
+ : (next.activeProfiles = {});
939
+ for (const tool of tools) active[tool] = null;
940
+ return saveConfigAtomicCAS(Buffer.from(JSON.stringify(next, null, 2), 'utf8'), original, configPath);
941
+ }
942
+
182
943
  // ---------------- contract lab ----------------
183
944
 
184
945
  export const CONTRACT_LAB_OFF = { url: '', apiKey: '', enabled: false };
@@ -204,11 +965,13 @@ export function hasProfile(cfg, key) {
204
965
  }
205
966
 
206
967
  export function isValidProfileKey(key) {
207
- return typeof key === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(key);
968
+ if (typeof key !== 'string') return false;
969
+ if (COMMAND_WORDS.has(key.toLowerCase())) return false;
970
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(key);
208
971
  }
209
972
 
210
973
  export function isValidTarget(target) {
211
- return TARGETS.includes(target);
974
+ return TOOLS.includes(target) || Object.hasOwn(LEGACY_TARGET_TOOL, target);
212
975
  }
213
976
 
214
977
  // Case-insensitive profile lookup (CLI accepts `switch MyProfile` or `switch myprofile`).
@@ -220,11 +983,21 @@ export function findProfileKey(cfg, name) {
220
983
  }
221
984
 
222
985
  export function profileAcceptsTarget(profile, target) {
986
+ if (profile?.tool) {
987
+ if (target === 'claude' || target === 'anthropic') return profile.tool === 'claude';
988
+ if (target === 'codex' || target === 'responses') return profile.tool === 'codex';
989
+ return false;
990
+ }
223
991
  const inFmt = profile?.inFormat || 'auto';
224
- return inFmt === 'auto' || inFmt === target;
992
+ if (inFmt === 'auto') return true;
993
+ if (inFmt === 'anthropic') return target === 'anthropic' || target === 'claude';
994
+ if (inFmt === 'responses') return target === 'responses' || target === 'codex';
995
+ return inFmt === target;
225
996
  }
226
997
 
227
998
  export function modelSlotsForProfile(profile) {
999
+ if (profile?.tool === 'claude') return CLAUDE_MODEL_SLOTS;
1000
+ if (profile?.tool === 'codex') return CODEX_MODEL_SLOTS;
228
1001
  return MODEL_SLOTS_BY_FORMAT[profile?.inFormat] || MODEL_SLOTS_BY_FORMAT.auto;
229
1002
  }
230
1003
 
@@ -293,9 +1066,18 @@ export function codexPublicModelsWarning(key, profile) {
293
1066
  + `"publicModels": ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], then run \`switch codex ${key}\` again.`;
294
1067
  }
295
1068
 
1069
+ /**
1070
+ * The host table of R3: every host the interceptor answers on, and the only hosts this CA may
1071
+ * sign for (blindfold/make-certs.sh builds exactly these). One leaf serves all three, because the
1072
+ * interceptor presents the same certificate whichever host the tool dialled — a leaf from an
1073
+ * older version covers one host and fails the other two with a TLS error that reads like a
1074
+ * network fault.
1075
+ */
1076
+ export const INTERCEPT_HOSTS = Object.freeze(['api.anthropic.com', 'api.openai.com', 'chatgpt.com']);
1077
+
296
1078
  /**
297
1079
  * Does this leaf certificate cover the host the interceptor will present it for?
298
- * Changing blindfoldHost without rebuilding the leaf produces a TLS error that reads
1080
+ * Changing the host table without rebuilding the leaf produces a TLS error that reads
299
1081
  * like a network fault, so the launcher compares the two before it starts anything.
300
1082
  */
301
1083
  export function certCoversHost(pem, host) {
@@ -332,19 +1114,8 @@ function codexCatalogTemplate() {
332
1114
  return cachedCatalogTemplate;
333
1115
  }
334
1116
 
335
- /**
336
- * Build the catalog Codex loads through `--config model_catalog_json`.
337
- * Returns null when the profile publishes no official names: Codex then keeps its
338
- * own built-in catalog, which is leak-free too.
339
- */
340
- export function buildCodexCatalog(profile) {
341
- if (!codexCatalogTemplate()) return null;
342
- const windows = publicModelWindows(profile);
343
- if (!windows.size) return null;
344
- return { models: [...windows].map(([name, is1M]) => codexModelEntry(name, is1M)) };
345
- }
346
-
347
- // One catalog entry. The catalog file and the gateway's /v1/models both use it, so they cannot drift.
1117
+ // One catalog entry. /v1/models and the OpenAI-Model handshake both use it, so they cannot drift.
1118
+ // model-catalog.json itself is no longer written (R9: publicModels stays the gateway's mapping table).
348
1119
  export function codexModelEntry(name, is1M) {
349
1120
  // The template copies a real OpenAI model. Its Responses Lite and code modes send the tools in a
350
1121
  // form made for OpenAI's own tools, and an upstream then gets none (LS-5).
@@ -392,15 +1163,43 @@ export function resolvePort(argv = process.argv.slice(2), cfg = loadConfig()) {
392
1163
  return parsePort(cfg?.port) || DEFAULT_PORT;
393
1164
  }
394
1165
 
395
- // Legacy config only has `activeProfile` -> derive the per-CLI-target map from it.
1166
+ // The whole map, and nothing else: `{ claude, codex }`. A pointer that is present wins even
1167
+ // when its value is null — an explicit "off" must never fall back to the legacy single
1168
+ // pointer and re-enable a tool under a different API key. This is the same chain
1169
+ // deriveActiveTools walks, so the launcher state and the interceptor's tool set can never
1170
+ // disagree about which tools are on.
396
1171
  export function getActiveMap(cfg) {
397
- const out = {};
398
- const legacy = cfg?.activeProfile || null;
399
- for (const t of TARGETS) {
400
- if (cfg?.activeProfiles && Object.hasOwn(cfg.activeProfiles, t)) out[t] = cfg.activeProfiles[t] || null;
401
- else out[t] = legacy;
1172
+ const ap = cfg?.activeProfiles || {};
1173
+ const pointer = (tool, legacy) => (
1174
+ Object.hasOwn(ap, tool) ? ap[tool]
1175
+ : Object.hasOwn(ap, legacy) ? ap[legacy]
1176
+ : cfg?.activeProfile ?? null
1177
+ );
1178
+ return { claude: pointer('claude', 'anthropic'), codex: pointer('codex', 'responses') };
1179
+ }
1180
+
1181
+ /**
1182
+ * Which tools this config points at a profile, rendered as the interceptor's `--active-tools`
1183
+ * list (F2, Finding 6). The chain accepts both key spellings in the wild — the newer `claude` /
1184
+ * `codex` and the older `anthropic` / `responses` — and a key that is present wins even when its
1185
+ * value is null: an explicit "off" must never fall through to the legacy single pointer.
1186
+ * Codex counts only when the profile it lands on can serve Codex; and a pointer that names no
1187
+ * profile we can inspect is not evidence that Codex is off, so Codex stays on.
1188
+ */
1189
+ export function deriveActiveTools(raw) {
1190
+ const ap = raw?.activeProfiles || {};
1191
+ const pointer = (key, legacy) => (
1192
+ Object.hasOwn(ap, key) ? ap[key]
1193
+ : Object.hasOwn(ap, legacy) ? ap[legacy]
1194
+ : raw?.activeProfile ?? null
1195
+ );
1196
+ const tools = [];
1197
+ if (pointer('claude', 'anthropic')) tools.push('claude');
1198
+ const codexProfile = pointer('codex', 'responses');
1199
+ if (codexProfile && (!hasProfile(raw, codexProfile) || profileAcceptsTarget(raw.profiles[codexProfile], 'codex'))) {
1200
+ tools.push('codex');
402
1201
  }
403
- return out;
1202
+ return tools.sort();
404
1203
  }
405
1204
 
406
1205
  function ensureActiveMap(cfg) {
@@ -410,20 +1209,24 @@ function ensureActiveMap(cfg) {
410
1209
 
411
1210
  // ---------------- mutations (do not persist by themselves) ----------------
412
1211
 
413
- // Assign a profile to one target. Returns an error string or null.
1212
+ // Assign a profile to one tool. Returns an error string or null.
414
1213
  export function setTargetProfile(cfg, target, profileKey) {
415
- if (!isValidTarget(target)) return `Unknown target "${target}". Valid: ${TARGETS.join(', ')}`;
1214
+ // `anthropic` and `responses` are spellings of `claude` and `codex`, not targets of their
1215
+ // own; `openai-chat` and `vertex` are no longer a target a caller may name at all.
1216
+ const tool = TOOLS.includes(target) ? target
1217
+ : (Object.hasOwn(LEGACY_TARGET_TOOL, target) ? LEGACY_TARGET_TOOL[target] : null);
1218
+ if (!tool) return `Unknown target "${target}". Valid: ${TOOLS.join(', ')}`;
416
1219
  const map = ensureActiveMap(cfg);
417
1220
  if (!profileKey) {
418
- map[target] = null;
1221
+ map[tool] = null;
419
1222
  return null;
420
1223
  }
421
1224
  if (!hasProfile(cfg, profileKey)) return `Profile "${profileKey}" does not exist`;
422
1225
  const p = cfg.profiles[profileKey];
423
- if (!profileAcceptsTarget(p, target)) {
424
- return `Profile "${profileKey}" only accepts "${p.inFormat}" input and cannot serve target "${target}"`;
1226
+ if (!profileAcceptsTarget(p, tool)) {
1227
+ return `Profile "${profileKey}" only accepts "${p.tool || p.inFormat}" input and cannot serve target "${tool}"`;
425
1228
  }
426
- map[target] = profileKey;
1229
+ map[tool] = profileKey;
427
1230
  cfg.activeProfile = profileKey;
428
1231
  return null;
429
1232
  }
@@ -433,7 +1236,7 @@ export function activateProfile(cfg, profileKey) {
433
1236
  if (!hasProfile(cfg, profileKey)) return `Profile "${profileKey}" does not exist`;
434
1237
  const map = ensureActiveMap(cfg);
435
1238
  const p = cfg.profiles[profileKey];
436
- for (const t of TARGETS) {
1239
+ for (const t of TOOLS) {
437
1240
  if (profileAcceptsTarget(p, t)) map[t] = profileKey;
438
1241
  }
439
1242
  cfg.activeProfile = profileKey;
@@ -443,13 +1246,13 @@ export function activateProfile(cfg, profileKey) {
443
1246
  // Disable exactly the targets using this profile (leaves other targets untouched).
444
1247
  export function deactivateProfile(cfg, profileKey) {
445
1248
  const map = ensureActiveMap(cfg);
446
- for (const t of TARGETS) {
1249
+ for (const t of TOOLS) {
447
1250
  if (map[t] === profileKey) map[t] = null;
448
1251
  }
449
1252
  }
450
1253
 
451
1254
  export function deactivateAll(cfg) {
452
- cfg.activeProfiles = Object.fromEntries(TARGETS.map(t => [t, null]));
1255
+ cfg.activeProfiles = Object.fromEntries(TOOLS.map(t => [t, null]));
453
1256
  }
454
1257
 
455
1258
  export function deleteProfile(cfg, profileKey) {
@@ -500,84 +1303,48 @@ export function primaryModel(profile) {
500
1303
  export function computeLaunchState(cfg, port) {
501
1304
  const map = getActiveMap(cfg);
502
1305
  const pick = (t) => (hasProfile(cfg, map[t]) ? cfg.profiles[map[t]] : null);
503
- const claude = pick('anthropic');
504
- const codex = pick('responses');
505
- const openai = pick('openai-chat');
506
- const vertex = pick('vertex');
507
- const base = `http://127.0.0.1:${port}`;
1306
+ const claude = pick('claude');
1307
+ const codex = pick('codex');
1308
+ const active = Boolean(claude || codex);
1309
+
1310
+ // One interceptor serves both tools (R3) and its port lives at the top level of config.json
1311
+ // (R3b). The per-profile port went with the per-profile host and prefix.
1312
+ const bfPort = parsePort(cfg?.blindfold?.port) || DEFAULT_BLINDFOLD_PORT;
1313
+ const interceptor = `http://127.0.0.1:${bfPort}`;
1314
+ const loopbackOnly = '127.0.0.1,localhost';
1315
+ const proxyPairs = () => [
1316
+ ['HTTPS_PROXY', interceptor],
1317
+ ['https_proxy', interceptor],
1318
+ ['NO_PROXY', loopbackOnly],
1319
+ ['no_proxy', loopbackOnly]
1320
+ ];
508
1321
 
509
1322
  const state = {
510
- active: Boolean(claude || codex || openai || vertex),
511
- claude1M: claude ? claudeTier1M(claude) : null,
512
- claude1MTiers: claude ? CLAUDE_MODEL_SLOTS.filter(slot => model1MForSlot(claude, slot)) : [],
513
- codex1M: codex && model1MForSlot(codex, 'main') ? (primaryModel(codex) || '1000000') : null,
514
- openai1M: openai && anyTier1M(openai) ? (primaryModel(openai) || '1000000') : null,
515
- // host and prefix travel with the port: an account that signs in with an API key
516
- // reaches a different host under a different prefix, and the launcher cannot guess
517
- // either one. The defaults cover ChatGPT sign-in.
518
- blindfold: codex?.blindfold
1323
+ active,
1324
+ // R3b: host and prefix are gone. The interceptor answers on the fixed host table of R3, so
1325
+ // there is nothing left to choose; what the launcher still needs as data is the port, the CA
1326
+ // and the tool set. The tool set travels sorted as one string (Finding 6), which is also what
1327
+ // lets a running interceptor be updated in place instead of restarted.
1328
+ // null when no tool is active, which is how reconcileBlindfold knows to stop it.
1329
+ blindfold: active
519
1330
  ? {
520
- port: parsePort(codex.blindfoldPort) || DEFAULT_BLINDFOLD_PORT,
521
- host: String(codex.blindfoldHost || DEFAULT_BLINDFOLD_HOST),
522
- prefix: String(codex.blindfoldPrefix || DEFAULT_BLINDFOLD_PREFIX),
1331
+ port: bfPort,
1332
+ activeTools: deriveActiveTools(cfg).join(','),
523
1333
  ca: paths.blindfoldCA
524
1334
  }
525
1335
  : null,
526
- env: [],
527
- // Variables only the Codex shim may apply. They go to a separate file because the
528
- // `claude` shim sources the shared one, and a Codex-only proxy would capture every
529
- // claude HTTPS call — including after a restart, when the interceptor is not running.
1336
+ // Exactly the variables of R2, each in its own file (A1). Nothing here is a value a coding
1337
+ // tool reads as configuration: no base URL, no model name, no CLAUDE_CODE_* / OPENAI_*,
1338
+ // no --config (F1, F3, F4). The shim of the tool may therefore never capture the traffic
1339
+ // of the other tool, and a restart cannot leave a stale override behind.
1340
+ envClaude: [],
530
1341
  envCodex: []
531
1342
  };
532
1343
 
533
- if (claude) {
534
- state.env.push(['ANTHROPIC_BASE_URL', base]);
535
- state.env.push(['CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT', '1']);
536
- if (state.claude1M) {
537
- state.env.push(['ANTHROPIC_MODEL', state.claude1M]);
538
- state.env.push(['CLAUDE_CODE_AUTO_COMPACT_WINDOW', '900000']);
539
- }
540
- // Claude Code reads `[1m]` per variable: if only ANTHROPIC_MODEL carries the suffix, `/model sonnet`,
541
- // tier switches or subagent alias calls fall back to the real 200K Claude model. Set `<tier>[1m]` for
542
- // each tier the profile enables model1M on; Claude Code then sends plain `opus`/`sonnet`/... so the proxy
543
- // still maps by the active profile (hot profile switches need no CLI restart).
544
- for (const tier of CLAUDE_MODEL_SLOTS) {
545
- if (claude.model1M?.[tier]) state.env.push([`ANTHROPIC_DEFAULT_${tier.toUpperCase()}_MODEL`, `${tier}[1m]`]);
546
- }
547
- }
548
- if (codex) {
549
- // These are internal shim inputs, not Codex configuration variables.
550
- // The installed Codex shim converts them to documented `--config` keys.
551
- if (codex.blindfold) {
552
- // Blindfold mode: Codex keeps its official endpoint and reaches the gateway
553
- // through blindfold/blindfold.mjs, so no base URL override exists to report.
554
- // NO_PROXY keeps local MCP servers off the intercept path.
555
- const blindfoldURL = `http://127.0.0.1:${state.blindfold.port}`;
556
- state.envCodex.push(['HTTPS_PROXY', blindfoldURL]);
557
- state.envCodex.push(['https_proxy', blindfoldURL]);
558
- state.envCodex.push(['NO_PROXY', '127.0.0.1,localhost']);
559
- state.envCodex.push(['no_proxy', '127.0.0.1,localhost']);
560
- state.envCodex.push(['CODEX_CA_CERTIFICATE', paths.blindfoldCA]);
561
- } else {
562
- state.env.push(['LLM_SWITCHER_CODEX_BASE_URL', `${base}/v1`]);
563
- }
564
- // Official names only. An upstream ID here would reach the CLI as a --config
565
- // value and show up in its UI, which is the leak this indirection exists for.
566
- for (const slot of CODEX_MODEL_SLOTS) {
567
- const publicName = codexPublicModel(codex, slot);
568
- if (isSafeModelName(publicName)) {
569
- state.env.push([`LLM_SWITCHER_CODEX_${slot.toUpperCase()}_MODEL`, publicName]);
570
- }
571
- }
572
- if (state.codex1M) {
573
- state.env.push(['LLM_SWITCHER_CODEX_CONTEXT_WINDOW', '1000000']);
574
- state.env.push(['LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT', '900000']);
575
- }
576
- }
577
- if (openai) {
578
- state.env.push(['OPENAI_BASE_URL', `${base}/v1`]);
579
- if (state.openai1M) state.env.push(['OPENAI_MAX_CONTEXT_TOKENS', '1000000']);
580
- }
1344
+ // NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
1345
+ // because only then does it know whether the user brought a CA of their own (R2).
1346
+ if (claude) state.envClaude = proxyPairs();
1347
+ if (codex) state.envCodex = [...proxyPairs(), ['CODEX_CA_CERTIFICATE', paths.blindfoldCA]];
581
1348
  return state;
582
1349
  }
583
1350
 
@@ -640,75 +1407,94 @@ function withLaunchLock(fn) {
640
1407
  }
641
1408
  }
642
1409
 
643
- // Write env.cmd/env.sh and the flags from activeProfiles, and clean up Claude Code settings.json.
1410
+ // Neutral stubs. A shell rc that still sources env.sh / env.cmd gets nothing new, so an old rc
1411
+ // line can never re-introduce a base URL the shim just scrubbed (R8). They are overwritten and
1412
+ // never unlinked: a missing file would turn that rc line into an error instead of silence.
1413
+ const STUB_SH = '# Neutral stub - LLM Switcher\n';
1414
+ const STUB_CMD = 'REM Neutral stub - LLM Switcher\r\n';
1415
+
1416
+ function renderSh(pairs) {
1417
+ return ['#!/usr/bin/env sh', '# Auto-generated by LLM Switcher for the active tool',
1418
+ ...pairs.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, `'\\''`)}'`)].join('\n') + '\n';
1419
+ }
1420
+ function renderCmd(pairs) {
1421
+ return ['@echo off', 'REM Auto-generated by LLM Switcher for the active tool',
1422
+ ...pairs.map(([k, v]) => `SET "${k}=${v}"`)].join('\r\n') + '\r\n';
1423
+ }
1424
+
1425
+ // An empty file means "this tool is off": the shim then leaves the tool's environment alone and
1426
+ // the tool reaches its official endpoint. Empty, never deleted — a deleted file would make a
1427
+ // stale shell variable survive with nothing left to scrub it.
1428
+ function writeToolFiles(pairs, shPath, cmdPath) {
1429
+ if (pairs.length) {
1430
+ writeAtomic(cmdPath, renderCmd(pairs));
1431
+ writeAtomic(shPath, renderSh(pairs));
1432
+ } else {
1433
+ writeAtomic(cmdPath, '');
1434
+ writeAtomic(shPath, '');
1435
+ }
1436
+ }
1437
+
1438
+ // `switch off <tool>` while saveConfig refuses (R7b) cannot use applyLaunchState: that derives both
1439
+ // tools from activeProfiles, and a colliding config is one this build will not migrate. This empties
1440
+ // only the file pair of the tool that was switched off, under the same lock, so the other tool's
1441
+ // launcher files are never opened (A13: emptied, never deleted).
1442
+ export function emptyToolEnvFiles(tool) {
1443
+ const codex = tool === 'codex';
1444
+ withLaunchLock(() => {
1445
+ writeAtomic(codex ? paths.envCodexCmd : paths.envClaudeCmd, '');
1446
+ writeAtomic(codex ? paths.envCodexSh : paths.envClaudeSh, '');
1447
+ });
1448
+ }
1449
+
1450
+ // Write the per-tool env files from activeProfiles. settings.json is never touched here: it
1451
+ // belongs to the coding tool (R1), and model-catalog.json is no longer written (R9).
644
1452
  export function applyLaunchState(cfg, port, opts = {}) {
645
1453
  return withLaunchLock(() => writeLaunchState(cfg, port, opts));
646
1454
  }
647
1455
 
648
- function writeLaunchState(cfg, port, { cleanSettings = true } = {}) {
1456
+ function writeLaunchState(cfg, port) {
649
1457
  const st = computeLaunchState(cfg, port);
650
1458
 
651
- const renderCmd = (pairs) => ['@echo off', 'REM Auto-generated by LLM Switcher for active profiles',
652
- ...pairs.map(([k, v]) => `SET "${k}=${v}"`)].join('\r\n') + '\r\n';
653
- const renderSh = (pairs) => ['#!/usr/bin/env sh', '# Auto-generated by LLM Switcher for active profiles',
654
- ...pairs.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, `'\\''`)}'`)].join('\n') + '\n';
655
-
656
- // The env files come first and the flags last: a flag that says "active" while its env file is
657
- // missing or stale makes the launcher bypass the gateway. A failed write leaves the flags as they were.
658
- if (st.active) {
659
- try {
660
- writeAtomic(paths.envCmd, renderCmd(st.env));
661
- writeAtomic(paths.envSh, renderSh(st.env));
662
- // Written even when empty, so a stale Codex-only file from a previous profile
663
- // can never survive a switch.
664
- writeAtomic(paths.envCodexCmd, renderCmd(st.envCodex));
665
- writeAtomic(paths.envCodexSh, renderSh(st.envCodex));
666
- } catch (err) {
667
- st.envWriteError = err.message;
668
- return st;
669
- }
1459
+ // Env files first, the flag last: a flag that says "active" while a file is missing or stale
1460
+ // makes the launcher route with the wrong variables. A failed write leaves the flag as it was.
1461
+ try {
1462
+ writeToolFiles(st.envClaude, paths.envClaudeSh, paths.envClaudeCmd);
1463
+ writeToolFiles(st.envCodex, paths.envCodexSh, paths.envCodexCmd);
1464
+ // Recorded on switch on so a shell opened while the gateway ran can still recognize its own
1465
+ // stale loopback URL later, after the port changed (R8).
1466
+ if (st.active) writeAtomic(paths.gatewayPort, `${port}\n`);
1467
+ // The stub always wins: an env.sh left by an older release must not survive a switch.
1468
+ writeAtomic(paths.envCmd, STUB_CMD);
1469
+ writeAtomic(paths.envSh, STUB_SH);
1470
+ } catch (err) {
1471
+ st.envWriteError = err.message;
1472
+ return st;
670
1473
  }
671
1474
  writeOrRemove(paths.activeFlag, st.active ? 'active' : null);
672
- writeOrRemove(paths.flag1M, st.claude1M);
673
- writeOrRemove(paths.flagCodex1M, st.codex1M);
674
- writeOrRemove(paths.flagOpenAI1M, st.openai1M);
675
- if (!st.active) {
676
- writeOrRemove(paths.envCmd, null);
677
- writeOrRemove(paths.envSh, null);
678
- writeOrRemove(paths.envCodexCmd, null);
679
- writeOrRemove(paths.envCodexSh, null);
680
- }
681
-
682
- // The catalog follows the active Codex profile, so a profile switch can never
683
- // leave the previous profile's model names on the /model screen.
684
- const codexKey = getActiveMap(cfg).responses;
685
- const codexProfile = hasProfile(cfg, codexKey) ? cfg.profiles[codexKey] : null;
686
- const catalog = st.active && codexProfile ? buildCodexCatalog(codexProfile) : null;
687
- writeOrRemove(paths.codexCatalog, catalog ? JSON.stringify(catalog) : null);
688
-
689
- // Clean only after the env files are in place: a failed write returned above, before settings.json.
690
- if (cleanSettings) st.settings = cleanClaudeSettings(port);
691
1475
  return st;
692
1476
  }
693
1477
 
694
1478
  export function clearLaunchState(port) {
695
1479
  withLaunchLock(() => {
696
- for (const f of [paths.activeFlag, paths.flag1M, paths.flagCodex1M, paths.flagOpenAI1M,
697
- paths.envCmd, paths.envSh, paths.envCodexCmd, paths.envCodexSh, paths.codexCatalog]) {
698
- writeOrRemove(f, null);
1480
+ writeOrRemove(paths.activeFlag, null);
1481
+ // Stubs rather than deletions, and the tool files emptied rather than removed (R8, A13):
1482
+ // `switch off claude` must leave an empty claude file behind, and bare `switch off` must
1483
+ // leave env.sh present and containing no export and no unset.
1484
+ writeAtomic(paths.envCmd, STUB_CMD);
1485
+ writeAtomic(paths.envSh, STUB_SH);
1486
+ for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh]) {
1487
+ writeAtomic(f, '');
699
1488
  }
700
1489
  });
701
- return cleanClaudeSettings(port);
1490
+ // settings.json belongs to the coding tool: the switcher neither reads nor writes it (R1).
1491
+ return { changed: false, removed: [] };
702
1492
  }
703
1493
 
704
1494
  export function readLaunchFlags() {
705
- const active = fs.existsSync(paths.activeFlag);
706
- return {
707
- isUsingProxy: active,
708
- is1MActive: active && fs.existsSync(paths.flag1M),
709
- isCodex1MActive: active && fs.existsSync(paths.flagCodex1M),
710
- isOpenAI1MActive: active && fs.existsSync(paths.flagOpenAI1M)
711
- };
1495
+ // Only "is the gateway wired in". The 1M flags went with the forced window (R9): the tool
1496
+ // sizes a session from the window of the official model the user picked.
1497
+ return { isUsingProxy: fs.existsSync(paths.activeFlag) };
712
1498
  }
713
1499
 
714
1500
  // settings.json belongs to Claude Code and to the user. Remove a value only when it is exactly what
@@ -763,7 +1549,16 @@ export function redactConfig(cfg) {
763
1549
  // body. Only a process that can read admin.token can answer HMAC(token, nonce) for a fresh nonce.
764
1550
  // The MAC covers role, listening port, pid and arguments: a proof relayed from the process on
765
1551
  // another port, or a body with an edited pid, no longer verifies. blindfold.mjs signs the same fields.
766
- export function identityProof(nonce, { role, port, pid, gatewayPort = '', host = '', prefix = '' }, token = readAdminToken()) {
1552
+ // R3 replaced `host` and `prefix` with the active tool set, so the two builds sign different
1553
+ // fields. Both must verify: a running interceptor from the older build is still ours, and an
1554
+ // answer that stops verifying would make stopping it look like tampering with a foreign
1555
+ // process that merely holds the port we need back.
1556
+ export function identityProof(nonce, { role, port, pid, gatewayPort = '', activeTools = '' }, token = readAdminToken()) {
1557
+ if (!token) return '';
1558
+ return crypto.createHmac('sha256', token).update([role, port, pid, gatewayPort, activeTools, nonce].join('|')).digest('hex');
1559
+ }
1560
+
1561
+ export function legacyIdentityProof(nonce, { role, port, pid, gatewayPort = '', host = '', prefix = '' }, token = readAdminToken()) {
767
1562
  if (!token) return '';
768
1563
  return crypto.createHmac('sha256', token).update([role, port, pid, gatewayPort, host, prefix, nonce].join('|')).digest('hex');
769
1564
  }
@@ -817,16 +1612,32 @@ export async function probeGateway(port) {
817
1612
  return proof && b.proof === proof ? 'ours' : 'foreign';
818
1613
  }
819
1614
 
820
- /** { state: 'ours', pid, gatewayPort, host, prefix } | { state: 'foreign' | 'silent' | 'free' } */
1615
+ /**
1616
+ * `ours` — an interceptor of this build; answers a proof over the active tool set.
1617
+ * `legacy-ours` — an interceptor built before R3; answers a proof over host and prefix. Same
1618
+ * token, same port, so it is ours, but it cannot be told a new tool set in
1619
+ * place and reconcile replaces it.
1620
+ * `foreign` | `silent` | `free` — not ours; never signalled.
1621
+ */
821
1622
  export async function probeBlindfold(port) {
822
1623
  const nonce = newNonce();
823
1624
  const r = await getJson(port, `/?challenge=${nonce}`);
824
1625
  if (r.state !== 'answered') return { state: r.state };
825
1626
  const b = r.body;
826
1627
  if (b?.proxy !== 'llm-switcher-blindfold' || b.port !== port) return { state: 'foreign' };
827
- const proof = identityProof(nonce, { role: 'blindfold', port, pid: b.pid, gatewayPort: b.gatewayPort, host: b.host, prefix: b.prefix });
828
- if (!proof || b.proof !== proof) return { state: 'foreign' };
829
- return { state: 'ours', pid: b.pid, gatewayPort: b.gatewayPort, host: b.host, prefix: b.prefix };
1628
+ const activeTools = typeof b.activeTools === 'string' ? b.activeTools : '';
1629
+ const proof = identityProof(nonce, { role: 'blindfold', port, pid: b.pid, gatewayPort: b.gatewayPort, activeTools });
1630
+ if (proof && b.proof === proof) {
1631
+ return { state: 'ours', pid: b.pid, gatewayPort: b.gatewayPort, activeTools };
1632
+ }
1633
+ const legacy = legacyIdentityProof(nonce, {
1634
+ role: 'blindfold', port, pid: b.pid, gatewayPort: b.gatewayPort,
1635
+ host: typeof b.host === 'string' ? b.host : '', prefix: typeof b.prefix === 'string' ? b.prefix : ''
1636
+ });
1637
+ if (legacy && b.proof === legacy) {
1638
+ return { state: 'legacy-ours', pid: b.pid, gatewayPort: b.gatewayPort, host: b.host, prefix: b.prefix };
1639
+ }
1640
+ return { state: 'foreign' };
830
1641
  }
831
1642
 
832
1643
  // Signal a pid only right after a fresh identity probe named it.
@@ -854,14 +1665,18 @@ function writeBlindfoldState(st) {
854
1665
  fs.writeFileSync(blindfoldStatePath, JSON.stringify(st), { encoding: 'utf8', mode: 0o600 });
855
1666
  }
856
1667
 
1668
+ // An interceptor built before R3 is ours too: it holds the port a new one needs to bind, and it
1669
+ // stops on the same verified pid. A foreign process on that port is never signalled.
1670
+ const isOurBlindfold = (p) => p.state === 'ours' || p.state === 'legacy-ours';
1671
+
857
1672
  /** true when no interceptor of ours answers on the port any more. */
858
1673
  async function stopBlindfoldAt(port) {
859
1674
  const cur = await probeBlindfold(port);
860
- if (cur.state !== 'ours') return true;
1675
+ if (!isOurBlindfold(cur)) return true;
861
1676
  killVerified(cur.pid);
862
1677
  for (let i = 0; i < 20; i++) {
863
1678
  await sleep(100);
864
- if ((await probeBlindfold(port)).state !== 'ours') return true;
1679
+ if (!isOurBlindfold(await probeBlindfold(port))) return true;
865
1680
  }
866
1681
  return false;
867
1682
  }
@@ -877,14 +1692,17 @@ export async function stopRecordedBlindfold() {
877
1692
  /** null when the interceptor can start, otherwise the reason and the command that fixes it. */
878
1693
  export function blindfoldPreflight(desired) {
879
1694
  const certDir = path.dirname(desired.ca);
880
- const build = `bash blindfold/make-certs.sh ${desired.host}${process.env.LLM_SWITCHER_BLINDFOLD_CERTS ? ` "${certDir}"` : ''}`;
1695
+ const build = `bash blindfold/make-certs.sh${process.env.LLM_SWITCHER_BLINDFOLD_CERTS ? ` "${certDir}"` : ''}`;
881
1696
  for (const f of [desired.ca, path.join(certDir, 'leaf.pem'), path.join(certDir, 'leaf.key')]) {
882
1697
  if (!fs.existsSync(f)) return `Blindfold mode is on, but ${path.basename(f)} is missing in ${certDir}. Build the certificates first: ${build}`;
883
1698
  }
884
- // A leaf for another host fails the TLS handshake with an error that reads like a network fault.
1699
+ // A leaf from an older version covers one host and fails the other two at the handshake with an
1700
+ // error that reads like a network fault. Name every host it is missing and change nothing:
1701
+ // the rebuild command is the only answer this returns (R3, spec A5).
885
1702
  const leafPem = fs.readFileSync(path.join(certDir, 'leaf.pem'), 'utf8');
886
- if (!certCoversHost(leafPem, desired.host)) {
887
- return `The leaf certificate does not cover "${desired.host}". Rebuild it for that host: ${build}`;
1703
+ const missing = INTERCEPT_HOSTS.filter(h => !certCoversHost(leafPem, h));
1704
+ if (missing.length) {
1705
+ return `The leaf certificate does not cover ${missing.join(', ')}. Rebuild it for all hosts: ${build}`;
888
1706
  }
889
1707
  // Files from two different builds fail the same way.
890
1708
  try {
@@ -918,15 +1736,21 @@ export function openLog(file) {
918
1736
  // The single place that starts an interceptor.
919
1737
  function spawnBlindfold(desired, gatewayPort) {
920
1738
  const log = openLog(paths.blindfoldLog);
921
- const child = spawn(process.execPath, [
1739
+ const args = [
922
1740
  blindfoldScript,
923
1741
  '--port', String(desired.port),
924
1742
  '--gateway-port', String(gatewayPort),
925
- '--host', desired.host,
926
- '--prefix', desired.prefix,
1743
+ // R3: the interceptor re-reads the config itself for the host table and the active tool set,
1744
+ // instead of being handed a host and a prefix that go stale (F6).
1745
+ '--config', configPath,
927
1746
  '--certs', path.dirname(desired.ca),
928
1747
  '--token-file', adminTokenPath
929
- ], { detached: true, stdio: ['ignore', log, log], windowsHide: true });
1748
+ ];
1749
+ // Finding 6: a fixed, sorted list. It is also the field `matches` compares, so a changed tool
1750
+ // set reaches a running interceptor through POST /_control/active-tools (Finding 2) instead of
1751
+ // through a kill and a restart.
1752
+ if (desired.activeTools) args.push('--active-tools', String(desired.activeTools));
1753
+ const child = spawn(process.execPath, args, { detached: true, stdio: ['ignore', log, log], windowsHide: true });
930
1754
  child.unref();
931
1755
  fs.closeSync(log);
932
1756
  return child.pid;
@@ -944,8 +1768,72 @@ export async function checkBlindfoldTarget(cfg, gatewayPort) {
944
1768
  return null;
945
1769
  }
946
1770
 
1771
+ // The running interceptor is the one this config asks for: same gateway, same tool set. An older
1772
+ // build never matches, because it has no channel to be told a new tool set — it gets replaced.
947
1773
  const matches = (cur, desired, gatewayPort) =>
948
- cur.state === 'ours' && cur.gatewayPort === gatewayPort && cur.host === desired.host && cur.prefix === desired.prefix;
1774
+ cur.state === 'ours'
1775
+ && cur.gatewayPort === gatewayPort
1776
+ && String(cur.activeTools || '') === String(desired.activeTools || '');
1777
+
1778
+ /**
1779
+ * Ask a running interceptor to re-derive its tool set from config.json (Finding 2, Item 7). The
1780
+ * body is empty and is read as nothing: a delta is one more thing to get wrong, and the
1781
+ * interceptor already owns the config. Authenticated with the admin token, over loopback only.
1782
+ * Returns the status code, or null when it could not be reached.
1783
+ */
1784
+ async function pushActiveTools(port, token) {
1785
+ if (!token) return null;
1786
+ return new Promise((resolve) => {
1787
+ const req = http.request({
1788
+ host: '127.0.0.1',
1789
+ port,
1790
+ path: '/_control/active-tools',
1791
+ method: 'POST',
1792
+ headers: { 'content-type': 'application/json', 'content-length': 2, 'x-llm-switcher-token': token },
1793
+ timeout: 5000
1794
+ }, (res) => {
1795
+ const chunks = [];
1796
+ res.on('data', (c) => chunks.push(c));
1797
+ res.on('end', () => {
1798
+ if (res.statusCode !== 200) return resolve(null);
1799
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { resolve(null); }
1800
+ });
1801
+ });
1802
+ req.on('error', () => resolve(null));
1803
+ req.on('timeout', () => { req.destroy(); resolve(null); });
1804
+ // The body is ignored by the interceptor, by design (Finding 4, option (a)).
1805
+ req.end('{}');
1806
+ });
1807
+ }
1808
+
1809
+ /**
1810
+ * Push a tool set to a running interceptor with no gateway in the way (Finding 4). The CLI reaches
1811
+ * for this only when `probeGateway` did not answer `ours`: the interceptor re-reads config.json on
1812
+ * every call and derives the list itself, so a message that arrives late cannot undo a change the
1813
+ * gateway queue already applied. A port with no listener is not a failure — there is nothing to
1814
+ * update, and starting one is not this command's job — but a listener this build cannot recognize
1815
+ * is, and the reason names the state it saw.
1816
+ * Returns { ok: true, activeTools } or { ok: false, error }.
1817
+ */
1818
+ export async function syncInterceptorTools(cfg, gatewayPort) {
1819
+ const port = computeLaunchState(cfg, gatewayPort).blindfold?.port || readBlindfoldState()?.port;
1820
+ if (!port) return { ok: true, activeTools: [] };
1821
+ const cur = await probeBlindfold(port);
1822
+ if (cur.state === 'free') return { ok: true, activeTools: [] };
1823
+ if (cur.state !== 'ours') {
1824
+ const named = {
1825
+ 'legacy-ours': 'runs an older build of this switcher',
1826
+ foreign: 'is held by another process',
1827
+ silent: 'accepts connections but never answers'
1828
+ }[cur.state] || `reports state "${cur.state}"`;
1829
+ return { ok: false, error: `the interceptor on port ${port} ${named}` };
1830
+ }
1831
+ const ack = await pushActiveTools(port, readAdminToken());
1832
+ if (!ack || !Array.isArray(ack.activeTools)) {
1833
+ return { ok: false, error: `the interceptor on port ${port} did not accept the active tool set` };
1834
+ }
1835
+ return { ok: true, activeTools: ack.activeTools };
1836
+ }
949
1837
 
950
1838
  /**
951
1839
  * Bring the interceptor in line with the saved config: start, respawn with new arguments, or stop.
@@ -970,10 +1858,31 @@ export async function reconcileBlindfold(cfg, gatewayPort) {
970
1858
  }
971
1859
  const cur = await probeBlindfold(desired.port);
972
1860
  if (matches(cur, desired, gatewayPort)) {
973
- writeBlindfoldState({ pid: cur.pid, port: desired.port, gatewayPort, host: desired.host, prefix: desired.prefix });
1861
+ writeBlindfoldState({ pid: cur.pid, port: desired.port, gatewayPort, activeTools: desired.activeTools });
974
1862
  return { ok: true, action: 'kept' };
975
1863
  }
976
- if (cur.state === 'ours' && !(await stopBlindfoldAt(desired.port))) {
1864
+ // Finding 2 / Item 7: the tool set is the one thing that changes while the interceptor runs.
1865
+ // One authenticated loopback POST, no signal, no restart, port untouched and in-flight
1866
+ // streams undisturbed. What gets confirmed is the list the interceptor says it now holds, not
1867
+ // the snapshot this call began with: config.json can change in between, and the process that
1868
+ // already read it is the one that is running.
1869
+ if (cur.state === 'ours' && cur.gatewayPort === gatewayPort) {
1870
+ const ack = await pushActiveTools(desired.port, readAdminToken());
1871
+ if (ack && Array.isArray(ack.activeTools)) {
1872
+ const want = ack.activeTools.join(',');
1873
+ for (let i = 0; i < 20; i++) {
1874
+ const now = await probeBlindfold(desired.port);
1875
+ if (now.state === 'ours' && String(now.activeTools || '') === want) {
1876
+ writeBlindfoldState({ pid: now.pid, port: desired.port, gatewayPort, activeTools: want });
1877
+ return { ok: true, action: 'updated' };
1878
+ }
1879
+ await sleep(100);
1880
+ }
1881
+ return { ok: false, error: 'failed to update active tools on interceptor' };
1882
+ }
1883
+ // Anything else — an interceptor too old to know the endpoint — falls through to a restart.
1884
+ }
1885
+ if (isOurBlindfold(cur) && !(await stopBlindfoldAt(desired.port))) {
977
1886
  return { ok: false, error: `the interceptor on port ${desired.port} did not stop` };
978
1887
  }
979
1888
 
@@ -982,7 +1891,7 @@ export async function reconcileBlindfold(cfg, gatewayPort) {
982
1891
  await sleep(250);
983
1892
  const now = await probeBlindfold(desired.port);
984
1893
  if (matches(now, desired, gatewayPort)) {
985
- writeBlindfoldState({ pid: now.pid, port: desired.port, gatewayPort, host: desired.host, prefix: desired.prefix });
1894
+ writeBlindfoldState({ pid: now.pid, port: desired.port, gatewayPort, activeTools: desired.activeTools });
986
1895
  return { ok: true, action: 'started' };
987
1896
  }
988
1897
  }