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/shim.test.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
14
|
|
|
15
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -42,15 +42,31 @@ function runShim(name, args, { active, fakeDir, extraPath = '', env = {}, files
|
|
|
42
42
|
for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
|
|
43
43
|
if (active) {
|
|
44
44
|
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
45
|
-
|
|
45
|
+
// One file per tool (A1). The value is the interceptor proxy of R2 — never a base URL the
|
|
46
|
+
// tool would read as its own endpoint (R1).
|
|
47
|
+
fs.writeFileSync(path.join(STATE, `env-${name}.sh`), "export HTTPS_PROXY='http://127.0.0.1:3457'\n");
|
|
46
48
|
}
|
|
47
49
|
for (const [f, content] of Object.entries(files)) fs.writeFileSync(path.join(STATE, f), content);
|
|
48
|
-
const PATH_ = [RENDER_DIR, fakeDir, extraPath || '/usr/bin:/bin'
|
|
50
|
+
const PATH_ = [RENDER_DIR, fakeDir, extraPath || '/usr/bin:/bin', path.dirname(process.execPath)]
|
|
51
|
+
.filter(Boolean).join(path.delimiter);
|
|
49
52
|
return execFileSync(path.join(RENDER_DIR, name), args, {
|
|
50
53
|
encoding: 'utf8', env: { ...process.env, ...env, PATH: PATH_ }, timeout: 15000
|
|
51
54
|
}).trim();
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
// Prints every value the switcher could have touched, so a test can see both what was scrubbed
|
|
58
|
+
// and what must have been kept.
|
|
59
|
+
function makeEnvBin(name) {
|
|
60
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimenv-'));
|
|
61
|
+
fs.writeFileSync(path.join(dir, name),
|
|
62
|
+
'#!/usr/bin/env bash\n'
|
|
63
|
+
+ 'echo "HTTPS_PROXY=${HTTPS_PROXY:-NONE} BASE=${ANTHROPIC_BASE_URL:-NONE} OPENAI=${OPENAI_BASE_URL:-NONE} MODEL=${ANTHROPIC_MODEL:-NONE} DEFOPUS=${ANTHROPIC_DEFAULT_OPUS_MODEL:-NONE} CA=${NODE_EXTRA_CA_CERTS:-NONE}"\n'
|
|
64
|
+
+ 'echo "DEFOTHER=${ANTHROPIC_DEFAULT_SONNET_MODEL:-NONE} CAX=${LLM_SWITCHER_CODEX_MAIN_MODEL:-NONE} MYSET=${LLM_SWITCHER_MY_SETTING:-NONE}"\n'
|
|
65
|
+
+ 'printf \'%s\\n\' "$@"\n');
|
|
66
|
+
fs.chmodSync(path.join(dir, name), 0o755);
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
// A fake binary that prints every argument on its own line, so the test sees argument boundaries.
|
|
55
71
|
function makeArgvBin(name) {
|
|
56
72
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimargv-'));
|
|
@@ -67,12 +83,17 @@ function makeFakeBin(name) {
|
|
|
67
83
|
return dir;
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
test('shim
|
|
86
|
+
test('A1: claude shim sources env-claude.sh, never a shared file, and sets no base URL', (t) => {
|
|
71
87
|
if (process.platform === 'win32') return t.skip('posix only');
|
|
72
|
-
const fake =
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
const fake = makeEnvBin('claude');
|
|
89
|
+
const lines = runShim('claude', ['--resume', 'abc'], { active: true, fakeDir: fake }).split('\n');
|
|
90
|
+
// R2: the tool reaches the interceptor through its own proxy variable, not through a
|
|
91
|
+
// rewritten endpoint — that would be the switcher configuring the tool (vision, F1).
|
|
92
|
+
assert.match(lines[0], /^HTTPS_PROXY=http:\/\/127\.0\.0\.1:3457 /);
|
|
93
|
+
assert.match(lines[0], / BASE=NONE /, 'R1: no value the tool reads as configuration');
|
|
94
|
+
assert.match(lines[0], / OPENAI=NONE /);
|
|
95
|
+
assert.match(lines[0], / CA=NONE$/, 'ca.pem is absent: never export a path that does not exist');
|
|
96
|
+
assert.deepEqual(lines.slice(2), ['--resume', 'abc'], 'arguments must be preserved');
|
|
76
97
|
});
|
|
77
98
|
|
|
78
99
|
test('shim stays transparent when the gateway is OFF', (t) => {
|
|
@@ -109,77 +130,94 @@ test('helpers report PATH guidance and shim wiring', () => {
|
|
|
109
130
|
assert.deepEqual(st.shims.map(s => s.name), SHIMMED);
|
|
110
131
|
});
|
|
111
132
|
|
|
112
|
-
//
|
|
113
|
-
|
|
133
|
+
// A2 / F3: Codex is told NOTHING. It keeps the endpoint and the model names of its own
|
|
134
|
+
// config.toml; the switcher only stands at the network hop (vision, R1). Checked as text so it
|
|
135
|
+
// runs on Windows too, where the POSIX behaviour below is skipped.
|
|
136
|
+
test('A2: codex shim passes no config override', () => {
|
|
137
|
+
for (const platform of ['win32', 'linux']) {
|
|
138
|
+
const body = renderShim('codex', platform);
|
|
139
|
+
assert.doesNotMatch(body, /--config/, platform);
|
|
140
|
+
assert.doesNotMatch(body, /model_catalog_json/, platform);
|
|
141
|
+
assert.doesNotMatch(body, /CODEX_SWITCHER_ARGS/, platform);
|
|
142
|
+
assert.doesNotMatch(body, /openai_base_url/, platform);
|
|
143
|
+
assert.doesNotMatch(body, /model_context_window/, platform);
|
|
144
|
+
assert.doesNotMatch(body, /review_model/, platform);
|
|
145
|
+
assert.doesNotMatch(body, /default_subagent_model/, platform);
|
|
146
|
+
assert.doesNotMatch(body, /model_auto_compact_token_limit/, platform);
|
|
147
|
+
assert.doesNotMatch(body, /model-catalog/, platform);
|
|
148
|
+
}
|
|
149
|
+
// The tool's own arguments must still arrive untouched.
|
|
150
|
+
assert.match(renderShim('codex', 'linux'), /exec "\$REAL" "\$@"/);
|
|
151
|
+
assert.match(renderShim('codex', 'win32'), /call "%%i" %\*/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Behaviour on POSIX: the shim, run against the launch files, wires Codex to the interceptor
|
|
155
|
+
// and nothing else, then hands its arguments through unchanged.
|
|
156
|
+
test('codex shim wires only the interceptor proxy and passes its arguments through', (t) => {
|
|
114
157
|
if (process.platform === 'win32') return t.skip('posix only');
|
|
115
|
-
const env = [
|
|
116
|
-
"export LLM_SWITCHER_CODEX_BASE_URL='http://127.0.0.1:3456/v1'",
|
|
117
|
-
"export LLM_SWITCHER_CODEX_MAIN_MODEL='gpt-5.6-sol'",
|
|
118
|
-
"export LLM_SWITCHER_CODEX_REVIEW_MODEL='gpt-5.2'",
|
|
119
|
-
"export LLM_SWITCHER_CODEX_SUBAGENT_MODEL='gpt-5.6-luna'",
|
|
120
|
-
"export LLM_SWITCHER_CODEX_CONTEXT_WINDOW='1000000'",
|
|
121
|
-
"export LLM_SWITCHER_CODEX_AUTO_COMPACT_LIMIT='900000'"
|
|
122
|
-
].join('\n') + '\n';
|
|
123
158
|
const out = runShim('codex', ['exec', 'hi'], {
|
|
124
159
|
fakeDir: makeArgvBin('codex'),
|
|
125
|
-
files: { 'active.flag': 'active'
|
|
160
|
+
files: { 'active.flag': 'active' }
|
|
126
161
|
}).split('\n');
|
|
127
|
-
assert.equal(out[0], 'HTTPS_PROXY=http://127.0.0.1:3457');
|
|
128
|
-
assert.deepEqual(out.slice(1), [
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
'--config', 'model="gpt-5.6-sol"',
|
|
132
|
-
'--config', 'review_model="gpt-5.2"',
|
|
133
|
-
'--config', 'agents.default_subagent_model="gpt-5.6-luna"',
|
|
134
|
-
'--config', 'model_context_window=1000000',
|
|
135
|
-
'--config', 'model_auto_compact_token_limit=900000',
|
|
136
|
-
'exec', 'hi'
|
|
137
|
-
]);
|
|
138
|
-
// With the gateway off, Codex gets its own arguments only and no proxy.
|
|
139
|
-
const off = runShim('codex', ['exec'], { fakeDir: makeArgvBin('codex'), files: { 'env.sh': env, 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:3457'\n" } }).split('\n');
|
|
162
|
+
assert.equal(out[0], 'HTTPS_PROXY=http://127.0.0.1:3457', 'the interceptor is the only wiring');
|
|
163
|
+
assert.deepEqual(out.slice(1), ['exec', 'hi'], 'arguments must be preserved');
|
|
164
|
+
// Gateway off: no proxy is forced and the tool runs as-is.
|
|
165
|
+
const off = runShim('codex', ['exec'], { fakeDir: makeArgvBin('codex') }).split('\n');
|
|
140
166
|
assert.deepEqual(off, ['HTTPS_PROXY=NONE', 'exec']);
|
|
141
167
|
});
|
|
142
168
|
|
|
143
169
|
test('Claude shim never loads the Codex-only file or passes Codex overrides', (t) => {
|
|
144
170
|
if (process.platform === 'win32') return t.skip('posix only');
|
|
171
|
+
// env-codex.sh carries a deliberately different port: if the claude shim ever sourced it,
|
|
172
|
+
// that value would show up instead of the claude one.
|
|
145
173
|
const out = runShim('claude', ['--resume'], {
|
|
146
174
|
fakeDir: makeArgvBin('claude'),
|
|
147
|
-
files: { '
|
|
175
|
+
files: { 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:9999'\n" }
|
|
148
176
|
}).split('\n');
|
|
149
|
-
assert.
|
|
177
|
+
assert.equal(out[0], 'HTTPS_PROXY=http://127.0.0.1:3457', 'only env-claude.sh was sourced');
|
|
178
|
+
assert.deepEqual(out.slice(1), ['--resume']);
|
|
150
179
|
});
|
|
151
180
|
|
|
152
|
-
// Windows cannot
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
assert.
|
|
163
|
-
assert.match(
|
|
164
|
-
assert.match(
|
|
181
|
+
// Windows cannot execute the .cmd here, so the Windows template is checked as text.
|
|
182
|
+
// A1: exactly one env file per tool, the shared one is never called, and an empty file means
|
|
183
|
+
// the tool is off (A13). The recorded port is read because R8 needs it to recognize a stale
|
|
184
|
+
// loopback URL left by an earlier switch on.
|
|
185
|
+
test('A1: the Windows shim loads only its own tool env file and never the shared one', () => {
|
|
186
|
+
assert.match(renderShim('claude', 'win32'), /env-claude\.cmd/);
|
|
187
|
+
assert.doesNotMatch(renderShim('claude', 'win32'), /env-codex/);
|
|
188
|
+
assert.match(renderShim('codex', 'win32'), /env-codex\.cmd/);
|
|
189
|
+
for (const n of ['claude', 'codex']) {
|
|
190
|
+
const b = renderShim(n, 'win32');
|
|
191
|
+
assert.doesNotMatch(b, /\\env\.cmd\b/, `${n}: the shared file is never called (F5)`);
|
|
192
|
+
assert.match(b, /active\.flag/, n);
|
|
193
|
+
assert.match(b, /%%~zA GTR 0/, `${n}: a non-empty file decides that the tool runs`);
|
|
194
|
+
assert.match(b, /gateway\.port/, `${n}: R8 reads the recorded port`);
|
|
195
|
+
assert.match(b, /setlocal/, `${n}: nothing leaks back into the caller's shell`);
|
|
165
196
|
}
|
|
166
197
|
});
|
|
167
198
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
assert.
|
|
175
|
-
assert.
|
|
176
|
-
assert.match(
|
|
177
|
-
assert.match(
|
|
199
|
+
// A7 / R8: every entry of the table is scrubbed, and only that. The proxy comes from the tool's
|
|
200
|
+
// own env file, never from the shim, and every OTHER LLM_SWITCHER_* name is a user setting.
|
|
201
|
+
test('A7: the Windows shim implements the R8 table and keeps every other value', () => {
|
|
202
|
+
for (const b of [renderShim('claude', 'win32'), renderShim('codex', 'win32')]) {
|
|
203
|
+
assert.match(b, /:SCRUB_URL/, 'loopback gateway URLs');
|
|
204
|
+
assert.match(b, /:SCRUB_TIER/, 'the per-tier [1m] suffix');
|
|
205
|
+
assert.match(b, /opus\[1m\]/);
|
|
206
|
+
assert.match(b, /sonnet\[1m\]/);
|
|
207
|
+
assert.match(b, /haiku\[1m\]/);
|
|
208
|
+
assert.match(b, /fable\[1m\]/);
|
|
209
|
+
assert.match(b, /ANTHROPIC_DEFAULT_OPUS_MODEL/);
|
|
210
|
+
assert.match(b, /CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT/);
|
|
211
|
+
assert.match(b, /CLAUDE_CODE_AUTO_COMPACT_WINDOW/);
|
|
212
|
+
assert.match(b, /OPENAI_MAX_CONTEXT_TOKENS/);
|
|
213
|
+
assert.match(b, /findstr \/b \/c:"LLM_SWITCHER_CODEX_"/, 'only the shim inputs are removed');
|
|
214
|
+
assert.doesNotMatch(b, /set "HTTPS_PROXY=/, 'R1: the shim writes no endpoint of its own');
|
|
215
|
+
assert.doesNotMatch(b, /set "ANTHROPIC_BASE_URL=/, 'R1: no value the tool reads');
|
|
178
216
|
}
|
|
179
217
|
});
|
|
180
218
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
219
|
+
// Each shim reads only its own tool file: that is what keeps one tool from capturing the
|
|
220
|
+
// other's environment (A1, F5).
|
|
183
221
|
test('only the Windows Codex shim loads the Codex-only environment file', () => {
|
|
184
222
|
for (const platform of ['win32']) {
|
|
185
223
|
assert.match(renderShim('codex', platform), /env-codex\.(cmd|sh)/);
|
|
@@ -192,9 +230,17 @@ test('Windows Claude shim does not receive Codex config overrides', () => {
|
|
|
192
230
|
assert.doesNotMatch(renderShim('claude', 'win32'), /model_catalog_json/);
|
|
193
231
|
});
|
|
194
232
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
233
|
+
// R2 on Windows: the bundle is built by a FILE, never `node -e "..."`. cmd.exe re-parses quotes
|
|
234
|
+
// and reads %2 out of a percent-encoded path, so a checkout under a directory with a space
|
|
235
|
+
// would corrupt the program or its arguments.
|
|
236
|
+
test('R2: the Windows shim builds the CA bundle through the helper file', () => {
|
|
237
|
+
const b = renderShim('claude', 'win32');
|
|
238
|
+
assert.match(b, /node "%STATE_HELPER%"/);
|
|
239
|
+
assert.match(b, /ensure-ca-bundle\.mjs/);
|
|
240
|
+
assert.doesNotMatch(b, /node -e/);
|
|
241
|
+
assert.match(b, /set "INHERITED_CA=%NODE_EXTRA_CA_CERTS%"/, 'captured before the tool env runs');
|
|
242
|
+
assert.match(b, /%SWITCHER_DIR%\\blindfold\\certs\\ca\.pem/);
|
|
243
|
+
assert.match(b, /if not "%TOOL_ACTIVE%"=="1" goto :CA_DONE/, 'nothing happens while claude is off');
|
|
198
244
|
});
|
|
199
245
|
|
|
200
246
|
// setx truncates at 1024 characters and writes the merged system+user PATH into the user key;
|
|
@@ -209,11 +255,13 @@ test('Windows PATH advice never uses setx or %PATH%, and names no POSIX rc file'
|
|
|
209
255
|
assert.deepEqual(suggestedRcFiles('win32'), []);
|
|
210
256
|
});
|
|
211
257
|
|
|
212
|
-
//
|
|
213
|
-
|
|
258
|
+
// Codex takes its CA from the tool env file (CODEX_CA_CERTIFICATE) and needs no bundle of its
|
|
259
|
+
// own; only the Claude path does this work.
|
|
260
|
+
test('R2: the Codex shim never builds a CA bundle', () => {
|
|
214
261
|
const body = renderShim('codex', 'win32');
|
|
215
|
-
assert.
|
|
216
|
-
assert.
|
|
262
|
+
assert.doesNotMatch(body, /CA_BUNDLE/);
|
|
263
|
+
assert.doesNotMatch(body, /ensure-ca-bundle/);
|
|
264
|
+
assert.doesNotMatch(body, /ca\.pem/);
|
|
217
265
|
});
|
|
218
266
|
|
|
219
267
|
// The shim runs whatever env.sh says. It must not source a file, or a directory, that another account owns.
|
|
@@ -242,3 +290,48 @@ test('pathOrderHint tells a zsh user to put the export last in .zshrc and .zprof
|
|
|
242
290
|
if (shell === undefined) delete process.env.SHELL; else process.env.SHELL = shell;
|
|
243
291
|
}
|
|
244
292
|
});
|
|
293
|
+
|
|
294
|
+
// A8. The three defects below were invisible to every text assertion above, and only showed up
|
|
295
|
+
// when the generated .cmd was run: cmd.exe could not find :SCRUB_TIER, and the 127.0.0.1 base
|
|
296
|
+
// URL survived the scrub so the tool bypassed the interceptor entirely. Render the real shim,
|
|
297
|
+
// point it at a fake `claude`, and read what the child actually saw.
|
|
298
|
+
test('A8: the Windows shim scrubs a loopback base URL and resolves every subroutine', (t) => {
|
|
299
|
+
if (process.platform !== 'win32') return t.skip('cmd.exe only');
|
|
300
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimwin-'));
|
|
301
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimfake-'));
|
|
302
|
+
t.after(() => {
|
|
303
|
+
fs.rmSync(shimDir, { recursive: true, force: true });
|
|
304
|
+
fs.rmSync(fakeDir, { recursive: true, force: true });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// The fake real binary reports exactly the variables the R8 scrub must have decided about.
|
|
308
|
+
fs.writeFileSync(path.join(fakeDir, 'claude.cmd'),
|
|
309
|
+
'@echo off\r\necho BASE=[%ANTHROPIC_BASE_URL%] HTTPS=[%HTTPS_PROXY%] CA=[%NODE_EXTRA_CA_CERTS%]\r\n');
|
|
310
|
+
|
|
311
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
312
|
+
fs.writeFileSync(path.join(STATE, 'env-claude.cmd'), 'SET "HTTPS_PROXY=http://127.0.0.1:3457"\r\n');
|
|
313
|
+
|
|
314
|
+
const shim = path.join(shimDir, 'shim.cmd');
|
|
315
|
+
fs.writeFileSync(shim, renderShim('claude', 'win32'), 'utf8');
|
|
316
|
+
|
|
317
|
+
const child = spawnSync('cmd.exe', ['/d', '/c', shim, '--version'], {
|
|
318
|
+
encoding: 'utf8',
|
|
319
|
+
env: {
|
|
320
|
+
...process.env,
|
|
321
|
+
// Replaced, not appended: the shim must find the fake binary and nothing named claude
|
|
322
|
+
// from this machine, and where/findstr must still resolve.
|
|
323
|
+
PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)]
|
|
324
|
+
.join(path.delimiter),
|
|
325
|
+
ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456',
|
|
326
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet[1m]',
|
|
327
|
+
NODE_EXTRA_CA_CERTS: ''
|
|
328
|
+
},
|
|
329
|
+
timeout: 30000
|
|
330
|
+
});
|
|
331
|
+
const all = `${child.stdout || ''}${child.stderr || ''}`;
|
|
332
|
+
assert.doesNotMatch(all, /cannot find the batch label/, 'a .cmd written with bare LF loses subroutine lookup');
|
|
333
|
+
assert.match(child.stdout || '', /BASE=\[\]/,
|
|
334
|
+
'R8: a loopback gateway base URL is scrubbed, so the tool reaches the interceptor');
|
|
335
|
+
assert.match(child.stdout || '', /HTTPS=\[http:\/\/127\.0\.0\.1:3457\]/,
|
|
336
|
+
'R2: the proxy comes from the env file of this tool');
|
|
337
|
+
});
|