llm-switcher 1.1.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/config.example.json +12 -34
  8. package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/docs/response-matrix.json +1130 -1130
  24. package/ensure-ca-bundle.mjs +28 -0
  25. package/formats.mjs +13 -155
  26. package/mcp.mjs +39 -11
  27. package/package.json +1 -1
  28. package/proxy.mjs +92 -27
  29. package/shim.mjs +200 -57
  30. package/skills/llm-switcher/SKILL.md +93 -88
  31. package/state.mjs +1100 -191
  32. package/switch +0 -0
  33. package/switch.cmd +2 -2
  34. package/switch.mjs +228 -53
  35. package/tests/blindfold-e2e.test.mjs +380 -0
  36. package/tests/blindfold-task5.test.mjs +429 -0
  37. package/tests/blindfold.task3.test.mjs +700 -0
  38. package/tests/blindfold.test.mjs +10 -5
  39. package/tests/catalog.test.mjs +147 -0
  40. package/tests/contract-lab.test.mjs +22 -7
  41. package/tests/formats.test.mjs +33 -46
  42. package/tests/gateway.e2e.test.mjs +136 -36
  43. package/tests/helpers.mjs +24 -24
  44. package/tests/lifecycle.test.mjs +16 -10
  45. package/tests/live-optimizer-interop.mjs +205 -205
  46. package/tests/mcp.test.mjs +78 -2
  47. package/tests/real-user-sim.test.mjs +464 -0
  48. package/tests/shim.test.mjs +159 -66
  49. package/tests/state.test.mjs +975 -193
  50. package/tests/switch.test.mjs +446 -2
  51. package/ui.html +61 -154
@@ -7,7 +7,7 @@ import net from 'node:net';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { spawn } from 'node:child_process';
10
- import { fileURLToPath } from 'node:url';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
11
 
12
12
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
13
  const POSIX = process.platform !== 'win32';
@@ -90,7 +90,7 @@ test('switch on keeps a change that another writer saved while it waited for the
90
90
  assert.ok(edited, 'the CLI started a gateway');
91
91
  const cfg = readCfg(ws);
92
92
  assert.equal(cfg.profiles.plain.name, 'Renamed while waiting');
93
- assert.equal(cfg.activeProfiles.anthropic, 'plain');
93
+ assert.equal(cfg.activeProfiles.claude, 'plain');
94
94
  });
95
95
 
