llm-switcher 1.1.11 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/classifier.mjs +238 -0
  8. package/config.example.json +12 -34
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/ensure-ca-bundle.mjs +28 -0
  24. package/formats.mjs +43 -156
  25. package/icons/antigravity.png +0 -0
  26. package/icons/claude.png +0 -0
  27. package/icons/codex.png +0 -0
  28. package/icons/deepseek.png +0 -0
  29. package/icons/gemini.png +0 -0
  30. package/icons/github.png +0 -0
  31. package/icons/groq.png +0 -0
  32. package/icons/intact.svg +1 -0
  33. package/icons/ollama.png +0 -0
  34. package/icons/openai.png +0 -0
  35. package/icons/openrouter.png +0 -0
  36. package/icons/qwen.png +0 -0
  37. package/icons/vertex.png +0 -0
  38. package/mcp.mjs +39 -11
  39. package/package.json +1 -1
  40. package/proxy.mjs +114 -37
  41. package/shim.mjs +200 -57
  42. package/skills/llm-switcher/SKILL.md +15 -10
  43. package/state.mjs +1100 -191
  44. package/switch.cmd +2 -2
  45. package/switch.mjs +228 -53
  46. package/tests/blindfold-e2e.test.mjs +380 -0
  47. package/tests/blindfold-task5.test.mjs +429 -0
  48. package/tests/blindfold.task3.test.mjs +700 -0
  49. package/tests/blindfold.test.mjs +10 -5
  50. package/tests/catalog.test.mjs +147 -0
  51. package/tests/classifier.test.mjs +210 -0
  52. package/tests/contract-lab.test.mjs +22 -7
  53. package/tests/formats.test.mjs +63 -46
  54. package/tests/gateway.e2e.test.mjs +136 -36
  55. package/tests/lifecycle.test.mjs +16 -10
  56. package/tests/mcp.test.mjs +78 -2
  57. package/tests/real-user-sim.test.mjs +464 -0
  58. package/tests/shim.test.mjs +159 -66
  59. package/tests/state.test.mjs +975 -193
  60. package/tests/switch.test.mjs +446 -2
  61. package/ui.html +1710 -1726
@@ -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
- fs.writeFileSync(path.join(STATE, 'env.sh'), "export ANTHROPIC_BASE_URL='http://127.0.0.1:3456'\n");
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'].filter(Boolean).join(':');
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 injects gateway env when the gateway is ON (the --resume fix)', (t) => {
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 = makeFakeBin('claude');
73
- const out = runShim('claude', ['--resume', 'abc'], { active: true, fakeDir: fake });
74
- assert.match(out, /BASE=http:\/\/127\.0\.0\.1:3456/, 'env must be injected');
75
- assert.match(out, /ARGS=--resume abc/, 'arguments must be preserved');
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
- // Behaviour on POSIX: the shim, run against the launch files, hands Codex exactly these arguments.
113
- test('Codex shim passes the gateway, the catalog and quoted role names from the launch files', (t) => {
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', 'env.sh': env, 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:3457'\n", 'model-catalog.json': '{}' }
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
- '--config', 'openai_base_url=http://127.0.0.1:3456/v1',
130
- '--config', `model_catalog_json=${path.join(STATE, 'model-catalog.json')}`,
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: { 'active.flag': 'active', 'env.sh': "export LLM_SWITCHER_CODEX_MAIN_MODEL='gpt-x'\n", 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:3457'\n" }
175
+ files: { 'env-codex.sh': "export HTTPS_PROXY='http://127.0.0.1:9999'\n" }
148
176
  }).split('\n');
149
- assert.deepEqual(out, ['HTTPS_PROXY=NONE', '--resume']);
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 run here, so the Windows template is checked as text.
153
- test('Windows Codex shim names every documented config override', () => {
154
- for (const platform of ['win32']) {
155
- const body = renderShim('codex', platform);
156
- assert.match(body, /openai_base_url/);
157
- // The main model stays official (from the user's config.toml): forcing the
158
- // internal "main" alias here used to make first-party IDs bypass the proxy.
159
- assert.doesNotMatch(body, /--config model=main/);
160
- assert.match(body, /review_model/);
161
- assert.match(body, /agents\.default_subagent_model/);
162
- assert.match(body, /model_context_window/);
163
- assert.match(body, /model_auto_compact_token_limit/);
164
- assert.match(body, /model_catalog_json/);
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
- // The /model picker reads the local catalog file and the --config values, never
169
- // /v1/models. A literal `review_model=review` therefore printed the switcher's
170
- // own slot names straight into the Codex UI (found 2026-09-20).
171
- test('Windows Codex shim passes official role names through, never a hard-coded slot alias', () => {
172
- for (const platform of ['win32']) {
173
- const body = renderShim('codex', platform);
174
- assert.doesNotMatch(body, /review_model=["']?review["']?\s/, 'slot alias must not be hard-coded');
175
- assert.doesNotMatch(body, /default_subagent_model=["']?subagent["']?\s/, 'slot alias must not be hard-coded');
176
- assert.match(body, /review_model=.*LLM_SWITCHER_CODEX_REVIEW_MODEL/);
177
- assert.match(body, /default_subagent_model=.*LLM_SWITCHER_CODEX_SUBAGENT_MODEL/);
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
- // Blindfold's HTTPS_PROXY belongs to Codex alone. The claude shim sources the shared
182
- // env file, so the proxy variables live in a Codex-only file that only this shim reads.
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
- // README promises that the main role reaches Codex as the `model` override.
196
- test('Windows Codex shim passes the main model as --config model', () => {
197
- assert.match(renderShim('codex', 'win32'), /if defined LLM_SWITCHER_CODEX_MAIN_MODEL set "CODEX_SWITCHER_ARGS=%CODEX_SWITCHER_ARGS% --config model=/);
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
- // cmd.exe `if exist` is only sure to work with the native backslash path; the TOML value keeps '/'.
213
- test('the Windows shim tests the catalog with a native path', () => {
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.match(body, /if exist "%SWITCHER_DIR%\\model-catalog\.json"/);
216
- assert.match(body, /--config model_catalog_json=[^\r\n]*\/model-catalog\.json/);
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
+ });