llm-switcher 1.1.11 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/classifier.mjs +238 -0
  8. package/config.example.json +12 -34
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/ensure-ca-bundle.mjs +28 -0
  24. package/formats.mjs +43 -156
  25. package/icons/antigravity.png +0 -0
  26. package/icons/claude.png +0 -0
  27. package/icons/codex.png +0 -0
  28. package/icons/deepseek.png +0 -0
  29. package/icons/gemini.png +0 -0
  30. package/icons/github.png +0 -0
  31. package/icons/groq.png +0 -0
  32. package/icons/intact.svg +1 -0
  33. package/icons/ollama.png +0 -0
  34. package/icons/openai.png +0 -0
  35. package/icons/openrouter.png +0 -0
  36. package/icons/qwen.png +0 -0
  37. package/icons/vertex.png +0 -0
  38. package/mcp.mjs +39 -11
  39. package/package.json +1 -1
  40. package/proxy.mjs +114 -37
  41. package/shim.mjs +200 -57
  42. package/skills/llm-switcher/SKILL.md +15 -10
  43. package/state.mjs +1100 -191
  44. package/switch.cmd +2 -2
  45. package/switch.mjs +228 -53
  46. package/tests/blindfold-e2e.test.mjs +380 -0
  47. package/tests/blindfold-task5.test.mjs +429 -0
  48. package/tests/blindfold.task3.test.mjs +700 -0
  49. package/tests/blindfold.test.mjs +10 -5
  50. package/tests/catalog.test.mjs +147 -0
  51. package/tests/classifier.test.mjs +210 -0
  52. package/tests/contract-lab.test.mjs +22 -7
  53. package/tests/formats.test.mjs +63 -46
  54. package/tests/gateway.e2e.test.mjs +136 -36
  55. package/tests/lifecycle.test.mjs +16 -10
  56. package/tests/mcp.test.mjs +78 -2
  57. package/tests/real-user-sim.test.mjs +464 -0
  58. package/tests/shim.test.mjs +159 -66
  59. package/tests/state.test.mjs +975 -193
  60. package/tests/switch.test.mjs +446 -2
  61. package/ui.html +1710 -1726
@@ -4,13 +4,17 @@ import assert from 'node:assert/strict';
4
4
  import {
5
5
  getActiveMap, setTargetProfile, activateProfile, deactivateProfile, deleteProfile,
6
6
  computeLaunchState, findProfileKey, isValidProfileKey, resolvePort, redactConfig, MASKED_KEY,
7
- modelSlotsForProfile, modelForSlot, primaryModel, codexPublicModel, buildCodexCatalog,
8
- certCoversHost, blindfoldPreflight, ROOT_DIR, openLog, probeGateway, isSafeModelName
7
+ modelSlotsForProfile, modelForSlot, primaryModel, codexPublicModel,
8
+ certCoversHost, blindfoldPreflight, ROOT_DIR, openLog, probeGateway, isSafeModelName,
9
+ getMigrationCollision, getMigrationError, getConfigLoadError, saveConfig, loadConfig,
10
+ migrateConfigInMemory, saveConfigAtomicCAS, TOOLS, validateProfileInput, needsMigration
9
11
  } from '../state.mjs';
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
12
14
  import os from 'node:os';
15
+ import crypto from 'node:crypto';
13
16
  import { execFileSync, execFile as execFileCb } from 'node:child_process';
17
+ import { pathToFileURL } from 'node:url';
14
18
 
