llm-switcher 1.1.11 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
package/tests/state.test.mjs
CHANGED
|
@@ -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,
|
|
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()), {
|
|
33
|
+
assert.deepEqual(getActiveMap(makeCfg()), { claude: 'router', codex: 'router' });
|
|
30
34
|
});
|
|
31
35
|
|
|
32
|
-
test('setTargetProfile validates target, existence (no prototype keys) and
|
|
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
|
-
|
|
39
|
-
assert.
|
|
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
|
|
46
|
+
test('activateProfile only assigns compatible tools; deactivateProfile leaves the other alone', () => {
|
|
43
47
|
const cfg = makeCfg();
|
|
44
|
-
setTargetProfile(cfg, '
|
|
48
|
+
setTargetProfile(cfg, 'claude', null);
|
|
45
49
|
activateProfile(cfg, 'codexOnly');
|
|
46
|
-
assert.deepEqual(cfg.activeProfiles, {
|
|
47
|
-
deactivateProfile(cfg, '
|
|
48
|
-
assert.deepEqual(cfg.activeProfiles, {
|
|
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.
|
|
59
|
+
assert.equal(cfg.activeProfiles.codex, null);
|
|
56
60
|
assert.equal(cfg.activeProfile, 'router');
|
|
57
61
|
});
|
|
58
62
|
|
|
59
|
-
test('computeLaunchState derives
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
assert.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
assert.equal(
|
|
73
|
-
assert.equal(
|
|
74
|
-
|
|
75
|
-
assert.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
assert.
|
|
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
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
const codexEnv = Object.fromEntries(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
assert.equal(st.blindfold.
|
|
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
|
-
//
|
|
187
|
-
//
|
|
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'],
|
|
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.
|
|
196
|
-
assert.equal(apiKeySt.blindfold.
|
|
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
|
-
|
|
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
|
-
//
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
assert.
|
|
209
|
-
assert.equal(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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'),
|
|
298
|
-
assert.equal(certCoversHost(pem, '
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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().
|
|
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.
|
|
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().
|
|
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
|
-
//
|
|
704
|
-
|
|
705
|
-
test('
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
});
|