llm-switcher 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.gitattributes +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +587 -0
  4. package/README.vi.md +585 -0
  5. package/blindfold/blindfold.mjs +633 -0
  6. package/blindfold/make-certs.sh +88 -0
  7. package/blindfold/wsframe.mjs +176 -0
  8. package/codex-catalog-template.json +1 -0
  9. package/config.example.json +84 -0
  10. package/contract-exclusions.json +41 -0
  11. package/contract.mjs +561 -0
  12. package/docs/LLM-RESPONSE-MATRIX.md +165 -0
  13. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
  14. package/docs/codex-blindfold.md +214 -0
  15. package/docs/cross-platform.md +136 -0
  16. package/docs/diagrams/blindfold-request-routing.html +14972 -0
  17. package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
  18. package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
  19. package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
  20. package/docs/diagrams/codex-model-name-resolution.html +15005 -0
  21. package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
  22. package/docs/response-matrix.json +1131 -0
  23. package/formats.mjs +2308 -0
  24. package/mcp.mjs +340 -0
  25. package/package.json +36 -0
  26. package/proxy.mjs +1743 -0
  27. package/service.mjs +132 -0
  28. package/shim.mjs +292 -0
  29. package/skills/llm-switcher/SKILL.md +88 -0
  30. package/state.mjs +978 -0
  31. package/switch +5 -0
  32. package/switch.cmd +2 -0
  33. package/switch.mjs +930 -0
  34. package/tests/blindfold.test.mjs +307 -0
  35. package/tests/blindfold.wire.test.mjs +170 -0
  36. package/tests/contract/run.test.mjs +214 -0
  37. package/tests/contract-check.test.mjs +458 -0
  38. package/tests/contract-lab.test.mjs +755 -0
  39. package/tests/datadir.test.mjs +37 -0
  40. package/tests/formats.test.mjs +794 -0
  41. package/tests/gateway.e2e.test.mjs +999 -0
  42. package/tests/helpers.mjs +24 -0
  43. package/tests/lifecycle.test.mjs +416 -0
  44. package/tests/live-optimizer-interop.mjs +205 -0
  45. package/tests/mcp.test.mjs +91 -0
  46. package/tests/service.test.mjs +69 -0
  47. package/tests/shim.test.mjs +228 -0
  48. package/tests/state.test.mjs +675 -0
  49. package/tests/switch.test.mjs +156 -0
  50. package/tests/wsframe.test.mjs +154 -0
  51. package/ui.html +2234 -0
