llm-switcher 1.1.11 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
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
|
|
1212
|
+
// Assign a profile to one tool. Returns an error string or null.
|
|
414
1213
|
export function setTargetProfile(cfg, target, profileKey) {
|
|
415
|
-
|
|
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[
|
|
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,
|
|
424
|
-
return `Profile "${profileKey}" only accepts "${p.inFormat}" input and cannot serve 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[
|
|
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
|
|
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
|
|
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(
|
|
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('
|
|
504
|
-
const codex = pick('
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
//
|
|
516
|
-
|
|
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:
|
|
521
|
-
|
|
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
|
-
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
//
|
|
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
|
|
1456
|
+
function writeLaunchState(cfg, port) {
|
|
649
1457
|
const st = computeLaunchState(cfg, port);
|
|
650
1458
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
828
|
-
|
|
829
|
-
|
|
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
|
|
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))
|
|
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
|
|
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
|
|
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
|
-
|
|
887
|
-
|
|
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
|
|
1739
|
+
const args = [
|
|
922
1740
|
blindfoldScript,
|
|
923
1741
|
'--port', String(desired.port),
|
|
924
1742
|
'--gateway-port', String(gatewayPort),
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
]
|
|
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'
|
|
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,
|
|
1861
|
+
writeBlindfoldState({ pid: cur.pid, port: desired.port, gatewayPort, activeTools: desired.activeTools });
|
|
974
1862
|
return { ok: true, action: 'kept' };
|
|
975
1863
|
}
|
|
976
|
-
|
|
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,
|
|
1894
|
+
writeBlindfoldState({ pid: now.pid, port: desired.port, gatewayPort, activeTools: desired.activeTools });
|
|
986
1895
|
return { ok: true, action: 'started' };
|
|
987
1896
|
}
|
|
988
1897
|
}
|