96
96
  test('switch port refuses a port that another process holds and leaves the gateway where it was', { skip: !POSIX && 'posix' }, async (t) => {
@@ -171,3 +171,447 @@ test('switch codex and switch doctor warn when the Codex profile has no publicMo
171
171
  assert.doesNotMatch(named.stdout + named.stderr, /publicModels/);
172
172
  assert.doesNotMatch((await run(ws, ['doctor'])).stdout, /publicModels/);
173
173
  });
174
+
175
+ // ==================== Task 1 Tests ====================
176
+
177
+ const failCASInject = {
178
+ NODE_OPTIONS: `--import=data:text/javascript,${encodeURIComponent(`
179
+ const fs = (await import('node:fs')).default;
180
+ let c = 0;
181
+ const origRead = fs.readFileSync;
182
+ fs.readFileSync = (f, ...a) => {
183
+ const res = origRead(f, ...a);
184
+ if (typeof f === 'string' && f.endsWith('config.json') && c++ % 2 === 1) return Buffer.from(res.toString() + ' ');
185
+ return res;
186
+ };
187
+ `)}`
188
+ };
189
+
190
+ test('with migrationError set, running "switch <p>" asserts exit 1, stderr containing "Migration error", and no gateway answering on the port afterwards', { skip: !POSIX && 'posix' }, async (t) => {
191
+ const port = await freePort();
192
+ const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
193
+ const r = await run(ws, ['plain'], { env: failCASInject });
194
+ assert.equal(r.status, 1, r.stdout + r.stderr);
195
+ assert.match(r.stderr, /Migration error/);
196
+ const probe = await new Promise(res => {
197
+ const s = net.connect(port, '127.0.0.1', () => { s.destroy(); res('listening'); });
198
+ s.on('error', () => res('free'));
199
+ });
200
+ assert.equal(probe, 'free', 'no gateway answers on port');
201
+ });
202
+
203
+ test('A11: save-profile refuses command word profile keys with 400', async () => {
204
+ const { isValidProfileKey } = await import('../state.mjs');
205
+ for (const word of ['on', 'off', 'status', 'doctor', 'ui', 'claude', 'codex', 'ON', 'Claude']) {
206
+ assert.equal(isValidProfileKey(word), false, `Command word "${word}" must not be valid profile key`);
207
+ }
208
+ });
209
+
210
+ test('A5: save-profile refuses inFormat and legacy blindfold fields with 400', async () => {
211
+ const { validateProfileInput } = await import('../state.mjs');
212
+ assert.ok(validateProfileInput({ inFormat: 'auto' }), 'must reject inFormat');
213
+ assert.ok(validateProfileInput({ blindfoldPort: 3457 }), 'must reject blindfoldPort');
214
+ assert.ok(validateProfileInput({ blindfoldHost: 'chatgpt.com' }), 'must reject blindfoldHost');
215
+ assert.ok(validateProfileInput({ blindfoldPrefix: '/v1' }), 'must reject blindfoldPrefix');
216
+ assert.ok(validateProfileInput({ blindfold: true }), 'must reject blindfold');
217
+ });
218
+
219
+ test('R7: gateway process startup and reconcile refuse while getMigrationCollision is non-null', { skip: !POSIX && 'posix' }, async (t) => {
220
+ const port = await freePort();
221
+ const ws = workspace(t, port);
222
+ // Write a colliding config into workspace
223
+ const collidingCfg = {
224
+ port,
225
+ activeProfile: 'p',
226
+ profiles: {
227
+ p: { inFormat: 'auto', defaultModels: { opus: 'x', main: 'y' } },
228
+ 'p-codex': { tool: 'codex' }
229
+ }
230
+ };
231
+ fs.writeFileSync(ws.cfgPath, JSON.stringify(collidingCfg, null, 2));
232
+ const r = await run(ws, ['on', 'p']);
233
+ assert.notEqual(r.status, 0, r.stdout);
234
+ assert.match(r.stderr, /collision/i);
235
+ });
236
+
237
+ // ==================== Task 5 ====================
238
+
239
+ // A gateway started straight (not through `switch`) is what the service and the tests do, and it
240
+ // has to refuse every route that would rewrite a config.json it cannot rewrite. Returns its log.
241
+ async function startGateway(t, dir, cfgPath, port, extraEnv = {}) {
242
+ const child = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], {
243
+ env: {
244
+ ...process.env, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir,
245
+ CLAUDE_CONFIG_DIR: path.join(dir, 'claude'), LLM_SWITCHER_PORT: '', PORT: '',
246
+ ...extraEnv
247
+ },
248
+ stdio: ['ignore', 'pipe', 'pipe']
249
+ });
250
+ let log = '';
251
+ child.stdout.on('data', d => { log += d; });
252
+ child.stderr.on('data', d => { log += d; });
253
+ t.after(() => { if (child.exitCode === null) child.kill('SIGKILL'); });
254
+ for (let i = 0; i < 200; i++) {
255
+ try { if ((await fetch(`http://127.0.0.1:${port}/health`)).ok) return log; } catch { /* not listening yet */ }
256
+ await new Promise(res => setTimeout(res, 50));
257
+ }
258
+ throw new Error(`gateway did not start: ${log}`);
259
+ }
260
+
261
+ // The token file is written once the gateway has decided what it serves, so wait for it instead
262
+ // of racing it.
263
+ async function waitForToken(dir) {
264
+ for (let i = 0; i < 200; i++) {
265
+ try {
266
+ const t = fs.readFileSync(path.join(dir, 'admin.token'), 'utf8').trim();
267
+ if (t) return t;
268
+ } catch { /* not written yet */ }
269
+ await new Promise(res => setTimeout(res, 50));
270
+ }
271
+ throw new Error('admin.token was never written');
272
+ }
273
+
274
+ test('calling /api/switch, /api/toggle, /api/save-profile, /api/delete-profile with collision present returns HTTP 409 with clashing keys and leaves config.json bytes identical before and after', async (t) => {
275
+ const port = await freePort();
276
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-409-'));
277
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
278
+ const cfgPath = path.join(dir, 'config.json');
279
+ // `p` would split into p-claude and p-codex on migration, and p-codex already exists: the two
280
+ // pointers disagree about who owns that name.
281
+ const colliding = {
282
+ port,
283
+ activeProfile: 'p',
284
+ profiles: {
285
+ p: { name: 'P', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x', main: 'y' } },
286
+ 'p-codex': { name: 'P codex', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'y' } }
287
+ }
288
+ };
289
+ fs.writeFileSync(cfgPath, JSON.stringify(colliding, null, 2), { mode: 0o600 });
290
+ const before = fs.readFileSync(cfgPath);
291
+ await startGateway(t, dir, cfgPath, port);
292
+ const token = await waitForToken(dir);
293
+ const headers = { 'Content-Type': 'application/json', 'x-llm-switcher-token': token };
294
+ const calls = [
295
+ ['/api/switch', { target: 'claude', profile: 'p' }],
296
+ ['/api/toggle', { target: 'claude', enabled: true }],
297
+ ['/api/save-profile', { key: 'brand-new', profile: { name: 'N', mode: 'convert', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: {} } }],
298
+ ['/api/delete-profile', { key: 'p' }]
299
+ ];
300
+ for (const [p, body] of calls) {
301
+ const r = await fetch(`http://127.0.0.1:${port}${p}`, { method: 'POST', headers, body: JSON.stringify(body) });
302
+ assert.equal(r.status, 409, `${p} must be refused, got ${r.status}`);
303
+ const j = await r.json();
304
+ assert.equal(j.error, 'Migration collision', `${p} says what is wrong`);
305
+ assert.ok(Array.isArray(j.clashingKeys) && j.clashingKeys.length > 0, `${p} names the clashing keys`);
306
+ }
307
+ assert.ok(fs.readFileSync(cfgPath).equals(before), 'config.json is byte-identical after every refused change');
308
+ });
309
+
310
+ // ==================== Task 5: collision mode, turnOff, doctor ====================
311
+
312
+ // A config that collides on migration: `p` serves both tools, so migration would split it into
313
+ // `p-claude` and `p-codex`, and `p-codex` already exists. It gets a blindfold port of its own, so a
314
+ // probe from this workspace can never reach the interceptor of another install on the machine.
315
+ async function collisionWs(t, extra = {}) {
316
+ const port = await freePort();
317
+ const ws = workspace(t, port);
318
+ const bfPort = await freePort();
319
+ fs.writeFileSync(ws.cfgPath, JSON.stringify({
320
+ port,
321
+ activeProfile: 'p',
322
+ blindfold: { port: bfPort },
323
+ activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null, ...(extra.activeProfiles || {}) },
324
+ ...(extra.top || {}),
325
+ profiles: {
326
+ p: { name: 'P', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x', main: 'y' } },
327
+ 'p-codex': { name: 'P codex', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'y' } },
328
+ claudeOnly: { name: 'C', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x' } },
329
+ ...(extra.profiles || {})
330
+ }
331
+ }, null, 2), { mode: 0o600 });
332
+ return { ...ws, port, bfPort };
333
+ }
334
+
335
+ // Every mutating command has to refuse before it stops or starts anything. These five are the
336
+ // surface the collision rules name (R7).
337
+ const R7_REFUSALS = [
338
+ ['R7: during collision, gateway start exits non-zero', ['on', 'p']],
339
+ ['R7: during collision, switch on exits non-zero', ['on']],
340
+ ['R7: during collision, switch <p> exits non-zero', ['p']],
341
+ ['R7: during collision, switch claude exits non-zero', ['claude', 'p']],
342
+ ['R7: during collision, switch codex exits non-zero', ['codex', 'p']]
343
+ ];
344
+ for (const [name, args] of R7_REFUSALS) {
345
+ test(name, async (t) => {
346
+ const ws = await collisionWs(t);
347
+ const before = fs.readFileSync(ws.cfgPath);
348
+ const r = await run(ws, args);
349
+ assert.notEqual(r.status, 0, `${args.join(' ')} must exit non-zero:\n${r.stdout}`);
350
+ assert.match(r.stderr, /collision/i);
351
+ assert.ok(fs.readFileSync(ws.cfgPath).equals(before), 'config.json untouched');
352
+ const probe = await new Promise(res => {
353
+ const s = net.connect(ws.port, '127.0.0.1', () => { s.destroy(); res('listening'); });
354
+ s.on('error', () => res('free'));
355
+ });
356
+ assert.equal(probe, 'free', 'no gateway was started');
357
+ });
358
+ }
359
+
360
+ // The user asked for it and the build still has to honour it: nothing is refused, only rewritten
361
+ // through the CAS writer (R7b). Exit 0 is the whole point — a broken migration must not turn
362
+ // "switch off" into an error the user cannot act on.
363
+ test('R7: during collision, bare switch off exits 0', async (t) => {
364
+ const ws = await collisionWs(t);
365
+ const r = await run(ws, ['off']);
366
+ assert.equal(r.status, 0, r.stdout + r.stderr);
367
+ const cfg = readCfg(ws);
368
+ assert.ok(Object.hasOwn(cfg.activeProfiles, 'claude') && cfg.activeProfiles.claude === null);
369
+ assert.ok(Object.hasOwn(cfg.activeProfiles, 'codex') && cfg.activeProfiles.codex === null);
370
+ });
371
+
372
+ test('bare switch off during collision writes both new keys as null, clears launch state, stops gateway, and exits 0', async (t) => {
373
+ const ws = await collisionWs(t);
374
+ await startGateway(t, ws.dir, ws.cfgPath, ws.port);
375
+ fs.writeFileSync(path.join(ws.dir, 'active.flag'), 'active');
376
+ fs.writeFileSync(path.join(ws.dir, 'env-claude.sh'), "export HTTPS_PROXY='http://127.0.0.1:1'\n");
377
+
378
+ const r = await run(ws, ['off']);
379
+ assert.equal(r.status, 0, r.stdout + r.stderr);
380
+
381
+ const cfg = readCfg(ws);
382
+ assert.equal(cfg.activeProfiles.claude, null, 'claude pointer cleared');
383
+ assert.equal(cfg.activeProfiles.codex, null, 'codex pointer cleared');
384
+ assert.equal(cfg.activeProfile, 'p', 'the legacy pointer this build will not rewrite stays');
385
+ assert.equal(fs.existsSync(path.join(ws.dir, 'active.flag')), false, 'launch state cleared');
386
+ assert.equal(fs.readFileSync(path.join(ws.dir, 'env-claude.sh'), 'utf8'), '', 'env files emptied');
387
+
388
+ let up = false;
389
+ try { up = (await fetch(`http://127.0.0.1:${ws.port}/health`)).ok; } catch { /* already gone */ }
390
+ assert.equal(up, false, 'the gateway was stopped');
391
+ });
392
+
393
+ test('R7b: during collision, switch off codex writes only activeProfiles.codex=null, empties env-codex.sh, and keeps old pointer keys byte-equal', async (t) => {
394
+ const ws = await collisionWs(t);
395
+ const before = readCfg(ws);
396
+ fs.writeFileSync(path.join(ws.dir, 'env-codex.sh'), "export HTTPS_PROXY='codex'\n");
397
+ fs.writeFileSync(path.join(ws.dir, 'env-claude.sh'), "export HTTPS_PROXY='claude'\n");
398
+
399
+ const r = await run(ws, ['off', 'codex']);
400
+ assert.equal(r.status, 0, r.stdout + r.stderr);
401
+
402
+ const after = readCfg(ws);
403
+ assert.equal(after.activeProfiles.codex, null, 'only the codex pointer was cleared');
404
+ assert.ok(!Object.hasOwn(after.activeProfiles, 'claude'), 'no claude pointer was written');
405
+ for (const k of ['anthropic', 'responses', 'openai-chat', 'vertex']) {
406
+ assert.equal(after[k], before[k], `top-level ${k} byte-equal`);
407
+ assert.equal(after.activeProfiles[k], before.activeProfiles[k], `activeProfiles.${k} byte-equal`);
408
+ }
409
+ assert.equal(after.activeProfile, before.activeProfile, 'activeProfile byte-equal');
410
+ assert.deepEqual(after.profiles, before.profiles, 'profiles byte-equal');
411
+ assert.equal(fs.readFileSync(path.join(ws.dir, 'env-codex.sh'), 'utf8'), '', 'env-codex.sh emptied');
412
+ assert.equal(fs.readFileSync(path.join(ws.dir, 'env-claude.sh'), 'utf8'), "export HTTPS_PROXY='claude'\n", 'the other tool keeps its env file');
413
+ });
414
+
415
+ // The writer must take its bytes from the file, not from the CLI's cache: the cached copy of a
416
+ // colliding config is the one migration would have produced, and writing that would rewrite a file
417
+ // this build has refused to rewrite.
418
+ test('R2: collision-mode switch off <tool> re-reads config from disk and writes via CAS keeping seeded old pointer keys byte-equal at top level and inside activeProfiles (anthropic, responses, openai-chat, vertex)', async (t) => {
419
+ const ws = await collisionWs(t, {
420
+ top: { anthropic: 'z-ant-top', responses: 'z-res-top', 'openai-chat': 'z-oai-top', vertex: 'z-vert-top' },
421
+ activeProfiles: { anthropic: 'z-ant', responses: 'z-res', 'openai-chat': 'z-oai', vertex: 'z-vert' }
422
+ });
423
+ const before = readCfg(ws);
424
+
425
+ const r = await run(ws, ['off', 'claude']);
426
+ assert.equal(r.status, 0, r.stdout + r.stderr);
427
+
428
+ const after = readCfg(ws);
429
+ assert.ok(Object.hasOwn(after.activeProfiles, 'claude') && after.activeProfiles.claude === null, 'claude cleared');
430
+ assert.ok(!Object.hasOwn(after.activeProfiles, 'codex'), 'codex untouched');
431
+ for (const k of ['anthropic', 'responses', 'openai-chat', 'vertex']) {
432
+ assert.equal(after[k], before[k], `top-level ${k} byte-equal`);
433
+ assert.equal(after.activeProfiles[k], before.activeProfiles[k], `activeProfiles.${k} byte-equal`);
434
+ }
435
+ assert.deepEqual(after.profiles, before.profiles, 'profiles byte-equal');
436
+ // The file on disk is still the unmigrated one: no p-claude was created, activeProfile survived.
437
+ assert.equal(after.activeProfile, 'p', 'still unmigrated');
438
+ assert.ok(!Object.hasOwn(after.profiles, 'p-claude'), 'no split profile was written');
439
+ });
440
+
441
+ test('R7: during collision, switch doctor exits 0', async (t) => {
442
+ const ws = await collisionWs(t);
443
+ const before = fs.readFileSync(ws.cfgPath);
444
+ const r = await run(ws, ['doctor']);
445
+ assert.equal(r.status, 0, r.stdout + r.stderr);
446
+ assert.match(r.stdout + r.stderr, /collision/i, 'the doctor names the collision instead of hiding it');
447
+ assert.ok(fs.readFileSync(ws.cfgPath).equals(before), 'doctor is read-only');
448
+ });
449
+
450
+ // A profile belongs to one tool. Being told so costs nothing and saves the user a gateway that
451
+ // accepts the request and then has nowhere to send it.
452
+ test('A6: switch codex <claude-profile> exits non-zero and changes nothing', async (t) => {
453
+ const port = await freePort();
454
+ const ws = workspace(t, port, {
455
+ claudeOnly: { name: 'C', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x' } },
456
+ codexOnly: { name: 'K', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'y' } }
457
+ });
458
+ // The first load rewrites a legacy config on its own. Migrate once here so the assertion below
459
+ // measures this command and nothing else.
460
+ await run(ws, ['status']);
461
+ const before = fs.readFileSync(ws.cfgPath);
462
+ const r = await run(ws, ['codex', 'claudeOnly']);
463
+ assert.notEqual(r.status, 0, r.stdout);
464
+ assert.match(r.stderr, /\[Error\]/);
465
+ assert.ok(fs.readFileSync(ws.cfgPath).equals(before), 'config.json untouched');
466
+ const probe = await new Promise(res => {
467
+ const s = net.connect(port, '127.0.0.1', () => { s.destroy(); res('listening'); });
468
+ s.on('error', () => res('free'));
469
+ });
470
+ assert.equal(probe, 'free', 'no gateway was started');
471
+ });
472
+
473
+ // --- migrationError mode: the same CAS writer, because saveConfig refuses here too (R1) ---
474
+
475
+ // The burst variant stops patching the moment the load sets migrationError, so the load fails its CAS
476
+ // while `switch off` gets clean bytes and can write. `failCASInject` below never stops, which is the
477
+ // other half of the rule: three failures and the command gives up without touching anything.
478
+ const failCASThenClean = {
479
+ NODE_OPTIONS: `--import=data:text/javascript,${encodeURIComponent(`
480
+ const fs = (await import('node:fs')).default;
481
+ const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
482
+ const origRead = fs.readFileSync;
483
+ fs.readFileSync = (f, ...a) => {
484
+ const res = origRead(f, ...a);
485
+ const bad = typeof f === 'string' && f.endsWith('config.json') && !s.getMigrationError();
486
+ let c = fs.__c = (fs.__c || 0) + 1;
487
+ if (bad && c % 2 === 0) return Buffer.from(res.toString() + ' ');
488
+ return res;
489
+ };
490
+ `)}`
491
+ };
492
+
493
+ test('with migrationError set, running "switch off <tool>" uses CAS setting activeProfiles.<tool>=null, empties env-<tool>.sh, syncs interceptor, and exits 0', async (t) => {
494
+ const port = await freePort();
495
+ const ws = workspace(t, port);
496
+ fs.writeFileSync(ws.cfgPath, JSON.stringify({
497
+ port,
498
+ activeProfile: 'plain',
499
+ blindfold: { port: await freePort() },
500
+ activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
501
+ profiles: { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
502
+ }, null, 2), { mode: 0o600 });
503
+ fs.writeFileSync(path.join(ws.dir, 'env-claude.sh'), "export HTTPS_PROXY='x'\n");
504
+ const before = fs.readFileSync(ws.cfgPath);
505
+
506
+ const r = await run(ws, ['off', 'claude'], { env: failCASThenClean });
507
+ assert.equal(r.status, 0, r.stdout + r.stderr);
508
+
509
+ const cfg = readCfg(ws);
510
+ assert.ok(!cfg.activeProfiles.claude, 'activeProfiles.claude cleared');
511
+ assert.equal(cfg.activeProfile, 'plain', 'the legacy pointer this build will not rewrite stays');
512
+ assert.equal(fs.readFileSync(path.join(ws.dir, 'env-claude.sh'), 'utf8'), '', 'env-claude.sh emptied');
513
+ assert.ok(!before.equals(fs.readFileSync(ws.cfgPath)), 'the file did change');
514
+ });
515
+
516
+ test('R1: bare switch off under migrationError writes both pointers null through the CAS writer; after 3 forced CAS failures it exits 1 and the gateway still answers', async (t) => {
517
+ const port = await freePort();
518
+ const ws = workspace(t, port);
519
+ fs.writeFileSync(ws.cfgPath, JSON.stringify({
520
+ port,
521
+ activeProfile: 'plain',
522
+ blindfold: { port: await freePort() },
523
+ activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
524
+ profiles: { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
525
+ }, null, 2), { mode: 0o600 });
526
+ await startGateway(t, ws.dir, ws.cfgPath, port, failCASInject);
527
+ fs.writeFileSync(path.join(ws.dir, 'active.flag'), 'active');
528
+ const before = fs.readFileSync(ws.cfgPath);
529
+
530
+ const r = await run(ws, ['off'], { env: failCASInject });
531
+ assert.equal(r.status, 1, r.stdout + r.stderr);
532
+ assert.match(r.stderr, /config\.json/i, 'the failure is named on stderr');
533
+ assert.ok(fs.readFileSync(ws.cfgPath).equals(before), 'config.json untouched after three failures');
534
+ assert.equal(fs.existsSync(path.join(ws.dir, 'active.flag')), true, 'launch state was not cleared');
535
+
536
+ let up = false;
537
+ try { up = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* down */ }
538
+ assert.equal(up, true, 'the gateway still answers: nothing was torn down on a failed write');
539
+ });
540
+
541
+ test('R7e: CLI mutating command exits non-zero with error on stderr when saveConfig refuses under getMigrationError', async (t) => {
542
+ const port = await freePort();
543
+ const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
544
+ const r = await run(ws, ['on', 'plain'], { env: failCASInject });
545
+ assert.equal(r.status, 1, r.stdout + r.stderr);
546
+ assert.match(r.stderr, /Migration error/);
547
+ const probe = await new Promise(res => {
548
+ const s = net.connect(port, '127.0.0.1', () => { s.destroy(); res('listening'); });
549
+ s.on('error', () => res('free'));
550
+ });
551
+ assert.equal(probe, 'free', 'the refusal happens before any gateway starts');
552
+ });
553
+
554
+ test('R1: port-change path exits 1 before stopProxy is called under migrationError', async (t) => {
555
+ const port = await freePort();
556
+ const other = await freePort();
557
+ const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
558
+ await startGateway(t, ws.dir, ws.cfgPath, port, failCASInject);
559
+
560
+ const r = await run(ws, ['port', String(other)], { env: failCASInject });
561
+ assert.equal(r.status, 1, r.stdout + r.stderr);
562
+ assert.match(r.stderr, /Migration error/);
563
+ assert.equal(readCfg(ws).port, port, 'the port is unchanged');
564
+
565
+ let up = false;
566
+ try { up = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* down */ }
567
+ assert.equal(up, true, 'the old gateway was never stopped');
568
+ });
569
+
570
+ // The guard that matters is the second read: config.json parsed when the CLI started and stopped
571
+ // parsing while it waited for the gateway.
572
+ test('R7e: parse guard blocks writes and exits non-zero when lastLoadError is set', async (t) => {
573
+ const port = await freePort();
574
+ const ws = workspace(t, port);
575
+ fs.writeFileSync(ws.cfgPath, JSON.stringify({
576
+ port,
577
+ blindfold: { port: await freePort() },
578
+ activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
579
+ profiles: { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
580
+ }, null, 2), { mode: 0o600 });
581
+ const before = fs.readFileSync(ws.cfgPath);
582
+ const corrupt = Buffer.from('{"port": ');
583
+ let broken = false;
584
+ const r = await run(ws, ['on', 'plain'], {
585
+ onLine: (line) => {
586
+ if (broken || !line.startsWith('Activating profile')) return;
587
+ broken = true;
588
+ fs.writeFileSync(ws.cfgPath, corrupt);
589
+ }
590
+ });
591
+ assert.ok(broken, 'the CLI reached the point where it re-reads config.json');
592
+ assert.notEqual(r.status, 0, r.stdout);
593
+ assert.match(r.stderr, /does not parse/);
594
+ assert.ok(fs.readFileSync(ws.cfgPath).equals(corrupt), 'the guard did not rewrite the broken file');
595
+ // Put it back so the workspace teardown can stop the gateway this run started.
596
+ fs.writeFileSync(ws.cfgPath, before);
597
+ await run(ws, ['off']);
598
+ });
599
+
600
+ test('R7e: saves work again after the file is fixed', async (t) => {
601
+ const port = await freePort();
602
+ const ws = workspace(t, port, { plain: { inFormat: 'anthropic' } });
603
+ fs.writeFileSync(ws.cfgPath, '{"port": ');
604
+ const broken = await run(ws, ['plain']);
605
+ assert.notEqual(broken.status, 0, broken.stdout);
606
+ assert.match(broken.stderr, /Cannot load|does not parse/);
607
+
608
+ fs.writeFileSync(ws.cfgPath, JSON.stringify({
609
+ port,
610
+ activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
611
+ profiles: { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
612
+ }, null, 2), { mode: 0o600 });
613
+
614
+ const fixed = await run(ws, ['off', 'claude']);
615
+ assert.equal(fixed.status, 0, fixed.stdout + fixed.stderr);
616
+ assert.ok(readCfg(ws).profiles.plain, 'the file writes again');
617
+ });