package/state.mjs ADDED
@@ -0,0 +1,978 @@
1
+ // ============================================================
2
+ // state.mjs — shared config / launcher-flag state for LLM Switcher
3
+ //
4
+ // Shared by proxy.mjs, switch.mjs, mcp.mjs to avoid 3 copies of the
5
+ // profile on/off logic drifting out of sync.
6
+ // ============================================================
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import crypto from 'node:crypto';
12
+ import http from 'node:http';
13
+ import { spawn, execFileSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ export const ROOT_DIR = __dirname;
19
+
20
+ // A git checkout keeps its data next to the code. An npm install must not: an upgrade replaces
21
+ // the package directory. LLM_SWITCHER_HOME overrides both.
22
+ export function resolveDataDir(rootDir, env = process.env, home = os.homedir()) {
23
+ if (env.LLM_SWITCHER_HOME) return path.resolve(env.LLM_SWITCHER_HOME);
24
+ return fs.existsSync(path.join(rootDir, '.git')) ? rootDir : path.join(home, '.llm-switcher');
25
+ }
26
+ export const DATA_DIR = resolveDataDir(ROOT_DIR);
27
+ // Outside a checkout the data dir starts empty; config, token and launch files all write into it.
28
+ if (DATA_DIR !== ROOT_DIR) {
29
+ try { fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); } catch {}
30
+ }
31
+ export const TARGETS = ['anthropic', 'responses', 'openai-chat', 'vertex'];
32
+ export const DEFAULT_PORT = 3456;
33
+ export const DEFAULT_BLINDFOLD_PORT = 3457;
34
+ // Codex with ChatGPT sign-in calls https://chatgpt.com/backend-api/codex.
35
+ // 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';
38
+ export const CLAUDE_MODEL_SLOTS = ['opus', 'sonnet', 'haiku', 'fable'];
39
+ // Codex CLI model roles per OpenAI docs (config-reference):
40
+ // - main <-> `model` (session model)
41
+ // - review <-> `review_model` (override for /review)
42
+ // - subagent <-> `agents.default_subagent_model` (spawned agents)
43
+ // No fast/fallback in the docs — those are custom keys, read only for backward compatibility.
44
+ export const CODEX_MODEL_SLOTS = ['main', 'review', 'subagent'];
45
+ export const CHAT_MODEL_SLOTS = ['default'];
46
+ export const VERTEX_MODEL_SLOTS = ['default'];
47
+
48
+ export const MODEL_SLOTS_BY_FORMAT = {
49
+ anthropic: CLAUDE_MODEL_SLOTS,
50
+ responses: CODEX_MODEL_SLOTS,
51
+ 'openai-chat': CHAT_MODEL_SLOTS,
52
+ vertex: VERTEX_MODEL_SLOTS,
53
+ auto: CLAUDE_MODEL_SLOTS
54
+ };
55
+
56
+ // Fallback key chain when reading old profiles (preserves values, no config loss).
57
+ const SLOT_LEGACY_KEYS = {
58
+ main: ['opus'],
59
+ review: ['sonnet'],
60
+ subagent: ['fast', 'fallback', 'haiku', 'fable'],
61
+ default: ['sonnet', 'opus', 'haiku', 'fable']
62
+ };
63
+
64
+ export const configPath = process.env.LLM_SWITCHER_CONFIG
65
+ ? path.resolve(process.env.LLM_SWITCHER_CONFIG)
66
+ : path.join(DATA_DIR, 'config.json');
67
+
68
+ // Any local process can reach loopback, so /api/* needs a secret that only the owner can read.
69
+ // It lives next to config.json so a test config in a temp dir gets its own token.
70
+ export const adminTokenPath = path.join(path.dirname(configPath), 'admin.token');
71
+
72
+ export function readAdminToken() {
73
+ try {
74
+ return fs.readFileSync(adminTokenPath, 'utf8').trim() || null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export function ensureAdminToken() {
81
+ const current = readAdminToken();
82
+ if (current) {
83
+ try { fs.chmodSync(adminTokenPath, 0o600); } catch {}
84
+ return current;
85
+ }
86
+ const token = crypto.randomBytes(32).toString('hex');
87
+ try {
88
+ fs.writeFileSync(adminTokenPath, `${token}\n`, { mode: 0o600, flag: 'wx' });
89
+ } catch (err) {
90
+ if (err.code === 'EEXIST') return readAdminToken();
91
+ throw err;
92
+ }
93
+ return token;
94
+ }
95
+
96
+ // `switch ui` must not put the token on a command line: /proc/<pid>/cmdline is readable by every
97
+ // account. It opens this private file instead, which redirects to the dashboard with the token.
98
+ export function writeDashboardLauncher(url) {
99
+ const file = path.join(path.dirname(configPath), 'ui-open.html');
100
+ const target = `${url}#token=${ensureAdminToken()}`;
101
+ const tmp = `${file}.${process.pid}.tmp`;
102
+ fs.rmSync(tmp, { force: true });
103
+ fs.writeFileSync(tmp, `<!doctype html><meta charset="utf-8"><title>LLM Switcher</title><script>location.replace(${JSON.stringify(target)})</script>\n`, { mode: 0o600, flag: 'wx' });
104
+ fs.renameSync(tmp, file);
105
+ return file;
106
+ }
107
+
108
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
109
+ export const claudeSettingsPath = path.join(claudeDir, 'settings.json');
110
+
111
+ // The shims read the launch files from the checkout. LLM_SWITCHER_STATE_DIR moves them for tests,
112
+ // which must not rewrite the launch state of a switcher that is in use.
113
+ export const STATE_DIR = process.env.LLM_SWITCHER_STATE_DIR ? path.resolve(process.env.LLM_SWITCHER_STATE_DIR) : DATA_DIR;
114
+
115
+ export const paths = {
116
+ 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
120
+ envCmd: path.join(STATE_DIR, 'env.cmd'),
121
+ envSh: path.join(STATE_DIR, 'env.sh'),
122
+ envCodexCmd: path.join(STATE_DIR, 'env-codex.cmd'),
123
+ envCodexSh: path.join(STATE_DIR, 'env-codex.sh'),
124
+ codexCatalog: path.join(STATE_DIR, 'model-catalog.json'),
125
+ proxyLog: path.join(STATE_DIR, 'proxy.log'),
126
+ blindfoldLog: path.join(STATE_DIR, 'blindfold.log'),
127
+ 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')
129
+ };
130
+
131
+ // ---------------- config IO ----------------
132
+
133
+ let cachedConfig = null;
134
+ let lastSignature = '';
135
+ let lastLoadError = null;
136
+
137
+ // mtime alone misses a rewrite inside the same timestamp tick. Every save renames a new file into
138
+ // place, so the inode changes even then.
139
+ const fileSignature = (st) => `${st.mtimeMs}:${st.ctimeMs}:${st.size}:${st.ino}`;
140
+
141
+ // Cached config read. If the file is half-written (invalid JSON), keep the old cached copy.
142
+ export function loadConfig() {
143
+ try {
144
+ const stat = fs.statSync(configPath);
145
+ if (!cachedConfig || fileSignature(stat) !== lastSignature) {
146
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
147
+ if (!parsed || typeof parsed !== 'object') throw new Error('config root must be an object');
148
+ if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {};
149
+ cachedConfig = parsed;
150
+ lastSignature = fileSignature(stat);
151
+ }
152
+ lastLoadError = null;
153
+ } catch (err) {
154
+ lastLoadError = err;
155
+ }
156
+ return cachedConfig;
157
+ }
158
+
159
+ export function getConfigLoadError() {
160
+ return lastLoadError;
161
+ }
162
+
163
+ // Atomic write (tmp + rename) so a running proxy never reads a half-written file.
164
+ // The tmp file is created 0600 and renamed over config.json, so the keys are never readable by
165
+ // another account, even when an earlier release left config.json at 0644.
166
+ export function saveConfig(cfg) {
167
+ const tmp = `${configPath}.${process.pid}.tmp`;
168
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), { encoding: 'utf8', mode: 0o600 });
169
+ fs.chmodSync(tmp, 0o600);
170
+ const ino = fs.statSync(tmp).ino;
171
+ fs.renameSync(tmp, configPath);
172
+ cachedConfig = cfg;
173
+ // Another process can rename its own file in between; its signature must not be taken as ours.
174
+ try {
175
+ const st = fs.statSync(configPath);
176
+ lastSignature = st.ino === ino ? fileSignature(st) : '';
177
+ } catch {
178
+ lastSignature = '';
179
+ }
180
+ }
181
+
182
+ // ---------------- contract lab ----------------
183
+
184
+ export const CONTRACT_LAB_OFF = { url: '', apiKey: '', enabled: false };
185
+
186
+ // The top-level `contractLab` block, normalized. It stays off unless the block names an http(s)
187
+ // intact URL and a key, so a half-filled block never starts sampling.
188
+ export function contractLabSettings(cfg) {
189
+ const raw = cfg?.contractLab;
190
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ...CONTRACT_LAB_OFF };
191
+ const apiKey = typeof raw.apiKey === 'string' ? raw.apiKey.trim() : '';
192
+ let url = '';
193
+ try {
194
+ const parsed = new URL(typeof raw.url === 'string' ? raw.url.trim() : '');
195
+ if (['http:', 'https:'].includes(parsed.protocol)) url = String(raw.url).trim().replace(/\/+$/, '');
196
+ } catch {}
197
+ return { url, apiKey, enabled: raw.enabled === true && Boolean(url) && Boolean(apiKey) };
198
+ }
199
+
200
+ // ---------------- helpers ----------------
201
+
202
+ export function hasProfile(cfg, key) {
203
+ return Boolean(key) && Boolean(cfg?.profiles) && Object.hasOwn(cfg.profiles, key);
204
+ }
205
+
206
+ export function isValidProfileKey(key) {
207
+ return typeof key === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(key);
208
+ }
209
+
210
+ export function isValidTarget(target) {
211
+ return TARGETS.includes(target);
212
+ }
213
+
214
+ // Case-insensitive profile lookup (CLI accepts `switch MyProfile` or `switch myprofile`).
215
+ export function findProfileKey(cfg, name) {
216
+ if (!name || !cfg?.profiles) return null;
217
+ if (hasProfile(cfg, name)) return name;
218
+ const lower = String(name).toLowerCase();
219
+ return Object.keys(cfg.profiles).find(k => k.toLowerCase() === lower) || null;
220
+ }
221
+
222
+ export function profileAcceptsTarget(profile, target) {
223
+ const inFmt = profile?.inFormat || 'auto';
224
+ return inFmt === 'auto' || inFmt === target;
225
+ }
226
+
227
+ export function modelSlotsForProfile(profile) {
228
+ return MODEL_SLOTS_BY_FORMAT[profile?.inFormat] || MODEL_SLOTS_BY_FORMAT.auto;
229
+ }
230
+
231
+ function legacyKeysForSlot(slot) {
232
+ return SLOT_LEGACY_KEYS[slot] || [];
233
+ }
234
+
235
+ // UI saves using the new slot keys; on read, try the new key first, legacy keys after.
236
+ export function modelForSlot(profile, slot) {
237
+ const models = profile?.defaultModels || {};
238
+ // An explicit empty canonical value clears any legacy value. This matters
239
+ // during migration because the UI keeps old keys until the profile is saved.
240
+ if (Object.hasOwn(models, slot)) return models[slot] || '';
241
+ for (const k of legacyKeysForSlot(slot)) {
242
+ if (models[k]) return models[k];
243
+ }
244
+ return '';
245
+ }
246
+
247
+ export function model1MForSlot(profile, slot) {
248
+ const flags = profile?.model1M || {};
249
+ if (Object.hasOwn(flags, slot)) return Boolean(flags[slot]);
250
+ for (const k of legacyKeysForSlot(slot)) {
251
+ if (Object.hasOwn(flags, k)) return Boolean(flags[k]);
252
+ }
253
+ return false;
254
+ }
255
+
256
+ // ---------------- Codex-facing model names ----------------
257
+ //
258
+ // Codex must never be handed a switcher-internal name. It can display whatever it
259
+ // is given: the /model picker renders the local catalog file, and `review_model` /
260
+ // `agents.default_subagent_model` show up in its config. So every name that leaves
261
+ // this process for the CLI comes from `publicModels` (official OpenAI slugs), while
262
+ // the slots main/review/subagent stay server-side for mapModel to resolve.
263
+ //
264
+ // `codexRoles` overrides a single role when position is not the wanted pairing.
265
+ export function codexPublicModel(profile, slot) {
266
+ // A present key wins even when it is '': the dashboard saves a blank role as "no override".
267
+ if (Object.hasOwn(profile?.codexRoles || {}, slot)) return String(profile.codexRoles[slot] || '');
268
+ // Read by POSITION, without compacting the array. Dropping a blank entry first would
269
+ // move every later role onto the wrong model, and /review would run on the subagent.
270
+ const published = Array.isArray(profile?.publicModels) ? profile.publicModels : [];
271
+ const index = CODEX_MODEL_SLOTS.indexOf(slot);
272
+ return index >= 0 ? String(published[index] || '') : '';
273
+ }
274
+
275
+ // A model name reaches cmd.exe twice: env.cmd runs as a batch file, and the shim then
276
+ // expands the variable unquoted onto the Codex command line. So the name is restricted
277
+ // to the characters real model IDs use. An unsafe name is dropped, never escaped:
278
+ // dropping it loses one override, escaping it correctly in two shells is a bet.
279
+ const SAFE_MODEL_NAME = /^[A-Za-z0-9._:/-]{1,128}$/;
280
+ // Codex parses a --config value as TOML before it falls back to a string, and the Windows shim passes
281
+ // the name unquoted. A name that TOML reads as a number, a boolean or a date would change type.
282
+ const TOML_NON_STRING = /^([+-]?(0x[0-9a-f_]+|0o[0-7_]+|0b[01_]+|inf|nan|[\d_]+(\.[\d_]+)?(e[+-]?[\d_]+)?)|true|false|\d{4}-\d{2}-\d{2}([t ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(z|[+-]\d{2}:\d{2})?)?|[^a-z]*)$/i;
283
+
284
+ export function isSafeModelName(name) {
285
+ return typeof name === 'string' && SAFE_MODEL_NAME.test(name) && !TOML_NON_STRING.test(name);
286
+ }
287
+
288
+ /**
289
+ * Does this leaf certificate cover the host the interceptor will present it for?
290
+ * Changing blindfoldHost without rebuilding the leaf produces a TLS error that reads
291
+ * like a network fault, so the launcher compares the two before it starts anything.
292
+ */
293
+ export function certCoversHost(pem, host) {
294
+ if (!pem || !host) return false;
295
+ let names;
296
+ try {
297
+ names = new crypto.X509Certificate(pem).subjectAltName;
298
+ } catch {
299
+ return false;
300
+ }
301
+ if (!names) return false;
302
+ const target = String(host).toLowerCase();
303
+ return names.split(',').some(entry => {
304
+ const value = entry.trim().replace(/^DNS:/i, '').toLowerCase();
305
+ if (value === target) return true;
306
+ // One wildcard label only, exactly as TLS clients match it.
307
+ if (value.startsWith('*.')) {
308
+ const suffix = value.slice(1);
309
+ return target.endsWith(suffix) && !target.slice(0, -suffix.length).includes('.');
310
+ }
311
+ return false;
312
+ });
313
+ }
314
+
315
+ let cachedCatalogTemplate = null;
316
+
317
+ function codexCatalogTemplate() {
318
+ if (cachedCatalogTemplate) return cachedCatalogTemplate;
319
+ try {
320
+ cachedCatalogTemplate = JSON.parse(fs.readFileSync(paths.codexCatalogTemplate, 'utf8'));
321
+ } catch {
322
+ cachedCatalogTemplate = null;
323
+ }
324
+ return cachedCatalogTemplate;
325
+ }
326
+
327
+ /**
328
+ * Build the catalog Codex loads through `--config model_catalog_json`.
329
+ * Returns null when the profile publishes no official names: Codex then keeps its
330
+ * own built-in catalog, which is leak-free too.
331
+ */
332
+ export function buildCodexCatalog(profile) {
333
+ if (!codexCatalogTemplate()) return null;
334
+ const windows = publicModelWindows(profile);
335
+ if (!windows.size) return null;
336
+ return { models: [...windows].map(([name, is1M]) => codexModelEntry(name, is1M)) };
337
+ }
338
+
339
+ // One catalog entry. The catalog file and the gateway's /v1/models both use it, so they cannot drift.
340
+ export function codexModelEntry(name, is1M) {
341
+ return {
342
+ ...structuredClone(codexCatalogTemplate() || {}),
343
+ slug: name,
344
+ display_name: name,
345
+ ...(is1M ? { context_window: 1000000, max_context_window: 1000000 } : {})
346
+ };
347
+ }
348
+
349
+ // name -> is1M. The picker sizes a session from this window, so when two slots share a name the
350
+ // smaller window wins: overstating it makes Codex plan against space it does not have.
351
+ export function smallestWindows(pairs) {
352
+ const windows = new Map();
353
+ for (const [name, is1M] of pairs) {
354
+ if (!name) continue;
355
+ if (!windows.has(name) || !is1M) windows.set(name, Boolean(is1M));
356
+ }
357
+ return windows;
358
+ }
359
+
360
+ export function publicModelWindows(profile) {
361
+ return smallestWindows(CODEX_MODEL_SLOTS.map(slot => [codexPublicModel(profile, slot), model1MForSlot(profile, slot)]));
362
+ }
363
+
364
+ export function parsePort(value) {
365
+ const p = parseInt(value, 10);
366
+ return Number.isInteger(p) && p > 0 && p <= 65535 ? p : null;
367
+ }
368
+
369
+ // Precedence: --port / -p > LLM_SWITCHER_PORT > config.port > 3456. A generic PORT is ignored: other
370
+ // tools (dev servers) set it, and it would move the gateway in silence.
371
+ export function resolvePort(argv = process.argv.slice(2), cfg = loadConfig()) {
372
+ for (let i = 0; i < argv.length; i++) {
373
+ if ((argv[i] === '--port' || argv[i] === '-p') && argv[i + 1]) {
374
+ const p = parsePort(argv[i + 1]);
375
+ if (p) return p;
376
+ }
377
+ }
378
+ const envP = parsePort(process.env.LLM_SWITCHER_PORT);
379
+ if (envP) return envP;
380
+ return parsePort(cfg?.port) || DEFAULT_PORT;
381
+ }
382
+
383
+ // Legacy config only has `activeProfile` -> derive the per-CLI-target map from it.
384
+ export function getActiveMap(cfg) {
385
+ const out = {};
386
+ const legacy = cfg?.activeProfile || null;
387
+ for (const t of TARGETS) {
388
+ if (cfg?.activeProfiles && Object.hasOwn(cfg.activeProfiles, t)) out[t] = cfg.activeProfiles[t] || null;
389
+ else out[t] = legacy;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ function ensureActiveMap(cfg) {
395
+ cfg.activeProfiles = getActiveMap(cfg);
396
+ return cfg.activeProfiles;
397
+ }
398
+
399
+ // ---------------- mutations (do not persist by themselves) ----------------
400
+
401
+ // Assign a profile to one target. Returns an error string or null.
402
+ export function setTargetProfile(cfg, target, profileKey) {
403
+ if (!isValidTarget(target)) return `Unknown target "${target}". Valid: ${TARGETS.join(', ')}`;
404
+ const map = ensureActiveMap(cfg);
405
+ if (!profileKey) {
406
+ map[target] = null;
407
+ return null;
408
+ }
409
+ if (!hasProfile(cfg, profileKey)) return `Profile "${profileKey}" does not exist`;
410
+ const p = cfg.profiles[profileKey];
411
+ if (!profileAcceptsTarget(p, target)) {
412
+ return `Profile "${profileKey}" only accepts "${p.inFormat}" input and cannot serve target "${target}"`;
413
+ }
414
+ map[target] = profileKey;
415
+ cfg.activeProfile = profileKey;
416
+ return null;
417
+ }
418
+
419
+ // Enable a profile for every target it supports (inFormat auto -> all).
420
+ export function activateProfile(cfg, profileKey) {
421
+ if (!hasProfile(cfg, profileKey)) return `Profile "${profileKey}" does not exist`;
422
+ const map = ensureActiveMap(cfg);
423
+ const p = cfg.profiles[profileKey];
424
+ for (const t of TARGETS) {
425
+ if (profileAcceptsTarget(p, t)) map[t] = profileKey;
426
+ }
427
+ cfg.activeProfile = profileKey;
428
+ return null;
429
+ }
430
+
431
+ // Disable exactly the targets using this profile (leaves other targets untouched).
432
+ export function deactivateProfile(cfg, profileKey) {
433
+ const map = ensureActiveMap(cfg);
434
+ for (const t of TARGETS) {
435
+ if (map[t] === profileKey) map[t] = null;
436
+ }
437
+ }
438
+
439
+ export function deactivateAll(cfg) {
440
+ cfg.activeProfiles = Object.fromEntries(TARGETS.map(t => [t, null]));
441
+ }
442
+
443
+ export function deleteProfile(cfg, profileKey) {
444
+ if (!hasProfile(cfg, profileKey)) return 'Profile not found';
445
+ deactivateProfile(cfg, profileKey);
446
+ delete cfg.profiles[profileKey];
447
+ if (cfg.activeProfile === profileKey) {
448
+ cfg.activeProfile = Object.keys(cfg.profiles)[0] || '';
449
+ }
450
+ return null;
451
+ }
452
+
453
+ export function isProfileActive(cfg, profileKey) {
454
+ return Object.values(getActiveMap(cfg)).includes(profileKey);
455
+ }
456
+
457
+ // ---------------- launcher flags & env files ----------------
458
+
459
+ function writeOrRemove(file, content) {
460
+ if (content) {
461
+ fs.writeFileSync(file, content, 'utf8');
462
+ } else if (fs.existsSync(file)) {
463
+ try { fs.unlinkSync(file); } catch {}
464
+ }
465
+ }
466
+
467
+ // The main session model. Haiku is left out on purpose: a haiku-only 1M profile would otherwise
468
+ // move the main session to Haiku. claude1MTiers reports every tier, haiku included.
469
+ function claudeTier1M(profile) {
470
+ const m = profile?.model1M || {};
471
+ return m.opus ? 'opus[1m]' : m.sonnet ? 'sonnet[1m]' : m.fable ? 'fable[1m]' : null;
472
+ }
473
+
474
+ function anyTier1M(profile) {
475
+ return modelSlotsForProfile(profile).some(slot => model1MForSlot(profile, slot));
476
+ }
477
+
478
+ export function primaryModel(profile) {
479
+ const slots = modelSlotsForProfile(profile);
480
+ for (const slot of slots) {
481
+ const model = modelForSlot(profile, slot);
482
+ if (model) return model;
483
+ }
484
+ return '';
485
+ }
486
+
487
+ // Derive launcher state from activeProfiles (single source of truth).
488
+ export function computeLaunchState(cfg, port) {
489
+ const map = getActiveMap(cfg);
490
+ const pick = (t) => (hasProfile(cfg, map[t]) ? cfg.profiles[map[t]] : null);
491
+ const claude = pick('anthropic');
492
+ const codex = pick('responses');
493
+ const openai = pick('openai-chat');
494
+ const vertex = pick('vertex');
495
+ const base = `http://127.0.0.1:${port}`;
496
+
497
+ const state = {
498
+ active: Boolean(claude || codex || openai || vertex),
499
+ claude1M: claude ? claudeTier1M(claude) : null,
500
+ claude1MTiers: claude ? CLAUDE_MODEL_SLOTS.filter(slot => model1MForSlot(claude, slot)) : [],
501
+ codex1M: codex && model1MForSlot(codex, 'main') ? (primaryModel(codex) || '1000000') : null,
502
+ openai1M: openai && anyTier1M(openai) ? (primaryModel(openai) || '1000000') : null,
503
+ // host and prefix travel with the port: an account that signs in with an API key
504
+ // reaches a different host under a different prefix, and the launcher cannot guess
505
+ // either one. The defaults cover ChatGPT sign-in.
506
+ blindfold: codex?.blindfold
507
+ ? {
508
+ port: parsePort(codex.blindfoldPort) || DEFAULT_BLINDFOLD_PORT,
509
+ host: String(codex.blindfoldHost || DEFAULT_BLINDFOLD_HOST),
510
+ prefix: String(codex.blindfoldPrefix || DEFAULT_BLINDFOLD_PREFIX),
511
+ ca: paths.blindfoldCA
512
+ }
513
+ : null,
514
+ env: [],
515
+ // Variables only the Codex shim may apply. They go to a separate file because the
516
+ // `claude` shim sources the shared one, and a Codex-only proxy would capture every
517
+ // claude HTTPS call — including after a restart, when the interceptor is not running.
518
+ envCodex: []
519
+ };
520
+
521
+ if (claude) {
522
+ state.env.push(['ANTHROPIC_BASE_URL', base]);
523
+ state.env.push(['CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT', '1']);
524
+ if (state.claude1M) {
525
+ state.env.push(['ANTHROPIC_MODEL', state.claude1M]);
526
+ state.env.push(['CLAUDE_CODE_AUTO_COMPACT_WINDOW', '900000']);
527
+ }
528
+ // Claude Code reads `[1m]` per variable: if only ANTHROPIC_MODEL carries the suffix, `/model sonnet`,
529
+ // tier switches or subagent alias calls fall back to the real 200K Claude model. Set `<tier>[1m]` for
530
+ // each tier the profile enables model1M on; Claude Code then sends plain `opus`/`sonnet`/... so the proxy
531
+ // still maps by the active profile (hot profile switches need no CLI restart).
532
+ for (const tier of CLAUDE_MODEL_SLOTS) {
533
+ if (claude.model1M?.[tier]) state.env.push([`ANTHROPIC_DEFAULT_${tier.toUpperCase()}_MODEL`, `${tier}[1m]`]);
534
+ }
535
+ }
536
+ if (codex) {
537
+ // These are internal shim inputs, not Codex configuration variables.
538
+ // The installed Codex shim converts them to documented `--config` keys.
539
+ if (codex.blindfold) {
540
+ // Blindfold mode: Codex keeps its official endpoint and reaches the gateway
541
+ // through blindfold/blindfold.mjs, so no base URL override exists to report.
542
+ // NO_PROXY keeps local MCP servers off the intercept path.
543
+ const blindfoldURL = `http://127.0.0.1:${state.blindfold.port}`;
544
+ state.envCodex.push(['HTTPS_PROXY', blindfoldURL]);
545
+ state.envCodex.push(['https_proxy', blindfoldURL]);
546
+ state.envCodex.push(['NO_PROXY', '127.0.0.1,localhost']);
547
+ state.envCodex.push(['no_proxy', '127.0.0.1,localhost']);
548
+ state.envCodex.push(['CODEX_CA_CERTIFICATE', paths.blindfoldCA]);
549
+ } else {
550
+ state.env.push(['LLM_SWITCHER_CODEX_BASE_URL', `${base}/v1`]);
551
+ }
552
+ // Official names only. An upstream ID here would reach the CLI as a --config
553
+ // value and show up in its UI, which is the leak this indirection exists for.
554
+ for (const slot of CODEX_MODEL_SLOTS) {
555
+ const publicName = codexPublicModel(codex, slot);
556
+ if (isSafeModelName(publicName)) {
557
+ state.env.push([`LLM_SWITCHER_CODEX_${slot.toUpperCase()}_MODEL`, publicName]);
558
+ }
559
+ }
560
+ if (state.codex1M) {
561
+ state.env.push(['LLM_SWITCHER_CODEX_CONTEXT_WINDOW', '1000000']);
562
+ state.env.push(['LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT', '900000']);
563
+ }
564
+ }
565
+ if (openai) {
566
+ state.env.push(['OPENAI_BASE_URL', `${base}/v1`]);
567
+ if (state.openai1M) state.env.push(['OPENAI_MAX_CONTEXT_TOKENS', '1000000']);
568
+ }
569
+ return state;
570
+ }
571
+
572
+ // tmp + rename: a launcher never sources a half-written env file.
573
+ function writeAtomic(file, content) {
574
+ const tmp = `${file}.${process.pid}.tmp`;
575
+ try {
576
+ fs.writeFileSync(tmp, content, 'utf8');
577
+ fs.renameSync(tmp, file);
578
+ } catch (err) {
579
+ fs.rmSync(tmp, { force: true });
580
+ throw err;
581
+ }
582
+ }
583
+
584
+ const LAUNCH_LOCK = path.join(STATE_DIR, '.launch.lock');
585
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
586
+
587
+ const readLock = () => { try { return fs.readFileSync(LAUNCH_LOCK, 'utf8'); } catch { return null; } };
588
+
589
+ function pidAlive(pid) {
590
+ try {
591
+ process.kill(pid, 0);
592
+ return true;
593
+ } catch (err) {
594
+ return err.code === 'EPERM';
595
+ }
596
+ }
597
+
598
+ // The CLI and the gateway both write the launch files, one file at a time. Without turns, two writers
599
+ // that overlap leave env.sh from one state and env-codex.sh from another. The writes take milliseconds,
600
+ // so a holder that no longer runs, or that keeps the lock for 5 s, is taken over.
601
+ function withLaunchLock(fn) {
602
+ const mine = String(process.pid);
603
+ const tmp = `${LAUNCH_LOCK}.${process.pid}.tmp`;
604
+ fs.writeFileSync(tmp, mine, { mode: 0o600 });
605
+ try {
606
+ for (let waited = 0; ; waited += 25) {
607
+ try {
608
+ // link, not create-then-write: the lock never exists without the holder's pid in it.
609
+ fs.linkSync(tmp, LAUNCH_LOCK);
610
+ break;
611
+ } catch (err) {
612
+ if (err.code !== 'EEXIST') throw err;
613
+ const holder = readLock();
614
+ const pid = parseInt(holder, 10);
615
+ const stale = (Number.isInteger(pid) && pid > 0 && !pidAlive(pid)) || waited >= 5000;
616
+ // Remove only the lock that was judged stale; another taker may have replaced it already.
617
+ if (stale && readLock() === holder) fs.rmSync(LAUNCH_LOCK, { force: true });
618
+ else pause(25);
619
+ }
620
+ }
621
+ } finally {
622
+ fs.rmSync(tmp, { force: true });
623
+ }
624
+ try {
625
+ return fn();
626
+ } finally {
627
+ if (readLock() === mine) fs.rmSync(LAUNCH_LOCK, { force: true });
628
+ }
629
+ }
630
+
631
+ // Write env.cmd/env.sh and the flags from activeProfiles, and clean up Claude Code settings.json.
632
+ export function applyLaunchState(cfg, port, opts = {}) {
633
+ return withLaunchLock(() => writeLaunchState(cfg, port, opts));
634
+ }
635
+
636
+ function writeLaunchState(cfg, port, { cleanSettings = true } = {}) {
637
+ const st = computeLaunchState(cfg, port);
638
+
639
+ const renderCmd = (pairs) => ['@echo off', 'REM Auto-generated by LLM Switcher for active profiles',
640
+ ...pairs.map(([k, v]) => `SET "${k}=${v}"`)].join('\r\n') + '\r\n';
641
+ const renderSh = (pairs) => ['#!/usr/bin/env sh', '# Auto-generated by LLM Switcher for active profiles',
642
+ ...pairs.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, `'\\''`)}'`)].join('\n') + '\n';
643
+
644
+ // The env files come first and the flags last: a flag that says "active" while its env file is
645
+ // missing or stale makes the launcher bypass the gateway. A failed write leaves the flags as they were.
646
+ if (st.active) {
647
+ try {
648
+ writeAtomic(paths.envCmd, renderCmd(st.env));
649
+ writeAtomic(paths.envSh, renderSh(st.env));
650
+ // Written even when empty, so a stale Codex-only file from a previous profile
651
+ // can never survive a switch.
652
+ writeAtomic(paths.envCodexCmd, renderCmd(st.envCodex));
653
+ writeAtomic(paths.envCodexSh, renderSh(st.envCodex));
654
+ } catch (err) {
655
+ st.envWriteError = err.message;
656
+ return st;
657
+ }
658
+ }
659
+ writeOrRemove(paths.activeFlag, st.active ? 'active' : null);
660
+ writeOrRemove(paths.flag1M, st.claude1M);
661
+ writeOrRemove(paths.flagCodex1M, st.codex1M);
662
+ writeOrRemove(paths.flagOpenAI1M, st.openai1M);
663
+ if (!st.active) {
664
+ writeOrRemove(paths.envCmd, null);
665
+ writeOrRemove(paths.envSh, null);
666
+ writeOrRemove(paths.envCodexCmd, null);
667
+ writeOrRemove(paths.envCodexSh, null);
668
+ }
669
+
670
+ // The catalog follows the active Codex profile, so a profile switch can never
671
+ // leave the previous profile's model names on the /model screen.
672
+ const codexKey = getActiveMap(cfg).responses;
673
+ const codexProfile = hasProfile(cfg, codexKey) ? cfg.profiles[codexKey] : null;
674
+ const catalog = st.active && codexProfile ? buildCodexCatalog(codexProfile) : null;
675
+ writeOrRemove(paths.codexCatalog, catalog ? JSON.stringify(catalog) : null);
676
+
677
+ // Clean only after the env files are in place: a failed write returned above, before settings.json.
678
+ if (cleanSettings) st.settings = cleanClaudeSettings(port);
679
+ return st;
680
+ }
681
+
682
+ export function clearLaunchState(port) {
683
+ withLaunchLock(() => {
684
+ for (const f of [paths.activeFlag, paths.flag1M, paths.flagCodex1M, paths.flagOpenAI1M,
685
+ paths.envCmd, paths.envSh, paths.envCodexCmd, paths.envCodexSh, paths.codexCatalog]) {
686
+ writeOrRemove(f, null);
687
+ }
688
+ });
689
+ return cleanClaudeSettings(port);
690
+ }
691
+
692
+ export function readLaunchFlags() {
693
+ const active = fs.existsSync(paths.activeFlag);
694
+ return {
695
+ isUsingProxy: active,
696
+ is1MActive: active && fs.existsSync(paths.flag1M),
697
+ isCodex1MActive: active && fs.existsSync(paths.flagCodex1M),
698
+ isOpenAI1MActive: active && fs.existsSync(paths.flagOpenAI1M)
699
+ };
700
+ }
701
+
702
+ // settings.json belongs to Claude Code and to the user. Remove a value only when it is exactly what
703
+ // the switcher itself would write: its own base URL, or a `<tier>[1m]` alias. Everything else,
704
+ // including ANTHROPIC_AUTH_TOKEN and *_MODEL_NAME, is someone else's and stays.
705
+ function isSwitcherValue(key, value, port) {
706
+ if (key === 'ANTHROPIC_BASE_URL') {
707
+ return /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):(\d+)\/?$/.exec(String(value))?.[2] === String(port);
708
+ }
709
+ const tier = /^ANTHROPIC_DEFAULT_(OPUS|SONNET|HAIKU|FABLE)_MODEL$/.exec(key)?.[1];
710
+ return Boolean(tier) && value === `${tier.toLowerCase()}[1m]`;
711
+ }
712
+
713
+ // Only writes when a value is removed; the write goes through the path, so a symlinked
714
+ // settings.json stays a symlink and keeps its mode. Never throws.
715
+ export function cleanClaudeSettings(port) {
716
+ try {
717
+ if (!fs.existsSync(claudeSettingsPath)) return { changed: false, removed: [] };
718
+ const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
719
+ if (!settings?.env || typeof settings.env !== 'object') return { changed: false, removed: [] };
720
+ const removed = Object.keys(settings.env).filter(k => isSwitcherValue(k, settings.env[k], port));
721
+ if (!removed.length) return { changed: false, removed: [] };
722
+ for (const k of removed) delete settings.env[k];
723
+ fs.writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2), 'utf8');
724
+ return { changed: true, removed };
725
+ } catch (err) {
726
+ return { changed: false, removed: [], error: err.message };
727
+ }
728
+ }
729
+
730
+ // Hide API keys when returning config to the UI / API.
731
+ export const MASKED_KEY = '__LLM_SWITCHER_KEEP_KEY__';
732
+
733
+ export function redactConfig(cfg) {
734
+ const clone = JSON.parse(JSON.stringify(cfg || {}));
735
+ for (const p of Object.values(clone.profiles || {})) {
736
+ if (p && typeof p === 'object') {
737
+ p.hasApiKey = Boolean(p.apiKey);
738
+ p.apiKey = p.apiKey ? MASKED_KEY : '';
739
+ }
740
+ }
741
+ const lab = clone.contractLab;
742
+ if (lab && typeof lab === 'object' && !Array.isArray(lab)) {
743
+ lab.hasApiKey = Boolean(lab.apiKey);
744
+ lab.apiKey = lab.apiKey ? MASKED_KEY : '';
745
+ }
746
+ return clone;
747
+ }
748
+
749
+ // ---------------- process identity ----------------
750
+ // An answer on a port proves nothing: any local process can bind a free port and replay a /health
751
+ // body. Only a process that can read admin.token can answer HMAC(token, nonce) for a fresh nonce.
752
+ // The MAC covers role, listening port, pid and arguments: a proof relayed from the process on
753
+ // another port, or a body with an edited pid, no longer verifies. blindfold.mjs signs the same fields.
754
+ export function identityProof(nonce, { role, port, pid, gatewayPort = '', host = '', prefix = '' }, token = readAdminToken()) {
755
+ if (!token) return '';
756
+ return crypto.createHmac('sha256', token).update([role, port, pid, gatewayPort, host, prefix, nonce].join('|')).digest('hex');
757
+ }
758
+
759
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
760
+
761
+ // A busy but genuine process can take a moment; a squatter gains nothing from a longer wait.
762
+ // Settles exactly once, on every path. A pending probe holds the gateway's admin chain, so an answer
763
+ // that is too large, or one that trickles without end, must still end the probe.
764
+ function getJson(port, pathname, timeoutMs = 3000) {
765
+ return new Promise(resolve => {
766
+ let settled = false;
767
+ const done = (result) => {
768
+ if (settled) return;
769
+ settled = true;
770
+ clearTimeout(deadline);
771
+ req.destroy();
772
+ resolve(result);
773
+ };
774
+ // A listener that never answers can be a hung gateway of ours; the caller must not call it foreign.
775
+ // The socket timeout restarts on every byte, so the whole probe also has a deadline.
776
+ const deadline = setTimeout(() => done({ state: 'silent' }), timeoutMs);
777
+ const req = http.get({ host: '127.0.0.1', port, path: pathname, timeout: timeoutMs }, res => {
778
+ let data = '';
779
+ res.setEncoding('utf8');
780
+ res.on('data', c => { data += c; if (data.length > 65536) done({ state: 'foreign' }); });
781
+ res.on('end', () => {
782
+ let body = null;
783
+ try { body = JSON.parse(data); } catch {}
784
+ done({ state: 'answered', body });
785
+ });
786
+ res.on('error', () => done({ state: 'foreign' }));
787
+ });
788
+ req.on('error', err => done({ state: err.code === 'ECONNREFUSED' ? 'free' : 'foreign' }));
789
+ req.on('timeout', () => done({ state: 'silent' }));
790
+ });
791
+ }
792
+
793
+ const newNonce = () => crypto.randomBytes(16).toString('hex');
794
+
795
+ /** 'ours' | 'foreign' | 'silent' | 'free'. Treat 'silent' like 'foreign' in every decision. */
796
+ export async function probeGateway(port) {
797
+ const nonce = newNonce();
798
+ const r = await getJson(port, `/health?challenge=${nonce}`);
799
+ if (r.state !== 'answered') return r.state;
800
+ const b = r.body;
801
+ if (b?.proxy !== 'llm-switcher' || b.port !== port) return 'foreign';
802
+ const proof = identityProof(nonce, { role: 'gateway', port, pid: b.pid });
803
+ return proof && b.proof === proof ? 'ours' : 'foreign';
804
+ }
805
+
806
+ /** { state: 'ours', pid, gatewayPort, host, prefix } | { state: 'foreign' | 'silent' | 'free' } */
807
+ export async function probeBlindfold(port) {
808
+ const nonce = newNonce();
809
+ const r = await getJson(port, `/?challenge=${nonce}`);
810
+ if (r.state !== 'answered') return { state: r.state };
811
+ const b = r.body;
812
+ if (b?.proxy !== 'llm-switcher-blindfold' || b.port !== port) return { state: 'foreign' };
813
+ const proof = identityProof(nonce, { role: 'blindfold', port, pid: b.pid, gatewayPort: b.gatewayPort, host: b.host, prefix: b.prefix });
814
+ if (!proof || b.proof !== proof) return { state: 'foreign' };
815
+ return { state: 'ours', pid: b.pid, gatewayPort: b.gatewayPort, host: b.host, prefix: b.prefix };
816
+ }
817
+
818
+ // Signal a pid only right after a fresh identity probe named it.
819
+ function killVerified(pid) {
820
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return;
821
+ try {
822
+ if (process.platform === 'win32') execFileSync('taskkill', ['/F', '/PID', String(pid)], { stdio: 'ignore' });
823
+ else process.kill(pid, 'SIGTERM');
824
+ } catch {}
825
+ }
826
+
827
+ // ---------------- blindfold interceptor ----------------
828
+ // HTTPS_PROXY in env-codex.* is a hard dependency: with no interceptor behind it, Codex reaches no
829
+ // host at all. The gateway process owns the interceptor and reconcileBlindfold is the only code
830
+ // that starts one; the CLI asks the gateway through POST /api/blindfold/sync.
831
+
832
+ const blindfoldScript = path.join(ROOT_DIR, 'blindfold', 'blindfold.mjs');
833
+ export const blindfoldStatePath = path.join(path.dirname(configPath), 'blindfold.json');
834
+
835
+ function readBlindfoldState() {
836
+ try { return JSON.parse(fs.readFileSync(blindfoldStatePath, 'utf8')); } catch { return null; }
837
+ }
838
+
839
+ function writeBlindfoldState(st) {
840
+ fs.writeFileSync(blindfoldStatePath, JSON.stringify(st), { encoding: 'utf8', mode: 0o600 });
841
+ }
842
+
843
+ /** true when no interceptor of ours answers on the port any more. */
844
+ async function stopBlindfoldAt(port) {
845
+ const cur = await probeBlindfold(port);
846
+ if (cur.state !== 'ours') return true;
847
+ killVerified(cur.pid);
848
+ for (let i = 0; i < 20; i++) {
849
+ await sleep(100);
850
+ if ((await probeBlindfold(port)).state !== 'ours') return true;
851
+ }
852
+ return false;
853
+ }
854
+
855
+ /** Stop the interceptor recorded in blindfold.json, if a probe confirms it is ours. Never starts one. */
856
+ export async function stopRecordedBlindfold() {
857
+ const prev = readBlindfoldState();
858
+ const stopped = prev?.port ? await stopBlindfoldAt(prev.port) : true;
859
+ if (stopped) try { fs.unlinkSync(blindfoldStatePath); } catch {}
860
+ return { ok: stopped, ...(stopped ? {} : { error: `the interceptor on port ${prev.port} did not stop` }) };
861
+ }
862
+
863
+ /** null when the interceptor can start, otherwise the reason and the command that fixes it. */
864
+ export function blindfoldPreflight(desired) {
865
+ const certDir = path.dirname(desired.ca);
866
+ const build = `bash blindfold/make-certs.sh ${desired.host}${process.env.LLM_SWITCHER_BLINDFOLD_CERTS ? ` "${certDir}"` : ''}`;
867
+ for (const f of [desired.ca, path.join(certDir, 'leaf.pem'), path.join(certDir, 'leaf.key')]) {
868
+ if (!fs.existsSync(f)) return `Blindfold mode is on, but ${path.basename(f)} is missing in ${certDir}. Build the certificates first: ${build}`;
869
+ }
870
+ // A leaf for another host fails the TLS handshake with an error that reads like a network fault.
871
+ const leafPem = fs.readFileSync(path.join(certDir, 'leaf.pem'), 'utf8');
872
+ if (!certCoversHost(leafPem, desired.host)) {
873
+ return `The leaf certificate does not cover "${desired.host}". Rebuild it for that host: ${build}`;
874
+ }
875
+ // Files from two different builds fail the same way.
876
+ try {
877
+ const leaf = new crypto.X509Certificate(leafPem);
878
+ const ca = new crypto.X509Certificate(fs.readFileSync(desired.ca));
879
+ if (!leaf.checkIssued(ca) || !leaf.verify(ca.publicKey)) {
880
+ return `The leaf certificate was not signed by ${desired.ca}. Rebuild both: ${build}`;
881
+ }
882
+ if (!leaf.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(path.join(certDir, 'leaf.key'))))) {
883
+ return `leaf.key does not match leaf.pem in ${certDir}. Rebuild both: ${build}`;
884
+ }
885
+ } catch (err) {
886
+ return `Cannot read the certificates in ${certDir}: ${err.message}. Rebuild them: ${build}`;
887
+ }
888
+ return null;
889
+ }
890
+
891
+ const LOG_LIMIT = 10 * 1024 * 1024;
892
+
893
+ // Opens a private log for appending. A log above LOG_LIMIT moves to <file>.1 first, so the two
894
+ // files together stay near twice the limit.
895
+ export function openLog(file) {
896
+ try {
897
+ if (fs.statSync(file).size > LOG_LIMIT) fs.renameSync(file, `${file}.1`);
898
+ } catch {}
899
+ const fd = fs.openSync(file, 'a', 0o600);
900
+ try { fs.fchmodSync(fd, 0o600); } catch {}
901
+ return fd;
902
+ }
903
+
904
+ // The single place that starts an interceptor.
905
+ function spawnBlindfold(desired, gatewayPort) {
906
+ const log = openLog(paths.blindfoldLog);
907
+ const child = spawn(process.execPath, [
908
+ blindfoldScript,
909
+ '--port', String(desired.port),
910
+ '--gateway-port', String(gatewayPort),
911
+ '--host', desired.host,
912
+ '--prefix', desired.prefix,
913
+ '--certs', path.dirname(desired.ca),
914
+ '--token-file', adminTokenPath
915
+ ], { detached: true, stdio: ['ignore', log, log], windowsHide: true });
916
+ child.unref();
917
+ fs.closeSync(log);
918
+ return child.pid;
919
+ }
920
+
921
+ /** null when the interceptor that cfg asks for can run, otherwise the reason. No side effects. */
922
+ export async function checkBlindfoldTarget(cfg, gatewayPort) {
923
+ const desired = computeLaunchState(cfg, gatewayPort).blindfold;
924
+ if (!desired) return null;
925
+ const problem = blindfoldPreflight(desired);
926
+ if (problem) return problem;
927
+ const held = (await probeBlindfold(desired.port)).state;
928
+ if (held === 'foreign') return `Port ${desired.port} is held by another process, not by this switcher's interceptor.`;
929
+ if (held === 'silent') return `Port ${desired.port} accepts connections but does not answer. A hung interceptor or another program holds it.`;
930
+ return null;
931
+ }
932
+
933
+ const matches = (cur, desired, gatewayPort) =>
934
+ cur.state === 'ours' && cur.gatewayPort === gatewayPort && cur.host === desired.host && cur.prefix === desired.prefix;
935
+
936
+ /**
937
+ * Bring the interceptor in line with the saved config: start, respawn with new arguments, or stop.
938
+ * Returns { ok: true, action } or { ok: false, error }.
939
+ */
940
+ export async function reconcileBlindfold(cfg, gatewayPort) {
941
+ const desired = computeLaunchState(cfg, gatewayPort).blindfold;
942
+ const prev = readBlindfoldState();
943
+ if (!desired) {
944
+ if (prev?.port) {
945
+ const stopped = await stopRecordedBlindfold();
946
+ if (!stopped.ok) return stopped;
947
+ }
948
+ return { ok: true, action: 'none' };
949
+ }
950
+ // Validate the new interceptor before the old one is stopped: a failed change keeps Codex working.
951
+ const problem = await checkBlindfoldTarget(cfg, gatewayPort);
952
+ if (problem) return { ok: false, error: problem };
953
+ if (prev?.port && prev.port !== desired.port) {
954
+ const stopped = await stopRecordedBlindfold();
955
+ if (!stopped.ok) return stopped;
956
+ }
957
+ const cur = await probeBlindfold(desired.port);
958
+ if (matches(cur, desired, gatewayPort)) {
959
+ writeBlindfoldState({ pid: cur.pid, port: desired.port, gatewayPort, host: desired.host, prefix: desired.prefix });
960
+ return { ok: true, action: 'kept' };
961
+ }
962
+ if (cur.state === 'ours' && !(await stopBlindfoldAt(desired.port))) {
963
+ return { ok: false, error: `the interceptor on port ${desired.port} did not stop` };
964
+ }
965
+
966
+ const pid = spawnBlindfold(desired, gatewayPort);
967
+ for (let i = 0; i < 20; i++) {
968
+ await sleep(250);
969
+ const now = await probeBlindfold(desired.port);
970
+ if (matches(now, desired, gatewayPort)) {
971
+ writeBlindfoldState({ pid: now.pid, port: desired.port, gatewayPort, host: desired.host, prefix: desired.prefix });
972
+ return { ok: true, action: 'started' };
973
+ }
974
+ }
975
+ // Stop the child it started: coming up later, it would run with no record that could find it.
976
+ killVerified(pid);
977
+ return { ok: false, error: `The interceptor did not come up on port ${desired.port}. See blindfold.log.` };
978
+ }