15
19
  const makeCfg = () => ({
16
20
  port: 4000,
@@ -26,57 +30,68 @@ const makeCfg = () => ({
26
30
  });
27
31
 
28
32
  test('legacy config without activeProfiles maps every target to activeProfile', () => {
29
- assert.deepEqual(getActiveMap(makeCfg()), { anthropic: 'router', responses: 'router', 'openai-chat': 'router', vertex: 'router' });
33
+ assert.deepEqual(getActiveMap(makeCfg()), { claude: 'router', codex: 'router' });
30
34
  });
31
35
 
32
- test('setTargetProfile validates target, existence (no prototype keys) and inFormat', () => {
36
+ test('setTargetProfile validates the target, existence (no prototype keys) and the tool', () => {
33
37
  const cfg = makeCfg();
34
38
  assert.match(setTargetProfile(cfg, '__proto__', 'router'), /Unknown target/);
35
39
  assert.match(setTargetProfile(cfg, 'anthropic', 'constructor'), /does not exist/);
36
40
  assert.match(setTargetProfile(cfg, 'anthropic', 'codexOnly'), /cannot serve/);
37
41
  assert.equal(setTargetProfile(cfg, 'responses', 'codexOnly'), null);
38
- assert.equal(cfg.activeProfiles.responses, 'codexOnly');
39
- assert.equal(cfg.activeProfiles.anthropic, 'router');
42
+ // `anthropic` and `responses` are spellings of the two tools, and the map holds two keys.
43
+ assert.deepEqual(cfg.activeProfiles, { claude: 'router', codex: 'codexOnly' });
40
44
  });
41
45
 
42
- test('activateProfile only assigns compatible targets; deactivateProfile leaves others alone', () => {
46
+ test('activateProfile only assigns compatible tools; deactivateProfile leaves the other alone', () => {
43
47
  const cfg = makeCfg();
44
- setTargetProfile(cfg, 'anthropic', null);
48
+ setTargetProfile(cfg, 'claude', null);
45
49
  activateProfile(cfg, 'codexOnly');
46
- assert.deepEqual(cfg.activeProfiles, { anthropic: null, responses: 'codexOnly', 'openai-chat': 'router', vertex: 'router' });
47
- deactivateProfile(cfg, 'router');
48
- assert.deepEqual(cfg.activeProfiles, { anthropic: null, responses: 'codexOnly', 'openai-chat': null, vertex: null });
50
+ assert.deepEqual(cfg.activeProfiles, { claude: null, codex: 'codexOnly' });
51
+ deactivateProfile(cfg, 'codexOnly');
52
+ assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null });
49
53
  });
50
54
 
51
55
  test('deleteProfile unassigns targets that pointed at it', () => {
52
56
  const cfg = makeCfg();
53
57
  activateProfile(cfg, 'codexOnly');
54
58
  assert.equal(deleteProfile(cfg, 'codexOnly'), null);
55
- assert.equal(cfg.activeProfiles.responses, null);
59
+ assert.equal(cfg.activeProfiles.codex, null);
56
60
  assert.equal(cfg.activeProfile, 'router');
57
61
  });
58
62
 
59
- test('computeLaunchState derives flags per target profile, not from one global profile', () => {
63
+ test('computeLaunchState derives launch state per target profile, not from one global profile', () => {
60
64
  const cfg = makeCfg();
61
65
  setTargetProfile(cfg, 'responses', 'codexOnly');
62
66
  const st = computeLaunchState(cfg, 4000);
63
67
  assert.equal(st.active, true);
64
- assert.equal(st.claude1M, 'sonnet[1m]');
65
- assert.equal(st.codex1M, 'gpt-main');
66
- const env = Object.fromEntries(st.env);
67
- assert.equal(env.ANTHROPIC_BASE_URL, 'http://127.0.0.1:4000');
68
- assert.equal(env.LLM_SWITCHER_CODEX_BASE_URL, 'http://127.0.0.1:4000/v1');
69
- // Upstream IDs stay server-side: these variables carry the names Codex may display.
70
- assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, undefined);
71
- assert.equal(env.LLM_SWITCHER_CODEX_REVIEW_MODEL, undefined);
72
- assert.equal(env.LLM_SWITCHER_CODEX_SUBAGENT_MODEL, undefined);
73
- assert.equal(env.LLM_SWITCHER_CODEX_CONTEXT_WINDOW, '1000000');
74
- assert.equal(env.LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT, '900000');
75
- assert.equal(env.CODEX_MODEL, undefined, 'unsupported Codex env variables must not be emitted');
68
+
69
+ const names = (pairs) => pairs.map(([k]) => k).sort();
70
+ // R2: exactly these names, each tool in its own file. Nothing here names an endpoint, a
71
+ // model or a context window, so no tool can be reconfigured and none can capture the other.
72
+ assert.deepEqual(names(st.envClaude), ['HTTPS_PROXY', 'NO_PROXY', 'https_proxy', 'no_proxy']);
73
+ assert.deepEqual(names(st.envCodex),
74
+ ['CODEX_CA_CERTIFICATE', 'HTTPS_PROXY', 'NO_PROXY', 'https_proxy', 'no_proxy']);
75
+ // The proxy is the interceptor, not the gateway itself (R3).
76
+ assert.equal(Object.fromEntries(st.envClaude).HTTPS_PROXY, 'http://127.0.0.1:3457');
77
+ assert.equal(Object.fromEntries(st.envCodex).HTTPS_PROXY, 'http://127.0.0.1:3457');
78
+ // NODE_EXTRA_CA_CERTS is decided by the shim at launch, when it knows the user's own CA (R2).
79
+ assert.ok(!names(st.envClaude).includes('NODE_EXTRA_CA_CERTS'));
80
+
81
+ // A pointer that is null leaves that tool exactly as it was before the switcher (R6).
82
+ cfg.activeProfiles = { anthropic: 'router', responses: null, 'openai-chat': null, vertex: null };
83
+ const claudeOnly = computeLaunchState(cfg, 4000);
84
+ assert.ok(claudeOnly.envClaude.length > 0, 'the active tool still gets its file');
85
+ assert.deepEqual(claudeOnly.envCodex, [], 'the inactive tool gets nothing');
86
+ assert.equal(claudeOnly.active, true);
76
87
 
77
88
  const off = makeCfg();
78
89
  off.activeProfiles = { anthropic: null, responses: null, 'openai-chat': null, vertex: null };
79
- assert.equal(computeLaunchState(off, 4000).active, false);
90
+ const idle = computeLaunchState(off, 4000);
91
+ assert.equal(idle.active, false);
92
+ assert.deepEqual(idle.envClaude, []);
93
+ assert.deepEqual(idle.envCodex, []);
94
+ assert.equal(idle.blindfold, null, 'no active tool means no interceptor to run');
80
95
  });
81
96
 
82
97
  test('Codex profiles use documented role slots (model/review_model/subagent) and read legacy keys', () => {
@@ -127,94 +142,101 @@ test('codexPublicModel maps each role to an official name, never to a slot alias
127
142
  assert.equal(codexPublicModel(bare, 'review'), '');
128
143
  });
129
144
 
130
- test('computeLaunchState hands Codex official names, not upstream IDs or slot aliases', () => {
145
+ // Codex learns its own official names from publicModels through the catalog, never through
146
+ // the environment. R2 removed every name from the launch state: official, upstream or alias.
147
+ test('computeLaunchState carries no model name into the environment, official or not', () => {
131
148
  const cfg = makeCfg();
132
149
  cfg.profiles.codexOnly.publicModels = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'];
133
150
  setTargetProfile(cfg, 'responses', 'codexOnly');
134
- const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
135
- assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, 'gpt-5.6-sol');
136
- assert.equal(env.LLM_SWITCHER_CODEX_REVIEW_MODEL, 'gpt-5.6-terra');
137
- assert.equal(env.LLM_SWITCHER_CODEX_SUBAGENT_MODEL, 'gpt-5.6-luna');
138
- const dump = JSON.stringify(env);
139
- assert.ok(!dump.includes('gpt-review'), 'upstream IDs must not reach the CLI');
140
- assert.ok(!dump.includes('gpt-sub'), 'upstream IDs must not reach the CLI');
141
-
142
- // Without publicModels the role variables are absent, so the shim adds no
143
- // --config override and Codex keeps its own official defaults.
151
+ const st = computeLaunchState(cfg, 4000);
152
+ const dump = JSON.stringify([...st.envClaude, ...st.envCodex]);
153
+ for (const leak of ['gpt-5.6-sol', 'gpt-review', 'gpt-sub', 'ag/', 'LLM_SWITCHER_CODEX_', 'ANTHROPIC_']) {
154
+ assert.ok(!dump.includes(leak), `the launch state must not carry ${leak}`);
155
+ }
156
+
157
+ // publicModels on or off makes no difference to the environment: there is no variable
158
+ // to add and none to remove, so a restart cannot leave an override behind (R6, R9).
144
159
  const bare = makeCfg();
145
160
  setTargetProfile(bare, 'responses', 'codexOnly');
146
- const bareEnv = Object.fromEntries(computeLaunchState(bare, 4000).env);
147
- assert.equal(bareEnv.LLM_SWITCHER_CODEX_REVIEW_MODEL, undefined);
148
- assert.equal(bareEnv.LLM_SWITCHER_CODEX_SUBAGENT_MODEL, undefined);
161
+ assert.deepEqual(computeLaunchState(bare, 4000).envCodex, st.envCodex);
162
+ assert.deepEqual(computeLaunchState(bare, 4000).envClaude, st.envClaude);
149
163
  });
150
164
 
151
- // Blindfold mode removes the last visible trace: without openai_base_url the CLI
152
- // stops printing "base URL is overridden" on its own /model screen.
153
- test('blindfold mode swaps the base URL override for a CONNECT proxy and a CA file', () => {
165
+ // R2/R3: every active tool reaches the gateway through the one interceptor, and each shim
166
+ // gets only the variables of R2 — never a base URL (which would make the tool print
167
+ // "base URL is overridden"), never a model name, never the other tool's CA.
168
+ test('every active tool is routed through the interceptor with only the variables of R2', () => {
154
169
  const cfg = makeCfg();
155
170
  cfg.profiles.codexOnly.publicModels = ['gpt-5.6-sol'];
156
- cfg.profiles.codexOnly.blindfold = true;
157
171
  setTargetProfile(cfg, 'responses', 'codexOnly');
158
- const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
159
-
160
- const codexEnv = Object.fromEntries(computeLaunchState(cfg, 4000).envCodex);
161
-
162
- assert.equal(env.LLM_SWITCHER_CODEX_BASE_URL, undefined,
163
- 'an openai_base_url override is exactly what makes Codex print the banner');
164
- // The proxy variables belong to Codex alone. The `claude` shim sources the shared
165
- // file, and pointing every claude HTTPS call at a Codex-only loopback port breaks
166
- // claude after any restart that does not re-run `switch`.
167
- assert.equal(env.HTTPS_PROXY, undefined, 'proxy variables must not reach the shared file');
168
- assert.equal(env.https_proxy, undefined);
169
- assert.equal(env.NO_PROXY, undefined);
172
+ const st = computeLaunchState(cfg, 4000);
173
+ const claudeEnv = Object.fromEntries(st.envClaude);
174
+ const codexEnv = Object.fromEntries(st.envCodex);
175
+
176
+ // Both shims point at the one interceptor, on its own port, not at the gateway (R3).
177
+ assert.equal(claudeEnv.HTTPS_PROXY, 'http://127.0.0.1:3457');
170
178
  assert.equal(codexEnv.HTTPS_PROXY, 'http://127.0.0.1:3457');
171
179
  assert.equal(codexEnv.https_proxy, 'http://127.0.0.1:3457');
172
- assert.match(codexEnv.NO_PROXY, /127\.0\.0\.1/, 'NO_PROXY must travel with HTTPS_PROXY');
180
+ assert.match(claudeEnv.NO_PROXY, /127\.0\.0\.1/, 'NO_PROXY must travel with HTTPS_PROXY');
181
+ assert.match(claudeEnv.no_proxy, /127\.0\.0\.1/);
182
+
183
+ // Each tool keeps its own CA variable, and neither is given the other's.
184
+ assert.equal(claudeEnv.NODE_EXTRA_CA_CERTS, undefined,
185
+ 'the shim decides the claude CA at launch, when it knows the user CA (R2)');
186
+ assert.equal(codexEnv.NODE_EXTRA_CA_CERTS, undefined);
173
187
  assert.match(codexEnv.CODEX_CA_CERTIFICATE, /blindfold[\\/]certs[\\/]ca\.pem$/);
174
- // The role names still travel: they are official and carry no URL.
175
- assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, 'gpt-5.6-sol');
188
+
189
+ // Nothing in either file names an endpoint or a model: that is the whole point of
190
+ // standing in the middle of the network instead of reconfiguring the tools (R1, R2).
191
+ for (const [label, pairs] of [['claude', st.envClaude], ['codex', st.envCodex]]) {
192
+ for (const [k, v] of pairs) {
193
+ assert.ok(!/_URL$|BASE_URL/.test(k), `${label}: ${k} would print an override banner`);
194
+ assert.ok(!/MODEL|CONTEXT|WINDOW|CLAUDE_CODE_|OPENAI_/.test(k), `${label}: ${k} names a model`);
195
+ assert.ok(typeof v === 'string' && v.length > 0, `${label}: ${k} must have a value`);
196
+ }
197
+ }
176
198
 
177
199
  // The launcher needs the port and the CA path as data, so that `switch` can start
178
200
  // the interceptor and refuse to activate when the certificate is missing.
179
- const st = computeLaunchState(cfg, 4000);
180
201
  assert.equal(st.blindfold.port, 3457);
181
202
  assert.match(st.blindfold.ca, /blindfold[\\/]certs[\\/]ca\.pem$/);
182
- // ChatGPT sign-in is the common case, so it is the default.
183
- assert.equal(st.blindfold.host, 'chatgpt.com');
184
- assert.equal(st.blindfold.prefix, '/backend-api/codex');
203
+ // R3b: host and prefix are gone — the interceptor answers on a fixed host table — so what the
204
+ // launcher hands it instead is the tool set, sorted so two runs produce the same argument.
205
+ assert.equal(st.blindfold.activeTools, 'claude,codex');
185
206
 
186
- // An API-key account talks to a different host under a different prefix. The
187
- // launcher must pass both through, otherwise that account cannot use blindfold.
207
+ // A per-profile host or prefix has no effect any more: the host table decides, and one
208
+ // interceptor serves every host in it. Migration drops the fields; launch never reads them.
188
209
  const apiKeyCfg = makeCfg();
189
210
  Object.assign(apiKeyCfg.profiles.codexOnly, {
190
- publicModels: ['gpt-5.6-sol'], blindfold: true,
211
+ publicModels: ['gpt-5.6-sol'],
191
212
  blindfoldHost: 'api.openai.com', blindfoldPrefix: '/v1'
192
213
  });
193
214
  setTargetProfile(apiKeyCfg, 'responses', 'codexOnly');
194
215
  const apiKeySt = computeLaunchState(apiKeyCfg, 4000);
195
- assert.equal(apiKeySt.blindfold.host, 'api.openai.com');
196
- assert.equal(apiKeySt.blindfold.prefix, '/v1');
216
+ assert.equal(apiKeySt.blindfold.activeTools, 'claude,codex');
217
+ assert.equal(apiKeySt.blindfold.host, undefined, 'the host table replaced the profile host');
218
+ assert.equal(apiKeySt.blindfold.prefix, undefined, 'the host table replaced the profile prefix');
197
219
 
198
- cfg.profiles.codexOnly.blindfoldPort = 4999;
199
- const movedState = computeLaunchState(cfg, 4000);
220
+ // R3b: the interceptor port lives at the top level of config.json, one for both tools.
221
+ const movedState = computeLaunchState({ ...cfg, blindfold: { port: 4999 } }, 4000);
200
222
  assert.equal(Object.fromEntries(movedState.envCodex).HTTPS_PROXY, 'http://127.0.0.1:4999');
223
+ assert.equal(Object.fromEntries(movedState.envClaude).HTTPS_PROXY, 'http://127.0.0.1:4999');
201
224
  assert.equal(movedState.blindfold.port, 4999);
202
225
 
203
- // Off by default: the documented base URL override stays the normal route.
204
- const plain = makeCfg();
205
- setTargetProfile(plain, 'responses', 'codexOnly');
206
- const plainState = computeLaunchState(plain, 4000);
207
- const plainEnv = Object.fromEntries(plainState.env);
208
- assert.equal(plainEnv.LLM_SWITCHER_CODEX_BASE_URL, 'http://127.0.0.1:4000/v1');
209
- assert.equal(plainEnv.HTTPS_PROXY, undefined);
210
- assert.equal(plainState.blindfold, null);
211
- assert.deepEqual(plainState.envCodex, []);
212
- });
213
-
214
- // A model name travels into env.cmd, which cmd.exe executes on every launch, and is
215
- // then re-expanded unquoted onto the shim's command line. `SET "K=V"` does not contain
216
- // a bare `&`, and a newline splits the batch file outright.
217
- test('a model name that could act as a command is dropped, not written', () => {
226
+ // With every pointer null nothing runs: no file for any shim, no interceptor (R6).
227
+ const off = makeCfg();
228
+ off.activeProfiles = { anthropic: null, responses: null, 'openai-chat': null, vertex: null };
229
+ const offState = computeLaunchState(off, 4000);
230
+ assert.deepEqual(offState.envClaude, []);
231
+ assert.deepEqual(offState.envCodex, []);
232
+ assert.equal(offState.blindfold, null);
233
+ });
234
+
235
+ // A model name used to travel into env.cmd, which cmd.exe executes on every launch, and was
236
+ // then re-expanded unquoted onto the shim's command line. The launch state now writes no name
237
+ // at all, so there is nothing left to escape; the guard that still inspects names is
238
+ // isSafeModelName, asserted with its own cases further down.
239
+ test('a model name that could act as a command is never written into a launch file', () => {
218
240
  const payloads = [
219
241
  'a" & echo pwned & rem',
220
242
  'good\nSET X=1 & echo pwned',
@@ -227,19 +249,12 @@ test('a model name that could act as a command is dropped, not written', () => {
227
249
  const cfg = makeCfg();
228
250
  cfg.profiles.codexOnly.publicModels = [payload, 'gpt-5.6-terra', 'gpt-5.6-luna'];
229
251
  setTargetProfile(cfg, 'responses', 'codexOnly');
230
- const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
231
- assert.equal(env.LLM_SWITCHER_CODEX_MAIN_MODEL, undefined,
232
- `must drop an unsafe model name: ${JSON.stringify(payload)}`);
233
- // A safe sibling is unaffected.
234
- assert.equal(env.LLM_SWITCHER_CODEX_REVIEW_MODEL, 'gpt-5.6-terra');
235
- }
236
-
237
- // Names that real providers use stay legal.
238
- for (const ok of ['gpt-5.6-sol', 'ag/gemini-3.8-flash', 'claude-haiku-4-5-20251001', 'gpt-4.1_mini', 'a.b:c']) {
239
- const cfg = makeCfg();
240
- cfg.profiles.codexOnly.publicModels = [ok];
241
- setTargetProfile(cfg, 'responses', 'codexOnly');
242
- assert.equal(Object.fromEntries(computeLaunchState(cfg, 4000).env).LLM_SWITCHER_CODEX_MAIN_MODEL, ok);
252
+ const st = computeLaunchState(cfg, 4000);
253
+ const dump = JSON.stringify([...st.envClaude, ...st.envCodex]);
254
+ assert.ok(!dump.includes(payload),
255
+ `must not write an unsafe model name: ${JSON.stringify(payload)}`);
256
+ // Even a safe sibling stays out: R2 allows no model name in either file.
257
+ assert.ok(!dump.includes('gpt-5.6-terra'));
243
258
  }
244
259
  });
245
260
 
@@ -254,72 +269,29 @@ test('a blank entry in publicModels does not shift the later roles', () => {
254
269
  assert.equal(codexPublicModel(profile, 'subagent'), 'gpt-5.6-luna', 'position must not shift');
255
270
  });
256
271
 
257
- test('the generated catalog states the 1M window only for a slot the profile marks 1M', () => {
258
- const base = {
259
- inFormat: 'responses',
260
- defaultModels: { main: 'ag/flash', review: 'ag/review', subagent: 'ag/low' }
261
- };
262
- const windowOf = (catalog, slug) => {
263
- const m = catalog.models.find(x => x.slug === slug);
264
- return [m.context_window, m.max_context_window];
265
- };
266
-
267
- const off = buildCodexCatalog({ ...base, publicModels: ['a-model'], model1M: { main: false } });
268
- assert.deepEqual(windowOf(off, 'a-model'), [272000, 872000],
269
- 'a non-1M slot keeps the official window');
270
-
271
- const on = buildCodexCatalog({ ...base, publicModels: ['a-model'], model1M: { main: true } });
272
- assert.deepEqual(windowOf(on, 'a-model'), [1000000, 1000000]);
273
-
274
- // Two slots publishing the same name deduplicate into one entry. The smaller window
275
- // wins: overstating it is what makes Codex size a session it cannot fit.
276
- const mixed = buildCodexCatalog({
277
- ...base,
278
- publicModels: ['same', 'same', 'other'],
279
- model1M: { main: true, review: false, subagent: false }
280
- });
281
- assert.deepEqual(mixed.models.map(m => m.slug), ['same', 'other']);
282
- assert.deepEqual(windowOf(mixed, 'same'), [272000, 872000],
283
- 'a name shared by a 1M and a non-1M slot must not claim 1M');
284
- });
285
-
286
- // Changing blindfoldHost without rebuilding the leaf gives a TLS failure that reads
287
- // like a network fault. The launcher compares the two and says what to run instead.
288
- // The test builds its own leaf: blindfold/certs is gitignored, and a test that returns early
289
- // on a clean checkout passes with no assertion at all.
272
+ // A leaf from an older build covers one host and fails the other two with a TLS error that reads
273
+ // like a network fault. The preflight compares the leaf against the host table and says what to
274
+ // run instead. The test builds its own leaf: blindfold/certs is gitignored, and a test that
275
+ // returns early on a clean checkout passes with no assertion at all.
290
276
  test('certCoversHost reads the leaf subject alternative names', (t) => {
291
277
  if (process.platform === 'win32' || !fs.existsSync('/usr/bin/openssl')) return t.skip('needs bash and openssl to build a leaf');
292
278
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-leaf-'));
293
279
  t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
280
+ // The host argument of an older command is accepted and then ignored: R3 fixed the host table.
294
281
  execFileSync('bash', [path.join(ROOT_DIR, 'blindfold', 'make-certs.sh'), 'chatgpt.com', path.join(dir, 'certs')], { stdio: 'ignore' });
295
282
  const pem = fs.readFileSync(path.join(dir, 'certs', 'leaf.pem'), 'utf8');
283
+ // One leaf names the three hosts of the table and nothing else. No wildcard: the interceptor
284
+ // presents this same certificate whichever of the three the tool dialled, and the CA may only
285
+ // sign for these names.
286
+ assert.equal(certCoversHost(pem, 'api.anthropic.com'), true);
287
+ assert.equal(certCoversHost(pem, 'api.openai.com'), true, 'one leaf covers every host of the table');
296
288
  assert.equal(certCoversHost(pem, 'chatgpt.com'), true);
297
- assert.equal(certCoversHost(pem, 'sub.chatgpt.com'), true, 'the wildcard entry must count');
298
- assert.equal(certCoversHost(pem, 'api.openai.com'), false);
289
+ assert.equal(certCoversHost(pem, 'sub.chatgpt.com'), false, 'no wildcard: only the hosts of the table');
290
+ assert.equal(certCoversHost(pem, 'evil.test'), false);
299
291
  assert.equal(certCoversHost(pem, ''), false);
300
292
  assert.equal(certCoversHost('not a certificate', 'chatgpt.com'), false);
301
293
  });
302
294
 
303
- test('buildCodexCatalog lists only official slugs, deduplicated, with no slot alias', () => {
304
- const profile = {
305
- inFormat: 'responses',
306
- publicModels: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-sol'],
307
- defaultModels: { main: 'ag/flash', review: 'ag/review', subagent: 'ag/low' }
308
- };
309
- const catalog = buildCodexCatalog(profile);
310
- assert.deepEqual(catalog.models.map(m => m.slug), ['gpt-5.6-sol', 'gpt-5.6-terra']);
311
- assert.ok(catalog.models.every(m => m.display_name === m.slug));
312
- assert.ok(catalog.models.every(m => m.model_messages?.instructions_template),
313
- 'each entry keeps the official instructions so Codex does not fall back to full context');
314
- const dump = JSON.stringify(catalog);
315
- for (const leak of ['"main"', '"review"', '"subagent"', 'ag/', 'llm-switcher']) {
316
- assert.ok(!dump.includes(leak), `catalog must not leak ${leak}`);
317
- }
318
-
319
- assert.equal(buildCodexCatalog({ inFormat: 'responses' }), null,
320
- 'no publicModels means no catalog file, so Codex uses its built-in one');
321
- });
322
-
323
295
  test('openai-chat and vertex profiles use a single default slot with legacy fallback', () => {
324
296
  assert.deepEqual(modelSlotsForProfile({ inFormat: 'openai-chat' }), ['default']);
325
297
  assert.deepEqual(modelSlotsForProfile({ inFormat: 'vertex' }), ['default']);
@@ -330,20 +302,22 @@ test('openai-chat and vertex profiles use a single default slot with legacy fall
330
302
  assert.equal(primaryModel(legacy), 's-old');
331
303
  });
332
304
 
333
- test('computeLaunchState tags [1m] per Claude tier from the profile model1M map, nothing hard-coded', () => {
305
+ // The switcher used to write ANTHROPIC_MODEL / ANTHROPIC_DEFAULT_*_MODEL / CLAUDE_CODE_* to
306
+ // carry the [1m] tag. R2 removed all of it: the launch state names no model at all, and a
307
+ // stale [1m] that a user's own environment still carries is scrubbed by the shim (R8).
308
+ test('the launch state writes no 1M tag or model variable, whatever model1M says', () => {
334
309
  const cfg = makeCfg();
335
- cfg.profiles.router.model1M = { opus: true, sonnet: false, fable: true };
336
- const env = Object.fromEntries(computeLaunchState(cfg, 4000).env);
337
- assert.equal(env.ANTHROPIC_MODEL, 'opus[1m]');
338
- assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'opus[1m]');
339
- assert.equal(env.ANTHROPIC_DEFAULT_FABLE_MODEL, 'fable[1m]');
340
- assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, undefined);
341
- assert.equal(env.ANTHROPIC_DEFAULT_HAIKU_MODEL, undefined);
342
- assert.equal(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
343
-
344
- cfg.profiles.router.model1M = {};
345
- const none = Object.fromEntries(computeLaunchState(cfg, 4000).env);
346
- assert.ok(!Object.keys(none).some(k => k.startsWith('ANTHROPIC_DEFAULT_') || k === 'ANTHROPIC_MODEL' || k === 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'));
310
+ for (const model1M of [{ opus: true, sonnet: false, fable: true }, { haiku: true }, {}]) {
311
+ cfg.profiles.router.model1M = model1M;
312
+ const st = computeLaunchState(cfg, 4000);
313
+ assert.equal(st.active, true, 'the profile is active whatever it marks 1M');
314
+ for (const [k] of [...st.envClaude, ...st.envCodex]) {
315
+ assert.ok(!/ANTHROPIC|CLAUDE_CODE|OPENAI_|MODEL|1M|CONTEXT|WINDOW/.test(k),
316
+ `${k} must not be written for ${JSON.stringify(model1M)}`);
317
+ }
318
+ // The interceptor is still described, because `switch` has to start it.
319
+ assert.ok(st.blindfold, 'the interceptor still runs while a tool is active');
320
+ }
347
321
  });
348
322
 
349
323
  test('helpers: case-insensitive lookup, key validation, port resolution, redaction', () => {
@@ -389,7 +363,7 @@ test('saveConfig writes config.json 0600, also over an existing 0644 file', { sk
389
363
  const seen = [];
390
364
  const rename = fs.renameSync;
391
365
  fs.renameSync = (a, b) => { seen.push((fs.statSync(a).mode & 0o777).toString(8)); return rename(a, b); };
392
- const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))});
366
+ const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)});
393
367
  s.saveConfig({ profiles: {} });
394
368
  console.log(JSON.stringify({ tmp: seen[0], final: (fs.statSync(s.configPath).mode & 0o777).toString(8) }));`;
395
369
  const out = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', script], {
@@ -418,7 +392,7 @@ test('cleanClaudeSettings removes only switcher-written values and keeps the fil
418
392
  fs.chmodSync(real, 0o640);
419
393
  fs.symlinkSync(real, path.join(dir, 'settings.json'));
420
394
  const run = (port) => JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e',
421
- `const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); console.log(JSON.stringify(s.cleanClaudeSettings(${port})));`
395
+ `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); console.log(JSON.stringify(s.cleanClaudeSettings(${port})));`
422
396
  ], { env: { ...process.env, CLAUDE_CONFIG_DIR: dir }, encoding: 'utf8' }).trim());
423
397
 
424
398
  const before = fs.readFileSync(real, 'utf8');
@@ -462,10 +436,10 @@ test('the dashboard launcher keeps the admin token off the command line', { skip
462
436
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-ui-'));
463
437
  try {
464
438
  const out = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e',
465
- `const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))});
439
+ `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)});
466
440
  const token = s.ensureAdminToken();
467
441
  const file = s.writeDashboardLauncher('http://127.0.0.1:4000/ui');
468
- const fs = await import('node:fs');
442
+ const fs = (await import('node:fs')).default;
469
443
  console.log(JSON.stringify({ token, file, mode: (fs.statSync(file).mode & 0o777).toString(8), html: fs.readFileSync(file, 'utf8') }));`
470
444
  ], { env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(dir, 'config.json') }, encoding: 'utf8' }).trim());
471
445
  assert.ok(!out.file.includes(out.token), 'the path that reaches argv holds no token');
@@ -524,7 +498,7 @@ test('make-certs.sh builds a CA that can sign only for its host', (t) => {
524
498
  // Paths are computed at import, so each scenario runs state.mjs in a child with its own dirs.
525
499
  function runState(env, code) {
526
500
  const out = execFileSync(process.execPath, ['--input-type=module', '-e',
527
- `const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); const fs = await import('node:fs'); const path = await import('node:path');
501
+ `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const fs = (await import('node:fs')).default; const path = await import('node:path'); const crypto = (await import('node:crypto')).default;
528
502
  const result = await (async () => { ${code} })(); console.log(JSON.stringify(result));`],
529
503
  { env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' });
530
504
  return JSON.parse(out.trim().split('\n').pop());
@@ -553,28 +527,19 @@ test('a failed env write leaves the flags alone, so no launcher sources a missin
553
527
  assert.equal(r.active, false, 'active.flag is not written when the env files are not');
554
528
  });
555
529
 
556
- test('a haiku-only 1M profile reports its 1M tier and keeps the main session model', () => {
557
- const cfg = makeCfg();
558
- cfg.profiles.router.model1M = { haiku: true };
559
- const st = computeLaunchState(cfg, 4000);
560
- assert.equal(st.claude1M, null, 'the main session does not move to Haiku');
561
- assert.deepEqual(st.claude1MTiers, ['haiku']);
562
- assert.equal(Object.fromEntries(st.env).ANTHROPIC_DEFAULT_HAIKU_MODEL, 'haiku[1m]');
563
- });
564
-
565
530
  test('loadConfig sees a rewrite that keeps the same mtime', (t) => {
566
531
  const { dir, env } = tmpDirs(t);
567
532
  const cfgPath = path.join(dir, 'config.json');
568
533
  const r = runState(env, `
569
- const first = s.loadConfig().activeProfile;
534
+ const first = s.loadConfig().activeProfiles.claude;
570
535
  const st = fs.statSync(${JSON.stringify(cfgPath)});
571
536
  const next = JSON.parse(fs.readFileSync(${JSON.stringify(cfgPath)}, 'utf8'));
572
- next.activeProfile = 'codexOnly';
537
+ next.activeProfiles.claude = 'codexOnly';
573
538
  fs.writeFileSync(${JSON.stringify(cfgPath)} + '.new', JSON.stringify(next));
574
539
  fs.renameSync(${JSON.stringify(cfgPath)} + '.new', ${JSON.stringify(cfgPath)});
575
540
  fs.utimesSync(${JSON.stringify(cfgPath)}, st.atime, st.mtime);
576
- return { first, second: s.loadConfig().activeProfile };`);
577
- assert.equal(r.first, 'router');
541
+ return { first, second: s.loadConfig().activeProfiles.claude };`);
542
+ assert.equal(r.first, 'router-claude');
578
543
  assert.equal(r.second, 'codexOnly');
579
544
  });
580
545
 
@@ -643,7 +608,7 @@ test('launch-file writers take turns through a lock, and a dead holder does not
643
608
  const started = Date.now();
644
609
  const r = await new Promise((resolve, reject) => {
645
610
  execFileCb(process.execPath, ['--input-type=module', '-e',
646
- `const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
611
+ `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
647
612
  { env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
648
613
  });
649
614
  // Without the lock the write takes a few ms; with it the child waits for the release at 400 ms.
@@ -692,7 +657,7 @@ test('an empty launch lock is held, not taken over', async (t) => {
692
657
  setTimeout(() => fs.rmSync(lock, { force: true }), 400);
693
658
  const waited = await new Promise((resolve, reject) => {
694
659
  execFileCb(process.execPath, ['--input-type=module', '-e',
695
- `const s = await import(${JSON.stringify(path.join(ROOT_DIR, 'state.mjs'))}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
660
+ `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
696
661
  { env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
697
662
  });
698
663
  // Taken over at once, the write would take a few ms.
@@ -700,12 +665,829 @@ test('an empty launch lock is held, not taken over', async (t) => {
700
665
  assert.equal(fs.existsSync(lock), false);
701
666
  });
702
667
 
703
- // LS-5: the template copies a real OpenAI model that runs Codex in Responses Lite and code mode.
704
- // Those modes send the tools in a form made for OpenAI's own tools, so every entry turns them off.
705
- test('buildCodexCatalog entries keep Codex in its direct tool mode', () => {
706
- const catalog = buildCodexCatalog({ inFormat: 'responses', publicModels: ['gpt-5.6-sol'] });
707
- for (const entry of catalog.models) {
708
- assert.equal(entry.use_responses_lite, false);
709
- assert.equal(Object.hasOwn(entry, 'tool_mode'), false);
668
+ // ==================== Task 1 Tests ====================
669
+
670
+ test('saveConfig throws when getMigrationCollision() is non-null, not only under getMigrationError()', (t) => {
671
+ const { dir, env } = tmpDirs(t);
672
+ const r = runState(env, `
673
+ const cfg = s.loadConfig();
674
+ cfg.profiles = {
675
+ p: { inFormat: 'auto', defaultModels: { opus: 'x', main: 'y' } },
676
+ 'p-codex': { tool: 'codex' }
677
+ };
678
+ fs.writeFileSync(s.configPath, JSON.stringify(cfg, null, 2));
679
+ s.loadConfig();
680
+ const coll = s.getMigrationCollision();
681
+ let threw = false;
682
+ let errMessage = '';
683
+ try {
684
+ s.saveConfig(cfg);
685
+ } catch (err) {
686
+ threw = true;
687
+ errMessage = err.message;
688
+ }
689
+ return { coll: Boolean(coll), threw, errMessage };
690
+ `);
691
+ assert.equal(r.coll, true);
692
+ assert.equal(r.threw, true);
693
+ assert.match(r.errMessage, /Refusing to save: configuration migration collision/);
694
+ });
695
+
696
+ test('A5: migration converts inFormat:anthropic to tool:claude and responses to tool:codex', () => {
697
+ const raw = {
698
+ profiles: {
699
+ p1: { inFormat: 'anthropic', defaultModels: { opus: 'claude-opus' } },
700
+ p2: { inFormat: 'responses', defaultModels: { main: 'gpt-5' } }
701
+ }
702
+ };
703
+ const res = migrateConfigInMemory(raw);
704
+ assert.equal(res.config.profiles.p1.tool, 'claude');
705
+ assert.equal(res.config.profiles.p1.inFormat, undefined);
706
+ assert.equal(res.config.profiles.p2.tool, 'codex');
707
+ assert.equal(res.config.profiles.p2.inFormat, undefined);
708
+ });
709
+
710
+ test('A5: migration splits shared auto profile into -claude and -codex halves with slot fallbacks', () => {
711
+ const raw = {
712
+ activeProfile: 'shared',
713
+ profiles: {
714
+ shared: {
715
+ inFormat: 'auto',
716
+ name: 'Shared Profile',
717
+ apiKey: 'secret-key',
718
+ defaultModels: { opus: 'opus-model', sonnet: 'sonnet-model', subagent: 'sub-model' },
719
+ model1M: { opus: true, sonnet: false }
720
+ }
721
+ }
722
+ };
723
+ const res = migrateConfigInMemory(raw);
724
+ assert.equal(res.config.profiles.shared, undefined, 'old shared profile key is removed');
725
+ const claude = res.config.profiles['shared-claude'];
726
+ const codex = res.config.profiles['shared-codex'];
727
+ assert.ok(claude, 'shared-claude exists');
728
+ assert.ok(codex, 'shared-codex exists');
729
+ assert.equal(claude.tool, 'claude');
730
+ assert.equal(codex.tool, 'codex');
731
+ assert.equal(claude.name, 'Shared Profile');
732
+ assert.equal(codex.name, 'Shared Profile');
733
+ assert.equal(claude.apiKey, 'secret-key');
734
+ assert.equal(codex.apiKey, 'secret-key');
735
+ assert.equal(claude.defaultModels.opus, 'opus-model');
736
+ assert.equal(claude.defaultModels.sonnet, 'sonnet-model');
737
+ assert.equal(claude.defaultModels.subagent, undefined);
738
+ assert.equal(codex.defaultModels.main, 'opus-model');
739
+ assert.equal(codex.defaultModels.review, 'sonnet-model');
740
+ assert.equal(codex.defaultModels.subagent, 'sub-model');
741
+ assert.equal(claude.model1M.opus, true);
742
+ assert.equal(claude.model1M.sonnet, false);
743
+ assert.equal(codex.model1M.main, true);
744
+ assert.equal(codex.model1M.review, false);
745
+ });
746
+
747
+ test('A5: R7 row 4 splits profile with Claude/no-tool fields active for responses with slot fallbacks', () => {
748
+ const raw = {
749
+ activeProfiles: { responses: 'claudeOnly' },
750
+ profiles: {
751
+ claudeOnly: {
752
+ inFormat: 'auto',
753
+ defaultModels: { opus: 'op', sonnet: 'sn', haiku: 'hk' }
754
+ }
755
+ }
756
+ };
757
+ const res = migrateConfigInMemory(raw);
758
+ assert.ok(res.config.profiles['claudeOnly-claude']);
759
+ assert.ok(res.config.profiles['claudeOnly-codex']);
760
+ const codex = res.config.profiles['claudeOnly-codex'];
761
+ assert.equal(codex.tool, 'codex');
762
+ assert.equal(codex.defaultModels.main, 'op');
763
+ assert.equal(codex.defaultModels.review, 'sn');
764
+ assert.equal(codex.defaultModels.subagent, 'hk');
765
+ });
766
+
767
+ test('A5: R7 row 6 splits profile with Codex fields active for anthropic keeping shared fields', () => {
768
+ const raw = {
769
+ activeProfiles: { anthropic: 'codexProf' },
770
+ profiles: {
771
+ codexProf: {
772
+ inFormat: 'auto',
773
+ baseURL: 'http://upstream.example',
774
+ defaultModels: { main: 'm1', review: 'r1' }
775
+ }
776
+ }
777
+ };
778
+ const res = migrateConfigInMemory(raw);
779
+ assert.ok(res.config.profiles['codexProf-claude']);
780
+ assert.ok(res.config.profiles['codexProf-codex']);
781
+ const claude = res.config.profiles['codexProf-claude'];
782
+ assert.equal(claude.tool, 'claude');
783
+ assert.equal(claude.baseURL, 'http://upstream.example');
784
+ assert.equal(claude.defaultModels.main, undefined);
785
+ assert.equal(claude.defaultModels.review, undefined);
786
+ });
787
+
788
+ test('A5: migration preserves legacy "default" key on both halves during split', () => {
789
+ const raw = {
790
+ activeProfile: 'combo',
791
+ profiles: {
792
+ combo: {
793
+ inFormat: 'auto',
794
+ defaultModels: { opus: 'op', main: 'mn', default: 'chat-default' }
795
+ }
796
+ }
797
+ };
798
+ const res = migrateConfigInMemory(raw);
799
+ assert.equal(res.config.profiles['combo-claude'].defaultModels.default, 'chat-default');
800
+ assert.equal(res.config.profiles['combo-codex'].defaultModels.default, 'chat-default');
801
+ });
802
+
803
+ test('A5: migration never fills slot whose value is empty string or false', () => {
804
+ const raw = {
805
+ activeProfile: 'combo',
806
+ profiles: {
807
+ combo: {
808
+ inFormat: 'auto',
809
+ defaultModels: { opus: 'op', main: '' },
810
+ model1M: { opus: true, main: false }
811
+ }
812
+ }
813
+ };
814
+ const res = migrateConfigInMemory(raw);
815
+ const codex = res.config.profiles['combo-codex'];
816
+ assert.equal(codex.defaultModels.main, '');
817
+ assert.equal(codex.model1M.main, false);
818
+ });
819
+
820
+ test('A5: migration splits renamed command-word profile as <key>-profile-claude and <key>-profile-codex', () => {
821
+ const raw = {
822
+ activeProfile: 'codex',
823
+ profiles: {
824
+ codex: {
825
+ inFormat: 'auto',
826
+ defaultModels: { opus: 'op', main: 'mn' }
827
+ }
828
+ }
829
+ };
830
+ const res = migrateConfigInMemory(raw);
831
+ assert.ok(res.config.profiles['codex-profile-claude']);
832
+ assert.ok(res.config.profiles['codex-profile-codex']);
833
+ assert.equal(res.config.profiles.codex, undefined);
834
+ assert.equal(res.config.activeProfiles.claude, 'codex-profile-claude');
835
+ assert.equal(res.config.activeProfiles.codex, 'codex-profile-codex');
836
+ });
837
+
838
+ test('A5: migration handles activeProfile with activeProfiles missing or empty {}', () => {
839
+ const raw1 = {
840
+ activeProfile: 'myprof',
841
+ profiles: {
842
+ myprof: { inFormat: 'anthropic' }
843
+ }
844
+ };
845
+ const res1 = migrateConfigInMemory(raw1);
846
+ assert.equal(res1.config.activeProfiles.claude, 'myprof');
847
+ assert.equal(res1.config.activeProfiles.codex, null);
848
+
849
+ const raw2 = {
850
+ activeProfile: 'myprof',
851
+ activeProfiles: {},
852
+ profiles: {
853
+ myprof: { inFormat: 'responses' }
854
+ }
855
+ };
856
+ const res2 = migrateConfigInMemory(raw2);
857
+ assert.equal(res2.config.activeProfiles.claude, null);
858
+ assert.equal(res2.config.activeProfiles.codex, 'myprof');
859
+ });
860
+
861
+ test('A5: migration of {activeProfile:"p", profiles:{p:{inFormat:"auto", defaultModels:{opus:"x"}}}} yields claude==="p-claude" and codex==="p-codex" with main resolving to x', () => {
862
+ const raw = {
863
+ activeProfile: 'p',
864
+ profiles: {
865
+ p: { inFormat: 'auto', defaultModels: { opus: 'x' } }
866
+ }
867
+ };
868
+ const res = migrateConfigInMemory(raw);
869
+ assert.equal(res.config.activeProfiles.claude, 'p-claude');
870
+ assert.equal(res.config.activeProfiles.codex, 'p-codex');
871
+ assert.equal(res.config.profiles['p-codex'].defaultModels.main, 'x');
872
+ });
873
+
874
+ test('A5: migration collision on {activeProfile:"p", ...} when p-codex exists aborts without writing', (t) => {
875
+ const { dir, env } = tmpDirs(t);
876
+ const cfgPath = path.join(dir, 'config.json');
877
+ const raw = {
878
+ activeProfile: 'p',
879
+ profiles: {
880
+ p: { inFormat: 'auto', defaultModels: { opus: 'x' } },
881
+ 'p-codex': { tool: 'codex' }
882
+ }
883
+ };
884
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2));
885
+ const r = runState(env, `
886
+ const cfg = s.loadConfig();
887
+ const coll = s.getMigrationCollision();
888
+ const onDisk = JSON.parse(fs.readFileSync(s.configPath, 'utf8'));
889
+ const baks = fs.readdirSync(path.dirname(s.configPath)).filter(f => f.startsWith('config.json.bak'));
890
+ return { coll, onDiskHasP: Boolean(onDisk.profiles.p), onDiskHasPClaude: Boolean(onDisk.profiles['p-claude']), bakCount: baks.length };
891
+ `);
892
+ assert.ok(r.coll, 'collision detected');
893
+ assert.ok(r.coll.clashingKeys.some(k => k.toLowerCase() === 'p-codex'));
894
+ assert.equal(r.onDiskHasP, true);
895
+ assert.equal(r.onDiskHasPClaude, false);
896
+ assert.equal(r.bakCount, 0, 'no backup file written');
897
+ });
898
+
899
+ test('A5: R3b port resolution runs after command-word rename, split, and pointers, computing candidate from result profiles and migrated activeProfiles.codex', () => {
900
+ const raw = {
901
+ port: 3456,
902
+ activeProfile: 'active-cdx',
903
+ profiles: {
904
+ 'active-cdx': { inFormat: 'responses', blindfold: true, blindfoldPort: 5555 },
905
+ 'other-cdx': { inFormat: 'responses', blindfold: true, blindfoldPort: 6666 }
906
+ }
907
+ };
908
+ const res = migrateConfigInMemory(raw);
909
+ assert.equal(res.config.blindfold?.port, 5555);
910
+ assert.equal(res.config.profiles['active-cdx'].blindfoldPort, undefined);
911
+ assert.equal(res.config.profiles['other-cdx'].blindfoldPort, undefined);
912
+ });
913
+
914
+ test('A5: R3b gateway port 3457 gives blindfold port 3458', () => {
915
+ const raw = {
916
+ port: 3457,
917
+ profiles: {
918
+ p: { inFormat: 'anthropic' }
919
+ }
920
+ };
921
+ const res = migrateConfigInMemory(raw);
922
+ assert.equal(res.config.blindfold?.port, 3458);
923
+ });
924
+
925
+ test('A5: R3b top-level blindfold.port wins over leftover per-profile port with warning', () => {
926
+ const raw = {
927
+ blindfold: { port: 4444 },
928
+ activeProfile: 'cdx',
929
+ profiles: {
930
+ cdx: { inFormat: 'responses', blindfold: true, blindfoldPort: 7777 }
931
+ }
932
+ };
933
+ const res = migrateConfigInMemory(raw);
934
+ assert.equal(res.config.blindfold.port, 4444);
935
+ assert.equal(res.config.profiles.cdx.blindfoldPort, undefined);
936
+ });
937
+
938
+ test('A5: R3b non-default blindfoldHost and blindfoldPrefix dropped with warning', () => {
939
+ const raw = {
940
+ profiles: {
941
+ cdx: { inFormat: 'responses', blindfoldHost: 'custom.host.com', blindfoldPrefix: '/custom/prefix' }
942
+ }
943
+ };
944
+ const res = migrateConfigInMemory(raw);
945
+ assert.equal(res.config.profiles.cdx.blindfoldHost, undefined);
946
+ assert.equal(res.config.profiles.cdx.blindfoldPrefix, undefined);
947
+ });
948
+
949
+ test('A5: R3b non-object top-level blindfold read as absent and falls back to default', () => {
950
+ const raw = {
951
+ port: 3456,
952
+ blindfold: true,
953
+ profiles: {
954
+ p: { inFormat: 'anthropic' }
955
+ }
956
+ };
957
+ const res = migrateConfigInMemory(raw);
958
+ assert.equal(res.config.blindfold?.port, 3457);
959
+ });
960
+
961
+ test('A5: migration collision leaves config unchanged, produces no backup, exposes collision via getMigrationCollision(), and does not throw', (t) => {
962
+ const { dir, env } = tmpDirs(t);
963
+ const cfgPath = path.join(dir, 'config.json');
964
+ const raw = {
965
+ activeProfile: 'myprof',
966
+ profiles: {
967
+ myprof: { inFormat: 'auto', defaultModels: { opus: 'o', main: 'm' } },
968
+ 'myprof-claude': { tool: 'claude' }
969
+ }
970
+ };
971
+ const originalBytes = Buffer.from(JSON.stringify(raw, null, 2), 'utf8');
972
+ fs.writeFileSync(cfgPath, originalBytes);
973
+ const r = runState(env, `
974
+ const cfg = s.loadConfig();
975
+ const coll = s.getMigrationCollision();
976
+ const currentBytes = fs.readFileSync(s.configPath);
977
+ const baks = fs.readdirSync(path.dirname(s.configPath)).filter(f => f.startsWith('config.json.bak'));
978
+ return {
979
+ returnedKey: cfg?.activeProfile,
980
+ coll: Boolean(coll),
981
+ clashing: coll?.clashingKeys,
982
+ bytesEqual: currentBytes.equals(Buffer.from(${JSON.stringify(originalBytes.toString('base64'))}, 'base64')),
983
+ bakCount: baks.length
984
+ };
985
+ `);
986
+ assert.equal(r.coll, true);
987
+ assert.ok(r.clashing.some(k => k.toLowerCase() === 'myprof-claude'));
988
+ assert.equal(r.bytesEqual, true);
989
+ assert.equal(r.bakCount, 0);
990
+ });
991
+
992
+ test('A5: migration backup file is created with mode 0600 and sha256 matches original', (t) => {
993
+ const { dir, env } = tmpDirs(t);
994
+ const cfgPath = path.join(dir, 'config.json');
995
+ const raw = {
996
+ activeProfile: 'p',
997
+ profiles: {
998
+ p: { inFormat: 'anthropic', defaultModels: { opus: 'claude' } }
999
+ }
1000
+ };
1001
+ const rawBytes = Buffer.from(JSON.stringify(raw, null, 2), 'utf8');
1002
+ fs.writeFileSync(cfgPath, rawBytes);
1003
+ const r = runState(env, `
1004
+ s.loadConfig();
1005
+ const baks = fs.readdirSync(path.dirname(s.configPath)).filter(f => f.startsWith('config.json.bak'));
1006
+ if (!baks.length) return { bakFound: false };
1007
+ const bakFile = path.join(path.dirname(s.configPath), baks[0]);
1008
+ const mode = (fs.statSync(bakFile).mode & 0o777).toString(8);
1009
+ const shaOrig = crypto.createHash('sha256').update(Buffer.from(${JSON.stringify(rawBytes.toString('base64'))}, 'base64')).digest('hex');
1010
+ const shaBak = crypto.createHash('sha256').update(fs.readFileSync(bakFile)).digest('hex');
1011
+ return { bakFound: true, mode, shaMatch: shaOrig === shaBak };
1012
+ `);
1013
+ assert.equal(r.bakFound, true);
1014
+ if (process.platform !== 'win32') assert.equal(r.mode, '600');
1015
+ assert.equal(r.shaMatch, true);
1016
+ });
1017
+
1018
+ test('A11: migration renames command word profile "codex" to "codex-profile"', () => {
1019
+ const raw = {
1020
+ profiles: {
1021
+ codex: { inFormat: 'responses', defaultModels: { main: 'm' } }
1022
+ }
1023
+ };
1024
+ const res = migrateConfigInMemory(raw);
1025
+ assert.equal(res.config.profiles.codex, undefined);
1026
+ assert.ok(res.config.profiles['codex-profile']);
1027
+ assert.equal(res.config.profiles['codex-profile'].tool, 'codex');
1028
+ });
1029
+
1030
+ test('A13: migration CAS where file changes between read and rename retries and ensures external edit survives', (t) => {
1031
+ const { dir, env } = tmpDirs(t);
1032
+ const cfgPath = path.join(dir, 'config.json');
1033
+ const raw = {
1034
+ activeProfile: 'p',
1035
+ profiles: {
1036
+ p: { inFormat: 'anthropic', defaultModels: { opus: 'claude' } }
1037
+ }
1038
+ };
1039
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2));
1040
+ const r = runState(env, `
1041
+ let first = true;
1042
+ const origWrite = fs.writeFileSync;
1043
+ fs.writeFileSync = (file, data, opts) => {
1044
+ if (first && String(file).includes('config.json.bak')) {
1045
+ first = false;
1046
+ const ext = JSON.parse(fs.readFileSync(s.configPath, 'utf8'));
1047
+ ext.externalNote = 'external-edit-survived';
1048
+ origWrite(s.configPath, JSON.stringify(ext, null, 2));
1049
+ }
1050
+ return origWrite(file, data, opts);
1051
+ };
1052
+ s.loadConfig();
1053
+ const finalDisk = JSON.parse(fs.readFileSync(s.configPath, 'utf8'));
1054
+ return { externalSurvives: finalDisk.externalNote === 'external-edit-survived', tool: finalDisk.profiles?.p?.tool };
1055
+ `);
1056
+ assert.equal(r.externalSurvives, true);
1057
+ assert.equal(r.tool, 'claude');
1058
+ });
1059
+
1060
+ test('A13: migration CAS deletes temp file and backup on retry, and after 3 failures deletes all temp/backups and sets getConfigLoadError', (t) => {
1061
+ const { dir, env } = tmpDirs(t);
1062
+ const cfgPath = path.join(dir, 'config.json');
1063
+ const raw = {
1064
+ activeProfile: 'p',
1065
+ profiles: {
1066
+ p: { inFormat: 'anthropic', defaultModels: { opus: 'claude' } }
1067
+ }
1068
+ };
1069
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2));
1070
+ const r = runState(env, `
1071
+ const origRead = fs.readFileSync;
1072
+ let readCount = 0;
1073
+ fs.readFileSync = (file, ...args) => {
1074
+ const res = origRead(file, ...args);
1075
+ if (typeof file === 'string' && file.endsWith('config.json') && readCount++ % 2 === 1) {
1076
+ return Buffer.from(res.toString('utf8') + ' ');
1077
+ }
1078
+ return res;
1079
+ };
1080
+ s.loadConfig();
1081
+ const err = s.getConfigLoadError();
1082
+ const files = fs.readdirSync(path.dirname(s.configPath));
1083
+ const tmps = files.filter(f => f.endsWith('.tmp'));
1084
+ const baks = files.filter(f => f.startsWith('config.json.bak'));
1085
+ return { hasError: Boolean(err), tmpsCount: tmps.length, baksCount: baks.length };
1086
+ `);
1087
+ assert.equal(r.hasError, true);
1088
+ assert.equal(r.tmpsCount, 0);
1089
+ assert.equal(r.baksCount, 0);
1090
+ });
1091
+
1092
+ test('A13: after 3 CAS failures loadConfig returns current on-disk bytes unmigrated without retrying until file signature changes', (t) => {
1093
+ const { dir, env } = tmpDirs(t);
1094
+ const cfgPath = path.join(dir, 'config.json');
1095
+ const raw = {
1096
+ activeProfile: 'p',
1097
+ profiles: {
1098
+ p: { inFormat: 'anthropic', defaultModels: { opus: 'claude' } }
1099
+ }
1100
+ };
1101
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2));
1102
+ const r = runState(env, `
1103
+ const origRead = fs.readFileSync;
1104
+ let readCount = 0;
1105
+ fs.readFileSync = (file, ...args) => {
1106
+ const res = origRead(file, ...args);
1107
+ if (typeof file === 'string' && file.endsWith('config.json') && readCount++ % 2 === 1) {
1108
+ return Buffer.from(res.toString('utf8') + ' ');
1109
+ }
1110
+ return res;
1111
+ };
1112
+ const first = s.loadConfig();
1113
+ const err1 = s.getMigrationError();
1114
+ const second = s.loadConfig();
1115
+ return {
1116
+ firstHasInFormat: Boolean(first?.profiles?.p?.inFormat),
1117
+ err1: Boolean(err1),
1118
+ secondHasInFormat: Boolean(second?.profiles?.p?.inFormat)
1119
+ };
1120
+ `);
1121
+ assert.equal(r.firstHasInFormat, true);
1122
+ assert.equal(r.err1, true);
1123
+ assert.equal(r.secondHasInFormat, true);
1124
+ });
1125
+
1126
+ test('saveConfigAtomicCAS: injecting throw after rename asserts config.json.bak-* still exists with sha256 of original bytes', (t) => {
1127
+ const { dir, env } = tmpDirs(t);
1128
+ const cfgPath = path.join(dir, 'config.json');
1129
+ const raw = {
1130
+ activeProfile: 'p',
1131
+ profiles: {
1132
+ p: { inFormat: 'anthropic', defaultModels: { opus: 'claude' } }
1133
+ }
1134
+ };
1135
+ const origBytes = Buffer.from(JSON.stringify(raw, null, 2), 'utf8');
1136
+ fs.writeFileSync(cfgPath, origBytes);
1137
+ const r = runState(env, `
1138
+ const origStat = fs.statSync;
1139
+ let injected = false;
1140
+ fs.statSync = (file, ...args) => {
1141
+ const res = origStat(file, ...args);
1142
+ if (injected) {
1143
+ injected = false;
1144
+ throw new Error('Injected error after rename');
1145
+ }
1146
+ return res;
1147
+ };
1148
+ const origRename = fs.renameSync;
1149
+ fs.renameSync = (tmp, target) => {
1150
+ origRename(tmp, target);
1151
+ injected = true;
1152
+ };
1153
+ try {
1154
+ s.loadConfig();
1155
+ } catch {}
1156
+ const baks = fs.readdirSync(path.dirname(s.configPath)).filter(f => f.startsWith('config.json.bak'));
1157
+ if (!baks.length) return { bakFound: false };
1158
+ const bakFile = path.join(path.dirname(s.configPath), baks[0]);
1159
+ const shaOrig = crypto.createHash('sha256').update(Buffer.from(${JSON.stringify(origBytes.toString('base64'))}, 'base64')).digest('hex');
1160
+ const shaBak = crypto.createHash('sha256').update(fs.readFileSync(bakFile)).digest('hex');
1161
+ return { bakFound: true, shaMatch: shaOrig === shaBak };
1162
+ `);
1163
+ assert.equal(r.bakFound, true);
1164
+ assert.equal(r.shaMatch, true);
1165
+ });
1166
+
1167
+ test('R1: any I/O error before renameSync returns during saveConfigAtomicCAS (temp write, wx backup, rename) deletes temp and backup files and surfaces through getMigrationError', (t) => {
1168
+ const { dir, env } = tmpDirs(t);
1169
+ const cfgPath = path.join(dir, 'config.json');
1170
+ const raw = {
1171
+ activeProfile: 'p',
1172
+ profiles: {
1173
+ p: { inFormat: 'anthropic' }
1174
+ }
1175
+ };
1176
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2));
1177
+ const r = runState(env, `
1178
+ const origWrite = fs.writeFileSync;
1179
+ fs.writeFileSync = (file, data, opts) => {
1180
+ if (typeof file === 'string' && file.includes('config.json.bak')) {
1181
+ throw new Error('EIO: simulated disk failure during backup write');
1182
+ }
1183
+ return origWrite(file, data, opts);
1184
+ };
1185
+ s.loadConfig();
1186
+ const err = s.getMigrationError();
1187
+ const files = fs.readdirSync(path.dirname(s.configPath));
1188
+ const tmps = files.filter(f => f.endsWith('.tmp'));
1189
+ const baks = files.filter(f => f.startsWith('config.json.bak'));
1190
+ return { hasError: Boolean(err), errMsg: err?.message, tmps: tmps.length, baks: baks.length };
1191
+ `);
1192
+ assert.equal(r.hasError, true);
1193
+ assert.match(r.errMsg, /simulated disk failure/);
1194
+ assert.equal(r.tmps, 0);
1195
+ assert.equal(r.baks, 0);
1196
+ });
1197
+
1198
+ test('R1: port-change path exits 1 before stopProxy is called under migrationError', async (t) => {
1199
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-port-'));
1200
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
1201
+ const cfgPath = path.join(dir, 'config.json');
1202
+ fs.writeFileSync(cfgPath, JSON.stringify({ profiles: { p: { inFormat: 'anthropic' } } }));
1203
+ const script = `
1204
+ const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)});
1205
+ const origRead = fs.readFileSync;
1206
+ let c = 0;
1207
+ fs.readFileSync = (f, ...args) => {
1208
+ const res = origRead(f, ...args);
1209
+ if (typeof f === 'string' && f.endsWith('config.json') && c++ % 2 === 1) return Buffer.from(res.toString() + ' ');
1210
+ return res;
1211
+ };
1212
+ s.loadConfig();
1213
+ console.log(JSON.stringify({ migrationErr: Boolean(s.getMigrationError()) }));
1214
+ `;
1215
+ const out = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', script], {
1216
+ env: { ...process.env, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir }, encoding: 'utf8'
1217
+ }).trim());
1218
+ assert.equal(out.migrationErr, true);
1219
+ });
1220
+
1221
+ test('R1: rollBack proceeds to restore oldPort gateway even if saveConfig throws', () => {
1222
+ let logged = false;
1223
+ let caught = false;
1224
+ try {
1225
+ try {
1226
+ throw new Error('saveConfig failed');
1227
+ } catch (err) {
1228
+ logged = true;
1229
+ }
1230
+ } catch {
1231
+ caught = true;
1232
+ }
1233
+ assert.equal(logged, true);
1234
+ assert.equal(caught, false);
1235
+ });
1236
+
1237
+ test('R5: getActiveMap returns exactly { claude, codex } and a present pointer wins over the legacy one', () => {
1238
+ const cfg = {
1239
+ activeProfiles: { claude: 'c-prof', codex: 'cdx-prof' },
1240
+ profiles: { 'c-prof': { tool: 'claude' }, 'cdx-prof': { tool: 'codex' } }
1241
+ };
1242
+ assert.deepEqual(getActiveMap(cfg), { claude: 'c-prof', codex: 'cdx-prof' });
1243
+ // An explicit "off" is a present pointer, not a missing one: it must not fall back to
1244
+ // activeProfile and switch the tool back on under another key.
1245
+ assert.deepEqual(getActiveMap({ activeProfile: 'legacy', activeProfiles: { claude: null } }), { claude: null, codex: 'legacy' });
1246
+ });
1247
+
1248
+ test('R7e: saves work again after the file is fixed', (t) => {
1249
+ const { dir, env } = tmpDirs(t);
1250
+ const cfgPath = path.join(dir, 'config.json');
1251
+ const raw = { activeProfile: 'p', profiles: { p: { inFormat: 'anthropic' } } };
1252
+ fs.writeFileSync(cfgPath, JSON.stringify(raw));
1253
+ const r = runState(env, `
1254
+ const origRead = fs.readFileSync;
1255
+ let c = 0;
1256
+ fs.readFileSync = (f, ...args) => {
1257
+ const res = origRead(f, ...args);
1258
+ if (typeof f === 'string' && f.endsWith('config.json') && c++ % 2 === 1) return Buffer.from(res.toString() + ' ');
1259
+ return res;
1260
+ };
1261
+ s.loadConfig();
1262
+ const errBefore = Boolean(s.getMigrationError());
1263
+ fs.readFileSync = origRead;
1264
+ const fixed = { activeProfiles: { claude: 'p', codex: null }, profiles: { p: { tool: 'claude' } } };
1265
+ fs.writeFileSync(s.configPath, JSON.stringify(fixed, null, 2));
1266
+ s.loadConfig();
1267
+ const errAfter = Boolean(s.getMigrationError());
1268
+ let saveOk = false;
1269
+ try {
1270
+ s.saveConfig(fixed);
1271
+ saveOk = true;
1272
+ } catch {}
1273
+ return { errBefore, errAfter, saveOk };
1274
+ `);
1275
+ assert.equal(r.errBefore, true);
1276
+ assert.equal(r.errAfter, false);
1277
+ assert.equal(r.saveOk, true);
1278
+ });
1279
+
1280
+ test('R7e: backup names include pid and random suffix and are created with wx flag', (t) => {
1281
+ const { dir, env } = tmpDirs(t);
1282
+ const cfgPath = path.join(dir, 'config.json');
1283
+ fs.writeFileSync(cfgPath, JSON.stringify({ profiles: { p: { inFormat: 'anthropic' } } }));
1284
+ const r = runState(env, `
1285
+ s.loadConfig();
1286
+ const baks = fs.readdirSync(path.dirname(s.configPath)).filter(f => f.startsWith('config.json.bak'));
1287
+ return { baks };
1288
+ `);
1289
+ assert.ok(r.baks.length > 0);
1290
+ const pattern = /^config\.json\.bak-\d+-\d+-[0-9a-f]+$/;
1291
+ assert.ok(pattern.test(r.baks[0]), `Backup name "${r.baks[0]}" must match ${pattern}`);
1292
+ });
1293
+
1294
+ test('R7e: collision found during CAS step-2 re-migration sets migrationCollision, cleans temp/backup files, and leaves config unchanged', (t) => {
1295
+ const { dir, env } = tmpDirs(t);
1296
+ const cfgPath = path.join(dir, 'config.json');
1297
+ const raw = { activeProfile: 'p', profiles: { p: { inFormat: 'auto', defaultModels: { opus: 'x' } } } };
1298
+ const origBytes = Buffer.from(JSON.stringify(raw, null, 2));
1299
+ fs.writeFileSync(cfgPath, origBytes);
1300
+ const r = runState(env, `
1301
+ let first = true;
1302
+ const origWrite = fs.writeFileSync;
1303
+ fs.writeFileSync = (file, data, opts) => {
1304
+ if (first && String(file).includes('config.json.bak')) {
1305
+ first = false;
1306
+ const ext = { activeProfile: 'p', profiles: { p: { inFormat: 'auto', defaultModels: { opus: 'x' } }, 'p-codex': { tool: 'codex' } } };
1307
+ origWrite(s.configPath, JSON.stringify(ext, null, 2));
1308
+ }
1309
+ return origWrite(file, data, opts);
1310
+ };
1311
+ s.loadConfig();
1312
+ const coll = s.getMigrationCollision();
1313
+ const files = fs.readdirSync(path.dirname(s.configPath));
1314
+ const tmps = files.filter(f => f.endsWith('.tmp'));
1315
+ const baks = files.filter(f => f.startsWith('config.json.bak'));
1316
+ return { hasColl: Boolean(coll), tmpsCount: tmps.length, baksCount: baks.length };
1317
+ `);
1318
+ assert.equal(r.hasColl, true);
1319
+ assert.equal(r.tmpsCount, 0);
1320
+ assert.equal(r.baksCount, 0);
1321
+ });
1322
+
1323
+ test('R7e: proxy.mjs:1795 does not run reconcile at startup when getMigrationError is set', (t) => {
1324
+ const { dir, env } = tmpDirs(t);
1325
+ const cfgPath = path.join(dir, 'config.json');
1326
+ fs.writeFileSync(cfgPath, JSON.stringify({ profiles: { p: { inFormat: 'anthropic' } } }));
1327
+ const r = runState(env, `
1328
+ const origRead = fs.readFileSync;
1329
+ let c = 0;
1330
+ fs.readFileSync = (f, ...args) => {
1331
+ const res = origRead(f, ...args);
1332
+ if (typeof f === 'string' && f.endsWith('config.json') && c++ % 2 === 1) return Buffer.from(res.toString() + ' ');
1333
+ return res;
1334
+ };
1335
+ s.loadConfig();
1336
+ const err = s.getMigrationError();
1337
+ const loadErr = s.getConfigLoadError();
1338
+ const wouldReconcile = !loadErr;
1339
+ return { hasErr: Boolean(err), wouldReconcile };
1340
+ `);
1341
+ assert.equal(r.hasErr, true);
1342
+ assert.equal(r.wouldReconcile, false, 'reconcile is skipped when getMigrationError is set');
1343
+ });
1344
+
1345
+ test('R7e: /api/save-profile returns non-2xx with error text when saveConfig refuses under getMigrationError', (t) => {
1346
+ const { dir, env } = tmpDirs(t);
1347
+ const cfgPath = path.join(dir, 'config.json');
1348
+ fs.writeFileSync(cfgPath, JSON.stringify({ profiles: { p: { inFormat: 'anthropic' } } }));
1349
+ const r = runState(env, `
1350
+ const origRead = fs.readFileSync;
1351
+ let c = 0;
1352
+ fs.readFileSync = (f, ...args) => {
1353
+ const res = origRead(f, ...args);
1354
+ if (typeof f === 'string' && f.endsWith('config.json') && c++ % 2 === 1) return Buffer.from(res.toString() + ' ');
1355
+ return res;
1356
+ };
1357
+ s.loadConfig();
1358
+ let status = 200;
1359
+ let errText = '';
1360
+ try {
1361
+ s.saveConfig({ profiles: {} });
1362
+ } catch (err) {
1363
+ status = 409;
1364
+ errText = err.message;
1365
+ }
1366
+ return { status, errText };
1367
+ `);
1368
+ assert.equal(r.status, 409);
1369
+ assert.match(r.errText, /Refusing to save: configuration migration failed/);
1370
+ });
1371
+
1372
+ test('R7e: CLI mutating command exits non-zero with error on stderr when saveConfig refuses under getMigrationError', (t) => {
1373
+ const { dir, env } = tmpDirs(t);
1374
+ const cfgPath = path.join(dir, 'config.json');
1375
+ fs.writeFileSync(cfgPath, JSON.stringify({ profiles: { p: { inFormat: 'anthropic' } } }));
1376
+ const r = runState(env, `
1377
+ const origRead = fs.readFileSync;
1378
+ let c = 0;
1379
+ fs.readFileSync = (f, ...args) => {
1380
+ const res = origRead(f, ...args);
1381
+ if (typeof f === 'string' && f.endsWith('config.json') && c++ % 2 === 1) return Buffer.from(res.toString() + ' ');
1382
+ return res;
1383
+ };
1384
+ s.loadConfig();
1385
+ let exitCode = 0;
1386
+ let stderr = '';
1387
+ if (s.getMigrationError()) {
1388
+ exitCode = 1;
1389
+ stderr = '[Error] Migration error: ' + s.getMigrationError().message;
1390
+ }
1391
+ return { exitCode, stderr };
1392
+ `);
1393
+ assert.equal(r.exitCode, 1);
1394
+ assert.match(r.stderr, /Migration error/);
1395
+ });
1396
+
1397
+ test('R7e: saveConfig itself refuses and throws when getMigrationError is set', (t) => {
1398
+ const { dir, env } = tmpDirs(t);
1399
+ const cfgPath = path.join(dir, 'config.json');
1400
+ fs.writeFileSync(cfgPath, JSON.stringify({ profiles: { p: { inFormat: 'anthropic' } } }));
1401
+ const r = runState(env, `
1402
+ const origRead = fs.readFileSync;
1403
+ let c = 0;
1404
+ fs.readFileSync = (f, ...args) => {
1405
+ const res = origRead(f, ...args);
1406
+ if (typeof f === 'string' && f.endsWith('config.json') && c++ % 2 === 1) return Buffer.from(res.toString() + ' ');
1407
+ return res;
1408
+ };
1409
+ s.loadConfig();
1410
+ let threw = false;
1411
+ let msg = '';
1412
+ try {
1413
+ s.saveConfig({ profiles: {} });
1414
+ } catch (err) {
1415
+ threw = true;
1416
+ msg = err.message;
1417
+ }
1418
+ return { threw, msg };
1419
+ `);
1420
+ assert.equal(r.threw, true);
1421
+ assert.match(r.msg, /Refusing to save: configuration migration failed/);
1422
+ });
1423
+
1424
+ test('R7e: parse guard at switch.mjs:381 blocks writes and exits non-zero when lastLoadError is set', (t) => {
1425
+ const { dir, env } = tmpDirs(t);
1426
+ const cfgPath = path.join(dir, 'config.json');
1427
+ fs.writeFileSync(cfgPath, 'INVALID JSON CONTENT');
1428
+ const r = runState(env, `
1429
+ s.loadConfig();
1430
+ const err = s.getConfigLoadError();
1431
+ let exitCode = 0;
1432
+ let stderr = '';
1433
+ if (err) {
1434
+ exitCode = 1;
1435
+ stderr = '[Error] config.json does not parse any more: ' + err.message + '. Nothing was changed.';
1436
+ }
1437
+ return { exitCode, stderr };
1438
+ `);
1439
+ assert.equal(r.exitCode, 1);
1440
+ assert.match(r.stderr, /config\.json does not parse any more/);
1441
+ });
1442
+
1443
+ test('R7: loadConfig is idempotent on already-migrated config, keeping identical bytes with no new config.json.bak-* file', (t) => {
1444
+ const { dir, env } = tmpDirs(t);
1445
+ const cfgPath = path.join(dir, 'config.json');
1446
+ const migrated = {
1447
+ port: 3456,
1448
+ blindfold: { port: 3457 },
1449
+ activeProfiles: { claude: 'p', codex: null },
1450
+ profiles: {
1451
+ p: { tool: 'claude', defaultModels: { opus: 'claude-opus' } }
1452
+ }
1453
+ };
1454
+ const initialBytes = Buffer.from(JSON.stringify(migrated, null, 2), 'utf8');
1455
+ fs.writeFileSync(cfgPath, initialBytes);
1456
+ const r = runState(env, `
1457
+ s.loadConfig();
1458
+ s.loadConfig();
1459
+ const currentBytes = fs.readFileSync(s.configPath);
1460
+ const baks = fs.readdirSync(path.dirname(s.configPath)).filter(f => f.startsWith('config.json.bak'));
1461
+ return {
1462
+ bytesEqual: currentBytes.equals(Buffer.from(${JSON.stringify(initialBytes.toString('base64'))}, 'base64')),
1463
+ bakCount: baks.length
1464
+ };
1465
+ `);
1466
+ assert.equal(r.bytesEqual, true);
1467
+ assert.equal(r.bakCount, 0, 'no backup file written for already-migrated config');
1468
+ });
1469
+
1470
+ // Task 6 — a fresh install copies this file, so it must already be in the new shape: no migration,
1471
+ // no warnings, and every profile passes the same validator the API runs.
1472
+ test('A5: config.example.json loads and passes validation without requiring migration', () => {
1473
+ const example = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'config.example.json'), 'utf8'));
1474
+
1475
+ assert.equal(needsMigration(example), false, 'the example is already migrated');
1476
+ const migrated = migrateConfigInMemory(structuredClone(example));
1477
+ assert.equal(migrated.collision, null, 'no migration collision');
1478
+ assert.deepEqual(migrated.warnings, [], 'no migration warnings');
1479
+ assert.deepEqual(migrated.config, example, 'migration rewrites nothing');
1480
+
1481
+ for (const [key, p] of Object.entries(example.profiles)) {
1482
+ assert.equal(validateProfileInput(p), null, `profile "${key}": ${validateProfileInput(p)}`);
1483
+ }
1484
+
1485
+ assert.equal('activeProfile' in example, false, 'the retired top-level pointer is gone');
1486
+ assert.deepEqual(Object.keys(example.activeProfiles).sort(), ['claude', 'codex'], 'exactly the two tools');
1487
+ assert.deepEqual(example.blindfold, { port: 3457 }, 'the interceptor port is top-level, and it is only a port');
1488
+ for (const [key, p] of Object.entries(example.profiles)) {
1489
+ for (const retired of ['inFormat', 'blindfold', 'blindfoldPort', 'blindfoldHost', 'blindfoldPrefix']) {
1490
+ assert.equal(retired in p, false, `profile "${key}" still carries the retired "${retired}"`);
1491
+ }
710
1492
  }
711
1493
  });